Compare commits

..

4 Commits

Author SHA1 Message Date
cbfb6ae514 fix: add PR template — reviewer checklist (#1558)
Some checks failed
Check PR Changes / check-changes (pull_request) Successful in 16s
CI / test (pull_request) Failing after 1m16s
CI / validate (pull_request) Failing after 1m14s
Review Approval Gate / verify-review (pull_request) Successful in 11s
2026-04-15 03:46:45 +00:00
098fe746d7 fix: add docs/rubber-stamping-prevention.md — prevent rubber-stamping (#1558) 2026-04-15 03:45:16 +00:00
23b04b50eb fix: add bin/check_zombie_prs.py — prevent rubber-stamping (#1558) 2026-04-15 03:45:14 +00:00
205252f048 fix: add .gitea/workflows/check-pr-changes.yml — prevent rubber-stamping (#1558) 2026-04-15 03:45:12 +00:00
5 changed files with 240 additions and 114 deletions

View File

@@ -0,0 +1,23 @@
## Description
<!-- What does this PR do? -->
## Changes
- [ ]
## Testing
- [ ]
## Reviewer Checklist
**IMPORTANT: Do not rubber-stamp. Verify each item below.**
- [ ] **PR has actual changes** — check additions, deletions, and changed files are > 0
- [ ] **Changes match description** — the code changes match what the PR claims to do
- [ ] **Code quality** — no obvious bugs, follows conventions, readable
- [ ] **Tests are adequate** — new code has tests, existing tests pass
- [ ] **Documentation updated** — if applicable
**By approving, I confirm I have actually reviewed the code changes in this PR.**

View File

@@ -0,0 +1,40 @@
name: Check PR Changes
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
check-changes:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for actual changes
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
ADDITIONS=${{ github.event.pull_request.additions }}
DELETIONS=${{ github.event.pull_request.deletions }}
CHANGED_FILES=${{ github.event.pull_request.changed_files }}
echo "PR Stats: +${ADDITIONS} -${DELETIONS} files:${CHANGED_FILES}"
if [ "$ADDITIONS" -eq 0 ] && [ "$DELETIONS" -eq 0 ] && [ "$CHANGED_FILES" -eq 0 ]; then
echo "::error::ZOMBIE PR detected — zero changes between base and head."
echo "This PR has no additions, deletions, or changed files."
echo "Please add actual changes or close this PR."
exit 1
fi
# Check for empty commits
COMMITS=$(git rev-list --count "$BASE".."$HEAD" 2>/dev/null || echo "0")
if [ "$COMMITS" -eq 0 ]; then
echo "::warning::PR has no commits between base and head."
fi
echo "PR has valid changes (+${ADDITIONS} -${DELETIONS})."

121
bin/check_zombie_prs.py Normal file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
Zombie PR Detector — scans Gitea repos for PRs with no changes.
Usage:
python bin/check_zombie_prs.py
python bin/check_zombie_prs.py --repos the-nexus timmy-home
python bin/check_zombie_prs.py --report
"""
import argparse
import json
import os
import urllib.request
from typing import Optional
def get_token() -> str:
"""Read Gitea API token."""
for path in ["~/.config/gitea/token", "~/.config/forge.token"]:
expanded = os.path.expanduser(path)
if os.path.exists(expanded):
return open(expanded).read().strip()
raise RuntimeError("No Gitea token found")
def get_open_prs(token: str, repo: str, base_url: str) -> list:
"""Get all open PRs for a repo."""
url = f"{base_url}/repos/{repo}/pulls?state=open&limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
return json.loads(urllib.request.urlopen(req, timeout=30).read())
def check_pr_zombie(pr: dict) -> Optional[dict]:
"""Check if a PR is a zombie (no changes)."""
additions = pr.get("additions", 0)
deletions = pr.get("deletions", 0)
changed_files = pr.get("changed_files", 0)
if additions == 0 and deletions == 0 and changed_files == 0:
return {
"number": pr["number"],
"title": pr["title"],
"author": pr.get("user", {}).get("login", "unknown"),
"url": pr.get("html_url", ""),
"created": pr.get("created_at", ""),
"additions": additions,
"deletions": deletions,
"changed_files": changed_files,
}
return None
def scan_repos(token: str, repos: list, base_url: str) -> list:
"""Scan repos for zombie PRs."""
zombies = []
for repo in repos:
try:
prs = get_open_prs(token, repo, base_url)
for pr in prs:
zombie = check_pr_zombie(pr)
if zombie:
zombie["repo"] = repo
zombies.append(zombie)
except Exception as e:
print(f" Error scanning {repo}: {e}")
return zombies
def list_org_repos(token: str, org: str, base_url: str) -> list:
"""List all repos in an org."""
url = f"{base_url}/orgs/{org}/repos?limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
repos = json.loads(urllib.request.urlopen(req, timeout=30).read())
return [r["full_name"] for r in repos]
def main():
parser = argparse.ArgumentParser(description="Detect zombie PRs with no changes")
parser.add_argument("--repos", nargs="+", help="Specific repos to scan")
parser.add_argument("--org", default="Timmy_Foundation", help="Organization name")
parser.add_argument("--base-url", default="https://forge.alexanderwhitestone.com/api/v1")
parser.add_argument("--report", action="store_true", help="Generate detailed report")
args = parser.parse_args()
token = get_token()
if args.repos:
repos = [f"{args.org}/{r}" if "/" not in r else r for r in args.repos]
else:
repos = list_org_repos(token, args.org, args.base_url)
print(f"Scanning {len(repos)} repos...")
zombies = scan_repos(token, repos, args.base_url)
if zombies:
print(f"\nFOUND {len(zombies)} ZOMBIE PR(s):\n")
for z in zombies:
print(f" [{z['repo']}] #{z['number']}: {z['title']}")
print(f" Author: {z['author']} Created: {z['created']}")
print(f" Stats: +{z['additions']} -{z['deletions']} files:{z['changed_files']}")
print(f" URL: {z['url']}")
print()
else:
print("\nNo zombie PRs found. All clear.")
if args.report:
report = {
"scanned_repos": len(repos),
"zombie_prs": len(zombies),
"zombies": zombies,
}
report_path = os.path.expanduser("~/.hermes/reports/zombie_prs.json")
os.makedirs(os.path.dirname(report_path), exist_ok=True)
with open(report_path, "w") as f:
json.dump(report, f, indent=2)
print(f"Report saved to {report_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,52 @@
# Rubber-Stamping Prevention
## What is Rubber-Stamping?
Rubber-stamping is approving a PR without actually reviewing the code. This was observed in PR #359 which received 3 APPROVED reviews despite having zero changes.
## Why It's Bad
1. Wastes reviewer time
2. Creates false sense of review quality
3. Allows zombie PRs to appear reviewed
## Prevention Measures
### 1. CI Check (`.gitea/workflows/check-pr-changes.yml`)
Automated check that runs on every PR:
- Detects PRs with no changes (0 additions, 0 deletions, 0 files changed)
- Blocks merge if PR is a zombie
- Provides clear error messages
### 2. PR Template
Enhanced reviewer checklist:
- Verify PR has actual changes
- Changes match description
- Code quality review
- Tests are adequate
- Documentation is updated
### 3. Zombie PR Detection
```bash
# Scan all repos
python bin/check_zombie_prs.py
# Scan specific repos
python bin/check_zombie_prs.py --repos the-nexus timmy-home
# Generate report
python bin/check_zombie_prs.py --report
```
## Testing
```bash
# Create a test PR with no changes
git checkout -b test/zombie-pr
git commit --allow-empty -m "test: empty commit"
git push origin test/zombie-pr
# Create PR — CI should fail
```

118
server.py
View File

@@ -3,34 +3,20 @@
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
the body (Evennia/Morrowind), and the visualization surface.
Security features:
- Binds to 127.0.0.1 by default (localhost only)
- Optional external binding via NEXUS_WS_HOST environment variable
- Token-based authentication via NEXUS_WS_TOKEN environment variable
- Rate limiting on connections
- Connection logging and monitoring
"""
import asyncio
import json
import logging
import os
import signal
import sys
import time
from typing import Set, Dict, Optional
from collections import defaultdict
from typing import Set
# Branch protected file - see POLICY.md
import websockets
# Configuration
PORT = int(os.environ.get("NEXUS_WS_PORT", "8765"))
HOST = os.environ.get("NEXUS_WS_HOST", "127.0.0.1") # Default to localhost only
AUTH_TOKEN = os.environ.get("NEXUS_WS_TOKEN", "") # Empty = no auth required
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX_CONNECTIONS = 10 # max connections per IP per window
RATE_LIMIT_MAX_MESSAGES = 100 # max messages per connection per window
PORT = 8765
HOST = "0.0.0.0" # Allow external connections if needed
# Logging setup
logging.basicConfig(
@@ -42,97 +28,15 @@ logger = logging.getLogger("nexus-gateway")
# State
clients: Set[websockets.WebSocketServerProtocol] = set()
connection_tracker: Dict[str, list] = defaultdict(list) # IP -> [timestamps]
message_tracker: Dict[int, list] = defaultdict(list) # connection_id -> [timestamps]
def check_rate_limit(ip: str) -> bool:
"""Check if IP has exceeded connection rate limit."""
now = time.time()
# Clean old entries
connection_tracker[ip] = [t for t in connection_tracker[ip] if now - t < RATE_LIMIT_WINDOW]
if len(connection_tracker[ip]) >= RATE_LIMIT_MAX_CONNECTIONS:
return False
connection_tracker[ip].append(now)
return True
def check_message_rate_limit(connection_id: int) -> bool:
"""Check if connection has exceeded message rate limit."""
now = time.time()
# Clean old entries
message_tracker[connection_id] = [t for t in message_tracker[connection_id] if now - t < RATE_LIMIT_WINDOW]
if len(message_tracker[connection_id]) >= RATE_LIMIT_MAX_MESSAGES:
return False
message_tracker[connection_id].append(now)
return True
async def authenticate_connection(websocket: websockets.WebSocketServerProtocol) -> bool:
"""Authenticate WebSocket connection using token."""
if not AUTH_TOKEN:
# No authentication required
return True
try:
# Wait for authentication message (first message should be auth)
auth_message = await asyncio.wait_for(websocket.recv(), timeout=5.0)
auth_data = json.loads(auth_message)
if auth_data.get("type") != "auth":
logger.warning(f"Invalid auth message type from {websocket.remote_address}")
return False
token = auth_data.get("token", "")
if token != AUTH_TOKEN:
logger.warning(f"Invalid auth token from {websocket.remote_address}")
return False
logger.info(f"Authenticated connection from {websocket.remote_address}")
return True
except asyncio.TimeoutError:
logger.warning(f"Authentication timeout from {websocket.remote_address}")
return False
except json.JSONDecodeError:
logger.warning(f"Invalid auth JSON from {websocket.remote_address}")
return False
except Exception as e:
logger.error(f"Authentication error from {websocket.remote_address}: {e}")
return False
async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
"""Handles individual client connections and message broadcasting."""
addr = websocket.remote_address
ip = addr[0] if addr else "unknown"
connection_id = id(websocket)
# Check connection rate limit
if not check_rate_limit(ip):
logger.warning(f"Connection rate limit exceeded for {ip}")
await websocket.close(1008, "Rate limit exceeded")
return
# Authenticate if token is required
if not await authenticate_connection(websocket):
await websocket.close(1008, "Authentication failed")
return
clients.add(websocket)
addr = websocket.remote_address
logger.info(f"Client connected from {addr}. Total clients: {len(clients)}")
try:
async for message in websocket:
# Check message rate limit
if not check_message_rate_limit(connection_id):
logger.warning(f"Message rate limit exceeded for {addr}")
await websocket.send(json.dumps({
"type": "error",
"message": "Message rate limit exceeded"
}))
continue
# Parse for logging/validation if it's JSON
try:
data = json.loads(message)
@@ -177,20 +81,6 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
async def main():
"""Main server loop with graceful shutdown."""
# Log security configuration
if AUTH_TOKEN:
logger.info("Authentication: ENABLED (token required)")
else:
logger.warning("Authentication: DISABLED (no token required)")
if HOST == "0.0.0.0":
logger.warning("Host binding: 0.0.0.0 (all interfaces) - SECURITY RISK")
else:
logger.info(f"Host binding: {HOST} (localhost only)")
logger.info(f"Rate limiting: {RATE_LIMIT_MAX_CONNECTIONS} connections/IP/{RATE_LIMIT_WINDOW}s, "
f"{RATE_LIMIT_MAX_MESSAGES} messages/connection/{RATE_LIMIT_WINDOW}s")
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
# Set up signal handlers for graceful shutdown