diff --git a/docs/specs/pipeline-recipe-builder-technical-design.md b/docs/specs/pipeline-recipe-builder-technical-design.md index c283b681..f18891bf 100644 --- a/docs/specs/pipeline-recipe-builder-technical-design.md +++ b/docs/specs/pipeline-recipe-builder-technical-design.md @@ -141,6 +141,8 @@ export interface PipelineToolAdapter { warnings: readonly string[] defaultOptions: Record publicOptionKeys: readonly string[] + persistentOptionKeys?: readonly string[] + persistentOptionReview?: Record validateOptions(options: Record): AdapterValidationResult run(input: Input, options: Record): Promise> | AdapterRunResult } @@ -173,7 +175,19 @@ Adapter rules: - `warnings` must describe persistent adapter-level caveats shown or available to UI surfaces. - Adapters must return structured warnings instead of throwing for expected user errors. - Adapters must have unit tests covering success and failure paths. -- `publicOptionKeys` controls which options are allowed into shared recipe URLs. +- `publicOptionKeys` controls which options are editable or visible in the Pipeline Builder UI. +- `persistentOptionKeys` controls which reviewed options are allowed into saved recipes, exported recipe JSON, and shared recipe URLs. If omitted, it defaults to `publicOptionKeys` for backwards compatibility. +- `persistentOptionReview` must document any persisted option whose key looks sensitive even when the persisted value is a safe scalar, such as `urlSafe` or a boolean scrub-rule toggle. + +Option persistence taxonomy: + +| Persistence | Meaning | Examples | +|-------------|---------|----------| +| Safe | Reviewed scalar configuration that does not carry user-authored payload content. | `mode`, `indent`, `format`, `operation`, bounded numeric limits, boolean toggles. | +| Sensitive | User-authored text, structured data, or values likely to contain private contract details. These are excluded by default. | `schema`, regex `pattern`, headers, bodies, URLs, payloads, examples, defaults, constants. | +| Reviewed exception | A safe scalar whose key contains a suspicious substring and therefore needs an explicit reason. | `urlSafe`, boolean log scrubber toggles such as `apiKeys` or `bearerTokens`. | + +Guard tests must prevent new suspicious persistent option keys containing `token`, `secret`, `key`, `url`, `header`, `body`, `payload`, `input`, `output`, `example`, `default`, `const`, `query`, `endpoint`, `host`, `schema`, or `pattern` unless `persistentOptionReview` explains why the value is safe to persist. ## 6. Adapter Sets @@ -240,7 +254,8 @@ Recipe URL sharing should encode recipe structure only: Rules: - Do not include runtime input by default. -- Do not include unknown adapter options; share URLs keep only keys declared in each adapter's `publicOptionKeys`. +- Do not include unknown adapter options; share URLs keep only keys declared in each adapter's `persistentOptionKeys` or the fallback persistent key set. +- Do not include user-authored schemas, regex patterns, examples, defaults, URLs, headers, bodies, or payload-like options by default. - If a user explicitly chooses to include sample input, show a privacy warning. - Enforce a URL length budget and fall back to export JSON when the recipe is too large. - Reuse base64url behavior consistent with `tool-handoff`. @@ -311,6 +326,7 @@ Failure states must be explicit: - Runtime payloads stay in React state or browser storage only when the user explicitly saves a recipe with sample input. - Share URLs must not include payloads by default. - Analytics must never include recipe input, step output, secrets, or raw options that could contain data. +- Saved, exported, and shared recipes persist reviewed safe options only. User-authored schemas, regex patterns, examples, defaults, URLs, headers, bodies, and payload-like options stay out of the structure-only recipe boundary by default. - Export should be user-triggered only. - Imported recipes should never auto-run. @@ -412,7 +428,7 @@ Required gates: Continue workbench hardening after the public MVP stays green: 1. Run browser QA across the built-in templates in all supported locales. -2. Add more deterministic adapters only after each has explicit validation and `publicOptionKeys`. +2. Add more deterministic adapters only after each has explicit validation, `publicOptionKeys`, and reviewed `persistentOptionKeys` for save/export/share behavior. 3. Add direct "Send to Pipeline" actions from selected mature tool pages. 4. Evaluate worker-based execution only if large recipes make main-thread execution a real problem. 5. Keep branching/merge graph execution out of scope until a separate graph executor design exists. diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index cf6e74a6..8b77d27a 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -2849,7 +2849,7 @@ "privacy_preview_confirm_share": "Struktur-URL kopieren", "privacy_preview_cancel": "Abbrechen", "privacy_scope_recipe_metadata": "Rezeptname und Beschreibung", - "privacy_scope_steps_options": "Schrittreihenfolge, Tool-IDs, Labels und öffentliche Optionen", + "privacy_scope_steps_options": "Schrittreihenfolge, Tool-IDs, Labels und geprüfte dauerhafte Optionen", "privacy_scope_settings": "Sicherheitseinstellungen wie Stop-on-Error und lokale Byte-Limits", "privacy_scope_runtime_input": "Ursprüngliche Laufzeiteingabe", "privacy_scope_outputs": "Endausgabe und Zwischenoutputs", @@ -2889,7 +2889,8 @@ "usage_guide_composition_title": "Kompositionsregeln", "usage_guide_composition_body": "Tools koennen wiederholt werden. Die Validierung prueft Adapterversionen und Optionen, waehrend Kompatibilitaetshinweise Uebergaben wie Textausgabe in JSON-Eingabe markieren.", "usage_guide_privacy_title": "Datenschutzgrenze", - "usage_guide_privacy_body": "Save, Export JSON und Share URL behalten nur Struktur und oeffentliche Optionen. Laufzeiteingaben, Ausgaben, Logs, Dateien und konstante Eingaben werden entfernt." + "usage_guide_privacy_body": "Save, Export JSON und Share URL behalten nur Struktur und gepruefte dauerhafte Optionen. Laufzeiteingaben, Ausgaben, Logs, Dateien, konstante Eingaben, Schemas und Regex-Muster werden entfernt.", + "privacy_scope_user_authored_options": "Benutzerdefinierte Schemas, Regex-Muster, Beispiele, Standardwerte, URLs, Header, Bodies und Nutzdaten" }, "saml_decoder": { "title": "SAML-Dekoder", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index 1efdac36..41d8a965 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -2829,7 +2829,7 @@ "usage_guide_composition_title": "Composition rules", "usage_guide_composition_body": "Tools can repeat. Validation checks adapter versions and options, while compatibility hints flag handoffs such as text output into JSON input.", "usage_guide_privacy_title": "Privacy boundary", - "usage_guide_privacy_body": "Save, Export JSON, and Share URL keep structure and public options only. Runtime input, outputs, logs, files, and Constant input are stripped.", + "usage_guide_privacy_body": "Save, Export JSON, and Share URL keep structure and reviewed persistent options only. Runtime input, outputs, logs, files, Constant input, schemas, and regex patterns are stripped.", "no_options": "This adapter has no configurable public options.", "option_mode": "Mode", "option_indent": "Indent", @@ -2903,13 +2903,14 @@ "privacy_preview_confirm_share": "Copy structure-only URL", "privacy_preview_cancel": "Cancel", "privacy_scope_recipe_metadata": "Recipe name and description", - "privacy_scope_steps_options": "Step order, tool IDs, labels, and public options", + "privacy_scope_steps_options": "Step order, tool IDs, labels, and reviewed persistent options", "privacy_scope_settings": "Safety settings such as stop-on-error and local byte limits", "privacy_scope_runtime_input": "Initial runtime input", "privacy_scope_outputs": "Final output and intermediate step outputs", "privacy_scope_logs": "Run logs, diagnostics, and errors", "privacy_scope_files": "Uploaded file contents", - "privacy_scope_constants": "Constant step inputs, tokens, keys, and payloads" + "privacy_scope_constants": "Constant step inputs, tokens, keys, and payloads", + "privacy_scope_user_authored_options": "User-authored schemas, regex patterns, examples, defaults, URLs, headers, bodies, and payloads" }, "saml_decoder": { "title": "SAML Decoder", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 96db93db..6b557094 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -2849,7 +2849,7 @@ "privacy_preview_confirm_share": "Copier l’URL structure seule", "privacy_preview_cancel": "Annuler", "privacy_scope_recipe_metadata": "Nom et description de la recette", - "privacy_scope_steps_options": "Ordre des étapes, ID des outils, libellés et options publiques", + "privacy_scope_steps_options": "Ordre des étapes, ID des outils, libellés et options persistantes vérifiées", "privacy_scope_settings": "Paramètres de sécurité comme l'arrêt sur erreur et les limites locales en octets", "privacy_scope_runtime_input": "Entrée d'exécution initiale", "privacy_scope_outputs": "Sortie finale et sorties intermédiaires", @@ -2889,7 +2889,8 @@ "usage_guide_composition_title": "Regles de composition", "usage_guide_composition_body": "Les outils peuvent se repeter. La validation verifie versions d'adaptateur et options, tandis que les indications de compatibilite signalent les passages comme une sortie texte vers une entree JSON.", "usage_guide_privacy_title": "Limite de confidentialite", - "usage_guide_privacy_body": "Save, Export JSON et Share URL conservent uniquement la structure et les options publiques. Entrees d'execution, sorties, logs, fichiers et entrees constantes sont retires." + "usage_guide_privacy_body": "Save, Export JSON et Share URL conservent uniquement la structure et les options persistantes verifiees. Entrees d'execution, sorties, logs, fichiers, entrees constantes, schemas et motifs regex sont retires.", + "privacy_scope_user_authored_options": "Schémas, motifs regex, exemples, valeurs par défaut, URL, en-têtes, corps et charges utiles rédigés par l'utilisateur" }, "saml_decoder": { "title": "Décodeur SAML", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index a560bbbd..f6c3de75 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -2849,7 +2849,7 @@ "privacy_preview_confirm_share": "構造のみの URL をコピー", "privacy_preview_cancel": "キャンセル", "privacy_scope_recipe_metadata": "レシピ名と説明", - "privacy_scope_steps_options": "ステップ順序、ツール ID、ラベル、公開オプション", + "privacy_scope_steps_options": "ステップ順序、ツール ID、ラベル、レビュー済みの永続化オプション", "privacy_scope_settings": "エラー時停止やローカルバイト上限などの安全設定", "privacy_scope_runtime_input": "初期実行入力", "privacy_scope_outputs": "最終出力と中間ステップ出力", @@ -2889,7 +2889,8 @@ "usage_guide_composition_title": "組み合わせルール", "usage_guide_composition_body": "ツールは繰り返せます。検証はアダプタバージョンとオプションを確認し、互換性ヒントはテキスト出力から JSON 入力のような受け渡しを示します。", "usage_guide_privacy_title": "プライバシー境界", - "usage_guide_privacy_body": "保存、JSON 書き出し、共有 URL は構造と公開オプションのみ保持します。実行時入力、出力、ログ、ファイル、固定入力は削除されます。" + "usage_guide_privacy_body": "保存、JSON 書き出し、共有 URL は構造とレビュー済みの永続化オプションのみ保持します。実行時入力、出力、ログ、ファイル、固定入力、スキーマ、正規表現パターンは削除されます。", + "privacy_scope_user_authored_options": "ユーザー作成のスキーマ、正規表現パターン、例、デフォルト、URL、ヘッダー、本文、ペイロード" }, "saml_decoder": { "title": "SAML デコーダー", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index d189480d..7235bc40 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -2849,7 +2849,7 @@ "privacy_preview_confirm_share": "구조 전용 URL 복사", "privacy_preview_cancel": "취소", "privacy_scope_recipe_metadata": "레시피 이름과 설명", - "privacy_scope_steps_options": "단계 순서, 도구 ID, 라벨, 공개 옵션", + "privacy_scope_steps_options": "단계 순서, 도구 ID, 라벨, 검토된 영구 옵션", "privacy_scope_settings": "오류 시 중지와 로컬 바이트 제한 같은 안전 설정", "privacy_scope_runtime_input": "초기 실행 입력", "privacy_scope_outputs": "최종 출력과 중간 단계 출력", @@ -2889,7 +2889,8 @@ "usage_guide_composition_title": "구성 규칙", "usage_guide_composition_body": "도구는 반복할 수 있습니다. 검증은 어댑터 버전과 옵션을 확인하며, 호환성 힌트는 text 출력을 JSON 입력으로 넘기는 경우처럼 인계를 표시합니다.", "usage_guide_privacy_title": "개인정보 경계", - "usage_guide_privacy_body": "저장, JSON 내보내기, 공유 URL은 구조와 공개 옵션만 유지합니다. 런타임 입력, 출력, 로그, 파일, 고정 입력은 제거됩니다." + "usage_guide_privacy_body": "저장, JSON 내보내기, 공유 URL은 구조와 검토된 영구 옵션만 유지합니다. 런타임 입력, 출력, 로그, 파일, 고정 입력, 스키마, 정규식 패턴은 제거됩니다.", + "privacy_scope_user_authored_options": "사용자가 작성한 스키마, 정규식 패턴, 예시, 기본값, URL, 헤더, 본문, 페이로드" }, "saml_decoder": { "title": "SAML 디코더", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index e2fe27ef..59d24ca2 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -2849,7 +2849,7 @@ "privacy_preview_confirm_share": "复制结构分享 URL", "privacy_preview_cancel": "取消", "privacy_scope_recipe_metadata": "配方名称和描述", - "privacy_scope_steps_options": "步骤顺序、工具 ID、标签和公开选项", + "privacy_scope_steps_options": "步骤顺序、工具 ID、标签和已审查的持久化选项", "privacy_scope_settings": "停止出错、 本地字节限制等安全设置", "privacy_scope_runtime_input": "初始运行输入", "privacy_scope_outputs": "最终输出和中间步骤输出", @@ -2889,7 +2889,8 @@ "usage_guide_composition_title": "组合规则", "usage_guide_composition_body": "工具可以重复。验证会检查适配器版本和选项,兼容性提示会标出类似文本输出接入 JSON 输入的交接。", "usage_guide_privacy_title": "隐私边界", - "usage_guide_privacy_body": "保存、导出 JSON 和分享 URL 仅保留结构和公开选项。运行时输入、输出、日志、文件和固定输入都会被剥离。" + "usage_guide_privacy_body": "保存、导出 JSON 和分享 URL 仅保留结构和已审查的持久化选项。运行时输入、输出、日志、文件、固定输入、schema 和正则模式都会被剥离。", + "privacy_scope_user_authored_options": "用户编写的 schema、正则模式、示例、默认值、URL、标头、正文和载荷" }, "saml_decoder": { "title": "SAML 解码器", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index 7b82ce5d..00610eaf 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -2849,7 +2849,7 @@ "privacy_preview_confirm_share": "複製結構分享 URL", "privacy_preview_cancel": "取消", "privacy_scope_recipe_metadata": "配方名稱和描述", - "privacy_scope_steps_options": "步驟順序、工具 ID、標籤和公開選項", + "privacy_scope_steps_options": "步驟順序、工具 ID、標籤和已審查的持久化選項", "privacy_scope_settings": "停止於錯誤、本機位元組限制等安全設定", "privacy_scope_runtime_input": "初始執行輸入", "privacy_scope_outputs": "最終輸出和中間步驟輸出", @@ -2889,7 +2889,8 @@ "usage_guide_composition_title": "組合規則", "usage_guide_composition_body": "工具可以重複。驗證會檢查適配器版本和選項,相容性提示會標出類似文本輸出接入 JSON 輸入的交接。", "usage_guide_privacy_title": "隱私邊界", - "usage_guide_privacy_body": "儲存、匯出 JSON 和分享 URL 僅保留結構和公開選項。執行時輸入、輸出、日誌、檔案和固定輸入都會被移除。" + "usage_guide_privacy_body": "儲存、匯出 JSON 和分享 URL 僅保留結構和已審查的持久化選項。執行時輸入、輸出、日誌、檔案、固定輸入、schema 和正則模式都會被移除。", + "privacy_scope_user_authored_options": "使用者撰寫的 schema、正則模式、範例、預設值、URL、標頭、本文和載荷" }, "saml_decoder": { "title": "SAML 解碼器", diff --git a/src/features/pipeline/adapter-registry.ts b/src/features/pipeline/adapter-registry.ts index e90e9be4..2b58e718 100644 --- a/src/features/pipeline/adapter-registry.ts +++ b/src/features/pipeline/adapter-registry.ts @@ -124,6 +124,9 @@ const base64Adapter: PipelineToolAdapter = { urlSafe: false, }, publicOptionKeys: ["operation", "urlSafe"], + persistentOptionReview: { + urlSafe: "Boolean output encoding toggle; it does not contain a URL value.", + }, validateOptions(options) { const operation = stringOption(options, "operation", "encode") if (!["encode", "decode"].includes(operation)) return fail("operation must be encode or decode.") @@ -259,6 +262,14 @@ const logScrubberAdapter: PipelineToolAdapter = { ], defaultOptions: { ...DEFAULT_SCRUB_OPTIONS }, publicOptionKeys: Object.keys(DEFAULT_SCRUB_OPTIONS), + persistentOptionReview: { + jwtTokens: "Boolean scrub-rule toggle; it does not contain token material.", + bearerTokens: "Boolean scrub-rule toggle; it does not contain token material.", + apiKeys: "Boolean scrub-rule toggle; it does not contain key material.", + awsAccessKeys: "Boolean scrub-rule toggle; it does not contain key material.", + privateKeys: "Boolean scrub-rule toggle; it does not contain key material.", + urlCredentials: "Boolean scrub-rule toggle; it does not contain a URL value.", + }, validateOptions(options) { for (const key of Object.keys(DEFAULT_SCRUB_OPTIONS)) { if (typeof (options[key] ?? true) !== "boolean") return fail(`${key} must be boolean.`) @@ -331,6 +342,9 @@ const csvJsonAdapter: PipelineToolAdapter = { typeInference: true, }, publicOptionKeys: ["direction", "delimiter", "hasHeader", "typeInference"], + persistentOptionReview: { + hasHeader: "Boolean CSV parsing toggle; it does not contain header names or row data.", + }, validateOptions(options) { const direction = stringOption(options, "direction", "csv-to-json") if (!["csv-to-json", "json-to-csv"].includes(direction)) return fail("direction must be csv-to-json or json-to-csv.") @@ -518,6 +532,9 @@ const unixTimestampAdapter: PipelineToolAdapter = { output: "iso", }, publicOptionKeys: ["output"], + persistentOptionReview: { + output: "Bounded enum controlling timestamp output format; it does not contain output data.", + }, validateOptions(options) { const output = stringOption(options, "output", "iso") if (!["iso", "json"].includes(output)) return fail("output must be iso or json.") @@ -582,6 +599,7 @@ const regexTesterAdapter: PipelineToolAdapter = { maxMatches: 100, }, publicOptionKeys: ["pattern", "flags", "maxMatches"], + persistentOptionKeys: ["flags", "maxMatches"], validateOptions(options) { const pattern = stringOption(options, "pattern", "") const flags = stringOption(options, "flags", "g") @@ -659,6 +677,7 @@ const jsonSchemaAdapter: PipelineToolAdapter = { schema: "", }, publicOptionKeys: ["mode", "schema"], + persistentOptionKeys: ["mode"], validateOptions(options) { const mode = stringOption(options, "mode", "generate") if (!["generate", "validate"].includes(mode)) return fail("mode must be generate or validate.") diff --git a/src/features/pipeline/recipe-sanitizer.ts b/src/features/pipeline/recipe-sanitizer.ts index a3527911..396038f5 100644 --- a/src/features/pipeline/recipe-sanitizer.ts +++ b/src/features/pipeline/recipe-sanitizer.ts @@ -18,15 +18,40 @@ export const RECIPE_STRUCTURE_PRIVACY_SCOPE: RecipePrivacyScope = { "privacy_scope_logs", "privacy_scope_files", "privacy_scope_constants", + "privacy_scope_user_authored_options", ], } -function sanitizeOptions(toolKey: string, options: Record): Record { +export const SUSPICIOUS_PERSISTENT_OPTION_KEY_PARTS = [ + "token", + "secret", + "key", + "url", + "header", + "body", + "payload", + "input", + "output", + "example", + "default", + "const", + "query", + "endpoint", + "host", + "schema", + "pattern", +] as const + +export function getPersistentOptionKeys(toolKey: string): readonly string[] { const adapter = getPipelineAdapter(toolKey) - if (!adapter) return {} + return adapter?.persistentOptionKeys ?? adapter?.publicOptionKeys ?? [] +} + +function sanitizeOptions(toolKey: string, options: Record): Record { + const persistentOptionKeys = getPersistentOptionKeys(toolKey) return Object.fromEntries( - adapter.publicOptionKeys + persistentOptionKeys .filter((key) => Object.prototype.hasOwnProperty.call(options, key)) .map((key) => [key, options[key]]), ) diff --git a/src/features/pipeline/recipe-types.ts b/src/features/pipeline/recipe-types.ts index 2f1f8b50..93430d79 100644 --- a/src/features/pipeline/recipe-types.ts +++ b/src/features/pipeline/recipe-types.ts @@ -79,6 +79,8 @@ export interface PipelineToolAdapter { warnings: readonly string[] defaultOptions: Record publicOptionKeys: readonly string[] + persistentOptionKeys?: readonly string[] + persistentOptionReview?: Record validateOptions(options: Record): AdapterValidationResult run(input: Input, options: Record): Promise> | AdapterRunResult } diff --git a/tests/guards/pipeline-builder-privacy-preview-guard.test.ts b/tests/guards/pipeline-builder-privacy-preview-guard.test.ts index d41004db..50c65643 100644 --- a/tests/guards/pipeline-builder-privacy-preview-guard.test.ts +++ b/tests/guards/pipeline-builder-privacy-preview-guard.test.ts @@ -21,6 +21,17 @@ describe("pipeline builder privacy preview guard", () => { expect(storeSource).toContain("sanitizeRecipeForPersistence(recipe)") expect(sanitizerSource).toContain("constantInput") expect(sanitizerSource).toContain('inputMode: "previous_output"') - expect(sanitizerSource).toContain("publicOptionKeys") + expect(sanitizerSource).toContain("persistentOptionKeys") + expect(sanitizerSource).toContain("SUSPICIOUS_PERSISTENT_OPTION_KEY_PARTS") + expect(sanitizerSource).not.toContain("adapter.publicOptionKeys") + }) + + it("names user-authored options as excluded in the privacy preview scope", () => { + const sanitizerSource = readFileSync("src/features/pipeline/recipe-sanitizer.ts", "utf8") + const previewSource = readFileSync("src/features/tools/pipeline-builder/pipeline-privacy-preview.tsx", "utf8") + + expect(sanitizerSource).toContain("privacy_scope_user_authored_options") + expect(previewSource).toContain("scope.included") + expect(previewSource).toContain("scope.excluded") }) }) diff --git a/tests/unit/pipeline-foundation.test.ts b/tests/unit/pipeline-foundation.test.ts index 8dd48bc9..1781e1e8 100644 --- a/tests/unit/pipeline-foundation.test.ts +++ b/tests/unit/pipeline-foundation.test.ts @@ -4,7 +4,7 @@ import { TOOL_MANIFESTS } from "@/core/registry" import { createPortableRecipe, decodeRecipeFromUrlParam, encodeRecipeForShareUrl, encodeRecipeForUrl, recipeContainsRuntimeInput } from "@/features/pipeline/recipe-codec" import { createEmptyRecipe, runRecipe, validateRecipe } from "@/features/pipeline/executor" import { exportRecipeToJson, importRecipeFromJson } from "@/features/pipeline/recipe-import-export" -import { sanitizeRecipeForPersistence } from "@/features/pipeline/recipe-sanitizer" +import { getPersistentOptionKeys, sanitizeRecipeForPersistence, SUSPICIOUS_PERSISTENT_OPTION_KEY_PARTS } from "@/features/pipeline/recipe-sanitizer" import { createRecipeFromTemplate, getPipelineRecipeTemplateForWorkflow, PIPELINE_RECIPE_TEMPLATES } from "@/features/pipeline/recipe-templates" import { createSavedRecipeRecord, isRecipeStoreAvailable } from "@/features/pipeline/recipe-store" import { DEFAULT_RECIPE_SETTINGS, type PipelineToolAdapter, type RecipeDocument } from "@/features/pipeline/recipe-types" @@ -86,6 +86,15 @@ describe("pipeline foundation", () => { expect(typeof adapter.mayIncreaseSize).toBe("boolean") expect(Array.isArray(adapter.warnings)).toBe(true) expect(adapter.publicOptionKeys.every((key) => Object.prototype.hasOwnProperty.call(adapter.defaultOptions, key))).toBe(true) + expect(getPersistentOptionKeys(adapter.toolKey).every((key) => adapter.publicOptionKeys.includes(key))).toBe(true) + expect(getPersistentOptionKeys(adapter.toolKey).every((key) => Object.prototype.hasOwnProperty.call(adapter.defaultOptions, key))).toBe(true) + for (const key of getPersistentOptionKeys(adapter.toolKey)) { + const normalizedKey = key.toLowerCase() + const requiresReview = SUSPICIOUS_PERSISTENT_OPTION_KEY_PARTS.some((part) => normalizedKey.includes(part)) + if (requiresReview) { + expect(adapter.persistentOptionReview?.[key], `${adapter.toolKey}.${key} needs a privacy review reason`).toBeTruthy() + } + } } }) @@ -770,7 +779,7 @@ describe("pipeline foundation", () => { } }) - it("removes non-public options from share URLs", () => { + it("removes non-persistent options from share URLs", () => { const recipe = buildRecipe({ steps: [ { @@ -801,6 +810,61 @@ describe("pipeline foundation", () => { } }) + it("does not persist user-authored schemas or regex patterns by default", () => { + const privateSchema = JSON.stringify({ + type: "object", + properties: { + Authorization: { const: "Bearer secret-token-value" }, + customerEmail: { example: "alice@example.com" }, + }, + }) + const privatePattern = "Bearer\\s+(secret-token-value)" + const recipe = buildRecipe({ + steps: [ + { + id: "validate_contract", + toolKey: "json_schema_workbench", + adapterVersion: 1, + inputMode: "previous_output", + options: { + mode: "validate", + schema: privateSchema, + }, + }, + { + id: "match_summary", + toolKey: "regex_tester", + adapterVersion: 1, + inputMode: "previous_output", + options: { + pattern: privatePattern, + flags: "gi", + maxMatches: 25, + }, + }, + ], + edges: [], + }) + + const sanitized = sanitizeRecipeForPersistence(recipe) + const exported = exportRecipeToJson(recipe) + const saved = createSavedRecipeRecord(recipe, {}, "2026-06-10T01:00:00.000Z") + const decodedShare = decodeRecipeFromUrlParam(encodeRecipeForShareUrl(recipe)) + + expect(sanitized.steps[0].options).toEqual({ mode: "validate" }) + expect(sanitized.steps[1].options).toEqual({ flags: "gi", maxMatches: 25 }) + expect(JSON.stringify(saved)).not.toContain("secret-token-value") + expect(exported).not.toContain("secret-token-value") + expect(JSON.parse(exported).steps[0].options).not.toHaveProperty("schema") + expect(JSON.parse(exported).steps[1].options).not.toHaveProperty("pattern") + expect(decodedShare.ok).toBe(true) + if (decodedShare.ok) { + expect(decodedShare.recipe.steps[0].options).toEqual({ mode: "validate" }) + expect(decodedShare.recipe.steps[1].options).toEqual({ flags: "gi", maxMatches: 25 }) + expect(JSON.stringify(decodedShare.recipe)).not.toContain("secret-token-value") + } + }) + it("sanitizes saved and exported recipes to workflow structure only", () => { const recipe = buildRecipe({ steps: [