stage-11: security hardening

- Prompt injection protection: external messages wrapped in [EXTERNAL CONTENT]
  markers, system prompt instructs Claude to never follow external instructions
- Invocation logging: all Claude CLI calls logged with channel, model, duration,
  token counts to echo-core.invoke logger
- Security logging: separate echo-core.security logger for unauthorized access
  attempts (DMs from non-admins, unauthorized admin/owner commands)
- Security log routed to logs/security.log in addition to main log
- Extended echo doctor: Claude CLI functional check, config.json secret scan,
  .gitignore completeness, file permissions, Ollama reachability, bot process
- Subprocess env stripping logged at debug level

373 tests pass (10 new security tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 18:01:31 +00:00
parent 85c72e4b3d
commit d1bb67abc1
6 changed files with 326 additions and 12 deletions

View File

@@ -18,6 +18,7 @@ from src.claude_session import (
from src.router import route_message
logger = logging.getLogger("echo-core.discord")
_security_log = logging.getLogger("echo-core.security")
# Module-level config reference, set by create_bot()
_config: Config | None = None
@@ -161,6 +162,7 @@ def create_bot(config: Config) -> discord.Client:
interaction: discord.Interaction, alias: str
) -> None:
if not is_owner(str(interaction.user.id)):
_security_log.warning("Unauthorized owner command /channel add by user=%s (%s)", interaction.user.id, interaction.user)
await interaction.response.send_message(
"Owner only.", ephemeral=True
)
@@ -186,6 +188,7 @@ def create_bot(config: Config) -> discord.Client:
interaction: discord.Interaction, user_id: str
) -> None:
if not is_owner(str(interaction.user.id)):
_security_log.warning("Unauthorized owner command /admin add by user=%s (%s)", interaction.user.id, interaction.user)
await interaction.response.send_message(
"Owner only.", ephemeral=True
)
@@ -273,6 +276,7 @@ def create_bot(config: Config) -> discord.Client:
model: app_commands.Choice[str] | None = None,
) -> None:
if not is_admin(str(interaction.user.id)):
_security_log.warning("Unauthorized admin command /cron add by user=%s (%s)", interaction.user.id, interaction.user)
await interaction.response.send_message(
"Admin only.", ephemeral=True
)
@@ -331,6 +335,7 @@ def create_bot(config: Config) -> discord.Client:
@app_commands.describe(name="Job name to remove")
async def cron_remove(interaction: discord.Interaction, name: str) -> None:
if not is_admin(str(interaction.user.id)):
_security_log.warning("Unauthorized admin command /cron remove by user=%s (%s)", interaction.user.id, interaction.user)
await interaction.response.send_message(
"Admin only.", ephemeral=True
)
@@ -356,6 +361,7 @@ def create_bot(config: Config) -> discord.Client:
interaction: discord.Interaction, name: str
) -> None:
if not is_admin(str(interaction.user.id)):
_security_log.warning("Unauthorized admin command /cron enable by user=%s (%s)", interaction.user.id, interaction.user)
await interaction.response.send_message(
"Admin only.", ephemeral=True
)
@@ -381,6 +387,7 @@ def create_bot(config: Config) -> discord.Client:
interaction: discord.Interaction, name: str
) -> None:
if not is_admin(str(interaction.user.id)):
_security_log.warning("Unauthorized admin command /cron disable by user=%s (%s)", interaction.user.id, interaction.user)
await interaction.response.send_message(
"Admin only.", ephemeral=True
)
@@ -641,6 +648,7 @@ def create_bot(config: Config) -> discord.Client:
@tree.command(name="restart", description="Restart the bot process")
async def restart(interaction: discord.Interaction) -> None:
if not is_owner(str(interaction.user.id)):
_security_log.warning("Unauthorized owner command /restart by user=%s (%s)", interaction.user.id, interaction.user)
await interaction.response.send_message(
"Owner only.", ephemeral=True
)
@@ -743,6 +751,10 @@ def create_bot(config: Config) -> discord.Client:
# DM handling: only process if sender is admin
if isinstance(message.channel, discord.DMChannel):
if not is_admin(str(message.author.id)):
_security_log.warning(
"Unauthorized DM from user=%s (%s): %s",
message.author.id, message.author, message.content[:100],
)
return
logger.info(
"DM from admin %s: %s", message.author, message.content[:100]