Compare commits
8 Commits
fix/18-har
...
step35/16-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41db382761 | ||
| 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
|
||||||
|
|||||||
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,6 +11,10 @@ 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):
|
||||||
"""
|
"""
|
||||||
@@ -20,9 +25,16 @@ class AuditedCharacter(DefaultCharacter):
|
|||||||
- 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()
|
||||||
@@ -33,14 +45,40 @@ class AuditedCharacter(DefaultCharacter):
|
|||||||
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):
|
||||||
@@ -56,12 +94,15 @@ 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)
|
||||||
|
|
||||||
@@ -74,14 +115,21 @@ class AuditedCharacter(DefaultCharacter):
|
|||||||
# 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):
|
||||||
@@ -90,7 +138,11 @@ class AuditedCharacter(DefaultCharacter):
|
|||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os, sys
|
import os, sys
|
||||||
os.environ["DJANGO_SETTINGS_MODULE"] = "server.conf.settings"
|
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
|
import django
|
||||||
django.setup()
|
django.setup()
|
||||||
from evennia.objects.models import ObjectDB
|
from evennia.objects.models import ObjectDB
|
||||||
|
|||||||
Reference in New Issue
Block a user