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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ jobs:
echo "Notarization secrets found, enabling notarize"
npx json -I -f electron-builder.json -e 'this.mac.notarize=true'
fi
pnpm exec electron-builder --publish never
pnpm exec electron-builder --mac dmg zip --arm64 --x64 --publish never
- name: Generate checksums
run: |
cd release
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ DAEMON is a standalone Electron IDE designed around AI agent workflows. It ships

<a name="mac-install"></a>

**Mac:** Build from source (signed builds coming soon):
**Mac:** Build from source (signed builds configurable via Apple credentials):

```bash
git clone https://github.com/nullxnothing/daemon.git
Expand All @@ -52,7 +52,7 @@ pnpm run build
pnpm run package
```

The `.dmg` will be in `release/2.0.0/`. Drag to Applications. On first launch, right-click > Open to bypass Gatekeeper (not yet signed/notarized).
The `.dmg` will be in `release/2.0.0/`. Signed/notarized builds require Apple Developer credentials in the packaging environment. Without them, the app will still package, but Gatekeeper may require right-click > Open on first launch.

<a name="linux-install"></a>

Expand Down
26 changes: 20 additions & 6 deletions build/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
# Build Configuration

## Code Signing (Optional)
## Code Signing

### Windows
Set `CSC_LINK` to the path/base64 of your .pfx certificate and `CSC_KEY_PASSWORD` to the password.
Set `CSC_LINK` to the path/base64 of your `.pfx` certificate and `CSC_KEY_PASSWORD` to the password.

### macOS
Set `CSC_LINK` to the path/base64 of your .p12 certificate, `CSC_KEY_PASSWORD`, and for notarization: `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`.
DAEMON now supports env-gated signing and notarization during packaging.

### GitHub Actions
Add these as repository secrets. The release workflow will use them automatically.
Without signing secrets, builds are unsigned (fine for development).
Required for signed mac builds:
- `CSC_LINK`: path or base64 content for your `.p12` Developer ID Application certificate
- `CSC_KEY_PASSWORD`: password for the `.p12`

Required for notarization:
- `APPLE_ID`
- `APPLE_APP_SPECIFIC_PASSWORD`
- `APPLE_TEAM_ID`

Behavior:
- if the Apple env vars are missing, mac builds still package, but notarization is skipped
- if the certificate env vars are missing, mac builds remain unsigned
- no secrets are stored in the repo

### CI
Add the signing and notarization secrets to your CI environment before publishing mac releases.
Without signing secrets, builds are still suitable for development but will not be Gatekeeper-clean.
42 changes: 42 additions & 0 deletions build/notarize.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { notarize } from '@electron/notarize'

const REQUIRED_ENV_VARS = [
'APPLE_ID',
'APPLE_APP_SPECIFIC_PASSWORD',
'APPLE_TEAM_ID',
]

function hasNotarizeEnv() {
return REQUIRED_ENV_VARS.every((key) => Boolean(process.env[key]))
}

export default async function afterSign(context) {
if (process.platform !== 'darwin') {
return
}

if (!hasNotarizeEnv()) {
console.log('[notarize] Skipping notarization; missing Apple credentials in environment')
return
}

const { appOutDir, electronPlatformName, packager } = context
if (electronPlatformName !== 'darwin') {
return
}

const appName = packager.appInfo.productFilename
const appBundleId = packager.appInfo.id

console.log(`[notarize] Submitting ${appName}.app for notarization`)

await notarize({
appBundleId,
appPath: `${appOutDir}/${appName}.app`,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
teamId: process.env.APPLE_TEAM_ID,
})

console.log('[notarize] Notarization completed')
}
5 changes: 5 additions & 0 deletions codemagic.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ workflows:
node: 22
groups:
- cloudflare
# Optional: add a Codemagic group that provides
# CSC_LINK, CSC_KEY_PASSWORD, APPLE_ID,
# APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID
# when you are ready to ship signed/notarized mac builds.
# - apple-signing
triggering:
events:
- tag
Expand Down
5 changes: 3 additions & 2 deletions electron-builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@
"target": ["dmg", "zip"],
"category": "public.app-category.developer-tools",
"artifactName": "${productName}-${arch}.${ext}",
"identity": null,
"notarize": false
"hardenedRuntime": true,
"gatekeeperAssess": false
},
"afterSign": "build/notarize.mjs",
"win": {
"target": [
{
Expand Down
53 changes: 39 additions & 14 deletions electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ import { registerColosseumHandlers } from '../ipc/colosseum'
import { registerVaultHandlers } from '../ipc/vault'
import { registerValidatorHandlers } from '../ipc/validator'
import { registerPnlHandlers } from '../ipc/pnl'
import { registerFeedbackHandlers } from '../ipc/feedback'
import { clearLoadedWallets } from '../services/RecoveryService'
import pkg from 'electron-updater'
const { autoUpdater } = pkg
import { registerFeedbackHandlers } from '../ipc/feedback'
import { clearLoadedWallets } from '../services/RecoveryService'
import { isBlockedBrowserUrl } from '../services/BrowserService'
import pkg from 'electron-updater'
const { autoUpdater } = pkg

const __dirname = path.dirname(fileURLToPath(import.meta.url))

Expand Down Expand Up @@ -106,10 +107,14 @@ if (!SMOKE_TEST_MODE && !app.requestSingleInstanceLock()) {
process.exit(0)
}

let win: BrowserWindow | null = null
let ipcRegistered = false
const preload = path.join(__dirname, '../preload/index.mjs')
const indexHtml = path.join(RENDERER_DIST, 'index.html')
let win: BrowserWindow | null = null
let ipcRegistered = false
const preload = path.join(__dirname, '../preload/index.mjs')
const indexHtml = path.join(RENDERER_DIST, 'index.html')

function isBlockedWebviewNavigation(url: string): boolean {
return isBlockedBrowserUrl(url)
}

function registerAllIpc() {
if (ipcRegistered) return
Expand Down Expand Up @@ -301,12 +306,32 @@ async function createWindow() {
})

// Enforce security on webview creation from main process
win.webContents.on('will-attach-webview', (_event, webPreferences) => {
webPreferences.nodeIntegration = false
webPreferences.contextIsolation = true
webPreferences.sandbox = true
delete (webPreferences as Record<string, unknown>).preload
})
win.webContents.on('will-attach-webview', (event, webPreferences, params) => {
webPreferences.nodeIntegration = false
webPreferences.contextIsolation = true
webPreferences.sandbox = true
delete (webPreferences as Record<string, unknown>).preload

if (params.src && isBlockedWebviewNavigation(params.src)) {
event.preventDefault()
}
})

win.webContents.on('did-attach-webview', (_event, contents) => {
contents.setWindowOpenHandler(({ url }) => {
if (isBlockedWebviewNavigation(url)) {
return { action: 'deny' }
}
void shell.openExternal(url)
return { action: 'deny' }
})

contents.on('will-navigate', (event, url) => {
if (isBlockedWebviewNavigation(url)) {
event.preventDefault()
}
})
})

// Block navigation away from app origin (XSS defense)
win.webContents.on('will-navigate', (event, url) => {
Expand Down
88 changes: 81 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,86 @@ 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('.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 +105,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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "daemon",
"version": "2.0.5",
"version": "2.0.6",
"main": "dist-electron/main/index.js",
"description": "Custom Electron IDE for AI-native development",
"author": "nullxnothing",
Expand Down Expand Up @@ -55,6 +55,7 @@
"smol-toml": "^1.6.1"
},
"devDependencies": {
"@electron/notarize": "^2.2.1",
"@electron/rebuild": "^3.7.1",
"@monaco-editor/react": "^4.7.0",
"@types/better-sqlite3": "^7.6.12",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 16 additions & 10 deletions src/panels/BlockScanner/BlockScanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,30 @@ export default function BlockScanner() {
const [canGoForward, setCanGoForward] = useState(false)
const webviewRef = useRef<WebviewElement | null>(null)

const navigate = useCallback((target: string) => {
setUrl(target)
if (webviewRef.current) {
webviewRef.current.src = target
const navigate = useCallback(async (target: string) => {
try {
const res = await window.daemon.browser.navigate(target)
if (!res.ok || !res.data) return
setUrl(target)
if (webviewRef.current) {
webviewRef.current.src = target
}
} catch {
// blocked by main-process browser safety policy
}
}, [])

const handleSearch = () => {
const handleSearch = async () => {
const q = search.trim()
if (!q) {
navigate(clusterBase(cluster))
await navigate(clusterBase(cluster))
return
}
// Tx signatures are 87-88 base58 chars, addresses are 32-44
if (q.length > 60) {
navigate(txUrl(cluster, q))
await navigate(txUrl(cluster, q))
} else {
navigate(addressUrl(cluster, q))
await navigate(addressUrl(cluster, q))
}
setSearch('')
}
Expand Down Expand Up @@ -96,9 +102,9 @@ export default function BlockScanner() {
useEffect(() => {
if (!url) {
const initial = clusterBase(cluster)
setUrl(initial)
void navigate(initial)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
}, [cluster, navigate, url])

const webviewProps = {
ref: webviewRef as React.Ref<HTMLElement>,
Expand Down
Loading