1
0
Fork 0

feat(inbox): .csv formula injection + folder walk (stage 2c)

- .csv: cells leading with =/+/-/@ (leading whitespace stripped first) are the
  CSV-injection/DDE vector the guard cannot recognize, so the front-end refuses
  them; the raw cell text is still materialized so a prompt-injection phrase in a
  cell is caught by the stage-2 scan (T1). Detach-proof: plain-cell version of
  the same file ADMITs. Numeric -/+ leads are the accepted FP (honest-limits).
- folder: walked member-by-member with relative paths preserved, so a reserved
  basename member (index.md) lands on the guard's T4 gate; symlinks refused.

Refactor: per-file dispatch shared by top-level drops and folder walk (strict
raises on unsupported top-level suffix, folder skips). Tests 293 -> 300.
This commit is contained in:
Kjell Tore Guttormsen 2026-07-06 11:10:55 +02:00
commit 02d59efeb2
2 changed files with 145 additions and 11 deletions

View file

@ -153,3 +153,74 @@ def test_zip_symlink_entry_is_refused(tmp_path):
assert any("link.md" in n for n, _reason in extracted.rejected)
assert "uploads/link.md" not in extracted.bundle
assert verdict == "REJECT"
# --- slice 2c: .csv formula injection + folder walk -------------------------
# CSV cells that lead with =, +, -, @ are formula-injection vectors (RCE/DDE when
# a human opens the file in a spreadsheet). The front-end owns that format threat;
# a prompt-injection *phrase* in a cell is materialized into the concept text and
# caught by the stage-2 scan instead. A folder is walked member-by-member.
def test_csv_formula_injection_cell_is_flagged(tmp_path):
p = _write(tmp_path, "data.csv", "name,note\nAlice,=cmd|'/c calc'!A1\nBob,ok\n")
extracted, _result, verdict = receive([p])
assert any("data.csv" in n for n, _r in extracted.rejected)
assert verdict == "REJECT"
def test_csv_formula_leading_whitespace_bypass_is_flagged(tmp_path):
# a tab/space before the '=' still parses as a formula in a spreadsheet.
p = _write(tmp_path, "sneaky.csv", 'a,b\n1,"\t=HYPERLINK(\'http://evil\')"\n')
extracted, _result, verdict = receive([p])
assert extracted.rejected != ()
assert verdict == "REJECT"
def test_clean_csv_admits(tmp_path):
p = _write(tmp_path, "clean.csv", "name,count\nAlice,3\nBob,5\n")
extracted, _result, verdict = receive([p])
assert extracted.rejected == ()
assert verdict == "ADMIT"
assert "uploads/clean.md" in extracted.bundle
def test_csv_formula_detach_proof(tmp_path):
# The same file with plain cells has no formula flag -> ADMIT, so the flag is
# the formula content, not the .csv suffix or the filename.
p = _write(tmp_path, "data.csv", "name,note\nAlice,calc\nBob,ok\n")
extracted, _result, verdict = receive([p])
assert extracted.rejected == ()
assert verdict == "ADMIT"
def test_csv_injection_phrase_in_cell_is_caught_by_the_guard(tmp_path):
# not a formula — a prompt injection sitting in a cell. It rides the
# materialized concept text into the stage-2 scan (T1), not the formula check.
p = _write(tmp_path, "notes.csv", "id,note\n1," + _INJECTION + "\n")
extracted, result, verdict = receive([p])
assert extracted.rejected == () # no formula lead
assert result.disposition is Disposition.FAIL_SECURE # caught by the guard
assert verdict == "REJECT"
def test_folder_upload_reserved_member_is_rejected(tmp_path):
folder = tmp_path / "bundle"
(folder / "tables").mkdir(parents=True)
(folder / "tables" / "users.md").write_text("---\ntype: t\n---\nclean.\n", encoding="utf-8")
(folder / "index.md").write_text("---\ntype: t\n---\nlisting.\n", encoding="utf-8")
_extracted, result, verdict = receive([folder])
by_path = {c.path: c for c in result.concepts}
assert "uploads/index.md" in by_path # reserved basename
assert by_path["uploads/index.md"].disposition is Disposition.FAIL_SECURE # T4
assert verdict == "REJECT"
def test_clean_folder_admits(tmp_path):
folder = tmp_path / "bundle"
(folder / "tables").mkdir(parents=True)
(folder / "tables" / "users.md").write_text("---\ntype: t\n---\nclean.\n", encoding="utf-8")
(folder / "notes.txt").write_text("A routine note.", encoding="utf-8")
extracted, _result, verdict = receive([folder])
assert set(extracted.bundle) == {"uploads/tables/users.md", "uploads/notes.md"}
assert verdict == "ADMIT"