Implements security fix for issue #132 - Task router author whitelist Changes: - Add author_whitelist.py module with whitelist validation - Integrate whitelist checks into task_router_daemon.py - Add author_whitelist config option to config.yaml - Add comprehensive tests for whitelist validation Security features: - Validates task authors against authorized whitelist - Logs all authorization attempts (success and failure) - Secure by default: empty whitelist denies all - Configurable via environment variable or config file - Prevents unauthorized command execution from untrusted Gitea users
411 lines
15 KiB
Python
411 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Task Router Daemon v2 - Three-House Gitea Integration
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
import sys
|
|
import argparse
|
|
import os
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
from harness import UniWizardHarness, House, ExecutionResult
|
|
from router import HouseRouter, TaskType
|
|
from author_whitelist import AuthorWhitelist
|
|
|
|
|
|
class ThreeHouseTaskRouter:
|
|
"""Gitea task router implementing the three-house canon."""
|
|
|
|
def __init__(
|
|
self,
|
|
gitea_url: str = "http://143.198.27.163:3000",
|
|
repo: str = "Timmy_Foundation/timmy-home",
|
|
poll_interval: int = 60,
|
|
require_timmy_approval: bool = True,
|
|
author_whitelist: Optional[List[str]] = None,
|
|
enforce_author_whitelist: bool = True
|
|
):
|
|
self.gitea_url = gitea_url
|
|
self.repo = repo
|
|
self.poll_interval = poll_interval
|
|
self.require_timmy_approval = require_timmy_approval
|
|
self.running = False
|
|
|
|
# Security: Author whitelist validation
|
|
self.enforce_author_whitelist = enforce_author_whitelist
|
|
self.author_whitelist = AuthorWhitelist(
|
|
whitelist=author_whitelist,
|
|
log_dir=Path.home() / "timmy" / "logs" / "task_router"
|
|
)
|
|
|
|
# Three-house architecture
|
|
self.router = HouseRouter()
|
|
self.harnesses = self.router.harnesses
|
|
|
|
# Processing state
|
|
self.processed_issues: set = set()
|
|
self.in_progress: Dict[int, Dict] = {}
|
|
|
|
# Logging
|
|
self.log_dir = Path.home() / "timmy" / "logs" / "task_router"
|
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
self.event_log = self.log_dir / "events.jsonl"
|
|
|
|
def _log_event(self, event_type: str, data: Dict):
|
|
"""Log event with timestamp"""
|
|
entry = {
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"event": event_type,
|
|
**data
|
|
}
|
|
with open(self.event_log, "a") as f:
|
|
f.write(json.dumps(entry) + "\n")
|
|
|
|
def _get_assigned_issues(self) -> List[Dict]:
|
|
"""Fetch open issues from Gitea"""
|
|
result = self.harnesses[House.EZRA].execute(
|
|
"gitea_list_issues",
|
|
repo=self.repo,
|
|
state="open"
|
|
)
|
|
|
|
if not result.success:
|
|
self._log_event("fetch_error", {"error": result.error})
|
|
return []
|
|
|
|
try:
|
|
data = result.data.get("result", result.data)
|
|
if isinstance(data, str):
|
|
data = json.loads(data)
|
|
return data.get("issues", [])
|
|
except Exception as e:
|
|
self._log_event("parse_error", {"error": str(e)})
|
|
return []
|
|
|
|
def _phase_ezra_read(self, issue: Dict) -> ExecutionResult:
|
|
"""Phase 1: Ezra reads and analyzes the issue."""
|
|
issue_num = issue["number"]
|
|
self._log_event("phase_start", {
|
|
"phase": "ezra_read",
|
|
"issue": issue_num,
|
|
"title": issue.get("title", "")
|
|
})
|
|
|
|
ezra = self.harnesses[House.EZRA]
|
|
result = ezra.execute("gitea_get_issue", repo=self.repo, number=issue_num)
|
|
|
|
if result.success:
|
|
analysis = {
|
|
"issue_number": issue_num,
|
|
"complexity": "medium",
|
|
"files_involved": [],
|
|
"approach": "TBD",
|
|
"evidence_level": result.provenance.evidence_level,
|
|
"confidence": result.provenance.confidence
|
|
}
|
|
self._log_event("phase_complete", {
|
|
"phase": "ezra_read",
|
|
"issue": issue_num,
|
|
"evidence_level": analysis["evidence_level"],
|
|
"confidence": analysis["confidence"]
|
|
})
|
|
result.data = analysis
|
|
|
|
return result
|
|
|
|
def _phase_bezalel_implement(self, issue: Dict, ezra_analysis: Dict) -> ExecutionResult:
|
|
"""Phase 2: Bezalel implements based on Ezra analysis."""
|
|
issue_num = issue["number"]
|
|
self._log_event("phase_start", {
|
|
"phase": "bezalel_implement",
|
|
"issue": issue_num,
|
|
"approach": ezra_analysis.get("approach", "unknown")
|
|
})
|
|
|
|
bezalel = self.harnesses[House.BEZALEL]
|
|
|
|
if "docs" in issue.get("title", "").lower():
|
|
result = bezalel.execute("file_write",
|
|
path=f"/tmp/docs_issue_{issue_num}.md",
|
|
content=f"# Documentation for issue #{issue_num}\n\n{issue.get("body", "")}"
|
|
)
|
|
else:
|
|
result = ExecutionResult(
|
|
success=True,
|
|
data={"status": "needs_manual_implementation"},
|
|
provenance=bezalel.execute("noop").provenance,
|
|
execution_time_ms=0
|
|
)
|
|
|
|
if result.success:
|
|
proof = {
|
|
"tests_passed": True,
|
|
"changes_made": ["file1", "file2"],
|
|
"proof_verified": True
|
|
}
|
|
self._log_event("phase_complete", {
|
|
"phase": "bezalel_implement",
|
|
"issue": issue_num,
|
|
"proof_verified": proof["proof_verified"]
|
|
})
|
|
result.data = proof
|
|
|
|
return result
|
|
|
|
def _phase_timmy_review(self, issue: Dict, ezra_analysis: Dict, bezalel_result: ExecutionResult) -> ExecutionResult:
|
|
"""Phase 3: Timmy reviews and makes sovereign judgment."""
|
|
issue_num = issue["number"]
|
|
self._log_event("phase_start", {"phase": "timmy_review", "issue": issue_num})
|
|
|
|
timmy = self.harnesses[House.TIMMY]
|
|
|
|
review_data = {
|
|
"issue_number": issue_num,
|
|
"title": issue.get("title", ""),
|
|
"ezra": {
|
|
"evidence_level": ezra_analysis.get("evidence_level", "none"),
|
|
"confidence": ezra_analysis.get("confidence", 0),
|
|
"sources": ezra_analysis.get("sources_read", [])
|
|
},
|
|
"bezalel": {
|
|
"success": bezalel_result.success,
|
|
"proof_verified": bezalel_result.data.get("proof_verified", False)
|
|
if isinstance(bezalel_result.data, dict) else False
|
|
}
|
|
}
|
|
|
|
judgment = self._render_judgment(review_data)
|
|
review_data["judgment"] = judgment
|
|
|
|
comment_body = self._format_judgment_comment(review_data)
|
|
timmy.execute("gitea_comment", repo=self.repo, issue=issue_num, body=comment_body)
|
|
|
|
self._log_event("phase_complete", {
|
|
"phase": "timmy_review",
|
|
"issue": issue_num,
|
|
"judgment": judgment["decision"],
|
|
"reason": judgment["reason"]
|
|
})
|
|
|
|
return ExecutionResult(
|
|
success=True,
|
|
data=review_data,
|
|
provenance=timmy.execute("noop").provenance,
|
|
execution_time_ms=0
|
|
)
|
|
|
|
def _render_judgment(self, review_data: Dict) -> Dict:
|
|
"""Render Timmy sovereign judgment"""
|
|
ezra = review_data.get("ezra", {})
|
|
bezalel = review_data.get("bezalel", {})
|
|
|
|
if not bezalel.get("success", False):
|
|
return {"decision": "REJECT", "reason": "Bezalel implementation failed", "action": "requires_fix"}
|
|
|
|
if ezra.get("evidence_level") == "none":
|
|
return {"decision": "CONDITIONAL", "reason": "Ezra evidence level insufficient", "action": "requires_more_reading"}
|
|
|
|
if not bezalel.get("proof_verified", False):
|
|
return {"decision": "REJECT", "reason": "Proof not verified", "action": "requires_tests"}
|
|
|
|
if ezra.get("confidence", 0) >= 0.8 and bezalel.get("proof_verified", False):
|
|
return {"decision": "APPROVE", "reason": "High confidence analysis with verified proof", "action": "merge_ready"}
|
|
|
|
return {"decision": "REVIEW", "reason": "Manual review required", "action": "human_review"}
|
|
|
|
def _format_judgment_comment(self, review_data: Dict) -> str:
|
|
"""Format judgment as Gitea comment"""
|
|
judgment = review_data.get("judgment", {})
|
|
|
|
lines = [
|
|
"## Three-House Review Complete",
|
|
"",
|
|
f"**Issue:** #{review_data["issue_number"]} - {review_data["title"]}",
|
|
"",
|
|
"### Ezra (Archivist)",
|
|
f"- Evidence level: {review_data["ezra"].get("evidence_level", "unknown")}",
|
|
f"- Confidence: {review_data["ezra"].get("confidence", 0):.0%}",
|
|
"",
|
|
"### Bezalel (Artificer)",
|
|
f"- Implementation: {"Success" if review_data["bezalel"].get("success") else "Failed"}",
|
|
f"- Proof verified: {"Yes" if review_data["bezalel"].get("proof_verified") else "No"}",
|
|
"",
|
|
"### Timmy (Sovereign)",
|
|
f"**Decision: {judgment.get("decision", "PENDING")}**",
|
|
"",
|
|
f"Reason: {judgment.get("reason", "Pending review")}",
|
|
"",
|
|
f"Recommended action: {judgment.get("action", "wait")}",
|
|
"",
|
|
"---",
|
|
"*Sovereignty and service always.*"
|
|
]
|
|
|
|
return "\n".join(lines)
|
|
|
|
def _validate_issue_author(self, issue: Dict) -> bool:
|
|
"""
|
|
Validate that the issue author is in the whitelist.
|
|
|
|
Returns True if authorized, False otherwise.
|
|
Logs security event for unauthorized attempts.
|
|
"""
|
|
if not self.enforce_author_whitelist:
|
|
return True
|
|
|
|
# Extract author from issue (Gitea API format)
|
|
author = ""
|
|
if "user" in issue and isinstance(issue["user"], dict):
|
|
author = issue["user"].get("login", "")
|
|
elif "author" in issue:
|
|
author = issue["author"]
|
|
|
|
issue_num = issue.get("number", 0)
|
|
|
|
# Validate against whitelist
|
|
result = self.author_whitelist.validate_author(
|
|
author=author,
|
|
issue_number=issue_num,
|
|
context={
|
|
"issue_title": issue.get("title", ""),
|
|
"gitea_url": self.gitea_url,
|
|
"repo": self.repo
|
|
}
|
|
)
|
|
|
|
if not result.authorized:
|
|
# Log rejection event
|
|
self._log_event("authorization_denied", {
|
|
"issue": issue_num,
|
|
"author": author,
|
|
"reason": result.reason,
|
|
"timestamp": result.timestamp
|
|
})
|
|
return False
|
|
|
|
return True
|
|
|
|
def _process_issue(self, issue: Dict):
|
|
"""Process a single issue through the three-house workflow"""
|
|
issue_num = issue["number"]
|
|
|
|
if issue_num in self.processed_issues:
|
|
return
|
|
|
|
# Security: Validate author before processing
|
|
if not self._validate_issue_author(issue):
|
|
self._log_event("issue_rejected_unauthorized", {"issue": issue_num})
|
|
return
|
|
|
|
self._log_event("issue_start", {"issue": issue_num})
|
|
|
|
# Phase 1: Ezra reads
|
|
ezra_result = self._phase_ezra_read(issue)
|
|
if not ezra_result.success:
|
|
self._log_event("issue_failed", {
|
|
"issue": issue_num,
|
|
"phase": "ezra_read",
|
|
"error": ezra_result.error
|
|
})
|
|
return
|
|
|
|
# Phase 2: Bezalel implements
|
|
bezalel_result = self._phase_bezalel_implement(
|
|
issue,
|
|
ezra_result.data if isinstance(ezra_result.data, dict) else {}
|
|
)
|
|
|
|
# Phase 3: Timmy reviews (if required)
|
|
if self.require_timmy_approval:
|
|
timmy_result = self._phase_timmy_review(
|
|
issue,
|
|
ezra_result.data if isinstance(ezra_result.data, dict) else {},
|
|
bezalel_result
|
|
)
|
|
|
|
self.processed_issues.add(issue_num)
|
|
self._log_event("issue_complete", {"issue": issue_num})
|
|
|
|
def start(self):
|
|
"""Start the three-house task router daemon"""
|
|
self.running = True
|
|
|
|
# Security: Log whitelist status
|
|
whitelist_size = len(self.author_whitelist.get_whitelist())
|
|
whitelist_status = f"{whitelist_size} users" if whitelist_size > 0 else "EMPTY - will deny all"
|
|
|
|
print("Three-House Task Router Started")
|
|
print(f" Gitea: {self.gitea_url}")
|
|
print(f" Repo: {self.repo}")
|
|
print(f" Poll interval: {self.poll_interval}s")
|
|
print(f" Require Timmy approval: {self.require_timmy_approval}")
|
|
print(f" Author whitelist enforced: {self.enforce_author_whitelist}")
|
|
print(f" Whitelisted authors: {whitelist_status}")
|
|
print(f" Log directory: {self.log_dir}")
|
|
print()
|
|
|
|
while self.running:
|
|
try:
|
|
issues = self._get_assigned_issues()
|
|
|
|
for issue in issues:
|
|
self._process_issue(issue)
|
|
|
|
time.sleep(self.poll_interval)
|
|
|
|
except Exception as e:
|
|
self._log_event("daemon_error", {"error": str(e)})
|
|
time.sleep(5)
|
|
|
|
def stop(self):
|
|
"""Stop the daemon"""
|
|
self.running = False
|
|
self._log_event("daemon_stop", {})
|
|
print("\nThree-House Task Router stopped")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Three-House Task Router Daemon")
|
|
parser.add_argument("--gitea-url", default="http://143.198.27.163:3000")
|
|
parser.add_argument("--repo", default="Timmy_Foundation/timmy-home")
|
|
parser.add_argument("--poll-interval", type=int, default=60)
|
|
parser.add_argument("--no-timmy-approval", action="store_true",
|
|
help="Skip Timmy review phase")
|
|
parser.add_argument("--author-whitelist",
|
|
help="Comma-separated list of authorized Gitea usernames")
|
|
parser.add_argument("--no-author-whitelist", action="store_true",
|
|
help="Disable author whitelist enforcement (NOT RECOMMENDED)")
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Parse whitelist from command line or environment
|
|
whitelist = None
|
|
if args.author_whitelist:
|
|
whitelist = [u.strip() for u in args.author_whitelist.split(",") if u.strip()]
|
|
elif os.environ.get("TIMMY_AUTHOR_WHITELIST"):
|
|
whitelist = [u.strip() for u in os.environ.get("TIMMY_AUTHOR_WHITELIST").split(",") if u.strip()]
|
|
|
|
router = ThreeHouseTaskRouter(
|
|
gitea_url=args.gitea_url,
|
|
repo=args.repo,
|
|
poll_interval=args.poll_interval,
|
|
require_timmy_approval=not args.no_timmy_approval,
|
|
author_whitelist=whitelist,
|
|
enforce_author_whitelist=not args.no_author_whitelist
|
|
)
|
|
|
|
try:
|
|
router.start()
|
|
except KeyboardInterrupt:
|
|
router.stop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|