Parse password-protected bank statements into voucher proposals, through PDFium - #444
Merged
Merged
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Owner
Author
|
Coordination note from #446, which touches the same file. #446 moves the inline
|
Owner
Author
|
Update: #446 merged as |
lamemustafa
force-pushed
the
bank-statement-parser-rust
branch
3 times, most recently
from
September 16, 2026 21:51
8d507df to
ef76fb8
Compare
lamemustafa
force-pushed
the
bank-statement-parser-rust
branch
from
September 16, 2026 22:33
ef76fb8 to
ce95b10
Compare
…gh a bundled PDFium, and build an import file from them Adds the bridge-bank-statement crate (a port of scripts/bank_statement_import.py's parsing half), the parse_bank_statement MCP tool, PDFium pinned by SHA-256 and bundled in the MCPB, build_import_xml by proposals_id, and admits amends_batch_id through tools/call argument validation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa
force-pushed
the
bank-statement-parser-rust
branch
from
September 16, 2026 22:48
ce95b10 to
78c4d23
Compare
lamemustafa
pushed a commit
that referenced
this pull request
Sep 16, 2026
#444 admitted amends_batch_id through tools/call. What stays open is why it was refused: validate_string_bounds refuses every value under a pattern admission does not implement, and nothing notices a new one. Split the matcher lookup out so a test can walk every tool's top-level string patterns through it, and admit the batch id with the build's own valid_batch_id, checked equal to is_uuid_v4_lowercase on edge inputs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa
added a commit
that referenced
this pull request
Sep 16, 2026
#444 admitted amends_batch_id through tools/call. What stays open is why it was refused: validate_string_bounds refuses every value under a pattern admission does not implement, and nothing notices a new one. Split the matcher lookup out so a test can walk every tool's top-level string patterns through it, and admit the batch id with the build's own valid_batch_id, checked equal to is_uuid_v4_lowercase on edge inputs. Co-authored-by: t <dev@example.invalid> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
parse_bank_statementis a local, read-only MCP tool that turns a password-protected SBI or HDFC bank-statement PDF into Payment / Receipt / Contra proposals, in exactly the voucher shapebuild_import_xmlaccepts. It ports the parsing half ofscripts/bank_statement_import.pyinto a new crate,src-tauri/crates/bridge-bank-statement, and reads PDFs through PDFium (pdfium-render0.9.4, dynamic binding).The ported half covers word geometry,
_linesand_dewrap, the SBI and HDFC profiles, account binding, the exact-decimal balance replay and control totals, the mapping with its suspense fallback, and the self-check. Every stage refuses the whole run.It mints no REMOTEID and renders no XML. Proposals carry
bridge_txn_idlabels only. The label is derived from the statement row (account, date, amounts, balance, narration), so re-parsing with a corrected mapping yields the same labels. Anamends_batch_idamendment has to name exactly those labels. A label is never a Tally key: Bridge's writer derives the REMOTEID from its own batch.build_import_xmlbuilds from the proposals file (third commit, owner's go-ahead). Passproposals_idandproposals_sha256, theproposals_idandsha256valuesparse_bank_statementreturned, in place ofvouchers.The resolver (
agent_bank_statement.rs::resolve_import_arguments) refuses in these cases:vouchersis also given, or either proposals field is missing;bank-statements/;proposals_changed).It then passes the file's vouchers to the unchanged admission path, which checks the schema, masters, cash/bank groups, and the amendment compare-and-swap. The only edit in
agent_import.rsis that one call. Nothing underagent_import*.rsdecides anything new.Found while doing it: amendments were unreachable through
tools/callon master. The catalog validator refuses any schema pattern outside its vocabulary, andamends_batch_id's pattern was not in it. So every MCP call namingamends_batch_idreturnedargument_invalid:amends_batch_idbefore the handler ran. #439's tests call the handler directly, which is why they pass. Measured withvalidate_tool_argumentson this branch before the fix. The validator now knows that exact shape, and the schema and validator share one constant.Decisions (owner choices marked)
<data_dir>/bank-statements/, written staged then renamed. The tool returns only what is needed to write the mapping: counterparty spellings grouped by mapping key, row counts and totals, disposition, suspense flag, and the ledger names to check withvalidate_masters. Every name is marked as a party, somask_partiesmasks it.password_file. It must be a regular file owned by this user, with a single link and no group or other permission bits on Unix. It is read into a zeroizing buffer, handed to PDFium once and dropped. It is never an argument, never echoed, and never written anywhere.packaging/pdfium/pdfium.lock.jsonpins bblanchon/pdfium-binarieschromium/7881(V8 and XFA disabled).scripts/fetch-pdfium.pychecks byte count and SHA-256 before extracting. PDF tests are#[ignore]d by default and fail rather than skip whenBRIDGE_PDFIUM_LIBRARYis unset. The native CI job fetches the library and runs them.pipeline::preparethat fixes the fail-closed order.src-tauri/src/agent_bank_statement.rsholds the tool: file admission, persistence and the summary. The tool is listed and dispatched only when imports are enabled, because it only prepares an import and its summary carries names.pdfium-renderis MIT/Apache-2.0.THIRD_PARTY_LICENSES_RUST.txtis regenerated with cargo-about 0.9.1 and the inventory check passes. PDFium itself is not shipped by this PR; see below.Packaging: PDFium is bundled in the MCPB (second commit, owner's go-ahead)
The MCPB manifest enables imports by default, so every archive lists
parse_bank_statement. The archive therefore now carries the pinned library.scripts/fetch-pdfium.pychecks three digests pinned per platform inpackaging/pdfium/pdfium.lock.json: the release archive (before extraction), the extracted library, andTHIRD_PARTY_LICENSES_PDFIUM.txt. That notice is generated from every licence file the archive ships, byte for byte. It is generated rather than committed because the Windows and macOS archives ship different licence bytes: CRLF line endings, and a//-prefixedpdfium.txton macOS.scripts/package-mcpb.mjsrequires--pdfium. It stages the library atbin/<triple>/libpdfium.dyliborpdfium.dll, besidebridge_mcpwhere the tool loads it, and the notice at the archive root.scripts/check-mcpb-bundle.pyrequires exactly 8 members and matches the library and notice to the host's pins. It then parses the synthetic encrypted statement through the unpacked bundle and requires 6 vouchers, with the password absent from stdout and stderr.release-mcpb-preview.ymlfetch the pinned build before staging.Archive size grows by roughly the library: 7,732,336 bytes on macOS arm64 and 7,211,520 bytes on Windows x64, before compression. The library ships unsigned and un-notarized, like
bridge_mcpitself. Whether macOS Gatekeeper lets a quarantined, installed MCPB load it has not been tested on a real client install.Measured
scripts/fixtures/{hdfc,sbi}-bbox-capture.xml).pdftotext -bbox-layoutwords produce (poppler 26.04.0). Those rows match four digests the Python reference computes over the poppler capture, including a whole-row JSON digest. Horizontal word edges agree within 0.01pt. Vertical edges are shifted by a constant per font: y0 by 1.43pt and y1 by 0.99pt on 7pt Courier, with spread under 0.01pt. The cause is that PDFium takes ascent from a substituted Courier New face, while poppler uses the AFM metrics.-. A test failed before the fix.pdfium-renderpanics on a password containing NUL, and its panic message prints the password's bytes. Such a password is now refused first (unusable_password).pdftotext -upwrejects the synthetic SBI file and-opwopens it. PDFium opens it with the one password supplied, and refuses wrong or empty passwords.malformed_input_is_refused_not_parsedvia the pdfium-render panic. 6 tool-surface controls were checked the same way: import gating in catalog and dispatch, password-file permissions, party marking, rows staying out of the result, and nested mapping validation. Two tests were missing and were added: the running balance in the label, and rotated-page refusal.cargo test --workspaceinsrc-tauri: 1,619 passed, 0 failed, 7 ignored (6 are these PDFium tests; they pass with the library).tools/: 55 passed. Clippy with-D warnings -A clippy::pedanticis clean on the workspace.scripts/reseal.sh --verifyis current. The fixture byte-integrity and provenance gate tests pass, and the new fixtures' declared hashes are machine-checked.Inferred or not measured
pdftotextrows on real statements, on the operator's machine, reporting counts only) is the next measurement.pdftotext, because the vertical shift is per font. Not observed.Deliberate divergences from the Python reference
\dalso accepts other scripts' digits; here they refuse (malformed_amount,unparseable_date).zero_amount_row), becausebuild_import_xmlrefuses zero amounts.build_import_xmlwould refuse (control characters,[BRIDGE:, length) is refused with its row number.buildis omitted: a RustMappingcan only be built byfrom_rows, which refuses sentinel keys, so the lock could never fire.csvcrate strips it; Python refused such a file asmapping_headers_missing. The tool itself takes the mapping inline.statement_<category>:row_<n>), so an operator gets less detail than the script gave._looks_like_utrand SBI's digit test use regex\d(Unicode Nd), while Python'sstr.isdigit()also accepts characters like superscript digits. Not reachable on the upper-case ASCII narrations seen so far; not measured.Compatibility surface
MAX_SURFACE_FILESgoes from 239 to 251, with twelve pins, each named with its reason in the constant's comment:agent_bank_statement.rs, the crateCargo.toml, andpdf,pipeline,money,parse,bank,geometry,text,mapping,dateandproposalsfrom the crate. Resealed with--pins-changed, then verified. No open PR changes the cap as of this push.Review
A Sonnet subagent reviewed the full diff against the Python reference, function by function. It ran both crates' tests, including the PDFium-gated ones, and the exact CI commands, and reproduced the
csvBOM behaviour standalone. It found no P1s. Its three narrower findings:\dversusisdigit()gap. Not reachable in practice; now listed above.It did not reproduce the mutation counts or the full-workspace total; those rest on the runs described above.
Second review (2026-09-17), after rebasing onto
ad13d7b0. A fresh Sonnet subagent reviewed the bundling commit and the proposals-build commit. It found no P1 or P2. It ran:fetch-pdfium.test.py: 5 tests.check-mcpb-bundle.test.py: 13 tests.package-mcpb.test.mjs: 7 tests.agent_importsuite (194 tests) and theagent::catalogsuite (7 tests).It also confirmed, with
--list, that the CI--ignoredfilter selects 4 tests rather than none.After the rebase I ran, locally:
cargo test --workspace: 1,622 passed, 0 failed, 8 ignored.tools/tests: pass.reseal.sh --verify: current.The conflict was only in the surface JSON. I took master's copy, re-added the same 12 pins, and resealed.
Proposals build: measured
tools/callagainst the simulator. A proposals build consumes the same 44 requests as the inline build of the same vouchers. The two import XML files are identical once REMOTEIDs and batch markers are blanked.parse_bank_statement, thenbuild_import_xmlbyproposals_id. Both calls go through tool dispatch against the simulator, withCashas the bank and a captured debtor as suspense. The file has 6 vouchers (4 Payment, 2 Receipt), and the narration carriesUPI 612345678901 from NORTHWIND TRADERSwith the wrapped reference whole. CI runs it with the pinned PDFium.proposals_changedafter a one-amount edit,proposals_sha256_required,proposals_id_required,vouchers_required,proposals_id_with_vouchers,proposals_not_found, a 46-character traversal id refused asargument_invalid:proposals_id, an uppercase digest,proposals_file_invalid, and a symlinked file (proposals_file_unreadable).tools/: 55 passed. Clippy is clean, and the reseal verifies. One run ofreseal-merge-driver.test.mjsfailed 2 cases straight after the commit, and a rerun passed all 3; not investigated further.Test plan
cargo test --locked --workspaceinsrc-tauri, and intools/BRIDGE_PDFIUM_LIBRARY=… cargo test -p bridge-bank-statement -- --ignoredandcargo test -p bridge --lib bank_statement -- --ignored(macOS arm64)cargo clippy --locked --workspace --all-targets -- -D warnings -A clippy::pedanticscripts/reseal.sh --verify;node scripts/check-fixture-byte-integrity.mjs; fixture provenance and byte-integrity gate tests;check-dependency-inventory.mjs --rust;check-mcpb-bundle.test.pypython3 scripts/generate-bank-statement-fixtures.py <fixtures> --checkpackage-mcpb.mjs --pdfium, a zip of the stage, andcheck-mcpb-bundle.py. 8 members, library pin matched,statement_vouchers: 6. Control: removing the dylib fails the smoke withstatement_pdf_engine_unavailable.fetch-pdfium.test.py(archive, library and notice refusals),check-mcpb-bundle.test.py(13),package-mcpb.test.mjs(7)mcpb pack: 8 archive members, the library digest matches its pin on each OS (1bc45b15…/79d4676b…), andstatement_vouchers: 6parsed through the bundle on both.build_import_xmlbuilds from a proposals file (bridge-aa was not running; no open PR touchedagent_import*.rs)🤖 Generated with Claude Code