TradingAgents/.claude/lib/alignment_fixer.py

730 lines
22 KiB
Python

#!/usr/bin/env python3
"""
Alignment fixer library for PROJECT.md bidirectional sync.
Implements bidirectional alignment sync between PROJECT.md (strategic intent),
documentation (README.md, CLAUDE.md), and code (implementation).
Features:
- Proposes PROJECT.md updates with approval workflow
- Only allows SCOPE (In Scope) and ARCHITECTURE updates (never GOALS, CONSTRAINTS, Out of Scope)
- Backup before modification
- Atomic updates
- Security validation (CWE-22 path traversal prevention)
- Audit logging for all operations
Date: 2025-12-13
Issue: #129 (Bidirectional alignment sync)
Agent: implementer
See error-handling-patterns skill for exception hierarchy and error handling best practices.
Design Patterns:
See library-design-patterns skill for standardized design patterns.
See state-management-patterns skill for standardized design patterns.
"""
import os
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# Import security utilities (standard pattern from project libraries)
try:
from .security_utils import audit_log, validate_path
except ImportError:
# Direct script execution - add lib dir to path
lib_dir = Path(__file__).parent.resolve()
sys.path.insert(0, str(lib_dir))
from security_utils import audit_log
# Import user state manager for consent workflow
try:
from .user_state_manager import (
UserStateManager,
DEFAULT_STATE_FILE,
get_user_preference,
set_user_preference,
)
except ImportError:
from user_state_manager import (
DEFAULT_STATE_FILE,
get_user_preference,
set_user_preference,
)
# Consent preference key
BIDIRECTIONAL_SYNC_CONSENT_KEY = "bidirectional_sync_enabled"
# Protected sections that should NEVER be auto-updated
PROTECTED_SECTIONS = ["GOALS", "CONSTRAINTS"]
# Sections that can be proposed for update (with approval)
PROPOSABLE_SECTIONS = ["SCOPE", "ARCHITECTURE"]
# Sub-sections within SCOPE that are protected
PROTECTED_SCOPE_SUBSECTIONS = ["Out of Scope"]
class AlignmentFixerError(Exception):
"""Exception raised for alignment fixer errors."""
pass
class ProposedUpdate:
"""Represents a proposed update to PROJECT.md."""
def __init__(
self,
section: str,
subsection: Optional[str],
action: str, # "add", "update", "remove"
current_value: Optional[str],
proposed_value: str,
reason: str,
):
"""
Initialize a proposed update.
Args:
section: Section name (e.g., "SCOPE", "ARCHITECTURE")
subsection: Optional subsection (e.g., "In Scope", "Commands")
action: Type of change ("add", "update", "remove")
current_value: Current value if updating/removing
proposed_value: Proposed new value
reason: Reason for the change
"""
self.section = section
self.subsection = subsection
self.action = action
self.current_value = current_value
self.proposed_value = proposed_value
self.reason = reason
self.approved = False
self.declined = False
def __repr__(self) -> str:
return (
f"ProposedUpdate(section={self.section!r}, "
f"subsection={self.subsection!r}, "
f"action={self.action!r}, "
f"proposed={self.proposed_value!r})"
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"section": self.section,
"subsection": self.subsection,
"action": self.action,
"current_value": self.current_value,
"proposed_value": self.proposed_value,
"reason": self.reason,
"approved": self.approved,
"declined": self.declined,
}
class AlignmentFixer:
"""
Manages bidirectional alignment sync for PROJECT.md.
Handles proposing, reviewing, and applying updates to PROJECT.md
with approval workflow and security validation.
"""
def __init__(self, project_root: Path, state_file: Path = DEFAULT_STATE_FILE):
"""
Initialize AlignmentFixer.
Args:
project_root: Root directory of the project
state_file: Path to user state file for consent
Raises:
AlignmentFixerError: If path validation fails
"""
self.project_root = self._validate_project_root(project_root)
self.project_md_path = self._find_project_md()
self.state_file = state_file
self.pending_updates: List[ProposedUpdate] = []
self.backup_path: Optional[Path] = None
def _validate_project_root(self, path: Path) -> Path:
"""
Validate project root path for security (CWE-22).
Args:
path: Path to validate
Returns:
Validated Path object
Raises:
AlignmentFixerError: If path is unsafe
"""
if isinstance(path, str):
path = Path(path)
# Check for path traversal
path_str = str(path)
if ".." in path_str:
audit_log(
"security_violation",
"failure",
{
"type": "path_traversal",
"path": path_str,
"component": "alignment_fixer"
}
)
raise AlignmentFixerError(f"Path traversal detected: {path_str}")
# Resolve to absolute path
try:
resolved_path = path.resolve()
except (OSError, RuntimeError) as e:
raise AlignmentFixerError(f"Failed to resolve path: {e}")
# Check if directory exists
if not resolved_path.is_dir():
raise AlignmentFixerError(f"Project root is not a directory: {resolved_path}")
return resolved_path
def _find_project_md(self) -> Path:
"""
Find PROJECT.md in project root or .claude directory.
Returns:
Path to PROJECT.md
Raises:
AlignmentFixerError: If PROJECT.md not found
"""
# Check root level first
root_path = self.project_root / "PROJECT.md"
if root_path.exists():
return root_path
# Check .claude directory (follow symlink if needed)
claude_path = self.project_root / ".claude" / "PROJECT.md"
if claude_path.exists():
# If it's a symlink, resolve to actual file
if claude_path.is_symlink():
resolved = claude_path.resolve()
if resolved.exists():
return resolved
return claude_path
raise AlignmentFixerError(
f"PROJECT.md not found in {self.project_root} or {self.project_root / '.claude'}"
)
def is_consent_enabled(self) -> bool:
"""
Check if bidirectional sync consent is enabled.
Returns:
True if consent given, False otherwise
"""
# Check environment variable override first
env_value = os.environ.get("BIDIRECTIONAL_SYNC_ENABLED", "").lower()
if env_value in ("true", "1", "yes"):
return True
if env_value in ("false", "0", "no"):
return False
# Fall back to user state
return get_user_preference(
BIDIRECTIONAL_SYNC_CONSENT_KEY,
self.state_file,
default=None, # None means not yet asked
)
def record_consent(self, enabled: bool) -> None:
"""
Record user consent for bidirectional sync.
Args:
enabled: Whether sync is enabled
"""
set_user_preference(
BIDIRECTIONAL_SYNC_CONSENT_KEY,
enabled,
self.state_file,
)
audit_log(
"bidirectional_sync_consent",
"success",
{
"enabled": enabled,
"state_file": str(self.state_file),
}
)
def is_section_protected(self, section: str, subsection: Optional[str] = None) -> bool:
"""
Check if a section is protected from auto-updates.
Args:
section: Section name
subsection: Optional subsection name
Returns:
True if protected, False if can be proposed
"""
# Top-level protected sections
if section in PROTECTED_SECTIONS:
return True
# Protected subsections within SCOPE
if section == "SCOPE" and subsection in PROTECTED_SCOPE_SUBSECTIONS:
return True
return False
def propose_update(
self,
section: str,
proposed_value: str,
reason: str,
subsection: Optional[str] = None,
action: str = "add",
current_value: Optional[str] = None,
) -> ProposedUpdate:
"""
Propose an update to PROJECT.md.
Args:
section: Section to update (must be in PROPOSABLE_SECTIONS)
proposed_value: Value to add/update
reason: Reason for the change
subsection: Optional subsection
action: "add", "update", or "remove"
current_value: Current value if updating/removing
Returns:
ProposedUpdate object
Raises:
AlignmentFixerError: If section is protected
"""
# Validate section
if section not in PROPOSABLE_SECTIONS:
raise AlignmentFixerError(
f"Section '{section}' cannot be proposed for update. "
f"Only {PROPOSABLE_SECTIONS} can be updated. "
f"Protected sections: {PROTECTED_SECTIONS}"
)
# Validate subsection
if self.is_section_protected(section, subsection):
raise AlignmentFixerError(
f"Subsection '{subsection}' within '{section}' is protected and cannot be updated."
)
# Create proposal
update = ProposedUpdate(
section=section,
subsection=subsection,
action=action,
current_value=current_value,
proposed_value=proposed_value,
reason=reason,
)
self.pending_updates.append(update)
audit_log(
"project_md_update_proposed",
"success",
{
"section": section,
"subsection": subsection,
"action": action,
"proposed_value": proposed_value[:100], # Truncate for log
"reason": reason,
}
)
return update
def format_proposals_for_display(self) -> str:
"""
Format pending proposals for user display.
Returns:
Formatted string showing all proposals
"""
if not self.pending_updates:
return "No pending PROJECT.md updates."
lines = [
"Proposed PROJECT.md updates:",
"" * 40,
]
for i, update in enumerate(self.pending_updates, 1):
section_display = update.section
if update.subsection:
section_display = f"{update.section} ({update.subsection})"
lines.append(f"\n{i}. {section_display}:")
lines.append(f" Action: {update.action}")
if update.current_value:
lines.append(f" Current: {update.current_value}")
lines.append(f" Proposed: {update.proposed_value}")
lines.append(f" Reason: {update.reason}")
lines.append("\n" + "" * 40)
return "\n".join(lines)
def create_backup(self) -> Path:
"""
Create a backup of PROJECT.md before modification.
Returns:
Path to backup file
Raises:
AlignmentFixerError: If backup fails
"""
if not self.project_md_path.exists():
raise AlignmentFixerError("PROJECT.md does not exist")
# Create backup directory
backup_dir = Path.home() / ".autonomous-dev" / "backups" / "project_md"
backup_dir.mkdir(parents=True, exist_ok=True)
# Ensure secure permissions
backup_dir.chmod(0o700)
# Create timestamped backup
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"PROJECT.md.{timestamp}.backup"
backup_path = backup_dir / backup_name
try:
shutil.copy2(self.project_md_path, backup_path)
backup_path.chmod(0o600)
self.backup_path = backup_path
audit_log(
"project_md_backup_created",
"success",
{
"source": str(self.project_md_path),
"backup": str(backup_path),
}
)
return backup_path
except (OSError, shutil.Error) as e:
audit_log(
"project_md_backup_failed",
"failure",
{
"source": str(self.project_md_path),
"error": str(e),
}
)
raise AlignmentFixerError(f"Failed to create backup: {e}")
def apply_approved_updates(self) -> Tuple[int, List[str]]:
"""
Apply all approved updates to PROJECT.md.
Returns:
Tuple of (applied_count, list of applied descriptions)
Raises:
AlignmentFixerError: If update fails
"""
approved = [u for u in self.pending_updates if u.approved]
if not approved:
return 0, []
# Create backup first
self.create_backup()
# Read current content
content = self.project_md_path.read_text()
applied_descriptions = []
try:
for update in approved:
content = self._apply_single_update(content, update)
applied_descriptions.append(
f"{update.action} in {update.section}: {update.proposed_value[:50]}"
)
# Atomic write: write to temp file, then rename
temp_path = self.project_md_path.with_suffix(".tmp")
temp_path.write_text(content)
temp_path.replace(self.project_md_path)
audit_log(
"project_md_updates_applied",
"success",
{
"applied_count": len(approved),
"backup": str(self.backup_path),
}
)
# Clear applied updates from pending
self.pending_updates = [u for u in self.pending_updates if not u.approved]
return len(approved), applied_descriptions
except Exception as e:
# Attempt rollback
if self.backup_path and self.backup_path.exists():
self._rollback()
audit_log(
"project_md_update_failed",
"failure",
{
"error": str(e),
"rollback_attempted": self.backup_path is not None,
}
)
raise AlignmentFixerError(f"Failed to apply updates: {e}")
def _apply_single_update(self, content: str, update: ProposedUpdate) -> str:
"""
Apply a single update to PROJECT.md content.
Args:
content: Current file content
update: Update to apply
Returns:
Modified content
"""
if update.section == "SCOPE":
return self._apply_scope_update(content, update)
elif update.section == "ARCHITECTURE":
return self._apply_architecture_update(content, update)
else:
raise AlignmentFixerError(f"Unknown section: {update.section}")
def _apply_scope_update(self, content: str, update: ProposedUpdate) -> str:
"""Apply update to SCOPE section."""
# Find the "In Scope" section
in_scope_pattern = r"(\*\*What's IN Scope\*\*.+?)(\n\n\*\*What's OUT)"
match = re.search(in_scope_pattern, content, re.DOTALL)
if not match:
# Try alternate pattern
in_scope_pattern = r"(## SCOPE.+?IN Scope.+?)(\n\n.*?OUT)"
match = re.search(in_scope_pattern, content, re.DOTALL)
if not match:
raise AlignmentFixerError("Could not find 'In Scope' section in PROJECT.md")
in_scope_section = match.group(1)
if update.action == "add":
# Add new item to end of In Scope section
new_line = f"- ✅ **{update.proposed_value}**"
if update.reason:
new_line += f" - {update.reason}"
# Find the last bullet point in the section
bullets = list(re.finditer(r"- ✅ .+", in_scope_section))
if bullets:
last_bullet = bullets[-1]
insert_pos = match.start(1) + last_bullet.end()
content = content[:insert_pos] + "\n" + new_line + content[insert_pos:]
else:
# Just append to section
content = content[:match.end(1)] + "\n" + new_line + content[match.end(1):]
return content
def _apply_architecture_update(self, content: str, update: ProposedUpdate) -> str:
"""Apply update to ARCHITECTURE section."""
# Handle count updates (e.g., "Commands: 7 → 8")
if update.subsection and "count" in update.subsection.lower():
# Pattern like "**Commands**: 7 active"
pattern = rf"(\*\*{update.subsection}\*\*[:\s]+)(\d+)"
def replace_count(m):
return m.group(1) + update.proposed_value
content = re.sub(pattern, replace_count, content)
return content
def _rollback(self) -> None:
"""Rollback to backup if available."""
if not self.backup_path or not self.backup_path.exists():
return
try:
shutil.copy2(self.backup_path, self.project_md_path)
audit_log(
"project_md_rollback",
"success",
{
"backup": str(self.backup_path),
"target": str(self.project_md_path),
}
)
except Exception as e:
audit_log(
"project_md_rollback_failed",
"failure",
{
"backup": str(self.backup_path),
"error": str(e),
}
)
def mark_approved(self, indices: List[int]) -> int:
"""
Mark specific proposals as approved.
Args:
indices: 1-based indices of proposals to approve
Returns:
Number of proposals marked approved
"""
count = 0
for idx in indices:
if 1 <= idx <= len(self.pending_updates):
self.pending_updates[idx - 1].approved = True
count += 1
return count
def mark_declined(self, indices: List[int]) -> int:
"""
Mark specific proposals as declined.
Args:
indices: 1-based indices of proposals to decline
Returns:
Number of proposals marked declined
"""
count = 0
for idx in indices:
if 1 <= idx <= len(self.pending_updates):
self.pending_updates[idx - 1].declined = True
count += 1
# Log declined proposals
for idx in indices:
if 1 <= idx <= len(self.pending_updates):
update = self.pending_updates[idx - 1]
audit_log(
"project_md_update_declined",
"success",
{
"section": update.section,
"proposed_value": update.proposed_value[:100],
"reason": update.reason,
}
)
return count
# Module-level convenience functions
def check_bidirectional_sync_consent(state_file: Path = DEFAULT_STATE_FILE) -> Optional[bool]:
"""
Check if bidirectional sync consent has been given.
Returns:
True if enabled, False if disabled, None if not yet asked
"""
return get_user_preference(
BIDIRECTIONAL_SYNC_CONSENT_KEY,
state_file,
default=None,
)
def record_bidirectional_sync_consent(
enabled: bool,
state_file: Path = DEFAULT_STATE_FILE
) -> None:
"""
Record bidirectional sync consent.
Args:
enabled: Whether sync is enabled
state_file: Path to state file
"""
set_user_preference(
BIDIRECTIONAL_SYNC_CONSENT_KEY,
enabled,
state_file,
)
audit_log(
"bidirectional_sync_consent",
"success",
{
"enabled": enabled,
"state_file": str(state_file),
}
)
def propose_scope_addition(
project_root: Path,
feature_name: str,
reason: str,
) -> ProposedUpdate:
"""
Convenience function to propose adding a feature to SCOPE.
Args:
project_root: Project root directory
feature_name: Name of feature to add
reason: Reason for addition
Returns:
ProposedUpdate object
"""
fixer = AlignmentFixer(project_root)
return fixer.propose_update(
section="SCOPE",
subsection="In Scope",
action="add",
proposed_value=feature_name,
reason=reason,
)
def is_section_protected(section: str, subsection: Optional[str] = None) -> bool:
"""
Check if a section is protected from auto-updates.
Args:
section: Section name
subsection: Optional subsection name
Returns:
True if protected, False if can be proposed
"""
if section in PROTECTED_SECTIONS:
return True
if section == "SCOPE" and subsection in PROTECTED_SCOPE_SUBSECTIONS:
return True
return False