feat: block binaries from a public repo, and write down the real attachment frame

Two halves of the same ask: the message database is private and must
stay that way.

The database was already gitignored, and the event table is pinned by a
test that asserts its exact column set so no message body can be quietly
added. What was missing was any defence against a binary reaching this
*public* repo at all -- and the proof it was missing was sitting in the
tree: three `bottles/**/indexeddb.sqlite` files, committed in "register
the bot as its own Signal account". Their contents turned out to be
harmless (one row, 120 bytes, a userPreferences store from
developer.android.com), but the path into history was real.

So the shared gate from ~/utils is wired in, the three files are
untracked, and .gitignore now blocks *.sqlite3 along with its -wal and
-shm companions rather than only the one filename.

The second half is the attachment envelope, captured from the live API
rather than assumed -- which is what 0.0.3 taught. A caption-less PNG
posted in the real group confirmed `attachments[].contentType`, and
settled something that was not guessable: a photo with no caption sends
`message: null`. That is exactly the frame `should_count` used to value
at zero.

Verified end to end on production data, not a fixture: the frame at
19:31:16 was recorded as media_kind=image, media_count=1, char_len=0,
filed under 2026-08-30 hour 19.

The doc also records the fields nothing reads yet, and one open question
left deliberately open: `caption` is a field on the attachment and was
null here, so whether a captioned photo puts its text in `message` or in
`attachments[].caption` has not been observed. If it is the latter,
char_len is recording 0 for those. DEBUG stays on until a `caption: str`
settles it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
This commit is contained in:
Krzysztof Rudnicki 2026-08-30 19:32:41 +02:00
parent 50f6a587c2
commit 0f824c441e
7 changed files with 91 additions and 0 deletions

9
.gitignore vendored
View File

@ -166,3 +166,12 @@ cython_debug/
*.jpg
*.gif
nohup.out
# The message database is private and never leaves this machine. A live
# SQLite database is three files, so all three are named.
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
# Wine/Bottles scratch dir, never part of the bot.
bottles/

View File

@ -45,6 +45,16 @@ repos:
language: pygrep
types: [python]
# This repository is public and holds a private message database. The
# database is gitignored, but three binary SQLite files reached the
# history anyway before this gate existed, so .gitignore is evidently
# not the whole defence. Exceptions go in .binary-allowlist.
- id: no-binaries
name: no binary or image files
entry: scripts/check_no_binaries.sh
language: script
pass_filenames: true
# The shared 250-line cap. Checks only the files in the commit, so an
# unrelated commit never breaks. No baseline, no allowlist -- those are
# suppressions. The exemption list lives with the checker in ~/utils so

View File

@ -28,6 +28,52 @@ Reference for the two JSON frames `signal-cli-rest-api` pushes down
}
```
## A message with an attachment
Captured from the live API on 2026-08-30 with `LOG_LEVEL=DEBUG`, which makes
the dispatcher log each frame's shape (keys and types, never values -- see
`signal_bot/envelope_shape.py`). This is a caption-less PNG posted in the real
group, reproduced exactly as the logger described it:
```
{account: str, envelope: {
dataMessage: {
attachments: [{caption: null, contentType: 'image/png', filename: null,
height: int, id: str, size: int, uploadTimestamp: int,
width: int} x1],
expiresInSeconds: int,
groupInfo: {groupId: str, groupName: str, revision: int, type: 'DELIVER'},
isExpirationUpdate: bool,
message: null,
timestamp: int,
viewOnce: bool},
serverDeliveredTimestamp: int, serverReceivedTimestamp: int,
source: str, sourceDevice: int, sourceName: str,
sourceNumber: str, sourceUuid: str, timestamp: int}}
```
Three things this settles, none of which were guessable:
- `attachments` is a list of objects and the type lives in `contentType`. That
is what `models._attachments` reads and what `counting.media_kind` maps.
- **A caption-less photo sends `message: null`**, not `""` and not an absent
key. Before 0.1.0 `should_count` ended in `bool(message.text)`, so every one
of these was silently worth nothing.
- Read them off the *resolved* payload from `_payload()`, never off
`envelope["dataMessage"]` -- see "The trap this caused" below.
Fields nothing reads yet, recorded so the next person does not have to capture
them again: `expiresInSeconds`, `isExpirationUpdate`, `viewOnce`,
`groupInfo.groupName`, `groupInfo.revision`, and the attachment's `caption`,
`filename`, `width`, `height`, `size` and `uploadTimestamp`.
**Open question, deliberately not assumed.** `caption` is a field on the
attachment, and it was `null` here because this photo had none. Whether a
photo *with* a caption puts the text in `message` or in `attachments[].caption`
has not been observed yet. `char_len` currently measures `message` only, so if
it turns out to be the latter, captioned photos are recording a length of 0.
Leave `LOG_LEVEL=DEBUG` on and watch for `caption: str` to settle it.
## Somebody changed the membership: `groupInfo.type` is `UPDATE`
The only frame that names *who* added the bot to a group, which is what

26
scripts/check_no_binaries.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/bash
# ============================================================================
# Thin delegate to the shared gate in ~/utils.
#
# Copying the logic here is what lets one repo's idea of the rule drift from
# every other repo's, so this script only locates the shared checker and
# forwards its arguments.
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_no_binaries.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"