Comprehensive strictly local hardening and enhancements
This commit completes the "Strictly Local" journey by adding several key features: - Permanent `security.strictLocal` configuration key. - Hardened Docker sandbox isolation (enforces `network: "none"`). - Local compliance checks in `moltbot security audit`. - Ollama health and model checks in `moltbot doctor`. - Local CLI-based TTS support (offline speech). - Skill vetting to prevent external URL leakage in strict mode. - Documentation and unit tests for all new functionality. Co-authored-by: samibs <1743891+samibs@users.noreply.github.com>
This commit is contained in:
parent
862e6a765c
commit
29b2d8acdc
@ -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.
|
||||
|
||||
@ -361,8 +361,23 @@ async function buildOllamaProvider(): Promise<ProviderConfig> {
|
||||
|
||||
export async function resolveImplicitProviders(params: {
|
||||
agentDir: string;
|
||||
config?: MoltbotConfig;
|
||||
}): Promise<ModelsConfig["providers"]> {
|
||||
const providers: Record<string, ProviderConfig> = {};
|
||||
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<ProviderConfig | null> {
|
||||
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;
|
||||
|
||||
@ -80,7 +80,7 @@ export async function ensureMoltbotModelsJson(
|
||||
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveMoltbotAgentDir();
|
||||
|
||||
const explicitProviders = (cfg.models?.providers ?? {}) as Record<string, ProviderConfig>;
|
||||
const implicitProviders = await resolveImplicitProviders({ agentDir });
|
||||
const implicitProviders = await resolveImplicitProviders({ agentDir, config: cfg });
|
||||
const providers: Record<string, ProviderConfig> = 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;
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ export function resolveSandboxDockerConfig(params: {
|
||||
scope: SandboxScope;
|
||||
globalDocker?: Partial<SandboxDockerConfig>;
|
||||
agentDocker?: Partial<SandboxDockerConfig>;
|
||||
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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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();
|
||||
|
||||
38
src/commands/doctor-ollama.ts
Normal file
38
src/commands/doctor-ollama.ts
Normal file
@ -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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
12
src/config/types.security.ts
Normal file
12
src/config/types.security.ts
Normal file
@ -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;
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<Secu
|
||||
findings.push(...collectSecretsInConfigFindings(cfg));
|
||||
findings.push(...collectModelHygieneFindings(cfg));
|
||||
findings.push(...collectSmallModelRiskFindings({ cfg, env }));
|
||||
findings.push(...collectStrictLocalFindings(cfg));
|
||||
findings.push(...collectExposureMatrixFindings(cfg));
|
||||
|
||||
const configSnapshot =
|
||||
|
||||
@ -14,6 +14,7 @@ import path from "node:path";
|
||||
import { completeSimple, type TextContent } from "@mariozechner/pi-ai";
|
||||
import { EdgeTTS } from "node-edge-tts";
|
||||
|
||||
import { runExec } from "../process/exec.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
@ -124,6 +125,10 @@ export type ResolvedTtsConfig = {
|
||||
proxy?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
cli: {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
prefsPath?: string;
|
||||
maxTextLength: number;
|
||||
timeoutMs: number;
|
||||
@ -296,6 +301,10 @@ export function resolveTtsConfig(cfg: MoltbotConfig): ResolvedTtsConfig {
|
||||
proxy: raw.edge?.proxy?.trim() || undefined,
|
||||
timeoutMs: raw.edge?.timeoutMs,
|
||||
},
|
||||
cli: {
|
||||
command: raw.cli?.command?.trim() || "",
|
||||
args: raw.cli?.args ?? [],
|
||||
},
|
||||
prefsPath: raw.prefsPath,
|
||||
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
|
||||
timeoutMs: raw.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
@ -410,6 +419,7 @@ export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): Tt
|
||||
if (prefs.tts?.provider) return prefs.tts.provider;
|
||||
if (config.providerSource === "config") return config.provider;
|
||||
|
||||
if (config.cli.command) return "cli";
|
||||
if (resolveTtsApiKey(config, "openai")) return "openai";
|
||||
if (resolveTtsApiKey(config, "elevenlabs")) return "elevenlabs";
|
||||
return "edge";
|
||||
@ -477,7 +487,7 @@ export function resolveTtsApiKey(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const TTS_PROVIDERS = ["openai", "elevenlabs", "edge"] as const;
|
||||
export const TTS_PROVIDERS = ["cli", "openai", "elevenlabs", "edge"] as const;
|
||||
|
||||
export function resolveTtsProviderOrder(primary: TtsProvider): TtsProvider[] {
|
||||
return [primary, ...TTS_PROVIDERS.filter((provider) => 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<void> {
|
||||
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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user