limbo-ai 1.6.1 → 1.8.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 +202 -7
- package/mcp-server/index.js +21 -13
- package/mcp-server/tools/read.js +66 -4
- package/mcp-server/tools/search.js +55 -22
- package/mcp-server/tools/update-map.js +23 -6
- package/mcp-server/tools/write.js +48 -11
- 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
|
@@ -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:
|
|
@@ -114,7 +220,19 @@ const TEXT = {
|
|
|
114
220
|
invalidOpenAIKey: 'OpenAI API keys usually start with "sk-".',
|
|
115
221
|
invalidAnthropicKey: 'Anthropic API keys usually start with "sk-ant-".',
|
|
116
222
|
telegramQuestion: 'Want to speak to Limbo through Telegram?',
|
|
223
|
+
telegramBotFatherSteps: [
|
|
224
|
+
'To create a Telegram bot:',
|
|
225
|
+
' 1. Open @BotFather: https://t.me/BotFather',
|
|
226
|
+
' 2. Send the command: /newbot',
|
|
227
|
+
' 3. Choose a display name for your bot (e.g. "My Limbo")',
|
|
228
|
+
' 4. Choose a username ending in "bot" (e.g. "my_limbo_bot")',
|
|
229
|
+
' 5. BotFather will reply with a token like:',
|
|
230
|
+
' 123456789:AAFxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
|
231
|
+
' 6. Copy that token and paste it below.',
|
|
232
|
+
],
|
|
233
|
+
telegramTokenSafe: 'Your token is stored locally in ~/.limbo/.env and never sent anywhere.',
|
|
117
234
|
telegramTokenPrompt: ' Telegram bot token: ',
|
|
235
|
+
telegramAutoPairQuestion: 'Auto-approve the first Telegram DM? (Convenient but less secure)',
|
|
118
236
|
yes: 'Yes',
|
|
119
237
|
no: 'No',
|
|
120
238
|
configuration: 'Configuration',
|
|
@@ -168,6 +286,7 @@ const TEXT = {
|
|
|
168
286
|
helpStatus: 'Show container status',
|
|
169
287
|
helpHelp: 'Show this help',
|
|
170
288
|
helpReconfigure: 'Reconfigure auth and onboarding settings (use with start)',
|
|
289
|
+
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.',
|
|
171
290
|
unknownCommand: (cmd) => `Unknown command: ${cmd}`,
|
|
172
291
|
},
|
|
173
292
|
es: {
|
|
@@ -192,7 +311,19 @@ const TEXT = {
|
|
|
192
311
|
invalidOpenAIKey: 'Las API keys de OpenAI normalmente empiezan con "sk-".',
|
|
193
312
|
invalidAnthropicKey: 'Las API keys de Anthropic normalmente empiezan con "sk-ant-".',
|
|
194
313
|
telegramQuestion: 'Quieres hablar con Limbo por Telegram?',
|
|
314
|
+
telegramBotFatherSteps: [
|
|
315
|
+
'Para crear un bot de Telegram:',
|
|
316
|
+
' 1. Abri @BotFather: https://t.me/BotFather',
|
|
317
|
+
' 2. Manda el comando: /newbot',
|
|
318
|
+
' 3. Elegí un nombre para tu bot (ej: "Mi Limbo")',
|
|
319
|
+
' 4. Elegí un username que termine en "bot" (ej: "mi_limbo_bot")',
|
|
320
|
+
' 5. BotFather te va a responder con un token como este:',
|
|
321
|
+
' 123456789:AAFxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
|
322
|
+
' 6. Copiá ese token y pegalo abajo.',
|
|
323
|
+
],
|
|
324
|
+
telegramTokenSafe: 'Tu token se guarda localmente en ~/.limbo/.env y nunca se envia a ningun servidor externo.',
|
|
195
325
|
telegramTokenPrompt: ' Telegram bot token: ',
|
|
326
|
+
telegramAutoPairQuestion: 'Auto-aprobar el primer DM de Telegram? (Conveniente pero menos seguro)',
|
|
196
327
|
yes: 'Si',
|
|
197
328
|
no: 'No',
|
|
198
329
|
configuration: 'Configuracion',
|
|
@@ -246,6 +377,7 @@ const TEXT = {
|
|
|
246
377
|
helpStatus: 'Muestra el estado del container',
|
|
247
378
|
helpHelp: 'Muestra esta ayuda',
|
|
248
379
|
helpReconfigure: 'Reconfigura auth y onboarding (usar con start)',
|
|
380
|
+
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.',
|
|
249
381
|
unknownCommand: (cmd) => `Comando desconocido: ${cmd}`,
|
|
250
382
|
},
|
|
251
383
|
};
|
|
@@ -411,15 +543,35 @@ function normalizeConfig(cfg, existingEnv = {}) {
|
|
|
411
543
|
LLM_API_KEY: cfg.apiKey || (cfg.keepExisting ? existingEnv.LLM_API_KEY || '' : ''),
|
|
412
544
|
TELEGRAM_ENABLED: cfg.telegramEnabled || existingEnv.TELEGRAM_ENABLED || 'false',
|
|
413
545
|
TELEGRAM_BOT_TOKEN: cfg.telegramToken || (cfg.keepExisting ? existingEnv.TELEGRAM_BOT_TOKEN || '' : ''),
|
|
414
|
-
TELEGRAM_AUTO_PAIR_FIRST_DM: existingEnv.TELEGRAM_AUTO_PAIR_FIRST_DM || '
|
|
546
|
+
TELEGRAM_AUTO_PAIR_FIRST_DM: cfg.telegramAutoPair || existingEnv.TELEGRAM_AUTO_PAIR_FIRST_DM || 'false',
|
|
415
547
|
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
|
|
416
548
|
};
|
|
417
549
|
|
|
418
550
|
return base;
|
|
419
551
|
}
|
|
420
552
|
|
|
553
|
+
function writeSecretFile(name, value) {
|
|
554
|
+
fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
|
|
555
|
+
const filePath = path.join(SECRETS_DIR, name);
|
|
556
|
+
fs.writeFileSync(filePath, value || '', { mode: 0o600 });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function writeSecrets(cfg, existingEnv = {}) {
|
|
560
|
+
const normalized = normalizeConfig(cfg, existingEnv);
|
|
561
|
+
writeSecretFile('llm_api_key', normalized.LLM_API_KEY);
|
|
562
|
+
writeSecretFile('telegram_bot_token', normalized.TELEGRAM_BOT_TOKEN);
|
|
563
|
+
writeSecretFile('gateway_token', normalized.OPENCLAW_GATEWAY_TOKEN);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const SECRET_KEYS = new Set([
|
|
567
|
+
'LLM_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY',
|
|
568
|
+
'TELEGRAM_BOT_TOKEN', 'OPENCLAW_GATEWAY_TOKEN',
|
|
569
|
+
]);
|
|
570
|
+
|
|
421
571
|
function writeEnv(cfg, existingEnv = {}) {
|
|
572
|
+
writeSecrets(cfg, existingEnv);
|
|
422
573
|
const content = Object.entries(normalizeConfig(cfg, existingEnv))
|
|
574
|
+
.filter(([key]) => !SECRET_KEYS.has(key))
|
|
423
575
|
.map(([key, value]) => `${key}=${value}`)
|
|
424
576
|
.join('\n') + '\n';
|
|
425
577
|
fs.writeFileSync(ENV_FILE, content, { mode: 0o600 });
|
|
@@ -520,11 +672,21 @@ async function collectConfig(existingEnv = {}) {
|
|
|
520
672
|
], language);
|
|
521
673
|
|
|
522
674
|
let telegramToken = '';
|
|
675
|
+
let telegramAutoPair = 'false';
|
|
523
676
|
if (telegramChoice.value === 'true') {
|
|
677
|
+
console.log('');
|
|
678
|
+
TEXT[language].telegramBotFatherSteps.forEach((line) => console.log(` ${c.dim}${line}${c.reset}`));
|
|
679
|
+
console.log(` ${c.yellow}${TEXT[language].telegramTokenSafe}${c.reset}`);
|
|
680
|
+
console.log('');
|
|
524
681
|
telegramToken = await promptValidated(
|
|
525
682
|
t(language, 'telegramTokenPrompt'),
|
|
526
683
|
(value) => value ? { ok: true, value } : { ok: false, message: t(language, 'requiredField') },
|
|
527
684
|
);
|
|
685
|
+
const autoPairChoice = await selectMenu(t(language, 'telegramAutoPairQuestion'), [
|
|
686
|
+
{ label: t(language, 'no'), value: 'false' },
|
|
687
|
+
{ label: t(language, 'yes'), value: 'true' },
|
|
688
|
+
], language);
|
|
689
|
+
telegramAutoPair = autoPairChoice.value;
|
|
528
690
|
}
|
|
529
691
|
|
|
530
692
|
return {
|
|
@@ -536,21 +698,50 @@ async function collectConfig(existingEnv = {}) {
|
|
|
536
698
|
apiKey,
|
|
537
699
|
telegramEnabled: telegramChoice.value,
|
|
538
700
|
telegramToken,
|
|
701
|
+
telegramAutoPair,
|
|
539
702
|
gatewayToken: existingEnv.OPENCLAW_GATEWAY_TOKEN || generateGatewayToken(),
|
|
540
703
|
};
|
|
541
704
|
}
|
|
542
705
|
|
|
543
|
-
function ensureComposeFile() {
|
|
706
|
+
function ensureComposeFile(hardened = false) {
|
|
544
707
|
fs.mkdirSync(LIMBO_DIR, { recursive: true });
|
|
545
708
|
fs.mkdirSync(path.join(VAULT_DIR, 'notes'), { recursive: true });
|
|
546
709
|
fs.mkdirSync(path.join(VAULT_DIR, 'maps'), { recursive: true });
|
|
547
|
-
fs.
|
|
710
|
+
fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
|
|
711
|
+
// Ensure secret files exist (Docker Compose secrets require the files to be present)
|
|
712
|
+
for (const name of ['llm_api_key', 'telegram_bot_token', 'gateway_token']) {
|
|
713
|
+
const fp = path.join(SECRETS_DIR, name);
|
|
714
|
+
if (!fs.existsSync(fp)) fs.writeFileSync(fp, '', { mode: 0o600 });
|
|
715
|
+
}
|
|
716
|
+
if (hardened) {
|
|
717
|
+
// Copy squid config files for egress filtering
|
|
718
|
+
const squidDir = path.join(LIMBO_DIR, 'squid');
|
|
719
|
+
fs.mkdirSync(squidDir, { recursive: true });
|
|
720
|
+
const srcSquidDir = path.join(__dirname, 'squid');
|
|
721
|
+
for (const file of ['squid.conf', 'allowed-domains.txt']) {
|
|
722
|
+
const src = path.join(srcSquidDir, file);
|
|
723
|
+
const dest = path.join(squidDir, file);
|
|
724
|
+
if (fs.existsSync(src)) fs.copyFileSync(src, dest);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
fs.writeFileSync(COMPOSE_FILE, hardened ? COMPOSE_CONTENT_HARDENED : COMPOSE_CONTENT);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function readSecretFile(name) {
|
|
731
|
+
const fp = path.join(SECRETS_DIR, name);
|
|
732
|
+
try { return fs.readFileSync(fp, 'utf8').trim(); } catch { return ''; }
|
|
548
733
|
}
|
|
549
734
|
|
|
550
735
|
function ensureGatewayToken(existingEnv) {
|
|
551
|
-
|
|
736
|
+
// Check secret file first, then legacy env
|
|
737
|
+
const fromFile = readSecretFile('gateway_token');
|
|
738
|
+
if (fromFile) return fromFile;
|
|
739
|
+
if (existingEnv.OPENCLAW_GATEWAY_TOKEN) {
|
|
740
|
+
writeSecretFile('gateway_token', existingEnv.OPENCLAW_GATEWAY_TOKEN);
|
|
741
|
+
return existingEnv.OPENCLAW_GATEWAY_TOKEN;
|
|
742
|
+
}
|
|
552
743
|
writeEnv({ keepExisting: true }, existingEnv);
|
|
553
|
-
return
|
|
744
|
+
return readSecretFile('gateway_token');
|
|
554
745
|
}
|
|
555
746
|
|
|
556
747
|
function pullOrBuildImage(lang) {
|
|
@@ -676,7 +867,8 @@ ${c.green}${c.bold}╚═══════════════════
|
|
|
676
867
|
async function cmdStart() {
|
|
677
868
|
if (!hasDocker()) die(t('en', 'dockerMissing'));
|
|
678
869
|
|
|
679
|
-
|
|
870
|
+
const hardened = process.argv.includes('--hardened');
|
|
871
|
+
ensureComposeFile(hardened);
|
|
680
872
|
|
|
681
873
|
const existingEnv = parseEnvFile();
|
|
682
874
|
const alreadyHasEnv = fs.existsSync(ENV_FILE);
|
|
@@ -741,10 +933,12 @@ async function cmdStart() {
|
|
|
741
933
|
ok(t(cfg.language, 'healthy'));
|
|
742
934
|
}
|
|
743
935
|
|
|
936
|
+
console.log(`\n ${c.yellow}⚠ ${t(cfg.language, 'securityNotice')}${c.reset}\n`);
|
|
937
|
+
|
|
744
938
|
printSuccess({
|
|
745
939
|
language: cfg.language,
|
|
746
940
|
telegramEnabled: mergedEnv.TELEGRAM_ENABLED || cfg.telegramEnabled || 'false',
|
|
747
|
-
},
|
|
941
|
+
}, readSecretFile('gateway_token') || mergedEnv.OPENCLAW_GATEWAY_TOKEN);
|
|
748
942
|
}
|
|
749
943
|
|
|
750
944
|
function cmdStop() {
|
|
@@ -793,6 +987,7 @@ ${c.bold}Commands:${c.reset}
|
|
|
793
987
|
|
|
794
988
|
${c.bold}Flags:${c.reset}
|
|
795
989
|
--reconfigure Reconfigure auth and onboarding settings (use with start)
|
|
990
|
+
--hardened Enable egress proxy (restricts outbound to AI provider APIs only)
|
|
796
991
|
|
|
797
992
|
${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
|
|
798
993
|
`);
|
package/mcp-server/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { vaultUpdateMap } from "./tools/update-map.js";
|
|
|
13
13
|
const server = new Server(
|
|
14
14
|
{
|
|
15
15
|
name: "limbo-vault",
|
|
16
|
-
version: "1.
|
|
16
|
+
version: "1.1.0",
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
capabilities: {
|
|
@@ -29,13 +29,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
29
29
|
{
|
|
30
30
|
name: "vault_search",
|
|
31
31
|
description:
|
|
32
|
-
"Search notes in the vault by
|
|
32
|
+
"Search notes in the vault by keyword query. Recursively searches all subdirectories. Returns matching notes with titles, snippets, relevance scores, and domain (subdirectory).",
|
|
33
33
|
inputSchema: {
|
|
34
34
|
type: "object",
|
|
35
35
|
properties: {
|
|
36
36
|
query: {
|
|
37
37
|
type: "string",
|
|
38
|
-
description: "
|
|
38
|
+
description: "Keyword query to search across all vault notes",
|
|
39
39
|
},
|
|
40
40
|
},
|
|
41
41
|
required: ["query"],
|
|
@@ -44,13 +44,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
44
44
|
{
|
|
45
45
|
name: "vault_read",
|
|
46
46
|
description:
|
|
47
|
-
"Read the full content of a vault note by ID. Returns raw markdown including YAML frontmatter.",
|
|
47
|
+
"Read the full content of a vault note by ID. Searches recursively through subdirectories. Returns raw markdown including YAML frontmatter.",
|
|
48
48
|
inputSchema: {
|
|
49
49
|
type: "object",
|
|
50
50
|
properties: {
|
|
51
51
|
noteId: {
|
|
52
52
|
type: "string",
|
|
53
|
-
description: "The note ID (filename without .md extension)",
|
|
53
|
+
description: "The note ID (filename without .md extension). Searched recursively across all subdirectories.",
|
|
54
54
|
},
|
|
55
55
|
},
|
|
56
56
|
required: ["noteId"],
|
|
@@ -59,16 +59,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
59
59
|
{
|
|
60
60
|
name: "vault_write_note",
|
|
61
61
|
description:
|
|
62
|
-
"Create or overwrite a vault note with YAML frontmatter.
|
|
62
|
+
"Create or overwrite a vault note with YAML frontmatter. Supports subdirectory organization — creates the subdirectory if it doesn't exist.",
|
|
63
63
|
inputSchema: {
|
|
64
64
|
type: "object",
|
|
65
65
|
properties: {
|
|
66
66
|
id: { type: "string", description: "Unique note identifier (alphanumeric, dashes, underscores)" },
|
|
67
67
|
title: { type: "string", description: "Human-readable note title" },
|
|
68
|
-
type: { type: "string", description: "Note type,
|
|
69
|
-
description: { type: "string", description: "One-sentence description of the note's claim
|
|
68
|
+
type: { type: "string", description: "Note type: gotcha, decision, config-fact, pattern, tool-knowledge, research-finding, personal-fact" },
|
|
69
|
+
description: { type: "string", description: "One-sentence falsifiable description of the note's claim" },
|
|
70
70
|
content: { type: "string", description: "Full markdown body of the note" },
|
|
71
|
-
|
|
71
|
+
subdirectory: { type: "string", description: "Optional subdirectory under notes/ (e.g. 'openclaw', 'research', 'aios/infrastructure'). Created if it doesn't exist." },
|
|
72
|
+
status: { type: "string", description: "Optional: current, outdated, superseded. Defaults to none." },
|
|
73
|
+
domain: { type: "string", description: "Optional: knowledge domain (e.g. openclaw, aios, research, personal)" },
|
|
74
|
+
source: { type: "string", description: "Optional: provenance (e.g. limbo, claude-code, web)" },
|
|
75
|
+
topics: {
|
|
76
|
+
type: "array",
|
|
77
|
+
items: { type: "string" },
|
|
78
|
+
description: "Optional: map references as wikilinks, e.g. [\"[[openclaw-map]]\"]",
|
|
79
|
+
},
|
|
72
80
|
},
|
|
73
81
|
required: ["id", "title", "type", "description", "content"],
|
|
74
82
|
},
|
|
@@ -76,13 +84,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
76
84
|
{
|
|
77
85
|
name: "vault_update_map",
|
|
78
86
|
description:
|
|
79
|
-
"Append entries to a section in a Map of Content (MOC). Creates the map file and/or section if they don't exist.",
|
|
87
|
+
"Append entries to a section in a Map of Content (MOC). Creates the map file (with frontmatter) and/or section if they don't exist. Maps live in vault/maps/.",
|
|
80
88
|
inputSchema: {
|
|
81
89
|
type: "object",
|
|
82
90
|
properties: {
|
|
83
91
|
map: {
|
|
84
92
|
type: "string",
|
|
85
|
-
description: "Map filename without extension (
|
|
93
|
+
description: "Map filename without extension (e.g. 'openclaw-map', 'ai-research-map')",
|
|
86
94
|
},
|
|
87
95
|
section: {
|
|
88
96
|
type: "string",
|
|
@@ -91,7 +99,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
91
99
|
entries: {
|
|
92
100
|
type: "array",
|
|
93
101
|
items: { type: "string" },
|
|
94
|
-
description: "Markdown link strings to append, e.g. [\"[[note-id|Note Title]]\"]",
|
|
102
|
+
description: "Markdown link strings to append, e.g. [\"- [[note-id|Note Title]]\"]",
|
|
95
103
|
},
|
|
96
104
|
},
|
|
97
105
|
required: ["map", "section", "entries"],
|
|
@@ -128,7 +136,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
128
136
|
case "vault_write_note": {
|
|
129
137
|
const result = await vaultWriteNote(args);
|
|
130
138
|
return {
|
|
131
|
-
content: [{ type: "text", text: `Note written: ${result.id}` }],
|
|
139
|
+
content: [{ type: "text", text: `Note written: ${result.id} → ${result.path}` }],
|
|
132
140
|
};
|
|
133
141
|
}
|
|
134
142
|
|
package/mcp-server/tools/read.js
CHANGED
|
@@ -1,11 +1,65 @@
|
|
|
1
|
-
import { readFile } from "fs/promises";
|
|
2
|
-
import { join } from "path";
|
|
1
|
+
import { readFile, readdir, stat } from "fs/promises";
|
|
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");
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Recursively find a note file by ID. Checks flat first, then subdirectories.
|
|
9
|
+
* Returns the file path or null.
|
|
10
|
+
*/
|
|
11
|
+
async function findNote(noteId) {
|
|
12
|
+
// Fast path: check flat location first
|
|
13
|
+
const flatPath = join(NOTES_DIR, `${noteId}.md`);
|
|
14
|
+
try {
|
|
15
|
+
await stat(flatPath);
|
|
16
|
+
return flatPath;
|
|
17
|
+
} catch {
|
|
18
|
+
// Not in root — search subdirectories
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return searchDir(NOTES_DIR, noteId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function searchDir(dir, noteId) {
|
|
25
|
+
let items;
|
|
26
|
+
try {
|
|
27
|
+
items = await readdir(dir);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
if (item.startsWith(".") || item === "_meta") continue;
|
|
34
|
+
|
|
35
|
+
const full = join(dir, item);
|
|
36
|
+
let s;
|
|
37
|
+
try {
|
|
38
|
+
s = await stat(full);
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (s.isDirectory()) {
|
|
44
|
+
// Check if the note exists directly in this subdirectory
|
|
45
|
+
const candidate = join(full, `${noteId}.md`);
|
|
46
|
+
try {
|
|
47
|
+
await stat(candidate);
|
|
48
|
+
return candidate;
|
|
49
|
+
} catch {
|
|
50
|
+
// Recurse deeper
|
|
51
|
+
const found = await searchDir(full, noteId);
|
|
52
|
+
if (found) return found;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
7
60
|
/**
|
|
8
61
|
* vault_read(noteId): reads full content of a note by ID.
|
|
62
|
+
* Searches recursively through subdirectories.
|
|
9
63
|
* Returns the raw markdown content including YAML frontmatter.
|
|
10
64
|
* Returns null if the note doesn't exist.
|
|
11
65
|
*/
|
|
@@ -14,13 +68,21 @@ export async function vaultRead(noteId) {
|
|
|
14
68
|
throw new Error("noteId must be a non-empty string");
|
|
15
69
|
}
|
|
16
70
|
|
|
17
|
-
// Sanitize:
|
|
71
|
+
// Sanitize: allow alphanumeric, dashes, underscores
|
|
18
72
|
const safe = noteId.replace(/[^a-zA-Z0-9_\-]/g, "");
|
|
19
73
|
if (safe !== noteId) {
|
|
20
74
|
throw new Error("noteId contains invalid characters");
|
|
21
75
|
}
|
|
22
76
|
|
|
23
|
-
const filePath =
|
|
77
|
+
const filePath = await findNote(safe);
|
|
78
|
+
if (!filePath) return null;
|
|
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
|
+
|
|
24
86
|
try {
|
|
25
87
|
return await readFile(filePath, "utf8");
|
|
26
88
|
} catch (err) {
|
|
@@ -1,17 +1,56 @@
|
|
|
1
|
-
import { readdir, readFile } from "fs/promises";
|
|
2
|
-
import { join, basename } from "path";
|
|
1
|
+
import { readdir, readFile, stat } from "fs/promises";
|
|
2
|
+
import { join, basename, relative } from "path";
|
|
3
3
|
|
|
4
4
|
const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
|
|
5
5
|
const NOTES_DIR = join(VAULT_PATH, "notes");
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Recursively collects all .md files under a directory.
|
|
9
|
+
* Returns array of { filePath, domain } where domain is the relative subdirectory.
|
|
10
|
+
*/
|
|
11
|
+
async function walkNotes(dir, base = dir) {
|
|
12
|
+
const entries = [];
|
|
13
|
+
let items;
|
|
14
|
+
try {
|
|
15
|
+
items = await readdir(dir);
|
|
16
|
+
} catch {
|
|
17
|
+
return entries;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const item of items) {
|
|
21
|
+
// Skip hidden directories and _meta
|
|
22
|
+
if (item.startsWith(".") || item === "_meta") continue;
|
|
23
|
+
|
|
24
|
+
const full = join(dir, item);
|
|
25
|
+
let s;
|
|
26
|
+
try {
|
|
27
|
+
s = await stat(full);
|
|
28
|
+
} catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (s.isDirectory()) {
|
|
33
|
+
const sub = await walkNotes(full, base);
|
|
34
|
+
entries.push(...sub);
|
|
35
|
+
} else if (item.endsWith(".md")) {
|
|
36
|
+
const rel = relative(base, dir);
|
|
37
|
+
entries.push({ filePath: full, domain: rel || null });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return entries;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extracts the title from YAML frontmatter, falling back to description or first H1.
|
|
9
45
|
*/
|
|
10
46
|
function extractTitle(content) {
|
|
11
47
|
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
12
48
|
if (fmMatch) {
|
|
13
49
|
const titleMatch = fmMatch[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
|
|
14
50
|
if (titleMatch) return titleMatch[1];
|
|
51
|
+
// Fallback: use description if no title field
|
|
52
|
+
const descMatch = fmMatch[1].match(/^description:\s*["']?(.+?)["']?\s*$/m);
|
|
53
|
+
if (descMatch) return descMatch[1];
|
|
15
54
|
}
|
|
16
55
|
const h1Match = content.match(/^#\s+(.+)$/m);
|
|
17
56
|
if (h1Match) return h1Match[1];
|
|
@@ -35,33 +74,27 @@ function extractSnippet(content, regex, maxLen = 150) {
|
|
|
35
74
|
}
|
|
36
75
|
|
|
37
76
|
/**
|
|
38
|
-
* vault_search(query):
|
|
39
|
-
* Returns [{noteId, title, snippet, score}] sorted by score desc.
|
|
77
|
+
* vault_search(query): recursive search across all .md files in vault/notes/.
|
|
78
|
+
* Returns [{noteId, title, snippet, score, domain}] sorted by score desc.
|
|
40
79
|
*
|
|
41
80
|
* NOTE: Current implementation is a linear scan (O(n) per query). This is fine
|
|
42
81
|
* for small vaults (hundreds of notes), but will need optimization at scale —
|
|
43
82
|
* consider an inverted index (e.g. SQLite FTS5) when the vault grows large.
|
|
44
83
|
*/
|
|
45
84
|
export async function vaultSearch(query) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
files = await readdir(NOTES_DIR);
|
|
49
|
-
} catch {
|
|
50
|
-
return [];
|
|
85
|
+
if (query.length > 200) {
|
|
86
|
+
throw new Error("Search query too long (max 200 characters)");
|
|
51
87
|
}
|
|
52
88
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
60
|
-
}
|
|
89
|
+
const files = await walkNotes(NOTES_DIR);
|
|
90
|
+
if (files.length === 0) return [];
|
|
91
|
+
|
|
92
|
+
// Always escape user input to prevent ReDoS from pathological patterns
|
|
93
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
94
|
+
const regex = new RegExp(escaped, "gi");
|
|
61
95
|
|
|
62
96
|
const results = [];
|
|
63
|
-
for (const
|
|
64
|
-
const filePath = join(NOTES_DIR, file);
|
|
97
|
+
for (const { filePath, domain } of files) {
|
|
65
98
|
let content;
|
|
66
99
|
try {
|
|
67
100
|
content = await readFile(filePath, "utf8");
|
|
@@ -72,12 +105,12 @@ export async function vaultSearch(query) {
|
|
|
72
105
|
const matches = content.match(regex);
|
|
73
106
|
if (!matches) continue;
|
|
74
107
|
|
|
75
|
-
const noteId = basename(
|
|
108
|
+
const noteId = basename(filePath, ".md");
|
|
76
109
|
const title = extractTitle(content) || noteId;
|
|
77
110
|
const score = matches.length;
|
|
78
111
|
const snippet = extractSnippet(content, regex);
|
|
79
112
|
|
|
80
|
-
results.push({ noteId, title, snippet, score });
|
|
113
|
+
results.push({ noteId, title, snippet, score, domain });
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
results.sort((a, b) => b.score - a.score);
|
|
@@ -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");
|
|
@@ -13,6 +13,19 @@ function sanitizeName(name) {
|
|
|
13
13
|
return safe;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Builds frontmatter for a new map file.
|
|
18
|
+
*/
|
|
19
|
+
function buildMapFrontmatter(name) {
|
|
20
|
+
const lines = [
|
|
21
|
+
"---",
|
|
22
|
+
`description: "${name.replace(/-/g, " ")}"`,
|
|
23
|
+
"type: moc",
|
|
24
|
+
"---",
|
|
25
|
+
];
|
|
26
|
+
return lines.join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
/**
|
|
17
30
|
* Finds or creates a section in markdown content.
|
|
18
31
|
* Returns the updated content string.
|
|
@@ -42,8 +55,9 @@ function upsertSection(content, section, entries) {
|
|
|
42
55
|
|
|
43
56
|
/**
|
|
44
57
|
* vault_update_map(map, section, entries): appends entries to a MOC section.
|
|
45
|
-
* Creates the section if
|
|
46
|
-
*
|
|
58
|
+
* Creates the map file and/or section if they don't exist.
|
|
59
|
+
* New maps are created with proper YAML frontmatter.
|
|
60
|
+
* Entries are markdown link strings, e.g. ["- [[note-id|Note Title]]"]
|
|
47
61
|
*
|
|
48
62
|
* @param {string} map - map filename without extension
|
|
49
63
|
* @param {string} section - section heading text
|
|
@@ -57,15 +71,18 @@ export async function vaultUpdateMap(map, section, entries) {
|
|
|
57
71
|
const safeMap = sanitizeName(map);
|
|
58
72
|
await mkdir(MAPS_DIR, { recursive: true });
|
|
59
73
|
|
|
60
|
-
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
|
+
}
|
|
61
78
|
|
|
62
79
|
let existing = "";
|
|
63
80
|
try {
|
|
64
81
|
existing = await readFile(filePath, "utf8");
|
|
65
82
|
} catch (err) {
|
|
66
83
|
if (err.code !== "ENOENT") throw err;
|
|
67
|
-
// New map — start with
|
|
68
|
-
existing =
|
|
84
|
+
// New map — start with frontmatter and title
|
|
85
|
+
existing = `${buildMapFrontmatter(map)}\n\n# ${map}\n`;
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
const updated = upsertSection(existing, section, entries);
|
|
@@ -1,32 +1,51 @@
|
|
|
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");
|
|
6
6
|
|
|
7
7
|
const REQUIRED_FIELDS = ["id", "title", "type", "description", "content"];
|
|
8
8
|
|
|
9
|
+
function escapeYaml(str) {
|
|
10
|
+
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
11
|
+
}
|
|
12
|
+
|
|
9
13
|
/**
|
|
10
14
|
* Builds YAML frontmatter string from note metadata.
|
|
15
|
+
* Supports the merged schema: id, title, description, type, status, domain,
|
|
16
|
+
* created, source, topics.
|
|
11
17
|
*/
|
|
12
18
|
function buildFrontmatter(note) {
|
|
13
19
|
const lines = ["---"];
|
|
14
20
|
lines.push(`id: ${note.id}`);
|
|
15
|
-
lines.push(`title: "${note.title
|
|
21
|
+
lines.push(`title: "${escapeYaml(note.title)}"`);
|
|
22
|
+
lines.push(`description: "${escapeYaml(note.description)}"`);
|
|
16
23
|
lines.push(`type: ${note.type}`);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
if (note.status) {
|
|
25
|
+
lines.push(`status: ${note.status}`);
|
|
26
|
+
}
|
|
27
|
+
if (note.domain) {
|
|
28
|
+
lines.push(`domain: ${note.domain}`);
|
|
29
|
+
}
|
|
30
|
+
lines.push(`created: "${note.created || new Date().toISOString().split("T")[0]}"`);
|
|
31
|
+
if (note.source) {
|
|
32
|
+
lines.push(`source: ${note.source}`);
|
|
33
|
+
}
|
|
34
|
+
if (note.topics && note.topics.length > 0) {
|
|
35
|
+
lines.push("topics:");
|
|
36
|
+
for (const topic of note.topics) {
|
|
37
|
+
lines.push(` - "${escapeYaml(topic)}"`);
|
|
38
|
+
}
|
|
20
39
|
}
|
|
21
|
-
lines.push(`created: ${new Date().toISOString().split("T")[0]}`);
|
|
22
40
|
lines.push("---");
|
|
23
41
|
return lines.join("\n");
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
/**
|
|
27
45
|
* vault_write_note(note): creates a markdown file with YAML frontmatter.
|
|
28
|
-
* Input: {id, title, type, description, content,
|
|
29
|
-
* Writes to /data/vault/notes/{id}.md
|
|
46
|
+
* Input: {id, title, type, description, content, subdirectory?, status?, domain?, source?, topics?}
|
|
47
|
+
* Writes to /data/vault/notes/{subdirectory?}/{id}.md
|
|
48
|
+
* Creates the subdirectory if it doesn't exist.
|
|
30
49
|
*/
|
|
31
50
|
export async function vaultWriteNote(note) {
|
|
32
51
|
for (const field of REQUIRED_FIELDS) {
|
|
@@ -41,11 +60,29 @@ export async function vaultWriteNote(note) {
|
|
|
41
60
|
throw new Error("note.id contains invalid characters");
|
|
42
61
|
}
|
|
43
62
|
|
|
44
|
-
|
|
63
|
+
// Determine target directory
|
|
64
|
+
let targetDir = NOTES_DIR;
|
|
65
|
+
if (note.subdirectory) {
|
|
66
|
+
// Sanitize subdirectory: allow alphanumeric, dashes, underscores, forward slashes
|
|
67
|
+
const safeSub = note.subdirectory.replace(/[^a-zA-Z0-9_\-/]/g, "");
|
|
68
|
+
if (safeSub !== note.subdirectory) {
|
|
69
|
+
throw new Error("subdirectory contains invalid characters");
|
|
70
|
+
}
|
|
71
|
+
// Prevent path traversal
|
|
72
|
+
if (safeSub.includes("..")) {
|
|
73
|
+
throw new Error("subdirectory cannot contain '..'");
|
|
74
|
+
}
|
|
75
|
+
targetDir = join(NOTES_DIR, safeSub);
|
|
76
|
+
}
|
|
45
77
|
|
|
46
|
-
|
|
78
|
+
await mkdir(targetDir, { recursive: true });
|
|
79
|
+
|
|
80
|
+
const frontmatter = buildFrontmatter({ ...note, id: safe });
|
|
47
81
|
const fileContent = `${frontmatter}\n\n${note.content}\n`;
|
|
48
|
-
const filePath =
|
|
82
|
+
const filePath = resolve(targetDir, `${safe}.md`);
|
|
83
|
+
if (!filePath.startsWith(resolve(NOTES_DIR) + "/")) {
|
|
84
|
+
throw new Error("Path traversal detected");
|
|
85
|
+
}
|
|
49
86
|
|
|
50
87
|
await writeFile(filePath, fileContent, "utf8");
|
|
51
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
|