Resolve security audit findings

ober

e4a0ba000c8f1759b8f2dc307f6e73ebd730032f

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d3c0483..df59fe5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,7 +21,7 @@ jobs:
   build-and-test:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
 
       - name: Install system tools
         run: |
@@ -30,14 +30,14 @@ jobs:
           sudo apt-get install -y --no-install-recommends build-essential git curl ca-certificates libssl-dev
 
       - name: Install Jerboa toolchain
-        run: sh support/ensure-jerboa.sh "$JERBOA_VERSION" .jerboa/bin
+        run: sh support/ensure-jerboa.sh "$JERBOA_VERSION" .jerboa/bin support/jerboa-toolchain.lock
 
       - name: Fetch dependencies
         run: |
           set -eu
           mkdir -p .deps
-          git clone --depth 1 "$JERBOA_SSL_REPO" "$JERBOA_SSL_DIR"
-          git clone --depth 1 "$JERBOA_MAIL_REPO" "$JERBOA_MAIL_DIR"
+          support/locked-dependency.sh fetch dependencies.lock jerboa-ssl "$JERBOA_SSL_DIR"
+          support/locked-dependency.sh fetch dependencies.lock jerboa-mail "$JERBOA_MAIL_DIR"
 
       - name: Release evidence
         run: make release-evidence
diff --git a/.github/workflows/security-baseline.yml b/.github/workflows/security-baseline.yml
index 28a713e..2a3187b 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
 
       - name: Required release files
         run: |
@@ -33,3 +33,6 @@ jobs:
             echo "High-confidence secret pattern found."
             exit 1
           fi
+
+      - name: Security policy regression gate
+        run: make security
diff --git a/.jerboa/security.json b/.jerboa/security.json
index 6a6d78d..df16a2b 100644
--- a/.jerboa/security.json
+++ b/.jerboa/security.json
@@ -12,9 +12,22 @@
   "policy": {
     "failOn": ["critical", "high"],
     "imports": { "directChezscheme": "deny" },
-    "ffi": { "allowed": false },
+    "ffi": {
+      "allowed": "reviewed-secure-open-only",
+      "requiredConvention": "open-varargs-after-2"
+    },
     "process": { "shellInterpolation": "deny" },
-    "network": { "requireTimeouts": true, "rawMailOutputEvidence": "deny" },
+    "network": {
+      "requireTimeouts": true,
+      "rawMailOutputEvidence": "deny",
+      "defaultTransport": "tls-fail-closed",
+      "plaintextTransport": "literal-loopback-explicit-only",
+      "maxImapLineBytes": 16384,
+      "maxImapLiteralBytes": 33554432,
+      "maxImapResponseBytes": 67108864,
+      "maxImapResponseEntries": 4096,
+      "responseDeadlineMilliseconds": 60000
+    },
     "eval": { "stringEval": "deny", "bareRead": "deny", "allowReadEval": false },
     "credentials": {
       "plaintextCache": "deny",
@@ -25,7 +38,15 @@
     "mail": {
       "defaultMutations": "deny",
       "requireBodyPeek": true,
-      "plaintextExportEvidence": "target-required"
+      "plaintextExportEvidence": "target-required",
+      "plaintextExportMode": "exclusive-nofollow-0600-atomic-publish",
+      "terminalRendering": "control-filtered",
+      "rawTerminalOutput": "explicit-opt-in-only"
+    },
+    "supplyChain": {
+      "dependencyLock": "dependencies.lock",
+      "toolchainLock": "support/jerboa-toolchain.lock",
+      "githubActions": "full-commit-sha-only"
     },
     "targetEvidence": {
       "bridgeProof": "fail-closed",
diff --git a/Makefile b/Makefile
index 72384b5..58c867f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,7 @@
 JERBOA_VERSION ?= v0.2.3
 JERBOA_TOOL_DIR ?= $(CURDIR)/.jerboa/bin
+JERBOA_TOOLCHAIN_LOCK ?= $(CURDIR)/support/jerboa-toolchain.lock
+DEPENDENCY_LOCK ?= $(CURDIR)/dependencies.lock
 JERBUILD ?= $(shell if [ -x "$(CURDIR)/jerbuild" ]; then echo "$(CURDIR)/jerbuild"; \
 	elif [ -x "$(JERBOA_TOOL_DIR)/jerbuild" ]; then echo "$(JERBOA_TOOL_DIR)/jerbuild"; \
 	elif [ -x "$(CURDIR)/../jerboa/dist/jerbuild" ]; then echo "$(CURDIR)/../jerboa/dist/jerbuild"; \
@@ -28,7 +30,7 @@ else
 endif
 LD_ENV = $(LD_VAR)="$(JERBOA_SSL_DIR)"
 
-.PHONY: help ensure-jerboa-tools ensure-deps deps build run test integration-test doctor verify security sanitize-evidence dependency-evidence sbom reproducibility-report target-evidence target-evidence-selftest release-evidence clean
+.PHONY: help ensure-jerboa-tools dependency-lock-check ensure-deps deps build run test integration-test doctor verify security sanitize-evidence dependency-evidence sbom reproducibility-report target-evidence target-evidence-selftest release-evidence clean
 .DEFAULT_GOAL := help
 
 help:
@@ -59,16 +61,21 @@ ensure-jerboa-tools:
 	  echo "=== Using Jerboa toolchain from PATH: $$(command -v jerbuild) ==="; \
 	else \
 	  echo "=== Fetching Jerboa $(JERBOA_VERSION) release tools into $(JERBOA_TOOL_DIR) ==="; \
-	  sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)"; \
+	  sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)" "$(JERBOA_TOOLCHAIN_LOCK)"; \
 	fi
 	@"$(JERBUILD)" --jerboa-home >/dev/null || { \
 	  echo "ERROR: Jerboa toolchain is unavailable; set JERBUILD=/path/to/jerbuild or JERBOA_VERSION=<tag>" >&2; \
 	  exit 1; \
 	}
 
-ensure-deps:
+dependency-lock-check:
+	@support/locked-dependency.sh check "$(DEPENDENCY_LOCK)"
+
+ensure-deps: dependency-lock-check
 	@test -d "$(JERBOA_SSL_DIR)" || { echo "ERROR: JERBOA_SSL_DIR missing: $(JERBOA_SSL_DIR)" >&2; exit 1; }
 	@test -d "$(JERBOA_MAIL_DIR)" || { echo "ERROR: JERBOA_MAIL_DIR missing: $(JERBOA_MAIL_DIR)" >&2; exit 1; }
+	@support/locked-dependency.sh verify "$(DEPENDENCY_LOCK)" jerboa-ssl "$(JERBOA_SSL_DIR)" >/dev/null
+	@support/locked-dependency.sh verify "$(DEPENDENCY_LOCK)" jerboa-mail "$(JERBOA_MAIL_DIR)" >/dev/null
 
 deps: ensure-jerboa-tools ensure-deps
 	@if [ ! -f "$(JERBOA_SSL_DIR)/lib/jerboa-ssl.sls" ] || [ ! -f "$(JERBOA_SSL_DIR)/jerboa_ssl_shim$(SO_EXT)" ]; then \
@@ -78,7 +85,7 @@ deps: ensure-jerboa-tools ensure-deps
 build: deps
 
 run: build
-	JERBOA_SSL_LIB="$(JERBOA_SSL_DIR)" $(LD_ENV) \
+	JERBOA_SSL_LIB="$(JERBOA_SSL_DIR)" JERBOA_SSL_ALLOW_DYNAMIC_NATIVE=1 $(LD_ENV) \
 		"$(JERBUILD)" exec --libdirs "$(LIBDIRS)" main.ss -- $(ARGS)
 
 doctor:
@@ -86,7 +93,7 @@ doctor:
 
 test: build
 	JERBOA_SSL_LIB="$(JERBOA_SSL_DIR)" $(LD_ENV) \
-		"$(JERBUILD)" exec --libdirs "$(LIBDIRS)" test/test-all.ss
+		JERBUILD="$(JERBUILD)" LIBDIRS="$(LIBDIRS)" sh test/run-tests.sh
 
 integration-test:
 	@if [ "$$PROTON_BRIDGE_INTEGRATION" != "1" ]; then \
@@ -111,12 +118,16 @@ security:
 	grep -q 'private_path_scan_status=pass' Makefile; \
 	grep -q 'target-bridge-proof-contains-sensitive-material' scripts/target-evidence.sh; \
 	grep -q 'proofMaterialPolicy' .jerboa/security.json; \
-	echo "secret_scan_status=pass"
+	echo "secret_scan_status=pass"; \
+	sh scripts/security-check.sh
 
 sanitize-evidence:
 	@sh scripts/sanitize-evidence.sh "$(RELEASE_EVIDENCE_DIR)"
 
 dependency-evidence: deps
+	@mkdir -p "$(RELEASE_EVIDENCE_DIR)"
+	@support/locked-dependency.sh verify "$(DEPENDENCY_LOCK)" jerboa-ssl "$(JERBOA_SSL_DIR)" > "$(RELEASE_EVIDENCE_DIR)/jerboa-ssl-lock.txt"
+	@support/locked-dependency.sh verify "$(DEPENDENCY_LOCK)" jerboa-mail "$(JERBOA_MAIL_DIR)" > "$(RELEASE_EVIDENCE_DIR)/jerboa-mail-lock.txt"
 	$(MAKE) -C "$(JERBOA_SSL_DIR)" JERBUILD="$(JERBUILD)" release-evidence
 	$(MAKE) -C "$(JERBOA_MAIL_DIR)" JERBUILD="$(JERBUILD)" release-evidence
 
@@ -165,6 +176,8 @@ release-evidence:
 	@$(MAKE) run ARGS='--help' > "$(RELEASE_EVIDENCE_DIR)/help.log" 2>&1
 	@echo "==> Running dependency release evidence"
 	@$(MAKE) dependency-evidence > "$(RELEASE_EVIDENCE_DIR)/dependency-evidence.log" 2>&1
+	@cp "$(DEPENDENCY_LOCK)" "$(RELEASE_EVIDENCE_DIR)/dependencies.lock"
+	@cp "$(JERBOA_TOOLCHAIN_LOCK)" "$(RELEASE_EVIDENCE_DIR)/jerboa-toolchain.lock"
 	@echo "==> Running reproducibility report"
 	@$(MAKE) reproducibility-report > "$(RELEASE_EVIDENCE_DIR)/reproducibility.log" 2>&1
 	@rm -rf "$(RELEASE_EVIDENCE_DIR)/reproducibility"
@@ -179,7 +192,7 @@ release-evidence:
 	@$(MAKE) sbom > "$(RELEASE_EVIDENCE_DIR)/sbom.log" 2>&1
 	@rm -rf "$(RELEASE_EVIDENCE_DIR)/sbom"
 	@cp -R "$(DIST_DIR)/sbom" "$(RELEASE_EVIDENCE_DIR)/sbom"
-	@find bin protonmail test docs support scripts .github .jerboa/security.json -type f -print | LC_ALL=C sort | xargs shasum -a 256 > "$(RELEASE_EVIDENCE_DIR)/source-hashes.sha256"
+	@find bin protonmail test docs support scripts .github .jerboa/security.json dependencies.lock Makefile README.md SECURITY.md plan.md main.ss -type f -print | LC_ALL=C sort | xargs shasum -a 256 > "$(RELEASE_EVIDENCE_DIR)/source-hashes.sha256"
 	@if ! grep -qx 'status=match' "$(RELEASE_EVIDENCE_DIR)/reproducibility/report.txt"; then \
 	  echo "reproducibility_match_status=fail" > "$(RELEASE_EVIDENCE_DIR)/reproducibility-match.status"; \
 	  { echo "release_evidence_status=blocked"; echo "blocked_reason=reproducibility-mismatch"; echo "evidence_privacy_status=not-run"; echo "private_path_scan_status=not-run"; } > "$(RELEASE_EVIDENCE_DIR)/result.txt"; \
diff --git a/README.md b/README.md
index 103ea6c..e6eb4c0 100644
--- a/README.md
+++ b/README.md
@@ -72,10 +72,15 @@ export PROTON_BRIDGE_HOST=127.0.0.1
 export PROTON_BRIDGE_PORT=1143
 export PROTON_BRIDGE_USER='bridge-generated-user'
 export PROTON_BRIDGE_PASSWORD='bridge-generated-password'
-export PROTON_BRIDGE_TLS=auto
+export PROTON_BRIDGE_TLS=loopback-plain
 ```
 
 Use Bridge-generated IMAP credentials, not your Proton account password.
+The default transport is `tls` and certificate or handshake failures are fatal.
+Because the common Bridge listener on port 1143 is plaintext, it requires the
+explicit mode `loopback-plain`; that mode accepts only literal `127.0.0.1` or
+`::1`. There is no opportunistic plaintext fallback and DNS names are rejected
+for plaintext.
 
 ## Commands
 
@@ -84,7 +89,7 @@ bin/protonmail-read doctor
 bin/protonmail-read folders
 bin/protonmail-read list --folder INBOX --limit 20
 bin/protonmail-read show --folder INBOX --uid 123
-bin/protonmail-read raw --folder INBOX --uid 123
+bin/protonmail-read raw --folder INBOX --uid 123 > message.eml
 bin/protonmail-read export-eml --folder INBOX --uid 123 --output message.eml
 bin/protonmail-read search --folder INBOX --from person@example.com
 bin/protonmail-read search --folder INBOX --subject invoice
@@ -98,7 +103,18 @@ messages as read. The tool does not issue `STORE`, `EXPUNGE`, `COPY`, `MOVE`,
 `APPEND`, `CREATE`, or `DELETE`.
 
 Exported `.eml` files contain plaintext mail after Bridge has decrypted it
-locally. Treat them as sensitive.
+locally. `export-eml` refuses existing destinations, symlinks, and untrusted
+group/world-writable parent directories; it creates a verified `0600` file and
+publishes complete content atomically. Treat exports as sensitive.
+
+Normal folder, summary, header, body, greeting, and error output filters ANSI,
+OSC, C0/C1, DEL, and bidirectional terminal controls. `raw` is intentionally
+unfiltered for exact RFC 5322 output and refuses a terminal unless the user
+passes `--unsafe-terminal`; piping it is preferred.
+
+IMAP input is bounded before allocation: 16 KiB per line, 32 MiB per message
+literal, 64 MiB per command response, 4096 response entries, 64 literals, and a
+60-second total response deadline in addition to socket idle timeouts.
 
 Credential and plaintext-mail handling are documented in
 [`docs/credential-handling.md`](docs/credential-handling.md). Dependency
diff --git a/SECURITY.md b/SECURITY.md
index 9179d20..a82eeca 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -16,6 +16,9 @@ experimental until the release checklist in `~/Release-plan.md` and
 - Use Proton Bridge generated IMAP credentials, not Proton account passwords.
 - Keep Bridge bound to loopback. Do not expose the Bridge IMAP listener on a
   shared or remote interface.
+- TLS is the fail-closed default. Plaintext requires the explicit
+  `loopback-plain` mode and literal `127.0.0.1` or `::1`; the client never falls
+  back from failed TLS to credential-bearing plaintext.
 - Do not commit Bridge usernames, Bridge passwords, `.env` files, exported
   `.eml`/`.mbox` messages, mailbox metadata, private hostnames, or user data.
 - Treat raw messages, decoded message bodies, headers, attachment filenames,
@@ -25,6 +28,12 @@ experimental until the release checklist in `~/Release-plan.md` and
   mutation commands without a threat model and tests.
 - Diagnostics must redact credentials and avoid printing message bodies unless
   the user explicitly requested `show`, `raw`, or `export-eml`.
+- Normal terminal output strips terminal controls. Exact `raw` output is an
+  explicit byte-oriented mode and requires `--unsafe-terminal` on a TTY.
+- IMAP lines, literals, aggregate response bytes, response/literal counts, and
+  total response time are bounded before allocation or accumulation.
+- `.eml` export never overwrites. It requires a trusted parent directory and
+  uses exclusive/no-follow `0600` files plus verified atomic publication.
 - Production release requires marker-complete live Proton Bridge target
   evidence through `JPROTONMAIL_TARGET_PROOF_FILE`; setting
   `JPROTONMAIL_REQUIRE_TARGET_PROOF=1` makes missing or incomplete proof fail
@@ -37,6 +46,8 @@ experimental until the release checklist in `~/Release-plan.md` and
   smoke, high-confidence secret scan, sibling `jerboa-ssl` and `jerboa-mail`
   release-evidence gates, SBOM/dependency capture, and launcher/source
   reproducibility checks.
+- CI actions, Jerboa toolchain archives, and the `jerboa-ssl`/`jerboa-mail`
+  checkouts are pinned by immutable SHA or commit-and-tree locks.
 
 Credential and plaintext-mail details are documented in
 [`docs/credential-handling.md`](docs/credential-handling.md).
diff --git a/bin/protonmail-read b/bin/protonmail-read
index 7108337..5e4ace6 100755
--- a/bin/protonmail-read
+++ b/bin/protonmail-read
@@ -5,6 +5,17 @@ repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
 jerboa_ssl_dir=${JERBOA_SSL_DIR:-"$repo_dir/../jerboa-ssl"}
 jerboa_mail_dir=${JERBOA_MAIL_DIR:-"$repo_dir/../jerboa-mail"}
 
+[ -d "$jerboa_ssl_dir" ] || {
+    echo "ERROR: JERBOA_SSL_DIR is not a directory" >&2
+    exit 1
+}
+[ -d "$jerboa_mail_dir" ] || {
+    echo "ERROR: JERBOA_MAIL_DIR is not a directory" >&2
+    exit 1
+}
+jerboa_ssl_dir=$(CDPATH= cd -P -- "$jerboa_ssl_dir" && pwd)
+jerboa_mail_dir=$(CDPATH= cd -P -- "$jerboa_mail_dir" && pwd)
+
 if [ "${JERBUILD:-}" ]; then
     jerbuild=$JERBUILD
 elif [ -x "$repo_dir/.jerboa/bin/jerbuild" ]; then
@@ -22,7 +33,8 @@ fi
 
 jerboa_home=$("$jerbuild" --jerboa-home)
 
-export JERBOA_SSL_LIB=${JERBOA_SSL_LIB:-"$jerboa_ssl_dir"}
+export JERBOA_SSL_LIB="$jerboa_ssl_dir"
+export JERBOA_SSL_ALLOW_DYNAMIC_NATIVE=1
 
 exec "$jerbuild" exec --libdirs "$repo_dir:$jerboa_mail_dir:$jerboa_ssl_dir/lib:$jerboa_home/lib" \
   "$repo_dir/main.ss" -- "$@"
diff --git a/dependencies.lock b/dependencies.lock
new file mode 100644
index 0000000..ced71b6
--- /dev/null
+++ b/dependencies.lock
@@ -0,0 +1,3 @@
+# name repository commit tree
+jerboa-ssl https://git.sr.ht/~lisp/jerboa-ssl 270b858534941baad6420e4906a26c279f4a8678 4d629174bbff9b1cd2ff1aa696be0cd33bf462e4
+jerboa-mail https://git.sr.ht/~lisp/jerboa-mail 5d6382371decb5ce93016b46a1710d65255401b0 48b15dbdb9e18749b6195f1cbbd7d2804c54b6a6
diff --git a/docs/credential-handling.md b/docs/credential-handling.md
index 69ae2db..c69ee43 100644
--- a/docs/credential-handling.md
+++ b/docs/credential-handling.md
@@ -13,7 +13,7 @@ PROTON_BRIDGE_HOST=127.0.0.1
 PROTON_BRIDGE_PORT=1143
 PROTON_BRIDGE_USER=bridge-generated-user
 PROTON_BRIDGE_PASSWORD=bridge-generated-password
-PROTON_BRIDGE_TLS=auto
+PROTON_BRIDGE_TLS=loopback-plain
 ```
 
 Use Bridge generated IMAP credentials only. Do not use a Proton account
@@ -27,6 +27,11 @@ chmod 0600 .protonmail-reader.local
 If a future config-file loader is added, it must reject group-readable or
 world-readable files when they contain a password.
 
+The configuration default is authenticated TLS and fails closed. Use
+`loopback-plain` only for a Bridge listener addressed literally as `127.0.0.1`
+or `::1`. `auto`, generic `plain`, DNS names, and TLS-to-plaintext fallback are
+rejected so a valid hostile greeting can never downgrade credential transport.
+
 ## Logging And Output
 
 The `doctor` command redacts the Bridge username and password. Error messages
@@ -36,9 +41,13 @@ mailbox responses that may contain message content.
 The `show`, `raw`, and `export-eml` commands intentionally disclose decrypted
 mail selected by the user. Treat their output as sensitive:
 
-- stdout may be captured by shell history wrappers, terminal logs, or calling
-  programs.
+- stdout may be captured by terminal logs or calling programs. Friendly output
+  filters terminal controls; exact `raw` bytes do not and are refused on a TTY
+  without `--unsafe-terminal`.
 - `.eml` exports are plaintext decrypted messages.
+- `export-eml` creates a verified private `0600` file, refuses existing names
+  and symlinks, requires an owner-controlled parent, and atomically publishes
+  only complete content.
 - headers can reveal correspondents, subjects, dates, and mailbox structure.
 - raw IMAP literals can contain attachments and embedded secrets.
 
diff --git a/docs/dependency-provenance.md b/docs/dependency-provenance.md
index 76f122c..a2b56a7 100644
--- a/docs/dependency-provenance.md
+++ b/docs/dependency-provenance.md
@@ -7,7 +7,7 @@ Dependency boundaries:
 
 - Proton Mail Bridge performs Proton authentication and local message
   decryption.
-- `jerboa-ssl` provides the local TLS transport when TLS is enabled.
+- `jerboa-ssl` provides the fail-closed TLS transport and bounded native reads.
 - `jerboa-mail` parses and decodes headers, MIME structure, and friendly message
   output.
 - This repository reads Bridge settings from environment variables and does not
@@ -17,3 +17,9 @@ Dependency boundaries:
 `jerboa-ssl` and `jerboa-mail` checkouts, then records their git state, hashes,
 OpenSSL advisory evidence, TLS reproducibility result, and mail parser
 dependency audit evidence in the local SBOM bundle.
+
+`dependencies.lock` pins both sibling repositories by exact commit and Git tree
+hash. CI fetches those commits directly and verifies the resulting trees.
+`support/jerboa-toolchain.lock` pins SHA-256 digests for every supported v0.2.3
+toolchain archive; the bootstrap no longer trusts a checksum downloaded beside
+the executable. GitHub Actions are pinned to full reviewed commit SHAs.
diff --git a/docs/release-evidence.md b/docs/release-evidence.md
index 5897f76..7446f47 100644
--- a/docs/release-evidence.md
+++ b/docs/release-evidence.md
@@ -7,9 +7,12 @@ The evidence bundle contains:
 
 - high-confidence tracked-file secret-scan output;
 - smoke-test output;
+- hostile framing, terminal-control, symlink/export, permission, and boundary
+  regression output;
 - CLI help smoke output;
 - sibling `jerboa-ssl` and `jerboa-mail` release-evidence logs;
 - SBOM-style source, workflow, launcher, dependency, and toolchain manifests;
+- exact dependency lock verification and the checked-in toolchain hash lock;
 - copied `jerboa-ssl` OpenSSL advisory and reproducibility evidence when
   available;
 - copied `jerboa-mail` dependency audit evidence when available;
diff --git a/docs/threat-model.md b/docs/threat-model.md
new file mode 100644
index 0000000..89b1895
--- /dev/null
+++ b/docs/threat-model.md
@@ -0,0 +1,31 @@
+# Threat Model
+
+`jerboa-protonmail` treats the configured IMAP endpoint, every message, every
+mailbox/header field, and every output pathname as untrusted. Bridge-generated
+credentials and decrypted message bytes are secrets.
+
+Security boundaries:
+
+- TLS is the default and fails closed. The only plaintext exception is the
+  explicit `loopback-plain` mode for literal `127.0.0.1` or `::1`; credentials
+  are never sent after a failed TLS attempt.
+- Socket idle timeouts and a monotonic command deadline bound slow peers. IMAP
+  line, token, nesting, literal, response-entry, literal-count, and cumulative
+  response limits are checked before allocation or list growth.
+- Friendly terminal rendering removes CSI, OSC, DCS/SOS/PM/APC, C0/C1, DEL,
+  and bidirectional override/isolate controls. Exact raw RFC 5322 output is a
+  separate explicit mode and is not silently rendered to a terminal.
+- Plaintext export requires an owner-controlled parent that is not
+  group/world-writable. Random temporary and destination-reservation files use
+  exclusive, no-follow, close-on-exec creation at mode `0600`; device/inode,
+  type, owner, link count, size, mode, and parent identity are verified around
+  atomic publication. Existing destinations are never overwritten.
+- `dependencies.lock`, the Jerboa toolchain digest lock, and full GitHub Action
+  SHAs define the reviewed build graph.
+
+Out of scope for local evidence are compromise of the Proton account or Bridge
+process, a hostile actor already running as the same OS user with access to the
+Bridge credentials, kernel/filesystem compromise, certificate-authority
+compromise, and availability after the documented bounded limits are reached.
+Production claims additionally require marker-complete live target evidence;
+mail or credentials must never be retained in that evidence.
diff --git a/plan.md b/plan.md
index 0d39fb2..3d6070b 100644
--- a/plan.md
+++ b/plan.md
@@ -167,7 +167,7 @@ The binary can be named `protonmail-read`.
 protonmail-read folders
 protonmail-read list --folder INBOX --limit 20
 protonmail-read show --folder INBOX --uid 12345
-protonmail-read raw --folder INBOX --uid 12345
+protonmail-read raw --folder INBOX --uid 12345 > message.eml
 protonmail-read search --folder INBOX --from someone@example.com
 protonmail-read search --folder INBOX --subject invoice
 protonmail-read search --folder INBOX --since 2026-01-01
@@ -184,7 +184,7 @@ PROTON_BRIDGE_HOST=127.0.0.1
 PROTON_BRIDGE_PORT=1143
 PROTON_BRIDGE_USER=bridge-generated-user
 PROTON_BRIDGE_PASSWORD=bridge-generated-password
-PROTON_BRIDGE_TLS=auto
+PROTON_BRIDGE_TLS=loopback-plain
 ```
 
 Optional later local config path:
@@ -198,6 +198,14 @@ Rules:
 - Add local config files to `.gitignore`.
 - Refuse to read config files that are group/world-readable if they contain a password.
 - Redact all secrets from diagnostics.
+- Default to fail-closed TLS. Permit plaintext only as explicit
+  `loopback-plain` to literal `127.0.0.1` or `::1`; never fall back after a TLS
+  failure.
+- Bound IMAP lines, literals, response counts, cumulative bytes, parser depth,
+  and total response time before allocation or accumulation.
+- Filter terminal controls in friendly output and require explicit unsafe TTY
+  opt-in for exact raw mail bytes.
+- Publish `.eml` only through exclusive/no-follow private atomic export.
 
 ## Architecture
 
diff --git a/protonmail/cli.ss b/protonmail/cli.ss
index f5528e8..58088e3 100644
--- a/protonmail/cli.ss
+++ b/protonmail/cli.ss
@@ -7,6 +7,9 @@
   (import (rnrs)
           (protonmail config)
           (protonmail imap client)
+          (protonmail secure-file)
+          (protonmail terminal)
+          (only (std os posix) posix-isatty)
           (jerboa-mail encoding)
           (jerboa-mail header)
           (jerboa-mail mime))
@@ -21,19 +24,23 @@
       "  folders                         List folders (phase 3)\n"
       "  list --folder INBOX --limit N   List messages (phase 3)\n"
       "  show --folder INBOX --uid UID   Decode one message (phase 5)\n"
-      "  raw --folder INBOX --uid UID    Print raw RFC 5322 message (phase 4)\n"
-      "  export-eml --uid UID --output F Export raw message (phase 4)\n"
+      "  raw --folder INBOX --uid UID    Write unfiltered RFC 5322 bytes; a TTY requires --unsafe-terminal\n"
+      "  export-eml --uid UID --output F Atomically create a private 0600 message file; never overwrites\n"
       "  search --folder INBOX ...       Search messages (phase 6)\n"
       "  doctor                          Check local configuration\n"
       "  version                         Print version\n"
       "  help                            Print this help\n"))
 
   (define (println s)
-    (display s)
+    (display (terminal-safe-field s))
+    (newline))
+
+  (define (println-field s)
+    (display (terminal-safe-field s))
     (newline))
 
   (define (die code msg)
-    (display msg (current-error-port))
+    (display (terminal-safe-field msg) (current-error-port))
     (newline (current-error-port))
     (exit code))
 
@@ -71,7 +78,7 @@
       (require-complete-config cfg)
       (let ([folders (run-network-command
                        (lambda () (imap-list-folders cfg)))])
-        (for-each println folders))))
+        (for-each println-field folders))))
 
   (define (option-ref args name default)
     (let loop ([xs args])
@@ -88,7 +95,7 @@
            [n (and raw (string->number raw))])
       (cond
         [(not raw) default]
-        [(and n (integer? n) (> n 0)) n]
+        [(and n (integer? n) (exact? n) (> n 0) (<= n 1000)) n]
         [else (die 2 (string-append "invalid number for " name))])))
 
   (define (required-option args name)
@@ -97,16 +104,22 @@
           value
           (die 2 (string-append "missing required option " name)))))
 
+  (define (option-present? args name)
+    (let loop ([xs args])
+      (and (pair? xs)
+           (or (string=? (car xs) name)
+               (loop (cdr xs))))))
+
   (define (print-summary-row s)
     (println
       (string-append
-        (assoc-value 'uid s "")
+        (terminal-safe-field (assoc-value 'uid s ""))
         "\t"
-        (assoc-value 'date s "")
+        (terminal-safe-field (assoc-value 'date s ""))
         "\t"
-        (assoc-value 'from s "")
+        (terminal-safe-field (assoc-value 'from s ""))
         "\t"
-        (assoc-value 'subject s ""))))
+        (terminal-safe-field (assoc-value 'subject s "")))))
 
   (define (print-summary-table summaries)
     (println "UID\tDate\tFrom\tSubject")
@@ -173,11 +186,6 @@
       (put-bytevector p bv)
       (flush-output-port p)))
 
-  (define (write-file-bytes path bv)
-    (call-with-port (open-file-output-port path (file-options no-fail))
-      (lambda (p)
-        (put-bytevector p bv))))
-
   (define (fetch-raw-from-args args)
     (let* ([cfg (config-from-environment)]
            [folder (option-ref args "--folder" "INBOX")]
@@ -188,20 +196,26 @@
           (imap-fetch-raw-message cfg folder uid)))))
 
   (define (cmd-raw args)
+    (when (and (posix-isatty 1)
+               (not (option-present? args "--unsafe-terminal")))
+      (die 2
+           "refusing to write unfiltered mail to a terminal; pipe it or pass --unsafe-terminal explicitly"))
     (write-stdout-bytes (fetch-raw-from-args args)))
 
   (define (cmd-export-eml args)
     (let* ([output (required-option args "--output")]
-           [message (fetch-raw-from-args args)])
-      (write-file-bytes output message)
-      (println (string-append "wrote " output))))
+           [message (fetch-raw-from-args args)]
+           [published (secure-export-bytes output message)])
+      (println-field
+        (string-append "wrote private 0600 export " published))))
 
   (define (decoded-header headers name)
     (mail-decode-encoded-words (mail-header-ref headers name "")))
 
   (define (print-header-if-present label value)
     (when (> (string-length value) 0)
-      (println (string-append label ": " value))))
+      (println-field
+        (string-append label ": " (terminal-safe-field value)))))
 
   (define (cmd-show args)
     (let* ([raw (fetch-raw-from-args args)]
@@ -214,7 +228,7 @@
       (print-header-if-present "Date" (mail-header-ref headers "Date" ""))
       (print-header-if-present "Subject" (decoded-header headers "Subject"))
       (newline)
-      (display (mail-best-text-body msg))
+      (display (terminal-safe-text (mail-best-text-body msg)))
       (newline)))
 
   (define (cmd-doctor)
diff --git a/protonmail/config.ss b/protonmail/config.ss
index b9db043..e2e56b2 100644
--- a/protonmail/config.ss
+++ b/protonmail/config.ss
@@ -9,6 +9,7 @@
     config-user
     config-password
     config-tls
+    config-loopback-host?
     config-from-environment
     config-complete?
     config-errors
@@ -43,35 +44,51 @@
         (if port-env (valid-port-string->number port-env) 1143)
         (or (env "PROTON_BRIDGE_USER") "")
         (or (env "PROTON_BRIDGE_PASSWORD") "")
-        (or (env "PROTON_BRIDGE_TLS") "auto"))))
+        (or (env "PROTON_BRIDGE_TLS") "tls"))))
 
   (define (nonempty? s)
     (and (string? s) (> (string-length s) 0)))
 
+  (define (config-loopback-host? host)
+    (and (string? host)
+         (or (string=? host "127.0.0.1")
+             (string=? host "::1"))))
+
+  (define (safe-host? host)
+    (and (nonempty? host)
+         (let loop ([i 0])
+           (cond
+             [(= i (string-length host)) #t]
+             [else
+              (let ([n (char->integer (string-ref host i))])
+                (and (> n 32) (< n 127) (not (= n 127))
+                     (loop (+ i 1))))]))))
+
   (define (valid-tls-mode? mode)
     (and (string? mode)
-         (or (string-ci=? mode "auto")
-             (string-ci=? mode "tls")
-             (string-ci=? mode "ssl")
-             (string-ci=? mode "plain")
-             (string-ci=? mode "true")
-             (string-ci=? mode "yes")
-             (string-ci=? mode "false")
-             (string-ci=? mode "no")
-             (string-ci=? mode "off"))))
+         (or (string-ci=? mode "tls")
+             (string-ci=? mode "loopback-plain"))))
 
   (define (config-errors cfg)
     (let ([errors '()])
-      (unless (nonempty? (config-host cfg))
-        (set! errors (cons "PROTON_BRIDGE_HOST is empty" errors)))
-      (unless (number? (config-port cfg))
+      (unless (safe-host? (config-host cfg))
+        (set! errors (cons "PROTON_BRIDGE_HOST must be a nonempty ASCII host without whitespace or controls" errors)))
+      (unless (and (integer? (config-port cfg))
+                   (exact? (config-port cfg))
+                   (<= 1 (config-port cfg) 65535))
         (set! errors (cons "PROTON_BRIDGE_PORT must be an integer from 1 to 65535" errors)))
       (unless (nonempty? (config-user cfg))
         (set! errors (cons "PROTON_BRIDGE_USER is unset" errors)))
       (unless (nonempty? (config-password cfg))
         (set! errors (cons "PROTON_BRIDGE_PASSWORD is unset" errors)))
       (unless (valid-tls-mode? (config-tls cfg))
-        (set! errors (cons "PROTON_BRIDGE_TLS must be auto, tls, or plain" errors)))
+        (set! errors (cons "PROTON_BRIDGE_TLS must be tls or loopback-plain" errors)))
+      (when (and (string? (config-tls cfg))
+                 (string-ci=? (config-tls cfg) "loopback-plain")
+                 (not (config-loopback-host? (config-host cfg))))
+        (set! errors
+              (cons "loopback-plain transport requires literal host 127.0.0.1 or ::1"
+                    errors)))
       (reverse errors)))
 
   (define (config-complete? cfg)
diff --git a/protonmail/imap/client.ss b/protonmail/imap/client.ss
index 8fa4e7d..74631c2 100644
--- a/protonmail/imap/client.ss
+++ b/protonmail/imap/client.ss
@@ -13,10 +13,21 @@
 
   (import (rnrs)
           (protonmail config)
+          (protonmail imap framing)
           (protonmail imap parser)
           (protonmail imap transport)
           (jerboa-mail header))
 
+  (define imap-max-header-bytes 1048576)
+  (define imap-max-message-bytes 33554432)
+  (define imap-max-result-count 1000)
+
+  (define header-imap-limits
+    (make-imap-limits 16384 imap-max-header-bytes 2097152 256 4 30000))
+
+  (define message-imap-limits
+    (make-imap-limits 16384 imap-max-message-bytes 35651584 256 4 60000))
+
   (define (make-client tr greeting)
     (vector tr greeting 0))
 
@@ -41,12 +52,18 @@
       (string-append "A" (number->string n))))
 
   (define (imap-quote s)
+    (unless (string? s)
+      (error 'imap-quote "value must be a string"))
     (call-with-string-output-port
       (lambda (out)
         (write-char #\" out)
         (let loop ([i 0])
           (when (< i (string-length s))
             (let ([ch (string-ref s i)])
+              (let ([n (char->integer ch)])
+                (when (or (< n 32) (= n 127))
+                  (error 'imap-quote
+                         "IMAP quoted value contains a forbidden control character")))
               (when (or (char=? ch #\\) (char=? ch #\"))
                 (write-char #\\ out))
               (write-char ch out)
@@ -56,75 +73,70 @@
   (define (imap-command c command)
     (map imap-entry-line (imap-command-entries c command)))
 
-  (define (make-imap-entry line response literal tail)
-    (vector 'imap-entry line response literal tail))
-
-  (define (imap-entry-line e) (vector-ref e 1))
-  (define (imap-entry-response e) (vector-ref e 2))
-  (define (imap-entry-literal e) (vector-ref e 3))
-
-  (define (imap-command-entries c command)
+  (define (imap-command-entries c command . options)
     (let* ([tag (next-tag c)]
-           [wire (string-append tag " " command)])
+           [wire (string-append tag " " command)]
+           [limits (if (pair? options) (car options) default-imap-limits)]
+           [tr (client-transport c)])
       (transport-write-line (client-transport c) wire)
-      (let loop ([entries '()])
-        (let ([line (transport-read-line (client-transport c))])
-          (let* ([response (imap-parse-line line)]
-                 [literal-len (imap-response-literal-length response)]
-                 [literal (if literal-len
-                              (transport-read-bytes (client-transport c) literal-len)
-                              #f)]
-                 [tail (if literal-len
-                           (transport-read-line (client-transport c))
-                           #f)]
-                 [entry (make-imap-entry line response literal tail)]
-                 [new-entries (cons entry entries)])
-            (if (string-prefix? tag line)
-                (reverse new-entries)
-                (loop new-entries)))))))
+      (imap-read-response-entries
+        tag
+        (lambda (max-line deadline)
+          (transport-read-line tr max-line deadline))
+        (lambda (length max-literal deadline)
+          (transport-read-bytes tr length max-literal deadline))
+        limits)))
 
   (define (imap-final-ok? lines)
     (let ([line (last lines)])
       (and line
-           (let loop ([i 0])
-             (cond
-               [(> (+ i 3) (string-length line)) #f]
-               [(string=? " OK" (substring line i (+ i 3))) #t]
-               [else (loop (+ i 1))])))))
+           (let ([response (guard (e [#t #f])
+                             (imap-parse-line line))])
+             (and response
+                  (eq? (imap-response-kind response) 'tagged)
+                  (string? (imap-response-status response))
+                  (string-ci=? (imap-response-status response) "OK"))))))
 
   (define (require-ok who lines)
     (unless (imap-final-ok? lines)
-      (error who (if (null? lines) "no IMAP response" (last lines))))
+      ;; Do not include hostile server text: a malicious listener could echo
+      ;; the LOGIN command and credentials into diagnostics.
+      (error who (if (null? lines)
+                     "no IMAP response"
+                     "IMAP command did not complete successfully")))
     lines)
 
   (define (normalize-tls-mode mode)
     (cond
-      [(or (string=? mode "tls") (string=? mode "ssl")
-           (string=? mode "true") (string=? mode "yes"))
-       "tls"]
-      [(or (string=? mode "plain") (string=? mode "false")
-           (string=? mode "no") (string=? mode "off"))
-       "plain"]
-      [(string=? mode "auto") "auto"]
-      [else (error 'imap-connect "invalid TLS mode")]))
+      [(and (string? mode) (string-ci=? mode "tls")) "tls"]
+      [(and (string? mode) (string-ci=? mode "loopback-plain"))
+       "loopback-plain"]
+      [else
+       (error 'imap-connect
+              "transport mode must be tls or loopback-plain")]))
 
   (define (open-mode cfg mode)
     (let ([tr (transport-connect (config-host cfg) (config-port cfg) mode)])
-      (let ([greeting (guard (e [#t (transport-close tr) (raise e)])
-                        (transport-read-line tr))])
-        (make-client tr greeting))))
+      (let ([greeting
+             (guard (e [#t (transport-close tr) (raise e)])
+               (transport-read-line
+                 tr
+                 (imap-limits-max-line-bytes default-imap-limits)
+                 (transport-make-deadline 10000)))])
+        (let ([response (guard (e [#t (transport-close tr) (raise e)])
+                          (imap-parse-line greeting))])
+          (unless (and (eq? (imap-response-kind response) 'untagged)
+                       (string? (imap-response-name response))
+                       (string-ci=? (imap-response-name response) "OK"))
+            (transport-close tr)
+            (error 'imap-connect "Bridge returned an invalid IMAP greeting"))
+          (make-client tr greeting)))))
 
   (define (open-client cfg)
     (let ([mode (normalize-tls-mode (config-tls cfg))])
-      (cond
-        [(string=? mode "auto")
-         (let ([plain-result (guard (e [#t e])
-                               (open-mode cfg "plain"))])
-           (if (condition? plain-result)
-               (open-mode cfg "tls")
-               plain-result))]
-        [else
-         (open-mode cfg mode)])))
+      ;; There is deliberately no opportunistic plaintext fallback. A TLS
+      ;; handshake or certificate failure is fatal.
+      (open-mode cfg mode)))
 
   (define (count-list-lines lines)
     (let loop ([xs lines] [n 0])
@@ -233,7 +245,7 @@
                       (char<=? ch #\9)
                       (loop (+ i 1))))))))
 
-  (define (fetch-literal-by-uid client uid fetch-item)
+  (define (fetch-literal-by-uid client uid fetch-item limits)
     (unless (decimal-uid? uid)
       (error 'imap-fetch "invalid decimal UID" uid))
     (let* ([command (string-append
@@ -241,20 +253,26 @@
                       uid
                       " "
                       fetch-item)]
-           [entries (imap-command-entries client command)]
+           [entries (imap-command-entries client command limits)]
            [lines (map imap-entry-line entries)])
       (require-ok 'imap-fetch lines)
-      (let loop ([xs entries])
+      (let loop ([xs entries] [found #f])
         (cond
-          [(null? xs) (error 'imap-fetch "response did not include a literal")]
+          [(null? xs)
+           (if found
+               found
+               (error 'imap-fetch "response did not include a literal"))]
           [(imap-entry-literal (car xs))
-           (imap-entry-literal (car xs))]
-          [else (loop (cdr xs))]))))
+           (if found
+               (error 'imap-fetch "response included multiple literals")
+               (loop (cdr xs) (imap-entry-literal (car xs))))]
+          [else (loop (cdr xs) found)]))))
 
   (define (fetch-header-by-uid client uid)
     (utf8->string
       (fetch-literal-by-uid client uid
-                            "BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)]")))
+                            "BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)]"
+                            header-imap-limits)))
 
   (define (summary-for-uid client uid)
     (let* ([header-text (fetch-header-by-uid client uid)]
@@ -266,6 +284,10 @@
         (cons 'subject (mail-header-ref headers "Subject" "")))))
 
   (define (imap-list-message-summaries cfg folder limit)
+    (unless (and (integer? limit) (exact? limit)
+                 (> limit 0) (<= limit imap-max-result-count))
+      (error 'imap-list-message-summaries
+             "limit must be between 1 and 1000"))
     (imap-with-session cfg
       (lambda (client)
         (require-ok 'imap-select
@@ -285,6 +307,10 @@
               (loop (cdr xs) (string-append acc sep (car xs)))))))
 
   (define (imap-search-message-summaries cfg folder criteria limit)
+    (unless (and (integer? limit) (exact? limit)
+                 (> limit 0) (<= limit imap-max-result-count))
+      (error 'imap-search-message-summaries
+             "limit must be between 1 and 1000"))
     (imap-with-session cfg
       (lambda (client)
         (require-ok 'imap-select
@@ -305,6 +331,6 @@
         (require-ok 'imap-select
                     (imap-command client
                                   (string-append "SELECT " (imap-quote folder))))
-        (fetch-literal-by-uid client uid "BODY.PEEK[]"))))
+        (fetch-literal-by-uid client uid "BODY.PEEK[]" message-imap-limits))))
 
   ) ;; end library
diff --git a/protonmail/imap/framing.ss b/protonmail/imap/framing.ss
new file mode 100644
index 0000000..11863a4
--- /dev/null
+++ b/protonmail/imap/framing.ss
@@ -0,0 +1,157 @@
+#!chezscheme
+;;; (protonmail imap framing) - bounded cumulative IMAP response reader.
+
+(library (protonmail imap framing)
+  (export
+    make-imap-limits
+    default-imap-limits
+    imap-limits-max-line-bytes
+    imap-limits-max-literal-bytes
+    imap-limits-max-response-bytes
+    imap-limits-max-entries
+    imap-limits-max-literals
+    imap-limits-deadline-milliseconds
+    imap-read-response-entries
+    imap-entry-line
+    imap-entry-response
+    imap-entry-literal
+    imap-entry-tail)
+
+  (import (rnrs)
+          (protonmail imap parser)
+          (protonmail imap transport))
+
+  ;; max-line, max-literal, max-total, max-entries, max-literals, deadline-ms
+  (define (make-imap-limits max-line max-literal max-total
+                            max-entries max-literals deadline-ms)
+    (for-each
+      (lambda (item)
+        (unless (and (integer? (cdr item)) (exact? (cdr item))
+                     (> (cdr item) 0))
+          (error 'make-imap-limits
+                 "all IMAP limits must be positive exact integers"
+                 (car item))))
+      (list (cons 'max-line max-line)
+            (cons 'max-literal max-literal)
+            (cons 'max-total max-total)
+            (cons 'max-entries max-entries)
+            (cons 'max-literals max-literals)
+            (cons 'deadline-ms deadline-ms)))
+    (when (> max-line max-total)
+      (error 'make-imap-limits "line limit exceeds aggregate response limit"))
+    (when (> max-literal max-total)
+      (error 'make-imap-limits "literal limit exceeds aggregate response limit"))
+    (vector 'imap-limits max-line max-literal max-total
+            max-entries max-literals deadline-ms))
+
+  (define (imap-limits-max-line-bytes limits) (vector-ref limits 1))
+  (define (imap-limits-max-literal-bytes limits) (vector-ref limits 2))
+  (define (imap-limits-max-response-bytes limits) (vector-ref limits 3))
+  (define (imap-limits-max-entries limits) (vector-ref limits 4))
+  (define (imap-limits-max-literals limits) (vector-ref limits 5))
+  (define (imap-limits-deadline-milliseconds limits) (vector-ref limits 6))
+
+  (define default-imap-limits
+    (make-imap-limits
+      transport-default-max-line-bytes
+      transport-default-max-literal-bytes
+      67108864                         ; 64 MiB cumulative response
+      4096
+      64
+      60000))
+
+  (define (make-imap-entry line response literal tail)
+    (vector 'imap-entry line response literal tail))
+
+  (define (imap-entry-line entry) (vector-ref entry 1))
+  (define (imap-entry-response entry) (vector-ref entry 2))
+  (define (imap-entry-literal entry) (vector-ref entry 3))
+  (define (imap-entry-tail entry) (vector-ref entry 4))
+
+  (define (checked-add-total total addition maximum)
+    (unless (and (integer? addition) (exact? addition) (>= addition 0))
+      (error 'imap-response "invalid response byte count"))
+    (when (> addition (- maximum total))
+      (error 'imap-response "aggregate IMAP response exceeds configured limit"))
+    (+ total addition))
+
+  (define (checked-line read-line max-line deadline)
+    (let ([line (read-line max-line deadline)])
+      (unless (string? line)
+        (error 'imap-response "line reader did not return a string"))
+      (when (> (string-length line) max-line)
+        (error 'imap-response "IMAP line exceeds configured limit"))
+      line))
+
+  (define (tagged-for? response tag)
+    (and (eq? (imap-response-kind response) 'tagged)
+         (string? (imap-response-tag response))
+         (string=? (imap-response-tag response) tag)))
+
+  ;; READ-LINE receives (max-line deadline). READ-BYTES receives
+  ;; (length max-literal deadline). The reader is callback-based so hostile
+  ;; framing can be tested without a live Bridge or native socket.
+  (define (imap-read-response-entries tag read-line read-bytes limits)
+    (unless (and (string? tag) (> (string-length tag) 0))
+      (error 'imap-response "expected a nonempty command tag"))
+    (unless (and (procedure? read-line) (procedure? read-bytes))
+      (error 'imap-response "response readers must be procedures"))
+    (let* ([max-line (imap-limits-max-line-bytes limits)]
+           [max-literal (imap-limits-max-literal-bytes limits)]
+           [max-total (imap-limits-max-response-bytes limits)]
+           [max-entries (imap-limits-max-entries limits)]
+           [max-literals (imap-limits-max-literals limits)]
+           [deadline
+            (transport-make-deadline
+              (imap-limits-deadline-milliseconds limits))])
+      (let loop ([entries '()] [entry-count 0]
+                 [literal-count 0] [total 0])
+        (when (transport-deadline-expired? deadline)
+          (error 'imap-response "total IMAP response deadline exceeded"))
+        (when (>= entry-count max-entries)
+          (error 'imap-response "IMAP response entry count exceeds configured limit"))
+        (let* ([line (checked-line read-line max-line deadline)]
+               [line-total (checked-add-total total