diff --git a/SECURITY_ASSESSMENT_LOCAL.md b/SECURITY_ASSESSMENT_LOCAL.md index 1af27c233..e3a724cd3 100644 --- a/SECURITY_ASSESSMENT_LOCAL.md +++ b/SECURITY_ASSESSMENT_LOCAL.md @@ -78,6 +78,9 @@ This will: - Clear all model fallbacks. - Deny web tools, browser, and skills-install. - Enable Docker sandboxing for non-main sessions. +- Enforce network isolation for sandboxes (`network: "none"`). +- Refuse to load cloud-based model providers. +- Enable local skill vetting (omits skills with external URLs). ## Recommended Configuration for "Strictly Local" Setup @@ -85,6 +88,9 @@ If you prefer to make these settings permanent in your configuration, add/update ```json5 { + "security": { + "strictLocal": true + }, "update": { "checkOnStart": false }, @@ -115,5 +121,24 @@ If you prefer to make these settings permanent in your configuration, add/update } ``` +## Enhanced Local Features + +### Local TTS (Text-to-Speech) +Moltbot now supports CLI-based local TTS. You can use tools like **Piper** or **Sherpa-ONNX**: +```json5 +"messages": { + "tts": { + "provider": "cli", + "cli": { + "command": "piper", + "args": ["--model", "en_US-amy-medium.onnx", "--output_file", "{output}", "{text}"] + } + } +} +``` + +### Ollama Health Checks +Running `moltbot doctor` will now automatically check your local Ollama instance and list available models. + ## Summary Verdict Moltbot is **well-suited** for local-only usage, provided the configuration is hardened as described above. The core architecture is local-first, and there are no mandatory "phone home" features that cannot be disabled. diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index a176dac8a..59ce91453 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -361,8 +361,23 @@ async function buildOllamaProvider(): Promise { export async function resolveImplicitProviders(params: { agentDir: string; + config?: MoltbotConfig; }): Promise { const providers: Record = {}; + if (params.config?.security?.strictLocal) { + // In strict local mode, only allow Ollama if explicitly configured + const ollamaKey = + resolveEnvApiKeyVarName("ollama") ?? + resolveApiKeyFromProfiles({ + provider: "ollama", + store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }), + }); + if (ollamaKey) { + providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey }; + } + return providers; + } + const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }); @@ -423,8 +438,10 @@ export async function resolveImplicitProviders(params: { export async function resolveImplicitCopilotProvider(params: { agentDir: string; + config?: MoltbotConfig; env?: NodeJS.ProcessEnv; }): Promise { + if (params.config?.security?.strictLocal) return null; const env = params.env ?? process.env; const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }); const hasProfile = listProfilesForProvider(authStore, "github-copilot").length > 0; diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 03b70f0da..60af22449 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -80,7 +80,7 @@ export async function ensureMoltbotModelsJson( const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveMoltbotAgentDir(); const explicitProviders = (cfg.models?.providers ?? {}) as Record; - const implicitProviders = await resolveImplicitProviders({ agentDir }); + const implicitProviders = await resolveImplicitProviders({ agentDir, config: cfg }); const providers: Record = mergeProviders({ implicit: implicitProviders, explicit: explicitProviders, @@ -92,7 +92,7 @@ export async function ensureMoltbotModelsJson( ? mergeProviderModels(implicitBedrock, existing) : implicitBedrock; } - const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir }); + const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir, config: cfg }); if (implicitCopilot && !providers["github-copilot"]) { providers["github-copilot"] = implicitCopilot; } diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index aa848c54b..bff766413 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -38,6 +38,7 @@ export function resolveSandboxDockerConfig(params: { scope: SandboxScope; globalDocker?: Partial; agentDocker?: Partial; + strictLocal?: boolean; }): SandboxDockerConfig { const agentDocker = params.scope === "shared" ? undefined : params.agentDocker; const globalDocker = params.globalDocker; @@ -59,9 +60,9 @@ export function resolveSandboxDockerConfig(params: { globalDocker?.containerPrefix ?? DEFAULT_SANDBOX_CONTAINER_PREFIX, workdir: agentDocker?.workdir ?? globalDocker?.workdir ?? DEFAULT_SANDBOX_WORKDIR, - readOnlyRoot: agentDocker?.readOnlyRoot ?? globalDocker?.readOnlyRoot ?? true, + readOnlyRoot: params.strictLocal ? true : (agentDocker?.readOnlyRoot ?? globalDocker?.readOnlyRoot ?? true), tmpfs: agentDocker?.tmpfs ?? globalDocker?.tmpfs ?? ["/tmp", "/var/tmp", "/run"], - network: agentDocker?.network ?? globalDocker?.network ?? "none", + network: params.strictLocal ? "none" : (agentDocker?.network ?? globalDocker?.network ?? "none"), user: agentDocker?.user ?? globalDocker?.user, capDrop: agentDocker?.capDrop ?? globalDocker?.capDrop ?? ["ALL"], env, @@ -148,6 +149,7 @@ export function resolveSandboxConfigForAgent(cfg?: MoltbotConfig, agentId?: stri scope, globalDocker: agent?.docker, agentDocker: agentSandbox?.docker, + strictLocal: cfg?.security?.strictLocal, }), browser: resolveSandboxBrowserConfig({ scope, diff --git a/src/agents/skills/config.ts b/src/agents/skills/config.ts index 57c2f7eca..13766b705 100644 --- a/src/agents/skills/config.ts +++ b/src/agents/skills/config.ts @@ -72,6 +72,34 @@ export function isBundledSkillAllowed(entry: SkillEntry, allowlist?: string[]): return allowlist.includes(key) || allowlist.includes(entry.skill.name); } +function vetSkillForStrictLocal(entry: SkillEntry): { ok: boolean; reason?: string } { + const metadata = entry.metadata; + const frontmatter = entry.frontmatter; + + // Check for external URLs in metadata or common frontmatter fields + const urlPattern = /https?:\/\//i; + const fieldsToCheck = [ + metadata?.url, + frontmatter?.url, + frontmatter?.homepage, + frontmatter?.repository, + ]; + + for (const field of fieldsToCheck) { + if (typeof field === "string" && urlPattern.test(field)) { + return { ok: false, reason: `Skill declares external URL: ${field}` }; + } + } + + // Check for suspicious instructions in the skill description or content + const description = entry.skill.description ?? ""; + if (urlPattern.test(description)) { + return { ok: false, reason: "Skill description contains external URLs" }; + } + + return { ok: true }; +} + export function hasBinary(bin: string): boolean { const pathEnv = process.env.PATH ?? ""; const parts = pathEnv.split(path.delimiter).filter(Boolean); @@ -147,5 +175,13 @@ export function shouldIncludeSkill(params: { } } + if (config?.security?.strictLocal) { + const vetting = vetSkillForStrictLocal(entry); + if (!vetting.ok) { + console.warn(`[skills] Strict Local: omitting skill "${entry.skill.name}" - ${vetting.reason}`); + return false; + } + } + return true; } diff --git a/src/cli/gateway-cli/local-only.test.ts b/src/cli/gateway-cli/local-only.test.ts index 2f6adc67b..aae17ac67 100644 --- a/src/cli/gateway-cli/local-only.test.ts +++ b/src/cli/gateway-cli/local-only.test.ts @@ -73,11 +73,7 @@ describe("gateway-cli --local-only", () => { // ignore exit } - expect(setConfigOverride).toHaveBeenCalledWith("update.checkOnStart", false); - expect(setConfigOverride).toHaveBeenCalledWith("diagnostics.enabled", false); - expect(setConfigOverride).toHaveBeenCalledWith("agents.defaults.model.fallbacks", []); - expect(setConfigOverride).toHaveBeenCalledWith("tools.deny", ["group:web", "browser", "skills-install"]); - expect(setConfigOverride).toHaveBeenCalledWith("agents.defaults.sandbox.mode", "non-main"); + expect(setConfigOverride).toHaveBeenCalledWith("security.strictLocal", true); }); it("does not apply hardening overrides by default", async () => { diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index eb2fbfb12..b53b63ed7 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -97,11 +97,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { if (opts.localOnly) { gatewayLog.info("local-only: hardening configuration for strictly local usage"); - setConfigOverride("update.checkOnStart", false); - setConfigOverride("diagnostics.enabled", false); - setConfigOverride("agents.defaults.model.fallbacks", []); - setConfigOverride("tools.deny", ["group:web", "browser", "skills-install"]); - setConfigOverride("agents.defaults.sandbox.mode", "non-main"); + setConfigOverride("security.strictLocal", true); } const cfg = loadConfig(); diff --git a/src/commands/doctor-ollama.ts b/src/commands/doctor-ollama.ts new file mode 100644 index 000000000..eb94b7135 --- /dev/null +++ b/src/commands/doctor-ollama.ts @@ -0,0 +1,38 @@ +import { note } from "../terminal/note.js"; +import type { MoltbotConfig } from "../config/config.js"; + +const OLLAMA_API_BASE_URL = "http://127.0.0.1:11434"; + +export async function noteOllamaHealth(cfg: MoltbotConfig): Promise { + const isStrictLocal = cfg.security?.strictLocal === true; + const hasOllamaProvider = cfg.models?.providers?.ollama !== undefined; + + // Only check Ollama if it's explicitly configured, or if we are in strict local mode. + if (!hasOllamaProvider && !isStrictLocal) return; + + try { + const response = await fetch(`${OLLAMA_API_BASE_URL}/api/tags`, { + signal: AbortSignal.timeout(2000), + }); + + if (!response.ok) { + note(`Ollama at ${OLLAMA_API_BASE_URL} responded with status ${response.status}.`, "Ollama"); + return; + } + + const data = (await response.json()) as { models?: Array<{ name: string }> }; + const models = data.models ?? []; + + if (models.length === 0) { + note(`Ollama is running but no models are downloaded. Run \`ollama pull llama3\` (or another model).`, "Ollama"); + } else { + const modelNames = models.map((m) => m.name).slice(0, 5); + const more = models.length > 5 ? ` (and ${models.length - 5} more)` : ""; + note(`Ollama is running with ${models.length} model(s): ${modelNames.join(", ")}${more}.`, "Ollama"); + } + } catch (err) { + if (isStrictLocal || hasOllamaProvider) { + note(`Ollama connection failed at ${OLLAMA_API_BASE_URL}: ${String(err)}. Is Ollama running?`, "Ollama"); + } + } +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 64ddb0945..b5ff9ffae 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -27,6 +27,7 @@ import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth, } from "./doctor-auth.js"; +import { noteOllamaHealth } from "./doctor-ollama.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js"; import { checkGatewayHealth } from "./doctor-gateway-health.js"; @@ -190,6 +191,8 @@ export async function doctorCommand( await noteMacLaunchAgentOverrides(); await noteMacLaunchctlGatewayEnvOverrides(cfg); + await noteOllamaHealth(cfg); + await noteSecurityWarnings(cfg); if (cfg.hooks?.gmail?.model?.trim()) { diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 82aada474..41fcfca80 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -143,6 +143,57 @@ export function applyTalkApiKey(config: MoltbotConfig): MoltbotConfig { }; } +export function applyStrictLocalDefaults(cfg: MoltbotConfig): MoltbotConfig { + if (cfg.security?.strictLocal !== true) return cfg; + + let next = { ...cfg }; + + // 1. Disable update checks + if (next.update?.checkOnStart !== false) { + next.update = { ...next.update, checkOnStart: false }; + } + + // 2. Disable diagnostics + if (next.diagnostics?.enabled !== false) { + next.diagnostics = { ...next.diagnostics, enabled: false }; + } + + // 3. Clear model fallbacks + const defaults = next.agents?.defaults; + if (defaults?.model && typeof defaults.model === "object") { + if (Array.isArray(defaults.model.fallbacks) && defaults.model.fallbacks.length > 0) { + next.agents = { + ...next.agents, + defaults: { + ...defaults, + model: { ...defaults.model, fallbacks: [] }, + }, + }; + } + } + + // 4. Deny external tools + const webToolsDeny = ["group:web", "browser", "skills-install"]; + const currentDeny = next.tools?.deny ?? []; + const nextDeny = Array.from(new Set([...currentDeny, ...webToolsDeny])); + if (nextDeny.length !== currentDeny.length) { + next.tools = { ...next.tools, deny: nextDeny }; + } + + // 5. Enforce Docker sandboxing for non-main sessions + if (defaults?.sandbox?.mode === undefined || defaults.sandbox.mode === "off") { + next.agents = { + ...next.agents, + defaults: { + ...next.agents?.defaults, + sandbox: { ...next.agents?.defaults?.sandbox, mode: "non-main" }, + }, + }; + } + + return next; +} + export function applyModelDefaults(cfg: MoltbotConfig): MoltbotConfig { let mutated = false; let nextCfg = cfg; diff --git a/src/config/io.ts b/src/config/io.ts index 50f1edb82..4cf74ecfc 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -20,6 +20,7 @@ import { applyMessageDefaults, applyModelDefaults, applySessionDefaults, + applyStrictLocalDefaults, applyTalkApiKey, } from "./defaults.js"; import { VERSION } from "../version.js"; @@ -287,7 +288,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { }); } - return applyConfigOverrides(cfg); + return applyStrictLocalDefaults(applyConfigOverrides(cfg)); } catch (err) { if (err instanceof DuplicateAgentDirError) { deps.logger.error(err.message); @@ -430,10 +431,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { parsed: parsedRes.parsed, valid: true, config: normalizeConfigPaths( - applyTalkApiKey( - applyModelDefaults( - applyAgentDefaults( - applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + applyStrictLocalDefaults( + applyTalkApiKey( + applyModelDefaults( + applyAgentDefaults( + applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + ), ), ), ), diff --git a/src/config/types.security.ts b/src/config/types.security.ts new file mode 100644 index 000000000..9ecce9f98 --- /dev/null +++ b/src/config/types.security.ts @@ -0,0 +1,12 @@ +export type SecurityConfig = { + /** + * If true, hardens the configuration for strictly local usage. + * - Disables update checks on start. + * - Disables diagnostics. + * - Clears model fallbacks. + * - Denies external tools (web_search, web_fetch, browser). + * - Enforces Docker network isolation for sandboxes. + * - Refuses to use cloud-based model providers. + */ + strictLocal?: boolean; +}; diff --git a/src/config/types.ts b/src/config/types.ts index 424ccaf26..b035af527 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -21,6 +21,7 @@ export * from "./types.msteams.js"; export * from "./types.plugins.js"; export * from "./types.queue.js"; export * from "./types.sandbox.js"; +export * from "./types.security.js"; export * from "./types.signal.js"; export * from "./types.skills.js"; export * from "./types.slack.js"; diff --git a/src/config/types.tts.ts b/src/config/types.tts.ts index 4eb4989b9..a26f385fa 100644 --- a/src/config/types.tts.ts +++ b/src/config/types.tts.ts @@ -1,4 +1,4 @@ -export type TtsProvider = "elevenlabs" | "openai" | "edge"; +export type TtsProvider = "elevenlabs" | "openai" | "edge" | "cli"; export type TtsMode = "final" | "all"; @@ -73,6 +73,13 @@ export type TtsConfig = { proxy?: string; timeoutMs?: number; }; + /** Local CLI TTS configuration (e.g. Piper, Sherpa-ONNX). */ + cli?: { + /** CLI binary command. */ + command: string; + /** CLI arguments. Use {text} as placeholder for input text. */ + args?: string[]; + }; /** Optional path for local TTS user preferences JSON. */ prefsPath?: string; /** Hard cap for text sent to TTS (chars). */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4a8c80bcc..86738aac5 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -156,7 +156,7 @@ export const MarkdownConfigSchema = z .strict() .optional(); -export const TtsProviderSchema = z.enum(["elevenlabs", "openai", "edge"]); +export const TtsProviderSchema = z.enum(["elevenlabs", "openai", "edge", "cli"]); export const TtsModeSchema = z.enum(["final", "all"]); export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]); export const TtsConfigSchema = z @@ -224,6 +224,13 @@ export const TtsConfigSchema = z }) .strict() .optional(), + cli: z + .object({ + command: z.string(), + args: z.array(z.string()).optional(), + }) + .strict() + .optional(), prefsPath: z.string().optional(), maxTextLength: z.number().int().min(1).optional(), timeoutMs: z.number().int().min(1000).max(120000).optional(), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ce4115517..a39652ee9 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -27,6 +27,13 @@ const NodeHostSchema = z .strict() .optional(); +const SecuritySchema = z + .object({ + strictLocal: z.boolean().optional(), + }) + .strict() + .optional(); + export const MoltbotSchema = z .object({ meta: z @@ -206,6 +213,7 @@ export const MoltbotSchema = z }) .strict() .optional(), + security: SecuritySchema, models: ModelsConfigSchema, nodeHost: NodeHostSchema, agents: AgentsSchema, diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index 3a92a30a8..0438ea225 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -926,6 +926,47 @@ function listGroupPolicyOpen(cfg: MoltbotConfig): string[] { return out; } +export function collectStrictLocalFindings(cfg: MoltbotConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + if (cfg.security?.strictLocal !== true) return findings; + + if (cfg.update?.checkOnStart !== false) { + findings.push({ + checkId: "security.strict_local.update_check", + severity: "warn", + title: "Strict Local: update checks are not disabled", + detail: "security.strictLocal is true but update.checkOnStart is not false. Metadata could leak to npm registry.", + remediation: "Set update.checkOnStart: false.", + }); + } + + if (cfg.diagnostics?.enabled !== false) { + findings.push({ + checkId: "security.strict_local.diagnostics", + severity: "warn", + title: "Strict Local: diagnostics are enabled", + detail: "security.strictLocal is true but diagnostics.enabled is not false.", + remediation: "Set diagnostics.enabled: false.", + }); + } + + const cloudProviders = ["anthropic", "openai", "google", "amazon-bedrock", "github-copilot"]; + const configuredProviders = Object.keys(cfg.models?.providers ?? {}); + const activeCloudProviders = cloudProviders.filter((p) => configuredProviders.includes(p)); + + if (activeCloudProviders.length > 0) { + findings.push({ + checkId: "security.strict_local.cloud_providers", + severity: "critical", + title: "Strict Local: cloud providers are configured", + detail: `security.strictLocal is true but cloud providers are configured: ${activeCloudProviders.join(", ")}. These will be ignored at runtime but should be removed from config.`, + remediation: "Remove cloud providers from models.providers.", + }); + } + + return findings; +} + export function collectExposureMatrixFindings(cfg: MoltbotConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const openGroups = listGroupPolicyOpen(cfg); diff --git a/src/security/audit.ts b/src/security/audit.ts index 681d14c1d..242cad5c7 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -18,6 +18,7 @@ import { collectPluginsTrustFindings, collectSecretsInConfigFindings, collectStateDeepFilesystemFindings, + collectStrictLocalFindings, collectSyncedFolderFindings, readConfigSnapshotForAudit, } from "./audit-extra.js"; @@ -876,6 +877,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise provider !== primary)]; @@ -485,6 +495,7 @@ export function resolveTtsProviderOrder(primary: TtsProvider): TtsProvider[] { export function isTtsProviderConfigured(config: ResolvedTtsConfig, provider: TtsProvider): boolean { if (provider === "edge") return config.edge.enabled; + if (provider === "cli") return Boolean(config.cli.command); return Boolean(resolveTtsApiKey(config, provider)); } @@ -1047,6 +1058,24 @@ function inferEdgeExtension(outputFormat: string): string { return ".mp3"; } +async function cliTTS(params: { + text: string; + outputPath: string; + config: ResolvedTtsConfig["cli"]; + timeoutMs: number; +}): Promise { + const { text, outputPath, config, timeoutMs } = params; + if (!config.command) { + throw new Error("CLI TTS command not configured"); + } + + const args = config.args.map((arg) => arg.replace("{text}", text).replace("{output}", outputPath)); + + await runExec(config.command, args, { + timeoutMs, + }); +} + async function edgeTTS(params: { text: string; outputPath: string; @@ -1097,6 +1126,45 @@ export async function textToSpeech(params: { for (const provider of providers) { const providerStart = Date.now(); try { + if (provider === "cli") { + if (!config.cli.command) { + lastError = "cli: not configured"; + continue; + } + + const tempDir = mkdtempSync(path.join(tmpdir(), "tts-")); + // We assume .wav as a safe default for CLI TTS like Piper, but we could infer from args if needed. + const audioPath = path.join(tempDir, `voice-${Date.now()}.wav`); + + try { + await cliTTS({ + text: params.text, + outputPath: audioPath, + config: config.cli, + timeoutMs: config.timeoutMs, + }); + } catch (err) { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + throw err; + } + + scheduleCleanup(tempDir); + const voiceCompatible = isVoiceCompatibleAudio({ fileName: audioPath }); + + return { + success: true, + audioPath, + latencyMs: Date.now() - providerStart, + provider, + outputFormat: "wav", + voiceCompatible, + }; + } + if (provider === "edge") { if (!config.edge.enabled) { lastError = "edge: disabled"; @@ -1262,6 +1330,11 @@ export async function textToSpeechTelephony(params: { for (const provider of providers) { const providerStart = Date.now(); try { + if (provider === "cli") { + lastError = "cli: unsupported for telephony"; + continue; + } + if (provider === "edge") { lastError = "edge: unsupported for telephony"; continue;