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:
66
cli.py
66
cli.py
@@ -82,11 +82,14 @@ def _load_sessions_file() -> dict:
|
||||
|
||||
def cmd_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
checks = []
|
||||
|
||||
# 1. Discord token present
|
||||
token = get_secret("discord_token")
|
||||
checks.append(("Discord token", bool(token)))
|
||||
checks.append(("Discord token in keyring", bool(token)))
|
||||
|
||||
# 2. Keyring working
|
||||
try:
|
||||
@@ -96,9 +99,17 @@ def cmd_doctor(args):
|
||||
except Exception:
|
||||
checks.append(("Keyring accessible", False))
|
||||
|
||||
# 3. Claude CLI found
|
||||
# 3. Claude CLI found and functional
|
||||
claude_found = shutil.which("claude") is not None
|
||||
checks.append(("Claude CLI found", claude_found))
|
||||
if claude_found:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "--version"], capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
checks.append(("Claude CLI functional", result.returncode == 0))
|
||||
except Exception:
|
||||
checks.append(("Claude CLI functional", False))
|
||||
|
||||
# 4. Disk space (warn if <1GB free)
|
||||
try:
|
||||
@@ -108,11 +119,19 @@ def cmd_doctor(args):
|
||||
except OSError:
|
||||
checks.append(("Disk space", False))
|
||||
|
||||
# 5. config.json valid
|
||||
# 5. config.json valid + no tokens/secrets in plain text
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
json.load(f)
|
||||
config_text = f.read()
|
||||
json.loads(config_text)
|
||||
checks.append(("config.json valid", True))
|
||||
# Scan for token-like patterns
|
||||
token_patterns = re.compile(
|
||||
r'(sk-[a-zA-Z0-9]{20,}|xoxb-|xoxp-|ghp_|gho_|discord.*token.*["\']:\s*["\'][A-Za-z0-9._-]{20,})',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
has_tokens = bool(token_patterns.search(config_text))
|
||||
checks.append(("config.json no plain text secrets", not has_tokens))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
checks.append(("config.json valid", False))
|
||||
|
||||
@@ -127,6 +146,45 @@ def cmd_doctor(args):
|
||||
except OSError:
|
||||
checks.append(("Logs dir writable", False))
|
||||
|
||||
# 7. .gitignore correct (must contain key entries)
|
||||
gitignore = PROJECT_ROOT / ".gitignore"
|
||||
required_gitignore = {"sessions/", "logs/", ".env", "*.sqlite"}
|
||||
try:
|
||||
gi_text = gitignore.read_text(encoding="utf-8")
|
||||
gi_lines = {l.strip() for l in gi_text.splitlines()}
|
||||
missing = required_gitignore - gi_lines
|
||||
checks.append((".gitignore complete", len(missing) == 0))
|
||||
if missing:
|
||||
print(f" (missing from .gitignore: {', '.join(sorted(missing))})")
|
||||
except FileNotFoundError:
|
||||
checks.append((".gitignore exists", False))
|
||||
|
||||
# 8. File permissions: sessions/ and config.json not world-readable
|
||||
for sensitive in [PROJECT_ROOT / "sessions", CONFIG_FILE]:
|
||||
if sensitive.exists():
|
||||
mode = sensitive.stat().st_mode
|
||||
world_read = mode & 0o004
|
||||
checks.append((f"{sensitive.name} not world-readable", not world_read))
|
||||
|
||||
# 9. Ollama reachable
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.urlopen("http://10.0.20.161:11434/api/tags", timeout=5)
|
||||
checks.append(("Ollama reachable", req.status == 200))
|
||||
except Exception:
|
||||
checks.append(("Ollama reachable", False))
|
||||
|
||||
# 10. Discord connection (bot PID running)
|
||||
pid_ok = False
|
||||
if PID_FILE.exists():
|
||||
try:
|
||||
pid = int(PID_FILE.read_text().strip())
|
||||
os.kill(pid, 0)
|
||||
pid_ok = True
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
checks.append(("Bot process running", pid_ok))
|
||||
|
||||
# Print results
|
||||
all_pass = True
|
||||
for label, passed in checks:
|
||||
|
||||
Reference in New Issue
Block a user