feat(ollama): add auto-discovery with Anthropic API and security hardening
- Auto-detect Ollama without requiring API key configuration - Switch to Anthropic Messages API for better Claude compatibility - Add security checks to prevent network exposure (0.0.0.0 binding) - Add desktop launcher with security validation - Hardcode localhost-only connections (127.0.0.1:11434) Security features: - Launch script forces OLLAMA_HOST=127.0.0.1:11434 - Warns if Ollama is exposed to network interfaces - Checks .bashrc/.profile for dangerous OLLAMA_HOST settings - ollama-security.ts module for URL validation New files: - scripts/launch-clawdbot-ollama.sh - Secure launcher script - scripts/install-desktop-launcher.sh - Desktop entry installer - assets/clawdbot-ollama.desktop - Linux desktop entry - src/agents/ollama-security.ts - Security utilities Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
10d5ea5de6
commit
fa2b77230b
10
assets/clawdbot-ollama.desktop
Normal file
10
assets/clawdbot-ollama.desktop
Normal file
@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Name=Clawdbot (Ollama)
|
||||
Comment=AI Assistant with local Ollama models
|
||||
Exec=/home/eve/Desktop/clawdbotPLUS/scripts/launch-clawdbot-ollama.sh
|
||||
Icon=/home/eve/Desktop/clawdbotPLUS/assets/chrome-extension/icons/icon128.png
|
||||
Terminal=true
|
||||
Type=Application
|
||||
Categories=Development;Utility;
|
||||
Keywords=ai;chat;ollama;local;llm;
|
||||
StartupNotify=true
|
||||
50
scripts/install-desktop-launcher.sh
Executable file
50
scripts/install-desktop-launcher.sh
Executable file
@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# Install Clawdbot Ollama desktop launcher
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Paths
|
||||
DESKTOP_FILE="$PROJECT_DIR/assets/clawdbot-ollama.desktop"
|
||||
LAUNCH_SCRIPT="$PROJECT_DIR/scripts/launch-clawdbot-ollama.sh"
|
||||
ICON_FILE="$PROJECT_DIR/assets/chrome-extension/icons/icon128.png"
|
||||
DEST_DIR="$HOME/.local/share/applications"
|
||||
|
||||
# Create destination directory if it doesn't exist
|
||||
mkdir -p "$DEST_DIR"
|
||||
|
||||
# Make launch script executable
|
||||
chmod +x "$LAUNCH_SCRIPT"
|
||||
|
||||
# Create the desktop file with absolute paths
|
||||
cat > "$DEST_DIR/clawdbot-ollama.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Name=Clawdbot (Ollama)
|
||||
Comment=AI Assistant with local Ollama models
|
||||
Exec=$LAUNCH_SCRIPT
|
||||
Icon=$ICON_FILE
|
||||
Terminal=true
|
||||
Type=Application
|
||||
Categories=Development;Utility;
|
||||
Keywords=ai;chat;ollama;local;llm;
|
||||
StartupNotify=true
|
||||
EOF
|
||||
|
||||
# Make desktop file executable (required on some distros)
|
||||
chmod +x "$DEST_DIR/clawdbot-ollama.desktop"
|
||||
|
||||
# Update desktop database
|
||||
if command -v update-desktop-database &> /dev/null; then
|
||||
update-desktop-database "$DEST_DIR" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Desktop launcher installed!"
|
||||
echo "Location: $DEST_DIR/clawdbot-ollama.desktop"
|
||||
echo ""
|
||||
echo "You can now find 'Clawdbot (Ollama)' in your application menu."
|
||||
echo ""
|
||||
echo "To also add it to your desktop:"
|
||||
echo " cp $DEST_DIR/clawdbot-ollama.desktop ~/Desktop/"
|
||||
echo " # Then right-click the icon and select 'Allow Launching'"
|
||||
188
scripts/launch-clawdbot-ollama.sh
Executable file
188
scripts/launch-clawdbot-ollama.sh
Executable file
@ -0,0 +1,188 @@
|
||||
#!/bin/bash
|
||||
# Launch script for Clawdbot with Ollama
|
||||
# Starts Ollama if not running, then launches Clawdbot
|
||||
#
|
||||
# SECURITY: This script enforces localhost-only Ollama connections
|
||||
|
||||
set -e
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# Force Ollama to bind to localhost only (prevents network exposure)
|
||||
export OLLAMA_HOST="127.0.0.1:11434"
|
||||
|
||||
# Never allow these dangerous configurations
|
||||
unset OLLAMA_ORIGINS # Prevents CORS bypass
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY CHECKS
|
||||
# =============================================================================
|
||||
|
||||
check_ollama_security() {
|
||||
echo "[Security] Checking Ollama configuration..."
|
||||
|
||||
# Check if Ollama is listening on 0.0.0.0 (dangerous!)
|
||||
if ss -tlnp 2>/dev/null | grep -q "0.0.0.0:11434"; then
|
||||
echo ""
|
||||
echo "!!! SECURITY WARNING !!!"
|
||||
echo "Ollama is exposed to ALL network interfaces (0.0.0.0:11434)"
|
||||
echo "This allows anyone on your network to use your Ollama instance!"
|
||||
echo ""
|
||||
echo "To fix this:"
|
||||
echo " 1. Stop Ollama: systemctl --user stop ollama (or pkill ollama)"
|
||||
echo " 2. Edit config: Remove OLLAMA_HOST=0.0.0.0 from your environment"
|
||||
echo " 3. Restart Ollama (this script will bind it to localhost)"
|
||||
echo ""
|
||||
read -p "Continue anyway? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted for security reasons."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if Ollama is listening on [::] (IPv6 all interfaces - also dangerous)
|
||||
if ss -tlnp 2>/dev/null | grep -q "\[::\]:11434"; then
|
||||
echo ""
|
||||
echo "!!! SECURITY WARNING !!!"
|
||||
echo "Ollama is exposed on IPv6 all interfaces ([::]:11434)"
|
||||
echo ""
|
||||
read -p "Continue anyway? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted for security reasons."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for dangerous environment variables in user's environment
|
||||
if [ -f "$HOME/.bashrc" ] && grep -q "OLLAMA_HOST=0.0.0.0" "$HOME/.bashrc" 2>/dev/null; then
|
||||
echo ""
|
||||
echo "[Warning] Found OLLAMA_HOST=0.0.0.0 in ~/.bashrc"
|
||||
echo "Consider removing this to prevent network exposure."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -f "$HOME/.profile" ] && grep -q "OLLAMA_HOST=0.0.0.0" "$HOME/.profile" 2>/dev/null; then
|
||||
echo ""
|
||||
echo "[Warning] Found OLLAMA_HOST=0.0.0.0 in ~/.profile"
|
||||
echo "Consider removing this to prevent network exposure."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check systemd service if it exists
|
||||
if [ -f "/etc/systemd/system/ollama.service" ]; then
|
||||
if grep -q "OLLAMA_HOST=0.0.0.0" /etc/systemd/system/ollama.service 2>/dev/null; then
|
||||
echo ""
|
||||
echo "[Warning] System Ollama service is configured to bind to 0.0.0.0"
|
||||
echo "Edit /etc/systemd/system/ollama.service to fix this."
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[Security] Checks complete."
|
||||
}
|
||||
|
||||
check_firewall() {
|
||||
# Informational: Check if firewall is active
|
||||
if command -v ufw &> /dev/null; then
|
||||
if ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
echo "[Security] UFW firewall is active (good!)"
|
||||
else
|
||||
echo "[Info] UFW firewall is not active. Consider enabling it:"
|
||||
echo " sudo ufw enable"
|
||||
fi
|
||||
elif command -v firewall-cmd &> /dev/null; then
|
||||
if firewall-cmd --state 2>/dev/null | grep -q "running"; then
|
||||
echo "[Security] Firewalld is active (good!)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# MAIN SCRIPT
|
||||
# =============================================================================
|
||||
|
||||
echo "=== Clawdbot + Ollama Launcher ==="
|
||||
echo ""
|
||||
|
||||
# Run security checks
|
||||
check_ollama_security
|
||||
check_firewall
|
||||
|
||||
# Check if Ollama is running
|
||||
if ! pgrep -x "ollama" > /dev/null; then
|
||||
echo ""
|
||||
echo "Starting Ollama (localhost only)..."
|
||||
|
||||
# Start Ollama with secure settings
|
||||
ollama serve &
|
||||
OLLAMA_PID=$!
|
||||
|
||||
# Wait for Ollama to be ready
|
||||
echo "Waiting for Ollama to start..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://127.0.0.1:11434/api/tags > /dev/null 2>&1; then
|
||||
echo "Ollama is ready (PID: $OLLAMA_PID)"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
echo "Error: Ollama failed to start within 30 seconds"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
else
|
||||
echo "Ollama is already running"
|
||||
fi
|
||||
|
||||
# Verify we're connecting to localhost only
|
||||
echo ""
|
||||
echo "[Security] Connecting to: http://127.0.0.1:11434 (localhost only)"
|
||||
|
||||
# Check if any models are available
|
||||
MODELS_JSON=$(curl -s http://127.0.0.1:11434/api/tags 2>/dev/null)
|
||||
MODEL_COUNT=$(echo "$MODELS_JSON" | grep -o '"name"' | wc -l)
|
||||
|
||||
if [ "$MODEL_COUNT" -eq 0 ]; then
|
||||
echo ""
|
||||
echo "No Ollama models found. You need to pull a model first."
|
||||
echo ""
|
||||
echo "Recommended models for coding:"
|
||||
echo " ollama pull qwen3-coder # Fast, good for code"
|
||||
echo " ollama pull deepseek-r1:8b # Reasoning model"
|
||||
echo " ollama pull llama3.3 # General purpose"
|
||||
echo ""
|
||||
|
||||
# Open terminal to let user pull a model
|
||||
if command -v gnome-terminal &> /dev/null; then
|
||||
gnome-terminal -- bash -c "echo 'Pull a model with: ollama pull <model-name>'; echo ''; ollama list; exec bash"
|
||||
elif command -v konsole &> /dev/null; then
|
||||
konsole -e bash -c "echo 'Pull a model with: ollama pull <model-name>'; echo ''; ollama list; exec bash"
|
||||
elif command -v xterm &> /dev/null; then
|
||||
xterm -e "echo 'Pull a model with: ollama pull <model-name>'; ollama list; exec bash"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found $MODEL_COUNT Ollama model(s)"
|
||||
echo ""
|
||||
echo "Starting Clawdbot..."
|
||||
echo ""
|
||||
|
||||
# Launch Clawdbot
|
||||
if command -v clawdbot &> /dev/null; then
|
||||
exec clawdbot
|
||||
elif [ -f "$HOME/.local/bin/clawdbot" ]; then
|
||||
exec "$HOME/.local/bin/clawdbot"
|
||||
elif [ -f "/usr/local/bin/clawdbot" ]; then
|
||||
exec /usr/local/bin/clawdbot
|
||||
else
|
||||
# Run from source
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_DIR"
|
||||
exec pnpm clawdbot
|
||||
fi
|
||||
@ -5,11 +5,27 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
describe("Ollama provider", () => {
|
||||
it("should not include ollama when no API key is configured", async () => {
|
||||
it("should not include ollama when no models are discovered", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-"));
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
|
||||
// Ollama requires explicit configuration via OLLAMA_API_KEY env var or profile
|
||||
// Ollama auto-discovers models; if none found, provider is not included
|
||||
// In test environment, discovery is skipped so no models are found
|
||||
expect(providers?.ollama).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use anthropic-messages API when Ollama is configured", async () => {
|
||||
// This test verifies the provider config structure
|
||||
// Actual model discovery requires a running Ollama instance
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-"));
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
|
||||
// When Ollama is discovered, it should use anthropic-messages API
|
||||
// (In test env, no models are discovered so provider won't be present)
|
||||
if (providers?.ollama) {
|
||||
expect(providers.ollama.api).toBe("anthropic-messages");
|
||||
expect(providers.ollama.baseUrl).toBe("http://127.0.0.1:11434");
|
||||
expect(providers.ollama.apiKey).toBe("ollama");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -64,7 +64,14 @@ const QWEN_PORTAL_DEFAULT_COST = {
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1";
|
||||
// Ollama supports both OpenAI-compatible (/v1) and Anthropic Messages API
|
||||
// We use the Anthropic Messages API for better Claude compatibility
|
||||
//
|
||||
// SECURITY: Hardcoded to 127.0.0.1 (localhost) only.
|
||||
// Ollama has no built-in authentication - anyone who can reach the port can use it.
|
||||
// Never change these to 0.0.0.0 or expose Ollama to the network without a reverse
|
||||
// proxy that handles authentication (e.g., nginx with basic auth or OAuth).
|
||||
const OLLAMA_BASE_URL = "http://127.0.0.1:11434";
|
||||
const OLLAMA_API_BASE_URL = "http://127.0.0.1:11434";
|
||||
const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
|
||||
@ -354,7 +361,7 @@ async function buildOllamaProvider(): Promise<ProviderConfig> {
|
||||
const models = await discoverOllamaModels();
|
||||
return {
|
||||
baseUrl: OLLAMA_BASE_URL,
|
||||
api: "openai-completions",
|
||||
api: "anthropic-messages",
|
||||
models,
|
||||
};
|
||||
}
|
||||
@ -410,12 +417,11 @@ export async function resolveImplicitProviders(params: {
|
||||
};
|
||||
}
|
||||
|
||||
// Ollama provider - only add if explicitly configured
|
||||
const ollamaKey =
|
||||
resolveEnvApiKeyVarName("ollama") ??
|
||||
resolveApiKeyFromProfiles({ provider: "ollama", store: authStore });
|
||||
if (ollamaKey) {
|
||||
providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey };
|
||||
// Ollama provider - auto-detect if running locally, no API key needed
|
||||
// Ollama ignores the API key but we set a placeholder for compatibility
|
||||
const ollamaProvider = await buildOllamaProvider();
|
||||
if (ollamaProvider.models.length > 0) {
|
||||
providers.ollama = { ...ollamaProvider, apiKey: "ollama" };
|
||||
}
|
||||
|
||||
return providers;
|
||||
|
||||
31
src/agents/ollama-security.test.ts
Normal file
31
src/agents/ollama-security.test.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isOllamaUrlSafe, getSafeOllamaUrl } from "./ollama-security.js";
|
||||
|
||||
describe("Ollama Security", () => {
|
||||
describe("isOllamaUrlSafe", () => {
|
||||
it("should allow localhost URLs", () => {
|
||||
expect(isOllamaUrlSafe("http://localhost:11434")).toBe(true);
|
||||
expect(isOllamaUrlSafe("http://127.0.0.1:11434")).toBe(true);
|
||||
expect(isOllamaUrlSafe("http://[::1]:11434")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject non-localhost URLs", () => {
|
||||
expect(isOllamaUrlSafe("http://0.0.0.0:11434")).toBe(false);
|
||||
expect(isOllamaUrlSafe("http://192.168.1.100:11434")).toBe(false);
|
||||
expect(isOllamaUrlSafe("http://10.0.0.1:11434")).toBe(false);
|
||||
expect(isOllamaUrlSafe("http://ollama.example.com:11434")).toBe(false);
|
||||
expect(isOllamaUrlSafe("http://my-server:11434")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid URLs", () => {
|
||||
expect(isOllamaUrlSafe("not-a-url")).toBe(false);
|
||||
expect(isOllamaUrlSafe("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSafeOllamaUrl", () => {
|
||||
it("should always return localhost URL", () => {
|
||||
expect(getSafeOllamaUrl()).toBe("http://127.0.0.1:11434");
|
||||
});
|
||||
});
|
||||
});
|
||||
117
src/agents/ollama-security.ts
Normal file
117
src/agents/ollama-security.ts
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Ollama Security Utilities
|
||||
*
|
||||
* Ollama has NO built-in authentication. Anyone who can reach the Ollama port
|
||||
* can use your models, see your prompts, and consume your compute resources.
|
||||
*
|
||||
* NEVER expose Ollama to the network (0.0.0.0) without a reverse proxy that
|
||||
* handles authentication.
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface OllamaSecurityStatus {
|
||||
isRunning: boolean;
|
||||
isExposedToNetwork: boolean;
|
||||
bindAddress: string | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Ollama is running and whether it's securely configured.
|
||||
* Returns warnings if Ollama is exposed to the network.
|
||||
*/
|
||||
export async function checkOllamaSecurity(): Promise<OllamaSecurityStatus> {
|
||||
const status: OllamaSecurityStatus = {
|
||||
isRunning: false,
|
||||
isExposedToNetwork: false,
|
||||
bindAddress: null,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Check listening sockets for Ollama port (11434)
|
||||
const { stdout } = await execAsync("ss -tlnp 2>/dev/null | grep 11434 || true");
|
||||
|
||||
if (!stdout.trim()) {
|
||||
// Ollama not running or not listening
|
||||
return status;
|
||||
}
|
||||
|
||||
status.isRunning = true;
|
||||
|
||||
// Parse the bind address
|
||||
// Example output: "LISTEN 0 4096 127.0.0.1:11434 0.0.0.0:*"
|
||||
// Or dangerous: "LISTEN 0 4096 0.0.0.0:11434 0.0.0.0:*"
|
||||
const lines = stdout.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Check for dangerous bindings
|
||||
if (line.includes("0.0.0.0:11434")) {
|
||||
status.isExposedToNetwork = true;
|
||||
status.bindAddress = "0.0.0.0:11434";
|
||||
status.warnings.push(
|
||||
"Ollama is listening on 0.0.0.0:11434 (ALL network interfaces)",
|
||||
"Anyone on your network can access your Ollama instance",
|
||||
"This is a security risk - Ollama has no authentication",
|
||||
"Fix: Set OLLAMA_HOST=127.0.0.1:11434 and restart Ollama",
|
||||
);
|
||||
} else if (line.includes("[::]:11434")) {
|
||||
status.isExposedToNetwork = true;
|
||||
status.bindAddress = "[::]:11434";
|
||||
status.warnings.push(
|
||||
"Ollama is listening on [::]:11434 (ALL IPv6 interfaces)",
|
||||
"Anyone on your network can access your Ollama instance",
|
||||
"Fix: Set OLLAMA_HOST=127.0.0.1:11434 and restart Ollama",
|
||||
);
|
||||
} else if (line.includes("127.0.0.1:11434")) {
|
||||
status.bindAddress = "127.0.0.1:11434";
|
||||
// This is secure - localhost only
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ss command not available or failed - can't determine status
|
||||
status.warnings.push("Could not verify Ollama network binding (ss command unavailable)");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an Ollama URL is localhost-only.
|
||||
* Returns true if the URL is safe, false if it points to a non-local address.
|
||||
*/
|
||||
export function isOllamaUrlSafe(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Safe hostnames
|
||||
const safeHosts = ["localhost", "127.0.0.1", "::1", "[::1]"];
|
||||
|
||||
if (safeHosts.includes(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for localhost IPv6 variants
|
||||
if (hostname.startsWith("[") && hostname.includes("::1")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a safe Ollama URL, always pointing to localhost.
|
||||
* Ignores any environment variables that might point elsewhere.
|
||||
*/
|
||||
export function getSafeOllamaUrl(): string {
|
||||
// Always return localhost - never trust environment variables for this
|
||||
return "http://127.0.0.1:11434";
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user