Skip to content

Commit 1537748

Browse files
AVADSA25Mikarina13claude
authored
Bridges write signed audit lines without message text (#443)
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: Mickael Farina <farina.mickael@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 63c34fc commit 1537748

5 files changed

Lines changed: 95 additions & 23 deletions

File tree

‎codec_imessage.py‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
CONFIG_PATH = os.path.expanduser("~/.codec/config.json")
4242
MEMORY_DB = os.path.expanduser("~/.codec/memory.db")
4343
STATE_FILE = os.path.expanduser("~/.codec/imessage_state.json")
44-
AUDIT_LOG = os.path.expanduser("~/.codec/audit.log")
4544

4645
# ── Config ───────────────────────────────────────────────────────────────────
4746
def load_config():
@@ -102,11 +101,15 @@ def save_state(state):
102101

103102

104103
# ── Audit logging ────────────────────────────────────────────────────────────
105-
def audit(msg):
104+
def audit(event, **fields):
105+
"""One signed line in ~/.codec/audit.log through codec_audit (JSON, HMAC,
106+
redaction): `bridge_<event>` with who and how long, never the message text.
107+
It used to append a plain-text line, which verify_audit_log() counted as
108+
broken (docs/known-issues.md, 2026-10-07)."""
106109
try:
107-
os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
108-
with open(AUDIT_LOG, "a") as f:
109-
f.write(f"[{datetime.now().isoformat()}] IMESSAGE: {msg}\n")
110+
from codec_audit import log_event
111+
log_event(f"bridge_{event}", "codec-imessage", f"iMessage bridge: {event.replace('_', ' ')}",
112+
extra=dict(fields, bridge="imessage"))
110113
except Exception:
111114
pass
112115

@@ -873,7 +876,7 @@ def process_message(msg, im_cfg, llm_cfg):
873876
return
874877

875878
log.info(f"📨 From {sender}: {text[:80]}")
876-
audit(f"RECEIVED from={sender} text={text[:100]}")
879+
audit("message_received", sender=sender, length=len(text))
877880

878881
# ── Handle attachments ───────────────────────────────────────────────
879882
attachment_context = []
@@ -918,7 +921,7 @@ def process_message(msg, im_cfg, llm_cfg):
918921
reply = reply[:max_len - 3] + "..."
919922
if im_cfg.get("auto_reply", True):
920923
send_imessage(sender, reply)
921-
audit(f"SENT to={sender} text={reply[:100]}")
924+
audit("reply_sent", to=sender, length=len(reply))
922925
add_to_conversation(sender, "user", text)
923926
add_to_conversation(sender, "assistant", reply)
924927
save_to_memory(sender, text, reply)
@@ -947,7 +950,7 @@ def process_message(msg, im_cfg, llm_cfg):
947950
if im_cfg.get("auto_reply", True):
948951
success = send_imessage(sender, reply)
949952
if success:
950-
audit(f"SENT to={sender} text={reply[:100]}")
953+
audit("reply_sent", to=sender, length=len(reply))
951954
else:
952955
log.error(f"Failed to send reply to {sender}")
953956

@@ -1004,7 +1007,7 @@ def main():
10041007
else:
10051008
log.info("All senders allowed (configure imessage.allowed_senders to restrict)")
10061009

1007-
audit("SERVICE_START")
1010+
audit("service_start")
10081011
log.info("Triggers: 'Hey CODEC' or '/codec' — all other messages ignored")
10091012
log.info(f"Polling every {poll_interval}s | LLM: {llm_cfg['model']}")
10101013

@@ -1020,7 +1023,7 @@ def _imsg_cleanup():
10201023
save_state(state)
10211024
except Exception as e:
10221025
log.warning(f"shutdown state save failed: {e}")
1023-
audit("SERVICE_STOP")
1026+
audit("service_stop")
10241027
log.info("codec-imessage graceful shutdown")
10251028
import codec_lifecycle
10261029
codec_lifecycle.install_handlers(_imsg_cleanup, name="codec-imessage")
@@ -1060,7 +1063,7 @@ def _imsg_cleanup():
10601063
log.info("Shutting down...")
10611064
state["last_rowid"] = last_rowid
10621065
save_state(state)
1063-
audit("SERVICE_STOP")
1066+
audit("service_stop")
10641067

10651068

10661069
if __name__ == "__main__":

‎codec_telegram.py‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ def _sanitize_log(s):
5757
# ── Paths ────────────────────────────────────────────────────────────────────
5858
CONFIG_PATH = os.path.expanduser("~/.codec/config.json")
5959
MEMORY_DB = os.path.expanduser("~/.codec/memory.db")
60-
AUDIT_LOG = os.path.expanduser("~/.codec/audit.log")
6160

6261

6362
# ── Config ───────────────────────────────────────────────────────────────────
@@ -106,11 +105,15 @@ def get_llm_config(cfg):
106105

107106

108107
# ── Audit ────────────────────────────────────────────────────────────────────
109-
def audit(msg):
108+
def audit(event, **fields):
109+
"""One signed line in ~/.codec/audit.log through codec_audit (JSON, HMAC,
110+
redaction): `bridge_<event>` with who and how long, never the message text.
111+
It used to append a plain-text line, which verify_audit_log() counted as
112+
broken (docs/known-issues.md, 2026-10-07)."""
110113
try:
111-
os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
112-
with open(AUDIT_LOG, "a") as f:
113-
f.write(f"[{datetime.now().isoformat()}] TELEGRAM: {msg}\n")
114+
from codec_audit import log_event
115+
log_event(f"bridge_{event}", "codec-telegram", f"Telegram bridge: {event.replace('_', ' ')}",
116+
extra=dict(fields, bridge="telegram"))
114117
except Exception:
115118
pass
116119

@@ -582,7 +585,7 @@ def process_message(bot, update, tg_cfg, llm_cfg):
582585
add_history(chat_id, "user", f"[Photo] {caption}" if caption else "[Photo]")
583586
add_history(chat_id, "assistant", result)
584587
save_to_memory(chat_id, f"[Photo] {caption}", result)
585-
audit(f"PHOTO chat={chat_id} user={username}")
588+
audit("photo_received", chat=chat_id, user=username)
586589
return
587590
try:
588591
os.unlink(tmp)
@@ -633,7 +636,7 @@ def process_message(bot, update, tg_cfg, llm_cfg):
633636
return
634637

635638
log.info(f"📨 From {first_name} (@{username}): {text[:80]}")
636-
audit(f"RECEIVED chat={chat_id} user={username} text={text[:100]}")
639+
audit("message_received", chat=chat_id, user=username, length=len(text))
637640

638641
# Show typing
639642
bot.send_typing(chat_id)
@@ -733,7 +736,7 @@ def process_message(bot, update, tg_cfg, llm_cfg):
733736
# ── Send reply ───────────────────────────────────────────────────────
734737
success = bot.send_message(chat_id, reply, reply_to=msg_id)
735738
if success:
736-
audit(f"SENT chat={chat_id} text={reply[:100]}")
739+
audit("reply_sent", chat=chat_id, length=len(reply))
737740
else:
738741
log.error(f"Failed to send reply to chat {chat_id}")
739742

@@ -771,7 +774,7 @@ def main():
771774
log.error(f"Bot token verification failed: {e}")
772775
return
773776

774-
audit("SERVICE_START")
777+
audit("service_start")
775778
log.info(f"LLM: {llm_cfg['model']}")
776779
if tg_cfg.get("require_trigger"):
777780
log.info("Trigger required: 'Hey CODEC' or '/codec'")
@@ -782,7 +785,7 @@ def main():
782785
# in-memory (Telegram re-delivers un-acked updates), so there's no position
783786
# to persist — emit the SERVICE_STOP audit the Ctrl-C path already emits.
784787
def _tg_cleanup():
785-
audit("SERVICE_STOP")
788+
audit("service_stop")
786789
log.info("codec-telegram graceful shutdown")
787790
import codec_lifecycle
788791
codec_lifecycle.install_handlers(_tg_cleanup, name="codec-telegram")
@@ -798,7 +801,7 @@ def _tg_cleanup():
798801
log.error(f"Message processing error: {e}")
799802
except KeyboardInterrupt:
800803
log.info("Shutting down...")
801-
audit("SERVICE_STOP")
804+
audit("service_stop")
802805

803806

804807
if __name__ == "__main__":

‎docs/codec-architecture.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,16 @@ One event name, emitted by the heartbeat's PM2 restart-storm detector (`codec_he
203203
|---|---|---|---|
204204
| `pm2_restart_storm` | `codec-heartbeat` | warning | `process`, `delta` (restarts since last heartbeat), `total_restarts` |
205205

206+
### Bridge events (2026-10, iMessage and Telegram)
207+
`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.
208+
209+
| Event | Source | level | extra fields |
210+
|---|---|---|---|
211+
| `bridge_service_start` / `bridge_service_stop` | `codec-imessage`, `codec-telegram` | info | `bridge` |
212+
| `bridge_message_received` | same | info | `bridge`, `sender` (iMessage) or `chat` + `user` (Telegram), `length` |
213+
| `bridge_reply_sent` | same | info | `bridge`, `to` (iMessage) or `chat` (Telegram), `length` |
214+
| `bridge_photo_received` | `codec-telegram` | info | `bridge`, `chat`, `user` |
215+
206216
### Phase 2 Step 6 audit events (Trigger System)
207217
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`.
208218

‎docs/known-issues.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ personal name in the public repo, while every other memory path uses the default
399399
structured write in P3.12 (Learning page), which left this line as it was. Fix: use the default user id (or
400400
`config.json`'s), regenerate the skill manifest, and decide whether the existing rows need it changed.
401401

402-
## The iMessage and Telegram bridges write plain text into the audit log (2026-10-07)
402+
## 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)
403403

404404
`codec_imessage.py` and `codec_telegram.py` each have their own `audit(msg)` that
405405
appends a plain line such as `[2026-10-07T08:54:09] IMESSAGE: SERVICE_START`

‎tests/test_bridge_audit.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""The iMessage and Telegram bridges write signed audit lines, not plain text.
2+
3+
Each bridge had its own `audit(msg)` that appended `[time] IMESSAGE: ...` to
4+
~/.codec/audit.log, with the first 100 characters of every message. Those lines
5+
have no JSON or HMAC, so verify_audit_log() counted each one as broken
6+
(docs/known-issues.md, 2026-10-07). They now go through codec_audit.log_event
7+
with who and how long, never the text.
8+
"""
9+
from __future__ import annotations
10+
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
REPO = Path(__file__).resolve().parent.parent
16+
17+
18+
@pytest.fixture
19+
def emitted(monkeypatch):
20+
import codec_audit
21+
seen = []
22+
monkeypatch.setattr(codec_audit, "log_event", lambda *a, **k: seen.append((a, k)))
23+
return seen
24+
25+
26+
@pytest.mark.parametrize("mod_name, source", [("codec_imessage", "codec-imessage"), ("codec_telegram", "codec-telegram")])
27+
def test_a_bridge_event_is_one_structured_line_without_text(emitted, mod_name, source):
28+
mod = __import__(mod_name)
29+
mod.audit("message_received", sender="+10000000000", length=42)
30+
(args, kwargs), = emitted
31+
assert args[0] == "bridge_message_received" and args[1] == source
32+
assert kwargs["extra"]["length"] == 42 and kwargs["extra"]["bridge"] in ("imessage", "telegram")
33+
34+
35+
@pytest.mark.parametrize("fname", ["codec_imessage.py", "codec_telegram.py"])
36+
def test_no_plain_text_writer_and_no_message_text_left(fname):
37+
src = (REPO / fname).read_text(encoding="utf-8")
38+
assert "AUDIT_LOG" not in src, "no direct writes to the audit log"
39+
for leak in ("text={text", "text={reply", "audit(f\""):
40+
assert leak not in src, f"{fname}: {leak}"
41+
42+
43+
def test_the_audit_log_verifies_after_bridge_events(tmp_path, monkeypatch):
44+
import codec_audit
45+
import codec_imessage
46+
import codec_telegram
47+
monkeypatch.setattr(codec_audit, "_AUDIT_DIR", tmp_path)
48+
monkeypatch.setattr(codec_audit, "_AUDIT_LOG", tmp_path / "audit.log")
49+
codec_imessage.audit("service_start")
50+
codec_telegram.audit("reply_sent", chat="123", length=5)
51+
lines = (tmp_path / "audit.log").read_text().splitlines()
52+
assert len(lines) == 2 and all(line.startswith("{") for line in lines)
53+
r = codec_audit.verify_audit_log(str(tmp_path / "audit.log"))
54+
if r.get("error"): # no signing secret on this machine (CI): the lines being JSON is what failed before
55+
return
56+
assert r["broken_lines"] == 0 and r["total_lines"] == 2

0 commit comments

Comments
 (0)