A CSV produced by an AI workflow is still untrusted data. The spreadsheet you open it in may interpret a cell as a formula instead of displaying the text you expected. Before an AI-generated prospect list or research export reaches a spreadsheet, inspect the parsed cells and route suspicious prefixes for review.
This guide builds a small text-only review function. It flags a defined set of leading markers, reports where they occur and leaves the source unchanged. It is not a spreadsheet sanitizer or a guarantee that an export is safe.
The fictional export
Fictional scenario: Orbit Studio collects company notes for a human to review. Its two-record fixture contains a normal booking note and a second note containing =1+1. These are authored sample records, not real prospects, model output or Jason Ead’s personal experience. The formula-like string is never executed in a spreadsheet during this example.
| Parsed company | Parsed note | Local review outcome |
|---|---|---|
| Orbit Studio | Discuss booking | No prefix flag for this cell |
| Nova Workshop | =1+1 | Record 2, note: review required |
CSV quoting is not the same as cell-type control
OWASP describes CSV injection as untrusted content being interpreted as spreadsheet formulas. Its guidance identifies leading equals, plus, minus, at signs, control characters and full-width variants as concerns. It also warns that quoting or escaping can behave differently after save/reopen and that no single sanitization strategy suits every spreadsheet and downstream consumer. OWASP CSV Injection.
A CSV parser handles field boundaries and quoting; it does not decide how another application will interpret a cell. Python’s csv reader normally returns strings, which lets this review inspect parsed cells rather than split raw lines on commas. Python csv documentation.
Do not delete markers or prepend characters silently. That changes business data and may still fail in a different consumer. This example chooses review instead of automatic repair. Its no-prefix-flags state means exactly that these particular checks found no flags, not that a spreadsheet passed a security test.
Build the text-only reviewer
Prerequisites are Python with the standard-library csv module. No third-party packages, API keys or office software are needed. Save the following as csv_review.py. The tested environment was Windows and Python 3.14.3; the current Python documentation describes the 3.14 branch.
"""Conservative text-only CSV review; never opens a spreadsheet or rewrites cells."""
import csv
import io
import json
HEADERS = ['company', 'note']
MARKERS = ('=', '+', '-', '@', '=', '+', '-', '@')
def review_csv(text):
if type(text) is not str or len(text.encode('utf-8')) > 100_000:
raise ValueError('Expected UTF-8 text within 100000 bytes')
try:
rows = list(csv.reader(io.StringIO(text, newline=''), strict=True))
except csv.Error as exc:
raise ValueError('Malformed CSV') from exc
if not rows or rows[0] != HEADERS:
raise ValueError('Expected exactly company,note headers in that order')
if not 1 <= len(rows) - 1 <= 100:
raise ValueError('Expected 1 to 100 data records')
issues = []
for record, row in enumerate(rows[1:], 1):
if len(row) != 2 or not row[0].strip():
raise ValueError('Each record needs two cells and a company')
for column, cell in zip(HEADERS, row):
trimmed = cell.lstrip()
leading = cell[:len(cell) - len(trimmed)]
if any(c in leading for c in '\t\r\n') or trimmed.startswith(MARKERS):
issues.append({'record': record, 'column': column,
'reason': 'leading formula marker or control character'})
return {'state': 'review-required' if issues else 'no-prefix-flags',
'records': len(rows) - 1, 'issues': issues}
if __name__ == '__main__':
sample = 'company,note\nOrbit Studio,Discuss booking\nNova Workshop,=1+1\n'
print(json.dumps(review_csv(sample), indent=2))
The contract is deliberately narrow: comma-delimited CSV, exact company/note headers, one to 100 data records and at most 100,000 UTF-8 bytes of input text. An invalid shape raises ValueError. A flagged prefix returns a review result rather than changing any cell.
The function checks both columns, not just the notes. It inspects leading whitespace for tab, carriage return and line feed, then checks the whitespace-trimmed start against the listed ASCII and full-width markers. Record numbers are one-based data-record numbers after the header, not physical line numbers: quoted multiline fields can span several lines.
The report contains coordinates and reasons rather than copying customer cell contents into logs. Still treat the original export and any review system according to their data sensitivity. In a real service, bound request size before loading or parsing; this function receives a string that already exists in memory.
Reproduce the failure cases
Save this as test_csv_review.py alongside the reviewer:
import csv
import io
import unittest
from csv_review import review_csv, MARKERS
def fixture(note):
out = io.StringIO(newline='')
writer = csv.writer(out)
writer.writerows([['company', 'note'], ['Orbit Studio', note]])
return out.getvalue()
class ReviewTests(unittest.TestCase):
def test_plain(self):
self.assertEqual(review_csv(fixture('Review booking'))['state'], 'no-prefix-flags')
def test_all_markers(self):
for marker in MARKERS:
with self.subTest(marker=marker):
self.assertEqual(review_csv(fixture(marker + '1'))['state'], 'review-required')
def test_whitespace(self):
for value in (' =1+1', '\tplain', ' \tplain', '\rplain', '\nplain'):
with self.subTest(value=value):
self.assertEqual(review_csv(fixture(value))['state'], 'review-required')
def test_csv_quoting_is_not_neutralization(self):
self.assertEqual(review_csv(fixture('=1+1,still text'))['issues'][0]['column'], 'note')
def test_multiline_literal(self):
self.assertEqual(review_csv(fixture('Review, "booking"\nNext week'))['records'], 1)
def test_header(self):
with self.assertRaises(ValueError): review_csv('note,company\na,b\n')
def test_width(self):
with self.assertRaises(ValueError): review_csv('company,note\na,b,c\n')
def test_bounds(self):
for text in ('company,note\n', 'company,note\n' + 'a,b\n' * 101, 'x' * 100001):
with self.subTest(size=len(text)):
with self.assertRaises(ValueError): review_csv(text)
def test_malformed(self):
with self.assertRaises(ValueError): review_csv('company,note\na,"unfinished')
def test_company_checked(self):
result = review_csv('company,note\n=1+1,plain\n')
self.assertEqual(result['issues'][0]['column'], 'company')
def test_false_positive_and_unchanged(self):
text = fixture('+15550123456')
original = text
self.assertEqual(review_csv(text)['state'], 'review-required')
self.assertEqual(text, original)
if __name__ == '__main__':
unittest.main()
Run the tests and the sample:
python -X utf8 -m unittest -v test_csv_review.py
python -X utf8 csv_review.py
Eleven test methods passed, including subcases for all eight markers and five whitespace/control-prefix examples. The checks cover quoted commas, a legitimate multiline field, malformed quoting, schema/width/size/record bounds, company-column inspection and an unchanged input string. The sample program printed:
{
"state": "review-required",
"records": 2,
"issues": [
{
"record": 2,
"column": "note",
"reason": "leading formula marker or control character"
}
]
}
The renderer-free program, tests and commands were extracted from the final article and run again in a disposable directory. Output matched the sample. These are parser and review-rule tests, not Excel or LibreOffice execution tests.
Expect false positives, and do not confuse review with approval
A phone number beginning with plus or a legitimate negative value is flagged by this conservative policy. The tests deliberately include a plus-prefixed fictional phone string. That is not evidence of an attack. It is a reason for a reviewer to decide how this field should be represented for the specific destination.
If a document is review-required, keep it out of automatic spreadsheet delivery. Show the reviewer the source in a text-oriented view, record the destination and proposed handling, and validate any transformed artifact separately. Do not simply relabel a flagged original as approved. If a different delimiter, schema or import path is needed, define that contract explicitly instead of guessing.
Pair this boundary with structured AI-output validation. Neither check establishes that a company exists, the notes are accurate or the owner authorized contacting anyone.
What remains untested
No spreadsheet was opened, no formula was executed, and no Excel/LibreOffice locale, import dialog, save/reopen behavior or downstream re-export was tested. This finite prefix policy may miss other consumer-specific interpretation paths. It does not sanitize files, inspect XLSX, detect macros, validate business claims, prevent prompt injection or implement a release approval system. The code prints a review report and performs no file writes or network calls.
Use the result as a small, inspectable gate in a larger workflow, not a security certification. The cover is generated conceptual artwork; the interior table reflects the local sample result. No real client, incident, measured loss prevention or AI model benchmark is claimed.
Jason Ead publishes AlgoThoughts AI News and connects practical business tooling with My Biz Heroes: websites, automation, workflows, AI tools and training. Explore Jason’s work. The useful outcome is a visible review decision before untrusted text reaches a spreadsheet.
