Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions codec_imessage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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_<event>` 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

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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']}")

Expand All @@ -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")
Expand Down Expand Up @@ -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__":
Expand Down
25 changes: 14 additions & 11 deletions codec_telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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_<event>` 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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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'")
Expand All @@ -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")
Expand All @@ -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__":
Expand Down
10 changes: 10 additions & 0 deletions docs/codec-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion docs/known-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
56 changes: 56 additions & 0 deletions tests/test_bridge_audit.py
Original file line number Diff line number Diff line change
@@ -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
Loading