Triage expands 9% of the bytes, and the URL fetch stays on your machine

Two releases for one caller: an agent deciding whether an untrusted document is worth ingesting at all. Both were measured, and one was attacked, before shipping.

Reference: API quickstart

An agent that routes intake asks a narrower question than a fraud reviewer: is this untrusted file worth a full inspection at all, and what will looking cost me? Engines 1.34.0 and 1.35.0 exist for that caller. The first makes the engine's measured I/O cost an output and adds a cheap triage scope; the second lets the MCP tools take a URL, fetched on the caller's own machine. Per the house rule, the triage was measured before it shipped, on 5,168 documents. The URL fetch got something better: an adversarial review that found two real holes.

The cost becomes an output

The parser has always spent decompression against a per-document budget; now it keeps the receipt. Every report carries summary.cost: physical bytes read, decompressed bytes produced, and the ratio between them. Both counts are deterministic, which is what lets them ride in a body that is otherwise byte-identical for identical input. Wall-clock time is not deterministic, so it stays out of the default body and appears only when asked for with ?timing=1. Byte counters in, wall-clock out: the determinism claim survives the feature.

Triage: the one expensive pass, skipped and disclosed

?mode=triage on /api/v1/inspect, or the triage_document MCP tool, reads a PDF for structure, metadata, signatures and presence flags, and skips the per-page content walk: the glyph geometry, the text runs, the arithmetic, the redaction paint order. The measurement of what that trade costs and buys:

measured over 5,168 documentsresult
decompressed output, triage as a share of full9.18%
median per-document ratio0.0000
routes the same as full (both clean, or both flagged)90.29%
of full-flagged documents, triage also flags70.90%
false clean: full flags, triage says clean502 (9.71%)

The half of that table worth trusting is the structural half: triage reproduces every structural, metadata and signature family count-for-count. 1,105 incremental-updates firings at medium or above under a full parse, 1,105 under triage. 851 id-inconsistency, 851. 151 signature-coverage, 151. For the tier that answers "was this file edited, appended to, re-issued, or signed", the cheap scope is not an approximation of the full one. It is the full one, at 9% of the decompression.

The other half is a blind spot, and it is by construction, not by degree. All 502 false cleans come from exactly three families: hybrid-page (290), redaction-exposure (221) and embedded-image-anomalies (23). Every one is a page-content family, produced by the walk triage skips. Not a single false clean comes from a structural, metadata or signature family.

Which is why the report does not leave the gap to be inferred from silence. A triage report carries a triage block naming what did not run and saying in words that a quiet triage is not a clean document; it is a document whose cheap tells were absent. It is the same rule that stops a parse-failed report from reading as reassurance, applied to a scope instead of a failure.

The URL fetch that deliberately is not an API feature

As of 1.35.0 the MCP tools accept an http or https URL instead of a local path: inspect_document, check_redaction, and compare_documents as originalUrl and candidateUrl. The REST API deliberately did not grow the same feature, because a server-side fetch of a caller-supplied URL is an SSRF primitive, and the production box has neighbours that listen only on the internal network. The MCP server runs on the caller's machine, so the fetch leaves from there and reaches only what the caller can already reach.

That moves the risk. It does not zero it, because the caller's machine has a localhost too, and an agent an attacker is steering is exactly the kind of caller this server expects. So the fetch shipped guarded: http and https only, re-checked at every redirect hop, a cap of five redirects, a 30 second timeout, and the API's own 10 MB limit enforced on the bytes as they arrive, because Content-Length can lie.

Then an adversarial review of the new surface found the two holes worth a post. First: the scheme check never looked at the destination address, so http://127.0.0.1 and http://169.254.169.254, the cloud metadata endpoint, sailed through as perfectly valid http. The fetch now resolves the hostname, checks the address against the non-routable ranges (loopback, link-local, private, CGNAT, reserved, and their IPv4-mapped IPv6 forms), and then pins the connection to the validated address, which closes the rebinding hole where a hostname resolves public for the check and private for the connect. Second: compare_documents was returning its report raw, while every other tool elides attacker-authored strings. A prompt injection planted in a /Title, which inspect_document detects and elides, rode back out through compare wearing a trusted label. It now passes through the same instruction-shaped-string criterion the injection families use, and the equality verdicts survive with the payloads replaced.

The sentence the README now says plainly

The path argument reads any local file the process can read and sends its bytes to the configured deployment. That was always true, and it is now documented instead of discoverable: run the server only with agents and inputs you trust. triage_document stays path-only.

The numbers that will not travel

The 9.18% cost ratio is a property of the parser and should hold anywhere. The 70.90% routing recall is a property of this corpus, whose medium-plus base rate of 33% is far above a random-web population because it is deliberately weighted toward banking, contracts and templates. A caller whose fraud is mostly page-content tampering will see triage miss more; a caller whose fraud is mostly structural will see it miss almost nothing. That is precisely why triage is documented as a pre-filter and never as a verdict.

One call before the bytes reach your model

If your agent opens attachments, the useful order is inspect, then read: triage before ingestion, a full inspect before trusting, and the injection families checked before the document's text enters a context window. The quickstart has the anonymous curl; the MCP server is npx tamperlens-mcp, version-pinned to the engine.

The API quickstart: an anonymous call returns the full report, 10 documents an hour per IP, no account. Nothing is stored, the parse is in memory, and identical bytes return an identical report, which means you can regression-test everything this post claims.