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 +1 -1
- package/SECURITY.md +109 -0
- package/cli.js +264 -33
- package/mcp-server/tools/read.js +7 -1
- package/mcp-server/tools/update-map.js +5 -2
- package/mcp-server/tools/write.js +5 -2
- package/package.json +1 -1
- package/squid/allowed-domains.txt +4 -0
- package/squid/squid.conf +39 -0
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 | `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
151
|
-
authFlowDone: '
|
|
152
|
-
authFlowFailed: '
|
|
153
|
-
authStatusFailed: '
|
|
154
|
-
configFlowStart: 'Applying
|
|
155
|
-
configFlowDone: '
|
|
156
|
-
configFlowFailed: 'Could not
|
|
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
|
|
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
|
|
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
|
|
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
|
|
240
|
-
authFlowDone: 'Autenticacion
|
|
241
|
-
authFlowFailed: 'La autenticacion
|
|
242
|
-
authStatusFailed: '
|
|
243
|
-
configFlowStart: 'Aplicando configuracion
|
|
244
|
-
configFlowDone: 'Configuracion
|
|
245
|
-
configFlowFailed: 'No se pudo
|
|
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 || '
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
659
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
`);
|
package/mcp-server/tools/read.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
package/squid/squid.conf
ADDED
|
@@ -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
|