feat(inbox): .xlsx extraction — formula gate, hidden sheets, cell comments (stage 2h)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-07 07:41:00 +02:00
commit ca26e117ea
3 changed files with 132 additions and 1 deletions

View file

@ -27,7 +27,7 @@ judge = [] # LLM-judge / source-grounding implementation (placeholder)
# §247). Deliberately in `dev`, NOT the core `dependencies` (which stays []) and # §247). Deliberately in `dev`, NOT the core `dependencies` (which stays []) and
# NOT a public `[extract]` extra — the front-end is an in-repo demonstration, not # NOT a public `[extract]` extra — the front-end is an in-repo demonstration, not
# v1 shipped code. They pull lxml/Pillow transitively; that footprint is dev-only. # v1 shipped code. They pull lxml/Pillow transitively; that footprint is dev-only.
dev = ["pytest>=8", "python-docx>=1.2", "python-pptx>=1.0"] dev = ["pytest>=8", "python-docx>=1.2", "python-pptx>=1.0", "openpyxl>=3.1"]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/llm_ingestion_guard"] packages = ["src/llm_ingestion_guard"]

View file

@ -199,6 +199,51 @@ def _extract_pptx_text(fs_path) -> str:
return "\n".join(parts) return "\n".join(parts)
def _extract_xlsx(fs_path):
"""Extract text from an ``.xlsx``, including the regions a human reading the
workbook in Excel does not see: cells on a *hidden* sheet (still worksheets, so
iterated) and cell comments. Returns ``(text, formula_cells)`` the text is
materialized as the concept body (so a hidden-sheet / comment injection rides
into the stage-2 scan) and ``formula_cells`` holds the formula-lead cells the
front-end refuses (a spreadsheet threat, RCE/DDE when a human opens the file).
``openpyxl`` is imported lazily a dev/showcase-scoped parser, not a core
dependency. It reads formulas as their string (default ``data_only=False``).
"""
from openpyxl import load_workbook
wb = load_workbook(str(fs_path))
parts: list = []
formula_cells: list = []
for ws in wb.worksheets: # includes hidden / very-hidden sheets
for row in ws.iter_rows():
for cell in row:
value = cell.value
if value is not None and value != "":
parts.append(str(value))
# Only a genuine text/formula cell can carry the injection lead;
# a numeric cell is typed (int/float) by openpyxl, so a negative
# number never trips the gate — unlike CSV, where every cell is
# text and a leading '-'/'+' is the accepted false-positive.
if isinstance(value, str) and _is_formula_cell(value):
formula_cells.append(value)
comment = cell.comment
if comment is not None and comment.text:
parts.append(comment.text)
return "\n".join(parts), formula_cells
def _ingest_xlsx(rel_name, fs_path, bundle, provenance, rejected):
"""Materialize an ``.xlsx`` (all sheets incl. hidden, + cell comments) as a
concept and flag formula-injection cells mirrors :func:`_ingest_csv`."""
text, formula_cells = _extract_xlsx(fs_path)
if formula_cells:
rejected.append(
(rel_name, f"XLSX formula-injection lead in {len(formula_cells)} cell(s): {formula_cells[0][:24]!r}")
)
_materialize_text(rel_name, text, "xlsx", bundle, provenance)
def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, strict): def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, strict):
"""Dispatch one on-disk file by suffix. ``strict`` raises on an unsupported """Dispatch one on-disk file by suffix. ``strict`` raises on an unsupported
suffix (a top-level drop); a folder walk passes ``strict=False`` to skip it.""" suffix (a top-level drop); a folder walk passes ``strict=False`` to skip it."""
@ -210,6 +255,8 @@ def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, str
_materialize_text(rel_name, _extract_docx_text(fs_path), "docx", bundle, provenance) _materialize_text(rel_name, _extract_docx_text(fs_path), "docx", bundle, provenance)
elif suffix == ".pptx": elif suffix == ".pptx":
_materialize_text(rel_name, _extract_pptx_text(fs_path), "pptx", bundle, provenance) _materialize_text(rel_name, _extract_pptx_text(fs_path), "pptx", bundle, provenance)
elif suffix == ".xlsx":
_ingest_xlsx(rel_name, fs_path, bundle, provenance, rejected)
elif suffix in _TEXT_SUFFIXES: elif suffix in _TEXT_SUFFIXES:
text = Path(fs_path).read_text(encoding="utf-8", errors="replace") text = Path(fs_path).read_text(encoding="utf-8", errors="replace")
_materialize_text(rel_name, text, suffix.lstrip("."), bundle, provenance) _materialize_text(rel_name, text, suffix.lstrip("."), bundle, provenance)

View file

@ -422,3 +422,87 @@ def test_clean_pptx_grouped_shape_admits(tmp_path):
fp = _pptx_with_grouped_text(tmp_path, "clean grouped text") fp = _pptx_with_grouped_text(tmp_path, "clean grouped text")
_extracted, _result, verdict = receive([fp]) _extracted, _result, verdict = receive([fp])
assert verdict == "ADMIT" assert verdict == "ADMIT"
# --- slice 2h: .xlsx (openpyxl) ---------------------------------------------
# Three planted regions (PLAN §247). A formula-lead cell (=cmd|'…', =HYPERLINK)
# is a spreadsheet threat the guard would not recognize, so the front-end refuses
# it (mirrors .csv). A hidden sheet and a cell comment hide injection text where a
# human reading the workbook does not look; the extractor surfaces both so the
# stage-2 scan catches them. openpyxl reads formulas as their string, iterates
# hidden sheets, and exposes cell comments (verified empirically).
def _make_xlsx(tmp_path, name="book.xlsx", *, cell="A normal value",
formula=None, hidden_sheet=None, comment=None):
from openpyxl import Workbook
from openpyxl.comments import Comment
wb = Workbook()
ws = wb.active
ws["A1"] = cell
if formula is not None:
ws["A2"] = formula # leading '=' -> stored as a formula
if comment is not None:
ws["A1"].comment = Comment(comment, "m")
if hidden_sheet is not None:
hs = wb.create_sheet("secret")
hs.sheet_state = "hidden" # invisible tab in Excel
hs["A1"] = hidden_sheet
fp = tmp_path / name
wb.save(str(fp))
return fp
def test_xlsx_formula_injection_cell_is_flagged(tmp_path):
fp = _make_xlsx(tmp_path, name="data.xlsx", formula="=cmd|'/c calc'!A1")
extracted, _result, verdict = receive([fp])
assert extracted.provenance[0].source_type == "xlsx"
assert any("data.xlsx" in n for n, _r in extracted.rejected)
assert verdict == "REJECT"
def test_xlsx_hyperlink_formula_is_flagged(tmp_path):
fp = _make_xlsx(tmp_path, formula="=HYPERLINK('http://evil')")
extracted, _result, verdict = receive([fp])
assert extracted.rejected != ()
assert verdict == "REJECT"
def test_xlsx_hidden_sheet_injection_is_caught(tmp_path):
fp = _make_xlsx(tmp_path, hidden_sheet=_INJECTION)
_extracted, result, verdict = receive([fp])
assert result.disposition is Disposition.FAIL_SECURE # caught by the guard
assert verdict == "REJECT"
def test_xlsx_cell_comment_injection_is_caught(tmp_path):
fp = _make_xlsx(tmp_path, comment=_INJECTION)
_extracted, result, verdict = receive([fp])
assert result.disposition is Disposition.FAIL_SECURE
assert verdict == "REJECT"
def test_clean_xlsx_admits(tmp_path):
fp = _make_xlsx(tmp_path, name="clean.xlsx", cell="A routine value. No behavior change.")
extracted, _result, verdict = receive([fp])
assert "uploads/clean.md" in extracted.bundle
assert extracted.rejected == ()
assert verdict == "ADMIT"
def test_xlsx_formula_detach_proof(tmp_path):
# The same workbook with a plain cell (no formula lead) has no flag -> ADMIT,
# so the flag is the formula content, not the .xlsx suffix or the filename.
fp = _make_xlsx(tmp_path, name="data.xlsx", formula="calc")
extracted, _result, verdict = receive([fp])
assert extracted.rejected == ()
assert verdict == "ADMIT"
def test_xlsx_hidden_sheet_detach_proof(tmp_path):
# Same visible cell, no hidden sheet -> ADMIT: it is the extractor surfacing the
# hidden-sheet region that caught it, not merely "an xlsx".
fp = _make_xlsx(tmp_path, cell="A routine value.")
_extracted, _result, verdict = receive([fp])
assert verdict == "ADMIT"