From 5994d14015e491fa3c5b872b306d303c812a2e3c Mon Sep 17 00:00:00 2001 From: Mickael Farina Date: Wed, 7 Oct 2026 13:51:28 +0700 Subject: [PATCH] Bridges write signed audit lines without message text codec_imessage and codec_telegram each appended a plain-text line to ~/.codec/audit.log (`[time] IMESSAGE: RECEIVED from=... text=...`), with the first 100 characters of each message. verify_audit_log() counted every one as broken, so Settings > Audit reported a failed integrity check after each bridge restart. They now emit bridge_service_start/stop, bridge_message_received, bridge_reply_sent and bridge_photo_received through codec_audit.log_event: who and how long, never the text. Event table in docs/codec-architecture.md. Co-Authored-By: Claude Opus 5.5 --- codec_imessage.py | 25 +++++++++-------- codec_telegram.py | 25 +++++++++-------- docs/codec-architecture.md | 10 +++++++ docs/known-issues.md | 2 +- tests/test_bridge_audit.py | 56 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 23 deletions(-) create mode 100644 tests/test_bridge_audit.py diff --git a/codec_imessage.py b/codec_imessage.py index bf33a38..e52b405 100644 --- a/codec_imessage.py +++ b/codec_imessage.py @@ -41,7 +41,6 @@ CONFIG_PATH = os.path.expanduser("~/.codec/config.json") MEMORY_DB = os.path.expanduser("~/.codec/memory.db") STATE_FILE = os.path.expanduser("~/.codec/imessage_state.json") -AUDIT_LOG = os.path.expanduser("~/.codec/audit.log") # ── Config ─────────────────────────────────────────────────────────────────── def load_config(): @@ -102,11 +101,15 @@ def save_state(state): # ── Audit logging ──────────────────────────────────────────────────────────── -def audit(msg): +def audit(event, **fields): + """One signed line in ~/.codec/audit.log through codec_audit (JSON, HMAC, + redaction): `bridge_` with who and how long, never the message text. + It used to append a plain-text line, which verify_audit_log() counted as + broken (docs/known-issues.md, 2026-10-07).""" try: - os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True) - with open(AUDIT_LOG, "a") as f: - f.write(f"[{datetime.now().isoformat()}] IMESSAGE: {msg}\n") + from codec_audit import log_event + log_event(f"bridge_{event}", "codec-imessage", f"iMessage bridge: {event.replace('_', ' ')}", + extra=dict(fields, bridge="imessage")) except Exception: pass @@ -873,7 +876,7 @@ def process_message(msg, im_cfg, llm_cfg): return log.info(f"📨 From {sender}: {text[:80]}") - audit(f"RECEIVED from={sender} text={text[:100]}") + audit("message_received", sender=sender, length=len(text)) # ── Handle attachments ─────────────────────────────────────────────── attachment_context = [] @@ -918,7 +921,7 @@ def process_message(msg, im_cfg, llm_cfg): reply = reply[:max_len - 3] + "..." if im_cfg.get("auto_reply", True): send_imessage(sender, reply) - audit(f"SENT to={sender} text={reply[:100]}") + audit("reply_sent", to=sender, length=len(reply)) add_to_conversation(sender, "user", text) add_to_conversation(sender, "assistant", reply) save_to_memory(sender, text, reply) @@ -947,7 +950,7 @@ def process_message(msg, im_cfg, llm_cfg): if im_cfg.get("auto_reply", True): success = send_imessage(sender, reply) if success: - audit(f"SENT to={sender} text={reply[:100]}") + audit("reply_sent", to=sender, length=len(reply)) else: log.error(f"Failed to send reply to {sender}") @@ -1004,7 +1007,7 @@ def main(): else: log.info("All senders allowed (configure imessage.allowed_senders to restrict)") - audit("SERVICE_START") + audit("service_start") log.info("Triggers: 'Hey CODEC' or '/codec' — all other messages ignored") log.info(f"Polling every {poll_interval}s | LLM: {llm_cfg['model']}") @@ -1020,7 +1023,7 @@ def _imsg_cleanup(): save_state(state) except Exception as e: log.warning(f"shutdown state save failed: {e}") - audit("SERVICE_STOP") + audit("service_stop") log.info("codec-imessage graceful shutdown") import codec_lifecycle codec_lifecycle.install_handlers(_imsg_cleanup, name="codec-imessage") @@ -1060,7 +1063,7 @@ def _imsg_cleanup(): log.info("Shutting down...") state["last_rowid"] = last_rowid save_state(state) - audit("SERVICE_STOP") + audit("service_stop") if __name__ == "__main__": diff --git a/codec_telegram.py b/codec_telegram.py index e5a2ae4..4bc9376 100644 --- a/codec_telegram.py +++ b/codec_telegram.py @@ -57,7 +57,6 @@ def _sanitize_log(s): # ── Paths ──────────────────────────────────────────────────────────────────── CONFIG_PATH = os.path.expanduser("~/.codec/config.json") MEMORY_DB = os.path.expanduser("~/.codec/memory.db") -AUDIT_LOG = os.path.expanduser("~/.codec/audit.log") # ── Config ─────────────────────────────────────────────────────────────────── @@ -106,11 +105,15 @@ def get_llm_config(cfg): # ── Audit ──────────────────────────────────────────────────────────────────── -def audit(msg): +def audit(event, **fields): + """One signed line in ~/.codec/audit.log through codec_audit (JSON, HMAC, + redaction): `bridge_` with who and how long, never the message text. + It used to append a plain-text line, which verify_audit_log() counted as + broken (docs/known-issues.md, 2026-10-07).""" try: - os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True) - with open(AUDIT_LOG, "a") as f: - f.write(f"[{datetime.now().isoformat()}] TELEGRAM: {msg}\n") + from codec_audit import log_event + log_event(f"bridge_{event}", "codec-telegram", f"Telegram bridge: {event.replace('_', ' ')}", + extra=dict(fields, bridge="telegram")) except Exception: pass @@ -582,7 +585,7 @@ def process_message(bot, update, tg_cfg, llm_cfg): add_history(chat_id, "user", f"[Photo] {caption}" if caption else "[Photo]") add_history(chat_id, "assistant", result) save_to_memory(chat_id, f"[Photo] {caption}", result) - audit(f"PHOTO chat={chat_id} user={username}") + audit("photo_received", chat=chat_id, user=username) return try: os.unlink(tmp) @@ -633,7 +636,7 @@ def process_message(bot, update, tg_cfg, llm_cfg): return log.info(f"📨 From {first_name} (@{username}): {text[:80]}") - audit(f"RECEIVED chat={chat_id} user={username} text={text[:100]}") + audit("message_received", chat=chat_id, user=username, length=len(text)) # Show typing bot.send_typing(chat_id) @@ -733,7 +736,7 @@ def process_message(bot, update, tg_cfg, llm_cfg): # ── Send reply ─────────────────────────────────────────────────────── success = bot.send_message(chat_id, reply, reply_to=msg_id) if success: - audit(f"SENT chat={chat_id} text={reply[:100]}") + audit("reply_sent", chat=chat_id, length=len(reply)) else: log.error(f"Failed to send reply to chat {chat_id}") @@ -771,7 +774,7 @@ def main(): log.error(f"Bot token verification failed: {e}") return - audit("SERVICE_START") + audit("service_start") log.info(f"LLM: {llm_cfg['model']}") if tg_cfg.get("require_trigger"): log.info("Trigger required: 'Hey CODEC' or '/codec'") @@ -782,7 +785,7 @@ def main(): # in-memory (Telegram re-delivers un-acked updates), so there's no position # to persist — emit the SERVICE_STOP audit the Ctrl-C path already emits. def _tg_cleanup(): - audit("SERVICE_STOP") + audit("service_stop") log.info("codec-telegram graceful shutdown") import codec_lifecycle codec_lifecycle.install_handlers(_tg_cleanup, name="codec-telegram") @@ -798,7 +801,7 @@ def _tg_cleanup(): log.error(f"Message processing error: {e}") except KeyboardInterrupt: log.info("Shutting down...") - audit("SERVICE_STOP") + audit("service_stop") if __name__ == "__main__": diff --git a/docs/codec-architecture.md b/docs/codec-architecture.md index e31b4bc..8d473f1 100644 --- a/docs/codec-architecture.md +++ b/docs/codec-architecture.md @@ -203,6 +203,16 @@ One event name, emitted by the heartbeat's PM2 restart-storm detector (`codec_he |---|---|---|---| | `pm2_restart_storm` | `codec-heartbeat` | warning | `process`, `delta` (restarts since last heartbeat), `total_restarts` | +### Bridge events (2026-10, iMessage and Telegram) +`codec_imessage.audit` and `codec_telegram.audit` emit through `codec_audit.log_event` (JSON, HMAC, redaction). Before 7 Oct 2026 they appended plain-text lines with the first 100 characters of each message, which `verify_audit_log()` counted as broken. Never the message text. + +| Event | Source | level | extra fields | +|---|---|---|---| +| `bridge_service_start` / `bridge_service_stop` | `codec-imessage`, `codec-telegram` | info | `bridge` | +| `bridge_message_received` | same | info | `bridge`, `sender` (iMessage) or `chat` + `user` (Telegram), `length` | +| `bridge_reply_sent` | same | info | `bridge`, `to` (iMessage) or `chat` (Telegram), `length` | +| `bridge_photo_received` | `codec-telegram` | info | `bridge`, `chat`, `user` | + ### Phase 2 Step 6 audit events (Trigger System) Four event names. `trigger_evaluated` fires only when a pattern matches (pre-cooldown, pre-consent — silent on no-match to avoid audit spam). `trigger_fired` is the actual dispatch. `trigger_blocked` fires for any non-firing reason except `killed` (silent). `trigger_muted` fires when an otherwise-eligible match is suppressed by the runtime mute config (`~/.codec/triggers.json` — see `docs/PHASE2-STEP6-TRIGGER-MUTE.md`). All inherit the wrapping observer poll's `correlation_id`. diff --git a/docs/known-issues.md b/docs/known-issues.md index c2dacb3..d796637 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -399,7 +399,7 @@ personal name in the public repo, while every other memory path uses the default structured write in P3.12 (Learning page), which left this line as it was. Fix: use the default user id (or `config.json`'s), regenerate the skill manifest, and decide whether the existing rows need it changed. -## The iMessage and Telegram bridges write plain text into the audit log (2026-10-07) +## The iMessage and Telegram bridges write plain text into the audit log (2026-10-07) — FIXED (bridge events go through codec_audit, docs/codec-architecture.md) `codec_imessage.py` and `codec_telegram.py` each have their own `audit(msg)` that appends a plain line such as `[2026-10-07T08:54:09] IMESSAGE: SERVICE_START` diff --git a/tests/test_bridge_audit.py b/tests/test_bridge_audit.py new file mode 100644 index 0000000..4032805 --- /dev/null +++ b/tests/test_bridge_audit.py @@ -0,0 +1,56 @@ +"""The iMessage and Telegram bridges write signed audit lines, not plain text. + +Each bridge had its own `audit(msg)` that appended `[time] IMESSAGE: ...` to +~/.codec/audit.log, with the first 100 characters of every message. Those lines +have no JSON or HMAC, so verify_audit_log() counted each one as broken +(docs/known-issues.md, 2026-10-07). They now go through codec_audit.log_event +with who and how long, never the text. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent + + +@pytest.fixture +def emitted(monkeypatch): + import codec_audit + seen = [] + monkeypatch.setattr(codec_audit, "log_event", lambda *a, **k: seen.append((a, k))) + return seen + + +@pytest.mark.parametrize("mod_name, source", [("codec_imessage", "codec-imessage"), ("codec_telegram", "codec-telegram")]) +def test_a_bridge_event_is_one_structured_line_without_text(emitted, mod_name, source): + mod = __import__(mod_name) + mod.audit("message_received", sender="+10000000000", length=42) + (args, kwargs), = emitted + assert args[0] == "bridge_message_received" and args[1] == source + assert kwargs["extra"]["length"] == 42 and kwargs["extra"]["bridge"] in ("imessage", "telegram") + + +@pytest.mark.parametrize("fname", ["codec_imessage.py", "codec_telegram.py"]) +def test_no_plain_text_writer_and_no_message_text_left(fname): + src = (REPO / fname).read_text(encoding="utf-8") + assert "AUDIT_LOG" not in src, "no direct writes to the audit log" + for leak in ("text={text", "text={reply", "audit(f\""): + assert leak not in src, f"{fname}: {leak}" + + +def test_the_audit_log_verifies_after_bridge_events(tmp_path, monkeypatch): + import codec_audit + import codec_imessage + import codec_telegram + monkeypatch.setattr(codec_audit, "_AUDIT_DIR", tmp_path) + monkeypatch.setattr(codec_audit, "_AUDIT_LOG", tmp_path / "audit.log") + codec_imessage.audit("service_start") + codec_telegram.audit("reply_sent", chat="123", length=5) + lines = (tmp_path / "audit.log").read_text().splitlines() + assert len(lines) == 2 and all(line.startswith("{") for line in lines) + r = codec_audit.verify_audit_log(str(tmp_path / "audit.log")) + if r.get("error"): # no signing secret on this machine (CI): the lines being JSON is what failed before + return + assert r["broken_lines"] == 0 and r["total_lines"] == 2