daemora 1.0.10 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ import { banner, stepHeader, kv, summaryTable, completeBanner, t, S } from "./th
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  const ROOT_DIR = join(__dirname, "..", "..");
10
- const TOTAL_STEPS = 9;
10
+ const TOTAL_STEPS = 11;
11
11
  const OLLAMA_EMBED_MODEL = "all-minilm";
12
12
 
13
13
  /**
@@ -342,6 +342,15 @@ export async function runSetupWizard() {
342
342
  envConfig.TWILIO_ACCOUNT_SID = guard(await p.password({ message: "Twilio Account SID" }));
343
343
  envConfig.TWILIO_AUTH_TOKEN = guard(await p.password({ message: "Twilio Auth Token" }));
344
344
  envConfig.TWILIO_WHATSAPP_FROM = guard(await p.text({ message: "WhatsApp From number", initialValue: "whatsapp:+14155238886" }));
345
+
346
+ const enableVoice = guard(await p.confirm({
347
+ message: "Enable voice calls? (needs a voice-capable Twilio number + public URL)",
348
+ initialValue: false,
349
+ }));
350
+ if (enableVoice) {
351
+ envConfig.TWILIO_PHONE_FROM = guard(await p.text({ message: "Twilio voice-capable phone number (e.g. +1234567890)" }));
352
+ envConfig.VOICE_WEBHOOK_BASE_URL = guard(await p.text({ message: "Public URL for voice webhooks (e.g. https://abc123.ngrok.io)" }));
353
+ }
345
354
  }
346
355
 
347
356
  if (channels.includes("discord")) {
@@ -378,22 +387,47 @@ export async function runSetupWizard() {
378
387
  }
379
388
 
380
389
  if (channels.includes("email")) {
381
- p.note(
382
- [
383
- "Gmail setup:",
384
- "1. Google Account Security 2-Step Verification enable",
385
- "2. Google Account Security App Passwords Mail create",
386
- "3. Use the 16-char app password below (NOT your Gmail password)",
387
- "",
388
- "For other providers: change IMAP/SMTP hosts below.",
389
- "Optional: EMAIL_ALLOWLIST=alice@example.com,bob@example.com",
390
- ].join("\n"),
391
- "Email Setup"
392
- );
393
- envConfig.EMAIL_USER = guard(await p.text({ message: "Email address" }));
394
- envConfig.EMAIL_PASSWORD = guard(await p.password({ message: "App password" }));
395
- envConfig.EMAIL_IMAP_HOST = guard(await p.text({ message: "IMAP host", initialValue: "imap.gmail.com" }));
396
- envConfig.EMAIL_SMTP_HOST = guard(await p.text({ message: "SMTP host", initialValue: "smtp.gmail.com" }));
390
+ const emailMethod = guard(await p.select({
391
+ message: "Email sending method",
392
+ options: [
393
+ { value: "resend", label: "Resend (easiest)", hint: "Just an API key, no SMTP config" },
394
+ { value: "gmail", label: "Gmail IMAP/SMTP", hint: "Full inbox read + send via Gmail app password" },
395
+ { value: "both", label: "Both (Resend + Gmail)", hint: "Resend for sending, Gmail for reading inbox" },
396
+ ],
397
+ }));
398
+
399
+ if (emailMethod === "resend" || emailMethod === "both") {
400
+ p.note(
401
+ [
402
+ "1. Sign up free at https://resend.com",
403
+ "2. API Keys Create API Key",
404
+ "3. Domains add your sending domain (or use shared domain for testing)",
405
+ ].join("\n"),
406
+ "Resend Setup"
407
+ );
408
+ envConfig.RESEND_API_KEY = guard(await p.password({ message: "Resend API key (re_...)" }));
409
+ envConfig.RESEND_FROM = guard(await p.text({ message: "From address", placeholder: "you@yourdomain.com" }));
410
+ }
411
+
412
+ if (emailMethod === "gmail" || emailMethod === "both") {
413
+ p.note(
414
+ [
415
+ "Gmail setup:",
416
+ "1. Google Account › Security › 2-Step Verification → enable",
417
+ "2. Google Account › Security › App Passwords → Mail → create",
418
+ "3. Use the 16-char app password below (NOT your Gmail password)",
419
+ "",
420
+ "For other providers: change IMAP/SMTP hosts below.",
421
+ ].join("\n"),
422
+ "Gmail IMAP/SMTP Setup"
423
+ );
424
+ envConfig.EMAIL_USER = guard(await p.text({ message: "Email address" }));
425
+ envConfig.EMAIL_PASSWORD = guard(await p.password({ message: "App password" }));
426
+ envConfig.EMAIL_IMAP_HOST = guard(await p.text({ message: "IMAP host", initialValue: "imap.gmail.com" }));
427
+ envConfig.EMAIL_SMTP_HOST = guard(await p.text({ message: "SMTP host", initialValue: "smtp.gmail.com" }));
428
+ }
429
+
430
+ p.log.info("Optional: EMAIL_ALLOWLIST=alice@example.com,bob@example.com");
397
431
  }
398
432
 
399
433
  if (channels.includes("line")) {
@@ -633,8 +667,115 @@ export async function runSetupWizard() {
633
667
  })];
634
668
  p.log.success(`Channels: ${t.bold(activeChannels.join(", "))}`);
635
669
 
636
- // ━━━ Step 6: Daemon ━━━
637
- stepHeader(6, TOTAL_STEPS, "Daemon Mode");
670
+ // ━━━ Step 6: Tool API Keys (optional) ━━━
671
+ stepHeader(6, TOTAL_STEPS, "Tool API Keys");
672
+
673
+ p.note(
674
+ [
675
+ "Some built-in tools need their own API keys to work.",
676
+ "You can skip this now and add keys later via: daemora config set <KEY> <value>",
677
+ "",
678
+ ` ${S.info} generateImage / textToSpeech / transcribeAudio → OPENAI_API_KEY`,
679
+ ` ${S.info} textToSpeech (premium voices) → ELEVENLABS_API_KEY`,
680
+ ` ${S.info} googlePlaces → GOOGLE_PLACES_API_KEY`,
681
+ ` ${S.info} calendar (Google) → GOOGLE_CALENDAR_API_KEY`,
682
+ ` ${S.info} database → DATABASE_URL / MYSQL_URL`,
683
+ ` ${S.info} notification (ntfy) → NTFY_TOPIC + NTFY_TOKEN`,
684
+ ` ${S.info} notification (pushover) → PUSHOVER_API_TOKEN + PUSHOVER_USER_KEY`,
685
+ ` ${S.info} philipsHue → HUE_BRIDGE_IP + HUE_API_KEY`,
686
+ ].join("\n"),
687
+ "Optional Tool Credentials"
688
+ );
689
+
690
+ const toolKeys = guard(await p.multiselect({
691
+ message: "Configure tool API keys? (space = toggle, enter = confirm)",
692
+ required: false,
693
+ options: [
694
+ { value: "openai_tools", label: "OpenAI (images, TTS, transcription)", hint: "OPENAI_API_KEY — skip if already set as main provider" },
695
+ { value: "elevenlabs", label: "ElevenLabs TTS", hint: "Premium voice synthesis" },
696
+ { value: "google_places", label: "Google Places", hint: "Location search & details" },
697
+ { value: "google_cal", label: "Google Calendar", hint: "Calendar read/write" },
698
+ { value: "database", label: "Database", hint: "PostgreSQL / MySQL connection" },
699
+ { value: "ntfy", label: "Ntfy notifications", hint: "Push notifications via ntfy.sh" },
700
+ { value: "pushover", label: "Pushover notifications", hint: "Push notifications via Pushover" },
701
+ { value: "hue", label: "Philips Hue", hint: "Smart light control" },
702
+ { value: "sonos", label: "Sonos speaker", hint: "Music / audio control" },
703
+ { value: "none", label: "Skip for now", hint: "Add later via daemora config set" },
704
+ ],
705
+ }));
706
+
707
+ if (toolKeys.includes("openai_tools") && !envConfig.OPENAI_API_KEY) {
708
+ const key = guard(await p.password({ message: "OpenAI API key (for images, TTS, transcription)" }));
709
+ if (key) envConfig.OPENAI_API_KEY = key;
710
+ }
711
+
712
+ if (toolKeys.includes("elevenlabs")) {
713
+ const key = guard(await p.password({ message: "ElevenLabs API key" }));
714
+ if (key) envConfig.ELEVENLABS_API_KEY = key;
715
+ }
716
+
717
+ if (toolKeys.includes("google_places")) {
718
+ const key = guard(await p.password({ message: "Google Places API key" }));
719
+ if (key) envConfig.GOOGLE_PLACES_API_KEY = key;
720
+ }
721
+
722
+ if (toolKeys.includes("google_cal")) {
723
+ const key = guard(await p.password({ message: "Google Calendar API key" }));
724
+ if (key) envConfig.GOOGLE_CALENDAR_API_KEY = key;
725
+ const calId = guard(await p.text({ message: "Calendar ID", initialValue: "primary" }));
726
+ if (calId) envConfig.GOOGLE_CALENDAR_ID = calId;
727
+ }
728
+
729
+ if (toolKeys.includes("database")) {
730
+ p.note(
731
+ "Format: postgresql://user:pass@host:5432/db or mysql://user:pass@host:3306/db",
732
+ "Database URL"
733
+ );
734
+ const dbUrl = guard(await p.text({ message: "Database URL (PostgreSQL or MySQL)" }));
735
+ if (dbUrl) {
736
+ if (dbUrl.startsWith("mysql")) envConfig.MYSQL_URL = dbUrl;
737
+ else envConfig.DATABASE_URL = dbUrl;
738
+ }
739
+ }
740
+
741
+ if (toolKeys.includes("ntfy")) {
742
+ envConfig.NTFY_URL = guard(await p.text({ message: "Ntfy server URL", initialValue: "https://ntfy.sh" }));
743
+ envConfig.NTFY_TOPIC = guard(await p.text({ message: "Ntfy topic name" }));
744
+ const ntfyToken = guard(await p.password({ message: "Ntfy access token (optional)" }));
745
+ if (ntfyToken) envConfig.NTFY_TOKEN = ntfyToken;
746
+ }
747
+
748
+ if (toolKeys.includes("pushover")) {
749
+ envConfig.PUSHOVER_API_TOKEN = guard(await p.password({ message: "Pushover API token" }));
750
+ envConfig.PUSHOVER_USER_KEY = guard(await p.password({ message: "Pushover user key" }));
751
+ }
752
+
753
+ if (toolKeys.includes("hue")) {
754
+ p.note(
755
+ [
756
+ "Find bridge IP: check your router or use the Hue app.",
757
+ "Get API key: press the bridge button, then run:",
758
+ ' curl -X POST http://<bridge-ip>/api -d \'{"devicetype":"daemora"}\'',
759
+ ].join("\n"),
760
+ "Philips Hue Setup"
761
+ );
762
+ envConfig.HUE_BRIDGE_IP = guard(await p.text({ message: "Hue Bridge IP" }));
763
+ envConfig.HUE_API_KEY = guard(await p.password({ message: "Hue API key" }));
764
+ }
765
+
766
+ if (toolKeys.includes("sonos")) {
767
+ envConfig.SONOS_SPEAKER_IP = guard(await p.text({ message: "Sonos speaker IP address" }));
768
+ }
769
+
770
+ const toolCount = toolKeys.filter(k => k !== "none").length;
771
+ if (toolCount > 0) {
772
+ p.log.success(`${toolCount} tool integration(s) configured`);
773
+ } else {
774
+ p.log.info("No tool keys configured. Add later via: daemora config set <KEY> <value>");
775
+ }
776
+
777
+ // ━━━ Step 7: Daemon Mode ━━━
778
+ stepHeader(7, TOTAL_STEPS, "Daemon Mode");
638
779
 
639
780
  p.note(
640
781
  [
@@ -662,8 +803,8 @@ export async function runSetupWizard() {
662
803
 
663
804
  p.log.success(`Daemon: ${t.bold(daemonMode ? "Enabled" : "Disabled")}`);
664
805
 
665
- // ━━━ Step 7: Data Cleanup ━━━
666
- stepHeader(7, TOTAL_STEPS, "Data Cleanup");
806
+ // ━━━ Step 8: Data Cleanup ━━━
807
+ stepHeader(8, TOTAL_STEPS, "Data Cleanup");
667
808
 
668
809
  const cleanupDays = guard(await p.select({
669
810
  message: "Auto-delete old tasks, logs & sessions after how many days?",
@@ -679,8 +820,8 @@ export async function runSetupWizard() {
679
820
 
680
821
  p.log.success(`Cleanup: ${t.bold(cleanupDays === "0" ? "Never" : cleanupDays + " days")}`);
681
822
 
682
- // ━━━ Step 8: MCP Servers ━━━
683
- stepHeader(8, TOTAL_STEPS, "MCP Tool Servers");
823
+ // ━━━ Step 9: MCP Servers ━━━
824
+ stepHeader(9, TOTAL_STEPS, "MCP Tool Servers");
684
825
 
685
826
  p.note(
686
827
  [
@@ -973,8 +1114,8 @@ export async function runSetupWizard() {
973
1114
  p.log.info("No MCP servers configured. Use `daemora mcp add` anytime to add one.");
974
1115
  }
975
1116
 
976
- // ━━━ Step 8: Secret Vault ━━━
977
- stepHeader(9, TOTAL_STEPS, "Secret Vault");
1117
+ // ━━━ Step 10: Secret Vault ━━━
1118
+ stepHeader(10, TOTAL_STEPS, "Secret Vault");
978
1119
 
979
1120
  p.note(
980
1121
  [
@@ -1060,6 +1201,52 @@ export async function runSetupWizard() {
1060
1201
  p.log.info("Vault skipped. API keys will be stored in .env (plaintext).");
1061
1202
  }
1062
1203
 
1204
+ // ━━━ Step 10: Multi-Tenant Mode ━━━
1205
+ stepHeader(11, TOTAL_STEPS, "Multi-Tenant Mode");
1206
+
1207
+ let multiTenantMode = "personal";
1208
+
1209
+ p.note(
1210
+ ` ${S.info} Personal — single user, global config (default)\n` +
1211
+ ` ${S.info} Multi-Tenant — per-user isolation, cost limits, model overrides`,
1212
+ "Deployment mode"
1213
+ );
1214
+
1215
+ const mtChoice = guard(await p.select({
1216
+ message: "How will you use Daemora?",
1217
+ options: [
1218
+ { value: "personal", label: "Personal", hint: "Single user, no tenant isolation" },
1219
+ { value: "multitenant", label: "Multi-Tenant", hint: "Multiple users via channels, per-user config" },
1220
+ ],
1221
+ }));
1222
+
1223
+ if (mtChoice === "multitenant") {
1224
+ multiTenantMode = "multitenant";
1225
+ envConfig.MULTI_TENANT_ENABLED = "true";
1226
+
1227
+ const autoReg = guard(await p.confirm({
1228
+ message: "Auto-register tenants on first message?",
1229
+ initialValue: true,
1230
+ }));
1231
+ envConfig.AUTO_REGISTER_TENANTS = autoReg ? "true" : "false";
1232
+
1233
+ const isolateFs = guard(await p.confirm({
1234
+ message: "Isolate each tenant's filesystem? (recommended for shared deployments)",
1235
+ initialValue: true,
1236
+ }));
1237
+ envConfig.TENANT_ISOLATE_FILESYSTEM = isolateFs ? "true" : "false";
1238
+
1239
+ const setKey = guard(await p.confirm({
1240
+ message: "Generate a tenant encryption key? (encrypts per-tenant API keys at rest)",
1241
+ initialValue: true,
1242
+ }));
1243
+ if (setKey) {
1244
+ const { randomBytes: rb } = await import("crypto");
1245
+ envConfig.DAEMORA_TENANT_KEY = rb(16).toString("hex");
1246
+ p.log.success(`${S.check} DAEMORA_TENANT_KEY generated (32 hex chars)`);
1247
+ }
1248
+ }
1249
+
1063
1250
  // ━━━ Write Config ━━━
1064
1251
  const spin = p.spinner();
1065
1252
  spin.start("Writing configuration");
@@ -1077,8 +1264,12 @@ export async function runSetupWizard() {
1077
1264
  "Filesystem": ["ALLOWED_PATHS", "BLOCKED_PATHS", "RESTRICT_COMMANDS"],
1078
1265
  "Telegram": ["TELEGRAM_BOT_TOKEN"],
1079
1266
  "WhatsApp": ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_WHATSAPP_FROM"],
1080
- "Email": ["EMAIL_USER", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST"],
1267
+ "Email (Resend)": ["RESEND_API_KEY", "RESEND_FROM"],
1268
+ "Email (IMAP/SMTP)": ["EMAIL_USER", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST"],
1269
+ "Voice Calls": ["TWILIO_PHONE_FROM", "VOICE_WEBHOOK_BASE_URL"],
1081
1270
  "Daemon": ["DAEMON_MODE", "HEARTBEAT_INTERVAL_MINUTES"],
1271
+ "Tool Keys": ["ELEVENLABS_API_KEY", "GOOGLE_PLACES_API_KEY", "GOOGLE_CALENDAR_API_KEY", "GOOGLE_CALENDAR_ID", "DATABASE_URL", "MYSQL_URL", "NTFY_URL", "NTFY_TOPIC", "NTFY_TOKEN", "PUSHOVER_API_TOKEN", "PUSHOVER_USER_KEY", "HUE_BRIDGE_IP", "HUE_API_KEY", "SONOS_SPEAKER_IP"],
1272
+ "Multi-Tenant": ["MULTI_TENANT_ENABLED", "AUTO_REGISTER_TENANTS", "TENANT_ISOLATE_FILESYSTEM", "DAEMORA_TENANT_KEY"],
1082
1273
  };
1083
1274
 
1084
1275
  for (const [category, keys] of Object.entries(categories)) {
@@ -1139,6 +1330,9 @@ export async function runSetupWizard() {
1139
1330
  ["Daemon", daemonMode ? t.success("Enabled") : t.muted("Disabled")],
1140
1331
  ["MCP Servers", allEnabled.length > 0 ? t.bold(allEnabled.join(", ")) : t.muted("None")],
1141
1332
  ["Vault", vaultPassphrase ? t.success("Encrypted") : t.warning("Plaintext (.env)")],
1333
+ ["Multi-Tenant", multiTenantMode === "multitenant"
1334
+ ? (envConfig.TENANT_ISOLATE_FILESYSTEM === "true" ? t.success("Enabled (isolated)") : t.accent("Enabled"))
1335
+ : t.muted("Disabled (personal)")],
1142
1336
  ]);
1143
1337
 
1144
1338
  // ━━━ Next Steps ━━━
@@ -127,6 +127,43 @@ class TenantManager {
127
127
  * Update tenant config (partial update - only provided keys are changed).
128
128
  */
129
129
  set(tenantId, updates) {
130
+ // Validate path arrays before applying
131
+ for (const field of ["allowedPaths", "blockedPaths"]) {
132
+ if (updates[field] !== undefined) {
133
+ if (!Array.isArray(updates[field])) {
134
+ throw new Error(`${field} must be an array`);
135
+ }
136
+ for (const p of updates[field]) {
137
+ if (typeof p !== "string") {
138
+ throw new Error(`${field} must contain strings`);
139
+ }
140
+ // Must be absolute — Unix (/...) or Windows (C:\...)
141
+ const isUnixAbs = p.startsWith("/");
142
+ const isWinAbs = /^[A-Za-z]:[\\\/]/.test(p);
143
+ if (!isUnixAbs && !isWinAbs) {
144
+ throw new Error(`${field} must contain absolute paths (got "${p}")`);
145
+ }
146
+ // Block null bytes (path injection)
147
+ if (p.includes("\0")) {
148
+ throw new Error(`${field} must not contain null bytes`);
149
+ }
150
+ // Block path traversal via /../ or \..\ sequences
151
+ const normalized = p.replace(/\\/g, "/");
152
+ if (/(^|\/)\.\.(\/|$)/.test(normalized)) {
153
+ throw new Error(`${field} must not contain ".." path traversal (got "${p}")`);
154
+ }
155
+ // Max length — OS limits are typically 260 (Win) or 4096 (Unix)
156
+ if (p.length > 4096) {
157
+ throw new Error(`${field} path too long (max 4096 chars)`);
158
+ }
159
+ // Block control characters (0x00-0x1F except nothing — all blocked)
160
+ if (/[\x00-\x1f]/.test(p)) {
161
+ throw new Error(`${field} must not contain control characters`);
162
+ }
163
+ }
164
+ }
165
+ }
166
+
130
167
  const tenants = this._load();
131
168
  if (!tenants[tenantId]) {
132
169
  tenants[tenantId] = _defaultTenant(tenantId);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Tenant-aware temp/workspace directory helper.
3
+ *
4
+ * TENANT_ISOLATE_FILESYSTEM=true + tenant context active
5
+ * → data/tenants/{safeId}/workspace/{subdir}
6
+ * Otherwise
7
+ * → os.tmpdir()/{subdir}
8
+ *
9
+ * Auto-creates the directory.
10
+ */
11
+ import { mkdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+ import { config } from "../config/default.js";
15
+ import tenantContext from "../tenants/TenantContext.js";
16
+
17
+ const TENANTS_DIR = join(config.dataDir, "tenants");
18
+
19
+ /**
20
+ * @param {string} [subdir] - e.g. "daemora-images", "daemora-tts"
21
+ * @returns {string} absolute directory path (already created)
22
+ */
23
+ export function getTenantTmpDir(subdir) {
24
+ const store = tenantContext.getStore();
25
+ const tenantId = store?.tenant?.id;
26
+
27
+ if (tenantId && config.multiTenant?.isolateFilesystem) {
28
+ const safeId = tenantId.replace(/[^a-zA-Z0-9_-]/g, "_");
29
+ const dir = subdir
30
+ ? join(TENANTS_DIR, safeId, "workspace", subdir)
31
+ : join(TENANTS_DIR, safeId, "workspace");
32
+ mkdirSync(dir, { recursive: true });
33
+ return dir;
34
+ }
35
+
36
+ const dir = subdir ? join(tmpdir(), subdir) : tmpdir();
37
+ if (subdir) mkdirSync(dir, { recursive: true });
38
+ return dir;
39
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { readFileSync, writeFileSync } from "node:fs";
7
7
  import { existsSync } from "node:fs";
8
+ import filesystemGuard from "../safety/FilesystemGuard.js";
8
9
 
9
10
  /**
10
11
  * Parse a unified diff string into an array of hunks.
@@ -105,6 +106,11 @@ function applyHunk(fileLines, hunk, offset) {
105
106
 
106
107
  export function applyPatch(filePath, patch) {
107
108
  try {
109
+ const readCheck = filesystemGuard.checkRead(filePath);
110
+ if (!readCheck.allowed) return `Error: ${readCheck.reason}`;
111
+ const writeCheck = filesystemGuard.checkWrite(filePath);
112
+ if (!writeCheck.allowed) return `Error: ${writeCheck.reason}`;
113
+
108
114
  if (!existsSync(filePath)) {
109
115
  return `Error: File not found: ${filePath}`;
110
116
  }
@@ -13,9 +13,11 @@
13
13
  * - Localhost allowed, private ranges blocked
14
14
  */
15
15
 
16
- import { join } from "path";
16
+ import { join, basename } from "path";
17
17
  import { mkdirSync, existsSync } from "fs";
18
18
  import { config } from "../config/default.js";
19
+ import filesystemGuard from "../safety/FilesystemGuard.js";
20
+ import { getTenantTmpDir } from "./_paths.js";
19
21
 
20
22
  let browser = null;
21
23
  let browserContext = null;
@@ -419,7 +421,7 @@ export async function browserAction(action, param1, param2) {
419
421
  case "screenshot": {
420
422
  const p = await ensureBrowser();
421
423
  const opts = { fullPage: false };
422
- let path = `/tmp/screenshot-${Date.now()}.png`;
424
+ let path = join(getTenantTmpDir("daemora-browser"), `screenshot-${Date.now()}.png`);
423
425
 
424
426
  if (param1 && param1.startsWith("/")) {
425
427
  path = param1;
@@ -428,6 +430,8 @@ export async function browserAction(action, param1, param2) {
428
430
  try {
429
431
  const locator = await getLocator(p, param1);
430
432
  path = param2 || path;
433
+ const sc = filesystemGuard.checkWrite(path);
434
+ if (!sc.allowed) return `Error: ${sc.reason}`;
431
435
  await locator.screenshot({ path });
432
436
  return `Element screenshot saved: ${path}`;
433
437
  } catch {
@@ -435,13 +439,17 @@ export async function browserAction(action, param1, param2) {
435
439
  path = param1;
436
440
  }
437
441
  }
442
+ const sc2 = filesystemGuard.checkWrite(path);
443
+ if (!sc2.allowed) return `Error: ${sc2.reason}`;
438
444
  if (param2 === "full") opts.fullPage = true;
439
445
  await p.screenshot({ path, ...opts });
440
446
  return `Screenshot saved: ${path}`;
441
447
  }
442
448
 
443
449
  case "pdf": {
444
- const path = param1 || `/tmp/page-${Date.now()}.pdf`;
450
+ const path = param1 || join(getTenantTmpDir("daemora-browser"), `page-${Date.now()}.pdf`);
451
+ const pc = filesystemGuard.checkWrite(path);
452
+ if (!pc.allowed) return `Error: ${pc.reason}`;
445
453
  await currentPage().pdf({ path, format: "A4", printBackground: true });
446
454
  return `PDF saved: ${path}`;
447
455
  }
@@ -638,9 +646,13 @@ export async function browserAction(action, param1, param2) {
638
646
  page.waitForEvent("download", { timeout: 30000 }),
639
647
  (await getLocator(page, param1)).click(),
640
648
  ]);
641
- const path = join(downloadDir, download.suggestedFilename());
642
- await download.saveAs(path);
643
- return `Downloaded: ${path} (${download.suggestedFilename()})`;
649
+ // Sanitize filename — strip path traversal, use only basename
650
+ const safeName = basename(download.suggestedFilename()).replace(/[^a-zA-Z0-9._-]/g, "_") || "download";
651
+ const dlPath = join(downloadDir, safeName);
652
+ const dc = filesystemGuard.checkWrite(dlPath);
653
+ if (!dc.allowed) return `Error: ${dc.reason}`;
654
+ await download.saveAs(dlPath);
655
+ return `Downloaded: ${dlPath} (${safeName})`;
644
656
  }
645
657
 
646
658
  // ── Viewport ────────────────────────────────────────────────────────
@@ -1,5 +1,6 @@
1
1
  import { writeFileSync, mkdirSync, readFileSync } from "fs";
2
2
  import { dirname } from "path";
3
+ import filesystemGuard from "../safety/FilesystemGuard.js";
3
4
 
4
5
  /**
5
6
  * Create Document - creates Markdown, text, PDF, or DOCX documents.
@@ -15,6 +16,9 @@ export async function createDocument(filePath, content, format) {
15
16
  return "Error: filePath and content are required.";
16
17
  }
17
18
 
19
+ const writeCheck = filesystemGuard.checkWrite(filePath);
20
+ if (!writeCheck.allowed) return `Error: ${writeCheck.reason}`;
21
+
18
22
  try {
19
23
  mkdirSync(dirname(filePath), { recursive: true });
20
24
 
@@ -56,7 +56,10 @@ export async function executeCommand(cmd, optionsJson) {
56
56
  // ──────────────────────────────────────────────────────────────────────────
57
57
 
58
58
  // ── Filesystem scope enforcement ───────────────────────────────────────────
59
- const allowedPaths = config.filesystem?.allowedPaths || [];
59
+ // Prefer per-tenant resolved config (set by TaskRunner), fall back to global
60
+ const store = tenantContext.getStore();
61
+ const resolvedConfig = store?.resolvedConfig;
62
+ const allowedPaths = resolvedConfig?.allowedPaths || config.filesystem?.allowedPaths || [];
60
63
  if (allowedPaths.length > 0) {
61
64
  // Always check that the cwd is inside an allowed directory
62
65
  const cwdGuard = filesystemGuard.checkRead(cwd);
@@ -93,7 +96,6 @@ export async function executeCommand(cmd, optionsJson) {
93
96
 
94
97
  // ── Docker sandbox mode — route through container ──
95
98
  if (config.sandbox?.mode === "docker" && dockerSandbox.isAvailable() && !background) {
96
- const store = tenantContext.getStore();
97
99
  const scope = config.sandbox.docker?.scope === "shared" ? "shared" : (store?.sessionId || "shared");
98
100
  return dockerSandbox.exec(scope, cmd, { timeout, cwd });
99
101
  }
@@ -4,8 +4,9 @@
4
4
  */
5
5
  import { writeFileSync, mkdirSync } from "node:fs";
6
6
  import { join } from "node:path";
7
- import { tmpdir } from "node:os";
8
7
  import tenantContext from "../tenants/TenantContext.js";
8
+ import filesystemGuard from "../safety/FilesystemGuard.js";
9
+ import { getTenantTmpDir } from "./_paths.js";
9
10
 
10
11
  export async function generateImage(prompt, optionsJson) {
11
12
  if (!prompt) return "Error: prompt is required.";
@@ -48,13 +49,16 @@ export async function generateImage(prompt, optionsJson) {
48
49
  if (images.length === 0) return "Error: No images returned.";
49
50
 
50
51
  const saved = [];
51
- const dir = join(tmpdir(), "daemora-images");
52
- mkdirSync(dir, { recursive: true });
52
+ const dir = getTenantTmpDir("daemora-images");
53
53
 
54
54
  for (let i = 0; i < images.length; i++) {
55
55
  const b64 = images[i].b64_json;
56
56
  const revised = images[i].revised_prompt || prompt;
57
57
  const filePath = outputPath || join(dir, `image-${Date.now()}-${i}.png`);
58
+ if (outputPath) {
59
+ const wc = filesystemGuard.checkWrite(outputPath);
60
+ if (!wc.allowed) return `Error: ${wc.reason}`;
61
+ }
58
62
  writeFileSync(filePath, Buffer.from(b64, "base64"));
59
63
  saved.push({ path: filePath, revisedPrompt: revised });
60
64
  }
@@ -11,6 +11,7 @@
11
11
  import tenantContext from "../tenants/TenantContext.js";
12
12
  import channelRegistry from "../channels/index.js";
13
13
  import { existsSync, statSync } from "node:fs";
14
+ import filesystemGuard from "../safety/FilesystemGuard.js";
14
15
 
15
16
  const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
16
17
 
@@ -18,6 +19,9 @@ export async function replyWithFile(filePath, caption) {
18
19
  try {
19
20
  if (!filePath) return "Error: filePath is required.";
20
21
 
22
+ const readCheck = filesystemGuard.checkRead(filePath);
23
+ if (!readCheck.allowed) return `Error: ${readCheck.reason}`;
24
+
21
25
  if (!existsSync(filePath)) {
22
26
  return `Error: File not found: ${filePath}`;
23
27
  }
@@ -15,15 +15,20 @@ import { execSync } from "node:child_process";
15
15
  import { existsSync, mkdirSync, statSync } from "node:fs";
16
16
  import { platform } from "node:os";
17
17
  import { join } from "node:path";
18
+ import filesystemGuard from "../safety/FilesystemGuard.js";
19
+ import { getTenantTmpDir } from "./_paths.js";
18
20
 
19
21
  export function screenCapture(optionsJson) {
20
22
  try {
21
23
  const opts = optionsJson ? JSON.parse(optionsJson) : {};
22
- const outputDir = opts.outputDir || "/tmp";
24
+ const outputDir = opts.outputDir || getTenantTmpDir("daemora-captures");
23
25
  const region = opts.region; // { x, y, width, height } - screenshot only
24
26
  const mode = (opts.mode || "screenshot").toLowerCase();
25
27
  const duration = parseInt(opts.duration || "10", 10); // seconds - video only
26
28
 
29
+ const wc = filesystemGuard.checkWrite(outputDir);
30
+ if (!wc.allowed) return `Error: ${wc.reason}`;
31
+
27
32
  if (!existsSync(outputDir)) {
28
33
  mkdirSync(outputDir, { recursive: true });
29
34
  }
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import channelRegistry from "../channels/index.js";
17
17
  import { existsSync, statSync } from "node:fs";
18
+ import filesystemGuard from "../safety/FilesystemGuard.js";
18
19
 
19
20
  const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB - most platforms limit around this
20
21
 
@@ -24,6 +25,9 @@ export async function sendFile(channel, target, filePath, caption) {
24
25
  if (!target) return "Error: target is required (chat ID, user ID, phone, or email)";
25
26
  if (!filePath) return "Error: filePath is required";
26
27
 
28
+ const readCheck = filesystemGuard.checkRead(filePath);
29
+ if (!readCheck.allowed) return `Error: ${readCheck.reason}`;
30
+
27
31
  if (!existsSync(filePath)) {
28
32
  return `Error: File not found: ${filePath}`;
29
33
  }
@@ -7,8 +7,8 @@
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
9
9
  import { join } from "node:path";
10
- import { tmpdir } from "node:os";
11
10
  import { randomBytes } from "node:crypto";
11
+ import { getTenantTmpDir } from "./_paths.js";
12
12
 
13
13
  export async function sshTool(action, paramsJson) {
14
14
  if (!action) return "Error: action required. Valid: exec, upload, download, tunnel";
@@ -97,7 +97,7 @@ export async function sshTool(action, paramsJson) {
97
97
 
98
98
  if (action === "keygen") {
99
99
  // Generate a new SSH key pair for the agent's use
100
- const keyDir = join(tmpdir(), `daemora-ssh-${randomBytes(4).toString("hex")}`);
100
+ const keyDir = join(getTenantTmpDir("daemora-ssh"), `keygen-${randomBytes(4).toString("hex")}`);
101
101
  mkdirSync(keyDir, { recursive: true });
102
102
  const keyFile = join(keyDir, "id_ed25519");
103
103
  try {