Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,16 @@ jobs:
electron-cache-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- name: Package (code signing)
run: pnpm run package
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
- name: Package
shell: pwsh
run: |
if ("${{ secrets.CSC_LINK }}") {
$env:CSC_LINK = "${{ secrets.CSC_LINK }}"
}
if ("${{ secrets.CSC_KEY_PASSWORD }}") {
$env:CSC_KEY_PASSWORD = "${{ secrets.CSC_KEY_PASSWORD }}"
}
pnpm run package -- --publish never
- name: Generate checksums
shell: pwsh
run: |
Expand Down Expand Up @@ -102,19 +107,28 @@ jobs:
electron-cache-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- name: Package (code signing & notarization)
- name: Package
run: |
if [ -n "${{ secrets.CSC_LINK }}" ]; then
export CSC_LINK="${{ secrets.CSC_LINK }}"
fi
if [ -n "${{ secrets.CSC_KEY_PASSWORD }}" ]; then
export CSC_KEY_PASSWORD="${{ secrets.CSC_KEY_PASSWORD }}"
fi
if [ -n "${{ secrets.APPLE_ID }}" ]; then
export APPLE_ID="${{ secrets.APPLE_ID }}"
fi
if [ -n "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" ]; then
export APPLE_APP_SPECIFIC_PASSWORD="${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}"
fi
if [ -n "${{ secrets.APPLE_TEAM_ID }}" ]; then
export APPLE_TEAM_ID="${{ secrets.APPLE_TEAM_ID }}"
fi
if [ -n "$APPLE_ID" ] && [ -n "$APPLE_APP_SPECIFIC_PASSWORD" ] && [ -n "$APPLE_TEAM_ID" ]; then
echo "Notarization secrets found, enabling notarize"
npx json -I -f electron-builder.json -e 'this.mac.notarize=true'
fi
pnpm run package
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
pnpm run package -- --publish never
- name: Generate checksums
run: |
cd release
Expand Down Expand Up @@ -156,7 +170,7 @@ jobs:
electron-cache-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm run package
- run: pnpm run package -- --publish never
- name: Generate checksums
run: |
cd release
Expand Down
18 changes: 17 additions & 1 deletion electron/ipc/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
import { ipcMain } from 'electron'
import { ipcMain, dialog } from 'electron'
import { ipcHandler } from '../services/IpcHandlerFactory'
import * as DeployService from '../services/DeployService'
import type { DeployPlatform, VercelLink, RailwayLink } from '../shared/types'

async function confirmTokenStorage(platform: 'vercel' | 'railway'): Promise<void> {
const label = platform === 'vercel' ? 'Vercel' : 'Railway'
const { response } = await dialog.showMessageBox({
type: 'warning',
buttons: ['Cancel', `Store ${label} Token`],
defaultId: 0,
cancelId: 0,
title: `Connect ${label}`,
message: `Store a ${label} access token in DAEMON?`,
detail: 'This grants deployment-related access from the renderer through the preload bridge.',
})
if (response === 0) throw new Error(`${label} connection cancelled by user`)
}

export function registerDeployHandlers() {
ipcMain.handle('deploy:auth-status', ipcHandler(async () => {
return DeployService.getAuthStatus()
}))

ipcMain.handle('deploy:connect-vercel', ipcHandler(async (_event, token: string) => {
await confirmTokenStorage('vercel')
const user = await DeployService.validateVercelToken(token)
DeployService.storeToken('vercel', token)
return user
}))

ipcMain.handle('deploy:connect-railway', ipcHandler(async (_event, token: string) => {
await confirmTokenStorage('railway')
const user = await DeployService.validateRailwayToken(token)
DeployService.storeToken('railway', token)
return user
Expand Down
31 changes: 30 additions & 1 deletion electron/ipc/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
updateSettings,
} from '../services/EmailService'
import { ipcHandler } from '../services/IpcHandlerFactory'
import { ValidationService } from '../services/ValidationService'

export function registerEmailHandlers() {
ipcMain.handle('email:accounts', ipcHandler(async () => {
Expand Down Expand Up @@ -52,7 +53,35 @@ export function registerEmailHandlers() {
}))

ipcMain.handle('email:send', ipcHandler(async (_event, accountId: string, to: string, subject: string, body: string, cc?: string, bcc?: string) => {
return await sendEmail(accountId, { to, subject, body, cc, bcc })
const accountResult = ValidationService.validateString(accountId, 1, 256)
if (!accountResult.success) throw new Error(accountResult.errors?.[0] ?? 'Invalid account ID')

const toResult = ValidationService.validateEmailList(to, true)
if (!toResult.success) throw new Error(toResult.errors?.[0] ?? 'Invalid recipient')

const ccResult = ValidationService.validateEmailList(cc)
if (!ccResult.success) throw new Error(ccResult.errors?.[0] ?? 'Invalid cc recipient')

const bccResult = ValidationService.validateEmailList(bcc)
if (!bccResult.success) throw new Error(bccResult.errors?.[0] ?? 'Invalid bcc recipient')

const subjectResult = ValidationService.validateString(subject, 1, 998)
if (!subjectResult.success) throw new Error(subjectResult.errors?.[0] ?? 'Invalid subject')

const bodyResult = ValidationService.validateString(body, 1, 100_000)
if (!bodyResult.success) throw new Error(bodyResult.errors?.[0] ?? 'Invalid body')

if (!ValidationService.checkRateLimit(`email-send:${accountResult.data}`, 10, 60_000)) {
throw new Error('Email send rate limit exceeded for this account. Please wait a minute.')
}

return await sendEmail(accountResult.data!, {
to: toResult.data!,
subject: subjectResult.data!,
body: bodyResult.data!,
cc: ccResult.data,
bcc: bccResult.data,
})
}))

ipcMain.handle('email:mark-read', ipcHandler(async (_event, accountId: string, messageIds: string[]) => {
Expand Down
40 changes: 40 additions & 0 deletions electron/ipc/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ import * as Vault from '../services/VaultService'

const MAX_FILE_SIZE = 1024 * 1024 // 1 MB — vault is for keys/creds, not large files

async function confirmVaultAction(options: {
title: string
message: string
detail: string
confirmLabel: string
}): Promise<void> {
const { response } = await dialog.showMessageBox({
type: 'warning',
buttons: ['Cancel', options.confirmLabel],
defaultId: 0,
cancelId: 0,
title: options.title,
message: options.message,
detail: options.detail,
})
if (response === 0) throw new Error(`${options.title} cancelled by user`)
}

export function registerVaultHandlers() {
ipcMain.handle('vault:list', ipcHandler(async () => {
return Vault.listFiles()
Expand All @@ -22,16 +40,38 @@ export function registerVaultHandlers() {
if (Buffer.byteLength(opts.data, 'utf8') > MAX_FILE_SIZE) throw new Error('File too large (max 1 MB)')
const validTypes = ['keypair', 'env', 'credential', 'seed_phrase', 'other']
const fileType = validTypes.includes(opts.fileType) ? opts.fileType : 'other'
await confirmVaultAction({
title: 'Store Vault Secret',
message: `Store "${opts.name}" in the vault?`,
detail: `Type: ${fileType}\nThis will persist encrypted secret material on this machine.`,
confirmLabel: 'Store Secret',
})
return Vault.storeFile({ name: opts.name, data: opts.data, fileType, ownerWallet: opts.ownerWallet })
}))

ipcMain.handle('vault:retrieve', ipcHandler(async (_event, id: string) => {
if (!id || typeof id !== 'string') throw new Error('Invalid vault file id')
const meta = Vault.getFileMeta(id)
if (!meta) throw new Error('Vault file not found')
await confirmVaultAction({
title: 'Reveal Vault Secret',
message: `Reveal "${meta.name}" from the vault?`,
detail: `Type: ${meta.file_type}\nThis will decrypt and return the secret to the renderer.`,
confirmLabel: 'Reveal Secret',
})
return Vault.retrieveFile(id)
}))

ipcMain.handle('vault:delete', ipcHandler(async (_event, id: string) => {
if (!id || typeof id !== 'string') throw new Error('Invalid vault file id')
const meta = Vault.getFileMeta(id)
if (!meta) throw new Error('Vault file not found')
await confirmVaultAction({
title: 'Delete Vault Secret',
message: `Delete "${meta.name}" from the vault?`,
detail: 'This removes the encrypted record from local storage and cannot be undone.',
confirmLabel: 'Delete Secret',
})
Vault.deleteFile(id)
}))

Expand Down
38 changes: 32 additions & 6 deletions electron/ipc/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ import { ipcHandler } from '../services/IpcHandlerFactory'
import { ValidationService } from '../services/ValidationService'
import type { WalletCreateInput, WalletGenerateInput, TransferSOLInput, TransferTokenInput } from '../shared/types'

async function confirmSensitiveAction(options: {
title: string
message: string
detail: string
confirmLabel: string
}): Promise<void> {
const { response } = await dialog.showMessageBox({
type: 'warning',
buttons: ['Cancel', options.confirmLabel],
defaultId: 0,
cancelId: 0,
title: options.title,
message: options.message,
detail: options.detail,
})
if (response === 0) throw new Error(`${options.title} cancelled by user`)
}

export function registerWalletHandlers() {
ipcMain.handle('wallet:dashboard', ipcHandler(async (_event, projectId?: string | null) => {
return await WalletService.getDashboard(projectId)
Expand Down Expand Up @@ -54,10 +72,22 @@ export function registerWalletHandlers() {
}))

ipcMain.handle('wallet:send-sol', ipcHandler(async (_event, input: TransferSOLInput) => {
await confirmSensitiveAction({
title: 'Send SOL',
message: `Send ${input.amountSol} SOL to ${input.toAddress}?`,
detail: 'This action signs and broadcasts a real transaction from the selected wallet.',
confirmLabel: 'Send SOL',
})
return await WalletService.transferSOL(input.fromWalletId, input.toAddress, input.amountSol)
}))

ipcMain.handle('wallet:send-token', ipcHandler(async (_event, input: TransferTokenInput) => {
await confirmSensitiveAction({
title: 'Send Token',
message: `Send ${input.amount} tokens to ${input.toAddress}?`,
detail: `Mint: ${input.mint}\nThis action signs and broadcasts a real token transfer.`,
confirmLabel: 'Send Token',
})
return await WalletService.transferToken(input.fromWalletId, input.toAddress, input.mint, input.amount)
}))

Expand Down Expand Up @@ -131,16 +161,12 @@ export function registerWalletHandlers() {
throw new Error('Too many export attempts. Please wait 5 minutes.')
}

const { response } = await dialog.showMessageBox({
type: 'warning',
buttons: ['Cancel', 'Export Key'],
defaultId: 0,
cancelId: 0,
await confirmSensitiveAction({
title: 'Export Private Key',
message: 'This will expose your private key in plaintext.',
detail: 'Only proceed if you understand the security implications.',
confirmLabel: 'Export Key',
})
if (response === 0) throw new Error('Export cancelled by user')

const keyString = await WalletService.exportPrivateKey(walletId)
clipboard.writeText(keyString)
Expand Down
2 changes: 1 addition & 1 deletion electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ async function createWindow() {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': ["default-src 'self' minipaint:; script-src 'self' minipaint: 'unsafe-eval'; style-src 'self' 'unsafe-inline' minipaint:; img-src 'self' data: daemon-icon: minipaint:; worker-src 'self' blob: monaco-editor: minipaint:; connect-src 'self' https://*.anthropic.com https://*.helius-rpc.com https://price.jup.ag https://api.coingecko.com; font-src 'self' minipaint:; frame-src minipaint:; object-src 'none'"]
'Content-Security-Policy': ["default-src 'self' minipaint:; script-src 'self' minipaint:; style-src 'self' 'unsafe-inline' minipaint:; img-src 'self' data: daemon-icon: minipaint:; worker-src 'self' blob: monaco-editor: minipaint:; connect-src 'self' https://*.anthropic.com https://*.helius-rpc.com https://price.jup.ag https://api.coingecko.com; font-src 'self' minipaint:; frame-src minipaint:; object-src 'none'"]
}
})
})
Expand Down
89 changes: 82 additions & 7 deletions electron/services/BrowserService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isIP } from 'node:net'
import { pluginPrompt, orchestratedPrompt } from './PluginPrompt'
import type { BrowserPage, BrowserNavResult, BrowserAnalysis } from '../shared/types'

Expand All @@ -13,13 +14,87 @@ function nextPageId(): string {

// --- URL Safety ---

function isCloudMetadataUrl(urlStr: string): boolean {
function isIpv4InCidr(ip: string, network: string, prefixLength: number): boolean {
const octets = ip.split('.').map(Number)
const networkOctets = network.split('.').map(Number)
if (octets.length !== 4 || networkOctets.length !== 4 || octets.some(Number.isNaN) || networkOctets.some(Number.isNaN)) {
return false
}

let ipValue = 0
let networkValue = 0
for (let i = 0; i < 4; i++) {
ipValue = (ipValue << 8) + octets[i]
networkValue = (networkValue << 8) + networkOctets[i]
}

const mask = prefixLength === 0 ? 0 : (0xffffffff << (32 - prefixLength)) >>> 0
return (ipValue & mask) === (networkValue & mask)
}

const BLOCKED_IPV4_RANGES: Array<{ network: string; prefixLength: number }> = [
{ network: '0.0.0.0', prefixLength: 8 },
{ network: '10.0.0.0', prefixLength: 8 },
{ network: '127.0.0.0', prefixLength: 8 },
{ network: '169.254.0.0', prefixLength: 16 },
{ network: '172.16.0.0', prefixLength: 12 },
{ network: '192.168.0.0', prefixLength: 16 },
{ network: '100.64.0.0', prefixLength: 10 },
{ network: '198.18.0.0', prefixLength: 15 },
{ network: '224.0.0.0', prefixLength: 4 },
{ network: '240.0.0.0', prefixLength: 4 },
{ network: '168.63.129.16', prefixLength: 32 },
]

export function isBlockedBrowserHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase()
if (!normalized) return true

if (
normalized === 'localhost'
|| normalized.endsWith('.localhost')
|| normalized === 'metadata.google.internal'
|| normalized === 'metadata.azure.internal'
|| normalized === 'kubernetes'
|| normalized.endsWith('.local')
|| normalized.endsWith('.internal')
|| normalized.endsWith('.localdomain')
|| normalized.endsWith('.home.arpa')
|| normalized.endsWith('.localdomain')
|| normalized.endsWith('.cluster.local')
) {
return true
}

const ipType = isIP(normalized)
if (ipType === 4) {
return BLOCKED_IPV4_RANGES.some(({ network, prefixLength }) => isIpv4InCidr(normalized, network, prefixLength))
}

if (ipType === 6) {
return normalized === '::1'
|| normalized === '::'
|| normalized.startsWith('fe80:')
|| normalized.startsWith('fc')
|| normalized.startsWith('fd')
|| normalized.startsWith('ff')
|| normalized === '::ffff:127.0.0.1'
|| normalized.startsWith('::ffff:10.')
|| normalized.startsWith('::ffff:192.168.')
|| /^::ffff:172\.(1[6-9]|2\d|3[0-1])\./.test(normalized)
|| normalized === '::ffff:169.254.169.254'
|| normalized === '::ffff:168.63.129.16'
}

return false
}

export function isBlockedBrowserUrl(urlStr: string): boolean {
try {
const parsed = new URL(urlStr)
const hostname = parsed.hostname
// Block cloud metadata endpoints only — real SSRF vectors
if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') return true
return false
if (!['http:', 'https:'].includes(parsed.protocol)) return true
if (parsed.username || parsed.password) return true
return isBlockedBrowserHost(parsed.hostname)
} catch { return true }
}

Expand All @@ -31,8 +106,8 @@ export async function navigate(url: string): Promise<BrowserNavResult> {
url = `https://${url}`
}

if (isCloudMetadataUrl(url)) {
throw new Error('Navigation to cloud metadata endpoints is blocked')
if (isBlockedBrowserUrl(url)) {
throw new Error('Navigation to private, local, or metadata endpoints is blocked')
}

// Create a page entry with the URL — actual content comes from webview via capturePageContent
Expand Down
Loading