@goodandready/dsh-image-gen 0.11.33 → 0.11.34
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.
- package/lib/attachment-helper.js +2 -1
- package/lib/frontend-assets.js +5 -4
- package/lib/generation-helpers.js +2 -1
- package/lib/security.js +66 -6
- package/lib/tools/frontend.js +2 -2
- package/lib/tools/generation.js +2 -1
- package/lib/tools/processing-advanced.js +2 -2
- package/lib/tools/responsive.js +2 -2
- package/lib/vault.js +31 -1
- package/package.json +1 -1
package/lib/attachment-helper.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { saveAttachmentSafe, buildSidecar, toLosslessJson, ensureImageFormat } from './providers.js'
|
|
4
|
+
import { resolveInside } from './security.js'
|
|
4
5
|
import { recordSpend } from './cost-meter.js'
|
|
5
6
|
import { buildDualOutputMarkdown } from './resolve-image.js'
|
|
6
7
|
|
|
@@ -29,7 +30,7 @@ export async function saveAndAttachResult(ctx, exec, cfg, {
|
|
|
29
30
|
|
|
30
31
|
const sessionCwd = exec?.agent?.session?.header?.cwd
|
|
31
32
|
const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
|
|
32
|
-
const outDir =
|
|
33
|
+
const outDir = resolveInside(sessionCwd || process.cwd(), targetDir)
|
|
33
34
|
await mkdir(outDir, { recursive: true })
|
|
34
35
|
const filePath = path.join(outDir, name)
|
|
35
36
|
await writeFile(filePath, bytes)
|
package/lib/frontend-assets.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveInside } from './security.js'
|
|
1
2
|
import sharp from 'sharp'
|
|
2
3
|
// @ts-check
|
|
3
4
|
import path from 'node:path'
|
|
@@ -712,20 +713,20 @@ export async function generatePwaIconSuite({
|
|
|
712
713
|
// 6. Write files to outDir if provided
|
|
713
714
|
const exportedFiles = []
|
|
714
715
|
if (outDir) {
|
|
715
|
-
const fullIconsDir =
|
|
716
|
+
const fullIconsDir = resolveInside(outDir, iconsDir || 'icons')
|
|
716
717
|
await fs.mkdir(fullIconsDir, { recursive: true })
|
|
717
718
|
|
|
718
719
|
for (const item of generatedIcons) {
|
|
719
|
-
const filePath =
|
|
720
|
+
const filePath = resolveInside(outDir, item.filename)
|
|
720
721
|
await fs.writeFile(filePath, item.bytes)
|
|
721
722
|
exportedFiles.push(filePath)
|
|
722
723
|
}
|
|
723
724
|
|
|
724
|
-
const icoPath =
|
|
725
|
+
const icoPath = resolveInside(outDir, faviconIcoFilename)
|
|
725
726
|
await fs.writeFile(icoPath, faviconIcoBuffer)
|
|
726
727
|
exportedFiles.push(icoPath)
|
|
727
728
|
|
|
728
|
-
const manifestPath =
|
|
729
|
+
const manifestPath = resolveInside(outDir, 'manifest.json')
|
|
729
730
|
await fs.writeFile(manifestPath, manifestJson)
|
|
730
731
|
exportedFiles.push(manifestPath)
|
|
731
732
|
}
|
|
@@ -20,6 +20,7 @@ import { enhancePrompt } from './prompt-enhancer.js'
|
|
|
20
20
|
import { buildFaceConsistencyPrompt, getSessionAnchor, applyAnchorPromptHints } from './anchor-helpers.js'
|
|
21
21
|
import { sanitizeNegativePrompt, supportsNegativePrompt } from './negative-sanitizer.js'
|
|
22
22
|
import { recordSpend } from './cost-meter.js'
|
|
23
|
+
import { resolveInside } from './security.js'
|
|
23
24
|
import { setCachedGeneration } from './generation-cache.js'
|
|
24
25
|
import {
|
|
25
26
|
readHistory,
|
|
@@ -183,7 +184,7 @@ export async function saveGeneratedImageArtifact({
|
|
|
183
184
|
|
|
184
185
|
const sessionCwd = exec?.agent?.session?.header?.cwd
|
|
185
186
|
const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
|
|
186
|
-
const outDir =
|
|
187
|
+
const outDir = resolveInside(sessionCwd || process.cwd(), targetDir)
|
|
187
188
|
await mkdir(outDir, { recursive: true })
|
|
188
189
|
const filePath = path.join(outDir, name)
|
|
189
190
|
await writeFile(filePath, bytes)
|
package/lib/security.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// Address validation and trusted request guards against DNS rebinding & cross-site leaks (Refs: GitHub #1, #280, #276, #277)
|
|
4
|
-
|
|
5
|
-
import { chmodSync, existsSync } from 'node:fs'
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { chmodSync, existsSync, realpathSync } from 'node:fs'
|
|
6
3
|
|
|
7
4
|
/**
|
|
8
5
|
* Masks sensitive API keys, leaving only the prefix/suffix.
|
|
@@ -233,4 +230,67 @@ export function isTrustedLocalRequest(req) {
|
|
|
233
230
|
}
|
|
234
231
|
|
|
235
232
|
return true
|
|
236
|
-
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Resolves candidatePath strictly inside baseDir.
|
|
237
|
+
* If candidatePath is absolute or contains '..', it must resolve inside baseDir.
|
|
238
|
+
* Both lexical containment check and realpath check (for baseDir and existing targets/ancestors)
|
|
239
|
+
* are performed to prevent symlink and lexical directory traversal attacks (#409, #410, #411, #412, #420).
|
|
240
|
+
*
|
|
241
|
+
* @param {string} baseDir Base directory (e.g. sessionCwd or workspace root)
|
|
242
|
+
* @param {string} candidatePath Relative or sub-path to resolve
|
|
243
|
+
* @returns {string} Absolute resolved path guaranteed to be contained within baseDir
|
|
244
|
+
* @throws {Error} if candidatePath escapes baseDir
|
|
245
|
+
*/
|
|
246
|
+
export function resolveInside(baseDir, candidatePath) {
|
|
247
|
+
const root = path.resolve(baseDir || '.')
|
|
248
|
+
const sub = candidatePath ? String(candidatePath).trim() : '.'
|
|
249
|
+
const resolved = path.resolve(root, sub)
|
|
250
|
+
const rel = path.relative(root, resolved)
|
|
251
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
252
|
+
throw new Error(`Path traversal denied: "${candidatePath}" escapes base directory "${root}"`)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let realRoot = root
|
|
256
|
+
try {
|
|
257
|
+
realRoot = realpathSync(root)
|
|
258
|
+
} catch (_err) {
|
|
259
|
+
// If baseDir does not exist yet on disk, lexical containment check above is sufficient
|
|
260
|
+
return resolved
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const realTarget = realpathSync(resolved)
|
|
265
|
+
const relReal = path.relative(realRoot, realTarget)
|
|
266
|
+
if (relReal.startsWith('..') || path.isAbsolute(relReal)) {
|
|
267
|
+
throw new Error(`Path traversal denied: "${candidatePath}" resolves via symlink outside "${realRoot}"`)
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (err?.code === 'ENOENT') {
|
|
271
|
+
let curr = path.dirname(resolved)
|
|
272
|
+
while (curr === root || curr.startsWith(root + path.sep)) {
|
|
273
|
+
try {
|
|
274
|
+
const realCurr = realpathSync(curr)
|
|
275
|
+
const relCurr = path.relative(realRoot, realCurr)
|
|
276
|
+
if (relCurr.startsWith('..') || path.isAbsolute(relCurr)) {
|
|
277
|
+
throw new Error(`Path traversal denied: ancestor in "${candidatePath}" resolves via symlink outside "${realRoot}"`)
|
|
278
|
+
}
|
|
279
|
+
break
|
|
280
|
+
} catch (ancestorErr) {
|
|
281
|
+
if (ancestorErr?.code === 'ENOENT') {
|
|
282
|
+
const parent = path.dirname(curr)
|
|
283
|
+
if (parent === curr) break
|
|
284
|
+
curr = parent
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
throw ancestorErr
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
throw err
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return resolved
|
|
296
|
+
}
|
package/lib/tools/frontend.js
CHANGED
|
@@ -4,7 +4,7 @@ import { renderToolOutput } from "../attachment-helper.js"
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
6
6
|
import { toLosslessJson } from '../providers.js'
|
|
7
|
-
import { sanitizeErrorAndLogs } from '../security.js'
|
|
7
|
+
import { sanitizeErrorAndLogs, resolveInside } from '../security.js'
|
|
8
8
|
import {
|
|
9
9
|
extractDesignTokens,
|
|
10
10
|
generateCssGradient,
|
|
@@ -219,7 +219,7 @@ export function registerFrontendTools(ctx, deps) {
|
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
const sessionCwd = exec?.agent?.session?.header?.cwd || process.cwd()
|
|
222
|
-
const outDir =
|
|
222
|
+
const outDir = resolveInside(sessionCwd, args.out_dir || args.output_dir || '.')
|
|
223
223
|
|
|
224
224
|
const res = await generatePwaIconSuite({
|
|
225
225
|
name: args.name || 'App',
|
package/lib/tools/generation.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildPresentationMeta } from '../attachment-helper.js'
|
|
2
|
+
import { resolveInside } from '../security.js'
|
|
2
3
|
import { registerGenerationPackTool } from './generation-pack.js'
|
|
3
4
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
5
|
import path from 'node:path'
|
|
@@ -496,7 +497,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
|
|
|
496
497
|
const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: diskHit.bytes, mediaType: diskHit.mediaType, name })
|
|
497
498
|
const sessionCwd = exec?.agent?.session?.header?.cwd
|
|
498
499
|
const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
|
|
499
|
-
const outDir =
|
|
500
|
+
const outDir = resolveInside(sessionCwd || process.cwd(), targetDir)
|
|
500
501
|
await mkdir(outDir, { recursive: true })
|
|
501
502
|
const filePath = path.join(outDir, name)
|
|
502
503
|
await writeFile(filePath, diskHit.bytes)
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
toLosslessJson,
|
|
11
11
|
saveAttachmentSafe,
|
|
12
12
|
} from '../providers.js'
|
|
13
|
-
import { sanitizeErrorAndLogs } from '../security.js'
|
|
13
|
+
import { sanitizeErrorAndLogs, resolveInside } from '../security.js'
|
|
14
14
|
import { resolveConversationImage } from '../resolve-image.js'
|
|
15
15
|
|
|
16
16
|
export function registerProcessingAdvancedTools(ctx, deps) {
|
|
@@ -408,7 +408,7 @@ export function registerProcessingAdvancedTools(ctx, deps) {
|
|
|
408
408
|
async execute(args, exec) {
|
|
409
409
|
try {
|
|
410
410
|
const sessionCwd = exec?.agent?.session?.header?.cwd || process.cwd()
|
|
411
|
-
const targetDir =
|
|
411
|
+
const targetDir = resolveInside(sessionCwd, args.output_dir || './assets/branding')
|
|
412
412
|
await mkdir(targetDir, { recursive: true })
|
|
413
413
|
|
|
414
414
|
const projectName = args.project_name || 'Project Brand'
|
package/lib/tools/responsive.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
PROVIDER_KEYS,
|
|
14
14
|
} from '../providers.js'
|
|
15
15
|
import { trackAndAssertLoopGuard } from '../loop-guard.js'
|
|
16
|
-
import { sanitizeErrorAndLogs } from '../security.js'
|
|
16
|
+
import { sanitizeErrorAndLogs, resolveInside } from '../security.js'
|
|
17
17
|
import { buildDualOutputMarkdown } from '../resolve-image.js'
|
|
18
18
|
import {
|
|
19
19
|
buildResponsivePrompt,
|
|
@@ -119,7 +119,7 @@ export function registerResponsiveTools(ctx, deps) {
|
|
|
119
119
|
const paletteColors = Array.isArray(args.palette_colors) ? args.palette_colors : undefined
|
|
120
120
|
const sessionCwd = exec?.agent?.session?.header?.cwd
|
|
121
121
|
const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
|
|
122
|
-
const outDir =
|
|
122
|
+
const outDir = resolveInside(sessionCwd || process.cwd(), targetDir)
|
|
123
123
|
await mkdir(outDir, { recursive: true })
|
|
124
124
|
|
|
125
125
|
const pdeps = {
|
package/lib/vault.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import os from 'node:os'
|
|
1
3
|
import { existsSync, unlinkSync } from 'node:fs'
|
|
2
4
|
import { readHistory, writeHistory, withHistoryLock } from './history.js'
|
|
3
5
|
import { isTrustedLocalRequest } from './security.js'
|
|
@@ -131,7 +133,26 @@ export async function readBoundedRequestBody(req, maxBytes = MAX_VAULT_BODY_BYTE
|
|
|
131
133
|
return Buffer.concat(chunks)
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
|
|
136
|
+
|
|
137
|
+
export function isPathContainedInRoots(targetPath, allowedRoots = []) {
|
|
138
|
+
if (!targetPath || typeof targetPath !== 'string') return false
|
|
139
|
+
const resolvedTarget = path.resolve(targetPath)
|
|
140
|
+
for (const root of allowedRoots) {
|
|
141
|
+
if (!root) continue
|
|
142
|
+
try {
|
|
143
|
+
const resolvedRoot = path.resolve(root)
|
|
144
|
+
const rel = path.relative(resolvedRoot, resolvedTarget)
|
|
145
|
+
if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
} catch (_err) {
|
|
149
|
+
/* ignore invalid root */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function deleteVaultEntry(id, options = {}) {
|
|
135
156
|
if (!id) return { success: false, error: 'Missing entry id' }
|
|
136
157
|
return await withHistoryLock(async () => {
|
|
137
158
|
const entries = await readHistory()
|
|
@@ -140,8 +161,17 @@ export async function deleteVaultEntry(id) {
|
|
|
140
161
|
return { success: false, error: 'Vault entry not found' }
|
|
141
162
|
}
|
|
142
163
|
|
|
164
|
+
const allowedRoots = options.allowedRoots || [
|
|
165
|
+
process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
|
|
166
|
+
process.cwd(),
|
|
167
|
+
os.tmpdir(),
|
|
168
|
+
]
|
|
143
169
|
const [entry] = entries.splice(idx, 1)
|
|
144
170
|
if (entry && entry.path) {
|
|
171
|
+
if (!isPathContainedInRoots(entry.path, allowedRoots)) {
|
|
172
|
+
await writeHistory(entries)
|
|
173
|
+
return { success: false, id, error: `Refusing to delete file outside allowed roots: ${entry.path}` }
|
|
174
|
+
}
|
|
145
175
|
try {
|
|
146
176
|
unlinkSync(entry.path)
|
|
147
177
|
} catch (_err) { /* ignore missing file */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.34",
|
|
4
4
|
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|