348 lines
11 KiB
Python
348 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
GOFAI Rule Engine for Hermes Fleet
|
|
Forward-chaining rule engine for principle enforcement and reasoning
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Dict, Callable, Optional, Any
|
|
from enum import Enum
|
|
|
|
class ActionType(Enum):
|
|
ALLOW = "allow"
|
|
BLOCK = "block"
|
|
WARN = "warn"
|
|
REQUIRE_APPROVAL = "require_approval"
|
|
LOG = "log"
|
|
|
|
@dataclass
|
|
class Rule:
|
|
"""A rule in the rule engine"""
|
|
id: str
|
|
name: str
|
|
condition: str # Python expression or pattern
|
|
action: ActionType
|
|
explanation: str
|
|
priority: int = 1
|
|
enabled: bool = True
|
|
category: str = "general"
|
|
|
|
def to_dict(self) -> Dict:
|
|
return {
|
|
'id': self.id,
|
|
'name': self.name,
|
|
'condition': self.condition,
|
|
'action': self.action.value,
|
|
'explanation': self.explanation,
|
|
'priority': self.priority,
|
|
'enabled': self.enabled,
|
|
'category': self.category
|
|
}
|
|
|
|
class RuleContext:
|
|
"""Context for rule evaluation"""
|
|
|
|
def __init__(self, **kwargs):
|
|
self.data = kwargs
|
|
self.history = []
|
|
|
|
def get(self, key: str, default=None):
|
|
return self.data.get(key, default)
|
|
|
|
def set(self, key: str, value):
|
|
self.data[key] = value
|
|
|
|
def log(self, message: str):
|
|
self.history.append(message)
|
|
|
|
class RuleEngine:
|
|
"""
|
|
Forward-chaining rule engine
|
|
Evaluates rules against context and executes actions
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.rules: Dict[str, Rule] = {}
|
|
self.actions: Dict[str, Callable] = {}
|
|
self.rule_order: List[str] = []
|
|
|
|
# Register default actions
|
|
self._register_default_actions()
|
|
|
|
def _register_default_actions(self):
|
|
"""Register built-in action handlers"""
|
|
|
|
def allow_action(ctx: RuleContext, rule: Rule) -> Dict:
|
|
return {'allowed': True, 'reason': rule.explanation}
|
|
|
|
def block_action(ctx: RuleContext, rule: Rule) -> Dict:
|
|
return {
|
|
'allowed': False,
|
|
'reason': rule.explanation,
|
|
'rule_id': rule.id,
|
|
'action': 'block'
|
|
}
|
|
|
|
def warn_action(ctx: RuleContext, rule: Rule) -> Dict:
|
|
return {
|
|
'allowed': True,
|
|
'warning': rule.explanation,
|
|
'rule_id': rule.id,
|
|
'action': 'warn'
|
|
}
|
|
|
|
def require_approval_action(ctx: RuleContext, rule: Rule) -> Dict:
|
|
return {
|
|
'allowed': False,
|
|
'reason': rule.explanation,
|
|
'requires_approval': True,
|
|
'rule_id': rule.id
|
|
}
|
|
|
|
def log_action(ctx: RuleContext, rule: Rule) -> Dict:
|
|
ctx.log(f"Rule {rule.id} triggered: {rule.explanation}")
|
|
return {'allowed': True, 'logged': True}
|
|
|
|
self.actions = {
|
|
ActionType.ALLOW.value: allow_action,
|
|
ActionType.BLOCK.value: block_action,
|
|
ActionType.WARN.value: warn_action,
|
|
ActionType.REQUIRE_APPROVAL.value: require_approval_action,
|
|
ActionType.LOG.value: log_action,
|
|
}
|
|
|
|
def add_rule(self, rule: Rule) -> None:
|
|
"""Add a rule to the engine"""
|
|
self.rules[rule.id] = rule
|
|
# Re-sort rules by priority
|
|
self.rule_order = sorted(
|
|
self.rules.keys(),
|
|
key=lambda r: self.rules[r].priority
|
|
)
|
|
|
|
def load_soul_principles(self, soul_md_content: str) -> List[Rule]:
|
|
"""
|
|
Parse SOUL.md and convert to rules
|
|
"""
|
|
rules = []
|
|
|
|
# Pattern matching for SOUL.md structure
|
|
sections = {
|
|
'must_do': r'Must do:([^#]+)',
|
|
'must_not_do': r'Must not do:([^#]+)',
|
|
'belief': r'Belief:([^#]+)',
|
|
}
|
|
|
|
for category, pattern in sections.items():
|
|
matches = re.findall(pattern, soul_md_content, re.IGNORECASE)
|
|
for i, match in enumerate(matches):
|
|
lines = [l.strip() for l in match.strip().split('\n') if l.strip()]
|
|
for line in lines:
|
|
if line.startswith('-') or line.startswith('*'):
|
|
principle = line[1:].strip()
|
|
rule = self._principle_to_rule(principle, category, i)
|
|
rules.append(rule)
|
|
self.add_rule(rule)
|
|
|
|
return rules
|
|
|
|
def _principle_to_rule(self, principle: str, category: str, index: int) -> Rule:
|
|
"""Convert a SOUL principle to a rule"""
|
|
|
|
# Extract keywords from principle
|
|
principle_lower = principle.lower()
|
|
|
|
# Determine action based on category
|
|
if category == 'must_not_do':
|
|
action = ActionType.BLOCK
|
|
# Check if it should just warn instead
|
|
if 'avoid' in principle_lower or 'prefer' in principle_lower:
|
|
action = ActionType.WARN
|
|
elif category == 'must_do':
|
|
action = ActionType.REQUIRE_APPROVAL
|
|
else:
|
|
action = ActionType.LOG
|
|
|
|
# Build condition from principle text
|
|
# This is a simplified version - sophisticated version would use NLP
|
|
keywords = self._extract_keywords(principle)
|
|
condition = f'action.contains_any({keywords})'
|
|
|
|
return Rule(
|
|
id=f'soul_{category}_{index}',
|
|
name=f'SOUL: {principle[:50]}...',
|
|
condition=condition,
|
|
action=action,
|
|
explanation=principle,
|
|
priority=1 if category == 'must_not_do' else 2,
|
|
category=category
|
|
)
|
|
|
|
def _extract_keywords(self, text: str) -> List[str]:
|
|
"""Extract significant keywords from text"""
|
|
# Simple keyword extraction
|
|
words = text.lower().split()
|
|
# Filter out common words
|
|
stopwords = {'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for'}
|
|
keywords = [w.strip('.,;:!?') for w in words if w not in stopwords and len(w) > 3]
|
|
return list(set(keywords))[:5] # Top 5 unique keywords
|
|
|
|
def evaluate(self, action_description: str, context: Optional[RuleContext] = None) -> Dict:
|
|
"""
|
|
Evaluate an action against all rules
|
|
Returns decision and explanations
|
|
"""
|
|
if context is None:
|
|
context = RuleContext(action=action_description)
|
|
else:
|
|
context.set('action', action_description)
|
|
|
|
results = []
|
|
blocked = False
|
|
warnings = []
|
|
|
|
# Evaluate rules in priority order
|
|
for rule_id in self.rule_order:
|
|
rule = self.rules[rule_id]
|
|
|
|
if not rule.enabled:
|
|
continue
|
|
|
|
# Check if condition matches
|
|
if self._condition_matches(rule.condition, action_description, context):
|
|
# Execute action
|
|
handler = self.actions.get(rule.action.value, self.actions['log'])
|
|
result = handler(context, rule)
|
|
results.append(result)
|
|
|
|
if result.get('action') == 'block':
|
|
blocked = True
|
|
if result.get('action') == 'warn':
|
|
warnings.append(result.get('warning'))
|
|
|
|
# For BLOCK actions, stop processing
|
|
if blocked and rule.priority <= 1:
|
|
break
|
|
|
|
return {
|
|
'allowed': not blocked,
|
|
'action': action_description,
|
|
'violations': [r for r in results if r.get('action') == 'block'],
|
|
'warnings': warnings,
|
|
'log': context.history
|
|
}
|
|
|
|
def _condition_matches(self, condition: str, action: str, ctx: RuleContext) -> bool:
|
|
"""
|
|
Check if condition matches action
|
|
Simplified version - full version would parse and evaluate properly
|
|
"""
|
|
action_lower = action.lower()
|
|
|
|
# Parse condition
|
|
if 'contains_any' in condition:
|
|
# Extract keywords from condition
|
|
match = re.search(r'\[([^\]]+)\]', condition)
|
|
if match:
|
|
keywords_str = match.group(1)
|
|
keywords = [k.strip().strip("'") for k in keywords_str.split(',')]
|
|
return any(kw in action_lower for kw in keywords)
|
|
|
|
# Simple substring match
|
|
if condition.lower() in action_lower:
|
|
return True
|
|
|
|
return False
|
|
|
|
def get_rules_by_category(self, category: str) -> List[Rule]:
|
|
"""Get all rules in a category"""
|
|
return [r for r in self.rules.values() if r.category == category]
|
|
|
|
def export_rules(self) -> str:
|
|
"""Export all rules as JSON"""
|
|
rules_list = [r.to_dict() for r in self.rules.values()]
|
|
return json.dumps(rules_list, indent=2)
|
|
|
|
def import_rules(self, json_str: str) -> int:
|
|
"""Import rules from JSON"""
|
|
rules_data = json.loads(json_str)
|
|
count = 0
|
|
for data in rules_data:
|
|
rule = Rule(
|
|
id=data['id'],
|
|
name=data['name'],
|
|
condition=data['condition'],
|
|
action=ActionType(data['action']),
|
|
explanation=data['explanation'],
|
|
priority=data.get('priority', 1),
|
|
enabled=data.get('enabled', True),
|
|
category=data.get('category', 'general')
|
|
)
|
|
self.add_rule(rule)
|
|
count += 1
|
|
return count
|
|
|
|
# Pre-built rule sets for children
|
|
|
|
CHILD_SAFETY_RULES = [
|
|
Rule(
|
|
id='child_no_delete',
|
|
name='Children cannot delete critical files',
|
|
condition='delete and (critical or system)',
|
|
action=ActionType.REQUIRE_APPROVAL,
|
|
explanation='Children must request approval before deleting critical system files',
|
|
priority=0,
|
|
category='safety'
|
|
),
|
|
Rule(
|
|
id='child_no_network',
|
|
name='Children cannot modify network config',
|
|
condition='network or firewall or iptables',
|
|
action=ActionType.BLOCK,
|
|
explanation='Network configuration changes are restricted',
|
|
priority=0,
|
|
category='safety'
|
|
),
|
|
Rule(
|
|
id='child_report_error',
|
|
name='Children must report errors',
|
|
condition='error and not report',
|
|
action=ActionType.WARN,
|
|
explanation='When encountering errors, children should report to father',
|
|
priority=2,
|
|
category='behavior'
|
|
),
|
|
]
|
|
|
|
FLEET_COORDINATION_RULES = [
|
|
Rule(
|
|
id='coord_no_conflict',
|
|
name='Avoid task conflicts',
|
|
condition='same_task and same_resource',
|
|
action=ActionType.REQUIRE_APPROVAL,
|
|
explanation='Multiple wizards working on same task/resource requires coordination',
|
|
priority=1,
|
|
category='coordination'
|
|
),
|
|
Rule(
|
|
id='coord_report_complete',
|
|
name='Report task completion',
|
|
condition='complete and not report',
|
|
action=ActionType.WARN,
|
|
explanation='Completed tasks should be reported to father-messages/',
|
|
priority=2,
|
|
category='coordination'
|
|
),
|
|
]
|
|
|
|
def create_child_rule_engine() -> RuleEngine:
|
|
"""Create a rule engine pre-configured for child wizards"""
|
|
engine = RuleEngine()
|
|
for rule in CHILD_SAFETY_RULES:
|
|
engine.add_rule(rule)
|
|
for rule in FLEET_COORDINATION_RULES:
|
|
engine.add_rule(rule)
|
|
return engine
|