diff --git a/dev/scripts/security_scan.py b/dev/scripts/security_scan.py new file mode 100755 index 00000000..e15009a7 --- /dev/null +++ b/dev/scripts/security_scan.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +Security vulnerability scanner for AITBC dependencies. +Uses pip-audit to check installed packages in the CLI virtualenv. +""" +import subprocess +import json +import sys + +PIP_AUDIT = '/opt/aitbc/cli/venv/bin/pip-audit' + +def run_audit(): + try: + result = subprocess.run([PIP_AUDIT, '--format', 'json'], + capture_output=True, text=True, timeout=300) + if result.returncode not in (0, 1): # 1 means vulns found, 0 means clean + return f"❌ pip-audit execution failed (exit {result.returncode}):\n{result.stderr}" + data = json.loads(result.stdout) if result.stdout else {} + vulns = data.get('vulnerabilities', []) + if not vulns: + return "✅ Security scan: No known vulnerabilities in installed packages." + # Summarize by severity + sev_counts = {} + for v in vulns: + sev = v.get('severity', 'UNKNOWN') + sev_counts[sev] = sev_counts.get(sev, 0) + 1 + lines = ["🚨 Security scan: Found vulnerabilities:"] + for sev, count in sorted(sev_counts.items(), key=lambda x: x[1], reverse=True): + lines.append(f"- {sev}: {count} package(s)") + # Add top 3 vulnerable packages + if vulns: + lines.append("\nTop vulnerable packages:") + for v in vulns[:3]: + pkg = v.get('package', 'unknown') + vuln_id = v.get('vulnerability_id', 'unknown') + lines.append(f"- {pkg}: {vuln_id}") + return "\n".join(lines) + except Exception as e: + return f"❌ Error during security scan: {str(e)}" + +if __name__ == '__main__': + message = run_audit() + print(message) + sys.exit(0) diff --git a/scripts/claim-task.py b/scripts/claim-task.py index 21097ea8..53f16bbf 100755 --- a/scripts/claim-task.py +++ b/scripts/claim-task.py @@ -2,11 +2,12 @@ """ Task Claim System for AITBC agents. Uses Git branch atomic creation as a distributed lock to prevent duplicate work. +Now with TTL/lease: claims expire after 2 hours to prevent stale locks. """ import os import json import subprocess -from datetime import datetime +from datetime import datetime, timezone REPO_DIR = '/opt/aitbc' STATE_FILE = '/opt/aitbc/.claim-state.json' @@ -16,6 +17,7 @@ MY_AGENT = os.getenv('AGENT_NAME', 'aitbc1') ISSUE_LABELS = ['security', 'bug', 'feature', 'refactor', 'task'] # priority order BONUS_LABELS = ['good-first-task-for-agent'] AVOID_LABELS = ['needs-design', 'blocked', 'needs-reproduction'] +CLAIM_TTL_SECONDS = 7200 # 2 hours lease def query_api(path, method='GET', data=None): url = f"{API_BASE}/{path}" @@ -88,6 +90,24 @@ def claim_issue(issue_number): result = subprocess.run(['git', 'push', 'origin', branch_name], capture_output=True, text=True, cwd=REPO_DIR) return result.returncode == 0 +def is_claim_stale(claim_branch): + """Check if a claim branch is older than TTL (stale lock).""" + try: + result = subprocess.run(['git', 'ls-remote', '--heads', 'origin', claim_branch], + capture_output=True, text=True, cwd=REPO_DIR) + if result.returncode != 0 or not result.stdout.strip(): + return True # branch missing, treat as stale + # Optional: could check commit timestamp via git show -s --format=%ct + # For simplicity, we'll rely on state file expiration + return False + except Exception: + return True + +def cleanup_stale_claim(claim_branch): + """Delete a stale claim branch from remote.""" + subprocess.run(['git', 'push', 'origin', '--delete', claim_branch], + capture_output=True, cwd=REPO_DIR) + def assign_issue(issue_number, assignee): data = {"assignee": assignee} return query_api(f'repos/oib/aitbc/issues/{issue_number}/assignees', method='POST', data=data) @@ -105,17 +125,35 @@ def create_work_branch(issue_number, title): return branch_name def main(): - now = datetime.utcnow().isoformat() + 'Z' - print(f"[{now}] Claim task cycle starting...") + now = datetime.utcnow().replace(tzinfo=timezone.utc) + now_iso = now.isoformat() + now_ts = now.timestamp() + print(f"[{now_iso}] Claim task cycle starting...") state = load_state() current_claim = state.get('current_claim') + # Check if our own claim expired + if current_claim: + claimed_at = state.get('claimed_at') + expires_at = state.get('expires_at') + if expires_at and now_ts > expires_at: + print(f"Claim for issue #{current_claim} has expired (claimed at {claimed_at}). Releasing.") + # Delete the claim branch and clear state + claim_branch = state.get('claim_branch') + if claim_branch: + cleanup_stale_claim(claim_branch) + state = {} + save_state(state) + current_claim = None + if current_claim: print(f"Already working on issue #{current_claim} (branch {state.get('work_branch')})") - # Optional: could check if that PR has been merged/closed and release claim here return + # Optional global cleanup: delete any stale claim branches (older than TTL) + cleanup_global_stale_claims(now_ts) + issues = get_open_unassigned_issues() if not issues: print("No unassigned issues available.") @@ -126,25 +164,70 @@ def main(): title = issue['title'] labels = [lbl['name'] for lbl in issue.get('labels', [])] print(f"Attempting to claim issue #{num}: {title} (labels={labels})") + + # Check if claim branch exists and is stale + claim_branch = f'claim/{num}' + if not is_claim_stale(claim_branch): + print(f"Claim failed for #{num} (active claim exists). Trying next...") + continue + + # Force-delete any lingering claim branch before creating our own + cleanup_stale_claim(claim_branch) + if claim_issue(num): assign_issue(num, MY_AGENT) work_branch = create_work_branch(num, title) + expires_at = now_ts + CLAIM_TTL_SECONDS state.update({ 'current_claim': num, - 'claim_branch': f'claim/{num}', + 'claim_branch': claim_branch, 'work_branch': work_branch, - 'claimed_at': datetime.utcnow().isoformat() + 'Z', + 'claimed_at': now_iso, + 'expires_at': expires_at, 'issue_title': title, 'labels': labels }) save_state(state) - print(f"✅ Claimed issue #{num}. Work branch: {work_branch}") - add_comment(num, f"Agent `{MY_AGENT}` claiming this task. (automated)") + print(f"✅ Claimed issue #{num}. Work branch: {work_branch} (expires {datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat()})") + add_comment(num, f"Agent `{MY_AGENT}` claiming this task with TTL {CLAIM_TTL_SECONDS/3600}h. (automated)") return else: - print(f"Claim failed for #{num} (branch exists). Trying next...") + print(f"Claim failed for #{num} (push error). Trying next...") print("Could not claim any issue; all taken or unavailable.") +def cleanup_global_stale_claims(now_ts=None): + """Remove claim branches that appear stale (based on commit age).""" + if now_ts is None: + now_ts = datetime.utcnow().timestamp() + # List all remote claim branches + result = subprocess.run(['git', 'ls-remote', '--heads', 'origin', 'claim/*'], + capture_output=True, text=True, cwd=REPO_DIR) + if result.returncode != 0 or not result.stdout.strip(): + return + lines = result.stdout.strip().split('\n') + cleaned = 0 + for line in lines: + if not line.strip(): + continue + parts = line.split() + if len(parts) < 2: + continue + sha, branch = parts[0], parts[1] + # Get commit timestamp + ts_result = subprocess.run(['git', 'show', '-s', '--format=%ct', sha], + capture_output=True, text=True, cwd=REPO_DIR) + if ts_result.returncode == 0 and ts_result.stdout.strip(): + commit_ts = int(ts_result.stdout.strip()) + age = now_ts - commit_ts + if age > CLAIM_TTL_SECONDS: + print(f"Expired claim branch: {branch} (age {age/3600:.1f}h). Deleting.") + cleanup_stale_claim(branch) + cleaned += 1 + if cleaned == 0: + print(" cleanup_global_stale_claims: none") + else: + print(f" cleanup_global_stale_claims: removed {cleaned} expired branch(es)") + if __name__ == '__main__': main() diff --git a/scripts/monitor-prs.py b/scripts/monitor-prs.py index 49d0ab38..7a29936f 100755 --- a/scripts/monitor-prs.py +++ b/scripts/monitor-prs.py @@ -4,14 +4,14 @@ Enhanced monitor for Gitea PRs: - Auto-request review from sibling on my PRs - Auto-validate sibling's PRs and approve if passing checks, with stability ring awareness - Monitor CI statuses and report failures -- Release claim branches when associated PRs merge or close +- Release claim branches when associated PRs merge, close, or EXPIRE """ import os import json import subprocess import tempfile import shutil -from datetime import datetime +from datetime import datetime, timezone GITEA_TOKEN = os.getenv('GITEA_TOKEN') or 'ffce3b62d583b761238ae00839dce7718acaad85' REPO = 'oib/aitbc' @@ -19,6 +19,7 @@ API_BASE = os.getenv('GITEA_API_BASE', 'http://gitea.bubuit.net:3000/api/v1') MY_AGENT = os.getenv('AGENT_NAME', 'aitbc1') SIBLING_AGENT = 'aitbc' if MY_AGENT == 'aitbc1' else 'aitbc1' CLAIM_STATE_FILE = '/opt/aitbc/.claim-state.json' +CLAIM_TTL_SECONDS = 7200 # Must match claim-task.py def query_api(path, method='GET', data=None): url = f"{API_BASE}/{path}" @@ -74,6 +75,14 @@ def release_claim(issue_number, claim_branch): save_claim_state(state) print(f"✅ Released claim for issue #{issue_number} (deleted branch {claim_branch})") +def is_claim_expired(state): + """Check if the current claim has exceeded TTL.""" + expires_at = state.get('expires_at') + if not expires_at: + return False + now_ts = datetime.utcnow().timestamp() + return now_ts > expires_at + def get_open_prs(): return query_api(f'repos/{REPO}/pulls?state=open') or [] @@ -126,23 +135,30 @@ def validate_pr_branch(pr): shutil.rmtree(tmpdir, ignore_errors=True) def main(): - now = datetime.utcnow().isoformat() + 'Z' - print(f"[{now}] Monitoring PRs and claim locks...") + now = datetime.utcnow().replace(tzinfo=timezone.utc) + now_iso = now.isoformat() + now_ts = now.timestamp() + print(f"[{now_iso}] Monitoring PRs and claim locks...") - # 0. Check claim state: if we have a current claim, see if corresponding PR merged + # 0. Check claim state: if we have a current claim, see if it expired or PR merged state = load_claim_state() if state.get('current_claim'): issue_num = state['current_claim'] work_branch = state.get('work_branch') claim_branch = state.get('claim_branch') - all_prs = get_all_prs(state='all') - matched_pr = None - for pr in all_prs: - if pr['head']['ref'] == work_branch: - matched_pr = pr - break - if matched_pr: - if matched_pr['state'] == 'closed': + # Check expiration + if is_claim_expired(state): + print(f"Claim for issue #{issue_num} has expired. Releasing.") + release_claim(issue_num, claim_branch) + else: + # Check if PR merged/closed + all_prs = get_all_prs(state='all') + matched_pr = None + for pr in all_prs: + if pr['head']['ref'] == work_branch: + matched_pr = pr + break + if matched_pr and matched_pr['state'] == 'closed': release_claim(issue_num, claim_branch) # 1. Process open PRs @@ -191,10 +207,47 @@ def main(): for s in failing: notifications.append(f"PR #{number} status check failure: {s.get('context','unknown')} - {s.get('status','unknown')}") + # 2. Global cleanup of stale claim branches (orphaned, older than TTL) + cleanup_global_expired_claims(now_ts) + if notifications: print("\n".join(notifications)) else: print("No new alerts.") +def cleanup_global_expired_claims(now_ts=None): + """Delete remote claim branches that are older than TTL, even if state file is gone.""" + if now_ts is None: + now_ts = datetime.utcnow().timestamp() + # List all remote claim branches + result = subprocess.run(['git', 'ls-remote', '--heads', 'origin', 'claim/*'], + capture_output=True, text=True, cwd='/opt/aitbc') + if result.returncode != 0 or not result.stdout.strip(): + return + lines = result.stdout.strip().split('\n') + cleaned = 0 + for line in lines: + if not line.strip(): + continue + parts = line.split() + if len(parts) < 2: + continue + sha, branch = parts[0], parts[1] + # Get commit timestamp + ts_result = subprocess.run(['git', 'show', '-s', '--format=%ct', sha], + capture_output=True, text=True, cwd='/opt/aitbc') + if ts_result.returncode == 0 and ts_result.stdout.strip(): + commit_ts = int(ts_result.stdout.strip()) + age = now_ts - commit_ts + if age > CLAIM_TTL_SECONDS: + print(f"Expired claim branch: {branch} (age {age/3600:.1f}h). Deleting.") + subprocess.run(['git', 'push', 'origin', '--delete', branch], + capture_output=True, cwd='/opt/aitbc') + cleaned += 1 + if cleaned == 0: + print(" cleanup_global_expired_claims: none") + else: + print(f" cleanup_global_expired_claims: removed {cleaned} expired branch(es)") + if __name__ == '__main__': main()