diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ee20b6f7..bf994ddb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/README.md b/README.md
index ca7ceb1c..163bbf5e 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ DAEMON is a standalone Electron IDE designed around AI agent workflows. It ships
-**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
@@ -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.
diff --git a/build/README.md b/build/README.md
index 6a5aed2c..a5094c28 100644
--- a/build/README.md
+++ b/build/README.md
@@ -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.
diff --git a/build/notarize.mjs b/build/notarize.mjs
new file mode 100644
index 00000000..82f51718
--- /dev/null
+++ b/build/notarize.mjs
@@ -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')
+}
diff --git a/codemagic.yaml b/codemagic.yaml
index 79b76e05..87c7ebc3 100644
--- a/codemagic.yaml
+++ b/codemagic.yaml
@@ -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
diff --git a/electron-builder.json b/electron-builder.json
index 16726e1b..33da3a0a 100644
--- a/electron-builder.json
+++ b/electron-builder.json
@@ -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": [
{
diff --git a/electron/main/index.ts b/electron/main/index.ts
index 835d2a59..d35b3814 100644
--- a/electron/main/index.ts
+++ b/electron/main/index.ts
@@ -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))
@@ -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
@@ -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).preload
- })
+ win.webContents.on('will-attach-webview', (event, webPreferences, params) => {
+ webPreferences.nodeIntegration = false
+ webPreferences.contextIsolation = true
+ webPreferences.sandbox = true
+ delete (webPreferences as Record).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) => {
diff --git a/electron/services/BrowserService.ts b/electron/services/BrowserService.ts
index 317fe75f..d94a9efb 100644
--- a/electron/services/BrowserService.ts
+++ b/electron/services/BrowserService.ts
@@ -1,3 +1,4 @@
+import { isIP } from 'node:net'
import { pluginPrompt, orchestratedPrompt } from './PluginPrompt'
import type { BrowserPage, BrowserNavResult, BrowserAnalysis } from '../shared/types'
@@ -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 }
}
@@ -31,8 +105,8 @@ export async function navigate(url: string): Promise {
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
diff --git a/package.json b/package.json
index f1d6de03..72ff46aa 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 214499da..dc31f879 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -83,6 +83,9 @@ importers:
specifier: ^1.6.1
version: 1.6.1
devDependencies:
+ '@electron/notarize':
+ specifier: ^2.2.1
+ version: 2.2.1
'@electron/rebuild':
specifier: ^3.7.1
version: 3.7.2
diff --git a/src/panels/BlockScanner/BlockScanner.tsx b/src/panels/BlockScanner/BlockScanner.tsx
index 08701753..155e1f6e 100644
--- a/src/panels/BlockScanner/BlockScanner.tsx
+++ b/src/panels/BlockScanner/BlockScanner.tsx
@@ -41,24 +41,30 @@ export default function BlockScanner() {
const [canGoForward, setCanGoForward] = useState(false)
const webviewRef = useRef(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('')
}
@@ -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,
diff --git a/src/panels/BrowserMode/BrowserMode.tsx b/src/panels/BrowserMode/BrowserMode.tsx
index 115af12b..c8d6b3ec 100644
--- a/src/panels/BrowserMode/BrowserMode.tsx
+++ b/src/panels/BrowserMode/BrowserMode.tsx
@@ -17,15 +17,13 @@ export function BrowserMode() {
const loadStatus = useBrowserStore((s) => s.loadStatus)
const canGoBack = useBrowserStore((s) => s.canGoBack)
const canGoForward = useBrowserStore((s) => s.canGoForward)
- const setUrl = useBrowserStore((s) => s.setUrl)
const setInspectMode = useBrowserStore((s) => s.setInspectMode)
const handleNavigate = useCallback(
(url: string) => {
- setUrl(url)
webviewRef.current?.navigate(url)
},
- [setUrl]
+ []
)
const handleBack = useCallback(() => {
diff --git a/src/panels/BrowserMode/BrowserWebview.tsx b/src/panels/BrowserMode/BrowserWebview.tsx
index e7d5acec..bd91fd9b 100644
--- a/src/panels/BrowserMode/BrowserWebview.tsx
+++ b/src/panels/BrowserMode/BrowserWebview.tsx
@@ -57,22 +57,24 @@ export const BrowserWebview = forwardRef(function BrowserW
async (url: string) => {
const normalized = normalizeUrl(url)
if (!normalized) return
- setUrl(normalized)
setLoadStatus('loading')
- isNavigated.current = true
// Create page cache entry in main process and store the pageId
try {
const res = await window.daemon.browser.navigate(normalized)
- if (res.ok && res.data) {
- useBrowserStore.getState().setLastPageId(res.data.pageId)
+ if (!res.ok || !res.data) {
+ setLoadStatus('error')
+ return
}
- } catch {
- // Cache entry creation failed — capture will use fallback ID
- }
- if (webviewRef.current) {
- webviewRef.current.src = normalized
+ useBrowserStore.getState().setLastPageId(res.data.pageId)
+ setUrl(normalized)
+ isNavigated.current = true
+ if (webviewRef.current) {
+ webviewRef.current.src = normalized
+ }
+ } catch {
+ setLoadStatus('error')
}
},
[normalizeUrl, setUrl, setLoadStatus]
diff --git a/test/services/BrowserService.test.ts b/test/services/BrowserService.test.ts
new file mode 100644
index 00000000..63d753aa
--- /dev/null
+++ b/test/services/BrowserService.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest'
+import { isBlockedBrowserHost, isBlockedBrowserUrl, navigate } from '../../electron/services/BrowserService'
+
+describe('BrowserService SSRF guard', () => {
+ it('blocks private, loopback, metadata, and cluster-local hosts', () => {
+ for (const host of [
+ 'localhost',
+ 'app.localhost',
+ '127.0.0.1',
+ '10.0.0.42',
+ '172.16.8.9',
+ '192.168.1.10',
+ '169.254.169.254',
+ '168.63.129.16',
+ '::1',
+ 'fe80::1',
+ 'fd00::1',
+ 'metadata.google.internal',
+ 'metadata.azure.internal',
+ 'kubernetes.default.svc.cluster.local',
+ ]) {
+ expect(isBlockedBrowserHost(host)).toBe(true)
+ }
+ })
+
+ it('allows normal public hosts', () => {
+ for (const host of ['example.com', 'solana.com', 'api.github.com', '1.1.1.1']) {
+ expect(isBlockedBrowserHost(host)).toBe(false)
+ }
+ })
+
+ it('rejects unsafe URLs and accepts public https URLs', () => {
+ expect(isBlockedBrowserUrl('file:///etc/passwd')).toBe(true)
+ expect(isBlockedBrowserUrl('http://localhost:3000')).toBe(true)
+ expect(isBlockedBrowserUrl('https://user:pass@example.com')).toBe(true)
+ expect(isBlockedBrowserUrl('https://example.com')).toBe(false)
+ })
+
+ it('throws on blocked navigation targets', async () => {
+ await expect(navigate('http://127.0.0.1:8899')).rejects.toThrow(
+ 'Navigation to private, local, or metadata endpoints is blocked'
+ )
+ })
+})