mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
* Security hardening: fix critical and high-severity vulnerabilities (#18) * feat(mcp): add sanitizeObject utility to strip prototype pollution keys Recursively removes __proto__, constructor, and prototype keys from parsed JSON objects to prevent prototype pollution attacks via malicious .op files or batch_design DSL input. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(electron): validate file path in saveToPath IPC handler Prevent path traversal attacks by checking for null bytes and restricting file extensions to .op and .pen only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(mcp): guard against prototype pollution in document parsing and batch design Sanitize JSON.parse output in openDocument() and parseJsonArg() to strip __proto__, constructor, and prototype keys before processing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ai): sanitize debug logs and harden temp file handling Filter credential patterns from debug tail before sending to client. Set restrictive 0o700 permissions on temp directory. Validate attachment media types against allowlist to prevent extension spoofing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ai): restrict environment variables passed to codex subprocess Replace full process.env with explicit allowlist of PATH, HOME, TERM, LANG, SHELL, TMPDIR, and OPENAI_*/CODEX_* prefixed vars only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(figma): add decompression size limits to prevent zip bombs Enforce 100MB total unzipped size and 50MB per-image limits during .fig file extraction to guard against malicious archives. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(canvas): add dangerous SVG tags to skip list and fix ReDoS in getAttr Strip script, foreignObject, animate, animateMotion, and set elements during SVG import. Escape regex-special characters in style attribute name lookup to prevent ReDoS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(security): add unit tests for security hardening fixes 27 tests covering: sanitizeObject prototype pollution stripping, document-manager sanitization, batch-design DSL sanitization, codex env allowlist, debug tail credential filtering, media type validation, SVG skip tags, and ReDoS safety. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat(mcp): add live canvas sync and HTTP transport support Introduce real-time MCP ↔ renderer sync via SSE (server/api/mcp endpoints, use-mcp-sync hook, mcp-sync-state). Add StreamableHTTPServerTransport for HTTP and dual stdio+http modes. Electron writes ~/.openpencil/.port for MCP discovery. New design_prompt tool. Agent settings dialog gains transport mode selector. Security: restrict Electron file writes to home/temp dirs. Bump version to 0.1.2. --------- Co-authored-by: RolandSherwin <RolandSherwin@protonmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
270 lines
6.9 KiB
TypeScript
270 lines
6.9 KiB
TypeScript
import { spawn } from 'node:child_process'
|
|
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
type ThinkingMode = 'adaptive' | 'disabled' | 'enabled'
|
|
type ThinkingEffort = 'low' | 'medium' | 'high' | 'max'
|
|
|
|
interface CodexExecOptions {
|
|
model?: string
|
|
systemPrompt?: string
|
|
thinkingMode?: ThinkingMode
|
|
thinkingBudgetTokens?: number
|
|
effort?: ThinkingEffort
|
|
timeoutMs?: number
|
|
/** Paths to temporary image files to reference in the prompt */
|
|
imageFiles?: string[]
|
|
}
|
|
|
|
interface CodexCliResult {
|
|
text?: string
|
|
error?: string
|
|
}
|
|
|
|
const DEFAULT_CODEX_TIMEOUT_MS = 15 * 60 * 1000
|
|
|
|
/**
|
|
* Allowlist-based env filter for Codex CLI subprocess.
|
|
* Only passes through safe system vars and provider-specific prefixes.
|
|
* Prevents leaking secrets like ANTHROPIC_API_KEY, AWS_SECRET_KEY, GITHUB_TOKEN, etc.
|
|
*/
|
|
const CODEX_ENV_ALLOWLIST = new Set(['PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR'])
|
|
|
|
export function filterCodexEnv(
|
|
env: Record<string, string | undefined>,
|
|
): Record<string, string | undefined> {
|
|
const result: Record<string, string | undefined> = {}
|
|
for (const [k, v] of Object.entries(env)) {
|
|
if (CODEX_ENV_ALLOWLIST.has(k) || k.startsWith('OPENAI_') || k.startsWith('CODEX_')) {
|
|
result[k] = v
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
export async function runCodexExec(
|
|
userPrompt: string,
|
|
options: CodexExecOptions = {},
|
|
): Promise<CodexCliResult> {
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-codex-'))
|
|
const outputPath = join(tempDir, 'last-message.txt')
|
|
const prompt = buildPrompt(options.systemPrompt, userPrompt, options.imageFiles)
|
|
const codexEffort = resolveCodexEffort(options.thinkingMode, options.effort)
|
|
|
|
const args = [
|
|
'exec',
|
|
'--json',
|
|
'--skip-git-repo-check',
|
|
'--sandbox',
|
|
'read-only',
|
|
'--output-last-message',
|
|
outputPath,
|
|
]
|
|
|
|
if (options.model) {
|
|
args.push('--model', options.model)
|
|
}
|
|
|
|
if (codexEffort) {
|
|
args.push('--config', `model_reasoning_effort="${codexEffort}"`)
|
|
}
|
|
|
|
args.push(prompt)
|
|
|
|
try {
|
|
const runResult = await executeCodexCommand(
|
|
args,
|
|
options.timeoutMs ?? DEFAULT_CODEX_TIMEOUT_MS,
|
|
)
|
|
const finalText = await readFile(outputPath, 'utf-8').catch(() => '')
|
|
const normalizedText = finalText.trim() || runResult.text.trim()
|
|
|
|
if (normalizedText) {
|
|
return { text: normalizedText }
|
|
}
|
|
|
|
if (runResult.errors.length > 0) {
|
|
return { error: runResult.errors.join('; ') }
|
|
}
|
|
|
|
return { error: 'Codex returned no output.' }
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : 'Codex execution failed' }
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
|
}
|
|
}
|
|
|
|
function buildPrompt(systemPrompt: string | undefined, userPrompt: string, imageFiles?: string[]): string {
|
|
const userText = userPrompt.trim()
|
|
const imageSection = imageFiles && imageFiles.length > 0
|
|
? '\n' + imageFiles.map((f) => `[Attached image: ${f} — read this file to see the image]`).join('\n')
|
|
: ''
|
|
|
|
if (!systemPrompt?.trim()) {
|
|
return userText + imageSection
|
|
}
|
|
|
|
return [
|
|
'SYSTEM INSTRUCTIONS:',
|
|
systemPrompt.trim(),
|
|
'',
|
|
'USER REQUEST:',
|
|
userText + imageSection,
|
|
].join('\n')
|
|
}
|
|
|
|
function resolveCodexEffort(
|
|
thinkingMode: ThinkingMode | undefined,
|
|
effort: ThinkingEffort | undefined,
|
|
): 'low' | 'medium' | 'high' | undefined {
|
|
if (thinkingMode === 'disabled') {
|
|
return 'low'
|
|
}
|
|
|
|
if (effort === 'max') {
|
|
return 'high'
|
|
}
|
|
|
|
if (effort === 'low' || effort === 'medium' || effort === 'high') {
|
|
return effort
|
|
}
|
|
|
|
if (thinkingMode === 'enabled') {
|
|
return 'medium'
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
async function executeCodexCommand(
|
|
args: string[],
|
|
timeoutMs: number,
|
|
): Promise<{ text: string; errors: string[] }> {
|
|
return await new Promise((resolve, reject) => {
|
|
const child = spawn('codex', args, {
|
|
env: filterCodexEnv(process.env as Record<string, string | undefined>),
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
})
|
|
|
|
let stdoutBuffer = ''
|
|
let stderrBuffer = ''
|
|
let textAccumulator = ''
|
|
const errors: string[] = []
|
|
|
|
const flushStdoutLine = (line: string) => {
|
|
const event = parseCodexJsonLine(line)
|
|
if (!event) return
|
|
if (event.text) {
|
|
textAccumulator += event.text
|
|
}
|
|
if (event.error) {
|
|
errors.push(event.error)
|
|
}
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
child.kill('SIGTERM')
|
|
reject(new Error(`Codex request timed out after ${Math.round(timeoutMs / 1000)}s.`))
|
|
}, timeoutMs)
|
|
|
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
stdoutBuffer += chunk.toString('utf-8')
|
|
let idx = stdoutBuffer.indexOf('\n')
|
|
while (idx >= 0) {
|
|
const line = stdoutBuffer.slice(0, idx).trim()
|
|
stdoutBuffer = stdoutBuffer.slice(idx + 1)
|
|
if (line) flushStdoutLine(line)
|
|
idx = stdoutBuffer.indexOf('\n')
|
|
}
|
|
})
|
|
|
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
stderrBuffer += chunk.toString('utf-8')
|
|
})
|
|
|
|
child.on('error', (err) => {
|
|
clearTimeout(timer)
|
|
reject(err)
|
|
})
|
|
|
|
child.on('close', (code) => {
|
|
clearTimeout(timer)
|
|
|
|
const tail = stdoutBuffer.trim()
|
|
if (tail) {
|
|
flushStdoutLine(tail)
|
|
}
|
|
|
|
if (code === 0) {
|
|
resolve({ text: textAccumulator, errors })
|
|
return
|
|
}
|
|
|
|
const stderrError = extractCodexCliError(stderrBuffer)
|
|
const fallback = errors[errors.length - 1]
|
|
reject(
|
|
new Error(
|
|
stderrError
|
|
|| fallback
|
|
|| `Codex exited with code ${code ?? 'unknown'}.`,
|
|
),
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
function parseCodexJsonLine(
|
|
line: string,
|
|
): { text?: string; error?: string } | null {
|
|
let parsed: Record<string, unknown>
|
|
try {
|
|
parsed = JSON.parse(line) as Record<string, unknown>
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
const type = typeof parsed.type === 'string' ? parsed.type : ''
|
|
if (type === 'error') {
|
|
const message = getStringField(parsed, ['message'])
|
|
return { error: message || 'Codex returned an unknown error.' }
|
|
}
|
|
|
|
// Common Codex JSONL stream events include deltas in "delta" or "text".
|
|
const text =
|
|
getStringField(parsed, ['delta'])
|
|
|| getStringField(parsed, ['text'])
|
|
|| getStringField(parsed, ['content'])
|
|
|
|
if (!text) return null
|
|
return { text }
|
|
}
|
|
|
|
function getStringField(
|
|
obj: Record<string, unknown>,
|
|
keys: string[],
|
|
): string | null {
|
|
for (const key of keys) {
|
|
const val = obj[key]
|
|
if (typeof val === 'string' && val.length > 0) {
|
|
return val
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function extractCodexCliError(stderr: string): string | null {
|
|
const trimmed = stderr.trim()
|
|
if (!trimmed) return null
|
|
|
|
const lines = trimmed.split('\n').map((line) => line.trim()).filter(Boolean)
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const line = lines[i]
|
|
if (line.toLowerCase().startsWith('error:')) {
|
|
return line.replace(/^error:\s*/i, '').trim()
|
|
}
|
|
}
|
|
|
|
return lines[lines.length - 1] ?? null
|
|
}
|