@goodandready/dsh-image-gen 0.11.17 → 0.11.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -317,6 +317,20 @@ export function optimizeSvgContent(svgString, options = {}) {
317
317
  cleaned = cleaned.replace(/\s*(?:xmlns:inkscape|xmlns:sodipodi|xmlns:sketch|inkscape:[a-z-]+|sodipodi:[a-z-]+|sketch:[a-z-]+)="[^"]*"/gi, '')
318
318
  cleaned = cleaned.replace(/<(?:metadata|sodipodi:namedview)[\s\S]*?<\/(?:metadata|sodipodi:namedview)>/gi, '')
319
319
 
320
+ // Security (#374): Strip active/dangerous SVG tags (<script>, <foreignObject>, <iframe>, <embed>, <object>, <applet>)
321
+ cleaned = cleaned.replace(/<script[\s\S]*?<\/script>/gi, '')
322
+ cleaned = cleaned.replace(/<script[^>]*\/?>/gi, '')
323
+ cleaned = cleaned.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, '')
324
+ cleaned = cleaned.replace(/<foreignObject[^>]*\/?>/gi, '')
325
+ cleaned = cleaned.replace(/<(?:iframe|embed|object|applet)[\s\S]*?<\/(?:iframe|embed|object|applet)>/gi, '')
326
+ cleaned = cleaned.replace(/<(?:iframe|embed|object|applet)[^>]*\/?>/gi, '')
327
+
328
+ // Strip event handler attributes (onload, onerror, onclick, etc.)
329
+ cleaned = cleaned.replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
330
+
331
+ // Strip dangerous URI schemes (javascript:, data:text/html, vbscript:) in href/xlink:href/src/action
332
+ cleaned = cleaned.replace(/\s+(?:href|xlink:href|src|action)\s*=\s*(?:"\s*(?:javascript|data:text\/html|vbscript):[^"]*"|'\s*(?:javascript|data:text\/html|vbscript):[^']*')/gi, '')
333
+
320
334
  cleaned = cleaned.replace(/\s{2,}/g, ' ')
321
335
  cleaned = cleaned.replace(/>\s+</g, '><')
322
336
 
@@ -0,0 +1,250 @@
1
+ // lib/generation-helpers.js
2
+ // Extracted execution and prompt/caching helpers for generate_image tool (#349).
3
+
4
+ import path from 'node:path'
5
+ import { mkdir, writeFile } from 'node:fs/promises'
6
+ import {
7
+ computeGenerationHash,
8
+ IMAGE_SIZES,
9
+ OUTPUT_FORMATS,
10
+ PROVIDER_KEYS,
11
+ buildSidecar,
12
+ ASPECT_RATIOS,
13
+ embedPngMetadata,
14
+ resolveStylePreset,
15
+ saveAttachmentSafe,
16
+ } from './providers.js'
17
+ import { enhancePrompt } from './prompt-enhancer.js'
18
+ import { buildFaceConsistencyPrompt, getSessionAnchor, applyAnchorPromptHints } from './anchor-helpers.js'
19
+ import { sanitizeNegativePrompt, supportsNegativePrompt } from './negative-sanitizer.js'
20
+ import { recordSpend } from './cost-meter.js'
21
+ import { setCachedGeneration } from './generation-cache.js'
22
+ import {
23
+ readHistory,
24
+ writeHistory,
25
+ pruneHistory,
26
+ } from './history.js'
27
+
28
+ /** Run an array of async task functions with a concurrency cap */
29
+ export async function asyncPool(tasks, concurrency = 3) {
30
+ const results = new Array(tasks.length)
31
+ let nextIdx = 0
32
+ async function worker() {
33
+ while (nextIdx < tasks.length) {
34
+ const idx = nextIdx++
35
+ results[idx] = await tasks[idx]()
36
+ }
37
+ }
38
+ const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())
39
+ await Promise.all(workers)
40
+ return results
41
+ }
42
+
43
+ /** Validates generation configuration and arguments */
44
+ export function validateGenerationParams(cfg, args) {
45
+ if (cfg.enabled === false) {
46
+ throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
47
+ }
48
+ const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
49
+ const deliverAs = cfg.deliverAs
50
+ const size = args.aspect_ratio ? 'custom' : (args.image_size ?? cfg.defaultSize)
51
+ if (!args.aspect_ratio && !IMAGE_SIZES.includes(size)) {
52
+ throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
53
+ }
54
+ const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
55
+ const format = args.output_format ?? cfg.defaultFormat
56
+ if (!OUTPUT_FORMATS.includes(format)) {
57
+ throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
58
+ }
59
+ return { provider, deliverAs, size, aspectPixels, format }
60
+ }
61
+
62
+ /** Prepares enhanced prompt, style hints, facial identity, anchors, and sanitized negative prompt */
63
+ export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource }) {
64
+ const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
65
+ const effectiveStyle = args.style || cfg.stylePreset
66
+ const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
67
+ let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
68
+ let effectiveNegative = styleInfo.negativePrompt
69
+ const activeAnchor = getSessionAnchor(sessionId)
70
+ let resolvedFaceRef
71
+
72
+ const faceRefInput = args.face_reference || (activeAnchor && (activeAnchor.mode === 'face' || activeAnchor.mode === 'character') ? activeAnchor.image : undefined)
73
+ if (faceRefInput) {
74
+ try {
75
+ resolvedFaceRef = await resolveSource(ctx, exec, faceRefInput)
76
+ } catch (_err) {
77
+ /* non-fatal: face_reference resolve fallback */
78
+ resolvedFaceRef = { url: faceRefInput }
79
+ }
80
+ const facePlan = buildFaceConsistencyPrompt({
81
+ prompt: effectivePrompt,
82
+ faceStrength: args.face_strength || activeAnchor?.strength,
83
+ characterName: activeAnchor?.label,
84
+ })
85
+ effectivePrompt = facePlan.prompt
86
+ if (facePlan.negativePrompt) {
87
+ effectiveNegative = effectiveNegative ? `${effectiveNegative}, ${facePlan.negativePrompt}` : facePlan.negativePrompt
88
+ }
89
+ } else if (args.reference_image) {
90
+ effectivePrompt = `${effectivePrompt}, visual reference anchor`
91
+ } else if (activeAnchor) {
92
+ effectivePrompt = applyAnchorPromptHints(activeAnchor, effectivePrompt)
93
+ }
94
+
95
+ // #165: Negative prompt sanitizer for diffusion models
96
+ if (supportsNegativePrompt(provider, cfg.model || cfg.customModel)) {
97
+ effectiveNegative = sanitizeNegativePrompt({
98
+ positivePrompt: effectivePrompt,
99
+ userNegative: effectiveNegative,
100
+ autoInjectDefects: true,
101
+ })
102
+ }
103
+
104
+ return {
105
+ enhanced,
106
+ effectivePrompt,
107
+ effectiveNegative,
108
+ effectiveGuidance: styleInfo.guidanceScale,
109
+ styleInfo,
110
+ activeAnchor,
111
+ resolvedFaceRef,
112
+ }
113
+ }
114
+
115
+ /** Saves generated image to disk, sidecar, history, records spend, and builds return structure */
116
+ export async function saveGeneratedImageArtifact({
117
+ ctx,
118
+ exec,
119
+ cfg,
120
+ args,
121
+ gen,
122
+ promptArg,
123
+ jobSeed,
124
+ provider,
125
+ deliverAs,
126
+ size,
127
+ format,
128
+ effectiveNegative,
129
+ effectiveGuidance,
130
+ enhanced,
131
+ slugify,
132
+ estimatedCost,
133
+ totalCount,
134
+ }) {
135
+ const mediaType = gen.mediaType || 'image/png'
136
+ let bytes = gen.bytes
137
+ if (mediaType === 'image/png') {
138
+ bytes = embedPngMetadata(bytes, {
139
+ prompt: promptArg,
140
+ seed: gen.seed,
141
+ provider,
142
+ model: cfg.model || cfg.customModel,
143
+ negative_prompt: effectiveNegative,
144
+ guidance_scale: effectiveGuidance,
145
+ width: gen.width,
146
+ height: gen.height,
147
+ })
148
+ }
149
+ const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
150
+ const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
151
+ const name = `${stem}.${extension}`
152
+
153
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
154
+
155
+ const sessionCwd = exec?.agent?.session?.header?.cwd
156
+ const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
157
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
158
+ await mkdir(outDir, { recursive: true })
159
+ const filePath = path.join(outDir, name)
160
+ await writeFile(filePath, bytes)
161
+
162
+ // Sidecar: metadata written alongside image file, making the output
163
+ // directory self-documenting without external database dependencies.
164
+ await writeFile(
165
+ path.join(outDir, `${stem}.json`),
166
+ JSON.stringify(buildSidecar({
167
+ prompt: promptArg,
168
+ size,
169
+ format,
170
+ seed: gen.seed,
171
+ provider,
172
+ deliverAs,
173
+ width: gen.width || attachment.width,
174
+ height: gen.height || attachment.height,
175
+ mediaType,
176
+ attachmentId: attachment.attachmentId,
177
+ url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
178
+ cost: gen.cost,
179
+ }), null, 2),
180
+ )
181
+
182
+ const cacheHash = computeGenerationHash({
183
+ provider,
184
+ model: cfg.model || cfg.customModel,
185
+ prompt: promptArg,
186
+ seed: gen.seed,
187
+ size: args.image_size || cfg.defaultSize,
188
+ style: args.style || cfg.stylePreset,
189
+ })
190
+
191
+ // #170: Store in content-addressed disk cache
192
+ if (cfg.diskCache !== false) {
193
+ setCachedGeneration(cacheHash, {
194
+ bytes,
195
+ mediaType,
196
+ width: gen.width || attachment.width,
197
+ height: gen.height || attachment.height,
198
+ seed: gen.seed,
199
+ meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
200
+ })
201
+ }
202
+ const entry = {
203
+ path: filePath,
204
+ prompt: promptArg,
205
+ provider,
206
+ model: cfg.model,
207
+ size,
208
+ format,
209
+ seed: gen.seed,
210
+ cacheHash,
211
+ thumbnailUrl: localUrl,
212
+ width: gen.width || attachment.width,
213
+ height: gen.height || attachment.height,
214
+ mediaType,
215
+ bytes: attachment.bytes,
216
+ createdAt: new Date().toISOString(),
217
+ attachmentId: attachment.attachmentId,
218
+ }
219
+ let current = await readHistory()
220
+ current = await pruneHistory(current, cfg.pruneDays)
221
+ current.unshift(entry)
222
+ if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
223
+ await writeHistory(current)
224
+
225
+ // #167: Record spend in cost meter
226
+ recordSpend(gen.cost || (estimatedCost / totalCount), {
227
+ ctx,
228
+ meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
229
+ })
230
+
231
+ return {
232
+ path: filePath,
233
+ url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
234
+ width: gen.width || attachment.width,
235
+ height: gen.height || attachment.height,
236
+ seed: gen.seed,
237
+ prompt: promptArg,
238
+ ...(enhanced?.enhanced ? { originalPrompt: args.prompt } : {}),
239
+ cost: gen.cost,
240
+ format: mediaType.replace('image/', ''),
241
+ attachment: {
242
+ attachmentId: attachment.attachmentId,
243
+ mediaType: attachment.mediaType,
244
+ bytes: attachment.bytes,
245
+ width: attachment.width,
246
+ height: attachment.height,
247
+ name: attachment.name,
248
+ },
249
+ }
250
+ }
@@ -1,3 +1,4 @@
1
+ import { sanitizeErrorAndLogs } from './security.js'
1
2
  // lib/provider-utils.js
2
3
  // Shared provider utilities and size/format constants extracted from providers.js (#239)
3
4
 
@@ -234,11 +235,12 @@ export function formatErrorMessage(e, providerKey = '') {
234
235
  msg = msg.replace(/\[object Object\]/g, 'unknown error object').trim()
235
236
  }
236
237
 
238
+ const sanitized = sanitizeErrorAndLogs(msg)
237
239
  if (providerKey) {
238
- return `${providerKey}: ${msg}`
240
+ return `${providerKey}: ${sanitized}`
239
241
  }
240
242
 
241
- return msg
243
+ return sanitized
242
244
  }
243
245
 
244
246
  export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
@@ -1,4 +1,4 @@
1
- import { readFile, stat } from 'node:fs/promises'
1
+ import { readFile, stat, realpath } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { estimateSharpnessAndVariance } from './providers.js'
4
4
 
@@ -186,14 +186,24 @@ export async function resolveConversationImage(ctx, exec, targetRef) {
186
186
  }
187
187
 
188
188
  // 3. Local filesystem resolution with strict workspace containment check
189
- const root = sessionCwd ? path.resolve(sessionCwd) : process.cwd()
189
+ const root = path.resolve(sessionCwd ? sessionCwd : process.cwd())
190
190
  const absPath = path.resolve(root, effectiveRef)
191
191
  const rel = path.relative(root, absPath)
192
192
  if (rel.startsWith('..') || path.isAbsolute(rel)) {
193
193
  throw new Error(`Access denied: path "${effectiveRef}" is outside allowed workspace directory "${root}"`)
194
194
  }
195
195
 
196
- const fileStat = await stat(absPath).catch(() => null)
196
+ const realRoot = await realpath(root).catch(() => root)
197
+ const realTarget = await realpath(absPath).catch(() => null)
198
+ if (!realTarget) {
199
+ throw new Error(`Image file not found: ${effectiveRef}`)
200
+ }
201
+ const relReal = path.relative(realRoot, realTarget)
202
+ if (relReal.startsWith('..') || path.isAbsolute(relReal)) {
203
+ throw new Error(`Access denied: path "${effectiveRef}" points outside allowed workspace directory "${realRoot}"`)
204
+ }
205
+
206
+ const fileStat = await stat(realTarget).catch(() => null)
197
207
  if (!fileStat) {
198
208
  throw new Error(`Image file not found: ${effectiveRef}`)
199
209
  }
@@ -208,7 +218,7 @@ export async function resolveConversationImage(ctx, exec, targetRef) {
208
218
  throw new Error(`Image file too large (${(fileStat.size / (1024 * 1024)).toFixed(1)} MB). Maximum allowed size is 50 MB.`)
209
219
  }
210
220
 
211
- const bytes = await readFile(absPath)
221
+ const bytes = await readFile(realTarget)
212
222
  validateImageSignature(bytes, effectiveRef)
213
223
  const mediaType = detectMediaType(absPath, bytes)
214
224
  return {
@@ -1,7 +1,7 @@
1
1
  // lib/resolve-source.js — Safe image source resolution for image tools (#317)
2
2
 
3
3
  import path from 'node:path'
4
- import { readFile, stat } from 'node:fs/promises'
4
+ import { readFile, stat, realpath } from 'node:fs/promises'
5
5
  import { validateImageSignature } from './resolve-image.js'
6
6
 
7
7
  /** Read source image for editing: filesystem path or attachment id. */
@@ -41,7 +41,17 @@ export async function resolveSource(ctx, exec, ref) {
41
41
  throw new Error(`Access denied: path "${ref}" is outside allowed workspace directory "${root}"`)
42
42
  }
43
43
 
44
- const fileStat = await stat(target).catch(() => null)
44
+ const realRoot = await realpath(root).catch(() => root)
45
+ const realTarget = await realpath(target).catch(() => null)
46
+ if (!realTarget) {
47
+ throw new Error(`Source image not found: ${ref}`)
48
+ }
49
+ const relReal = path.relative(realRoot, realTarget)
50
+ if (relReal.startsWith('..') || path.isAbsolute(relReal)) {
51
+ throw new Error(`Access denied: path "${ref}" points outside allowed workspace directory "${realRoot}"`)
52
+ }
53
+
54
+ const fileStat = await stat(realTarget).catch(() => null)
45
55
  if (!fileStat) {
46
56
  throw new Error(`Source image not found: ${ref}`)
47
57
  }
@@ -55,7 +65,7 @@ export async function resolveSource(ctx, exec, ref) {
55
65
  throw new Error(`Source image too large (${(fileStat.size / (1024 * 1024)).toFixed(1)} MB). Maximum allowed size is 50 MB.`)
56
66
  }
57
67
 
58
- const bytes = await readFile(target)
68
+ const bytes = await readFile(realTarget)
59
69
  validateImageSignature(bytes, ref)
60
70
  const ext = path.extname(target).toLowerCase()
61
71
  const mediaType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
@@ -1,3 +1,4 @@
1
+ import { sanitizeErrorAndLogs } from '../security.js'
1
2
  // anchor.js — set_style_anchor tool (#283).
2
3
  // Manages persistent visual style & character reference anchors in session context.
3
4
 
@@ -69,6 +70,7 @@ export function registerAnchorTools(ctx, deps) {
69
70
  },
70
71
  isConcurrencySafe: () => true,
71
72
  async execute(args, exec) {
73
+ try {
72
74
  const cfg = live ? live() : {}
73
75
  if (cfg.enabled === false) {
74
76
  throw new Error('Image generation plugin is disabled in settings.')
@@ -121,6 +123,9 @@ export function registerAnchorTools(ctx, deps) {
121
123
  anchor,
122
124
  summary,
123
125
  })
126
+ } catch (err) {
127
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
128
+ }
124
129
  },
125
130
  }),
126
131
  )
@@ -1,3 +1,4 @@
1
+ import { sanitizeErrorAndLogs } from '../security.js'
1
2
  // character-sheet.js — generate_character_sheet tool (#181).
2
3
  // Generates multi-angle character model sheets and turnarounds with visual consistency.
3
4
 
@@ -1,3 +1,4 @@
1
+ import { sanitizeErrorAndLogs } from '../security.js'
1
2
  // diagram.js — beautify_diagram tool (#185).
2
3
  // Transforms raw Mermaid / Graphviz diagrams into high-end 3D isometric architectural illustrations.
3
4
 
@@ -1,3 +1,4 @@
1
+ import { sanitizeErrorAndLogs } from '../security.js'
1
2
  import { renderToolOutput } from "../attachment-helper.js"
2
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
3
4
  import { toLosslessJson } from '../providers.js'