{"record":{"id":"15b3e7fdcce2600c","repo":"agalwood/Motrix","slug":"plugin-ffmpeg-destination-phase-disallowed","errorCode":"plugin.ffmpeg.destination_phase_disallowed","errorMessage":"ffmpeg output ${userOutput} resolves outside saveDir and plugin storage","messagePattern":"ffmpeg output (.+?) resolves outside saveDir and plugin storage","errorType":"exception","errorClass":"PluginCodedError","httpStatus":null,"severity":"error","filePath":"src/core/plugin/host/capability-bridge.ts","lineNumber":1081,"sourceCode":"   * Pre-Plan-C (orchestrator wires real pluginStorageRoot in PR-2), call sites\n   * pass `pluginStorageRoot: ''`. Empty pluginStorageRoot short-circuits the\n   * gate so legacy tests keep passing; once PR-2 lands, every Plan-C call site\n   * has a real root and the gate becomes effective everywhere.\n   */\n  private async gateFfmpegOutput(userOutput: string): Promise<string> {\n    if (this.currentPhase === 'idle') return userOutput\n    if (!this.hookSaveDir || !this.hookPluginStorageRoot) {\n      // Plan-B context or pre-PR-2 placeholder — gate inactive.\n      return userOutput\n    }\n    const kind = classifyFfmpegOutput(\n      userOutput,\n      this.hookSaveDir,\n      this.hookPluginStorageRoot\n    )\n    if (kind === 'pluginStorage') return userOutput\n    if (kind === 'other') {\n      throw new PluginCodedError(\n        'plugin.ffmpeg.destination_phase_disallowed',\n        `ffmpeg output ${userOutput} resolves outside saveDir and plugin storage`\n      )\n    }\n    // kind === 'saveDir' — beforeFinalize + staging only.\n    if (this.currentPhase !== 'beforeFinalize') {\n      throw new PluginCodedError(\n        'plugin.ffmpeg.destination_phase_disallowed',\n        `ffmpeg cannot write to saveDir in ${this.currentPhase}; move this call into beforeFinalize or write to plugin storage`\n      )\n    }\n    if (!this.hookStaging) {\n      throw new PluginCodedError(\n        'plugin.ffmpeg.destination_phase_disallowed',\n        'ffmpeg saveDir write in beforeFinalize requires a FfmpegStaging in HookContextArgs'\n      )\n    }\n    const staged = this.hookStaging.redirectOutput(userOutput)","sourceCodeStart":1063,"sourceCodeEnd":1099,"githubUrl":"https://github.com/agalwood/Motrix/blob/1a708ee57746c434e2c67a44bbf0906a976afea4/src/core/plugin/host/capability-bridge.ts#L1063-L1099","documentation":"During a hook (phase !== 'idle') with the Plan-C context active (both hookSaveDir and hookPluginStorageRoot set), gateFfmpegOutput classifies the user-supplied ffmpeg output path. If it resolves to neither the task's saveDir nor the plugin's storage root (classifyFfmpegOutput returns 'other'), the host rejects it. This is the filesystem boundary that stops a plugin from writing arbitrary paths via ffmpeg.","triggerScenarios":"A plugin calls ffmpeg.run/transcode/extractAudio/mergeStreams/generateThumbnail inside beforeCreate/beforeFinalize/afterComplete/onError with output/outputPath set to an absolute path (/etc/x, /tmp/x, os.tmpdir()) or a relative path that escapes both saveDir and pluginStorageRoot.","commonSituations":"Plugin hardcodes a temp or absolute output path; reuses a scratch path from a non-task context; uses ../ traversal; writes to a directory discovered outside the hook context.","solutions":["Write ffmpeg output under the plugin's storage root — allowed in every phase.","Or write under the task saveDir, but only inside the beforeFinalize hook (see error 162).","Avoid absolute paths and os.tmpdir() for ffmpeg output inside hooks.","Derive any scratch path from ctx.saveDir or ctx.pluginStorageRoot."],"exampleFix":"// before\nconst out = path.join(os.tmpdir(), 'out.mp4')\nawait ffmpeg.transcode({ input, output: out })\n// after — plugin storage is allowed in all phases\nconst out = path.join(ctx.pluginStorageRoot, 'transcoded.mp4')\nawait ffmpeg.transcode({ input, output: out })","handlingStrategy":"validation","validationCode":"import path from 'node:path'\nfunction assertFfmpegOutputAllowed(output: string, saveDir: string, pluginStorageRoot: string): void {\n  const under = (base: string) => {\n    if (path.isAbsolute(output) !== path.isAbsolute(base)) return false\n    const rel = path.relative(base, output)\n    return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))\n  }\n  if (!under(saveDir) && !under(pluginStorageRoot)) {\n    throw new Error(`ffmpeg output ${output} is outside saveDir and pluginStorageRoot`)\n  }\n}\n// call before ffmpeg.run/transcode/extractAudio/mergeStreams/generateThumbnail:\nassertFfmpegOutputAllowed(opts.output, ctx.saveDir, ctx.pluginStorageRoot)","typeGuard":"function isOutputUnderRoots(output: string, saveDir: string, pluginStorageRoot: string): boolean {\n  const under = (base: string) => {\n    if (path.isAbsolute(output) !== path.isAbsolute(base)) return false\n    const rel = path.relative(base, output)\n    return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))\n  }\n  return under(saveDir) || under(pluginStorageRoot)\n}","tryCatchPattern":"try {\n  const { opId } = await ffmpeg.transcode(opts)\n} catch (e) {\n  if ((e as { code?: string }).code === 'plugin.ffmpeg.destination_phase_disallowed' && /outside/.test(e.message)) {\n    opts.output = path.join(ctx.pluginStorageRoot, path.basename(opts.output))\n    // retry once with the rewritten output\n  } else throw e\n}","preventionTips":["Never pass absolute or os.tmpdir() paths as ffmpeg output inside a hook.","Derive every ffmpeg output path from ctx.saveDir or ctx.pluginStorageRoot.","Treat 'outside saveDir and plugin storage' as a hard security boundary, not a soft warning."],"tags":["plugin","ffmpeg","security","filesystem","hooks"],"backgroundTag":null,"analyzedSha":"1a708ee57746c434e2c67a44bbf0906a976afea4","analyzedAt":"2026-08-12T16:18:09.346Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}