feat(pdf): sandbox optional content classification (#724)

* feat(pdf): sandbox optional content classification

* fix(pdf): bind advisory to complete input

* fix(pdf): close classifier protocol edge cases

* fix(pdf): reject overflowing confidence values

* fix(pdf): keep worker adapter outcomes closed

* fix(pdf): close worker and publication races
This commit is contained in:
Edward Cheng-I Wu
2026-08-14 03:14:47 +08:00
committed by GitHub
parent 96580b2de7
commit ca0746aed4
12 changed files with 3537 additions and 21 deletions
+2
View File
@@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file.
### Added
- **Opt-in, process-isolated PDF text/OCR advisory (#512 follow-up; safe replacement for the concept proposed in external PR #623).** Default calls keep the original sidecar shape, tool version, and structural `PASS / FAIL / UNAVAILABLE` decision; only a caller that selects `--classify-content` starts a fixed child worker over the exact already-hashed bytes and receives an all-or-nothing extension whose verdict scope is explicitly `STRUCTURE_ONLY`. The parent never imports the optional native classifier, uses no shell, writes the exact input off the timeout-control loop, enforces one five-second execution deadline plus a shared 0.2-second teardown grace, and caps only stdout/stderr. Each loop iteration polls and then immediately observes the clock, accepting an exit only when that observation is strictly before the deadline; a late-returning poll cannot turn a timeout into success. POSIX cleanup kills the isolated worker group before joining pipe helpers; the portable Windows path terminates only the direct worker. Launch/helper, exit/signal, pipe, and protocol failures become closed reason codes; non-finite confidence and out-of-range OCR pages are rejected; only `TEXT_AVAILABLE / OCR_RECOMMENDED / unavailable` advisory states are emitted. The stdout-only legacy CLI performs no alias precheck, so malformed input paths remain exit-0 structural `UNAVAILABLE` data. When a file write is requested, conservative NFC/casefold keys, resolved targets, and existing-inode checks reject literal, `..`, case/canonical-equivalent, symlink, and hard-link aliases before the worker or either write; `samefile` errors fail closed except for an absent leaf, while an unresolvable input remains the structural preflight's responsibility. Raw upstream types and exception text cannot enter the prompt-facing sidecar; an explicitly requested, exclusive POSIX-only mode-0600 local diagnostic retains at most 512 bytes of untrusted operator detail, with its resolved parent dirfd/inode bound before the worker so a later parent-symlink retarget cannot redirect raw detail. Its created fd is inode-bound: any pre-success fchmod/write/file-fsync/close/parent-fsync failure removes the final leaf only if a fresh no-follow lookup still names that exact created inode, parent-fsyncs best-effort, and preserves the primary error. Thus a partial/malformed diagnostic does not permanently consume the exclusive path and retry succeeds, while an attacker replacement leaf is never deleted. Non-POSIX platforms or platforms without `fchmod` reject that option before path creation. POSIX sidecar output binds the resolved parent dirfd and inode before the worker starts, creates a fixed-length random-named private `0700` staging directory through that dirfd, and uses only anchored dirfd-relative operations thereafter. The complete fixed-name payload is file-fsynced, its open inode is checked against the staging entry, atomically installed with dirfd-relative `os.replace`, checked again at the target, and parent-fsynced; an observed check-to-replace substitution is rejected and its installed attacker entry removed, retargeting a parent symlink cannot redirect publication, final-component links are replaced rather than followed, and a legal 255-byte destination basename still fits. Cleanup attempts close/unlink/rmdir independently, preserves any primary publication error over secondary close failures, and removes unpublished staging. The parent and private staging directory remain caller-controlled prerequisites: Python exposes no compare-and-rename primitive, so a same-UID actor that keeps racing after the instantaneous installed-inode postcondition is outside the isolation claim. Non-POSIX `--output` fails closed because Python does not expose the required anchored dirfd publication there; classifier-to-stdout remains available. The optional dependency has a separate pin and deterministic absent state rather than being installed unconditionally. Three closed Draft 2020-12 schemas, synthetic PDFs, temporary fake modules, and fake workers cover present/absent, late exit/timeout, non-zero/signal, malformed/invalid, oversize, page-bound, helper-startup, deadline/teardown, diagnostic failure/retry and attacker-leaf retention, diagnostic-parent and parent/final/staging alias races, close failures, long basenames, privacy, and permission paths without downloading the package, using a live PDF, invoking a model, or wiring an automatic OCR/anchor gate. This proves the isolation/protocol slice only, not classifier accuracy or downstream efficacy.
- **Offline role-topology utility design and synthetic seed (#582).** Two independent task classes now freeze their own matched role-count arms: decomposable reviewer evidence review uses 1/2/5/7-seat topologies, while sequential writing/revision uses 1/2/5/8-seat chains and a separate requirement-based evaluator that forbids accept/reject labels. A closed study plan fixes per-call token caps, empty tool/web/network allowance, three paired replicates, zero retries, first-partial stop, no imputation, small-seed uncertainty disclosure, arm/expert blinding, and a human-verified `unique verified value` rule that credits only first-originated, evidence-anchored units retained in the final output. Four repository-owned synthetic fixtures, closed plan/seed/materialized-manifest schemas, and a suite-specific validator/dry-run/materializer produce 186 self-contained neutral prompt templates with complete hash-bound role contracts, dependency placeholders, conservative input-budget receipts, and dispatch/measurement permanently false. No subject, expert, adjudicator, model, API, tool, dispatch, or measurement ran; reviewer interpretation still depends on #653 and #582 remains open.
- **Offline candidate-ledger substrate (#655 Track A).** Three closed Draft 2020-12 contracts now bind the complete high-impact consentable-plan projection, consistent known-reference or explicit-unknown provider retention disclosure, one visible initial attempt per planned query/index pair, hash- and time-bound retry authorizations, monotonic retained attempt/hit evidence, and explicit relevance success/failure evidence bound to the exact claim, candidate, assessor contract, and canonical prompt bytes. A pure local finalizer preserves failures and every raw terminal state, applies one NFKC visible-semantic-text boundary, treats only strict `10.<4-9 digits>/<suffix>` values as DOI identity, blocks no-DOI bridges between distinct DOI components, and applies the frozen filter/dedup/canonical/top-40 rules. CLI persistence fails before path creation for `session_only` and writes once only for hash-bound `explicit_local_export`, using an exact hash-bound absolute output path, exclusive/no-follow creation, mode 0600, file/directory fsync, and truthful persistence/export/path metadata; exact replay needs no network, model, discovery adapter, stance classification, rendering, evidence-row changes, pipeline wiring, or dispatch. Synthetic fixtures, direct Unicode/DOI/persistence mutation regressions, an exact module-use-allowlisted no-capability integration guard, resolver byte pins, pytest-manifest registration, and spec-consistency wiring cover this substrate. The live probe and Track B remain unimplemented and unmeasured; #655 stays open.
@@ -80,6 +80,103 @@ anchor downstream, so the conservative bucket is the honest one.
Exit code 0 whenever a verdict was produced (the verdict is data, not an error); 2 on usage
errors only — so orchestration can always consume the JSON without exit-code branching.
The closed Draft 2020-12 sidecar schema is
`shared/contracts/pdf/pdf_read_preflight.schema.json`. It accepts this unchanged legacy
shape and the all-or-nothing opt-in extension below.
### Optional content advisory — isolated and diagnostic-only (2026-08-13 follow-up)
`--classify-content` is an explicit opt-in diagnostic consumer. It is not used by the
default Stage-1 invocation and never changes the structural verdict. The parent sends the
exact bytes already read and hashed above to the fixed
`scripts/pdf_content_classifier_worker.py` child over stdin. Only that child imports the
optional native `pdf_inspector` package. The exact input write runs outside the timeout
control loop and is accepted only when complete. The parent uses `shell=False`, one
five-second execution deadline from child startup, one shared 0.2-second teardown grace,
and concurrent 8,192-byte stdout / 4,096-byte stderr caps; there is no claimed stdin cap.
Every loop iteration polls and then immediately observes the monotonic clock. The poll
result is accepted only if that observation is strictly before the deadline. An exit
whose poll returns at or after the boundary is therefore `WORKER_TIMEOUT`.
POSIX cleanup kills the isolated worker group before reader/writer joins, including when
the leader has exited while descendants retain pipe handles. The portable Windows path
terminates only the direct worker. Timeout, helper-startup, non-zero/signal exit, cap
breach, pipe failure, malformed JSON, and invalid output all yield closed outcomes.
Non-`PASS` structural inputs do not start the child.
Calls without this flag preserve the original sidecar field set and
`tool: pdf_read_preflight/1.0.0`. Opted-in sidecars change the tool version to 1.1.0 and
add all three extension fields together: `verdict_scope: STRUCTURE_ONLY`, the closed
`content_advisory`, and the closed `content_classification` object. A partial extension is
schema-invalid.
The parent applies a hand-written closed validator equivalent to
`shared/contracts/pdf/pdf_content_classifier_worker.schema.json`, without adding a
runtime `jsonschema` dependency. It also enforces stricter runtime invariants: exact
keys, finite confidence in `[0,1]`, sorted unique integer OCR pages, and every page index
below the structural `reader_page_count`. Open upstream types collapse to
`TEXT_AVAILABLE` only for the exact positive `text_based` + empty-page combination and
otherwise to `OCR_RECOMMENDED`; raw upstream type and exception strings never enter the
sidecar.
An opted-in scanned result is therefore represented honestly as structural
`verdict: PASS` with `verdict_scope: STRUCTURE_ONLY` and
`content_advisory: OCR_RECOMMENDED`, never as a content pass. A missing optional
dependency is deterministic `CONTENT_UNAVAILABLE / DEPENDENCY_ABSENT` and leaves the
structural verdict unchanged.
On POSIX, `--classifier-diagnostics <path>` requires the opt-in flag and creates an
exclusive, non-overwriting mode-`0600` local JSON file. Platforms without POSIX
`fchmod` reject this option before creating the path; classification without a local
diagnostic remains available. The artifact may contain at most 512 bytes of explicitly
untrusted worker detail plus byte counts; neither its path nor detail appears in the
prompt-facing sidecar. Contract:
`shared/contracts/pdf/pdf_content_classifier_diagnostic.schema.json`. Full frozen design
and residual-risk boundary:
`docs/design/2026-08-13-512-pdf-content-classification-sandbox-spec.md`.
The stdout-only legacy invocation performs no alias precheck: an unreadable input or
symlink loop remains an exit-0 structural `UNAVAILABLE` verdict. When `--output` or
`--classifier-diagnostics` is present, every requested write target must resolve safely
before parsing or worker launch. Conservative NFC/casefold keys cover literal, `..`, case-only,
and canonically equivalent spellings; resolved keys cover symlinks; `samefile` covers
existing hard links. `samefile` errors fail closed except `ENOENT`, which may mean an
uncreated target. Failure to resolve the input itself is left to structural preflight and
does not block a separately safe write target.
The diagnostic remains exclusive-create/no-follow. Its resolved parent directory is
opened and inode-bound before worker launch, and the final file is created relative to
that dirfd; a later parent-symlink retarget cannot redirect raw detail. The created fd's
inode is recorded before fchmod or writing. Any failure before complete publication,
including fchmod, partial write, file fsync, close, or parent fsync, performs a fresh
no-follow lookup and unlinks only when the leaf still names that exact created inode;
parent fsync during cleanup is best-effort and never replaces the primary error. The
exclusive path is therefore retryable after a partial diagnostic, while a symlink or
hard-link attacker replacement is retained rather than deleted.
POSIX sidecar output uses a different publication contract. Before structural parsing or worker launch, the CLI opens the
resolved parent directory, records its inode, and creates through that dirfd a
fixed-length random-named private `0700` staging directory. All subsequent staging and
publication operations are relative to these anchored dirfds, so retargeting the parent
symlink cannot redirect output. Complete bytes go to the fixed staging filename
`payload`, independent of the destination name (so a legal 255-byte basename works).
The open payload is fsynced and its inode is checked against the no-follow staging entry;
a substituted symlink or hard link is rejected. Dirfd-relative `os.replace` atomically
replaces the target entry, the installed inode is rechecked, and the parent is fsynced.
Thus final-component links are replaced rather than followed and cannot overwrite the
source PDF or diagnostic. Cleanup attempts file close, unlink, directory close/rmdir,
and parent close independently, preserves a primary error over secondary close errors,
and removes unpublished staging. Publication failures are usage errors. Non-POSIX
`--output` fails closed because Python does not expose the required anchored publication
contract there; stdout classification remains available.
This publication contract assumes the output parent is caller-controlled. The private
random staging directory is `0700`, and the worker group is terminated before
publication, but Python exposes no atomic compare-inode-and-rename primitive. The
pre-replace open-inode check plus the instantaneous post-install inode check reject both
tested swap timings; if the latter observes an attacker inode, that installed entry is
removed before failure. A same-UID actor that continues changing entries after the final
postcondition remains outside this process-isolation claim.
### Layer 2 — prompt rules
- **Three emitters** (`synthesis_agent`, `draft_writer_agent`, `report_compiler_agent`): a
@@ -138,3 +235,13 @@ enumeration exercises recursion), lying root `/Count` (FAIL), truncated tail (UN
FAIL, never PASS), encrypted marker (UNAVAILABLE), page-tree cycle (UNAVAILABLE via guard),
non-PDF bytes (UNAVAILABLE), missing file (UNAVAILABLE), pypdf absent (monkeypatched →
UNAVAILABLE with `pypdf-not-installed` warning), sidecar shape + hash stability, exit codes.
The 2026-08-13 follow-up adds no live PDF or package download. Temporary fake modules and
workers cover dependency present/absent, text/scanned results, exception isolation,
timeout, helper-startup failure, one-deadline/one-grace teardown, leader-exit inherited
pipes, non-zero/signal exits, malformed/invalid/oversize output, finite confidence, page
bounds, clock-before-poll late-exit rejection, stdout-only malformed-input compatibility,
conservative pre-run path-alias rejection, output/diagnostic parent-retarget and final-entry races,
staging symlink/hard-link swaps, close-failure cleanup, 255-byte basenames, diagnostic
partial-write/fsync/close cleanup and attacker-leaf retention, privacy/mode, and all three closed schemas. The test file is also registered in
`scripts/_ci_pytest_manifest.toml`.
@@ -0,0 +1,247 @@
# #512 follow-up — opt-in PDF content advisory with process isolation
**Date:** 2026-08-13 · **Provenance:** safe maintainer-owned replacement for the
useful diagnostic idea in external PR #623 · **Status:** frozen minimal slice
## Decision
The structural contract delivered by #512 remains authoritative and unchanged:
`PASS / FAIL / UNAVAILABLE` says whether the local PDF page structure can license a
page locator. It does not say whether a page contains usable extracted text.
This slice adds one explicit diagnostic consumer without placing a new native parser
inside the structural preflight process:
```text
operator --classify-content
-> pdf_read_preflight.py (existing structural parse + exact SHA-256)
-> fixed child worker over the same already-hashed bytes
-> closed validation in the parent
-> content_advisory in the sidecar
```
Default Stage-1 and library calls preserve the original sidecar shape and tool version
and do not run the classifier. The classifier is invoked
only when the operator selects `--classify-content` and the structural verdict is
`PASS`. This is an observable, diagnostic-only use path, not an OCR router, anchor gate,
or agent-prompt change.
## Frozen scope
- `scripts/pdf_read_preflight.py`: parent process, closed consumer, timeout/caps,
sidecar projection, and optional local diagnostic writer.
- `scripts/pdf_content_classifier_worker.py`: the only module that imports or calls
`pdf_inspector`.
- `shared/contracts/pdf/`: closed Draft 2020-12 schemas for the prompt-facing sidecar,
worker stdout, and local-only diagnostic.
- `requirements-pdf-content-classifier.txt`: isolated optional dependency pin; the
package is not added to the default developer/CI environment.
- synthetic and fake-worker tests in `scripts/test_pdf_read_preflight.py`, registered
in the unified pytest manifest.
No pipeline, agent definition, prompt, claim-audit finalizer, OCR tool, model, network,
or live/private PDF is added. No claim is made that scanned-page detection improves an
academic output or closes a measured reliability gap.
## Trust and process boundaries
### Exact-byte binding
The existing structural preflight reads and hashes the PDF once. The parent sends those
same bytes to the child on stdin. The worker does not reopen a path, so a file replacement
between structural validation and classification cannot bind a result from different
bytes to the structural SHA-256.
### Native parser containment
The parent never imports `pdf_inspector`. It starts the fixed repository worker with an
argv list and `shell=False`. On POSIX the child is the process-group leader of a new
session; the parent kills that group before reader/writer joins, including when the
leader has exited while an ordinary descendant retains inherited pipe handles. Every
terminal path repeats best-effort cleanup. On Windows the portable stdlib path terminates
and reaps only the direct worker and makes no process-tree containment claim. The parent
applies:
- one 5-second execution deadline beginning immediately after child startup;
- one explicitly separate, shared 0.2-second teardown grace (not one grace per wait or
helper);
- 8,192-byte stdout limit;
- 4,096-byte stderr limit;
- distinct closed outcomes for launch failure, timeout, non-zero exit, signal exit,
stdout/stderr limit, helper-startup/pipe failure, malformed JSON, and invalid closed
output.
The loop polls and then immediately observes the monotonic clock. Only a poll result
whose following observation is strictly before the execution deadline may be processed
as a worker exit. A poll that returns at or after the boundary is `WORKER_TIMEOUT`.
Readers drain both pipes concurrently and retain no more than each limit plus one byte.
Those are the only byte caps. The exact PDF input is written on a separate thread, so a
child that never reads stdin cannot block the parent's timeout loop, and a classified
result is accepted only after the complete input write. Helper construction failures are
closed `WORKER_IO_ERROR` outcomes inside the same immediate post-`Popen` cleanup region.
A native abort or segmentation fault terminates the child, not the structural preflight
process.
This is process isolation, not a general OS sandbox. A POSIX descendant that deliberately
detaches into another session or process group, and every host-wide resource failure,
remain outside the containment claim; the optional third-party parser is not trusted.
### Worker stdout contract
`pdf_content_classifier_worker/1` is closed and permits only:
| `status` | `reason` | value fields |
|---|---|---|
| `CLASSIFIED` | `CLASSIFIED` | `TEXT_AVAILABLE` or `OCR_RECOMMENDED`; finite confidence in `[0,1]`; at most 50,000 unique non-negative page indexes |
| `UNAVAILABLE` | `DEPENDENCY_ABSENT`, `CLASSIFIER_ERROR`, or `INVALID_CLASSIFIER_RESULT` | all value fields `null` |
The worker recognizes only the positive upstream combination `pdf_type ==
"text_based"` with an empty OCR-page list as `TEXT_AVAILABLE`. Every other non-empty
upstream type is reduced to `OCR_RECOMMENDED`; the open vendor enum is never emitted.
The parent applies a hand-written validator equivalent to the closed worker schema,
requires sorted unique integer pages, rejects JSON `NaN`/infinities and unknown fields,
and binds every page index to
`0 <= page < reader_page_count` from the structural parse.
### Sidecar semantics
The existing `schema: pdf_read_preflight/1` remains compatible. Calls without the
opt-in keep the original field set and `tool: pdf_read_preflight/1.0.0`; opted-in
sidecars use tool 1.1.0 and gain the following all-or-nothing extension:
- `verdict_scope: STRUCTURE_ONLY` — makes the old verdict's scope explicit;
- `content_advisory` — one of `TEXT_AVAILABLE`,
`OCR_RECOMMENDED`, `CONTENT_UNAVAILABLE`, or `STRUCTURAL_UNAVAILABLE`;
- `content_classification` — a closed object carrying request state, closed reason,
classification, confidence, and bounded page indexes.
An image-only PDF may truthfully have:
```json
{
"verdict": "PASS",
"verdict_scope": "STRUCTURE_ONLY",
"content_advisory": "OCR_RECOMMENDED"
}
```
That means the page tree is structurally coherent while text usability was not
established. It is not a content `PASS`. Dependency absence or any worker failure yields
`CONTENT_UNAVAILABLE` and never changes the structural verdict. A non-`PASS` structural
result yields `STRUCTURAL_UNAVAILABLE` without starting the optional child.
### Operator diagnostics
The prompt-facing sidecar contains only closed reason codes. The upstream type and raw
exception are absent. On POSIX, with the separate, explicit
`--classifier-diagnostics <local-path>` option, the operator may create one exclusive,
non-overwriting `0600` JSON file containing:
- the closed reason code;
- observed stdout/stderr byte counts;
- at most 512 bytes of clearly named `untrusted_detail`.
The diagnostic path and detail never appear in the sidecar. The file is local,
unencrypted, untrusted operator evidence and must not be copied into an agent prompt.
Platforms without POSIX `fchmod` reject the diagnostics option before path creation;
the ordinary classifier subprocess remains available under its narrower Windows
direct-worker containment claim.
The stdout-only legacy CLI performs no alias precheck, so an unreadable input or symlink
loop remains an exit-0 structural `UNAVAILABLE` verdict. With either write option, every
write target must resolve safely before structural parsing or worker launch.
NFC/casefold canonical keys conservatively reject literal, `..`, case-only, and Unicode
canonical-equivalent aliases even when their leaves do not exist; resolved keys reject
symlink aliases; existing-inode comparison rejects hard links. Existing-inode errors
fail closed except `ENOENT`, while failure to resolve the input itself stays a structural
preflight concern and does not suppress an otherwise safe output of that verdict.
The local diagnostic remains exclusive-create/no-follow. Its resolved parent directory
is opened and inode-bound before the worker, and final creation is relative to that
dirfd, so a parent-symlink retarget cannot redirect raw diagnostic detail. The created
fd is inode-bound before fchmod/write. On any pre-success fchmod, partial-write,
file-fsync, close, or parent-fsync failure, cleanup performs a fresh no-follow lookup and
unlinks the leaf only if it still identifies the created inode; it then best-effort
parent-fsyncs without replacing the primary error. A malformed partial diagnostic cannot
permanently consume the exclusive destination and immediate retry works. A symlink or
hard-link attacker replacement has a different inode and is not removed.
For ordinary sidecar output on
POSIX, the resolved parent directory is opened and inode-bound before the worker starts.
A fixed-length random-named private `0700` staging directory is created and opened
through that parent dirfd; every later operation is relative to the anchored parent or
staging dirfd, so a parent-symlink retarget cannot redirect publication. Complete bytes
use the fixed staging leaf `payload`, allowing a legal 255-byte destination basename.
After file fsync, the open payload inode must still match the no-follow staging entry;
symlink or hard-link replacement is rejected. Dirfd-relative `os.replace` installs the
payload, its installed inode is rechecked, and the parent is fsynced. Final-component
links are replaced rather than followed and cannot truncate the input or diagnostic.
Cleanup guards close, unlink, staging-dir close/rmdir, and parent close independently;
secondary cleanup failures never replace a primary publication error, and unpublished
staging is removed. Any output `OSError` becomes a CLI usage error. Non-POSIX
`--output` fails closed because the needed anchored dirfd publication surface is absent;
classification to stdout retains its narrower direct-worker Windows contract.
The output parent must be caller-controlled. Python's standard library exposes no
atomic compare-inode-and-rename operation. The private random staging directory is
`0700`, the worker group is terminated before publication, and identity is checked both
immediately before replace and immediately after install. A substitution observed after
install is rejected and the observed attacker entry is removed. Those instantaneous
postconditions cover the tested pre-check and exact check-to-replace swaps; a same-UID
actor that continues racing after the final check is outside this process-isolation
claim.
## Optional dependency model
`pdf-inspector` is not installed by `requirements-dev.txt`. Operators who deliberately
select this diagnostic may install the isolated pin in
`requirements-pdf-content-classifier.txt`. Only a top-level `ModuleNotFoundError` naming
`pdf_inspector` emits the deterministic closed `DEPENDENCY_ABSENT` state. Internal or
transitive import failures emit `CLASSIFIER_ERROR`, with bounded detail available only in
the explicit local diagnostic; structural output is unaffected.
The hermetic suite does not download or execute the real optional package. It inserts a
temporary fake `pdf_inspector` module into the child environment to cover the present,
absent, scanned, text, malformed-result, and exception adapters. Therefore this slice
proves the process and contract boundary, not the current third-party package's empirical
classification accuracy.
## Test matrix
- Existing #512 synthetic structural corpus remains green.
- Default path and structural non-`PASS` path prove the child is not started.
- Actual repository worker with temporary fake module: dependency absent, text,
scanned/open upstream type, classifier exception, and exact input bytes.
- Fake workers: timeout, non-zero exit, signal, malformed JSON, unknown key/enum,
non-finite/out-of-range confidence, invalid/duplicate/out-of-range pages, stdout flood,
stderr flood, all three helper-startup failures, and a direct descendant that inherits
pipes and must be terminated before joins.
- True top-level dependency absence is distinct from internal and transitive import
failures; the latter are local-diagnostic-only classifier errors.
- Schema mutations reject the legacy/extension tool versions on the wrong shape.
- Prompt-facing sidecar excludes raw worker detail; local diagnostic is bounded,
exclusive, mode `0600`, and schema-valid.
- A shared small teardown grace cannot accumulate across sequential waits; conservative
case/canonical folding, literal/`..`, symlink, hard-link, `samefile` error, and
malformed-input compatibility cases pin the precheck boundary.
- A clock-before-poll adversarial sequence rejects an otherwise successful exit first
observable at the deadline.
- Post-check output symlink/hard-link races against both input and diagnostic replace the
hostile final entry without following it; injected replace failure cleans staging.
- Parent-symlink retargeting stays on the pre-worker bound directory; staging symlink and
hard-link swaps are rejected by open-inode identity; a secondary close failure neither
masks the primary error nor leaves staging; a 255-byte basename publishes.
- Diagnostic-parent retargeting likewise stays on its pre-worker bound directory.
- Diagnostic partial-write, file-fsync, and close failures remove only the created inode
and permit retry; symlink/hard-link attacker replacements survive cleanup unchanged.
- All three Draft 2020-12 schemas validate; the parent contains no
`pdf_inspector` import.
## Residual risk and follow-on gate
This slice deliberately stops at an operator-visible diagnostic. Automatic OCR routing,
changing anchor eligibility, or sending the advisory into writer/auditor context would
change citation-integrity behavior and agent definitions. That requires a separate
issue-first design, explicit consumer semantics, prompt-injection review for every field,
and its own evidence. This slice is not that authorization.
+5
View File
@@ -0,0 +1,5 @@
# Optional, opt-in dependency for scripts/pdf_content_classifier_worker.py.
# Kept out of requirements-dev.txt so the dependency-absent path remains a
# supported deterministic state and the native parser is never imported by the
# structural preflight process.
pdf-inspector==0.2.6
+4
View File
@@ -543,3 +543,7 @@ path = "scripts/test_build_claim_standing_candidate_ledger.py"
[[pytest]]
id = "655-claim-standing-candidate-ledger-integration"
path = "scripts/test_check_claim_standing_candidate_ledger_integration.py"
[[pytest]]
id = "512-pdf-read-preflight-and-content-sandbox"
path = "scripts/test_pdf_read_preflight.py"
+176
View File
@@ -0,0 +1,176 @@
"""Isolated optional PDF content-classifier worker.
This process is the only place that imports or invokes ``pdf_inspector``. It
receives the exact bytes already hashed by ``pdf_read_preflight.py`` on stdin
and emits one small, closed JSON object on stdout. Any native panic, abort, or
segmentation fault is therefore contained to this child and interpreted by the
parent as an unavailable advisory signal.
The worker deliberately never emits the upstream classifier's free-form
``pdf_type`` or exception text on stdout. Bounded exception detail is written
only to stderr, which the parent discards unless the operator explicitly asks
for a separate local diagnostic file.
"""
from __future__ import annotations
import importlib
import importlib.util
import json
import math
import sys
from typing import Any
SCHEMA = "pdf_content_classifier_worker/1"
MAX_PAGE_ENTRIES = 50_000
MAX_OPERATOR_DETAIL_BYTES = 512
def _emit(payload: dict[str, Any]) -> None:
raw = json.dumps(
payload,
ensure_ascii=False,
allow_nan=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
sys.stdout.buffer.write(raw + b"\n")
sys.stdout.buffer.flush()
def _unavailable(reason: str) -> dict[str, Any]:
return {
"schema": SCHEMA,
"status": "UNAVAILABLE",
"reason": reason,
"classification": None,
"confidence": None,
"pages_needing_ocr": None,
}
def _operator_detail(exc: BaseException) -> None:
# This stream is never copied into the prompt-facing sidecar. Bound bytes
# before writing so even a hostile exception string cannot flood the pipe.
# Rendering the diagnostic must never replace the closed stdout result with
# another crash: third-party exceptions can have a broken __str__, and
# stderr can disappear while handling the original failure.
try:
try:
detail = str(exc)
except BaseException:
detail = "<unprintable exception>"
text = f"{type(exc).__name__}: {detail}".encode(
"utf-8",
errors="replace",
)[:MAX_OPERATOR_DETAIL_BYTES]
except BaseException:
text = b"unprintable classifier exception"
try:
sys.stderr.buffer.write(text)
sys.stderr.buffer.flush()
except BaseException:
pass
def _normalize_result(classified: Any) -> dict[str, Any]:
try:
raw_pdf_type = classified.pdf_type
raw_confidence = classified.confidence
raw_pages = classified.pages_needing_ocr
except BaseException as exc:
_operator_detail(exc)
return _unavailable("INVALID_CLASSIFIER_RESULT")
if type(raw_pdf_type) is not str or not raw_pdf_type:
return _unavailable("INVALID_CLASSIFIER_RESULT")
if type(raw_confidence) not in (int, float):
return _unavailable("INVALID_CLASSIFIER_RESULT")
try:
confidence = float(raw_confidence)
except BaseException as exc:
_operator_detail(exc)
return _unavailable("INVALID_CLASSIFIER_RESULT")
if not math.isfinite(confidence) or not 0.0 <= confidence <= 1.0:
return _unavailable("INVALID_CLASSIFIER_RESULT")
try:
iterator = iter(raw_pages)
except BaseException as exc:
_operator_detail(exc)
return _unavailable("INVALID_CLASSIFIER_RESULT")
pages: list[int] = []
seen: set[int] = set()
try:
for page in iterator:
if len(pages) >= MAX_PAGE_ENTRIES:
return _unavailable("INVALID_CLASSIFIER_RESULT")
if type(page) is not int or page < 0:
return _unavailable("INVALID_CLASSIFIER_RESULT")
if page in seen:
return _unavailable("INVALID_CLASSIFIER_RESULT")
seen.add(page)
pages.append(page)
except BaseException as exc:
_operator_detail(exc)
return _unavailable("INVALID_CLASSIFIER_RESULT")
pages.sort()
# Do not expose an open upstream enum. The one positively recognized
# state is text_based with no OCR pages; every other non-empty upstream
# type is conservatively reduced to the closed OCR_RECOMMENDED advisory.
classification = (
"TEXT_AVAILABLE"
if raw_pdf_type == "text_based" and not pages
else "OCR_RECOMMENDED"
)
return {
"schema": SCHEMA,
"status": "CLASSIFIED",
"reason": "CLASSIFIED",
"classification": classification,
"confidence": confidence,
"pages_needing_ocr": pages,
}
def main() -> int:
try:
pdf_inspector_spec = importlib.util.find_spec("pdf_inspector")
except BaseException as exc:
_operator_detail(exc)
_emit(_unavailable("CLASSIFIER_ERROR"))
return 0
if pdf_inspector_spec is None:
_emit(_unavailable("DEPENDENCY_ABSENT"))
return 0
try:
pdf_inspector = importlib.import_module("pdf_inspector")
except BaseException as exc:
_operator_detail(exc)
_emit(_unavailable("CLASSIFIER_ERROR"))
return 0
try:
data = sys.stdin.buffer.read()
classified = pdf_inspector.classify_pdf_bytes(data)
except BaseException as exc:
_operator_detail(exc)
_emit(_unavailable("CLASSIFIER_ERROR"))
return 0
try:
normalized = _normalize_result(classified)
except BaseException as exc:
# Final adapter backstop: a future normalization branch must not let a
# malformed third-party object escape the closed stdout contract.
_operator_detail(exc)
normalized = _unavailable("INVALID_CLASSIFIER_RESULT")
_emit(normalized)
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31
View File
@@ -3,6 +3,37 @@
Schema files for cross-skill contracts: reviewer sprint contracts, Material Passport
ports, and (v3.6.7+) cross-model audit artifact pipelines.
## PDF read-integrity and optional content advisory (#512 follow-up)
- `pdf/pdf_read_preflight.schema.json` accepts the unchanged legacy structural sidecar
or the all-or-nothing opt-in content extension. In that extension, `verdict` is
explicitly `verdict_scope: STRUCTURE_ONLY`; `OCR_RECOMMENDED` never rewrites that
structural value into a content claim. The schema binds the legacy shape to tool
version 1.0.0 and the extension shape to 1.1.0.
- `pdf/pdf_content_classifier_worker.schema.json` closes the stdout of the fixed
isolated worker to two classifications, three unavailable reasons, finite bounded
confidence, and bounded non-negative page indexes. Runtime additionally binds every
page to the structural page count.
- `pdf/pdf_content_classifier_diagnostic.schema.json` is the separate POSIX-only,
local mode-`0600` operator artifact. Platforms without `fchmod` reject its CLI
option before path creation. Its explicitly untrusted detail is capped and never
copied into or referenced by the sidecar. File-writing CLI invocations use
conservative NFC/casefold keys, path resolution, and existing-inode checks to reject
aliases before worker launch; the stdout-only legacy path adds no such precondition.
POSIX sidecar output pre-binds the parent dirfd/inode, then uses a private `0700`
fixed-name staging directory and anchored dirfd-relative publication. Open-inode
checks reject staging entry swaps; atomic final-entry replacement does not follow
post-check links. Non-POSIX output fails closed; stdout classification remains
available. The diagnostic parent is likewise dirfd-bound before the worker. These are
instantaneous inode postconditions, not a general same-UID sandbox; callers control
the output parent. A failed diagnostic unlinks only the no-follow leaf still matching
its created fd inode, preserving the primary error and any attacker replacement while
keeping its exclusive path retryable.
Runtime: `scripts/pdf_read_preflight.py` and
`scripts/pdf_content_classifier_worker.py`. Frozen opt-in scope and residual risk:
`docs/design/2026-08-13-512-pdf-content-classification-sandbox-spec.md`.
## Claim-standing candidate ledger (#655 Track A)
- `claim_standing/query_plan.schema.json` (`claim-standing-query-plan/1.0`)
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/Imbad0202/academic-research-skills/shared/contracts/pdf/pdf_content_classifier_diagnostic.schema.json",
"title": "POSIX local-only PDF classifier operator diagnostic",
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"reason",
"untrusted_detail",
"stdout_bytes_observed",
"stderr_bytes_observed"
],
"properties": {
"schema": {"const": "pdf_content_classifier_diagnostic/1"},
"reason": {
"enum": [
"NOT_REQUESTED",
"STRUCTURAL_NOT_PASS",
"CLASSIFIED",
"DEPENDENCY_ABSENT",
"CLASSIFIER_ERROR",
"INVALID_CLASSIFIER_RESULT",
"WORKER_LAUNCH_ERROR",
"WORKER_TIMEOUT",
"WORKER_NONZERO_EXIT",
"WORKER_SIGNAL",
"WORKER_STDOUT_LIMIT",
"WORKER_STDERR_LIMIT",
"WORKER_IO_ERROR",
"WORKER_MALFORMED_OUTPUT",
"WORKER_INVALID_OUTPUT"
]
},
"untrusted_detail": {"type": "string", "maxLength": 512},
"stdout_bytes_observed": {"type": "integer", "minimum": 0},
"stderr_bytes_observed": {"type": "integer", "minimum": 0}
}
}
@@ -0,0 +1,88 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/Imbad0202/academic-research-skills/shared/contracts/pdf/pdf_content_classifier_worker.schema.json",
"title": "Isolated PDF content-classifier worker result",
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"status",
"reason",
"classification",
"confidence",
"pages_needing_ocr"
],
"properties": {
"schema": {"const": "pdf_content_classifier_worker/1"},
"status": {"enum": ["CLASSIFIED", "UNAVAILABLE"]},
"reason": {
"enum": [
"CLASSIFIED",
"DEPENDENCY_ABSENT",
"CLASSIFIER_ERROR",
"INVALID_CLASSIFIER_RESULT"
]
},
"classification": {
"enum": ["TEXT_AVAILABLE", "OCR_RECOMMENDED", null]
},
"confidence": {
"type": ["number", "null"],
"minimum": 0.0,
"maximum": 1.0
},
"pages_needing_ocr": {
"oneOf": [
{
"type": "array",
"maxItems": 50000,
"uniqueItems": true,
"items": {"type": "integer", "minimum": 0}
},
{"type": "null"}
]
}
},
"oneOf": [
{
"properties": {
"status": {"const": "CLASSIFIED"},
"reason": {"const": "CLASSIFIED"},
"classification": {"enum": ["TEXT_AVAILABLE", "OCR_RECOMMENDED"]},
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
"pages_needing_ocr": {
"type": "array",
"maxItems": 50000,
"uniqueItems": true,
"items": {"type": "integer", "minimum": 0}
}
}
},
{
"properties": {
"status": {"const": "UNAVAILABLE"},
"reason": {
"enum": [
"DEPENDENCY_ABSENT",
"CLASSIFIER_ERROR",
"INVALID_CLASSIFIER_RESULT"
]
},
"classification": {"type": "null"},
"confidence": {"type": "null"},
"pages_needing_ocr": {"type": "null"}
}
}
],
"allOf": [
{
"if": {
"properties": {"classification": {"const": "TEXT_AVAILABLE"}},
"required": ["classification"]
},
"then": {
"properties": {"pages_needing_ocr": {"maxItems": 0}}
}
}
]
}
@@ -0,0 +1,227 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/Imbad0202/academic-research-skills/shared/contracts/pdf/pdf_read_preflight.schema.json",
"title": "PDF read-integrity sidecar with opt-in content extension",
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"verdict",
"file",
"sha256",
"declared_page_count",
"enumerated_page_count",
"reader_page_count",
"warnings",
"generated_at",
"tool"
],
"properties": {
"schema": {"const": "pdf_read_preflight/1"},
"verdict": {"enum": ["PASS", "FAIL", "UNAVAILABLE"]},
"verdict_scope": {"const": "STRUCTURE_ONLY"},
"file": {"type": "string"},
"sha256": {
"oneOf": [
{"type": "string", "pattern": "^[0-9a-f]{64}$"},
{"type": "null"}
]
},
"declared_page_count": {"type": ["integer", "null"]},
"enumerated_page_count": {"type": ["integer", "null"]},
"reader_page_count": {"type": ["integer", "null"]},
"warnings": {"type": "array", "items": {"type": "string"}},
"content_advisory": {
"enum": [
"TEXT_AVAILABLE",
"OCR_RECOMMENDED",
"CONTENT_UNAVAILABLE",
"STRUCTURAL_UNAVAILABLE"
]
},
"content_classification": {
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"requested",
"status",
"reason",
"classification",
"confidence",
"pages_needing_ocr"
],
"properties": {
"schema": {"const": "pdf_content_classification/1"},
"requested": {"const": true},
"status": {"enum": ["CLASSIFIED", "UNAVAILABLE"]},
"reason": {
"enum": [
"STRUCTURAL_NOT_PASS",
"CLASSIFIED",
"DEPENDENCY_ABSENT",
"CLASSIFIER_ERROR",
"INVALID_CLASSIFIER_RESULT",
"WORKER_LAUNCH_ERROR",
"WORKER_TIMEOUT",
"WORKER_NONZERO_EXIT",
"WORKER_SIGNAL",
"WORKER_STDOUT_LIMIT",
"WORKER_STDERR_LIMIT",
"WORKER_IO_ERROR",
"WORKER_MALFORMED_OUTPUT",
"WORKER_INVALID_OUTPUT"
]
},
"classification": {
"enum": ["TEXT_AVAILABLE", "OCR_RECOMMENDED", null]
},
"confidence": {
"type": ["number", "null"],
"minimum": 0.0,
"maximum": 1.0
},
"pages_needing_ocr": {
"oneOf": [
{
"type": "array",
"maxItems": 50000,
"uniqueItems": true,
"items": {"type": "integer", "minimum": 0}
},
{"type": "null"}
]
}
},
"oneOf": [
{
"properties": {
"status": {"const": "CLASSIFIED"},
"reason": {"const": "CLASSIFIED"},
"classification": {"enum": ["TEXT_AVAILABLE", "OCR_RECOMMENDED"]},
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
"pages_needing_ocr": {
"type": "array",
"maxItems": 50000,
"uniqueItems": true,
"items": {"type": "integer", "minimum": 0}
}
}
},
{
"properties": {
"status": {"const": "UNAVAILABLE"},
"reason": {
"enum": [
"STRUCTURAL_NOT_PASS",
"DEPENDENCY_ABSENT",
"CLASSIFIER_ERROR",
"INVALID_CLASSIFIER_RESULT",
"WORKER_LAUNCH_ERROR",
"WORKER_TIMEOUT",
"WORKER_NONZERO_EXIT",
"WORKER_SIGNAL",
"WORKER_STDOUT_LIMIT",
"WORKER_STDERR_LIMIT",
"WORKER_IO_ERROR",
"WORKER_MALFORMED_OUTPUT",
"WORKER_INVALID_OUTPUT"
]
},
"classification": {"type": "null"},
"confidence": {"type": "null"},
"pages_needing_ocr": {"type": "null"}
}
}
]
},
"generated_at": {"type": "string", "format": "date-time"},
"tool": {"type": "string", "pattern": "^pdf_read_preflight/[0-9]+\\.[0-9]+\\.[0-9]+$"}
},
"oneOf": [
{
"properties": {
"tool": {"const": "pdf_read_preflight/1.0.0"}
},
"not": {
"anyOf": [
{"required": ["verdict_scope"]},
{"required": ["content_advisory"]},
{"required": ["content_classification"]}
]
}
},
{
"required": ["verdict_scope", "content_advisory", "content_classification"],
"properties": {
"tool": {"const": "pdf_read_preflight/1.1.0"}
},
"oneOf": [
{
"properties": {
"verdict": {"enum": ["FAIL", "UNAVAILABLE"]},
"content_advisory": {"const": "STRUCTURAL_UNAVAILABLE"},
"content_classification": {
"properties": {
"status": {"const": "UNAVAILABLE"},
"reason": {"const": "STRUCTURAL_NOT_PASS"}
}
}
}
},
{
"properties": {
"verdict": {"const": "PASS"},
"content_advisory": {"const": "CONTENT_UNAVAILABLE"},
"content_classification": {
"properties": {
"status": {"const": "UNAVAILABLE"},
"reason": {
"enum": [
"DEPENDENCY_ABSENT",
"CLASSIFIER_ERROR",
"INVALID_CLASSIFIER_RESULT",
"WORKER_LAUNCH_ERROR",
"WORKER_TIMEOUT",
"WORKER_NONZERO_EXIT",
"WORKER_SIGNAL",
"WORKER_STDOUT_LIMIT",
"WORKER_STDERR_LIMIT",
"WORKER_IO_ERROR",
"WORKER_MALFORMED_OUTPUT",
"WORKER_INVALID_OUTPUT"
]
}
}
}
}
},
{
"properties": {
"verdict": {"const": "PASS"},
"content_advisory": {"const": "TEXT_AVAILABLE"},
"content_classification": {
"properties": {
"status": {"const": "CLASSIFIED"},
"classification": {"const": "TEXT_AVAILABLE"},
"pages_needing_ocr": {"maxItems": 0}
}
}
}
},
{
"properties": {
"verdict": {"const": "PASS"},
"content_advisory": {"const": "OCR_RECOMMENDED"},
"content_classification": {
"properties": {
"status": {"const": "CLASSIFIED"},
"classification": {"const": "OCR_RECOMMENDED"}
}
}
}
}
]
}
]
}