From c9f1f3674a20a0b3d4ef8171d9f0987d91b947a5 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sun, 5 Jul 2026 22:46:09 -0700 Subject: [PATCH 1/2] Fix slide deck MCP export provenance --- README.md | 2 + docs/slide-deck-mcp.md | 145 +++++ .../skills/judgmentkit-hosted-mcp/SKILL.md | 9 +- src/mcp.mjs | 534 +++++++++++++++++- tests/mcp.test.mjs | 261 ++++++++- 5 files changed, 912 insertions(+), 39 deletions(-) create mode 100644 docs/slide-deck-mcp.md diff --git a/README.md b/README.md index acbb89c..89fcec7 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ npm run mcp:smoke judgmentkit review --input examples/refund-triage.brief.txt ``` +For JudgmentKit slide deck planning and local PPTX export from Codex Desktop, use the receipt-backed MCP workflow in `docs/slide-deck-mcp.md`. + For a hosted MCP install: ```bash diff --git a/docs/slide-deck-mcp.md b/docs/slide-deck-mcp.md new file mode 100644 index 0000000..7315ae5 --- /dev/null +++ b/docs/slide-deck-mcp.md @@ -0,0 +1,145 @@ +# JudgmentKit Slide Deck MCP Export + +Use `create_slide_deck` only when the user explicitly asks JudgmentKit to create or plan a deck. Primary slide content should use domain language. Do not put prompts, schemas, MCP tool names, resource ids, traces, or model configuration into slide copy unless the deck is explicitly for setup, debugging, auditing, or integration work. + +## Path Contract + +`output.path` is always repo-relative and must stay under: + +```text +outputs/judgmentkit-slide-decks/ +``` + +For local PPTX export from Codex Desktop, pass the active workspace root separately: + +```json +{ + "output": { + "path": "outputs/judgmentkit-slide-decks/repro-minimal.pptx" + }, + "runtime": { + "workspace_root": "/absolute/path/to/active/workspace", + "artifact_tool_package": "/absolute/path/to/@oai/artifact-tool" + } +} +``` + +If `workspace_root` is missing and the MCP runtime cannot determine a safe active workspace, export fails with `workspace_root_missing`. The tool must not silently write under `/var/task`, `.codex`, or the bundled Codex runtime cache. + +## Minimal Codex Desktop Example + +First plan the deck without writing a file: + +```json +{ + "deck": { + "deck_id": "quarterly-review", + "title": "Quarterly review" + }, + "slides": [ + { + "template_id": "slide-21", + "content": { + "title": "Quarterly review", + "subtitle": "Evidence and decisions for the product team." + } + } + ], + "dry_run": true +} +``` + +Then export from a local runtime: + +```json +{ + "deck": { + "deck_id": "quarterly-review", + "title": "Quarterly review" + }, + "output": { + "path": "outputs/judgmentkit-slide-decks/quarterly-review.pptx" + }, + "runtime": { + "workspace_root": "/Users/mike/example-workspace", + "artifact_tool_package": "/Users/mike/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/@oai/artifact-tool" + }, + "dry_run": false, + "include_diagnostics": true, + "slides": [ + { + "template_id": "slide-21", + "content": { + "title": "Quarterly review", + "subtitle": "Evidence and decisions for the product team." + } + } + ] +} +``` + +A successful export returns `deck_creation_status: "exported"`, `artifact_ref`, and `provenance_receipt`. The tool also writes a sidecar receipt next to the PPTX, for example: + +```text +outputs/judgmentkit-slide-decks/quarterly-review.pptx.receipt.json +``` + +Treat a deck as JudgmentKit-generated only when the MCP response or sidecar receipt confirms: + +```json +{ + "tool_name": "mcp__judgmentkit.create_slide_deck", + "deck_creation_status": "exported", + "adapter_manifest_id": "judgmentkit.presentation-theme.adapter-v1", + "template_registry_version": "0.1.0" +} +``` + +## Portfolio Or Case-Study Example + +For portfolio, pitch, and case-study decks, pass explicit `template_id` values or strong `selection` metadata. Do not rely on broad defaults when layout variety matters. + +```json +{ + "deck": { + "deck_id": "case-study-review", + "title": "Case study review" + }, + "output": { + "path": "outputs/judgmentkit-slide-decks/case-study-review.pptx" + }, + "runtime": { + "workspace_root": "/Users/mike/example-workspace", + "artifact_tool_package": "/Users/mike/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/@oai/artifact-tool" + }, + "dry_run": false, + "slides": [ + { + "template_id": "slide-21", + "content": { "title": "Case study review", "subtitle": "Decision context and outcomes." } + }, + { + "template_id": "slide-06", + "content": { "title": "Problem and solution", "body": "Compare the starting constraint with the repaired workflow." } + }, + { + "template_id": "slide-08", + "content": { "title": "Evidence table", "rows": [{ "signal": "Review debt", "value": "Rising" }] } + }, + { + "template_id": "slide-64", + "content": { "title": "Trend evidence", "chart": { "type": "line" } } + }, + { + "template_id": "slide-62", + "content": { "title": "Outcome metrics", "metrics": [{ "label": "Saved time", "value": "32%" }] } + }, + { + "template_id": "slide-80", + "content": { "title": "Recommendation", "body": "Name the next decision and owner." } + } + ] +} +``` + +If more than half the deck uses the same layout or layout family, the response includes a repetition warning with suggested template families. diff --git a/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md b/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md index 0c21c37..8414c69 100644 --- a/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md +++ b/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md @@ -25,7 +25,9 @@ Before calling a deck creation MCP tool, establish: - required output format and delivery path - confidentiality boundary for hosted processing -When the active JudgmentKit MCP server exposes a deck creation tool, call it with the reviewed activity context and allowed source material. Keep primary slide copy in domain language and do not expose prompts, schemas, resource ids, MCP server names, tool names, traces, or model configuration in slide content. +When the active JudgmentKit MCP server exposes a deck creation tool, call it with the reviewed activity context and allowed source material. Keep primary slide copy in domain language and do not expose prompts, schemas, resource ids, MCP server names, tool names, traces, or model configuration in slide content. For local PPTX export, use a repo-relative `output.path` and pass the active workspace root separately through the tool runtime when required. + +Treat an exported PPTX as JudgmentKit output only when the MCP response or sidecar receipt confirms `tool_name: "mcp__judgmentkit.create_slide_deck"` and `deck_creation_status: "exported"`. A folder named `outputs/judgmentkit-slide-decks` is not provenance by itself. If no deck creation tool is listed by the active endpoint, state that the current JudgmentKit endpoint cannot create the deck yet. Do not fabricate a JudgmentKit packet, deck, or MCP result. Continue only with a deterministic outline or requirements summary if the user wants that fallback. @@ -85,8 +87,9 @@ For slide deck requests: 1. Confirm the deck audience, purpose, source material, confidentiality boundary, and target format from the brief or local context. 2. Use the activity contract to keep the deck focused on the participant decision and outcome, not implementation machinery. 3. Call the deck creation MCP tool when it is available from the active endpoint. -4. Treat returned deck guidance or artifacts as JudgmentKit output only when they came from the MCP tool. -5. Review slide copy for disclosure discipline before handoff: primary slides should use domain language, while prompts, schemas, resource ids, MCP details, traces, and model configuration stay out of the deck unless the deck is explicitly for setup, debugging, auditing, or integration. +4. Treat returned deck guidance or artifacts as JudgmentKit output only when the MCP response or sidecar receipt confirms `tool_name: "mcp__judgmentkit.create_slide_deck"` and `deck_creation_status: "exported"`. +5. For portfolio or case-study decks, pass explicit `template_id` values or strong selection metadata when layout variety matters; heed layout repetition warnings. +6. Review slide copy for disclosure discipline before handoff: primary slides should use domain language, while prompts, schemas, resource ids, MCP details, traces, and model configuration stay out of the deck unless the deck is explicitly for setup, debugging, auditing, or integration. ## Activity Contract diff --git a/src/mcp.mjs b/src/mcp.mjs index ad48386..e01ae05 100644 --- a/src/mcp.mjs +++ b/src/mcp.mjs @@ -38,8 +38,27 @@ import { const MCP_SERVER_NAME = "JudgmentKit"; const MCP_SERVER_VERSION = "0.6.5"; const SLIDE_DECK_SCHEMA = "judgmentkit.mcp.slide-deck/v1"; +const SLIDE_DECK_RECEIPT_SCHEMA = "judgmentkit.mcp.slide-deck.provenance-receipt/v1"; const MAX_SLIDE_DECK_SLIDES = 24; const DEFAULT_SLIDE_DECK_OUTPUT_DIR = "outputs/judgmentkit-slide-decks"; +const DEFAULT_BODY_TEMPLATE_SEQUENCE = [ + "slide-01", + "slide-06", + "slide-32", + "slide-62", + "slide-64", + "slide-08", + "slide-37", + "slide-70", + "slide-73", +]; +const WORKSPACE_ROOT_ENV_KEYS = [ + "JUDGMENTKIT_WORKSPACE_ROOT", + "CODEX_WORKSPACE_ROOT", + "CODEX_WORKSPACE_DIR", + "CODEX_WORKSPACE", + "INIT_CWD", +]; const PPTX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; const APPROVED_PRIMITIVES_INPUT_DESCRIPTION = @@ -562,6 +581,11 @@ const CREATE_SLIDE_DECK_TOOL = { description: "Optional local path to an @oai/artifact-tool package. If omitted, JudgmentKit checks standard Codex runtime environment paths.", }, + workspace_root: { + type: "string", + description: + "Optional absolute Codex workspace root used to resolve repo-relative output.path during local artifact export. Also supported through JUDGMENTKIT_WORKSPACE_ROOT or CODEX_WORKSPACE_ROOT.", + }, }, additionalProperties: false, }, @@ -689,7 +713,7 @@ function toDisplayList(values, limit = 4) { } return values - .map((entry) => compactText(entry)) + .map((entry) => compactText(entry) || (isRecord(entry) ? compactText(entry.message) : "")) .filter(Boolean) .slice(0, limit); } @@ -1141,6 +1165,95 @@ function normalizeSlideDeckSlides(slides) { }); } +function collectSlideContentSignals(value, signals = []) { + if (signals.length >= 200) { + return signals; + } + + if (typeof value === "string") { + signals.push(value); + return signals; + } + + if (Array.isArray(value)) { + for (const item of value) { + collectSlideContentSignals(item, signals); + } + + return signals; + } + + if (isRecord(value)) { + for (const [key, nestedValue] of Object.entries(value)) { + signals.push(key); + collectSlideContentSignals(nestedValue, signals); + } + } + + return signals; +} + +function slideContentSignalText(slide) { + return collectSlideContentSignals(slide.content).join(" ").toLowerCase(); +} + +function hasArrayContent(value, keys) { + return keys.some((key) => Array.isArray(value?.[key]) && value[key].length > 0); +} + +function hasRecordContent(value, keys) { + return keys.some((key) => isRecord(value?.[key])); +} + +function inferredDefaultTemplateId(slide, index) { + if (index === 0) { + return "slide-21"; + } + + const content = isRecord(slide.content) ? slide.content : {}; + const signalText = slideContentSignalText(slide); + + if ( + hasArrayContent(content, ["rows", "table", "table_rows", "evidence_rows"]) || + hasRecordContent(content, ["table"]) + ) { + return "slide-08"; + } + + if ( + hasRecordContent(content, ["chart", "graph"]) || + hasArrayContent(content, ["series", "data_points", "trend_points"]) || + /\b(chart|graph|trend|trajectory|funnel|distribution)\b/.test(signalText) + ) { + return "slide-64"; + } + + if ( + hasArrayContent(content, ["metrics", "stats", "kpis", "measures"]) || + /\b(metric|metrics|kpi|kpis|measure|measures|score|scores|outcome|outcomes)\b/.test( + signalText, + ) + ) { + return "slide-62"; + } + + if ( + hasArrayContent(content, ["columns", "sections", "comparisons"]) || + hasRecordContent(content, ["before_after", "comparison"]) || + /\b(compare|comparison|versus|before|after|tradeoff|trade-off|architecture|system model)\b/.test( + signalText, + ) + ) { + return "slide-06"; + } + + if (/\b(closing|next step|next steps|recommendation|takeaway|summary|handoff)\b/.test(signalText)) { + return "slide-80"; + } + + return DEFAULT_BODY_TEMPLATE_SEQUENCE[(index - 1) % DEFAULT_BODY_TEMPLATE_SEQUENCE.length]; +} + function selectDeckTemplate(slide, index, includeDiagnostics = false) { try { if (slide.template_id) { @@ -1175,7 +1288,7 @@ function selectDeckTemplate(slide, index, includeDiagnostics = false) { }; } - const defaultTemplateId = index === 0 ? "slide-21" : "slide-01"; + const defaultTemplateId = inferredDefaultTemplateId(slide, index); return { selected_by: "default", @@ -1215,7 +1328,15 @@ function slideTemplateSummary(slide, selected, index) { }; } -function normalizeSlideDeckOutputPath(output, deckId) { +function slideDeckOutputPathDetails(outputPath, workspaceRoot) { + return { + output_path: outputPath, + accepted_path_shape: `${DEFAULT_SLIDE_DECK_OUTPUT_DIR}/.pptx`, + workspace_root: workspaceRoot, + }; +} + +function normalizeSlideDeckOutputPath(output, deckId, workspaceRoot) { const rawPath = compactText(output?.path); const relativePath = rawPath || `${DEFAULT_SLIDE_DECK_OUTPUT_DIR}/${deckId}.pptx`; @@ -1225,8 +1346,11 @@ function normalizeSlideDeckOutputPath(output, deckId) { relativePath.includes("\0") ) { throw new JudgmentKitInputError( - "create_slide_deck output.path must be a repo-relative .pptx path using forward slashes.", - { code: "unsafe_output_path" }, + `create_slide_deck output.path must be repo-relative under ${DEFAULT_SLIDE_DECK_OUTPUT_DIR} and use forward slashes. Absolute paths are not accepted; pass runtime.workspace_root separately when exporting.`, + { + code: "unsafe_output_path", + details: slideDeckOutputPathDetails(relativePath, workspaceRoot), + }, ); } @@ -1244,7 +1368,7 @@ function normalizeSlideDeckOutputPath(output, deckId) { `create_slide_deck output.path must stay under ${DEFAULT_SLIDE_DECK_OUTPUT_DIR} and end in .pptx.`, { code: "unsafe_output_path", - details: { output_path: relativePath }, + details: slideDeckOutputPathDetails(relativePath, workspaceRoot), }, ); } @@ -1252,9 +1376,123 @@ function normalizeSlideDeckOutputPath(output, deckId) { return normalized; } -async function prepareSlideDeckOutputTarget(relativePath, output = {}) { - const root = path.resolve(process.cwd(), DEFAULT_SLIDE_DECK_OUTPUT_DIR); - const absolutePath = path.resolve(process.cwd(), relativePath); +function configuredWorkspaceRootCandidates(runtime = {}) { + return [ + { + source: "runtime.workspace_root", + value: runtime.workspace_root, + }, + ...WORKSPACE_ROOT_ENV_KEYS.map((key) => ({ + source: `env.${key}`, + value: process.env[key], + })), + ]; +} + +function normalizeConfiguredWorkspaceRoot(candidate) { + const value = compactText(candidate.value); + + if (!value) { + return null; + } + + if (!path.isAbsolute(value)) { + throw new JudgmentKitInputError( + "workspace_root_invalid: create_slide_deck workspace root must be an absolute path.", + { + code: "workspace_root_invalid", + details: { + workspace_root: value, + workspace_root_source: candidate.source, + }, + }, + ); + } + + const root = path.resolve(value); + + let stats; + try { + stats = fs.statSync(root); + } catch (error) { + throw new JudgmentKitInputError( + "workspace_root_missing: create_slide_deck workspace root does not exist.", + { + code: "workspace_root_missing", + details: { + workspace_root: root, + workspace_root_source: candidate.source, + message: error instanceof Error ? error.message : String(error), + }, + }, + ); + } + + if (!stats.isDirectory()) { + throw new JudgmentKitInputError( + "workspace_root_invalid: create_slide_deck workspace root must be a directory.", + { + code: "workspace_root_invalid", + details: { + workspace_root: root, + workspace_root_source: candidate.source, + }, + }, + ); + } + + return { + root, + source: candidate.source, + explicit: candidate.source === "runtime.workspace_root", + }; +} + +function implicitWorkspaceRootIsUnsafe(root) { + return ( + root === "/var/task" || + root.startsWith("/var/task/") || + root.includes(`${path.sep}.codex${path.sep}`) || + root.includes(`${path.sep}.cache${path.sep}codex-runtimes${path.sep}`) + ); +} + +function resolveSlideDeckWorkspaceRoot(runtime = {}) { + for (const candidate of configuredWorkspaceRootCandidates(runtime)) { + const resolved = normalizeConfiguredWorkspaceRoot(candidate); + + if (resolved) { + return resolved; + } + } + + const cwd = path.resolve(process.cwd()); + + if (implicitWorkspaceRootIsUnsafe(cwd)) { + throw new JudgmentKitInputError( + `workspace_root_missing: create_slide_deck could not determine the Codex workspace root. It would resolve ${DEFAULT_SLIDE_DECK_OUTPUT_DIR} under ${cwd}; provide runtime.workspace_root or configure the MCP runtime.`, + { + code: "workspace_root_missing", + details: { + attempted_workspace_root: cwd, + workspace_root_source: "process.cwd()", + checked_env: WORKSPACE_ROOT_ENV_KEYS, + }, + }, + ); + } + + return { + root: cwd, + source: "process.cwd()", + explicit: false, + }; +} + +async function prepareSlideDeckOutputTarget(relativePath, output = {}, workspaceRoot) { + const workspace = workspaceRoot ?? resolveSlideDeckWorkspaceRoot(); + const root = path.resolve(workspace.root, DEFAULT_SLIDE_DECK_OUTPUT_DIR); + const absolutePath = path.resolve(workspace.root, relativePath); const parent = path.dirname(absolutePath); await fsp.mkdir(parent, { recursive: true }); @@ -1273,7 +1511,7 @@ async function prepareSlideDeckOutputTarget(relativePath, output = {}) { "create_slide_deck output.path resolves outside the allowed output directory.", { code: "unsafe_output_path", - details: { output_path: relativePath }, + details: slideDeckOutputPathDetails(relativePath, workspace.root), }, ); } @@ -1292,7 +1530,7 @@ async function prepareSlideDeckOutputTarget(relativePath, output = {}) { "create_slide_deck refuses to overwrite symlink output paths.", { code: "unsafe_output_path", - details: { output_path: relativePath }, + details: slideDeckOutputPathDetails(relativePath, workspace.root), }, ); } @@ -1302,12 +1540,17 @@ async function prepareSlideDeckOutputTarget(relativePath, output = {}) { "create_slide_deck output.path already exists. Set output.overwrite true to replace it.", { code: "output_exists", - details: { output_path: relativePath }, + details: slideDeckOutputPathDetails(relativePath, workspace.root), }, ); } - return { absolutePath, relativePath }; + return { + absolutePath, + relativePath, + workspace_root: workspace.root, + workspace_root_source: workspace.source, + }; } function sha256File(filePath) { @@ -1517,8 +1760,143 @@ async function withoutArtifactExportConsoleNoise(callback) { } } -async function exportSlideDeckArtifact({ args, deckId, outputPath, selectedSlides, slideSize, themeMode }) { - const outputTarget = await prepareSlideDeckOutputTarget(outputPath, args.output ?? {}); +function commonTemplateSuggestions() { + return [ + { template_id: "slide-21", family: "cover", use_when: "opening or section title" }, + { template_id: "slide-06", family: "two-column", use_when: "paired narrative or comparison" }, + { template_id: "slide-08", family: "data-table", use_when: "structured evidence" }, + { template_id: "slide-64", family: "chart", use_when: "trend or visual evidence" }, + { template_id: "slide-62", family: "metrics", use_when: "quantified outcomes" }, + { template_id: "slide-80", family: "content", use_when: "closing or concise takeaway" }, + ]; +} + +function mostCommonValue(values) { + const counts = new Map(); + + for (const value of values.filter(Boolean)) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + + return [...counts.entries()] + .map(([value, count]) => ({ value, count })) + .sort((left, right) => + right.count === left.count ? left.value.localeCompare(right.value) : right.count - left.count, + )[0]; +} + +function slideDeckTemplateWarnings(selectedSlides) { + if (selectedSlides.length < 4) { + return []; + } + + const warnings = []; + const layoutUsage = mostCommonValue(selectedSlides.map((slide) => slide.layout_id)); + const familyUsage = mostCommonValue(selectedSlides.map((slide) => slide.layout_family)); + const suggested_templates = commonTemplateSuggestions(); + + if (layoutUsage && layoutUsage.count > selectedSlides.length / 2) { + warnings.push({ + code: "layout_repetition_warning", + message: `${layoutUsage.count} of ${selectedSlides.length} slides use ${layoutUsage.value}. For a case-study or portfolio deck, pass explicit template_id values or add slide purpose metadata.`, + layout_id: layoutUsage.value, + count: layoutUsage.count, + slide_count: selectedSlides.length, + suggested_templates, + }); + } + + if ( + familyUsage && + familyUsage.count > selectedSlides.length / 2 && + familyUsage.value !== layoutUsage?.value + ) { + warnings.push({ + code: "layout_family_repetition_warning", + message: `${familyUsage.count} of ${selectedSlides.length} slides use the ${familyUsage.value} family. Mix template families when a deck needs case-study or portfolio variety.`, + layout_family: familyUsage.value, + count: familyUsage.count, + slide_count: selectedSlides.length, + suggested_templates, + }); + } + + return warnings; +} + +function warningMessages(warnings) { + return warnings.map((warning) => + typeof warning === "string" ? warning : compactText(warning.message), + ).filter(Boolean); +} + +function buildSlideDeckProvenanceReceipt({ + deckId, + outputTarget, + runtime, + selectedSlides, + themeMode, + warnings, +}) { + const createdAt = new Date().toISOString(); + + return { + schema: SLIDE_DECK_RECEIPT_SCHEMA, + tool_name: "mcp__judgmentkit.create_slide_deck", + deck_creation_status: "exported", + deck_id: deckId, + output_path: outputTarget.relativePath, + absolute_resolved_path: outputTarget.absolutePath, + created_at: createdAt, + theme_mode: themeMode, + adapter_manifest_id: JUDGMENTKIT_PRESENTATION_THEME_ADAPTER_MANIFEST.id, + template_registry_version: JUDGMENTKIT_PRESENTATION_TEMPLATE_REGISTRY.registry_version, + slide_count: selectedSlides.length, + selected_templates: selectedSlides.map((slide) => ({ + slide_number: slide.slide_number, + layout_id: slide.layout_id, + selected_by: slide.selected_by, + caller_specified: slide.selected_by !== "default", + exact_template_id_specified: slide.selected_by === "template_id", + template_use: slide.template_use, + layout_family: slide.layout_family, + })), + runtime_package_path: runtime.package_path, + runtime_package_version: runtime.package_version, + workspace_root: outputTarget.workspace_root, + workspace_root_source: outputTarget.workspace_root_source, + warnings: warningMessages(warnings), + }; +} + +async function writeSlideDeckProvenanceReceipt(outputTarget, receipt) { + const absoluteReceiptPath = `${outputTarget.absolutePath}.receipt.json`; + const receiptPath = `${outputTarget.relativePath}.receipt.json`; + + await fsp.writeFile(absoluteReceiptPath, `${JSON.stringify(receipt, null, 2)}\n`, "utf8"); + + return { + ...receipt, + receipt_path: receiptPath, + absolute_receipt_path: absoluteReceiptPath, + }; +} + +async function exportSlideDeckArtifact({ + args, + deckId, + outputPath, + selectedSlides, + slideSize, + themeMode, + workspaceRoot, + warnings, +}) { + const outputTarget = await prepareSlideDeckOutputTarget( + outputPath, + args.output ?? {}, + workspaceRoot, + ); const runtime = await loadArtifactToolRuntime(args.runtime ?? {}); const { Presentation, @@ -1569,6 +1947,17 @@ async function exportSlideDeckArtifact({ args, deckId, outputPath, selectedSlide await fsp.rename(temporaryPath, outputTarget.absolutePath); await fsp.rm(temporaryInspectPath, { force: true }).catch(() => {}); + const receipt = await writeSlideDeckProvenanceReceipt( + outputTarget, + buildSlideDeckProvenanceReceipt({ + deckId, + outputTarget, + runtime, + selectedSlides, + themeMode, + warnings, + }), + ); return { artifact_ref: { @@ -1577,12 +1966,16 @@ async function exportSlideDeckArtifact({ args, deckId, outputPath, selectedSlide sha256: sha256File(outputTarget.absolutePath), bytes: structure.bytes, mime_type: PPTX_MIME_TYPE, + receipt_path: receipt.receipt_path, }, + provenance_receipt: receipt, diagnostics: { artifact_runtime: { package: "@oai/artifact-tool", version: runtime.package_version, + package_path: runtime.package_path, }, + output_target: outputTarget, pptx_structure: structure, }, }; @@ -1595,6 +1988,35 @@ async function exportSlideDeckArtifact({ args, deckId, outputPath, selectedSlide } } +function createSlideDeckExportFailureResult(baseResult, error, exportAttempt, warnings) { + const isInputError = error instanceof JudgmentKitInputError; + const message = isInputError + ? error.message + : `export_failed: create_slide_deck could not export the PPTX artifact. ${error instanceof Error ? error.message : String(error)}`; + const code = isInputError ? error.code : "export_failed"; + const details = { + ...(isRecord(error?.details) ? error.details : {}), + export_attempt: exportAttempt, + }; + + return { + ...baseResult, + deck_creation_status: "export_failed", + export_status: "failed", + export_attempt: { + ...exportAttempt, + status: "export_failed", + error_code: code, + }, + warnings, + error: { + code, + message, + details, + }, + }; +} + async function createSlideDeck(args = {}) { assertSlideDeckInput(args); @@ -1604,10 +2026,14 @@ async function createSlideDeck(args = {}) { const deckId = normalizeDeckId(deckInput.deck_id ?? deckInput.title); const slideSize = normalizeSlideSize(deckInput.slide_size ?? deckInput.slideSize); const themeMode = normalizeThemeMode(deckInput.theme_mode ?? deckInput.themeMode); - const outputPath = normalizeSlideDeckOutputPath(args.output, deckId); const shouldCreateArtifact = args.dry_run === false || (isRecord(args.output) && compactText(args.output.path)); const dryRun = args.dry_run === true || !shouldCreateArtifact; + const outputPath = normalizeSlideDeckOutputPath( + args.output, + deckId, + compactText(args.runtime?.workspace_root), + ); const selectedSlides = slides.map((slide, index) => { const selected = selectDeckTemplate(slide, index, includeDiagnostics); @@ -1617,14 +2043,19 @@ async function createSlideDeck(args = {}) { template: includeDiagnostics ? selected.template : undefined, }; }); - const warnings = dryRun - ? [ - "No PPTX artifact was written. Pass output.path or dry_run false from a local runtime to export a deck.", - ] - : []; + const templateWarnings = slideDeckTemplateWarnings(selectedSlides); + const warnings = [ + ...templateWarnings, + ...(dryRun + ? [ + "No PPTX artifact was written. Pass output.path or dry_run false from a local runtime to export a deck.", + ] + : []), + ]; const result = { schema: SLIDE_DECK_SCHEMA, - deck_creation_status: dryRun ? "planned" : "created", + deck_creation_status: dryRun ? "planned" : "export_attempted", + export_status: dryRun ? "not_requested" : "attempted", deck: { deck_id: deckId, title: compactText(deckInput.title) || undefined, @@ -1655,6 +2086,27 @@ async function createSlideDeck(args = {}) { return result; } + let workspaceRoot; + try { + workspaceRoot = resolveSlideDeckWorkspaceRoot(args.runtime ?? {}); + } catch (error) { + return createSlideDeckExportFailureResult( + result, + error, + { + output_path: outputPath, + workspace_root_source: "unresolved", + }, + warnings, + ); + } + + const exportAttempt = { + output_path: outputPath, + absolute_resolved_path: path.resolve(workspaceRoot.root, outputPath), + workspace_root: workspaceRoot.root, + workspace_root_source: workspaceRoot.source, + }; const artifact = await exportSlideDeckArtifact({ args, deckId, @@ -1662,19 +2114,38 @@ async function createSlideDeck(args = {}) { selectedSlides, slideSize, themeMode, - }); + workspaceRoot, + warnings, + }).catch((error) => + createSlideDeckExportFailureResult(result, error, exportAttempt, warnings), + ); + + if ("error" in artifact) { + return artifact; + } return { ...result, + deck_creation_status: "exported", + export_status: "exported", artifact_ref: artifact.artifact_ref, + provenance_receipt: artifact.provenance_receipt, diagnostics: includeDiagnostics ? artifact.diagnostics : undefined, - warnings: [], + warnings, }; } function planningStatus(result) { - if (result.deck_creation_status === "created") { - return "Deck created"; + if (result.deck_creation_status === "exported") { + return "Deck exported"; + } + + if (result.deck_creation_status === "export_attempted") { + return "Deck export attempted"; + } + + if (result.deck_creation_status === "export_failed") { + return "Deck export failed"; } if (result.deck_creation_status === "planned") { @@ -2370,11 +2841,11 @@ function formatIconCatalogCard(result) { } function formatSlideDeckCard(result) { - const created = result.deck_creation_status === "created"; + const exported = result.deck_creation_status === "exported"; const lines = [ "## JudgmentKit Slide Deck", `**Status:** ${planningStatus(result)}`, - created + exported ? "**Next step:** Review the exported PPTX artifact and run presentation evidence checks before treating it as release proof." : "**Next step:** Review selected templates, then call create_slide_deck with output.path from a local artifact-tool runtime to export PPTX.", ]; @@ -2394,6 +2865,8 @@ function formatSlideDeckCard(result) { ]); addSection(lines, "Artifact", [ firstLine("Path", result.artifact_ref?.path ?? result.planned_output?.path), + firstLine("Resolved path", result.provenance_receipt?.absolute_resolved_path), + firstLine("Receipt", result.provenance_receipt?.receipt_path ?? result.artifact_ref?.receipt_path), firstLine("Kind", result.artifact_ref?.kind ?? result.planned_output?.kind), firstLine("Bytes", result.artifact_ref?.bytes ? String(result.artifact_ref.bytes) : ""), firstLine("SHA-256", result.artifact_ref?.sha256), @@ -2428,6 +2901,10 @@ function formatErrorCard(result) { addSection(lines, "Targeted questions", bulletList(details.targeted_questions)); addSection(lines, "Diagnostics", [ firstLine("Code", result.error?.code), + firstLine("Deck status", result.deck_creation_status), + firstLine("Output path", details.export_attempt?.output_path), + firstLine("Workspace root", details.export_attempt?.workspace_root), + firstLine("Workspace root source", details.export_attempt?.workspace_root_source), firstLine("Review status", details.review_status), firstLine("Confidence", details.confidence), listLine("Implementation leakage", termList(details.implementation_leakage_terms)), @@ -2941,6 +3418,7 @@ export function createJudgmentKitMcpServer() { runtime: z .object({ artifact_tool_package: z.string().optional(), + workspace_root: z.string().optional(), }) .optional(), dry_run: z.boolean().optional(), diff --git a/tests/mcp.test.mjs b/tests/mcp.test.mjs index db1349f..e039ba4 100644 --- a/tests/mcp.test.mjs +++ b/tests/mcp.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { @@ -20,6 +21,27 @@ const OLD_TOOL_NAMES = [ "resolve_related", ]; +function localArtifactToolPackage() { + const candidates = [ + process.env.JUDGMENTKIT_ARTIFACT_TOOL_PACKAGE, + process.env.CODEX_RUNTIME_DEPENDENCIES + ? path.join( + process.env.CODEX_RUNTIME_DEPENDENCIES, + "node", + "node_modules", + "@oai", + "artifact-tool", + ) + : undefined, + path.join( + process.env.HOME ?? "", + ".cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/@oai/artifact-tool", + ), + ].filter(Boolean); + + return candidates.find((candidate) => fs.existsSync(path.join(candidate, "package.json"))); +} + assert.deepEqual( tools.map((tool) => tool.name), [ @@ -170,6 +192,7 @@ assert.equal(toolByName.create_slide_deck.inputSchema.required.includes("slides" assert.equal(toolByName.create_slide_deck.inputSchema.properties.slides.maxItems, 24); assert.equal(toolByName.create_slide_deck.inputSchema.properties.output.properties.path.type, "string"); assert.equal(toolByName.create_slide_deck.inputSchema.properties.runtime.properties.artifact_tool_package.type, "string"); +assert.equal(toolByName.create_slide_deck.inputSchema.properties.runtime.properties.workspace_root.type, "string"); assert.equal(toolByName.list_icon_catalog.inputSchema.properties.include_svg.type, "boolean"); assert.equal(toolByName.search_icon_catalog.inputSchema.required.includes("query"), true); assert.equal(toolByName.get_icon_svg.inputSchema.required.includes("id"), true); @@ -222,6 +245,95 @@ assert.ok(deckPlanText.includes("**Status:** Deck plan ready")); assert.ok(deckPlanText.includes("slide-21")); assert.equal(deckPlanText.includes("Evidence and decisions for the product team."), false); +const inferredTemplateDeck = await handleToolCall("create_slide_deck", { + deck: { deck_id: "inferred template deck" }, + slides: [ + { content: { title: "Case study", subtitle: "Decision context." } }, + { + content: { + title: "Evidence table", + rows: [ + { signal: "Queue pressure", value: "High" }, + { signal: "Review debt", value: "Rising" }, + ], + }, + }, + { + content: { + title: "Trend evidence", + chart: { type: "line" }, + series: [{ label: "Cycle time", values: [4, 5, 7] }], + }, + }, + { + content: { + title: "Outcome metrics", + metrics: [{ label: "Saved time", value: "32%" }], + }, + }, + { + content: { + title: "Before and after", + comparison: { + before: "Manual routing", + after: "Evidence-led review", + }, + }, + }, + ], + dry_run: true, +}); +assert.equal("error" in inferredTemplateDeck, false); +assert.deepEqual( + inferredTemplateDeck.slides.map((slide) => slide.layout_id), + ["slide-21", "slide-08", "slide-64", "slide-62", "slide-06"], +); +assert.equal(inferredTemplateDeck.slides.every((slide) => slide.selected_by === "default"), true); + +const repeatedLayoutDeck = await handleToolCall("create_slide_deck", { + deck: { deck_id: "repeated layout deck" }, + slides: Array.from({ length: 12 }, (_, index) => ({ + selection: { + template_use: index === 0 ? "cover" : "process", + layout_family: index === 0 ? "cover-image-field" : "two-column-content", + }, + content: { title: `Repeated layout ${index + 1}` }, + })), + dry_run: true, +}); +assert.equal("error" in repeatedLayoutDeck, false); +assert.equal( + repeatedLayoutDeck.warnings.some( + (warning) => warning.code === "layout_repetition_warning" && warning.layout_id === "slide-01", + ), + true, +); +assert.ok(formatPlanningCard(repeatedLayoutDeck).includes("layout")); + +const explicitTemplateDeck = await handleToolCall("create_slide_deck", { + deck: { deck_id: "explicit template variety" }, + slides: [ + { template_id: "slide-21", content: { title: "Case study" } }, + { template_id: "slide-06", content: { title: "Problem and solution" } }, + { template_id: "slide-64", content: { title: "Trend", chart: { type: "line" } } }, + { template_id: "slide-08", content: { title: "Evidence", rows: [{ label: "A" }] } }, + { template_id: "slide-62", content: { title: "Metrics", metrics: [{ label: "A" }] } }, + { template_id: "slide-80", content: { title: "Close", body: "Next steps" } }, + ], + dry_run: true, +}); +assert.equal("error" in explicitTemplateDeck, false); +assert.deepEqual( + explicitTemplateDeck.slides.map((slide) => slide.layout_id), + ["slide-21", "slide-06", "slide-64", "slide-08", "slide-62", "slide-80"], +); +assert.equal(new Set(explicitTemplateDeck.slides.map((slide) => slide.layout_id)).size >= 5, true); +assert.equal(explicitTemplateDeck.slides.every((slide) => slide.selected_by === "template_id"), true); +assert.equal( + explicitTemplateDeck.warnings.some((warning) => warning.code === "layout_repetition_warning"), + false, +); + const missingSlidesDeck = await handleToolCall("create_slide_deck", { slides: [], dry_run: true, @@ -252,19 +364,103 @@ const unsafeOutputDeck = await handleToolCall("create_slide_deck", { assert.equal("error" in unsafeOutputDeck, true); assert.equal(unsafeOutputDeck.error.code, "unsafe_output_path"); -const missingRuntimeDeck = await handleToolCall("create_slide_deck", { - dry_run: false, - runtime: { - artifact_tool_package: "/no/such/artifact-tool", - }, +const absoluteOutputDeck = await handleToolCall("create_slide_deck", { + output: { path: path.join(process.cwd(), "outputs/judgmentkit-slide-decks/absolute.pptx") }, + runtime: { workspace_root: process.cwd() }, slides: [ { - content: { title: "Needs runtime" }, + content: { title: "Absolute path" }, }, ], }); -assert.equal("error" in missingRuntimeDeck, true); -assert.equal(missingRuntimeDeck.error.code, "artifact_runtime_unavailable"); +assert.equal("error" in absoluteOutputDeck, true); +assert.equal(absoluteOutputDeck.error.code, "unsafe_output_path"); +assert.match(absoluteOutputDeck.error.message, /repo-relative/i); +assert.equal(absoluteOutputDeck.error.details.workspace_root, process.cwd()); + +const missingRuntimeWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-missing-runtime-")); +try { + const missingRuntimeDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + output: { path: "outputs/judgmentkit-slide-decks/missing-runtime-test.pptx" }, + runtime: { + artifact_tool_package: "/no/such/artifact-tool", + workspace_root: missingRuntimeWorkspace, + }, + slides: [ + { + content: { title: "Needs runtime" }, + }, + ], + }); + assert.equal("error" in missingRuntimeDeck, true); + assert.equal(missingRuntimeDeck.error.code, "artifact_runtime_unavailable"); + assert.equal(missingRuntimeDeck.deck_creation_status, "export_failed"); + assert.equal(missingRuntimeDeck.export_status, "failed"); + assert.equal(missingRuntimeDeck.export_attempt.workspace_root, missingRuntimeWorkspace); +} finally { + fs.rmSync(missingRuntimeWorkspace, { recursive: true, force: true }); +} + +const originalCwd = process.cwd(); +const originalWorkspaceEnv = Object.fromEntries( + [ + "JUDGMENTKIT_WORKSPACE_ROOT", + "CODEX_WORKSPACE_ROOT", + "CODEX_WORKSPACE_DIR", + "CODEX_WORKSPACE", + "INIT_CWD", + ].map((key) => [key, process.env[key]]), +); +const unsafeImplicitRoot = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-unsafe-cwd-")); +const unsafeNestedCwd = path.join(unsafeImplicitRoot, ".codex", "judgmentkit-mcp"); +fs.mkdirSync(unsafeNestedCwd, { recursive: true }); +try { + for (const key of Object.keys(originalWorkspaceEnv)) { + delete process.env[key]; + } + process.chdir(unsafeNestedCwd); + const missingWorkspaceDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + slides: [ + { + content: { title: "Missing workspace" }, + }, + ], + }); + assert.equal("error" in missingWorkspaceDeck, true); + assert.equal(missingWorkspaceDeck.error.code, "workspace_root_missing"); + assert.equal(missingWorkspaceDeck.deck_creation_status, "export_failed"); + + const explicitWorkspaceDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + output: { + path: `outputs/judgmentkit-slide-decks/explicit-workspace-${process.pid}.pptx`, + }, + runtime: { + artifact_tool_package: "/no/such/artifact-tool", + workspace_root: originalCwd, + }, + slides: [ + { + content: { title: "Explicit workspace" }, + }, + ], + }); + assert.equal("error" in explicitWorkspaceDeck, true); + assert.equal(explicitWorkspaceDeck.error.code, "artifact_runtime_unavailable"); + assert.equal(explicitWorkspaceDeck.export_attempt.workspace_root, originalCwd); +} finally { + process.chdir(originalCwd); + for (const [key, value] of Object.entries(originalWorkspaceEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + fs.rmSync(unsafeImplicitRoot, { recursive: true, force: true }); +} const existingOutputPath = path.join( process.cwd(), @@ -291,10 +487,59 @@ try { }); assert.equal("error" in existingOutputDeck, true); assert.equal(existingOutputDeck.error.code, "output_exists"); + assert.equal(existingOutputDeck.deck_creation_status, "export_failed"); } finally { fs.rmSync(existingOutputPath, { force: true }); } +const artifactToolPackage = localArtifactToolPackage(); +if (artifactToolPackage) { + const exportWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-mcp-export-")); + const exportPath = "outputs/judgmentkit-slide-decks/repro-minimal-test.pptx"; + try { + const exportedDeck = await handleToolCall("create_slide_deck", { + deck: { deck_id: "repro minimal test" }, + dry_run: false, + include_diagnostics: true, + output: { + path: exportPath, + }, + runtime: { + artifact_tool_package: artifactToolPackage, + workspace_root: exportWorkspace, + }, + slides: [ + { + template_id: "slide-21", + content: { + title: "Minimal export", + subtitle: "Workspace-root export proof.", + }, + }, + ], + }); + + assert.equal("error" in exportedDeck, false); + assert.equal(exportedDeck.deck_creation_status, "exported"); + assert.equal(exportedDeck.export_status, "exported"); + assert.equal(exportedDeck.artifact_ref.path, exportPath); + assert.equal(exportedDeck.provenance_receipt.tool_name, "mcp__judgmentkit.create_slide_deck"); + assert.equal(exportedDeck.provenance_receipt.output_path, exportPath); + assert.equal(exportedDeck.provenance_receipt.workspace_root, exportWorkspace); + assert.equal(exportedDeck.provenance_receipt.selected_templates[0].layout_id, "slide-21"); + assert.equal(exportedDeck.provenance_receipt.selected_templates[0].caller_specified, true); + assert.equal(fs.existsSync(path.join(exportWorkspace, exportPath)), true); + assert.equal(fs.existsSync(path.join(exportWorkspace, `${exportPath}.receipt.json`)), true); + assert.equal( + exportedDeck.provenance_receipt.absolute_resolved_path.startsWith(exportWorkspace), + true, + ); + assert.equal(formatPlanningCard(exportedDeck).includes("Receipt"), true); + } finally { + fs.rmSync(exportWorkspace, { recursive: true, force: true }); + } +} + const iconList = await handleToolCall("list_icon_catalog", { limit: 2 }); assert.equal("error" in iconList, false); assert.equal(iconList.icons.length, 2); From ef82af5904880a3f5479dcf7a00a121e613dfe13 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sun, 5 Jul 2026 23:41:04 -0700 Subject: [PATCH 2/2] Harden slide deck MCP export safety --- docs/slide-deck-mcp.md | 8 +- .../skills/judgmentkit-hosted-mcp/SKILL.md | 4 +- src/mcp.mjs | 487 +++++++++++++++--- tests/mcp.test.mjs | 472 +++++++++++++++-- 4 files changed, 865 insertions(+), 106 deletions(-) diff --git a/docs/slide-deck-mcp.md b/docs/slide-deck-mcp.md index 7315ae5..30d2de0 100644 --- a/docs/slide-deck-mcp.md +++ b/docs/slide-deck-mcp.md @@ -65,7 +65,6 @@ Then export from a local runtime: "artifact_tool_package": "/Users/mike/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/@oai/artifact-tool" }, "dry_run": false, - "include_diagnostics": true, "slides": [ { "template_id": "slide-21", @@ -84,12 +83,17 @@ A successful export returns `deck_creation_status: "exported"`, `artifact_ref`, outputs/judgmentkit-slide-decks/quarterly-review.pptx.receipt.json ``` +Treat dry-run planning as JudgmentKit guidance when the MCP response uses `schema: "judgmentkit.mcp.slide-deck/v1"` and `deck_creation_status: "planned"`. + Treat a deck as JudgmentKit-generated only when the MCP response or sidecar receipt confirms: ```json { "tool_name": "mcp__judgmentkit.create_slide_deck", "deck_creation_status": "exported", + "sha256": "", + "bytes": 12345, + "mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation", "adapter_manifest_id": "judgmentkit.presentation-theme.adapter-v1", "template_registry_version": "0.1.0" } @@ -142,4 +146,4 @@ For portfolio, pitch, and case-study decks, pass explicit `template_id` values o } ``` -If more than half the deck uses the same layout or layout family, the response includes a repetition warning with suggested template families. +For decks with at least four slides, if more than half the deck uses the same layout or layout family, the response includes a repetition warning with suggested template families. diff --git a/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md b/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md index 8414c69..b5469ff 100644 --- a/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md +++ b/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md @@ -27,7 +27,7 @@ Before calling a deck creation MCP tool, establish: When the active JudgmentKit MCP server exposes a deck creation tool, call it with the reviewed activity context and allowed source material. Keep primary slide copy in domain language and do not expose prompts, schemas, resource ids, MCP server names, tool names, traces, or model configuration in slide content. For local PPTX export, use a repo-relative `output.path` and pass the active workspace root separately through the tool runtime when required. -Treat an exported PPTX as JudgmentKit output only when the MCP response or sidecar receipt confirms `tool_name: "mcp__judgmentkit.create_slide_deck"` and `deck_creation_status: "exported"`. A folder named `outputs/judgmentkit-slide-decks` is not provenance by itself. +Treat dry-run deck planning as JudgmentKit guidance when the MCP response uses `schema: "judgmentkit.mcp.slide-deck/v1"` and `deck_creation_status: "planned"`. Treat an exported PPTX as JudgmentKit output only when the MCP response or sidecar receipt confirms `tool_name: "mcp__judgmentkit.create_slide_deck"`, `deck_creation_status: "exported"`, and matching `sha256`, `bytes`, and `mime_type` artifact fields. A folder named `outputs/judgmentkit-slide-decks` is not provenance by itself. If no deck creation tool is listed by the active endpoint, state that the current JudgmentKit endpoint cannot create the deck yet. Do not fabricate a JudgmentKit packet, deck, or MCP result. Continue only with a deterministic outline or requirements summary if the user wants that fallback. @@ -87,7 +87,7 @@ For slide deck requests: 1. Confirm the deck audience, purpose, source material, confidentiality boundary, and target format from the brief or local context. 2. Use the activity contract to keep the deck focused on the participant decision and outcome, not implementation machinery. 3. Call the deck creation MCP tool when it is available from the active endpoint. -4. Treat returned deck guidance or artifacts as JudgmentKit output only when the MCP response or sidecar receipt confirms `tool_name: "mcp__judgmentkit.create_slide_deck"` and `deck_creation_status: "exported"`. +4. Treat dry-run deck planning as JudgmentKit guidance when the MCP response uses `schema: "judgmentkit.mcp.slide-deck/v1"` and `deck_creation_status: "planned"`; treat exported PPTX artifacts as JudgmentKit output only when the MCP response or sidecar receipt confirms `tool_name: "mcp__judgmentkit.create_slide_deck"`, `deck_creation_status: "exported"`, and matching `sha256`, `bytes`, and `mime_type` artifact fields. 5. For portfolio or case-study decks, pass explicit `template_id` values or strong selection metadata when layout variety matters; heed layout repetition warnings. 6. Review slide copy for disclosure discipline before handoff: primary slides should use domain language, while prompts, schemas, resource ids, MCP details, traces, and model configuration stay out of the deck unless the deck is explicitly for setup, debugging, auditing, or integration. diff --git a/src/mcp.mjs b/src/mcp.mjs index e01ae05..026da2d 100644 --- a/src/mcp.mjs +++ b/src/mcp.mjs @@ -1441,6 +1441,36 @@ function normalizeConfiguredWorkspaceRoot(candidate) { ); } + const realRoot = fs.realpathSync(root); + + if (workspaceRootIsUnsafe(root)) { + throw new JudgmentKitInputError( + "workspace_root_invalid: create_slide_deck workspace root points to an unsafe Codex runtime/cache directory.", + { + code: "workspace_root_invalid", + details: { + workspace_root: root, + real_workspace_root: realRoot, + workspace_root_source: candidate.source, + }, + }, + ); + } + + if (workspaceRootIsUnsafe(realRoot)) { + throw new JudgmentKitInputError( + "workspace_root_invalid: create_slide_deck workspace root resolves to an unsafe Codex runtime/cache directory.", + { + code: "workspace_root_invalid", + details: { + workspace_root: root, + real_workspace_root: realRoot, + workspace_root_source: candidate.source, + }, + }, + ); + } + return { root, source: candidate.source, @@ -1448,12 +1478,31 @@ function normalizeConfiguredWorkspaceRoot(candidate) { }; } -function implicitWorkspaceRootIsUnsafe(root) { +function pathSegments(value) { + return path.resolve(value).split(path.sep).filter(Boolean); +} + +function pathHasSegmentSequence(segments, sequence) { + return segments.some((_, index) => + sequence.every((segment, offset) => segments[index + offset] === segment), + ); +} + +function pathIsInside(parent, child) { + const relative = path.relative(parent, child); + + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function workspaceRootIsUnsafe(root) { + const resolved = path.resolve(root); + const segments = pathSegments(resolved); + return ( - root === "/var/task" || - root.startsWith("/var/task/") || - root.includes(`${path.sep}.codex${path.sep}`) || - root.includes(`${path.sep}.cache${path.sep}codex-runtimes${path.sep}`) + resolved === "/var/task" || + resolved.startsWith("/var/task/") || + segments.includes(".codex") || + pathHasSegmentSequence(segments, [".cache", "codex-runtimes"]) ); } @@ -1467,14 +1516,16 @@ function resolveSlideDeckWorkspaceRoot(runtime = {}) { } const cwd = path.resolve(process.cwd()); + const realCwd = fs.realpathSync(cwd); - if (implicitWorkspaceRootIsUnsafe(cwd)) { + if (workspaceRootIsUnsafe(cwd) || workspaceRootIsUnsafe(realCwd)) { throw new JudgmentKitInputError( `workspace_root_missing: create_slide_deck could not determine the Codex workspace root. It would resolve ${DEFAULT_SLIDE_DECK_OUTPUT_DIR} under ${cwd}; provide runtime.workspace_root or configure the MCP runtime.`, { code: "workspace_root_missing", details: { attempted_workspace_root: cwd, + real_workspace_root: realCwd, workspace_root_source: "process.cwd()", checked_env: WORKSPACE_ROOT_ENV_KEYS, }, @@ -1489,65 +1540,165 @@ function resolveSlideDeckWorkspaceRoot(runtime = {}) { }; } -async function prepareSlideDeckOutputTarget(relativePath, output = {}, workspaceRoot) { - const workspace = workspaceRoot ?? resolveSlideDeckWorkspaceRoot(); - const root = path.resolve(workspace.root, DEFAULT_SLIDE_DECK_OUTPUT_DIR); - const absolutePath = path.resolve(workspace.root, relativePath); - const parent = path.dirname(absolutePath); +function slideDeckUnsafeOutputPathError(message, relativePath, workspaceRoot, details = {}) { + return new JudgmentKitInputError( + message, + { + code: "unsafe_output_path", + details: { + ...slideDeckOutputPathDetails(relativePath, workspaceRoot), + ...details, + }, + }, + ); +} - await fsp.mkdir(parent, { recursive: true }); +async function lstatIfExists(filePath) { + try { + return await fsp.lstat(filePath); + } catch (error) { + if (error?.code === "ENOENT") { + return null; + } - const [realRoot, realParent] = await Promise.all([ - fsp.realpath(root), - fsp.realpath(parent), - ]); - const relativeParent = path.relative(realRoot, realParent); + throw error; + } +} + +async function ensureSafeOutputDirectory(directoryPath, workspaceRoot, relativePath) { + const root = path.resolve(workspaceRoot); + const target = path.resolve(directoryPath); + const workspaceRealPath = await fsp.realpath(root); + const relativeDirectory = path.relative(root, target); if ( - relativeParent.startsWith("..") || - path.isAbsolute(relativeParent) + relativeDirectory.startsWith("..") || + path.isAbsolute(relativeDirectory) ) { - throw new JudgmentKitInputError( - "create_slide_deck output.path resolves outside the allowed output directory.", - { - code: "unsafe_output_path", - details: slideDeckOutputPathDetails(relativePath, workspace.root), - }, + throw slideDeckUnsafeOutputPathError( + "create_slide_deck output.path resolves outside the workspace root.", + relativePath, + workspaceRoot, + { directory_path: directoryPath }, ); } - let existing; - try { - existing = await fsp.lstat(absolutePath); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; + let current = root; + const segments = relativeDirectory.split(path.sep).filter(Boolean); + + for (const segment of segments) { + current = path.join(current, segment); + + let stats = await lstatIfExists(current); + + if (!stats) { + await fsp.mkdir(current); + stats = await fsp.lstat(current); + } + + if (stats.isSymbolicLink()) { + throw slideDeckUnsafeOutputPathError( + "create_slide_deck output.path cannot use symlinked output directories.", + relativePath, + workspaceRoot, + { directory_path: current }, + ); + } + + if (!stats.isDirectory()) { + throw slideDeckUnsafeOutputPathError( + "create_slide_deck output.path parent must be a directory.", + relativePath, + workspaceRoot, + { directory_path: current }, + ); + } + + const realCurrent = await fsp.realpath(current); + + if (!pathIsInside(workspaceRealPath, realCurrent)) { + throw slideDeckUnsafeOutputPathError( + "create_slide_deck output.path resolves outside the real workspace root.", + relativePath, + workspaceRoot, + { + directory_path: current, + real_directory_path: realCurrent, + real_workspace_root: workspaceRealPath, + }, + ); } } +} + +async function assertSafeOutputFileTarget(absolutePath, relativePath, output, workspaceRoot, label) { + const existing = await lstatIfExists(absolutePath); if (existing?.isSymbolicLink()) { - throw new JudgmentKitInputError( - "create_slide_deck refuses to overwrite symlink output paths.", - { - code: "unsafe_output_path", - details: slideDeckOutputPathDetails(relativePath, workspace.root), - }, + throw slideDeckUnsafeOutputPathError( + `create_slide_deck refuses to overwrite symlink ${label} paths.`, + relativePath, + workspaceRoot, + { target: label }, ); } - if (existing && output.overwrite !== true) { + if (existing && (output.overwrite !== true || existing.isDirectory())) { throw new JudgmentKitInputError( - "create_slide_deck output.path already exists. Set output.overwrite true to replace it.", + `create_slide_deck ${label} already exists. Set output.overwrite true to replace it.`, { code: "output_exists", - details: slideDeckOutputPathDetails(relativePath, workspace.root), + details: { + ...slideDeckOutputPathDetails(relativePath, workspaceRoot), + target: label, + }, }, ); } +} + +async function prepareSlideDeckOutputTarget(relativePath, output = {}, workspaceRoot) { + const workspace = workspaceRoot ?? resolveSlideDeckWorkspaceRoot(); + const root = path.resolve(workspace.root, DEFAULT_SLIDE_DECK_OUTPUT_DIR); + const absolutePath = path.resolve(workspace.root, relativePath); + const parent = path.dirname(absolutePath); + const absoluteReceiptPath = `${absolutePath}.receipt.json`; + const receiptPath = `${relativePath}.receipt.json`; + + await ensureSafeOutputDirectory(parent, workspace.root, relativePath); + + const realRoot = await fsp.realpath(root); + const realParent = await fsp.realpath(parent); + + if (!pathIsInside(realRoot, realParent)) { + throw slideDeckUnsafeOutputPathError( + "create_slide_deck output.path resolves outside the allowed output directory.", + relativePath, + workspace.root, + { real_output_root: realRoot, real_parent: realParent }, + ); + } + + await assertSafeOutputFileTarget( + absolutePath, + relativePath, + output, + workspace.root, + "output.path", + ); + await assertSafeOutputFileTarget( + absoluteReceiptPath, + receiptPath, + output, + workspace.root, + "provenance receipt", + ); return { absolutePath, relativePath, + absoluteReceiptPath, + receiptPath, workspace_root: workspace.root, workspace_root_source: workspace.source, }; @@ -1833,6 +1984,7 @@ function warningMessages(warnings) { function buildSlideDeckProvenanceReceipt({ deckId, outputTarget, + artifact, runtime, selectedSlides, themeMode, @@ -1847,6 +1999,9 @@ function buildSlideDeckProvenanceReceipt({ deck_id: deckId, output_path: outputTarget.relativePath, absolute_resolved_path: outputTarget.absolutePath, + sha256: artifact.sha256, + bytes: artifact.bytes, + mime_type: artifact.mime_type, created_at: createdAt, theme_mode: themeMode, adapter_manifest_id: JUDGMENTKIT_PRESENTATION_THEME_ADAPTER_MANIFEST.id, @@ -1869,16 +2024,153 @@ function buildSlideDeckProvenanceReceipt({ }; } -async function writeSlideDeckProvenanceReceipt(outputTarget, receipt) { - const absoluteReceiptPath = `${outputTarget.absolutePath}.receipt.json`; - const receiptPath = `${outputTarget.relativePath}.receipt.json`; +async function writeFileNoFollow(filePath, content, { overwrite = false } = {}) { + const flags = + fs.constants.O_CREAT | + fs.constants.O_WRONLY | + (overwrite ? fs.constants.O_TRUNC : fs.constants.O_EXCL) | + (fs.constants.O_NOFOLLOW ?? 0); + const handle = await fsp.open(filePath, flags, 0o600); - await fsp.writeFile(absoluteReceiptPath, `${JSON.stringify(receipt, null, 2)}\n`, "utf8"); + try { + await handle.writeFile(content, "utf8"); + } finally { + await handle.close(); + } +} + +function outputExistsError(relativePath, workspaceRoot, label) { + return new JudgmentKitInputError( + `create_slide_deck ${label} already exists. Set output.overwrite true to replace it.`, + { + code: "output_exists", + details: { + ...slideDeckOutputPathDetails(relativePath, workspaceRoot), + target: label, + }, + }, + ); +} + +async function publishFileNoFollow(sourcePath, destinationPath, { + overwrite = false, + relativePath, + workspaceRoot, + label, +} = {}) { + await assertSafeOutputFileTarget( + destinationPath, + relativePath, + { overwrite }, + workspaceRoot, + label, + ); + + try { + if (overwrite) { + await fsp.rename(sourcePath, destinationPath); + return; + } + + await fsp.link(sourcePath, destinationPath); + await fsp.rm(sourcePath, { force: true }).catch(() => {}); + } catch (error) { + if (error?.code === "EEXIST" || error?.code === "EISDIR") { + throw outputExistsError(relativePath, workspaceRoot, label); + } + + throw error; + } +} + +async function backupExistingOutputFile(filePath, relativePath, workspaceRoot, label) { + const existing = await lstatIfExists(filePath); + + if (!existing) { + return null; + } + + if (existing.isSymbolicLink()) { + throw slideDeckUnsafeOutputPathError( + `create_slide_deck refuses to overwrite symlink ${label} paths.`, + relativePath, + workspaceRoot, + { target: label }, + ); + } + + if (existing.isDirectory()) { + throw outputExistsError(relativePath, workspaceRoot, label); + } + + const safeLabel = label.replace(/[^a-z0-9]+/gi, "-").toLowerCase(); + const backupPath = `${filePath}.${process.pid}.${Date.now()}.${safeLabel}.bak`; + await fsp.rename(filePath, backupPath); + + return { + filePath, + backupPath, + }; +} + +async function restoreOutputBackups(backups) { + for (const backup of backups.slice().reverse()) { + await fsp.rm(backup.filePath, { force: true }).catch(() => {}); + await fsp.rename(backup.backupPath, backup.filePath); + } +} + +async function removeOutputBackups(backups) { + await Promise.all(backups.map((backup) => fsp.rm(backup.backupPath, { force: true }))); +} + +async function writeSlideDeckProvenanceReceipt(outputTarget, receipt, output = {}) { + const temporaryReceiptPath = `${outputTarget.absoluteReceiptPath}.${process.pid}.${Date.now()}.tmp`; + + try { + await writeFileNoFollow( + temporaryReceiptPath, + `${JSON.stringify(receipt, null, 2)}\n`, + { overwrite: false }, + ); + await publishFileNoFollow( + temporaryReceiptPath, + outputTarget.absoluteReceiptPath, + { + overwrite: output.overwrite === true, + relativePath: outputTarget.receiptPath, + workspaceRoot: outputTarget.workspace_root, + label: "provenance receipt", + }, + ); + } catch (error) { + if (error?.code === "EEXIST" || error?.code === "EISDIR") { + await fsp.rm(temporaryReceiptPath, { force: true }).catch(() => {}); + throw outputExistsError( + outputTarget.receiptPath, + outputTarget.workspace_root, + "provenance receipt", + ); + } + + if (error?.code === "ELOOP") { + await fsp.rm(temporaryReceiptPath, { force: true }).catch(() => {}); + throw slideDeckUnsafeOutputPathError( + "create_slide_deck refuses to write symlink provenance receipt paths.", + outputTarget.receiptPath, + outputTarget.workspace_root, + { target: "provenance receipt" }, + ); + } + + await fsp.rm(temporaryReceiptPath, { force: true }).catch(() => {}); + throw error; + } return { ...receipt, - receipt_path: receiptPath, - absolute_receipt_path: absoluteReceiptPath, + receipt_path: outputTarget.receiptPath, + absolute_receipt_path: outputTarget.absoluteReceiptPath, }; } @@ -1925,6 +2217,9 @@ async function exportSlideDeckArtifact({ const temporaryPath = `${outputTarget.absolutePath}.${process.pid}.${Date.now()}.tmp`; const temporaryInspectPath = `${temporaryPath}.inspect.ndjson`; + let committedOutput = false; + const overwrite = args.output?.overwrite === true; + const outputBackups = []; try { await withoutArtifactExportConsoleNoise(async () => { @@ -1945,27 +2240,66 @@ async function exportSlideDeckArtifact({ ); } - await fsp.rename(temporaryPath, outputTarget.absolutePath); + const artifactDetails = { + sha256: sha256File(temporaryPath), + bytes: structure.bytes, + mime_type: PPTX_MIME_TYPE, + }; + + if (overwrite) { + const outputBackup = await backupExistingOutputFile( + outputTarget.absolutePath, + outputTarget.relativePath, + outputTarget.workspace_root, + "output.path", + ); + if (outputBackup) { + outputBackups.push(outputBackup); + } + + const receiptBackup = await backupExistingOutputFile( + outputTarget.absoluteReceiptPath, + outputTarget.receiptPath, + outputTarget.workspace_root, + "provenance receipt", + ); + if (receiptBackup) { + outputBackups.push(receiptBackup); + } + } + + await publishFileNoFollow( + temporaryPath, + outputTarget.absolutePath, + { + overwrite, + relativePath: outputTarget.relativePath, + workspaceRoot: outputTarget.workspace_root, + label: "output.path", + }, + ); + committedOutput = true; await fsp.rm(temporaryInspectPath, { force: true }).catch(() => {}); const receipt = await writeSlideDeckProvenanceReceipt( outputTarget, buildSlideDeckProvenanceReceipt({ deckId, outputTarget, + artifact: artifactDetails, runtime, selectedSlides, themeMode, warnings, }), + args.output ?? {}, ); + await removeOutputBackups(outputBackups); return { artifact_ref: { kind: "pptx", path: outputTarget.relativePath, - sha256: sha256File(outputTarget.absolutePath), - bytes: structure.bytes, - mime_type: PPTX_MIME_TYPE, + ...artifactDetails, receipt_path: receipt.receipt_path, }, provenance_receipt: receipt, @@ -1983,7 +2317,10 @@ async function exportSlideDeckArtifact({ await Promise.all([ fsp.rm(temporaryPath, { force: true }).catch(() => {}), fsp.rm(temporaryInspectPath, { force: true }).catch(() => {}), + committedOutput ? fsp.rm(outputTarget.absolutePath, { force: true }).catch(() => {}) : undefined, + overwrite ? fsp.rm(outputTarget.absoluteReceiptPath, { force: true }).catch(() => {}) : undefined, ]); + await restoreOutputBackups(outputBackups); throw error; } } @@ -2029,11 +2366,6 @@ async function createSlideDeck(args = {}) { const shouldCreateArtifact = args.dry_run === false || (isRecord(args.output) && compactText(args.output.path)); const dryRun = args.dry_run === true || !shouldCreateArtifact; - const outputPath = normalizeSlideDeckOutputPath( - args.output, - deckId, - compactText(args.runtime?.workspace_root), - ); const selectedSlides = slides.map((slide, index) => { const selected = selectDeckTemplate(slide, index, includeDiagnostics); @@ -2052,7 +2384,7 @@ async function createSlideDeck(args = {}) { ] : []), ]; - const result = { + const baseResult = { schema: SLIDE_DECK_SCHEMA, deck_creation_status: dryRun ? "planned" : "export_attempted", export_status: dryRun ? "not_requested" : "attempted", @@ -2064,14 +2396,7 @@ async function createSlideDeck(args = {}) { theme_mode: themeMode, template_registry_version: JUDGMENTKIT_PRESENTATION_TEMPLATE_REGISTRY.registry_version, adapter_manifest_id: JUDGMENTKIT_PRESENTATION_THEME_ADAPTER_MANIFEST.id, - }, - planned_output: dryRun - ? { - kind: "pptx", - path: outputPath, - mime_type: PPTX_MIME_TYPE, - } - : undefined, + }, slides: selectedSlides.map(({ content, template, ...slide }) => { if (includeDiagnostics) { return { ...slide, template }; @@ -2081,6 +2406,40 @@ async function createSlideDeck(args = {}) { }), warnings, }; + let outputPath; + try { + outputPath = normalizeSlideDeckOutputPath( + args.output, + deckId, + compactText(args.runtime?.workspace_root), + ); + } catch (error) { + if (!dryRun) { + return createSlideDeckExportFailureResult( + baseResult, + error, + { + output_path: compactText(args.output?.path), + workspace_root: compactText(args.runtime?.workspace_root) || undefined, + workspace_root_source: "unresolved", + }, + warnings, + ); + } + + throw error; + } + + const result = { + ...baseResult, + planned_output: dryRun + ? { + kind: "pptx", + path: outputPath, + mime_type: PPTX_MIME_TYPE, + } + : undefined, + }; if (dryRun) { return result; diff --git a/tests/mcp.test.mjs b/tests/mcp.test.mjs index e039ba4..1cc78fb 100644 --- a/tests/mcp.test.mjs +++ b/tests/mcp.test.mjs @@ -20,26 +20,203 @@ const OLD_TOOL_NAMES = [ "get_example", "resolve_related", ]; +const WORKSPACE_ENV_KEYS = [ + "JUDGMENTKIT_WORKSPACE_ROOT", + "CODEX_WORKSPACE_ROOT", + "CODEX_WORKSPACE_DIR", + "CODEX_WORKSPACE", + "INIT_CWD", +]; -function localArtifactToolPackage() { - const candidates = [ - process.env.JUDGMENTKIT_ARTIFACT_TOOL_PACKAGE, - process.env.CODEX_RUNTIME_DEPENDENCIES - ? path.join( - process.env.CODEX_RUNTIME_DEPENDENCIES, - "node", - "node_modules", - "@oai", - "artifact-tool", - ) - : undefined, - path.join( - process.env.HOME ?? "", - ".cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/@oai/artifact-tool", - ), - ].filter(Boolean); +function createFixtureArtifactToolPackage(root) { + const packageRoot = path.join(root, "artifact-tool"); + const distDir = path.join(packageRoot, "dist"); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "@oai/artifact-tool", version: "2.0.0", type: "module" }, null, 2), + ); + fs.writeFileSync( + path.join(distDir, "artifact_tool.mjs"), + ` +import fs from "node:fs"; + +const CRC_TABLE = Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + return value >>> 0; +}); + +function crc32(buffer) { + let value = 0xffffffff; + for (const byte of buffer) { + value = (value >>> 8) ^ CRC_TABLE[(value ^ byte) & 0xff]; + } + return (value ^ 0xffffffff) >>> 0; +} + +function uint16(value) { + const buffer = Buffer.alloc(2); + buffer.writeUInt16LE(value); + return buffer; +} + +function uint32(value) { + const buffer = Buffer.alloc(4); + buffer.writeUInt32LE(value >>> 0); + return buffer; +} + +function writeZip(filePath, entries) { + const localParts = []; + const centralParts = []; + let offset = 0; + + for (const [entryName, content] of entries) { + const name = Buffer.from(entryName, "utf8"); + const data = Buffer.from(content, "utf8"); + const checksum = crc32(data); + const localHeader = Buffer.concat([ + uint32(0x04034b50), + uint16(20), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(checksum), + uint32(data.length), + uint32(data.length), + uint16(name.length), + uint16(0), + name, + data, + ]); + const centralHeader = Buffer.concat([ + uint32(0x02014b50), + uint16(20), + uint16(20), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(checksum), + uint32(data.length), + uint32(data.length), + uint16(name.length), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(0), + uint32(offset), + name, + ]); + + localParts.push(localHeader); + centralParts.push(centralHeader); + offset += localHeader.length; + } + + const centralDirectory = Buffer.concat(centralParts); + const endOfCentralDirectory = Buffer.concat([ + uint32(0x06054b50), + uint16(0), + uint16(0), + uint16(entries.length), + uint16(entries.length), + uint32(centralDirectory.length), + uint32(offset), + uint16(0), + ]); + + fs.writeFileSync(filePath, Buffer.concat([...localParts, centralDirectory, endOfCentralDirectory])); +} + +function createSlide() { + return { + background: {}, + layers: [], + charts: { add() {} }, + compose(layer, options) { + this.layers.push({ layer, options }); + }, + }; +} + +export const Presentation = { + create(options = {}) { + const presentation = { + createOptions: options, + slideSize: options.slideSize, + theme: {}, + slides: { + items: [], + add() { + const slide = createSlide(); + this.items.push(slide); + return slide; + }, + }, + }; + + return presentation; + }, +}; + +export const PresentationFile = { + async exportPptx(presentation) { + return { + async save(filePath) { + const slideCount = Math.max(1, presentation?.slides?.items?.length ?? 0); + const slideEntries = Array.from({ length: slideCount }, (_, index) => [ + "ppt/slides/slide" + String(index + 1) + ".xml", + "", + ]); + writeZip(filePath, [ + ["[Content_Types].xml", ""], + ["ppt/presentation.xml", ""], + ...slideEntries, + ]); + }, + }; + }, +}; + +export function layers(options, children = []) { + return { type: "layers", options, children }; +} + +export function shape(options) { + return { type: "shape", options }; +} + +export function table(options) { + return { type: "table", options }; +} + +export function text(lines, options) { + return { type: "text", lines, options }; +} +`, + ); - return candidates.find((candidate) => fs.existsSync(path.join(candidate, "package.json"))); + return packageRoot; +} + +function captureWorkspaceEnv() { + return Object.fromEntries(WORKSPACE_ENV_KEYS.map((key) => [key, process.env[key]])); +} + +function restoreWorkspaceEnv(values) { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } } assert.deepEqual( @@ -363,6 +540,9 @@ const unsafeOutputDeck = await handleToolCall("create_slide_deck", { }); assert.equal("error" in unsafeOutputDeck, true); assert.equal(unsafeOutputDeck.error.code, "unsafe_output_path"); +assert.equal(unsafeOutputDeck.deck_creation_status, "export_failed"); +assert.equal(unsafeOutputDeck.export_status, "failed"); +assert.equal(unsafeOutputDeck.export_attempt.output_path, "../unsafe.pptx"); const absoluteOutputDeck = await handleToolCall("create_slide_deck", { output: { path: path.join(process.cwd(), "outputs/judgmentkit-slide-decks/absolute.pptx") }, @@ -377,6 +557,8 @@ assert.equal("error" in absoluteOutputDeck, true); assert.equal(absoluteOutputDeck.error.code, "unsafe_output_path"); assert.match(absoluteOutputDeck.error.message, /repo-relative/i); assert.equal(absoluteOutputDeck.error.details.workspace_root, process.cwd()); +assert.equal(absoluteOutputDeck.deck_creation_status, "export_failed"); +assert.equal(absoluteOutputDeck.export_status, "failed"); const missingRuntimeWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-missing-runtime-")); try { @@ -403,20 +585,13 @@ try { } const originalCwd = process.cwd(); -const originalWorkspaceEnv = Object.fromEntries( - [ - "JUDGMENTKIT_WORKSPACE_ROOT", - "CODEX_WORKSPACE_ROOT", - "CODEX_WORKSPACE_DIR", - "CODEX_WORKSPACE", - "INIT_CWD", - ].map((key) => [key, process.env[key]]), -); +const originalWorkspaceEnv = captureWorkspaceEnv(); const unsafeImplicitRoot = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-unsafe-cwd-")); -const unsafeNestedCwd = path.join(unsafeImplicitRoot, ".codex", "judgmentkit-mcp"); +const unsafeExactCwd = path.join(unsafeImplicitRoot, ".codex"); +const unsafeNestedCwd = path.join(unsafeExactCwd, "judgmentkit-mcp"); fs.mkdirSync(unsafeNestedCwd, { recursive: true }); try { - for (const key of Object.keys(originalWorkspaceEnv)) { + for (const key of WORKSPACE_ENV_KEYS) { delete process.env[key]; } process.chdir(unsafeNestedCwd); @@ -432,6 +607,51 @@ try { assert.equal(missingWorkspaceDeck.error.code, "workspace_root_missing"); assert.equal(missingWorkspaceDeck.deck_creation_status, "export_failed"); + process.chdir(unsafeExactCwd); + const exactUnsafeWorkspaceDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + slides: [ + { + content: { title: "Exact unsafe workspace" }, + }, + ], + }); + assert.equal("error" in exactUnsafeWorkspaceDeck, true); + assert.equal(exactUnsafeWorkspaceDeck.error.code, "workspace_root_missing"); + assert.equal(exactUnsafeWorkspaceDeck.deck_creation_status, "export_failed"); + + process.chdir(originalCwd); + process.env.JUDGMENTKIT_WORKSPACE_ROOT = unsafeExactCwd; + const envUnsafeWorkspaceDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + slides: [ + { + content: { title: "Env unsafe workspace" }, + }, + ], + }); + assert.equal("error" in envUnsafeWorkspaceDeck, true); + assert.equal(envUnsafeWorkspaceDeck.error.code, "workspace_root_invalid"); + assert.equal(envUnsafeWorkspaceDeck.deck_creation_status, "export_failed"); + delete process.env.JUDGMENTKIT_WORKSPACE_ROOT; + + const unsafeSymlinkWorkspace = path.join(unsafeImplicitRoot, "workspace-link"); + fs.symlinkSync(unsafeExactCwd, unsafeSymlinkWorkspace); + const symlinkUnsafeWorkspaceDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + runtime: { + workspace_root: unsafeSymlinkWorkspace, + }, + slides: [ + { + content: { title: "Symlink unsafe workspace" }, + }, + ], + }); + assert.equal("error" in symlinkUnsafeWorkspaceDeck, true); + assert.equal(symlinkUnsafeWorkspaceDeck.error.code, "workspace_root_invalid"); + assert.equal(symlinkUnsafeWorkspaceDeck.deck_creation_status, "export_failed"); + const explicitWorkspaceDeck = await handleToolCall("create_slide_deck", { dry_run: false, output: { @@ -452,13 +672,7 @@ try { assert.equal(explicitWorkspaceDeck.export_attempt.workspace_root, originalCwd); } finally { process.chdir(originalCwd); - for (const [key, value] of Object.entries(originalWorkspaceEnv)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } + restoreWorkspaceEnv(originalWorkspaceEnv); fs.rmSync(unsafeImplicitRoot, { recursive: true, force: true }); } @@ -492,8 +706,179 @@ try { fs.rmSync(existingOutputPath, { force: true }); } -const artifactToolPackage = localArtifactToolPackage(); -if (artifactToolPackage) { +const receiptExistsWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-receipt-exists-")); +try { + const exportPath = "outputs/judgmentkit-slide-decks/receipt-exists-test.pptx"; + const receiptPath = path.join(receiptExistsWorkspace, `${exportPath}.receipt.json`); + fs.mkdirSync(path.dirname(receiptPath), { recursive: true }); + fs.writeFileSync(receiptPath, "existing receipt", "utf8"); + const existingReceiptDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + output: { path: exportPath }, + runtime: { + artifact_tool_package: "/no/such/artifact-tool", + workspace_root: receiptExistsWorkspace, + }, + slides: [ + { + content: { title: "Existing receipt" }, + }, + ], + }); + assert.equal("error" in existingReceiptDeck, true); + assert.equal(existingReceiptDeck.error.code, "output_exists"); + assert.equal(existingReceiptDeck.error.details.target, "provenance receipt"); + assert.equal(fs.existsSync(path.join(receiptExistsWorkspace, exportPath)), false); +} finally { + fs.rmSync(receiptExistsWorkspace, { recursive: true, force: true }); +} + +const receiptSymlinkWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-receipt-symlink-")); +try { + const exportPath = "outputs/judgmentkit-slide-decks/receipt-symlink-test.pptx"; + const receiptPath = path.join(receiptSymlinkWorkspace, `${exportPath}.receipt.json`); + const outsideReceiptTarget = path.join(receiptSymlinkWorkspace, "outside-receipt-target.json"); + fs.mkdirSync(path.dirname(receiptPath), { recursive: true }); + fs.writeFileSync(outsideReceiptTarget, "outside-before", "utf8"); + fs.symlinkSync(outsideReceiptTarget, receiptPath); + const symlinkReceiptDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + output: { path: exportPath }, + runtime: { + artifact_tool_package: "/no/such/artifact-tool", + workspace_root: receiptSymlinkWorkspace, + }, + slides: [ + { + content: { title: "Receipt symlink" }, + }, + ], + }); + assert.equal("error" in symlinkReceiptDeck, true); + assert.equal(symlinkReceiptDeck.error.code, "unsafe_output_path"); + assert.equal(fs.readFileSync(outsideReceiptTarget, "utf8"), "outside-before"); + assert.equal(fs.existsSync(path.join(receiptSymlinkWorkspace, exportPath)), false); +} finally { + fs.rmSync(receiptSymlinkWorkspace, { recursive: true, force: true }); +} + +const symlinkOutputWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-output-symlink-")); +const symlinkOutputOutside = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-output-outside-")); +try { + const outputRoot = path.join(symlinkOutputWorkspace, "outputs", "judgmentkit-slide-decks"); + fs.mkdirSync(path.dirname(outputRoot), { recursive: true }); + fs.symlinkSync(symlinkOutputOutside, outputRoot); + const symlinkOutputDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + output: { path: "outputs/judgmentkit-slide-decks/symlink-output-test.pptx" }, + runtime: { + artifact_tool_package: "/no/such/artifact-tool", + workspace_root: symlinkOutputWorkspace, + }, + slides: [ + { + content: { title: "Symlink output" }, + }, + ], + }); + assert.equal("error" in symlinkOutputDeck, true); + assert.equal(symlinkOutputDeck.error.code, "unsafe_output_path"); + assert.equal(fs.existsSync(path.join(symlinkOutputOutside, "symlink-output-test.pptx")), false); +} finally { + fs.rmSync(symlinkOutputWorkspace, { recursive: true, force: true }); + fs.rmSync(symlinkOutputOutside, { recursive: true, force: true }); +} + +const nestedSymlinkWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-nested-symlink-")); +const nestedSymlinkOutside = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-nested-outside-")); +try { + const linkPath = path.join( + nestedSymlinkWorkspace, + "outputs", + "judgmentkit-slide-decks", + "link", + ); + fs.mkdirSync(path.dirname(linkPath), { recursive: true }); + fs.symlinkSync(nestedSymlinkOutside, linkPath); + const nestedSymlinkDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + output: { path: "outputs/judgmentkit-slide-decks/link/nested/deck.pptx" }, + runtime: { + artifact_tool_package: "/no/such/artifact-tool", + workspace_root: nestedSymlinkWorkspace, + }, + slides: [ + { + content: { title: "Nested symlink" }, + }, + ], + }); + assert.equal("error" in nestedSymlinkDeck, true); + assert.equal(nestedSymlinkDeck.error.code, "unsafe_output_path"); + assert.equal(fs.existsSync(path.join(nestedSymlinkOutside, "nested")), false); +} finally { + fs.rmSync(nestedSymlinkWorkspace, { recursive: true, force: true }); + fs.rmSync(nestedSymlinkOutside, { recursive: true, force: true }); +} + +const receiptDirectoryWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-receipt-dir-")); +try { + const exportPath = "outputs/judgmentkit-slide-decks/receipt-dir-test.pptx"; + const receiptPath = path.join(receiptDirectoryWorkspace, `${exportPath}.receipt.json`); + fs.mkdirSync(receiptPath, { recursive: true }); + const receiptDirectoryDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + output: { path: exportPath }, + runtime: { + artifact_tool_package: "/no/such/artifact-tool", + workspace_root: receiptDirectoryWorkspace, + }, + slides: [ + { + content: { title: "Receipt directory" }, + }, + ], + }); + assert.equal("error" in receiptDirectoryDeck, true); + assert.equal(receiptDirectoryDeck.error.code, "output_exists"); + assert.equal(fs.existsSync(path.join(receiptDirectoryWorkspace, exportPath)), false); +} finally { + fs.rmSync(receiptDirectoryWorkspace, { recursive: true, force: true }); +} + +const overwriteReceiptFailureWorkspace = fs.mkdtempSync( + path.join(os.tmpdir(), "judgmentkit-overwrite-receipt-fail-"), +); +try { + const exportPath = "outputs/judgmentkit-slide-decks/overwrite-receipt-fail-test.pptx"; + const outputPath = path.join(overwriteReceiptFailureWorkspace, exportPath); + const receiptPath = path.join(overwriteReceiptFailureWorkspace, `${exportPath}.receipt.json`); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, "existing deck", "utf8"); + fs.mkdirSync(receiptPath, { recursive: true }); + const overwriteReceiptFailureDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + output: { path: exportPath, overwrite: true }, + runtime: { + artifact_tool_package: "/no/such/artifact-tool", + workspace_root: overwriteReceiptFailureWorkspace, + }, + slides: [ + { + content: { title: "Overwrite receipt failure" }, + }, + ], + }); + assert.equal("error" in overwriteReceiptFailureDeck, true); + assert.equal(overwriteReceiptFailureDeck.error.code, "output_exists"); + assert.equal(fs.readFileSync(outputPath, "utf8"), "existing deck"); +} finally { + fs.rmSync(overwriteReceiptFailureWorkspace, { recursive: true, force: true }); +} + +const artifactToolFixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-artifact-fixture-")); +const artifactToolPackage = createFixtureArtifactToolPackage(artifactToolFixtureRoot); +try { const exportWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "judgmentkit-mcp-export-")); const exportPath = "outputs/judgmentkit-slide-decks/repro-minimal-test.pptx"; try { @@ -526,10 +911,19 @@ if (artifactToolPackage) { assert.equal(exportedDeck.provenance_receipt.tool_name, "mcp__judgmentkit.create_slide_deck"); assert.equal(exportedDeck.provenance_receipt.output_path, exportPath); assert.equal(exportedDeck.provenance_receipt.workspace_root, exportWorkspace); + assert.equal(exportedDeck.provenance_receipt.sha256, exportedDeck.artifact_ref.sha256); + assert.equal(exportedDeck.provenance_receipt.bytes, exportedDeck.artifact_ref.bytes); + assert.equal(exportedDeck.provenance_receipt.mime_type, exportedDeck.artifact_ref.mime_type); assert.equal(exportedDeck.provenance_receipt.selected_templates[0].layout_id, "slide-21"); assert.equal(exportedDeck.provenance_receipt.selected_templates[0].caller_specified, true); assert.equal(fs.existsSync(path.join(exportWorkspace, exportPath)), true); assert.equal(fs.existsSync(path.join(exportWorkspace, `${exportPath}.receipt.json`)), true); + const writtenReceipt = JSON.parse( + fs.readFileSync(path.join(exportWorkspace, `${exportPath}.receipt.json`), "utf8"), + ); + assert.equal(writtenReceipt.sha256, exportedDeck.artifact_ref.sha256); + assert.equal(writtenReceipt.bytes, exportedDeck.artifact_ref.bytes); + assert.equal(writtenReceipt.mime_type, exportedDeck.artifact_ref.mime_type); assert.equal( exportedDeck.provenance_receipt.absolute_resolved_path.startsWith(exportWorkspace), true, @@ -538,6 +932,8 @@ if (artifactToolPackage) { } finally { fs.rmSync(exportWorkspace, { recursive: true, force: true }); } +} finally { + fs.rmSync(artifactToolFixtureRoot, { recursive: true, force: true }); } const iconList = await handleToolCall("list_icon_catalog", { limit: 2 });