Compare commits

...

9 Commits

Author SHA1 Message Date
Claude Code
41db382761 fix(world): replace hardcoded path in fix_world.py with dynamic derivation
world/fix_world.py line 3 used hardcoded '/root/workspace/timmy-academy'.
Changed to use os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
to derive project root dynamically, fixing non-VPS deployments.

Closes #16
2026-04-25 22:16:15 -04:00
d8600345b5 Merge PR #23: fix: Add audit log rotation to prevent unbounded growth (closes #10)
Merged by automated sweep after diff review and verification. PR #23: fix: Add audit log rotation to prevent unbounded growth (closes #10)
2026-04-22 02:38:58 +00:00
bbc73ff632 Merge pull request 'fix: NPC permissions audit and restrictions (#11)' (#22) from fix/11 into master 2026-04-21 15:25:44 +00:00
Alexander Whitestone
ff3d9ff238 fix: Add audit log rotation to prevent unbounded growth (closes #10)
- Add _audit_log() with per-session rate limiting (default 100 entries)
- Configurable audit_max_history (500) and audit_max_log_entries (100)
- Add prune_audit_history() for manual trimming
- Reset log counter on each puppet session
- Replace hardcoded 1000 cap with configurable audit_max_history
2026-04-21 03:13:09 -04:00
827d08ea21 fix(#11): NPC permissions audit and restrictions
Audit of Hermes bridge NPC permissions:
- Identified 5 excessive permissions
- Recommended least-privilege model
- Documented risks and fixes

Closes #11
2026-04-17 06:10:59 +00:00
3afdec9019 Merge PR #21
Merged PR #21: security: add .env to gitignore
2026-04-17 01:52:14 +00:00
Metatron
815f7d38e8 security: add .env to gitignore, create .env.example (#17)
hermes-agent/.env contained API credentials committed to repo.

Fix:
- Add .env to .gitignore (prevent future commits)
- Create .env.example with placeholders
- NOTE: Exposed credentials need immediate rotation
2026-04-15 21:56:18 -04:00
0aa6699356 Merge PR #20: fix: Replace hardcoded path with dynamic derivatio 2026-04-15 06:17:27 +00:00
37cecdf95a fix: Replace hardcoded path with dynamic derivation (closes #18) 2026-04-15 03:45:02 +00:00
6 changed files with 172 additions and 26 deletions

5
.gitignore vendored
View File

@@ -54,3 +54,8 @@ nosetests.xml
# VSCode config
.vscode
# Environment variables — never commit secrets
.env
*.env
!.env.example

View 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
View 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

View File

@@ -2,7 +2,8 @@
AuditedCharacter - A character typeclass with full audit logging.
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
@@ -10,44 +11,81 @@ from datetime import datetime
from evennia import DefaultCharacter
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):
"""
Character typeclass with comprehensive audit logging.
Tracks:
- Every room entered/exited with timestamps
- Total playtime
- Command count
- 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):
"""Set up audit attributes when character is created."""
super().at_object_creation()
# Initialize audit tracking attributes
self.db.location_history = [] # List of {room, timestamp, action}
self.db.command_count = 0
self.db.total_playtime = 0 # in seconds
self.db.session_start_time = 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()}")
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):
"""Called before moving - log departure."""
current = self.location
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)
def at_post_move(self, source_location, **kwargs):
"""Called after moving - record arrival in audit trail."""
destination = self.location
timestamp = datetime.utcnow().isoformat()
# Update location history
history = self.db.location_history or []
history.append({
@@ -56,41 +94,55 @@ class AuditedCharacter(DefaultCharacter):
"timestamp": timestamp,
"coord": getattr(destination, 'db', {}).get('coord', None) if destination else None
})
# Keep last 1000 movements
self.db.location_history = history[-1000:]
# Rotate: keep last audit_max_history movements
self.db.location_history = history[-self.audit_max_history:]
self.db.last_location = destination.key if destination else None
# Log to movement 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'}")
self._audit_log(
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)
def at_pre_cmd(self, cmd, args):
"""Called before executing any command."""
# Increment command counter
self.db.command_count = (self.db.command_count or 0) + 1
self.db.last_command_time = datetime.utcnow().isoformat()
# Log command (excluding sensitive commands like password)
cmd_name = cmd.key if cmd else "unknown"
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)
def at_pre_puppet(self, account, session, **kwargs):
"""Called when account takes control of character."""
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)
def at_post_unpuppet(self, account, session, **kwargs):
"""Called when account releases control of character."""
start_time = self.db.session_start_time
if start_time:
session_duration = time.time() - start_time
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
super().at_post_unpuppet(account, session, **kwargs)

View File

@@ -1,6 +1,6 @@
import os, sys
os.environ["DJANGO_SETTINGS_MODULE"] = "server.conf.settings"
sys.path.insert(0, "/root/workspace/timmy-academy")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import django
django.setup()
from evennia.objects.models import ObjectDB

View File

@@ -9,7 +9,7 @@ and configures the Public channel.
Safe to rerun (idempotent).
Usage:
cd /root/workspace/timmy-academy
cd /path/to/timmy-academy
source /root/workspace/evennia-venv/bin/activate
python world/rebuild_world.py
"""
@@ -19,7 +19,7 @@ import re
import ast
os.environ["DJANGO_SETTINGS_MODULE"] = "server.conf.settings"
sys.path.insert(0, "/root/workspace/timmy-academy")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import django
django.setup()