fix: SESSION_FILE shadow bug + hooks matcher + 5 security/privacy mons

state.sh used SESSION_FILE for session.json (global tracking data).
Both hook handlers override SESSION_FILE to sessions/<pgrp>.json for
per-session isolation. buddymon_session_reset() at session-stop end
was writing _version:1 data INTO sessions/<pgrp>.json, leaving stale
wrong-format session files that broke buddy lookup.

Fix: rename state.sh variable to SESSION_DATA_FILE — no more collision.

Also:
- Add matcher:'*' to SessionStart and Stop hooks (CC 2.x compatibility)
- Add dead-session GC to session-start.sh (cleans up orphaned PGRP files)
- Add 5 new privacy/security bug_monsters:
  LeakWraith (uncommon) — exposed credentials
  CipherNull (uncommon) — weak/broken crypto (MD5, ECB, rand() for secrets)
  ConsentShadow (rare)  — tracking/analytics without consent (CF flagship villain)
  ThrottleDemon (common) — ignored 429s and missing backoff
  PrivacyLich (legendary) — GDPR/CCPA/breach debt; unkillable, only containable
This commit is contained in:
pyr0ball 2026-04-05 23:24:27 -07:00
parent caa655ab9a
commit 0c2d245632
5 changed files with 231 additions and 5 deletions

View file

@ -30,6 +30,28 @@ json.dump(session_state, open('${SESSION_FILE}', 'w'), indent=2)
PYEOF PYEOF
fi fi
# Clean up session files for dead processes (runs async-style; errors ignored)
python3 << 'PYEOF' 2>/dev/null &
import os, glob, json
for f in glob.glob(os.path.expanduser("~/.claude/buddymon/sessions/*.json")):
pid_str = os.path.basename(f).replace('.json', '')
try:
pid = int(pid_str)
# Check if the process group still exists
os.killpg(pid, 0)
except (ValueError, ProcessLookupError, PermissionError):
# PermissionError means the process exists (just not ours) — keep it
# ProcessLookupError means it's dead — remove
try:
d = json.load(open(f))
if '_version' not in d: # only remove valid-format dead sessions
os.remove(f)
except Exception:
pass
except Exception:
pass
PYEOF
ACTIVE_ID=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('buddymon_id',''))" 2>/dev/null) ACTIVE_ID=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('buddymon_id',''))" 2>/dev/null)
SESSION_XP=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('session_xp',0))" 2>/dev/null) SESSION_XP=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('session_xp',0))" 2>/dev/null)

View file

@ -41,7 +41,7 @@ import json, os
catalog_file = '${CATALOG}' catalog_file = '${CATALOG}'
session_state_file = '${SESSION_FILE}' session_state_file = '${SESSION_FILE}'
roster_file = '${BUDDYMON_DIR}/roster.json' roster_file = '${BUDDYMON_DIR}/roster.json'
session_file = '${BUDDYMON_DIR}/session.json' session_file = '${BUDDYMON_DIR}/session.json' # SESSION_DATA_FILE from state.sh
catalog = json.load(open(catalog_file)) catalog = json.load(open(catalog_file))
session_state = json.load(open(session_state_file)) session_state = json.load(open(session_state_file))
@ -132,7 +132,7 @@ import json, os
from datetime import datetime, timezone from datetime import datetime, timezone
session_state_file = '${SESSION_FILE}' session_state_file = '${SESSION_FILE}'
session_file = '${BUDDYMON_DIR}/session.json' session_file = '${BUDDYMON_DIR}/session.json' # SESSION_DATA_FILE from state.sh
encounters_file = '${BUDDYMON_DIR}/encounters.json' encounters_file = '${BUDDYMON_DIR}/encounters.json'
handoff_file = '${BUDDYMON_DIR}/handoff.json' handoff_file = '${BUDDYMON_DIR}/handoff.json'

View file

@ -3,6 +3,7 @@
"hooks": { "hooks": {
"SessionStart": [ "SessionStart": [
{ {
"matcher": "*",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
@ -38,6 +39,7 @@
], ],
"Stop": [ "Stop": [
{ {
"matcher": "*",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",

View file

@ -646,6 +646,206 @@
} }
], ],
"flavor": "If you catch everything, you learn nothing." "flavor": "If you catch everything, you learn nothing."
},
"LeakWraith": {
"id": "LeakWraith",
"display": "\ud83d\udd11 LeakWraith",
"type": "bug_monster",
"rarity": "uncommon",
"base_strength": 70,
"xp_reward": 120,
"catchable": true,
"defeatable": false,
"catch_requires": [
"rotate_secret",
"audit_exposure",
"add_secret_scan"
],
"description": "A specter that feasts on exposed credentials. It doesn't steal them \u2014 it just shows them to everyone.",
"error_patterns": [
"api_key",
"secret_key",
"BEGIN.*PRIVATE KEY",
"PRIVATE KEY-----",
"password\\s*=\\s*['\"][^'\"]+['\"]",
"token\\s*=\\s*['\"][^'\"]+['\"]",
"Authorization: Bearer",
"hardcoded.*secret",
"leaked.*credential",
"exposed.*secret",
"sk-[a-zA-Z0-9]{20}",
"credential.*plain"
],
"weaken_actions": [
{
"action": "rotate_secret",
"strength_reduction": 40
},
{
"action": "audit_exposure",
"strength_reduction": 20
},
{
"action": "add_secret_scan",
"strength_reduction": 20
}
],
"flavor": "Rotation is not optional. Neither is the audit."
},
"CipherNull": {
"id": "CipherNull",
"display": "\ud83d\udd10 CipherNull",
"type": "bug_monster",
"rarity": "uncommon",
"base_strength": 60,
"xp_reward": 100,
"catchable": true,
"defeatable": true,
"catch_requires": [
"replace_weak_crypto",
"add_crypto_test"
],
"description": "A demon born from broken encryption. Every MD5 it touches makes it stronger.",
"error_patterns": [
"md5\\(",
"sha1\\(",
"hashlib.md5",
"hashlib.sha1",
"DES.new",
"AES.*ECB",
"ECB mode",
"Math.random.*token",
"Math.random.*secret",
"rand().*password",
"random.*nonce",
"base64.*password",
"rot13"
],
"weaken_actions": [
{
"action": "replace_weak_crypto",
"strength_reduction": 50
},
{
"action": "add_crypto_test",
"strength_reduction": 25
}
],
"flavor": "MD5 was deprecated before some of your dependencies were written."
},
"ConsentShadow": {
"id": "ConsentShadow",
"display": "\ud83d\udc64 ConsentShadow",
"type": "bug_monster",
"rarity": "rare",
"base_strength": 85,
"xp_reward": 220,
"catchable": true,
"defeatable": false,
"catch_requires": [
"add_consent_gate",
"document_data_flow",
"add_opt_out"
],
"description": "A privacy violation given form. It tracks everything, logs everything, consents to nothing. At CircuitForge, it's the end boss.",
"error_patterns": [
"track.*user",
"user.*tracking",
"analytics.*event",
"fingerprint.*user",
"browser.*fingerprint",
"log.*email",
"log.*ip.*address",
"collect.*personal",
"behavioral.*profil",
"third.party.*tracking",
"telemetry.*enabled",
"send.*analytics"
],
"weaken_actions": [
{
"action": "add_consent_gate",
"strength_reduction": 35
},
{
"action": "document_data_flow",
"strength_reduction": 25
},
{
"action": "add_opt_out",
"strength_reduction": 30
}
],
"flavor": "Plain-language consent. Always. No pre-checked boxes."
},
"ThrottleDemon": {
"id": "ThrottleDemon",
"display": "\u23f1\ufe0f ThrottleDemon",
"type": "bug_monster",
"rarity": "common",
"base_strength": 40,
"xp_reward": 60,
"catchable": true,
"defeatable": true,
"catch_requires": [
"add_backoff",
"add_rate_limit_handling"
],
"description": "Manifests from ignored 429s. Feed it a proper backoff strategy.",
"error_patterns": [
"429",
"Too Many Requests",
"rate.limit.*exceeded",
"rate limit",
"quota.*exceeded",
"throttle",
"throttled",
"RetryAfter",
"Retry-After",
"backoff.*error",
"RateLimitError",
"burst.*limit"
],
"weaken_actions": [
{
"action": "add_backoff",
"strength_reduction": 35
},
{
"action": "add_rate_limit_handling",
"strength_reduction": 40
}
],
"flavor": "Exponential backoff with jitter. Every time."
},
"PrivacyLich": {
"id": "PrivacyLich",
"display": "\ud83d\udcdc PrivacyLich",
"type": "bug_monster",
"rarity": "legendary",
"base_strength": 100,
"xp_reward": 500,
"catchable": false,
"defeatable": false,
"catch_requires": [],
"description": "An ancient entity. Data it touched never truly disappears. Cannot be caught or defeated \u2014 only contained through rigorous compliance work.",
"error_patterns": [
"GDPR",
"CCPA",
"LGPD",
"erasure.*request",
"right to be forgotten",
"data subject",
"DSAR",
"data.*breach",
"personal.*data.*leak",
"PII.*exposed",
"regulatory.*violation",
"compliance.*failure",
"notification.*breach"
],
"weaken_actions": [],
"flavor": "Some debts cannot be paid. They can only be carried responsibly."
} }
}, },
"event_encounters": { "event_encounters": {

View file

@ -6,7 +6,9 @@ BUDDYMON_DIR="${HOME}/.claude/buddymon"
ROSTER_FILE="${BUDDYMON_DIR}/roster.json" ROSTER_FILE="${BUDDYMON_DIR}/roster.json"
ENCOUNTERS_FILE="${BUDDYMON_DIR}/encounters.json" ENCOUNTERS_FILE="${BUDDYMON_DIR}/encounters.json"
ACTIVE_FILE="${BUDDYMON_DIR}/active.json" ACTIVE_FILE="${BUDDYMON_DIR}/active.json"
SESSION_FILE="${BUDDYMON_DIR}/session.json" # Named SESSION_DATA_FILE (not SESSION_FILE) to avoid shadowing the
# per-session state file hooks define as SESSION_FILE=sessions/<pgrp>.json
SESSION_DATA_FILE="${BUDDYMON_DIR}/session.json"
buddymon_init() { buddymon_init() {
mkdir -p "${BUDDYMON_DIR}" mkdir -p "${BUDDYMON_DIR}"
@ -42,7 +44,7 @@ EOF
EOF EOF
fi fi
if [[ ! -f "${SESSION_FILE}" ]]; then if [[ ! -f "${SESSION_DATA_FILE}" ]]; then
buddymon_session_reset buddymon_session_reset
fi fi
} }
@ -50,7 +52,7 @@ EOF
buddymon_session_reset() { buddymon_session_reset() {
local ts local ts
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
cat > "${SESSION_FILE}" << EOF cat > "${SESSION_DATA_FILE}" << EOF
{ {
"_version": 1, "_version": 1,
"started_at": "${ts}", "started_at": "${ts}",