Compare commits
8 Commits
fix/18-har
...
step35/678
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
848a2487d6 | ||
| d8600345b5 | |||
| bbc73ff632 | |||
|
|
ff3d9ff238 | ||
| 827d08ea21 | |||
| 3afdec9019 | |||
|
|
815f7d38e8 | ||
| 0aa6699356 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -54,3 +54,8 @@ nosetests.xml
|
|||||||
|
|
||||||
# VSCode config
|
# VSCode config
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
|
# Environment variables — never commit secrets
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
|||||||
104
GENOME.md
Normal file
104
GENOME.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# GENOME.md — Timmy_Foundation/timmy-academy
|
||||||
|
|
||||||
|
Generated by `pipelines/codebase_genome.py`.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**An Evennia MUD for AI agent training, collaboration, and crisis response practice.**
|
||||||
|
|
||||||
|
- Text files indexed: 75
|
||||||
|
- Source and script files: 37
|
||||||
|
- Test files: 1
|
||||||
|
- Documentation files: 25
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
repo_root["repo"]
|
||||||
|
commands["commands"]
|
||||||
|
hermes_agent["hermes-agent"]
|
||||||
|
server["server"]
|
||||||
|
tests["tests"]
|
||||||
|
typeclasses["typeclasses"]
|
||||||
|
web["web"]
|
||||||
|
world["world"]
|
||||||
|
repo_root --> commands
|
||||||
|
repo_root --> hermes_agent
|
||||||
|
repo_root --> server
|
||||||
|
repo_root --> tests
|
||||||
|
repo_root --> typeclasses
|
||||||
|
repo_root --> web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
- No explicit entry point detected.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. No explicit CLI/main guard entry point was detected; execution appears library- or doc-driven.
|
||||||
|
2. Core logic fans into top-level components: `commands`, `hermes-agent`, `server`, `tests`, `typeclasses`, `web`.
|
||||||
|
3. Validation is incomplete around `world/rebuild_world.py`, `world/gardens_wing.py`, `world/workshop_wing.py`, so changes there carry regression risk.
|
||||||
|
4. Final artifacts land as repository files, docs, or runtime side effects depending on the selected entry point.
|
||||||
|
|
||||||
|
## Key Abstractions
|
||||||
|
|
||||||
|
- `world/rebuild_world.py` — classes none detected; functions `parse_wing_file()`:40, `get_room()`:238, `rebuild_rooms()`:247, `verify_exits()`:327, `manage_characters()`:374, `setup_channels()`:397
|
||||||
|
- `commands/command.py` — classes `Command`:19, `CmdExamine`:28, `CmdRooms`:76, `CmdStatus`:108; functions none detected
|
||||||
|
- `world/gardens_wing.py` — classes `GardensEntrance`:8, `HerbGardens`:78, `EnchantedGrove`:145, `GreenhouseComplex`:213; functions none detected
|
||||||
|
- `world/workshop_wing.py` — classes `WorkshopEntrance`:8, `GreatSmithy`:74, `AlchemyLaboratories`:140, `WoodworkingStudio`:208; functions none detected
|
||||||
|
- `world/dormitory_entrance.py` — classes `DormitoryEntrance`:8, `DormitoryLodgingMaster`:86, `CorridorOfRest`:147, `NoviceDormitoryHall`:213; functions none detected
|
||||||
|
- `world/commons_wing.py` — classes `GrandCommonsHall`:8, `HearthsideDining`:77, `ScholarsCorner`:144, `EntertainmentGallery`:209; functions none detected
|
||||||
|
- `typeclasses/objects.py` — classes `ObjectParent`:14, `Object`:26; functions none detected
|
||||||
|
- `typeclasses/audited_character.py` — classes `AuditedCharacter`:19; functions none detected
|
||||||
|
|
||||||
|
## API Surface
|
||||||
|
|
||||||
|
- Python: `parse_wing_file()` from `world/rebuild_world.py:40`
|
||||||
|
- Python: `get_room()` from `world/rebuild_world.py:238`
|
||||||
|
- Python: `rebuild_rooms()` from `world/rebuild_world.py:247`
|
||||||
|
- Python: `verify_exits()` from `world/rebuild_world.py:327`
|
||||||
|
- Python: `manage_characters()` from `world/rebuild_world.py:374`
|
||||||
|
- Python: `setup_channels()` from `world/rebuild_world.py:397`
|
||||||
|
|
||||||
|
## Test Coverage Report
|
||||||
|
|
||||||
|
- Source and script files inspected: 37
|
||||||
|
- Test files inspected: 1
|
||||||
|
- Coverage gaps:
|
||||||
|
- `world/rebuild_world.py` — no matching test reference detected
|
||||||
|
- `world/gardens_wing.py` — no matching test reference detected
|
||||||
|
- `world/workshop_wing.py` — no matching test reference detected
|
||||||
|
- `world/dormitory_entrance.py` — no matching test reference detected
|
||||||
|
- `world/commons_wing.py` — no matching test reference detected
|
||||||
|
- `typeclasses/objects.py` — no matching test reference detected
|
||||||
|
- `server/conf/settings.py` — no matching test reference detected
|
||||||
|
- `typeclasses/audited_character.py` — no matching test reference detected
|
||||||
|
- `typeclasses/accounts.py` — no matching test reference detected
|
||||||
|
- `world/fix_world.py` — no matching test reference detected
|
||||||
|
- `typeclasses/channels.py` — no matching test reference detected
|
||||||
|
- `server/conf/mssp.py` — no matching test reference detected
|
||||||
|
|
||||||
|
## Security Audit Findings
|
||||||
|
|
||||||
|
- [medium] `commands/command.py:303` — hardcoded http endpoint: plaintext or fixed HTTP endpoints can drift or leak across environments. Evidence: `msg.append(" |wWeb:|n http://167.99.126.228:4001")`
|
||||||
|
- [medium] `hermes-agent/config.yaml:15` — hardcoded http endpoint: plaintext or fixed HTTP endpoints can drift or leak across environments. Evidence: `base_url: http://localhost:11434`
|
||||||
|
|
||||||
|
## Dead Code Candidates
|
||||||
|
|
||||||
|
- `world/rebuild_world.py` — not imported by indexed Python modules and not referenced by tests
|
||||||
|
- `server/conf/settings.py` — not imported by indexed Python modules and not referenced by tests
|
||||||
|
- `typeclasses/audited_character.py` — not imported by indexed Python modules and not referenced by tests
|
||||||
|
- `typeclasses/accounts.py` — not imported by indexed Python modules and not referenced by tests
|
||||||
|
- `world/fix_world.py` — not imported by indexed Python modules and not referenced by tests
|
||||||
|
- `typeclasses/channels.py` — not imported by indexed Python modules and not referenced by tests
|
||||||
|
- `server/conf/mssp.py` — not imported by indexed Python modules and not referenced by tests
|
||||||
|
- `typeclasses/scripts.py` — not imported by indexed Python modules and not referenced by tests
|
||||||
|
- `commands/default_cmdsets.py` — not imported by indexed Python modules and not referenced by tests
|
||||||
|
- `world/prototypes.py` — not imported by indexed Python modules and not referenced by tests
|
||||||
|
|
||||||
|
## Performance Bottleneck Analysis
|
||||||
|
|
||||||
|
- `commands/command.py` — large module (436 lines) likely hides multiple responsibilities
|
||||||
|
- `world/rebuild_world.py` — large module (482 lines) likely hides multiple responsibilities
|
||||||
74
docs/npc-permissions-audit.md
Normal file
74
docs/npc-permissions-audit.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# NPC Permissions Audit — timmy-academy #11
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Audit of Hermes bridge NPC agent permissions. NPCs may have excessive access that violates least-privilege principles.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
|
||||||
|
NPCs (Non-Player Characters) in the academy bridge system have the following permissions:
|
||||||
|
|
||||||
|
| Permission | Current | Recommended | Risk |
|
||||||
|
|------------|---------|-------------|------|
|
||||||
|
| read_rooms | ✅ | ✅ | Low |
|
||||||
|
| write_rooms | ✅ | ❌ | HIGH |
|
||||||
|
| modify_players | ✅ | ❌ | HIGH |
|
||||||
|
| access_inventory | ✅ | ✅ | Low |
|
||||||
|
| teleport_players | ✅ | ❌ | HIGH |
|
||||||
|
| send_global_messages | ✅ | ✅ | Medium |
|
||||||
|
| modify_world_state | ✅ | ❌ | CRITICAL |
|
||||||
|
| access_credentials | ✅ | ❌ | CRITICAL |
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
1. **write_rooms** — NPCs can modify room descriptions and exits
|
||||||
|
- Risk: Content injection, navigation traps
|
||||||
|
- Fix: Remove write access, NPCs should only read
|
||||||
|
|
||||||
|
2. **modify_players** — NPCs can change player stats/inventory
|
||||||
|
- Risk: Game economy manipulation
|
||||||
|
- Fix: Remove, NPCs should not touch player state
|
||||||
|
|
||||||
|
3. **teleport_players** — NPCs can move players arbitrarily
|
||||||
|
- Risk: Trap players in unreachable locations
|
||||||
|
- Fix: Remove or restrict to specific zones
|
||||||
|
|
||||||
|
4. **modify_world_state** — NPCs can change global game state
|
||||||
|
- Risk: Denial of service, game-breaking changes
|
||||||
|
- Fix: Remove entirely
|
||||||
|
|
||||||
|
5. **access_credentials** — NPCs can access authentication tokens
|
||||||
|
- Risk: Credential theft, privilege escalation
|
||||||
|
- Fix: Remove immediately
|
||||||
|
|
||||||
|
## Recommended Permission Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
NPC_PERMISSIONS = {
|
||||||
|
"read_rooms": True, # Read room descriptions
|
||||||
|
"access_inventory": True, # Check inventory (read-only)
|
||||||
|
"send_global_messages": True, # Broadcast messages
|
||||||
|
"interact_players": True, # Basic interaction
|
||||||
|
|
||||||
|
# DENIED
|
||||||
|
"write_rooms": False,
|
||||||
|
"modify_players": False,
|
||||||
|
"teleport_players": False,
|
||||||
|
"modify_world_state": False,
|
||||||
|
"access_credentials": False,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
1. Audit all NPC definitions
|
||||||
|
2. Update permission locks
|
||||||
|
3. Add permission checks to bridge code
|
||||||
|
4. Test NPC functionality with restricted permissions
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Issue #11: NPC permissions need review
|
||||||
|
- Source: Genome #678
|
||||||
15
hermes-agent/.env.example
Normal file
15
hermes-agent/.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# hermes-agent/.env.example
|
||||||
|
# Copy to .env and fill in real values. NEVER commit .env to git.
|
||||||
|
# Ref: #17
|
||||||
|
|
||||||
|
# API Keys (rotate if exposed)
|
||||||
|
KIMI_API_KEY=your-kimi-api-key-here
|
||||||
|
|
||||||
|
# Telegram
|
||||||
|
TELEGRAM_BOT_TOKEN=your-telegram-bot-token-here
|
||||||
|
TELEGRAM_HOME_CHANNEL=your-channel-id-here
|
||||||
|
TELEGRAM_HOME_CHANNEL_NAME="Your Channel Name"
|
||||||
|
TELEGRAM_ALLOWED_USERS=comma-separated-user-ids
|
||||||
|
|
||||||
|
# Gitea
|
||||||
|
GITEA_TOKEN=your-gitea-token-here
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
AuditedCharacter - A character typeclass with full audit logging.
|
AuditedCharacter - A character typeclass with full audit logging.
|
||||||
|
|
||||||
Tracks every movement, command, and action for complete visibility
|
Tracks every movement, command, and action for complete visibility
|
||||||
into player activity.
|
into player activity. Supports configurable log rotation to prevent
|
||||||
|
unbounded growth.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
@@ -10,44 +11,81 @@ from datetime import datetime
|
|||||||
from evennia import DefaultCharacter
|
from evennia import DefaultCharacter
|
||||||
from evennia.utils import logger
|
from evennia.utils import logger
|
||||||
|
|
||||||
|
# Default audit retention limits
|
||||||
|
DEFAULT_MAX_HISTORY = 500
|
||||||
|
DEFAULT_MAX_LOG_ENTRIES = 100 # Max AUDIT log lines per character per server session
|
||||||
|
|
||||||
|
|
||||||
class AuditedCharacter(DefaultCharacter):
|
class AuditedCharacter(DefaultCharacter):
|
||||||
"""
|
"""
|
||||||
Character typeclass with comprehensive audit logging.
|
Character typeclass with comprehensive audit logging.
|
||||||
|
|
||||||
Tracks:
|
Tracks:
|
||||||
- Every room entered/exited with timestamps
|
- Every room entered/exited with timestamps
|
||||||
- Total playtime
|
- Total playtime
|
||||||
- Command count
|
- Command count
|
||||||
- Last known location
|
- Last known location
|
||||||
- Full location history
|
- Full location history (rotated)
|
||||||
|
|
||||||
|
Configurable via class attributes:
|
||||||
|
audit_max_history (int): Max location_history entries kept in db (default 500)
|
||||||
|
audit_max_log_entries (int): Max AUDIT log lines per server session (default 100)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
audit_max_history = DEFAULT_MAX_HISTORY
|
||||||
|
audit_max_log_entries = DEFAULT_MAX_LOG_ENTRIES
|
||||||
|
|
||||||
def at_object_creation(self):
|
def at_object_creation(self):
|
||||||
"""Set up audit attributes when character is created."""
|
"""Set up audit attributes when character is created."""
|
||||||
super().at_object_creation()
|
super().at_object_creation()
|
||||||
|
|
||||||
# Initialize audit tracking attributes
|
# Initialize audit tracking attributes
|
||||||
self.db.location_history = [] # List of {room, timestamp, action}
|
self.db.location_history = [] # List of {room, timestamp, action}
|
||||||
self.db.command_count = 0
|
self.db.command_count = 0
|
||||||
self.db.total_playtime = 0 # in seconds
|
self.db.total_playtime = 0 # in seconds
|
||||||
self.db.session_start_time = None
|
self.db.session_start_time = None
|
||||||
self.db.last_location = None
|
self.db.last_location = None
|
||||||
|
self.db.audit_log_count = 0 # Tracks log entries this session for rate limiting
|
||||||
|
|
||||||
logger.log_info(f"AUDIT: Character '{self.key}' created at {datetime.utcnow()}")
|
logger.log_info(f"AUDIT: Character '{self.key}' created at {datetime.utcnow()}")
|
||||||
|
|
||||||
|
def _audit_log(self, message):
|
||||||
|
"""Write an audit log entry with rate limiting per server session."""
|
||||||
|
count = (self.db.audit_log_count or 0) + 1
|
||||||
|
if count <= self.audit_max_log_entries:
|
||||||
|
logger.log_info(message)
|
||||||
|
if count == self.audit_max_log_entries:
|
||||||
|
logger.log_info(
|
||||||
|
f"AUDIT: {self.key} reached log limit ({self.audit_max_log_entries}) "
|
||||||
|
f"- suppressing further audit logs this session"
|
||||||
|
)
|
||||||
|
self.db.audit_log_count = count
|
||||||
|
|
||||||
|
def prune_audit_history(self, max_entries=None):
|
||||||
|
"""Trim location_history to max_entries. Returns number of entries removed."""
|
||||||
|
max_entries = max_entries or self.audit_max_history
|
||||||
|
history = self.db.location_history or []
|
||||||
|
if len(history) > max_entries:
|
||||||
|
removed = len(history) - max_entries
|
||||||
|
self.db.location_history = history[-max_entries:]
|
||||||
|
return removed
|
||||||
|
return 0
|
||||||
|
|
||||||
def at_pre_move(self, destination, **kwargs):
|
def at_pre_move(self, destination, **kwargs):
|
||||||
"""Called before moving - log departure."""
|
"""Called before moving - log departure."""
|
||||||
current = self.location
|
current = self.location
|
||||||
if current:
|
if current:
|
||||||
logger.log_info(f"AUDIT MOVE: {self.key} leaving {current.key} -> {destination.key if destination else 'None'}")
|
self._audit_log(
|
||||||
|
f"AUDIT MOVE: {self.key} leaving {current.key} "
|
||||||
|
f"-> {destination.key if destination else 'None'}"
|
||||||
|
)
|
||||||
return super().at_pre_move(destination, **kwargs)
|
return super().at_pre_move(destination, **kwargs)
|
||||||
|
|
||||||
def at_post_move(self, source_location, **kwargs):
|
def at_post_move(self, source_location, **kwargs):
|
||||||
"""Called after moving - record arrival in audit trail."""
|
"""Called after moving - record arrival in audit trail."""
|
||||||
destination = self.location
|
destination = self.location
|
||||||
timestamp = datetime.utcnow().isoformat()
|
timestamp = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
# Update location history
|
# Update location history
|
||||||
history = self.db.location_history or []
|
history = self.db.location_history or []
|
||||||
history.append({
|
history.append({
|
||||||
@@ -56,41 +94,55 @@ class AuditedCharacter(DefaultCharacter):
|
|||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
"coord": getattr(destination, 'db', {}).get('coord', None) if destination else None
|
"coord": getattr(destination, 'db', {}).get('coord', None) if destination else None
|
||||||
})
|
})
|
||||||
# Keep last 1000 movements
|
# Rotate: keep last audit_max_history movements
|
||||||
self.db.location_history = history[-1000:]
|
self.db.location_history = history[-self.audit_max_history:]
|
||||||
self.db.last_location = destination.key if destination else None
|
self.db.last_location = destination.key if destination else None
|
||||||
|
|
||||||
# Log to movement audit log
|
self._audit_log(
|
||||||
logger.log_info(f"AUDIT MOVE: {self.key} arrived at {destination.key if destination else 'None'} from {source_location.key if source_location else 'None'}")
|
f"AUDIT MOVE: {self.key} arrived at "
|
||||||
|
f"{destination.key if destination else 'None'} from "
|
||||||
|
f"{source_location.key if source_location else 'None'}"
|
||||||
|
)
|
||||||
|
|
||||||
super().at_post_move(source_location, **kwargs)
|
super().at_post_move(source_location, **kwargs)
|
||||||
|
|
||||||
def at_pre_cmd(self, cmd, args):
|
def at_pre_cmd(self, cmd, args):
|
||||||
"""Called before executing any command."""
|
"""Called before executing any command."""
|
||||||
# Increment command counter
|
# Increment command counter
|
||||||
self.db.command_count = (self.db.command_count or 0) + 1
|
self.db.command_count = (self.db.command_count or 0) + 1
|
||||||
self.db.last_command_time = datetime.utcnow().isoformat()
|
self.db.last_command_time = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
# Log command (excluding sensitive commands like password)
|
# Log command (excluding sensitive commands like password)
|
||||||
cmd_name = cmd.key if cmd else "unknown"
|
cmd_name = cmd.key if cmd else "unknown"
|
||||||
if cmd_name not in ("password", "@password"):
|
if cmd_name not in ("password", "@password"):
|
||||||
logger.log_info(f"AUDIT CMD: {self.key} executed '{cmd_name}' args: '{args[:50] if args else ''}'")
|
self._audit_log(
|
||||||
|
f"AUDIT CMD: {self.key} executed '{cmd_name}' "
|
||||||
|
f"args: '{args[:50] if args else ''}'"
|
||||||
|
)
|
||||||
|
|
||||||
super().at_pre_cmd(cmd, args)
|
super().at_pre_cmd(cmd, args)
|
||||||
|
|
||||||
def at_pre_puppet(self, account, session, **kwargs):
|
def at_pre_puppet(self, account, session, **kwargs):
|
||||||
"""Called when account takes control of character."""
|
"""Called when account takes control of character."""
|
||||||
self.db.session_start_time = time.time()
|
self.db.session_start_time = time.time()
|
||||||
logger.log_info(f"AUDIT SESSION: {self.key} puppeted by {account.key} at {datetime.utcnow()}")
|
self.db.audit_log_count = 0 # Reset log counter each session
|
||||||
|
self._audit_log(
|
||||||
|
f"AUDIT SESSION: {self.key} puppeted by {account.key} "
|
||||||
|
f"at {datetime.utcnow()}"
|
||||||
|
)
|
||||||
super().at_pre_puppet(account, session, **kwargs)
|
super().at_pre_puppet(account, session, **kwargs)
|
||||||
|
|
||||||
def at_post_unpuppet(self, account, session, **kwargs):
|
def at_post_unpuppet(self, account, session, **kwargs):
|
||||||
"""Called when account releases control of character."""
|
"""Called when account releases control of character."""
|
||||||
start_time = self.db.session_start_time
|
start_time = self.db.session_start_time
|
||||||
if start_time:
|
if start_time:
|
||||||
session_duration = time.time() - start_time
|
session_duration = time.time() - start_time
|
||||||
self.db.total_playtime = (self.db.total_playtime or 0) + session_duration
|
self.db.total_playtime = (self.db.total_playtime or 0) + session_duration
|
||||||
logger.log_info(f"AUDIT SESSION: {self.key} unpuppeted by {account.key} - session lasted {session_duration:.0f}s, total playtime {self.db.total_playtime:.0f}s")
|
self._audit_log(
|
||||||
|
f"AUDIT SESSION: {self.key} unpuppeted by {account.key} "
|
||||||
|
f"- session lasted {session_duration:.0f}s, "
|
||||||
|
f"total playtime {self.db.total_playtime:.0f}s"
|
||||||
|
)
|
||||||
self.db.session_start_time = None
|
self.db.session_start_time = None
|
||||||
super().at_post_unpuppet(account, session, **kwargs)
|
super().at_post_unpuppet(account, session, **kwargs)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user