signal-bot/.pre-commit-config.yaml
Krzysztof kuhy Rudnicki 0f824c441e 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
2026-08-30 19:32:41 +02:00

128 lines
4.5 KiB
YAML

# Pre-commit hooks for signal-bot.
#
# Install with: pre-commit install
# Run manually: pre-commit run --all-files
#
# Every hook is `language: system` because the tools already live in .venv;
# pre-commit must not build isolated environments for them. fail_fast so a
# later check cannot run after an earlier one has already rejected the commit.
fail_fast: true
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-merge-conflict
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable
- id: detect-private-key
- id: debug-statements
- id: check-ast
- id: mixed-line-ending
args: [--fix=lf]
- repo: local
hooks:
# Suppressions are blocked mechanically, not by discipline. This repo
# has zero of them and the only way it stays that way is a gate: the
# rule is "fix the finding, or ask first", and a habit is not a gate.
- id: no-noqa
name: block noqa / type:ignore comments
entry: '(?i)#\s*(noqa|type:\s*ignore)'
language: pygrep
types: [python]
- id: no-ruff-noqa
name: block file-level ruff noqa
entry: '(?i)#\s*ruff:\s*noqa'
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
# this repo's idea of the rule cannot drift from every other repo's.
- id: file-length
name: file length <= 250 lines
entry: scripts/check_file_length.sh
language: system
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
- id: md-naming
name: markdown naming convention
entry: scripts/check_md_naming.sh
language: system
files: \.md$
# Exact pins at newest stable. Runs repo-wide: a pin goes stale because
# upstream released, not because this commit touched the manifest.
- id: dependency-freshness
name: dependencies are newest stable
entry: scripts/check_dependency_freshness.sh --all
language: system
pass_filenames: false
always_run: true
- id: ruff-format
name: ruff format
entry: .venv/bin/ruff format --check
language: system
types_or: [python, pyi]
- id: ruff
name: ruff (ALL rules)
entry: .venv/bin/ruff check --force-exclude
language: system
types_or: [python, pyi]
- id: mypy
name: mypy --strict
entry: .venv/bin/mypy
language: system
pass_filenames: false
args: [signal_bot, fake_signal]
- id: shellcheck
name: shellcheck
# Same severity floor as CI: a stricter runner version must not be
# the thing that discovers a finding this hook let through.
entry: shellcheck -x --severity=style
language: system
types: [shell]
# The watchdog is bash, so pytest cannot reach it. Its own suite stubs
# curl and docker and drives every branch, including the give-up cap.
- id: watchdog-tests
name: watchdog behaviour (bash)
entry: scripts/test_watchdog.sh
language: system
pass_filenames: false
files: ^scripts/(signal_api_watchdog|test_watchdog)\.sh$
# 100% branch coverage, no omits. Runs on every commit: coverage is the
# whole point of the suite, and a partial run cannot prove it.
- id: pytest
name: pytest (100% branch coverage)
entry: .venv/bin/python -m pytest -q
language: system
pass_filenames: false
always_run: true