limbo-ai 1.7.0 → 1.9.0

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.
package/README.md CHANGED
@@ -100,7 +100,7 @@ Managed automatically by `npx limbo-ai start`, stored in `~/.limbo/.env`.
100
100
  | `MODEL_NAME` | no | `claude-opus-4-6` | Model name (e.g. `claude-opus-4-6`, `claude-sonnet-4-6`, `gpt-5.4`) |
101
101
  | `TELEGRAM_ENABLED` | no | `false` | Enable Telegram bot integration |
102
102
  | `TELEGRAM_BOT_TOKEN` | no | — | Telegram bot token (required if `TELEGRAM_ENABLED=true`) |
103
- | `TELEGRAM_AUTO_PAIR_FIRST_DM` | no | `true` | Auto-approves the first Telegram DM sender and persists access (MVP-friendly onboarding) |
103
+ | `TELEGRAM_AUTO_PAIR_FIRST_DM` | no | `false` | Auto-approves the first Telegram DM sender and persists access (must opt-in explicitly) |
104
104
  | `OPENCLAW_GATEWAY_TOKEN` | no | generated | Stable gateway token for OpenClaw-compatible clients |
105
105
 
106
106
  > \* API keys are required only for `AUTH_MODE=api-key`. Subscription auth uses OpenClaw auth profiles instead.
package/SECURITY.md ADDED
@@ -0,0 +1,109 @@
1
+ # Security
2
+
3
+ ## Container Security Model
4
+
5
+ Limbo runs inside a Docker container with the following hardening:
6
+
7
+ - **Non-root user**: The `limbo` user (UID/GID created at build time) runs all processes
8
+ - **Read-only filesystem**: Container root filesystem is immutable (`read_only: true`)
9
+ - **No new privileges**: `no-new-privileges` seccomp flag prevents privilege escalation
10
+ - **Capabilities dropped**: All Linux capabilities are dropped (`cap_drop: ALL`)
11
+ - **Process limit**: PID limit of 200 prevents fork bombs
12
+ - **Loopback binding**: Gateway only listens on `127.0.0.1` — not exposed to LAN
13
+ - **Writable paths**: Only `/data` (volume), `/home/limbo/.openclaw` (volume), `/tmp` (tmpfs), and `/home/limbo/.npm` (tmpfs) are writable
14
+
15
+ ## What Agents Can Access
16
+
17
+ Inside the container, the AI agent can:
18
+
19
+ - Read and write vault notes in `/data/vault/` (via MCP tools only)
20
+ - Execute MCP tools registered through mcporter (vault_search, vault_read, vault_write_note, vault_update_map)
21
+ - Search the web and fetch URLs (`web_search`, `web_fetch` — enabled for recommendations, link previews, etc.)
22
+ - Respond to Telegram messages (if enabled, with pairing required)
23
+ - Make network requests to AI provider APIs (Anthropic, OpenAI, OpenRouter)
24
+
25
+ ## What Agents Cannot Do
26
+
27
+ - **Execute shell commands**: `exec` tool is denied + set to `security: "deny"`
28
+ - **Browse the web**: `browser` tool is denied
29
+ - **Read/write arbitrary files**: `group:fs` is denied, `fs.workspaceOnly` enforced
30
+ - **Modify gateway config**: `gateway` tool is denied
31
+ - **Create scheduled jobs**: `cron` tool is denied
32
+ - **Spawn sub-agents**: `sessions_spawn` and `sessions_send` are denied
33
+ - **Use elevated mode**: `elevated.enabled: false`
34
+ - **Escape the container**: Read-only root filesystem + all capabilities dropped
35
+ - **Escalate privileges**: `no-new-privileges` seccomp flag
36
+ - **Access host filesystem**: Only the bind-mounted vault directory is accessible
37
+ - **Spawn unlimited processes**: PID limit of 200
38
+
39
+ ## OpenClaw Tool Policy
40
+
41
+ The agent runs with `tools.profile: "messaging"` — the most restrictive built-in profile. On top of that:
42
+
43
+ - **Allowed**: `web_search`, `web_fetch` (for link previews, shopping recommendations, general web queries)
44
+ - **Denied**: `exec`, `browser`, `canvas`, `nodes`, `cron`, `gateway`, `sessions_spawn`, `sessions_send`, `process`, `image`, `group:automation`, `group:runtime`, `group:fs`
45
+ - **Exec**: `security: "deny"`, `ask: "always"`
46
+ - **Elevated mode**: disabled
47
+ - **Filesystem**: workspace-only
48
+
49
+ The agent can interact with users via messaging, access vault data through the MCP server, and search/fetch web content. It cannot execute commands, access the filesystem directly, modify the gateway config, or spawn sub-agents.
50
+
51
+ ## API Key Storage
52
+
53
+ API keys are stored as Docker Compose secrets:
54
+
55
+ - **Secret files**: `~/.limbo/secrets/` with `0600` permissions (user read/write only)
56
+ - **Mounted at runtime**: `/run/secrets/<name>` inside the container (read-only, restricted permissions)
57
+ - **Not in environment**: Secrets are scrubbed from the process environment before the gateway starts
58
+ - **Not in `docker inspect`**: Docker secrets don't appear in container inspect output
59
+ - **`.env` file**: Only contains non-sensitive configuration (model provider, model name, language, etc.)
60
+ - **Exception**: `OPENCLAW_GATEWAY_TOKEN` remains in the process environment because the gateway process needs it to validate incoming WebSocket connections. All other secrets (API keys, bot tokens) are scrubbed before exec
61
+
62
+ ## OpenClaw Security
63
+
64
+ Limbo uses OpenClaw in a **personal assistant trust model** (one trusted operator per gateway). Key settings:
65
+
66
+ - `gateway.mode: "local"` — local operation only
67
+ - `gateway.bind: "loopback"` — no network exposure
68
+ - `gateway.auth.mode: "token"` — all WebSocket clients must authenticate
69
+ - `session.dmScope: "per-channel-peer"` — DM sessions are isolated per sender (when using Telegram)
70
+ - `dmPolicy: "pairing"` — unknown Telegram senders must be explicitly approved
71
+
72
+ For more on OpenClaw's security model: https://docs.openclaw.ai/security
73
+
74
+ ## Network Access
75
+
76
+ The container can make outbound HTTPS requests to:
77
+
78
+ - AI provider APIs (api.anthropic.com, api.openai.com, openrouter.ai)
79
+ - Telegram Bot API (api.telegram.org) — if Telegram is enabled
80
+ - Arbitrary URLs via `web_search` and `web_fetch` tools (for user-requested link previews, recommendations, etc.)
81
+
82
+ For stricter environments, Limbo supports an optional Squid proxy sidecar (`--hardened` flag) that restricts outbound traffic to an allowlist of AI provider domains only. Note: `--hardened` mode disables `web_fetch` functionality since the proxy blocks non-allowlisted domains.
83
+
84
+ ## Input Validation
85
+
86
+ The MCP server applies the following protections:
87
+
88
+ - **Path traversal**: All file operations resolve paths and verify they don't escape the vault directory
89
+ - **ID sanitization**: Note and map IDs are restricted to alphanumeric characters, dashes, and underscores
90
+ - **Search queries**: User input is always escaped (no raw regex execution) with a 200-character limit to prevent ReDoS
91
+
92
+ ## Known Limitations
93
+
94
+ - **Prompt injection**: AI agents can be manipulated by carefully crafted input. The container sandbox limits blast radius, but agents may still misuse their available tools within the vault
95
+ - **Vault data exposure**: Anything stored in vault notes is accessible to the agent. Do not store passwords, private keys, or other high-sensitivity secrets in notes
96
+ - **Single trust boundary**: The container runs one agent with one set of credentials. All tools and data inside the container share the same trust level
97
+ - **Web fetch exfiltration**: The agent can read vault notes and fetch arbitrary URLs. A successful prompt injection could theoretically exfiltrate vault data via crafted URLs. Mitigation: DM pairing limits who can trigger the bot, strong models resist injection, and API keys are stored in Docker secrets (not in the vault). Do not store high-sensitivity data in vault notes
98
+ - **Outbound network**: The agent can reach any internet destination via `web_search`/`web_fetch`. Use `--hardened` mode for strict egress filtering (disables web_fetch)
99
+
100
+ ## Reporting Vulnerabilities
101
+
102
+ If you discover a security vulnerability in Limbo:
103
+
104
+ 1. **Do not** open a public issue
105
+ 2. Email the maintainer directly (see repository contact info)
106
+ 3. Include: description, reproduction steps, affected version, and impact assessment
107
+ 4. We will acknowledge within 48 hours and work on a fix
108
+
109
+ For vulnerabilities in OpenClaw itself, follow their responsible disclosure process at https://docs.openclaw.ai/security
package/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Orchestrates the Docker-based Limbo runtime.
4
4
  'use strict';
5
5
 
6
- const { execSync, spawnSync } = require('child_process');
6
+ const { execSync, spawn, spawnSync } = require('child_process');
7
7
  const crypto = require('crypto');
8
8
  const fs = require('fs');
9
9
  const os = require('os');
@@ -14,6 +14,7 @@ const readline = require('readline');
14
14
 
15
15
  const LIMBO_DIR = path.join(os.homedir(), '.limbo');
16
16
  const VAULT_DIR = path.join(LIMBO_DIR, 'vault');
17
+ const SECRETS_DIR = path.join(LIMBO_DIR, 'secrets');
17
18
  const ENV_FILE = path.join(LIMBO_DIR, '.env');
18
19
  const COMPOSE_FILE = path.join(LIMBO_DIR, 'docker-compose.yml');
19
20
  const GHCR_IMAGE = 'ghcr.io/tomasward1/limbo';
@@ -64,12 +65,25 @@ const COMPOSE_CONTENT = `services:
64
65
  limbo:
65
66
  image: ${GHCR_IMAGE}:${DEFAULT_TAG}
66
67
  restart: unless-stopped
68
+ read_only: true
69
+ security_opt:
70
+ - no-new-privileges:true
71
+ cap_drop:
72
+ - ALL
73
+ pids_limit: 200
74
+ tmpfs:
75
+ - /tmp:size=100M,noexec,nosuid,nodev
76
+ - /home/limbo/.npm:size=50M,noexec,nosuid,nodev
67
77
  ports:
68
78
  - "127.0.0.1:${PORT}:${PORT}"
69
79
  volumes:
70
80
  - limbo-data:/data
71
81
  - ./vault:/data/vault
72
82
  - limbo-openclaw-state:/home/limbo/.openclaw
83
+ secrets:
84
+ - llm_api_key
85
+ - telegram_bot_token
86
+ - gateway_token
73
87
  env_file:
74
88
  - .env
75
89
  environment:
@@ -86,6 +100,98 @@ const COMPOSE_CONTENT = `services:
86
100
  retries: 3
87
101
  start_period: 15s
88
102
 
103
+ secrets:
104
+ llm_api_key:
105
+ file: ./secrets/llm_api_key
106
+ telegram_bot_token:
107
+ file: ./secrets/telegram_bot_token
108
+ gateway_token:
109
+ file: ./secrets/gateway_token
110
+
111
+ volumes:
112
+ limbo-data:
113
+ limbo-openclaw-state:
114
+ `;
115
+
116
+ // Hardened variant: adds Squid egress proxy sidecar with domain allowlist
117
+ const COMPOSE_CONTENT_HARDENED = `services:
118
+ limbo:
119
+ image: ${GHCR_IMAGE}:${DEFAULT_TAG}
120
+ restart: unless-stopped
121
+ read_only: true
122
+ security_opt:
123
+ - no-new-privileges:true
124
+ cap_drop:
125
+ - ALL
126
+ pids_limit: 200
127
+ tmpfs:
128
+ - /tmp:size=100M,noexec,nosuid,nodev
129
+ - /home/limbo/.npm:size=50M,noexec,nosuid,nodev
130
+ ports:
131
+ - "127.0.0.1:${PORT}:${PORT}"
132
+ volumes:
133
+ - limbo-data:/data
134
+ - ./vault:/data/vault
135
+ - limbo-openclaw-state:/home/limbo/.openclaw
136
+ secrets:
137
+ - llm_api_key
138
+ - telegram_bot_token
139
+ - gateway_token
140
+ env_file:
141
+ - .env
142
+ environment:
143
+ OPENCLAW_CONFIG_PATH: /home/limbo/.openclaw/openclaw.json
144
+ OPENCLAW_STATE_DIR: /home/limbo/.openclaw
145
+ HTTP_PROXY: http://squid:3128
146
+ HTTPS_PROXY: http://squid:3128
147
+ NO_PROXY: "127.0.0.1,localhost"
148
+ networks:
149
+ - internal
150
+ healthcheck:
151
+ test:
152
+ - CMD-SHELL
153
+ - >-
154
+ node -e "const s=require('net').connect(${PORT},'127.0.0.1');const
155
+ done=(c)=>{try{s.destroy()}catch{};process.exit(c)};s.on('connect',()=>done(0));s.on('error',()=>done(1));setTimeout(()=>done(1),2000);"
156
+ interval: 30s
157
+ timeout: 10s
158
+ retries: 3
159
+ start_period: 15s
160
+
161
+ squid:
162
+ image: ubuntu/squid:latest
163
+ restart: unless-stopped
164
+ read_only: true
165
+ security_opt:
166
+ - no-new-privileges:true
167
+ cap_drop:
168
+ - ALL
169
+ cap_add:
170
+ - NET_BIND_SERVICE
171
+ tmpfs:
172
+ - /var/spool/squid:size=50M
173
+ - /var/log/squid:size=10M
174
+ - /var/run:size=5M
175
+ networks:
176
+ - internal
177
+ - external
178
+ volumes:
179
+ - ./squid/squid.conf:/etc/squid/squid.conf:ro
180
+ - ./squid/allowed-domains.txt:/etc/squid/allowed-domains.txt:ro
181
+
182
+ networks:
183
+ internal:
184
+ internal: true
185
+ external:
186
+
187
+ secrets:
188
+ llm_api_key:
189
+ file: ./secrets/llm_api_key
190
+ telegram_bot_token:
191
+ file: ./secrets/telegram_bot_token
192
+ gateway_token:
193
+ file: ./secrets/gateway_token
194
+
89
195
  volumes:
90
196
  limbo-data:
91
197
  limbo-openclaw-state:
@@ -106,7 +212,7 @@ const TEXT = {
106
212
  modelQuestion: 'Model',
107
213
  customModel: 'Add another supported model name',
108
214
  customModelPrompt: ' Model name: ',
109
- invalidModel: 'That model is not in the current OpenClaw docs allowlist for this provider/auth path.',
215
+ invalidModel: 'That model is not supported for this provider and access method.',
110
216
  supportedModels: 'Supported models:',
111
217
  openAiApiKeyPrompt: ' OpenAI API key (sk-...): ',
112
218
  anthropicApiKeyPrompt: ' Anthropic API key (sk-ant-...): ',
@@ -116,7 +222,7 @@ const TEXT = {
116
222
  telegramQuestion: 'Want to speak to Limbo through Telegram?',
117
223
  telegramBotFatherSteps: [
118
224
  'To create a Telegram bot:',
119
- ' 1. Open Telegram and search for @BotFather',
225
+ ' 1. Open @BotFather: https://t.me/BotFather',
120
226
  ' 2. Send the command: /newbot',
121
227
  ' 3. Choose a display name for your bot (e.g. "My Limbo")',
122
228
  ' 4. Choose a username ending in "bot" (e.g. "my_limbo_bot")',
@@ -126,6 +232,7 @@ const TEXT = {
126
232
  ],
127
233
  telegramTokenSafe: 'Your token is stored locally in ~/.limbo/.env and never sent anywhere.',
128
234
  telegramTokenPrompt: ' Telegram bot token: ',
235
+ telegramAutoPairQuestion: 'Auto-approve the first Telegram DM? (Convenient but less secure)',
129
236
  yes: 'Yes',
130
237
  no: 'No',
131
238
  configuration: 'Configuration',
@@ -145,15 +252,16 @@ const TEXT = {
145
252
  logsHint: 'Check logs with: limbo logs',
146
253
  healthy: 'Container is healthy.',
147
254
  subscriptionSetup: 'Provider authentication',
148
- openaiSubscriptionIntro: 'Limbo will open OpenClaw auth inside the container so you can complete Codex login.',
255
+ openaiSubscriptionIntro: 'Limbo will authenticate with your AI provider. A URL will appear — open it in your browser to complete login.',
149
256
  anthropicSubscriptionIntro: 'Generate a Claude setup-token on any machine with `claude setup-token`, then paste it into the next step.',
150
- authFlowStart: 'Starting provider auth flow...',
151
- authFlowDone: 'Provider auth completed.',
152
- authFlowFailed: 'Provider auth did not complete successfully.',
153
- authStatusFailed: 'OpenClaw still reports missing or invalid auth for the selected provider.',
154
- configFlowStart: 'Applying OpenClaw config...',
155
- configFlowDone: 'OpenClaw config updated.',
156
- configFlowFailed: 'Could not update the OpenClaw config for Limbo.',
257
+ authFlowStart: 'Starting authentication...',
258
+ authFlowDone: 'Authentication complete.',
259
+ authFlowFailed: 'Authentication did not complete successfully.',
260
+ authStatusFailed: 'Provider auth is still missing or invalid. Try running with --reconfigure.',
261
+ configFlowStart: 'Applying configuration...',
262
+ configFlowDone: 'Configuration applied.',
263
+ configFlowFailed: 'Could not apply configuration. Check your settings and try again.',
264
+ composing: 'Initializing...',
157
265
  success: 'Limbo is running!',
158
266
  gateway: 'Gateway',
159
267
  gatewayToken: 'Gateway token',
@@ -179,6 +287,7 @@ const TEXT = {
179
287
  helpStatus: 'Show container status',
180
288
  helpHelp: 'Show this help',
181
289
  helpReconfigure: 'Reconfigure auth and onboarding settings (use with start)',
290
+ securityNotice: 'Security notice: Limbo runs AI agents inside a Docker container with access to your API keys and vault data. The container can make network requests to AI provider APIs. Do not store sensitive secrets (passwords, private keys) in your vault notes.',
182
291
  unknownCommand: (cmd) => `Unknown command: ${cmd}`,
183
292
  },
184
293
  es: {
@@ -195,7 +304,7 @@ const TEXT = {
195
304
  modelQuestion: 'Modelo',
196
305
  customModel: 'Agregar otro nombre de modelo soportado',
197
306
  customModelPrompt: ' Nombre del modelo: ',
198
- invalidModel: 'Ese modelo no esta en la allowlist actual de OpenClaw para este provider y metodo.',
307
+ invalidModel: 'Ese modelo no esta soportado para este provider y metodo de acceso.',
199
308
  supportedModels: 'Modelos soportados:',
200
309
  openAiApiKeyPrompt: ' OpenAI API key (sk-...): ',
201
310
  anthropicApiKeyPrompt: ' Anthropic API key (sk-ant-...): ',
@@ -205,7 +314,7 @@ const TEXT = {
205
314
  telegramQuestion: 'Quieres hablar con Limbo por Telegram?',
206
315
  telegramBotFatherSteps: [
207
316
  'Para crear un bot de Telegram:',
208
- ' 1. Abri Telegram y busca @BotFather',
317
+ ' 1. Abri @BotFather: https://t.me/BotFather',
209
318
  ' 2. Manda el comando: /newbot',
210
319
  ' 3. Elegí un nombre para tu bot (ej: "Mi Limbo")',
211
320
  ' 4. Elegí un username que termine en "bot" (ej: "mi_limbo_bot")',
@@ -215,6 +324,7 @@ const TEXT = {
215
324
  ],
216
325
  telegramTokenSafe: 'Tu token se guarda localmente en ~/.limbo/.env y nunca se envia a ningun servidor externo.',
217
326
  telegramTokenPrompt: ' Telegram bot token: ',
327
+ telegramAutoPairQuestion: 'Auto-aprobar el primer DM de Telegram? (Conveniente pero menos seguro)',
218
328
  yes: 'Si',
219
329
  no: 'No',
220
330
  configuration: 'Configuracion',
@@ -234,15 +344,16 @@ const TEXT = {
234
344
  logsHint: 'Mira los logs con: limbo logs',
235
345
  healthy: 'El container esta healthy.',
236
346
  subscriptionSetup: 'Autenticacion del provider',
237
- openaiSubscriptionIntro: 'Limbo va a abrir la autenticacion de OpenClaw dentro del container para que completes el login de Codex.',
347
+ openaiSubscriptionIntro: 'Limbo va a autenticarse con tu proveedor de IA. Aparecera una URL abrisla en el navegador para completar el login.',
238
348
  anthropicSubscriptionIntro: 'Genera un Claude setup-token en cualquier maquina con `claude setup-token` y pegalo en el siguiente paso.',
239
- authFlowStart: 'Iniciando autenticacion del provider...',
240
- authFlowDone: 'Autenticacion del provider completada.',
241
- authFlowFailed: 'La autenticacion del provider no termino correctamente.',
242
- authStatusFailed: 'OpenClaw sigue reportando auth faltante o invalida para el provider elegido.',
243
- configFlowStart: 'Aplicando configuracion de OpenClaw...',
244
- configFlowDone: 'Configuracion de OpenClaw actualizada.',
245
- configFlowFailed: 'No se pudo actualizar la configuracion de OpenClaw para Limbo.',
349
+ authFlowStart: 'Iniciando autenticacion...',
350
+ authFlowDone: 'Autenticacion completada.',
351
+ authFlowFailed: 'La autenticacion no termino correctamente.',
352
+ authStatusFailed: 'La autenticacion del provider sigue siendo invalida o no esta configurada. Proba con --reconfigure.',
353
+ configFlowStart: 'Aplicando configuracion...',
354
+ configFlowDone: 'Configuracion aplicada.',
355
+ configFlowFailed: 'No se pudo aplicar la configuracion. Revisa los ajustes e intenta de nuevo.',
356
+ composing: 'Inicializando...',
246
357
  success: 'Limbo esta corriendo!',
247
358
  gateway: 'Gateway',
248
359
  gatewayToken: 'Token del gateway',
@@ -268,6 +379,7 @@ const TEXT = {
268
379
  helpStatus: 'Muestra el estado del container',
269
380
  helpHelp: 'Muestra esta ayuda',
270
381
  helpReconfigure: 'Reconfigura auth y onboarding (usar con start)',
382
+ securityNotice: 'Aviso de seguridad: Limbo corre agentes de IA dentro de un container Docker con acceso a tus API keys y datos del vault. El container puede hacer requests a las APIs de los proveedores de IA. No guardes secretos sensibles (passwords, claves privadas) en las notas del vault.',
271
383
  unknownCommand: (cmd) => `Comando desconocido: ${cmd}`,
272
384
  },
273
385
  };
@@ -433,15 +545,35 @@ function normalizeConfig(cfg, existingEnv = {}) {
433
545
  LLM_API_KEY: cfg.apiKey || (cfg.keepExisting ? existingEnv.LLM_API_KEY || '' : ''),
434
546
  TELEGRAM_ENABLED: cfg.telegramEnabled || existingEnv.TELEGRAM_ENABLED || 'false',
435
547
  TELEGRAM_BOT_TOKEN: cfg.telegramToken || (cfg.keepExisting ? existingEnv.TELEGRAM_BOT_TOKEN || '' : ''),
436
- TELEGRAM_AUTO_PAIR_FIRST_DM: existingEnv.TELEGRAM_AUTO_PAIR_FIRST_DM || 'true',
548
+ TELEGRAM_AUTO_PAIR_FIRST_DM: cfg.telegramAutoPair || existingEnv.TELEGRAM_AUTO_PAIR_FIRST_DM || 'false',
437
549
  OPENCLAW_GATEWAY_TOKEN: gatewayToken,
438
550
  };
439
551
 
440
552
  return base;
441
553
  }
442
554
 
555
+ function writeSecretFile(name, value) {
556
+ fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
557
+ const filePath = path.join(SECRETS_DIR, name);
558
+ fs.writeFileSync(filePath, value || '', { mode: 0o600 });
559
+ }
560
+
561
+ function writeSecrets(cfg, existingEnv = {}) {
562
+ const normalized = normalizeConfig(cfg, existingEnv);
563
+ writeSecretFile('llm_api_key', normalized.LLM_API_KEY);
564
+ writeSecretFile('telegram_bot_token', normalized.TELEGRAM_BOT_TOKEN);
565
+ writeSecretFile('gateway_token', normalized.OPENCLAW_GATEWAY_TOKEN);
566
+ }
567
+
568
+ const SECRET_KEYS = new Set([
569
+ 'LLM_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY',
570
+ 'TELEGRAM_BOT_TOKEN', 'OPENCLAW_GATEWAY_TOKEN',
571
+ ]);
572
+
443
573
  function writeEnv(cfg, existingEnv = {}) {
574
+ writeSecrets(cfg, existingEnv);
444
575
  const content = Object.entries(normalizeConfig(cfg, existingEnv))
576
+ .filter(([key]) => !SECRET_KEYS.has(key))
445
577
  .map(([key, value]) => `${key}=${value}`)
446
578
  .join('\n') + '\n';
447
579
  fs.writeFileSync(ENV_FILE, content, { mode: 0o600 });
@@ -542,6 +674,7 @@ async function collectConfig(existingEnv = {}) {
542
674
  ], language);
543
675
 
544
676
  let telegramToken = '';
677
+ let telegramAutoPair = 'false';
545
678
  if (telegramChoice.value === 'true') {
546
679
  console.log('');
547
680
  TEXT[language].telegramBotFatherSteps.forEach((line) => console.log(` ${c.dim}${line}${c.reset}`));
@@ -551,6 +684,11 @@ async function collectConfig(existingEnv = {}) {
551
684
  t(language, 'telegramTokenPrompt'),
552
685
  (value) => value ? { ok: true, value } : { ok: false, message: t(language, 'requiredField') },
553
686
  );
687
+ const autoPairChoice = await selectMenu(t(language, 'telegramAutoPairQuestion'), [
688
+ { label: t(language, 'no'), value: 'false' },
689
+ { label: t(language, 'yes'), value: 'true' },
690
+ ], language);
691
+ telegramAutoPair = autoPairChoice.value;
554
692
  }
555
693
 
556
694
  return {
@@ -562,21 +700,50 @@ async function collectConfig(existingEnv = {}) {
562
700
  apiKey,
563
701
  telegramEnabled: telegramChoice.value,
564
702
  telegramToken,
703
+ telegramAutoPair,
565
704
  gatewayToken: existingEnv.OPENCLAW_GATEWAY_TOKEN || generateGatewayToken(),
566
705
  };
567
706
  }
568
707
 
569
- function ensureComposeFile() {
708
+ function ensureComposeFile(hardened = false) {
570
709
  fs.mkdirSync(LIMBO_DIR, { recursive: true });
571
710
  fs.mkdirSync(path.join(VAULT_DIR, 'notes'), { recursive: true });
572
711
  fs.mkdirSync(path.join(VAULT_DIR, 'maps'), { recursive: true });
573
- fs.writeFileSync(COMPOSE_FILE, COMPOSE_CONTENT);
712
+ fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
713
+ // Ensure secret files exist (Docker Compose secrets require the files to be present)
714
+ for (const name of ['llm_api_key', 'telegram_bot_token', 'gateway_token']) {
715
+ const fp = path.join(SECRETS_DIR, name);
716
+ if (!fs.existsSync(fp)) fs.writeFileSync(fp, '', { mode: 0o600 });
717
+ }
718
+ if (hardened) {
719
+ // Copy squid config files for egress filtering
720
+ const squidDir = path.join(LIMBO_DIR, 'squid');
721
+ fs.mkdirSync(squidDir, { recursive: true });
722
+ const srcSquidDir = path.join(__dirname, 'squid');
723
+ for (const file of ['squid.conf', 'allowed-domains.txt']) {
724
+ const src = path.join(srcSquidDir, file);
725
+ const dest = path.join(squidDir, file);
726
+ if (fs.existsSync(src)) fs.copyFileSync(src, dest);
727
+ }
728
+ }
729
+ fs.writeFileSync(COMPOSE_FILE, hardened ? COMPOSE_CONTENT_HARDENED : COMPOSE_CONTENT);
730
+ }
731
+
732
+ function readSecretFile(name) {
733
+ const fp = path.join(SECRETS_DIR, name);
734
+ try { return fs.readFileSync(fp, 'utf8').trim(); } catch { return ''; }
574
735
  }
575
736
 
576
737
  function ensureGatewayToken(existingEnv) {
577
- if (existingEnv.OPENCLAW_GATEWAY_TOKEN) return existingEnv.OPENCLAW_GATEWAY_TOKEN;
738
+ // Check secret file first, then legacy env
739
+ const fromFile = readSecretFile('gateway_token');
740
+ if (fromFile) return fromFile;
741
+ if (existingEnv.OPENCLAW_GATEWAY_TOKEN) {
742
+ writeSecretFile('gateway_token', existingEnv.OPENCLAW_GATEWAY_TOKEN);
743
+ return existingEnv.OPENCLAW_GATEWAY_TOKEN;
744
+ }
578
745
  writeEnv({ keepExisting: true }, existingEnv);
579
- return parseEnvFile().OPENCLAW_GATEWAY_TOKEN;
746
+ return readSecretFile('gateway_token');
580
747
  }
581
748
 
582
749
  function pullOrBuildImage(lang) {
@@ -642,7 +809,53 @@ function applyOpenClawConfig(cfg) {
642
809
  ok(t(cfg.language, 'configFlowDone'));
643
810
  }
644
811
 
645
- function runSubscriptionAuthFlow(cfg) {
812
+ // Spawn OpenClaw auth with filtered output: extract OAuth URLs, suppress branding.
813
+ // Uses async spawn so URLs appear in real-time (spawnSync would buffer until exit).
814
+ function streamFilteredAuth(dockerArgs) {
815
+ return new Promise((resolve) => {
816
+ const proc = spawn('docker', dockerArgs, {
817
+ cwd: LIMBO_DIR,
818
+ stdio: ['inherit', 'pipe', 'pipe'],
819
+ });
820
+
821
+ const urlRe = /https?:\/\/[^\s"'<>\]]+/g;
822
+ const seenUrls = new Set();
823
+ let buf = '';
824
+
825
+ const handleData = (data) => {
826
+ buf += data.toString();
827
+ const lines = buf.split('\n');
828
+ buf = lines.pop(); // hold incomplete last line
829
+ for (const line of lines) emitLine(line);
830
+ };
831
+
832
+ const emitLine = (line) => {
833
+ const urls = line.match(urlRe) || [];
834
+ if (urls.length > 0) {
835
+ for (const url of urls) {
836
+ if (!seenUrls.has(url)) {
837
+ seenUrls.add(url);
838
+ console.log(`\n ${c.cyan}${c.bold}→ ${url}${c.reset}\n`);
839
+ }
840
+ }
841
+ return;
842
+ }
843
+ // Suppress lines that only contain internal gateway/runtime branding
844
+ if (/openclaw/i.test(line)) return;
845
+ if (line.trim()) console.log(` ${line}`);
846
+ };
847
+
848
+ proc.stdout.on('data', handleData);
849
+ proc.stderr.on('data', handleData);
850
+ proc.on('close', (code) => {
851
+ if (buf.trim()) emitLine(buf);
852
+ resolve(code ?? 1);
853
+ });
854
+ proc.on('error', () => resolve(1));
855
+ });
856
+ }
857
+
858
+ async function runSubscriptionAuthFlow(cfg) {
646
859
  header(t(cfg.language, 'subscriptionSetup'));
647
860
  if (cfg.providerFamily === 'openai') {
648
861
  log(t(cfg.language, 'openaiSubscriptionIntro'));
@@ -655,8 +868,17 @@ function runSubscriptionAuthFlow(cfg) {
655
868
  ? ['models', 'auth', 'login', '--provider', 'openai-codex']
656
869
  : ['models', 'auth', 'paste-token', '--provider', 'anthropic'];
657
870
 
658
- const authResult = runOpenClaw(authArgs);
659
- if (authResult.status !== 0) die(t(cfg.language, 'authFlowFailed'));
871
+ let exitCode;
872
+ if (cfg.providerFamily === 'openai') {
873
+ // Stream output and extract OAuth URL so the user never sees "openclaw"
874
+ exitCode = await streamFilteredAuth(['compose', 'run', '--rm', '--entrypoint', 'openclaw', 'limbo', ...authArgs]);
875
+ } else {
876
+ // Anthropic paste-token is interactive (user pastes a token); keep stdio inherited
877
+ const authResult = runOpenClaw(authArgs);
878
+ exitCode = authResult.status;
879
+ }
880
+
881
+ if (exitCode !== 0) die(t(cfg.language, 'authFlowFailed'));
660
882
 
661
883
  const statusResult = runOpenClaw(
662
884
  ['models', 'status', '--check', '--probe-provider', cfg.provider],
@@ -702,7 +924,8 @@ ${c.green}${c.bold}╚═══════════════════
702
924
  async function cmdStart() {
703
925
  if (!hasDocker()) die(t('en', 'dockerMissing'));
704
926
 
705
- ensureComposeFile();
927
+ const hardened = process.argv.includes('--hardened');
928
+ ensureComposeFile(hardened);
706
929
 
707
930
  const existingEnv = parseEnvFile();
708
931
  const alreadyHasEnv = fs.existsSync(ENV_FILE);
@@ -746,7 +969,7 @@ async function cmdStart() {
746
969
  pullOrBuildImage(cfg.language);
747
970
 
748
971
  if (cfg.authMode === 'subscription' && (process.argv.includes('--reconfigure') || !alreadyHasEnv)) {
749
- runSubscriptionAuthFlow(cfg);
972
+ await runSubscriptionAuthFlow(cfg);
750
973
  }
751
974
 
752
975
  applyOpenClawConfig({
@@ -756,7 +979,12 @@ async function cmdStart() {
756
979
  });
757
980
 
758
981
  header(t(cfg.language, 'starting'));
759
- run('docker compose up -d --remove-orphans');
982
+ log(t(cfg.language, 'composing'));
983
+ const upResult = runDockerCompose(['up', '-d', '--remove-orphans'], { stdio: 'pipe' });
984
+ if (upResult.status !== 0) {
985
+ process.stderr.write(upResult.stderr || '');
986
+ die('Container failed to start. Run `limbo logs` to investigate.');
987
+ }
760
988
 
761
989
  header(t(cfg.language, 'verifying'));
762
990
  const healthy = waitForHealthy(cfg.language);
@@ -767,10 +995,12 @@ async function cmdStart() {
767
995
  ok(t(cfg.language, 'healthy'));
768
996
  }
769
997
 
998
+ console.log(`\n ${c.yellow}⚠ ${t(cfg.language, 'securityNotice')}${c.reset}\n`);
999
+
770
1000
  printSuccess({
771
1001
  language: cfg.language,
772
1002
  telegramEnabled: mergedEnv.TELEGRAM_ENABLED || cfg.telegramEnabled || 'false',
773
- }, parseEnvFile().OPENCLAW_GATEWAY_TOKEN);
1003
+ }, readSecretFile('gateway_token') || mergedEnv.OPENCLAW_GATEWAY_TOKEN);
774
1004
  }
775
1005
 
776
1006
  function cmdStop() {
@@ -819,6 +1049,7 @@ ${c.bold}Commands:${c.reset}
819
1049
 
820
1050
  ${c.bold}Flags:${c.reset}
821
1051
  --reconfigure Reconfigure auth and onboarding settings (use with start)
1052
+ --hardened Enable egress proxy (restricts outbound to AI provider APIs only)
822
1053
 
823
1054
  ${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
824
1055
  `);
@@ -1,5 +1,5 @@
1
1
  import { readFile, readdir, stat } from "fs/promises";
2
- import { join } from "path";
2
+ import { join, resolve } from "path";
3
3
 
4
4
  const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
5
5
  const NOTES_DIR = join(VAULT_PATH, "notes");
@@ -77,6 +77,12 @@ export async function vaultRead(noteId) {
77
77
  const filePath = await findNote(safe);
78
78
  if (!filePath) return null;
79
79
 
80
+ // Defense-in-depth: ensure resolved path stays within vault
81
+ const resolved = resolve(filePath);
82
+ if (!resolved.startsWith(resolve(NOTES_DIR) + "/")) {
83
+ throw new Error("Path traversal detected");
84
+ }
85
+
80
86
  try {
81
87
  return await readFile(filePath, "utf8");
82
88
  } catch (err) {
@@ -1,5 +1,5 @@
1
1
  import { readFile, writeFile, mkdir } from "fs/promises";
2
- import { join } from "path";
2
+ import { join, resolve } from "path";
3
3
 
4
4
  const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
5
5
  const MAPS_DIR = join(VAULT_PATH, "maps");
@@ -71,7 +71,10 @@ export async function vaultUpdateMap(map, section, entries) {
71
71
  const safeMap = sanitizeName(map);
72
72
  await mkdir(MAPS_DIR, { recursive: true });
73
73
 
74
- const filePath = join(MAPS_DIR, `${safeMap}.md`);
74
+ const filePath = resolve(MAPS_DIR, `${safeMap}.md`);
75
+ if (!filePath.startsWith(resolve(MAPS_DIR) + "/")) {
76
+ throw new Error("Path traversal detected");
77
+ }
75
78
 
76
79
  let existing = "";
77
80
  try {
@@ -1,5 +1,5 @@
1
1
  import { writeFile, mkdir } from "fs/promises";
2
- import { join } from "path";
2
+ import { join, resolve } from "path";
3
3
 
4
4
  const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
5
5
  const NOTES_DIR = join(VAULT_PATH, "notes");
@@ -79,7 +79,10 @@ export async function vaultWriteNote(note) {
79
79
 
80
80
  const frontmatter = buildFrontmatter({ ...note, id: safe });
81
81
  const fileContent = `${frontmatter}\n\n${note.content}\n`;
82
- const filePath = join(targetDir, `${safe}.md`);
82
+ const filePath = resolve(targetDir, `${safe}.md`);
83
+ if (!filePath.startsWith(resolve(NOTES_DIR) + "/")) {
84
+ throw new Error("Path traversal detected");
85
+ }
83
86
 
84
87
  await writeFile(filePath, fileContent, "utf8");
85
88
  return { id: safe, path: filePath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "limbo-ai",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Your personal AI memory agent — install and manage Limbo via npx",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -0,0 +1,4 @@
1
+ .api.anthropic.com
2
+ .api.openai.com
3
+ .openrouter.ai
4
+ .api.telegram.org
@@ -0,0 +1,39 @@
1
+ # Squid proxy config for Limbo egress filtering
2
+ # Only allows HTTPS CONNECT to allowlisted domains
3
+
4
+ # ACL: allowed destination domains
5
+ acl allowed_domains dstdomain "/etc/squid/allowed-domains.txt"
6
+
7
+ # ACL: SSL ports used by AI provider APIs
8
+ acl SSL_ports port 443
9
+
10
+ # Only allow CONNECT method (HTTPS tunneling)
11
+ acl CONNECT method CONNECT
12
+
13
+ # Only CONNECT (HTTPS tunneling) is allowed — deny plain HTTP
14
+ http_access deny !CONNECT
15
+
16
+ # Deny CONNECT to non-SSL ports
17
+ http_access deny CONNECT !SSL_ports
18
+
19
+ # Allow CONNECT to allowlisted domains only
20
+ http_access allow CONNECT allowed_domains
21
+
22
+ # Deny everything else
23
+ http_access deny all
24
+
25
+ # Listen on port 3128
26
+ http_port 3128
27
+
28
+ # Minimal logging (privacy)
29
+ access_log none
30
+ cache_log /dev/null
31
+
32
+ # No caching — we're a pure forward proxy
33
+ cache deny all
34
+
35
+ # Suppress Squid version in headers
36
+ httpd_suppress_version_string on
37
+
38
+ # Visible hostname (internal only)
39
+ visible_hostname limbo-proxy