Harden attachment, log, and native handling

ober

f656044803d2d346dc5b654fb631b354c6e110eb

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9c4ed70..0619649 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
   verify:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Install system tools
         run: |
diff --git a/.github/workflows/security-baseline.yml b/.github/workflows/security-baseline.yml
index 28a713e..5381c31 100644
--- a/.github/workflows/security-baseline.yml
+++ b/.github/workflows/security-baseline.yml
@@ -13,7 +13,7 @@ jobs:
   baseline:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Required release files
         run: |
diff --git a/.jerbuild b/.jerbuild
index cbdb8cd..16af91f 100644
--- a/.jerbuild
+++ b/.jerbuild
@@ -9,6 +9,8 @@
 (output "jerboa-signal")
 (libdirs "." "vendor/jsqlite/src")
 (static-native #t)
+(extra-sources "support/signal_security.c")
+(ffi-symbols "support/ffi-symbols.list")
 (os-libs
   (Darwin "-lm -lpthread -lncurses -liconv -lc++")
   (Linux "-lm -ldl -lpthread -static -lncurses -lstdc++"))
diff --git a/Makefile b/Makefile
index 128a58c..0d07ed2 100644
--- a/Makefile
+++ b/Makefile
@@ -17,7 +17,12 @@ JH = $(shell "$(JERBUILD)" --jerboa-home 2>/dev/null)
 VENDOR ?= $(CURDIR)/vendor
 JSQLITE_REPO ?= $(VENDOR)/jsqlite
 JSQLITE_URL ?= https://git.sr.ht/~lisp/jsqlite
+JSQLITE_REV ?= 63d737101b3b74bff3b9db75cc35f34bff6df0b7
+JSQLITE_TREE ?= 79bfcf7a52d233076301c5cba9cd56d1b3e07ebf
 JSQLITE_SRC ?= $(JSQLITE_REPO)/src
+TERMBOX2_URL ?= https://github.com/termbox/termbox2.git
+TERMBOX2_REV ?= 605398fa79108412976191e062ea14bd4bd30213
+TERMBOX2_TREE ?= e3ff0f62611cb5cdc94bbea2bc0780541359a65e
 LIBDIRS = --libdirs $(CURDIR):$(JSQLITE_SRC):$(JH)/lib
 LOCAL_JERBOA_DIR ?= $(CURDIR)/../jerboa
 DEFAULT_JERBOA_NATIVE_MANIFEST = $(if $(wildcard $(LOCAL_JERBOA_DIR)/jerboa-native-rs/Cargo.toml),$(LOCAL_JERBOA_DIR)/jerboa-native-rs/Cargo.toml,$(JH)/jerboa-native-rs/Cargo.toml)
@@ -29,7 +34,11 @@ JERBOA_NATIVE_A ?= $(DEFAULT_JERBOA_NATIVE_A)
 JERBOA_NATIVE_LIB ?= $(DEFAULT_JERBOA_NATIVE_DIR)/libjerboa_native.$(TUI_SHIM_EXT)
 CARGO_AUDIT ?= $(shell command -v cargo-audit 2>/dev/null || printf '%s/.cargo/bin/cargo-audit' "$$HOME")
 export JERBOA_NATIVE_A
-JEXEC = JERBOA_NATIVE_LIB="$(JERBOA_NATIVE_LIB)" $(JERBUILD) exec $(LIBDIRS)
+JEXEC = JERBOA_DEV_NATIVE=1 \
+	JERBOA_SIGNAL_DEV_NATIVE=1 \
+	JERBOA_NATIVE_LIB="$(STAGED_JERBOA_NATIVE_LIB)" \
+	JERBOA_SIGNAL_SECURITY_LIB="$(STAGED_SECURITY_SHIM)" \
+	$(JERBUILD) exec $(LIBDIRS)
 BIN := jerboa-signal
 BIN_DIR := $(HOME)/.local/bin
 TUI_SHIM_DIR := $(CURDIR)/vendor/termbox2
@@ -41,6 +50,10 @@ TUI_SHIM_EXT := so
 endif
 TUI_SHIM := $(TUI_SHIM_DIR)/signal_tui_shim.$(TUI_SHIM_EXT)
 LOG_SHIM := signal_log_shim.$(TUI_SHIM_EXT)
+SECURITY_SHIM := signal_security_shim.$(TUI_SHIM_EXT)
+NATIVE_TEST_STAGE = $(HOME)/.cache/jerboa-signal/native-test
+STAGED_JERBOA_NATIVE_LIB = $(NATIVE_TEST_STAGE)/libjerboa_native.$(TUI_SHIM_EXT)
+STAGED_SECURITY_SHIM = $(NATIVE_TEST_STAGE)/signal_security_shim.$(TUI_SHIM_EXT)
 SQLCIPHER_PREFIX := $(shell brew --prefix sqlcipher 2>/dev/null)
 DIST_DIR ?= dist/release-evidence
 SBOM_DIR ?= dist/sbom
@@ -56,7 +69,7 @@ else \
 	printf '%s\n' signal-cli; \
 fi)
 
-.PHONY: all build binary run run-tui test security sanitize-evidence audit sbom reproducibility-report soak-evidence binary-smoke signal-cli-advisory-check verify release-evidence install install-log-shim clean help vendor-deps tui-shim log-shim ensure-jerboa-tools ensure-jerboa-native ensure-jsqlite
+.PHONY: all build binary run run-tui test security sanitize-evidence audit sbom reproducibility-report soak-evidence binary-smoke signal-cli-advisory-check verify release-evidence install install-log-shim clean help vendor-deps tui-shim log-shim security-shim native-test-stage ensure-jerboa-tools ensure-jerboa-native ensure-jsqlite ensure-termbox2
 .DEFAULT_GOAL := help
 
 all: binary
@@ -73,10 +86,14 @@ run: binary
 run-tui: binary tui-shim
 	./$(BIN) tui $(ARGS)
 
-test: binary
+test: binary native-test-stage
 	$(JEXEC) tests/test-send-result.ss
 	$(JEXEC) tests/test-rpc-demux.ss
+	$(JEXEC) tests/test-bounded-line.ss
 	$(JEXEC) tests/test-logdb-jsqlite.ss
+	$(JEXEC) tests/test-secret-input.ss
+	$(JEXEC) tests/test-attachments.ss
+	$(JEXEC) tests/test-native-loader.ss
 	$(JEXEC) tests/test-receive-normalization.ss
 	$(JEXEC) tests/test-history-replay.ss
 	./$(BIN) --help >/dev/null && echo "smoke ok"
@@ -107,6 +124,12 @@ sbom:
 	JERBOA_NATIVE_MANIFEST="$(JERBOA_NATIVE_MANIFEST)" \
 	JERBOA_NATIVE_A="$(JERBOA_NATIVE_A)" \
 	JSQLITE_SRC="$(JSQLITE_SRC)" \
+	JSQLITE_REPO="$(JSQLITE_REPO)" \
+	JSQLITE_REV="$(JSQLITE_REV)" \
+	JSQLITE_TREE="$(JSQLITE_TREE)" \
+	TERMBOX2_REPO="$(TUI_SHIM_DIR)" \
+	TERMBOX2_REV="$(TERMBOX2_REV)" \
+	TERMBOX2_TREE="$(TERMBOX2_TREE)" \
 	SIGNAL_CLI="$(SIGNAL_CLI)" \
 	sh scripts/sbom.sh
 
@@ -162,7 +185,7 @@ release-evidence: verify reproducibility-report sbom soak-evidence binary-smoke
 	fi
 	@find signal tests -type f \( -name '*.ss' -o -name '*.c' -o -name '*.h' \) -print | sort | xargs shasum -a 256 > "$(DIST_DIR)/source-sha256.txt"
 	@find vendor/jsqlite/src -type f -name '*.ss' -print | sort | xargs shasum -a 256 > "$(DIST_DIR)/jsqlite-sha256.txt"
-	@shasum -a 256 Makefile .jerbuild .jerboa/security.json SECURITY.md docs/threat-model.md docs/credential-handling.md docs/ffi-boundary.md docs/release-evidence.md docs/signal-cli-advisory-review.md scripts/security-check.sh scripts/sanitize-evidence.sh scripts/sbom.sh scripts/reproducibility-report.sh scripts/soak-evidence.sh scripts/binary-smoke.sh scripts/signal-cli-advisory-check.sh > "$(DIST_DIR)/release-inputs-sha256.txt"
+	@shasum -a 256 Makefile .jerbuild .jerboa/security.json SECURITY.md docs/threat-model.md docs/credential-handling.md docs/ffi-boundary.md docs/release-evidence.md docs/signal-cli-advisory-review.md support/signal_security.c support/ffi-symbols.list scripts/security-check.sh scripts/sanitize-evidence.sh scripts/sbom.sh scripts/reproducibility-report.sh scripts/soak-evidence.sh scripts/binary-smoke.sh scripts/signal-cli-advisory-check.sh > "$(DIST_DIR)/release-inputs-sha256.txt"
 	@shasum -a 256 "$(BIN)" > "$(DIST_DIR)/binary-sha256.txt"
 	@if command -v otool >/dev/null 2>&1; then otool -L "$(BIN)" > "$(DIST_DIR)/native-linkage.txt"; elif command -v ldd >/dev/null 2>&1; then ldd "$(BIN)" > "$(DIST_DIR)/native-linkage.txt"; else echo "no otool/ldd available" > "$(DIST_DIR)/native-linkage.txt"; fi
 	@if command -v cargo >/dev/null 2>&1 && [ -f "$(JERBOA_NATIVE_MANIFEST)" ]; then \
@@ -204,7 +227,7 @@ release-evidence: verify reproducibility-report sbom soak-evidence binary-smoke
 	} > "$(DIST_DIR)/result.txt"
 	@echo "release evidence written to $(DIST_DIR)"
 
-install: binary
+install: binary tui-shim security-shim
 	mkdir -p $(BIN_DIR)
 	install -m 0755 $(BIN) $(BIN_DIR)/$(BIN)
 	@if [ -f scripts/signal-cli-jvm ]; then \
@@ -216,6 +239,7 @@ install: binary
 	fi
 	test ! -f signal_tui_shim.$(TUI_SHIM_EXT) || install -m 0755 signal_tui_shim.$(TUI_SHIM_EXT) $(BIN_DIR)/signal_tui_shim.$(TUI_SHIM_EXT)
 	test ! -f $(LOG_SHIM) || install -m 0755 $(LOG_SHIM) $(BIN_DIR)/$(LOG_SHIM)
+	install -m 0755 $(SECURITY_SHIM) $(BIN_DIR)/$(SECURITY_SHIM)
 	@echo "Installed $(BIN) to $(BIN_DIR)/$(BIN)"
 
 install-log-shim: log-shim
@@ -227,21 +251,37 @@ clean:
 	rm -f $(BIN)
 	rm -f signal_tui_shim.dylib signal_tui_shim.so
 	rm -f signal_log_shim.dylib signal_log_shim.so
+	rm -f signal_security_shim.dylib signal_security_shim.so
 	find signal \( -name '*.so' -o -name '*.wpo' \) -delete 2>/dev/null || true
 	rm -rf dist
 
-vendor-deps: ensure-jsqlite vendor/termbox2
+vendor-deps: ensure-jsqlite ensure-termbox2
 
 ensure-jsqlite:
 	@if [ ! -f "$(JSQLITE_SRC)/jsqlite/api.ss" ]; then \
 	  mkdir -p "$(VENDOR)"; \
-	  git clone --depth 1 "$(JSQLITE_URL)" "$(JSQLITE_REPO)"; \
+	  rm -rf "$(JSQLITE_REPO)"; \
+	  git init "$(JSQLITE_REPO)"; \
+	  git -C "$(JSQLITE_REPO)" remote add origin "$(JSQLITE_URL)"; \
+	  git -C "$(JSQLITE_REPO)" fetch --depth 1 origin "$(JSQLITE_REV)"; \
+	  git -C "$(JSQLITE_REPO)" checkout --detach "$(JSQLITE_REV)"; \
 	fi
 	@test -f "$(JSQLITE_SRC)/jsqlite/api.ss"
+	@test "$$(git -C "$(JSQLITE_REPO)" rev-parse HEAD)" = "$(JSQLITE_REV)" || { echo "ERROR: vendor/jsqlite is not at pinned revision $(JSQLITE_REV)"; exit 1; }
+	@test "$$(git -C "$(JSQLITE_REPO)" rev-parse HEAD^{tree})" = "$(JSQLITE_TREE)" || { echo "ERROR: vendor/jsqlite tree hash mismatch"; exit 1; }
 
-vendor/termbox2:
-	mkdir -p vendor
-	git clone --depth 1 https://github.com/termbox/termbox2.git vendor/termbox2
+ensure-termbox2:
+	@if [ ! -f "$(TUI_SHIM_DIR)/termbox2.h" ]; then \
+	  mkdir -p "$(VENDOR)"; \
+	  rm -rf "$(TUI_SHIM_DIR)"; \
+	  git init "$(TUI_SHIM_DIR)"; \
+	  git -C "$(TUI_SHIM_DIR)" remote add origin "$(TERMBOX2_URL)"; \
+	  git -C "$(TUI_SHIM_DIR)" fetch --depth 1 origin "$(TERMBOX2_REV)"; \
+	  git -C "$(TUI_SHIM_DIR)" checkout --detach "$(TERMBOX2_REV)"; \
+	fi
+	@test -f "$(TUI_SHIM_DIR)/termbox2.h"
+	@test "$$(git -C "$(TUI_SHIM_DIR)" rev-parse HEAD)" = "$(TERMBOX2_REV)" || { echo "ERROR: vendor/termbox2 is not at pinned revision $(TERMBOX2_REV)"; exit 1; }
+	@test "$$(git -C "$(TUI_SHIM_DIR)" rev-parse HEAD^{tree})" = "$(TERMBOX2_TREE)" || { echo "ERROR: vendor/termbox2 tree hash mismatch"; exit 1; }
 
 ensure-jerboa-tools:
 	@if [ -x ./jerbuild ] && [ -x ./jerboa ]; then \
@@ -281,12 +321,11 @@ ensure-jerboa-native: ensure-jerboa-tools
 	  exit 1; \
 	}
 
-tui-shim: vendor/termbox2
-	@test -f $(TUI_SHIM) || \
-	  cc -shared -fPIC -DTB_OPT_ATTR_W=32 \
-	    -I$(TUI_SHIM_DIR) \
-	    -o $(TUI_SHIM) \
-	    signal/tui/signal_tui_shim.c
+tui-shim: ensure-termbox2
+	cc -shared -fPIC -DTB_OPT_ATTR_W=32 \
+	  -I$(TUI_SHIM_DIR) \
+	  -o $(TUI_SHIM) \
+	  signal/tui/signal_tui_shim.c
 	cp $(TUI_SHIM) signal_tui_shim.$(TUI_SHIM_EXT)
 
 log-shim:
@@ -300,6 +339,15 @@ log-shim:
 	  -o $(LOG_SHIM) \
 	  signal/log_shim.c
 
+security-shim:
+	cc -shared -fPIC -o $(SECURITY_SHIM) support/signal_security.c
+
+native-test-stage: ensure-jerboa-native security-shim
+	@test -f "$(JERBOA_NATIVE_LIB)" || { echo "ERROR: missing dynamic native test library: $(JERBOA_NATIVE_LIB)"; exit 1; }
+	install -d -m 0700 "$(NATIVE_TEST_STAGE)"
+	install -m 0700 "$(JERBOA_NATIVE_LIB)" "$(STAGED_JERBOA_NATIVE_LIB)"
+	install -m 0700 "$(SECURITY_SHIM)" "$(STAGED_SECURITY_SHIM)"
+
 help:
 	@echo "jerboa-signal -- Signal client over signal-cli"
 	@echo ""
diff --git a/README.md b/README.md
index 7fccf4d..e7c7ef6 100644
--- a/README.md
+++ b/README.md
@@ -123,11 +123,13 @@ durability issues, use:
 jerboa-signal --trace-all /tmp/jerboa-signal.raw.trace --log-persist immediate tui
 ```
 
-The default is `immediate`, which persists after every logged message so other
-readers and future app launches see the newest rows even if the TUI exits
-uncleanly. `--log-persist close` defers jsqlite encrypted-container writes until
-clean exit, which can reduce pauses with a large encrypted history, but new rows
-from that session may be lost if the process crashes.
+The default is `batch`: the encrypted container is rewritten after 64 rows,
+1 MiB of new retained payload, or two seconds, whichever comes first, and at
+clean close. This avoids whole-database work for every message while bounding
+the crash window. `--log-persist immediate` forces a rewrite after every row;
+`--log-persist close` persists only at clean exit. Operators can lower the
+bounded batch thresholds with `JERBOA_SIGNAL_LOG_BATCH_ROWS`,
+`JERBOA_SIGNAL_LOG_BATCH_BYTES`, and `JERBOA_SIGNAL_LOG_BATCH_MS`.
 
 ## Security
 
@@ -160,9 +162,12 @@ Enable it:
 ./jerboa-signal tui           # prompts: passphrase for encrypted message log
 ```
 
-At startup the TUI asks for a passphrase (echo off). Enter one to log; leave it
-blank to skip logging for that session. To run unattended, set
-`JERBOA_SIGNAL_DB_KEY` instead and the prompt is skipped.
+At startup the TUI asks for a passphrase on the controlling terminal. It fails
+closed if the exact terminal state cannot be saved, echo disabled, or the state
+restored. Enter one to log; leave it blank to skip logging for that session.
+For unattended use, prefer `--log-key-fd FD` with a dedicated inherited FD.
+`JERBOA_SIGNAL_DB_KEY` remains available but exposes the secret to the process
+environment.
 
 The log lives at `~/.local/share/jerboa-signal/messages-<account>.db`. Each
 inbound notification and outbound send is one append-only row (with the full raw
@@ -170,6 +175,12 @@ JSON); remote-deletes, edits, reactions, calls, attachments, and other
 displayable events are logged as new rows and never overwrite the
 original.
 
+Retention is bounded by default to 20,000 rows, 128 MiB of retained payload,
+and 4 MiB per entry; oldest rows are pruned before an insert exceeds the row or
+payload budget. The limits can be lowered with `JERBOA_SIGNAL_LOG_MAX_ROWS`,
+`JERBOA_SIGNAL_LOG_MAX_PAYLOAD_BYTES`, and
+`JERBOA_SIGNAL_LOG_MAX_ENTRY_BYTES`.
+
 To convert an older SQLCipher log to the current jsqlite container format:
 
 ```sh
diff --git a/SECURITY.md b/SECURITY.md
index 459e44d..310ce6e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -42,8 +42,13 @@ can still reveal account activity. Use local disk encryption and restrict
 backups for Signal client hosts.
 
 `JERBOA_SIGNAL_DB_KEY` is accepted for unattended sessions but exposes the log
-passphrase through the process environment. Prefer interactive prompting or a
-supervisor secret store.
+passphrase through the process environment. Prefer fail-closed controlling-TTY
+entry or `--log-key-fd FD` from a supervisor secret pipe.
+
+Encrypted-container persistence is batched and local history is bounded by row,
+payload, and per-entry quotas. Automatic attachment export is disabled by
+default and, when enabled, is bounded by count/space quotas and creates
+exclusive mode-0600 files under a non-symlink absolute root.
 
 ## External Dependencies
 
diff --git a/docs/credential-handling.md b/docs/credential-handling.md
index 9bd91ce..46dac6c 100644
--- a/docs/credential-handling.md
+++ b/docs/credential-handling.md
@@ -18,10 +18,12 @@ identity keys, sessions, and Signal service credentials are owned by
 The encrypted message log prompts for a passphrase before the TUI starts. Leave
 the prompt blank to disable logging for a session.
 
-`JERBOA_SIGNAL_DB_KEY` is supported for unattended runs. It is convenient but
-less private because environment variables may be visible to supervisors,
-diagnostics, shell history wrappers, or crash reports. Production deployments
-should prefer interactive entry or a protected service secret store.
+Interactive entry uses `/dev/tty`, disables echo only after saving the exact
+terminal state, and fails closed if disable or restoration fails. For unattended
+runs, pass a dedicated inherited descriptor with `--log-key-fd FD` (FD 3 or
+higher). `JERBOA_SIGNAL_DB_KEY` is convenient but less private because
+environment variables may be visible to supervisors, diagnostics, shell
+wrappers, or crash reports.
 
 Rotate the log passphrase by creating a new encrypted log and importing only the
 history you still need. Treat old `.sqlcipher.bak` migration backups as
@@ -39,12 +41,17 @@ delete them after debugging.
 
 ## Attachments
 
-Attachments are copied from the local `signal-cli` attachment cache to
-`~/Downloads/jerboa-signal/` or `JERBOA_SIGNAL_DOWNLOAD_DIR`.
+Automatic attachment export is off by default. Set
+`JERBOA_SIGNAL_AUTO_EXPORT_ATTACHMENTS=1` to copy from the local `signal-cli`
+attachment cache to `~/Downloads/jerboa-signal/` or an absolute
+`JERBOA_SIGNAL_DOWNLOAD_DIR`.
 
-Destination names are sanitized and never overwrite existing files, but the
-exported content is plaintext. Keep the download root outside synced or shared
-directories unless that is intentional.
+Destination names are sanitized, files are created exclusively at mode `0600`,
+symlinked sources/roots are rejected, and existing files are never overwritten.
+Per-message count, per-file, per-conversation, total-tree, and free-space-reserve
+limits are controlled by the bounded `JERBOA_SIGNAL_ATTACHMENT_*` variables.
+Exported content is plaintext; keep the root outside synced/shared directories
+unless that is intentional.
 
 ## Release Checklist
 
diff --git a/docs/ffi-boundary.md b/docs/ffi-boundary.md
index 2ef8e5e..0ab18cf 100644
--- a/docs/ffi-boundary.md
+++ b/docs/ffi-boundary.md
@@ -10,6 +10,11 @@ The encrypted jsqlite log container uses `jerboa_scrypt`, `jerboa_aead_seal`,
 archive or dynamic library.
 
 - Native loading is lazy through `(jerboa ffi)` `load-shared-object*`.
+- Dynamic fallbacks never search the current directory or use a bare soname;
+  paths must be canonical, absolute, owner-controlled, and non-writable by
+  group/other. Environment overrides require `JERBOA_SIGNAL_DEV_NATIVE=1`.
+- Availability is proven by calling the expected ABI canary, not by merely
+  constructing a foreign procedure object.
 - Importing the log module must not load or require a dynamic library.
 - Scheme passes bytevectors and explicit lengths for key derivation and AEAD.
 - Output lengths return through Scheme-managed bytevectors, not `foreign-alloc`.
@@ -38,6 +43,14 @@ The SQLCipher shim exists only to migrate older logs.
 - Release candidates with legacy migration support should build the shim on a
   host with SQLCipher installed and record native linkage.
 
+## Security Helper
+
+The bundled `support/signal_security.c` boundary validates dynamic-library
+paths, obtains filesystem free space, performs fail-closed controlling-terminal
+secret input, and pins attachment source/destination descriptors. Attachment
+opens use no-follow/inode checks and exclusive mode-0600 creation; Scheme copies
+exactly the size observed on the pinned descriptor.
+
 ## Do Not Do
 
 - Do not add top-level `load-shared-object` calls.
diff --git a/docs/threat-model.md b/docs/threat-model.md
index b6d355a..2fdd900 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -34,8 +34,11 @@ delegated to `signal-cli`.
 - Environment variables: `JERBOA_SIGNAL_CLI`, `JERBOA_SIGNAL_CLI_LOG`,
   `JERBOA_SIGNAL_CLI_VERBOSE`, `JERBOA_SIGNAL_TRACE`,
   `JERBOA_SIGNAL_TRACE_SENSITIVE`, `JERBOA_SIGNAL_DB_KEY`,
+  `JERBOA_SIGNAL_DB_KEY_FD`,
   `JERBOA_SIGNAL_LOG_BACKEND`, `JERBOA_SIGNAL_LOG_PERSIST`,
-  `JERBOA_SIGNAL_DOWNLOAD_DIR`, and `JERBOA_SIGNAL_CLI_DATA_DIR`.
+  log batch/retention limits, `JERBOA_SIGNAL_AUTO_EXPORT_ATTACHMENTS`,
+  attachment quotas, `JERBOA_SIGNAL_DOWNLOAD_DIR`, and
+  `JERBOA_SIGNAL_CLI_DATA_DIR`.
 - Local files: `signal-cli` account state, attachments, encrypted logs,
   migration backups, trace files, removed-conversation state, and themes.
 - Native libraries: bundled Jerboa native crypto, optional termbox2 shim, and
@@ -52,10 +55,16 @@ delegated to `signal-cli`.
 - Write state and trace files through checked paths and safe wrappers. State
   files created by this app must use mode `0600` when they contain account or
   preference state.
-- Sanitize attachment destination directories and filenames, never overwrite
-  existing downloads, and keep exports under an explicit user download root.
+- Keep automatic attachment export opt-in; enforce count, file, conversation,
+  total, and free-space quotas. Pin regular sources without following symlinks,
+  create mode-0600 destinations exclusively, and reject symlinked roots.
+- Bound every child stdout/stderr line before JSON parsing or diagnostic output;
+  terminate the child on a framing violation.
+- Batch whole-container encrypted-log persistence and enforce row, aggregate
+  payload, and per-entry retention limits under a per-handle mutex.
 - Load native libraries lazily through `(jerboa ffi)` so import and static
-  binary startup do not crash before user policy can run.
+  binary startup do not crash before user policy can run. Dynamic fallbacks are
+  canonical absolute owner-controlled paths and must pass a called ABI canary.
 - Keep C shim mutable event buffers thread-local or caller-owned.
 
 ## Residual Production Blockers
diff --git a/scripts/sbom.sh b/scripts/sbom.sh
index 8b7e6f4..10072d8 100755
--- a/scripts/sbom.sh
+++ b/scripts/sbom.sh
@@ -11,6 +11,12 @@ BIN=${BIN:-jerboa-signal}
 JERBOA_NATIVE_MANIFEST=${JERBOA_NATIVE_MANIFEST:-}
 JERBOA_NATIVE_A=${JERBOA_NATIVE_A:-}
 JSQLITE_SRC=${JSQLITE_SRC:-vendor/jsqlite/src}
+JSQLITE_REPO=${JSQLITE_REPO:-vendor/jsqlite}
+JSQLITE_REV=${JSQLITE_REV:-unknown}
+JSQLITE_TREE=${JSQLITE_TREE:-unknown}
+TERMBOX2_REPO=${TERMBOX2_REPO:-vendor/termbox2}
+TERMBOX2_REV=${TERMBOX2_REV:-unknown}
+TERMBOX2_TREE=${TERMBOX2_TREE:-unknown}
 SIGNAL_CLI=${SIGNAL_CLI:-signal-cli}
 
 rm -rf "$OUT"
@@ -103,6 +109,27 @@ if [ -n "$JERBOA_NATIVE_A" ] && [ -f "$JERBOA_NATIVE_A" ]; then
     native_staticlib_status=present
 fi
 
+actual_git_value() {
+    repo=$1
+    expression=$2
+    if [ -d "$repo/.git" ]; then
+        git -C "$repo" rev-parse "$expression" 2>/dev/null || echo unavailable
+    else
+        echo unavailable
+    fi
+}
+
+{
+    echo "jsqlite_expected_commit=$JSQLITE_REV"
+    echo "jsqlite_expected_tree=$JSQLITE_TREE"
+    echo "jsqlite_actual_commit=$(actual_git_value "$JSQLITE_REPO" HEAD)"
+    echo "jsqlite_actual_tree=$(actual_git_value "$JSQLITE_REPO" 'HEAD^{tree}')"
+    echo "termbox2_expected_commit=$TERMBOX2_REV"
+    echo "termbox2_expected_tree=$TERMBOX2_TREE"
+    echo "termbox2_actual_commit=$(actual_git_value "$TERMBOX2_REPO" HEAD)"
+    echo "termbox2_actual_tree=$(actual_git_value "$TERMBOX2_REPO" 'HEAD^{tree}')"
+} > "$OUT/vendor-revisions.txt"
+
 {
     echo "repo=jerboa-signal"
     echo "sbom_status=present"
@@ -123,6 +150,8 @@ fi
     echo "signal_cli_security_advisory_status=no-published-github-advisories-as-of-2026-06-23"
     echo "upstream_security_policy_status=no-upstream-security-policy-as-of-2026-06-23"
     echo "jsqlite_status=$jsqlite_status"
+    echo "jsqlite_pin_status=full-commit-and-tree-validated"
+    echo "termbox2_pin_status=full-commit-and-tree-validated"
     echo "native_dependency=jerboa-native-rs"
     echo "jerboa_native_manifest_status=$native_manifest_status"
     echo "jerboa_native_staticlib_status=$native_staticlib_status"
diff --git a/scripts/security-check.sh b/scripts/security-check.sh
index 0749fa1..8002481 100755
--- a/scripts/security-check.sh
+++ b/scripts/security-check.sh
@@ -29,6 +29,12 @@ require_file scripts/reproducibility-report.sh
 require_file scripts/binary-smoke.sh
 require_file scripts/soak-evidence.sh
 require_file scripts/signal-cli-advisory-check.sh
+require_file support/signal_security.c
+require_file support/ffi-symbols.list
+require_file tests/test-bounded-line.ss
+require_file tests/test-secret-input.ss
+require_file tests/test-attachments.ss
+require_file tests/test-native-loader.ss
 
 grep -q 'JSIGNAL_REQUIRE_TARGET_LIVE_PROOF=1' docs/release-evidence.md 2>/dev/null || \
     note_fail "release evidence docs must describe required target live proof"
@@ -54,6 +60,28 @@ grep -q 'reject_sensitive_proof' scripts/signal-cli-advisory-check.sh 2>/dev/nul
     note_fail "signal-cli advisory proof must reject sensitive proof material before copy"
 grep -q 'proofMaterialPolicy' .jerboa/security.json 2>/dev/null || \
     note_fail "security policy must record proof material policy"
+grep -q 'JERBOA_SIGNAL_AUTO_EXPORT_ATTACHMENTS' signal/attach-save.ss 2>/dev/null || \
+    note_fail "attachment export must remain explicitly opt-in"
+grep -q 'signal_read_secret_tty' support/signal_security.c 2>/dev/null || \
+    note_fail "secret prompt must use the fail-closed controlling-terminal helper"
+grep -q 'read-bounded-line' signal/rpc.ss 2>/dev/null || \
+    note_fail "one-shot RPC stdout must use bounded framing"
+grep -q 'read-bounded-line' signal/rpc-actor.ss 2>/dev/null || \
+    note_fail "actor RPC stdout must use bounded framing"
+grep -Eq '^JSQLITE_REV \?= [0-9a-f]{40}$' Makefile 2>/dev/null || \
+    note_fail "jsqlite must be pinned to a full commit hash"
+grep -Eq '^TERMBOX2_REV \?= [0-9a-f]{40}$' Makefile 2>/dev/null || \
+    note_fail "termbox2 must be pinned to a full commit hash"
+grep -q '^ensure-termbox2:' Makefile 2>/dev/null || \
+    note_fail "termbox2 pin/tree validation must run even when vendor exists"
+
+if grep -n -I 'get-line' signal/rpc.ss signal/rpc-actor.ss 2>/dev/null; then
+    note_fail "unbounded child-output get-line remains in RPC code"
+fi
+
+if grep -n -I 'stty' signal/logdb.ss 2>/dev/null; then
+    note_fail "shell-based terminal echo handling remains in log passphrase input"
+fi
 
 if grep -R -n -I '(chezscheme)' signal tests 2>/dev/null; then
     note_fail "direct (chezscheme) import remains in runtime/test source"
diff --git a/signal/attach-save.ss b/signal/attach-save.ss
index b770080..a328132 100644
--- a/signal/attach-save.ss
+++ b/signal/attach-save.ss
@@ -3,7 +3,7 @@
 ;;;
 ;;; signal-cli already downloads every received attachment into its data dir
 ;;; (<XDG_DATA_HOME or ~/.local/share>/signal-cli/attachments/<id>[.ext]). This
-;;; module copies those files, as each message arrives, into
+;;; module can copy those files, when explicitly enabled, into
 ;;;
 ;;;   ~/Downloads/jerboa-signal/<conversation>/<original-name>
 ;;;
@@ -12,7 +12,8 @@
 ;;; Only inbound dataMessage attachments are exported -- not our own sync sends.
 
 (library (signal attach-save)
-  (export save-notification-attachments! save-envelope-attachments! download-base)
+  (export save-notification-attachments! save-envelope-attachments!
+          attachment-auto-export-enabled? download-base)
 
   (import (except (scheme)
                   make-hash-table hash-table?
@@ -23,7 +24,28 @@
                   iota 1+ 1-
                   partition
                   make-date make-time)
-          (except (jerboa prelude) meta atom?))
+          (except (jerboa prelude) meta atom?)
+          (std os posix)
+          (signal security-native))
+
+  (def *export-lock* (make-mutex))
+  (def *export-base* (box #f))
+  (def *export-total-bytes* (box 0))
+  (def *export-folder-bytes* (make-hashtable equal-hash equal?))
+  (def *exported-sources* (make-hashtable equal-hash equal?))
+  (def *export-scan-entry-limit* 100000)
+  (def *copy-collision-limit* 128)
+
+  (def (truthy-env? name)
+    (let ([value (getenv name)])
+      (and (string? value)
+           (or (string-ci=? value "1")
+               (string-ci=? value "on")
+               (string-ci=? value "true")
+               (string-ci=? value "yes")))))
+
+  (def (attachment-auto-export-enabled?)
+    (truthy-env? "JERBOA_SIGNAL_AUTO_EXPORT_ATTACHMENTS"))
 
   ;; --- entry points -------------------------------------------------------
 
@@ -31,14 +53,18 @@
   ;; list of destination paths written (empty when there's nothing to save).
   (def (save-notification-attachments! notif)
     (guard (e [(condition? e) '()])
-      (let ([env (notif-envelope notif)])
-        (if (hashtable? env) (save-envelope-attachments! env) '()))))
+      (if (not (attachment-auto-export-enabled?))
+        '()
+        (let ([env (notif-envelope notif)])
+          (if (hashtable? env) (save-envelope-attachments! env) '())))))
 
   (def (save-envelope-attachments! envelope)
     (guard (e [(condition? e) '()])
-      (let ([data (htref envelope "dataMessage")])
+      (let ([data (and (attachment-auto-export-enabled?)
+                       (htref envelope "dataMessage"))])
         (if (hashtable? data)
-          (let ([atts (->list (htref data "attachments"))])
+          (let ([atts (bounded-list (htref data "attachments")
+                                    (attachment-max-count))])
             (if (pair? atts)
               (let ([folder (conversation-folder envelope data)])
                 (let loop ([xs atts] [acc '()])
@@ -55,12 +81,145 @@
            (let* ([id (->id-string (htref attach "id"))]
                   [src (find-source-file (attachments-source-dir) id)])
              (and src
-                  (let* ([destdir (string-append (download-base) "/" folder)]
-                         [name (dest-filename attach id src)]
-                         [dst (unique-dest destdir name)])
-                    (mkdir-p destdir)
-                    (copy-file! src dst)
-                    dst))))))
+                  (with-export-lock
+                    (lambda ()
+                      (let ([source (open-pinned-source src)])
+                        (and source
+                             (dynamic-wind
+                               (lambda () (void))
+                               (lambda ()
+                                 (let* ([source-fd (car source)]
+                                        [size (cadr source)]
+                                        [source-key (caddr source)]
+                                        [base (download-base)]
+                                        [destdir
+                                         (string-append base "/" folder)]
+                                        [name (dest-filename attach id src)])
+                                   (mkdir-p-safe base)
+                                   (initialize-export-state! base)
+                                   (let ([prior
+                                          (hashtable-ref
+                                            *exported-sources* source-key #f)])
+                                   (if prior
+                                     #f
+                                     (begin
+                                       (mkdir-p-safe destdir)
+                                       (and
+                                         (export-capacity-available?
+                                           destdir folder size)
+                                         (let ([dst
+                                                (copy-file-exclusive!
+                                                  source-fd size destdir name)])
+                                           (and dst
+                                                (begin
+                                                  (set-box!
+                                                    *export-total-bytes*
+                                                    (+ (unbox *export-total-bytes*)
+                                                       size))
+                                                  (hashtable-set!
+                                                    *export-folder-bytes*
+                                                    folder
+                                                    (+ (hashtable-ref
+                                                         *export-folder-bytes*
+                                                         folder 0)
+                                                       size))
+                                                  (hashtable-set!
+                                                    *exported-sources*
+                                                    source-key dst)
+                                                  dst)))))))))
+                               (lambda ()
+                                 (guard (_ [(condition? _) (void)])
+                                   (posix-close (car source))))))))))))))
+
+  (def (with-export-lock thunk)
+    (dynamic-wind
+      (lambda () (mutex-acquire *export-lock*))
+      thunk
+      (lambda () (mutex-release *export-lock*))))
+
+  (def (bounded-positive-env name fallback hard-limit)
+    (let* ([raw (getenv name)]
+           [value (and raw (string->number raw))])
+      (if (and (integer? value) (exact? value)
+               (> value 0) (<= value hard-limit))
+        value
+        fallback)))
+
+  (def (attachment-max-file-bytes)
+    (bounded-positive-env "JERBOA_SIGNAL_ATTACHMENT_MAX_FILE_BYTES"
+                          (* 25 1024 1024)
+                          (* 256 1024 1024)))
+
+  (def (attachment-max-count)
+    (bounded-positive-env "JERBOA_SIGNAL_ATTACHMENT_MAX_COUNT" 32 256))
+
+  (def (attachment-max-conversation-bytes)
+    (bounded-positive-env "JERBOA_SIGNAL_ATTACHMENT_MAX_CONVERSATION_BYTES"
+                          (* 250 1024 1024)
+                          (* 2 1024 1024 1024)))
+
+  (def (attachment-max-total-bytes)
+    (bounded-positive-env "JERBOA_SIGNAL_ATTACHMENT_MAX_TOTAL_BYTES"
+                          (* 1024 1024 1024)
+                          (* 10 1024 1024 1024)))
+
+  (def (attachment-free-reserve-bytes)
+    (bounded-positive-env "JERBOA_SIGNAL_ATTACHMENT_FREE_RESERVE_BYTES"
+                          (* 512 1024 1024)
+                          (* 10 1024 1024 1024)))
+
+  (def (initialize-export-state! base)
+    (unless (and (path-absolute? base)
+                 (file-directory? base)
+                 (safe-directory-tree? base))
+      (error 'attachment-export "download base must be an absolute directory"
+             base))
+    (unless (equal? (unbox *export-base*) base)
+      (set-box! *export-base* base)
+      (set-box! *export-total-bytes* (bounded-tree-bytes base))
+      (hashtable-clear! *export-folder-bytes*)
+      (hashtable-clear! *exported-sources*)))
+
+  (def (bounded-tree-bytes root)
+    (let ([seen 0])
+      (let walk ([path root])
+        (set! seen (+ seen 1))
+        (when (> seen *export-scan-entry-limit*)
+          (error 'attachment-export "attachment tree exceeds scan entry cap"
+                 *export-scan-entry-limit*))
+        (cond
+          [(file-symbolic-link? path) 0]
+          [(file-regular? path) (file-length path)]
+          [(file-directory? path)
+           (let loop ([entries (directory-list path)] [total 0])
+             (if (null? entries)
+               total
+               (loop (cdr entries)
+                     (+ total
+                        (walk (string-append path "/" (car entries)))))))]
+          [else 0]))))
+
+  (def (folder-bytes folder-path folder)
+    (let ([known (hashtable-ref *export-folder-bytes* folder #f)])
+      (if known
+        known
+        (let ([bytes (if (file-directory? folder-path)
+                       (bounded-tree-bytes folder-path)
+                       0)])
+          (hashtable-set! *export-folder-bytes* folder bytes)
+          bytes))))
+
+  (def (export-capacity-available? destdir folder incoming)
+    (let ([free (filesystem-free-bytes destdir)])
+      (and (integer? incoming)
+           (>= incoming 0)
+           (<= incoming (attachment-max-file-bytes))
+           (<= (+ (folder-bytes destdir folder) incoming)
+               (attachment-max-conversation-bytes))
+           (<= (+ (unbox *export-total-bytes*) incoming)
+               (attachment-max-total-bytes))
+           (>= free 0)
+           (>= (- free incoming) (attachment-free-reserve-bytes)))))
 
   ;; --- locations ----------------------------------------------------------
 
@@ -69,8 +228,13 @@
   (def (download-base)
     (let ([env (getenv "JERBOA_SIGNAL_DOWNLOAD_DIR")])
       (if (nonempty env)
-        env
-        (string-append (home) "/Downloads/jerboa-signal"))))
+        (if (path-absolute? env)
+          env
+          (error 'download-base "attachment download root must be absolute"))
+        (let ([h (home)])
+          (if (path-absolute? h)
+            (string-append h "/Downloads/jerboa-signal")
+            (error 'download-base "HOME must be absolute for attachment export"))))))
 
   (def (attachments-source-dir)
     (string-append (signal-cli-data-dir) "/attachments"))
@@ -78,10 +242,15 @@
   (def (signal-cli-data-dir)
     (let ([ov (getenv "JERBOA_SIGNAL_CLI_DATA_DIR")]
           [xdg (getenv "XDG_DATA_HOME")])
-      (cond
-        [(nonempty ov) ov]
-        [(nonempty xdg) (string-append xdg "/signal-cli")]
-        [else (string-append (home) "/.local/share/signal-cli")])))
+      (let ([path
+             (cond
+               [(nonempty ov) ov]
+               [(nonempty xdg) (string-append xdg "/signal-cli")]
+               [else (string-append (home) "/.local/share/signal-cli")])])
+        (if (path-absolute? path)
+          path
+          (error 'attachments-source-dir
+                 "signal-cli data directory must be absolute")))))
 
   ;; --- conversation folder ------------------------------------------------
 
@@ -140,44 +309,98 @@
         (string-append (sanitize-filename (or id "attachment"))
                        (file-ext (path-basename src))))))
 
-  ;; Append " (n)" before the extension until the name is free -- never
-  ;; overwrite an existing file.
-  (def (unique-dest dir filename)
-    (let ([full (string-append dir "/" filename)])
-      (if (not (file-exists? full))
-        full
-        (let ([stem (file-stem filename)]
-              [ext (file-ext filename)])
-          (let loop ([n 1])
-            (let ([cand (string-append dir "/" stem " (" (number->string n) ")" ext)])
-              (if (file-exists? cand) (loop (+ n 1)) cand)))))))
-
   ;; --- copy ---------------------------------------------------------------
 
-  (def (copy-file! src dst)
-    (let ([in (open-file-input-port src)])
-      (dynamic-wind
-        (lambda () (void))
-        (lambda ()
-          (let ([out (open-file-output-port dst (file-options no-fail))])
-            (dynamic-wind
-              (lambda () (void))
-              (lambda ()
-                (let loop ()
-                  (let ([chunk (get-bytevector-n in 65536)])
-                    (unless (eof-object? chunk)
-                      (put-bytevector out chunk)
-                      (loop)))))
-              (lambda () (close-port out)))))
-        (lambda () (close-port in)))))
-
-  ;; mkdir each ancestor in turn; the base (~/Downloads) usually exists but the
-  ;; per-conversation dir won't, and a custom base might be missing entirely.
-  (def (mkdir-p dir)
-    (unless (or (string=? dir "") (string=? dir "/") (file-exists? dir))
+  (def (open-pinned-source src)
+    (guard (_ [(condition? _) #f])
+      (let ([fd (open-attachment-source src)])
+        (and (integer? fd)
+             (>= fd 0)
+             (let ([size (attachment-source-size fd)])
+               (if (and (integer? size) (>= size 0))
+                 (list fd size (list src size))
+                 (begin
+                   (posix-close fd)
+                   #f)))))))
+
+  (def (copy-file-exclusive! source-fd source-size dir filename)
+    (let ([stem (file-stem filename)]
+          [ext (file-ext filename)])
+      (let loop ([attempt 0])
+        (and (< attempt *copy-collision-limit*)
+             (let* ([name (if (= attempt 0)
+                            filename
+                            (string-append stem " (" (number->string attempt)
+                                           ")" ext))]
+                    [dst (string-append dir "/" name)]
+                    [fd (open-attachment-destination dst)])
+               (cond
+                 [(= fd -2) (loop (+ attempt 1))]
+                 [(< fd 0) #f]
+                 [else
+                  (guard (e [(condition? e)
+                             (guard (_ [(condition? _) (void)])
+                               (posix-close fd))
+                             (guard (_ [(condition? _) (void)])
+                               (posix-unlink dst))
+                             #f])
+                    (posix-lseek source-fd 0 SEEK_SET)
+                    (copy-source-fd! source-fd fd source-size)
+                    (posix-close fd)
+                    dst)]))))))
+
+  (def (copy-source-fd! source-fd destination-fd expected-size)
+    (let loop ([copied 0])
+      (if (= copied expected-size)
+        (let ([extra (make-bytevector 1 0)])
+          (unless (= (posix-read source-fd extra 1) 0)
+            (error 'attachment-export "attachment grew during export"))
+          copied)
+        (let* ([remaining (- expected-size copied)]
+               [want (min remaining 65536)]
+               [chunk (make-bytevector want 0)]
+               [read-count (posix-read source-fd chunk want)])
+          (when (= read-count 0)
+            (error 'attachment-export "attachment shrank during export"))
+          (write-all-fd! destination-fd chunk read-count)
+          (loop (+ copied read-count))))))
+
+  (def (write-all-fd! fd bytes length)
+    (let loop ([offset 0])
+      (when (< offset length)
+        (let* ([remaining (- length offset)]
+               [chunk (if (= offset 0)
+                        bytes
+                        (let ([out (make-bytevector remaining 0)])
+                          (bytevector-copy! bytes offset out 0 remaining)
+                          out))]
+               [written (posix-write fd chunk remaining)])
+          (when (<= written 0)
+            (error 'attachment-export "short write" written))
+          (loop (+ offset written))))))
+
+  ;; Create each ancestor and reject symlink components. The export lock closes
+  ;; application-level races; O_NOFOLLOW/O_EXCL protect the file endpoints.
+  (def (mkdir-p-safe dir)
+    (unless (path-absolute? dir)
+      (error 'attachment-export "attachment directory must be absolute" dir))
+    (unless (or (string=? dir "/") (file-exists? dir))
       (let ([parent (parent-dir dir)])
-        (unless (string=? parent dir) (mkdir-p parent)))
-      (guard (e [(condition? e) (void)]) (mkdir dir))))
+        (unless (string=? parent dir) (mkdir-p-safe parent)))
+      (mkdir dir))
+    (unless (and (file-directory? dir)
+                 (not (file-symbolic-link? dir))
+                 (safe-directory-tree? dir))
+      (error 'attachment-export "unsafe attachment directory" dir)))
+
+  (def (safe-directory-tree? dir)
+    (cond
+      [(string=? dir "/") #t]
+      [(or (not (file-directory? dir)) (file-symbolic-link? dir)) #f]
+      [else
+       (let ([parent (parent-dir dir)])
+         (and (not (string=? parent dir))
+              (safe-directory-tree? parent)))]))
 
   (def (parent-dir path)
     (let loop ([i (- (string-length path) 1)])
@@ -199,6 +422,12 @@
   (def (->list x)
     (cond [(list? x) x] [(vector? x) (vector->list x)] [else '()]))
 
+  (def (bounded-list x limit)
+    (let loop ([xs (->list x)] [remaining limit] [acc '()])
+      (if (or (null? xs) (= remaining 0))
+        (reverse acc)
+        (loop (cdr xs) (- remaining 1) (cons (car xs) acc)))))
+
   (def (first-string . vs)
     (let loop ([xs vs])
       (cond [(null? xs) "unknown"]
diff --git a/signal/bounded-line.ss b/signal/bounded-line.ss
new file mode 100644
index 0000000..e4ef863
--- /dev/null
+++ b/signal/bounded-line.ss
@@ -0,0 +1,61 @@
+#!chezscheme
+;;; signal/bounded-line -- bounded textual framing for hostile child output.
+
+(library (signal bounded-line)
+  (export read-bounded-line rpc-frame-line-limit rpc-stderr-line-limit)
+
+  (import (except (scheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?))
+
+  (def *default-rpc-frame-chars* 262144)
+  (def *hard-rpc-frame-chars* 2097152)
+  (def *default-stderr-line-chars* 16384)
+  (def *hard-stderr-line-chars* 262144)
+
+  (def (configured-limit name fallback hard-limit)
+    (let* ([raw (getenv name)]
+           [value (and raw (string->number raw))])
+      (if (and (integer? value) (exact? value)
+               (> value 0) (<= value hard-limit))
+        value
+        fallback)))
+
+  (def (rpc-frame-line-limit)
+    (configured-limit "JERBOA_SIGNAL_RPC_MAX_FRAME_CHARS"
+                      *default-rpc-frame-chars*
+                      *hard-rpc-frame-chars*))
+
+  (def (rpc-stderr-line-limit)
+    (configured-limit "JERBOA_SIGNAL_RPC_MAX_STDERR_CHARS"
+                      *default-stderr-line-chars*
+                      *hard-stderr-line-chars*))
+
+  ;; Match get-line's EOF behavior while never retaining more than LIMIT
+  ;; characters. The caller treats a limit error as a protocol violation and
+  ;; terminates the child instead of attempting to drain an untrusted stream.
+  (def (read-bounded-line port limit who)
+    (unless (and (integer? limit) (exact? limit) (> limit 0))
+      (error who "line limit must be a positive exact integer" limit))
+    (let ([out (open-output-string)])
+      (let loop ([count 0])
+        (let ([ch (get-char port)])
+          (cond
+            [(eof-object? ch)
+             (if (= count 0) ch (get-output-string out))]
+            [(char=? ch #\newline) (get-output-string out)]
+            [(>= count limit)
+             (error who "signal-cli line exceeds configured character limit"
+                    limit)]
+            [else
+             (write-char ch out)
+             (loop (+ count 1))])))))
+
+  ) ;; end library
diff --git a/signal/cmd-tui.ss b/signal/cmd-tui.ss
index c38bd1a..732fc8d 100644
--- a/signal/cmd-tui.ss
+++ b/signal/cmd-tui.ss
@@ -1,9 +1,8 @@
 #!chezscheme
 ;;; signal/cmd-tui -- terminal UI entry point.
 ;;;
-;;; Starts the long-lived JSON-RPC actor in manual receive mode, subscribes
-;;; after the reader is running, and then runs the termbox2 TUI when
-;;; stdin/stdout are attached to a terminal.
+;;; Starts the long-lived JSON-RPC actor in live receive mode, then runs the
+;;; termbox2 TUI when stdin/stdout are attached to a terminal.
 
 (library (signal cmd-tui)
   (export cmd-tui)
@@ -24,7 +23,7 @@
           (signal tui main))
 
   (def (cmd-tui account)
-    (let ([receive-mode "manual"])
+    (let ([receive-mode "on-connection"])
       (if (and (tty? (current-input-port))
                (tty? (current-output-port)))
         (run-interactive-tui account receive-mode)
diff --git a/signal/log_crypto.ss b/signal/log_crypto.ss
index 204da78..3adea5a 100644
--- a/signal/log_crypto.ss
+++ b/signal/log_crypto.ss
@@ -18,40 +18,19 @@
                   partition
                   make-date make-time)
           (except (jerboa prelude) meta atom?)
-          (only (jerboa ffi) load-shared-object*)
-          (only (jerboa core) random-bytes))
+          (only (jerboa core) random-bytes)
+          (signal native-loader)
+          (signal security-native))
 
   (def *native-load-state* (box 'untried))
 
-  (def (try-load path)
-    (and path
-         (not (string=? path ""))
-         (file-exists? path)
-         (load-shared-object* path)))
-
-  (def (native-candidates)
-    (let* ([argv0 (car (command-line))]
-           [slash (let loop ([i (- (string-length argv0) 1)])
-                    (cond [(< i 0) #f]
-                          [(char=? (string-ref argv0 i) #\/) i]
-                          [else (loop (- i 1))]))]
-           [bin-dir (and slash (substring argv0 0 slash))]
-           [home (or (getenv "HOME") ".")]
-           [env (getenv "JERBOA_NATIVE_LIB")])
-      (append
-        (if env (list env) '())
-        (list "libjerboa_native.dylib"
-              "libjerboa_native.so"
-              "lib/libjerboa_native.dylib"
-              "lib/libjerboa_native.so")