The import door reads an Excel workbook - #312
Conversation
A question bank lives in Excel, and the import door refused an .xlsx outright, asking for a CSV export first. The Read tool can't open a workbook, so the earlier attempt to have the skill read one (#267) could never work. A workbook is a zip of XML, so `_xlsx.py` reads one sheet of it with zipfile and ElementTree alone — no dependency added to a plugin that installs with none — into the same rows a CSV yields. Everything after that is one parse, so there is still one place a column can be misread. What a real question-bank workbook needed, beyond "read the first sheet": - Several sheets, the questions not on the first. With none named, the parse stops and lists them; `--sheet` names one (case and surrounding space are forgiven when unambiguous). Which tab holds the questions stays the user's call. - A title block above the table. The header is the first row within the top 20 that names a question column, by the same exact alias match as before. - Rows numbered as Excel numbers them, including rows the file omits, so a skipped row is reported at the number the person sees. - Rows Excel formats down to 1000 but never fills are dropped, not reported as a thousand empty questions. Shared strings with rich-text runs (phonetic guides left out), inline strings, booleans, numbers and sparse cells read as the sheet shows them; an error value is blank. An .xls file is refused with how to get past it, as is a file that isn't a workbook, a part declaring a DTD, or an oversized part. `--csv` keeps working as a second spelling of `--file`. Closes #261. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical XLSX security and resource-exhaustion issues, plus compatibility and correctness gaps, remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds dependency-free .xlsx question-bank import while preserving the existing CSV parsing flow.
Changes:
- Adds workbook reading, sheet selection, header detection, and Excel row metadata.
- Updates CLI, skill documentation, dataset documentation, and changelog.
- Adds comprehensive workbook and documentation tests.
File summaries
| File | Summary and review notes |
|---|---|
tests/test_golden_author_xlsx.py |
Adds workbook parsing, edge-case, and safety tests. |
tests/test_ah109_save_golden_skill.py |
Verifies updated skill guidance. |
plugins/agami/skills/agami-save-golden/SKILL.md |
Documents workbook import; needs payload-field and workbook-error guidance updates (nits). |
plugins/agami/shared/golden-dataset-shape.md |
Documents supported workbook input. |
plugins/agami/scripts/golden_author.py |
Integrates workbook parsing; header scanning can silently drop headerless CSV questions (moderate, 1 vote). |
plugins/agami/scripts/_xlsx.py |
Needs encoding-safe DTD protection (critical, 2 votes), bounded row validation (critical, 3 votes), Strict OOXML support or actionable rejection (moderate, 2 votes), and accurate ambiguous-sheet errors (moderate, 1 vote). |
CHANGELOG.md |
Qualify that only trailing formatted-empty rows are dropped (nit). |
Review details
Suppressed comments (6)
CHANGELOG.md:24
- This changelog wording says every formatted-but-empty row is dropped, but the implementation intentionally keeps such rows between filled rows so they are reported as skips; only trailing padding is removed. Qualify this as trailing rows to keep the release note accurate.
formats but never fills are dropped rather than reported as empty questions. An `.xls` file —
plugins/agami/scripts/_xlsx.py:179
- The column index is likewise an untrusted list size. A small cell with a very large column reference can make this loop allocate an enormous row before the workbook is parsed. Cap the index at Excel's 16,384-column limit (XFD) and refuse malformed coordinates before padding.
reference = _CELL_REFERENCE.fullmatch(cell.get("r") or "")
column = _column_index(reference.group(1)) if reference else len(cells)
while len(cells) < column:
cells.append("")
plugins/agami/scripts/_xlsx.py:75
- When case/trim normalization matches more than one tab,
len(matches) != 1reports that there is “no sheet named” even though the requested name is ambiguous and the candidates exist. Distinguish zero matches from multiple matches and report the ambiguity so the user knows to choose an exact tab name.
if len(matches) != 1:
raise WorkbookError(
f"this workbook has no sheet named {sheet!r}. Sheets: {_listed(sheets)}"
)
plugins/agami/scripts/golden_author.py:288
- Scanning arbitrary rows for an alias makes the promised headerless-CSV refusal unsafe: a one-column bank whose first question is literally
prompt,question, oraskis accepted as the header and that question is silently dropped. Keep the existing first-row refusal for CSVs (or add an unambiguous header/data distinction) while allowing title blocks where intended.
for index, row in enumerate(rows[:_HEADER_SCAN_ROWS]):
if "query" in _columns(row):
return index
plugins/agami/skills/agami-save-golden/SKILL.md:84
- The parser now adds
header_rowand, for workbooks,sheet, but the Phase 2b instructions still describe onlycolumns,rows,skipped, andsummary. That means the skill does not tell the confirmation step to show which tab and header were actually read, losing the context needed to catch a wrong-tab import. Update the payload instructions and confirmation table to include the new fields.
python3 "$AGAMI_PLUGIN_ROOT/scripts/golden_author.py" parse \
--file <path-to-csv-or-xlsx> [--sheet "<sheet name>"] \
> /tmp/agami-golden-parse-<ts>.json
plugins/agami/skills/agami-save-golden/SKILL.md:275
- The new reader has additional actionable refusal messages for DTD-bearing, oversized, missing, and malformed workbook parts, but this error table only documents the
.xlsand non-workbook cases. Those failures therefore have no skill-level recovery guidance. Add a generic workbook-read failure entry or document the added messages and their next steps.
| `agami-save-golden: this workbook has N sheets, so which one holds the questions has to be named with --sheet` | Not a fault — the workbook has more than one tab. List the sheets the message names, suggest the one that reads like the question bank, and on the user's word re-run with `--sheet "<name>"`. Never pick one yourself. |
| `agami-save-golden: this workbook has no sheet named '<name>'` | The name matched no tab (case and surrounding spaces are already forgiven). Show the sheets the message lists and re-run with the one the user means. |
| `agami-save-golden: this is Excel's older binary .xls format` / `this file is not a readable .xlsx workbook` | The file can't be read as a workbook. Ask the user to save it as `.xlsx` (or CSV) from Excel and re-invoke. Do not open it with the Read tool, and do not reconstruct its contents from memory. |
- Files reviewed: 7/7 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if b"<!DOCTYPE" in data: | ||
| raise WorkbookError("this workbook contains XML this reader will not parse") | ||
| try: | ||
| return ET.fromstring(data) |
There was a problem hiding this comment.
Right — a UTF-16 part spells nothing an ASCII search can find. Fixed in ca351f0: the guard is now expat itself, with handlers on the doctype and entity declarations, run after expat has honoured the part's declared encoding. A parametrized test sends the same entity-expansion part in UTF-8 and UTF-16, and asserts the UTF-16 bytes don't contain <!DOCTYPE — the case the old check missed — before asserting both are refused.
| number = int(row.get("r") or len(rows) + 1) | ||
| # Excel omits a row it has nothing to say about; that row is still one a person counts. | ||
| while len(rows) < number - 1: | ||
| rows.append([]) |
There was a problem hiding this comment.
Agreed, and the column had the same hole. Fixed in ca351f0: a row number is checked against Excel's 1,048,576 and a column against its 16,384 before either places a value, and a malformed row number or cell reference is refused. The sheet is held sparsely — filled cells only — and rows are built only up to the last filled one, capped at 100,000 rows and 2,000,000 built cells, so a styled cell on Excel's last row costs nothing and data that deep is refused. A parametrized test covers row 2,147,483,647, a non-numeric row, column XFE, a malformed reference and data on row 1,048,576, each asserting a refusal within a time bound.
| _MAIN = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}" | ||
| _REL = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" | ||
| _PACKAGE_REL = "{http://schemas.openxmlformats.org/package/2006/relationships}" |
There was a problem hiding this comment.
Fixed in ca351f0: Strict is read rather than rejected. The spreadsheet namespace is taken from each part's own root element, the relationship-id attribute is matched by local name, and the workbook part and shared strings are found through relationships (the package's root .rels, then the workbook's) rather than fixed paths. A test builds the same workbook in both conformance classes and asserts they read identically.
…w unsaid Copilot's review on #312, all nine findings: - The DTD guard searched for ASCII bytes, which a UTF-16 part never contains. It is now expat's own check, after the part's encoding is honoured. - A row number or column letter was used as a list length unchecked, so a tiny cell could allocate billions of entries. Both are checked against Excel's limits before they place anything, the sheet is held sparsely, and only rows up to the last filled one are built, under a cell budget. - Strict OOXML workbooks are read: namespaces come from each part's own root, and the workbook and shared strings are found by relationship, not path. - A name matching two tabs loosely is reported as ambiguous, not missing. - Header scanning is for workbooks only. A CSV's header is still its first non-empty row, so a question reading "question" cannot make the rows above it vanish; in a workbook, every row above the header is reported. - The skill names the sheet and header row before the rows, documents the new payload fields, and has recovery steps for every workbook refusal. - The CHANGELOG says trailing rows, which is what is dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Copilot's six suppressed comments, all addressed in ca351f0:
Re-checked against the real 53-sheet workbook: header on row 4, all 50 questions, 0 skipped, the two title rows above the header reported, nothing written. |
…--column A header like 'Warehouse SQL' is not an alias, so its statements were dropped without a word. The parse now lists such columns under 'unrecognized', the skill asks the person about each one, and --column sql="<header>" reads it on a yes. Matching stays exact; a mapping is never inferred. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed 767b592: a column the import doesn't read, but whose header looks like a field the sheet hasn't supplied (e.g.
|
Closes #261.
The import door refused an
.xlsxand asked for a CSV export first — the one ingestion surface with a hard refusal, on the file format a question bank actually lives in. The earlier attempt (#267) tried to have the skill read the workbook, which can't work: the Read tool cannot open one, and nothing else on a clean machine can either. This is the route #261's correction proposed instead.Change
plugins/agami/scripts/_xlsx.pyreads one sheet of an.xlsxwithzipfileandxml.etree.ElementTree— a workbook is a zip of XML, so no dependency is added to a plugin that installs with none. It yields the same rows a CSV does, and everything after that is the existing parse: still one parser, one place a column can be misread.golden_author.py parse --file <path> [--sheet <name>].--csvstays as a second spelling of--file, so every existing caller works unchanged.What a real question-bank workbook needed
Checked against a real 53-sheet validation workbook, where "read the first sheet" would have been wrong four ways:
2) and lists them all;--sheetnames one. Which tab holds the questions stays the user's call, and the skill is told never to pick one. A name is matched exactly, then once more ignoring case and surrounding space if that's unambiguous.On that workbook: without a sheet name it stops and lists the sheets; with one, it finds the header on row 4 and reads all 50 questions with 0 skipped, in about 0.2s, writing nothing.
Also read as the sheet shows them: shared strings with rich-text runs (phonetic guides excluded), inline strings, booleans, numbers, sparse cells by column letter (past
Z); an error value (#N/A) is blank rather than imported as a question. Refused with a sentence rather than a traceback:.xls(Excel's older binary format), a file that isn't a workbook, an XML part declaring a DTD (the entity-expansion vector), and an oversized part.The payload gains
header_rowand, for a workbook,sheet, so the confirmation table can say what was read — a wrong tab parses cleanly and is only caught by someone seeing its name.Not changed, deliberately
Column matching stays exact. A statement column under a header the contract doesn't know is still not picked up — a workbook makes that easier to hit, so the skill now says to tell the user which header held the statements and have them rename it, but widening the alias set is its own decision.
Docs
agami-save-golden/SKILL.md: Phase 2a hands a workbook to the parse instead of refusing it, and asks which sheet; 2b shows--file/--sheetand the new payload fields; the error table swaps the refusal row for the three workbook messages; the frontmatter mentions workbooks.shared/golden-dataset-shape.mdand the CHANGELOG follow.Tests
tests/test_golden_author_xlsx.pybuilds every workbook in the test from the format's own parts — synthetic, a few kilobytes: a workbook parses to the same rows as the CSV it would export; header below a title block with skips at Excel's row numbers across an omitted row; formatted-but-empty rows aren't skips; several sheets refuse and name them, and--sheetpicks one; rich text, inline, boolean, number and error cells; sparse cells pastZ;.xlsand a non-workbook refused; a DTD-declaring part refused; parsing a workbook writes nothing;--sheeton a CSV is noted and the CSV still parses. Plus a skill-doc test that the refusal is gone and the sheet is left to the user. The existing parse, import and no-network tests pass unchanged.🤖 Generated with Claude Code