security: add TTL lease for claim branches, vulnerability scanning cron, and improvements\n\n- Implement claim TTL (2h) to prevent stale locks\n- Add global cleanup of expired claim branches\n- Add daily security_scan.py using pip-audit; schedule via OpenClaw cron\n- Monitor-prs now checks claim expiration and cleans up globally\n- Improves resilience of multi-agent coordination
This commit is contained in:
44
dev/scripts/security_scan.py
Executable file
44
dev/scripts/security_scan.py
Executable file
@@ -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)
|
||||
@@ -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 <sha>
|
||||
# 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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user