Files
timmy-home/uni-wizard/v2/task_router_daemon.py
Allegro 384fad6d5f security: Add author whitelist for task router (Issue #132)
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
2026-03-31 03:53:37 +00:00

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()