Skip to content

Commit 54608d0

Browse files
committed
feat(pptx): add ShapeFragment safety system and validation engine
- Introduce ShapeFragment branded type to prevent raw XML injection - addBody() now validates ShapeFragment, rejects raw strings - Hide _createShapeFragment from LLM discovery (underscore prefix) - Add validation engine: relationship caps, NaN/Infinity detection, cross-slide duplicate shape ID checks, notes sanitization - Chart complexity caps: 50 charts/deck, 24 series, 100 categories - Deduplicate MAX constants (single source in pptx-charts.ts) - Update hints/prompts and SKILL.md with ShapeFragment API docs - Add 59 safety tests covering all shape builders and edge cases Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent 64f80d3 commit 54608d0

File tree

15 files changed

+3431
-1195
lines changed

15 files changed

+3431
-1195
lines changed

builtin-modules/ooxml-core.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
"description": "Shared OOXML infrastructure - units, colors, themes, Content_Types, relationships",
44
"author": "system",
55
"mutable": false,
6-
"sourceHash": "sha256:24c8441a3504052f",
7-
"dtsHash": "sha256:9f88e7c59a56854c",
6+
"sourceHash": "sha256:00cfaa1e652856b2",
7+
"dtsHash": "sha256:6aac85502082bf89",
88
"importStyle": "named",
99
"hints": {
1010
"overview": "Low-level OOXML infrastructure. Most users should use ha:pptx instead.",

builtin-modules/pptx-charts.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
"description": "OOXML DrawingML chart generation - bar, pie, line charts for PPTX presentations",
44
"author": "system",
55
"mutable": false,
6-
"sourceHash": "sha256:029765ed53b96536",
7-
"dtsHash": "sha256:5f653830226c3554",
6+
"sourceHash": "sha256:27d40e5c5095ec38",
7+
"dtsHash": "sha256:4353b8263dc99405",
88
"importStyle": "named",
99
"hints": {
1010
"overview": "Chart generation for PPTX. Always used with ha:pptx.",
@@ -16,7 +16,10 @@
1616
"Use chartSlide(pres, {title, chart}) for simple full-slide charts",
1717
"Chart values must be finite numbers — not null, undefined, or strings",
1818
"pieChart: labels[] and values[] must have the same length",
19-
"barChart/lineChart: each series.values[] length must equal categories[] length"
19+
"barChart/lineChart: each series.values[] length must equal categories[] length",
20+
"Max 50 charts per deck, 24 series per chart, 100 categories per chart",
21+
"embedChart returns {shape, ...} — pass result.shape to customSlide, NOT result directly",
22+
"Do NOT call .toString() on chart results — it throws. Use .shape property."
2023
],
2124
"antiPatterns": [
2225
"Don't import chart functions from ha:pptx — they're in this module"

builtin-modules/pptx-tables.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
"description": "Styled tables for PPTX presentations - headers, borders, alternating rows",
44
"author": "system",
55
"mutable": false,
6-
"sourceHash": "sha256:399b5349b1c8c187",
7-
"dtsHash": "sha256:82d903ffbf4dfb1e",
6+
"sourceHash": "sha256:5940fb396a67f801",
7+
"dtsHash": "sha256:3ba75bbc44353467",
88
"importStyle": "named",
99
"hints": {
1010
"overview": "Table generation for PPTX. Always used with ha:pptx.",
11-
"relatedModules": ["ha:pptx"],
11+
"relatedModules": [
12+
"ha:pptx"
13+
],
1214
"criticalRules": [
13-
"comparisonTable: options array must not be empty, each option needs {name, values}"
15+
"comparisonTable: options array must not be empty, each option needs {name, values}",
16+
"All table functions return ShapeFragment — pass directly to customSlide shapes array"
1417
],
1518
"commonPatterns": [
1619
"table({x, y, w, headers, rows, style}) for data tables",

builtin-modules/pptx.json

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
"description": "PowerPoint PPTX presentation builder - slides, text, shapes, themes, layouts",
44
"author": "system",
55
"mutable": false,
6-
"sourceHash": "sha256:a13871a41506a523",
7-
"dtsHash": "sha256:2107e369816b4bd5",
6+
"sourceHash": "sha256:895d7188d5ba8b46",
7+
"dtsHash": "sha256:27520514e4401465",
88
"importStyle": "named",
99
"hints": {
1010
"overview": "Core PPTX slide building. Charts in ha:pptx-charts, tables in ha:pptx-tables.",
@@ -27,13 +27,18 @@
2727
"ALL slide functions need pres as FIRST parameter: titleSlide(pres, opts)",
2828
"Charts are NOT in this module — import from ha:pptx-charts",
2929
"Tables are NOT in this module — import from ha:pptx-tables",
30+
"Shape builders return ShapeFragment objects — NEVER construct raw XML strings",
31+
"customSlide shapes must be ShapeFragment or ShapeFragment[] — raw strings are rejected",
3032
"Use getThemeNames() to see valid themes",
3133
"DARK THEMES auto-handle contrast — don't use forceColor",
32-
"Don't specify text color — theme auto-selects readable colours"
34+
"Don't specify text color — theme auto-selects readable colours",
35+
"Speaker notes are plain text only — max 12,000 chars, auto-sanitized"
3336
],
3437
"antiPatterns": [
3538
"Don't store pres object in shared-state — use pres.serialize()",
36-
"Don't write raw OOXML XML — use module functions",
39+
"Don't write raw OOXML XML — use module shape builder functions",
40+
"Don't concatenate ShapeFragment objects with + — pass as arrays",
41+
"Don't call .toString() on chart results — use .shape property",
3742
"Don't guess function names — call module_info first",
3843
"series.name is REQUIRED for all chart data series"
3944
],

builtin-modules/src/ooxml-core.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,93 @@ export function isDark(hex: string): boolean {
652652
return luminance(hex) < 0.5;
653653
}
654654

655+
// ── ShapeFragment (Opaque Branded Type) ──────────────────────────────
656+
// All shape builders (textBox, rect, table, etc.) return ShapeFragment.
657+
// Only code that holds the private SHAPE_BRAND symbol can forge one.
658+
// This prevents LLMs from injecting arbitrary XML strings into slides.
659+
//
660+
// SECURITY MODEL:
661+
// The sandbox architecture shares all ha:* module exports at runtime.
662+
// We cannot make _createShapeFragment truly unexportable for cross-module
663+
// use (pptx.ts, pptx-charts.ts, pptx-tables.ts all need it).
664+
// Defence layers:
665+
// 1. Underscore prefix → excluded from module_info / hints by convention
666+
// 2. Filtered from ha-modules.d.ts → invisible to LLM type discovery
667+
// (generate-ha-modules-dts.ts skips _-prefixed exports)
668+
// 3. SKILL.md documents only builder functions, not the factory
669+
// 4. Code-validator + sandbox provide the hard security boundary
670+
// The threat model is LLM hallucinations, not adversarial humans.
671+
672+
/** Private brand symbol — never exported by module boundary. */
673+
const SHAPE_BRAND: unique symbol = Symbol("ShapeFragment");
674+
675+
/**
676+
* Opaque shape fragment produced by official shape builders.
677+
* Cannot be constructed from raw strings by LLM code.
678+
*
679+
* Internal code can read `._xml`; external (LLM) code treats this as opaque.
680+
*/
681+
export interface ShapeFragment {
682+
/** @internal Raw OOXML XML for this shape element. */
683+
readonly _xml: string;
684+
/** Returns the internal XML (for string concatenation in internal code). */
685+
toString(): string;
686+
}
687+
688+
/**
689+
* Create a branded ShapeFragment wrapping validated XML.
690+
* Called internally by shape builder functions (textBox, rect, table, etc.).
691+
* Underscore-prefixed to signal internal-only — LLMs should use builder
692+
* functions (textBox, rect, etc.) not this directly.
693+
* @internal
694+
*/
695+
export function _createShapeFragment(xml: string): ShapeFragment {
696+
const obj = {
697+
_xml: xml,
698+
toString(): string {
699+
return xml;
700+
},
701+
} as ShapeFragment;
702+
// Brand the object with the private symbol (runtime check)
703+
(obj as unknown as Record<symbol, boolean>)[SHAPE_BRAND] = true;
704+
return Object.freeze(obj);
705+
}
706+
707+
/**
708+
* Check whether a value is a genuine ShapeFragment from a builder function.
709+
* Uses the private symbol brand — cannot be forged by LLM code.
710+
*/
711+
export function isShapeFragment(x: unknown): x is ShapeFragment {
712+
return (
713+
x != null &&
714+
typeof x === "object" &&
715+
(x as Record<symbol, unknown>)[SHAPE_BRAND] === true
716+
);
717+
}
718+
719+
/**
720+
* Convert an array of ShapeFragments to a single XML string.
721+
* Validates that every element is a genuine branded ShapeFragment.
722+
* @throws If any element is not a ShapeFragment
723+
*/
724+
export function fragmentsToXml(
725+
fragments: ShapeFragment | ShapeFragment[],
726+
): string {
727+
const arr = Array.isArray(fragments) ? fragments : [fragments];
728+
const parts: string[] = [];
729+
for (let i = 0; i < arr.length; i++) {
730+
const f = arr[i];
731+
if (!isShapeFragment(f)) {
732+
throw new Error(
733+
`shapes[${i}]: expected a ShapeFragment from textBox/rect/table/bulletList/etc, ` +
734+
`but got ${typeof f}. Do NOT pass raw XML strings — use the shape builder functions.`,
735+
);
736+
}
737+
parts.push(f._xml);
738+
}
739+
return parts.join("");
740+
}
741+
655742
// ── Shape ID Counter ─────────────────────────────────────────────────
656743
// OOXML requires each shape in a presentation to have a unique positive integer ID.
657744
// PowerPoint will show a "found a problem with content" error if multiple

builtin-modules/src/pptx-charts.ts

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,23 @@ import {
1616
requireArray,
1717
requireNumber,
1818
nextShapeId,
19+
_createShapeFragment,
20+
type ShapeFragment,
1921
} from "ha:ooxml-core";
2022
import { escapeXml } from "ha:xml-escape";
2123

24+
// ── Chart Complexity Caps ────────────────────────────────────────────
25+
// Hard limits to prevent decks that exhaust PowerPoint's rendering budget.
26+
27+
/** Maximum charts per presentation deck. */
28+
export const MAX_CHARTS_PER_DECK = 50;
29+
30+
/** Maximum data series per chart (Excel column reference limit B–Y). */
31+
export const MAX_SERIES_PER_CHART = 24;
32+
33+
/** Maximum categories (X-axis labels) per chart. */
34+
export const MAX_CATEGORIES_PER_CHART = 100;
35+
2236
// ── Namespace Constants ──────────────────────────────────────────────
2337
const NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart";
2438
const NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main";
@@ -99,7 +113,11 @@ function seriesXml(
99113
// Series name is REQUIRED — charts with unnamed series produce meaningless legends.
100114
requireString(series.name, `series[${index}].name`);
101115
// Series values are REQUIRED and must be a non-empty array of numbers.
102-
if (!series.values || !Array.isArray(series.values) || series.values.length === 0) {
116+
if (
117+
!series.values ||
118+
!Array.isArray(series.values) ||
119+
series.values.length === 0
120+
) {
103121
throw new Error(
104122
`series[${index}].values: array must not be empty. ` +
105123
`This often happens when fetched data is empty. ` +
@@ -327,6 +345,18 @@ export function barChart(opts: BarChartOptions): ChartResult {
327345
requireArray(opts.categories || [], "barChart.categories");
328346
requireArray(opts.series || [], "barChart.series", { nonEmpty: true });
329347
if (opts.textColor) requireHex(opts.textColor, "barChart.textColor");
348+
// Enforce complexity caps
349+
if ((opts.categories || []).length > MAX_CATEGORIES_PER_CHART) {
350+
throw new Error(
351+
`barChart: ${(opts.categories || []).length} categories exceeds the maximum of ${MAX_CATEGORIES_PER_CHART}. ` +
352+
`Reduce category count or aggregate data.`,
353+
);
354+
}
355+
if ((opts.series || []).length > MAX_SERIES_PER_CHART) {
356+
throw new Error(
357+
`barChart: ${(opts.series || []).length} series exceeds the maximum of ${MAX_SERIES_PER_CHART}.`,
358+
);
359+
}
330360

331361
const dir = opts.horizontal ? "bar" : "col";
332362
const grouping = opts.stacked ? "stacked" : "clustered";
@@ -345,7 +375,10 @@ ${seriesXmls}
345375
${axisXml(1, 2, opts.horizontal ? "l" : "b", true, tc)}
346376
${axisXml(2, 1, opts.horizontal ? "b" : "l", false, tc)}`;
347377

348-
return chartResult("bar", chartXml(plotArea, opts.title, opts.showLegend, tc));
378+
return chartResult(
379+
"bar",
380+
chartXml(plotArea, opts.title, opts.showLegend, tc),
381+
);
349382
}
350383

351384
export interface PieChartOptions {
@@ -420,6 +453,13 @@ export function pieChart(opts: PieChartOptions): ChartResult {
420453
`has ${values.length}. They must have the same length — one label per slice.`,
421454
);
422455
}
456+
// Enforce complexity caps
457+
if (labels.length > MAX_CATEGORIES_PER_CHART) {
458+
throw new Error(
459+
`pieChart: ${labels.length} slices exceeds the maximum of ${MAX_CATEGORIES_PER_CHART}. ` +
460+
`Group smaller values into an "Other" slice.`,
461+
);
462+
}
423463
// Validate each value is a finite number
424464
values.forEach((v, i) => {
425465
if (typeof v !== "number" || !Number.isFinite(v)) {
@@ -519,7 +559,10 @@ ${seriesDataLabels}
519559
${holeSize}
520560
</${chartTag}>`;
521561

522-
return chartResult("pie", chartXml(plotArea, opts.title, effectiveShowLegend, tc));
562+
return chartResult(
563+
"pie",
564+
chartXml(plotArea, opts.title, effectiveShowLegend, tc),
565+
);
523566
}
524567

525568
export interface LineChartOptions {
@@ -566,6 +609,17 @@ export function lineChart(opts: LineChartOptions): ChartResult {
566609
requireArray(opts.categories || [], "lineChart.categories");
567610
requireArray(opts.series || [], "lineChart.series", { nonEmpty: true });
568611
if (opts.textColor) requireHex(opts.textColor, "lineChart.textColor");
612+
// Enforce complexity caps
613+
if ((opts.categories || []).length > MAX_CATEGORIES_PER_CHART) {
614+
throw new Error(
615+
`lineChart: ${(opts.categories || []).length} categories exceeds the maximum of ${MAX_CATEGORIES_PER_CHART}.`,
616+
);
617+
}
618+
if ((opts.series || []).length > MAX_SERIES_PER_CHART) {
619+
throw new Error(
620+
`lineChart: ${(opts.series || []).length} series exceeds the maximum of ${MAX_SERIES_PER_CHART}.`,
621+
);
622+
}
569623

570624
const chartTag = opts.area ? "c:areaChart" : "c:lineChart";
571625
const grouping = "standard";
@@ -650,7 +704,10 @@ ${seriesXmls}
650704
${axisXml(1, 2, "b", true, tc)}
651705
${axisXml(2, 1, "l", false, tc)}`;
652706

653-
return chartResult(opts.area ? "area" : "line", chartXml(plotArea, opts.title, opts.showLegend, tc));
707+
return chartResult(
708+
opts.area ? "area" : "line",
709+
chartXml(plotArea, opts.title, opts.showLegend, tc),
710+
);
654711
}
655712

656713
export interface ComboChartOptions {
@@ -691,6 +748,12 @@ export interface ComboChartOptions {
691748
export function comboChart(opts: ComboChartOptions): ChartResult {
692749
// ── Input validation ──────────────────────────────────────────────
693750
requireArray(opts.categories || [], "comboChart.categories");
751+
// Enforce complexity caps
752+
if ((opts.categories || []).length > MAX_CATEGORIES_PER_CHART) {
753+
throw new Error(
754+
`comboChart: ${(opts.categories || []).length} categories exceeds the maximum of ${MAX_CATEGORIES_PER_CHART}.`,
755+
);
756+
}
694757
const barSeries = opts.barSeries || [];
695758
const lineSeries = opts.lineSeries || [];
696759
requireArray(barSeries, "comboChart.barSeries");
@@ -770,7 +833,10 @@ ${lineXmls}
770833
${axisXml(1, 2, "b", true, tc)}
771834
${axisXml(2, 1, "l", false, tc)}`;
772835

773-
return chartResult("combo", chartXml(plotArea, opts.title, opts.showLegend, tc));
836+
return chartResult(
837+
"combo",
838+
chartXml(plotArea, opts.title, opts.showLegend, tc),
839+
);
774840
}
775841

776842
// ── Chart Embedding into PPTX Slides ─────────────────────────────────
@@ -783,11 +849,14 @@ export interface ChartPosition {
783849
}
784850

785851
export interface EmbedChartResult {
852+
/** ShapeFragment for use in customSlide shapes array. */
853+
shape: ShapeFragment;
854+
/** @internal Raw shape XML string (kept for internal compatibility). */
786855
shapeXml: string;
787856
zipEntries: Array<{ name: string; data: string }>;
788857
chartRelId: string;
789858
chartIndex: number;
790-
/** Returns shapeXml when converted to string (e.g., in string concatenation). */
859+
/** @deprecated Throws error — use .shape instead. */
791860
toString(): string;
792861
}
793862

@@ -834,6 +903,14 @@ export function embedChart(
834903
"Pass the object returned by createPresentation().",
835904
);
836905
}
906+
// Enforce deck-level chart cap
907+
const currentChartCount = (pres._charts || []).length;
908+
if (currentChartCount >= MAX_CHARTS_PER_DECK) {
909+
throw new Error(
910+
`embedChart: deck already has ${currentChartCount} charts — max ${MAX_CHARTS_PER_DECK}. ` +
911+
`Reduce chart count or split into multiple presentations.`,
912+
);
913+
}
837914
if (chart == null || chart.type !== "chart") {
838915
throw new Error(
839916
"embedChart: 'chart' must be a chart object from barChart/pieChart/lineChart/comboChart. " +
@@ -908,15 +985,21 @@ export function embedChart(
908985
pres._chartEntries.push(entry);
909986
}
910987

911-
// Return an object that stringifies to shapeXml for easy use in shape concatenation.
912-
// This allows: shapes: textBox(...) + embedChart(pres, chart, pos) + rect(...)
913-
// Instead of requiring: embedChart(...).shapeXml
988+
// Return structured result — use .shape for customSlide arrays.
989+
// toString() now THROWS to prevent accidental XML concatenation.
914990
const result: EmbedChartResult = {
991+
shape: _createShapeFragment(shapeXml),
915992
shapeXml,
916993
zipEntries,
917994
chartRelId: relId,
918995
chartIndex: idx,
919-
toString: () => shapeXml,
996+
toString(): string {
997+
throw new Error(
998+
"Cannot concatenate embedChart result directly into shapes. " +
999+
"Use the .shape property in your shapes array: " +
1000+
"customSlide(pres, { shapes: [textBox(...), chart.shape, rect(...)] })",
1001+
);
1002+
},
9201003
};
9211004
return result;
9221005
}

0 commit comments

Comments
 (0)