vectr 1.0.2__tar.gz → 1.1.0__tar.gz
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.
- {vectr-1.0.2/vectr.egg-info → vectr-1.1.0}/PKG-INFO +57 -14
- {vectr-1.0.2 → vectr-1.1.0}/README.md +56 -13
- vectr-1.1.0/agent/fs_permissions.py +33 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/instance_registry.py +4 -1
- {vectr-1.0.2 → vectr-1.1.0}/agent/working_context_store/__init__.py +4 -0
- vectr-1.1.0/agent/working_context_store/_audit.py +102 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/working_context_store/_encryption.py +21 -2
- {vectr-1.0.2 → vectr-1.1.0}/agent/working_context_store/_store.py +75 -10
- {vectr-1.0.2 → vectr-1.1.0}/api.py +11 -6
- {vectr-1.0.2 → vectr-1.1.0}/app/routes.py +8 -2
- {vectr-1.0.2 → vectr-1.1.0}/app/service.py +18 -3
- {vectr-1.0.2 → vectr-1.1.0}/integrations/mcp_server/_dispatch.py +18 -2
- {vectr-1.0.2 → vectr-1.1.0}/main.py +257 -9
- {vectr-1.0.2 → vectr-1.1.0}/pyproject.toml +1 -1
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_api.py +88 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_main.py +203 -2
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_memory.py +382 -0
- vectr-1.1.0/tests/test_scope_isolation.py +117 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_service.py +54 -0
- {vectr-1.0.2 → vectr-1.1.0/vectr.egg-info}/PKG-INFO +57 -14
- {vectr-1.0.2 → vectr-1.1.0}/vectr.egg-info/SOURCES.txt +2 -0
- vectr-1.0.2/agent/working_context_store/_audit.py +0 -46
- {vectr-1.0.2 → vectr-1.1.0}/LICENSE +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/MANIFEST.in +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/__init__.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/cartographer.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/chunk_quality.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/config.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/config.yaml +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/eviction_advisor.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/identifier_hint.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/indexer/__init__.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/indexer/_chunking.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/indexer/_constants.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/indexer/_core.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/indexer/_types.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/model_cache.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/prompt_templates.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/searcher.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/strategy_selector.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/symbol_graph/__init__.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/symbol_graph/_constants.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/symbol_graph/_extraction.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/symbol_graph/_graph.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/symbol_graph/_types.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/templates/claude_md.md +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/templates/claude_md_search_only.md +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/templates/cursor_mcp.json.template +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/templates/cursor_rules_header.txt +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/templates/hook_no_double_recall.txt +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/templates/mcp.json.template +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/templates/session_start_guidance_default.txt +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/templates/session_start_guidance_hooks_aware.txt +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/templates/tool_loading_guidance_claude.txt +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/templates/tool_loading_guidance_claude_search_only.txt +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/templates/vscode_mcp.json.template +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/tool_necessity_probe.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/version_stamp.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/watcher.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/agent/working_context_store/_types.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/app/__init__.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/app/models.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/integrations/__init__.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/integrations/mcp_server/__init__.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/integrations/mcp_server/_schemas.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/integrations/mcp_server/_session.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/integrations/vscode_bridge.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/integrations/workspace_detect.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/setup.cfg +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_agent.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_api_memory.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_cartographer.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_chunk_quality.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_config_loader.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_eviction_advisor.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_eviction_session_scope.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_fetch_route.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_grammar_availability.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_hint_language_live.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_hook_injection_observability.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_identifier_hint.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_indexer_searcher.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_instance_registry.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_integration.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_mcp_jsonrpc.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_mcp_server.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_mcp_session_handshake.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_memory_only_mode.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_model_cache.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_prompt_templates.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_query_prompt.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_ragas_eval.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_recall_hierarchy.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_remember_banner_fatigue.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_search_only_mode.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_strategy.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_subagent_memory.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_symbol_graph.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_task_note_recency.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_tool_necessity_probe.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_version_stamp.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_watcher.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/tests/test_workspace_detect.py +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/vectr.egg-info/dependency_links.txt +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/vectr.egg-info/entry_points.txt +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/vectr.egg-info/requires.txt +0 -0
- {vectr-1.0.2 → vectr-1.1.0}/vectr.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vectr
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Semantic codebase search + persistent working memory for AI code editors. Local, zero-config, MCP. No API key.
|
|
5
5
|
Author-email: Swapnanil Saha <swapnanilsaha26@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -43,6 +43,12 @@ Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
|
|
|
43
43
|
Requires-Dist: anyio==4.13.0; extra == "dev"
|
|
44
44
|
Dynamic: license-file
|
|
45
45
|
|
|
46
|
+
<!-- mcp-name: io.github.swapnanil/vectr -->
|
|
47
|
+
|
|
48
|
+
<p align="center">
|
|
49
|
+
<img src="https://raw.githubusercontent.com/swapnanil/vectr/main/assets/banner.svg" alt="vectr — semantic codebase search + persistent working memory for AI code editors" width="560">
|
|
50
|
+
</p>
|
|
51
|
+
|
|
46
52
|
# Vectr
|
|
47
53
|
|
|
48
54
|
> **Semantic search and persistent memory for AI code editors.**
|
|
@@ -50,10 +56,10 @@ Dynamic: license-file
|
|
|
50
56
|
[](https://github.com/swapnanil/vectr/actions/workflows/ci.yml)
|
|
51
57
|
[](LICENSE)
|
|
52
58
|
[](https://www.python.org/downloads/)
|
|
53
|
-
[](CHANGELOG.md)
|
|
54
60
|
[](#14-mcp-tools)
|
|
55
61
|
|
|
56
|
-
Version 1.
|
|
62
|
+
Version 1.1.0 · Last updated 2026-07-11 · [CHANGELOG](CHANGELOG.md)
|
|
57
63
|
|
|
58
64
|
Vectr gives AI code editors two things they lack: **semantic codebase search** and **persistent working memory** — both served over MCP with zero configuration.
|
|
59
65
|
|
|
@@ -155,7 +161,7 @@ vectr start
|
|
|
155
161
|
- Ubuntu/Debian: `sudo add-apt-repository ppa:deadsnakes/ppa && sudo apt install python3.14 python3.14-venv`
|
|
156
162
|
- Windows: [python.org/downloads](https://www.python.org/downloads/)
|
|
157
163
|
|
|
158
|
-
`vectr start` returns immediately. Indexing runs in the background — run `vectr status` to check progress. On first run the embedding model downloads once (~
|
|
164
|
+
`vectr start` returns immediately. Indexing runs in the background — run `vectr status` to check progress. On first run the embedding model downloads once (~290 MB). Restart your AI code editor once to pick up the new MCP config.
|
|
159
165
|
|
|
160
166
|
**Docker (CI/servers)**
|
|
161
167
|
|
|
@@ -335,7 +341,7 @@ HTTP routes (Flask/FastAPI decorators, Express `app.get()`, Spring `@GetMapping`
|
|
|
335
341
|
|
|
336
342
|
| | Cost |
|
|
337
343
|
|---|---|
|
|
338
|
-
| Embedding model | $0.00 — one-time ~
|
|
344
|
+
| Embedding model | $0.00 — one-time ~290 MB download, cached at `~/.cache/vectr/` |
|
|
339
345
|
| Re-index (10k files, first run) | ~10 min on CPU; <5 sec on subsequent runs (mtime cache) |
|
|
340
346
|
| Incremental re-index per changed file | ~0.5 sec |
|
|
341
347
|
| vectr_search / vectr_recall | $0.00 — local inference only |
|
|
@@ -344,15 +350,52 @@ HTTP routes (Flask/FastAPI decorators, Express `app.get()`, Spring `@GetMapping`
|
|
|
344
350
|
|
|
345
351
|
## Security
|
|
346
352
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
353
|
+
The default is unchanged and stays the headline: **local, no API key, zero
|
|
354
|
+
config** — a solo developer on a personal machine. Out of the box, the daemon
|
|
355
|
+
binds to `127.0.0.1` only, CORS is restricted to localhost origins, each
|
|
356
|
+
workspace gets its own isolated DB directory, port, and process (owner-only
|
|
357
|
+
`0700` on POSIX), and the index and notes persist locally in `~/.cache/vectr/`.
|
|
358
|
+
|
|
359
|
+
Everything below is **opt-in**; enabling nothing changes nothing.
|
|
360
|
+
|
|
361
|
+
**Authentication** — set `VECTR_API_KEY` and every request to `/v1/*` and
|
|
362
|
+
`/mcp` must carry it (`X-Api-Key: <key>` or `Authorization: Bearer <key>`;
|
|
363
|
+
constant-time comparison; `/v1/health` stays open for liveness probes).
|
|
364
|
+
Generate a key with `vectr key`. When the key is set at start time, the editor
|
|
365
|
+
MCP configs vectr writes include the header automatically. Those files
|
|
366
|
+
(`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`) then hold the key in
|
|
367
|
+
plaintext — treat them as secrets and keep them out of shared or public
|
|
368
|
+
version control.
|
|
369
|
+
|
|
370
|
+
**Encryption at rest** — set `VECTR_ENCRYPT_KEY` (or store a passphrase in the
|
|
371
|
+
OS keychain: service `vectr`, username `encrypt-key`; requires
|
|
372
|
+
`pip install vectr[encryption]`) and note content, note titles, and snapshot
|
|
373
|
+
payloads are encrypted (Fernet, PBKDF2-derived key). Honest boundary: the
|
|
374
|
+
**code index is not encrypted** — the search engine needs readable chunk text
|
|
375
|
+
and vectors; protect it with OS full-disk encryption. Note tags/metadata stay
|
|
376
|
+
plaintext, and note embedding vectors (a lossy projection of note text) are
|
|
377
|
+
kept for semantic recall unless you set
|
|
378
|
+
`VECTR_ENCRYPT_DISABLE_NOTE_VECTORS=1`.
|
|
379
|
+
|
|
380
|
+
**Retention and audit** — notes are kept until you delete them; set
|
|
381
|
+
`VECTR_NOTES_TTL_DAYS` to auto-purge older notes at startup.
|
|
382
|
+
`vectr_forget(all=true)` / `vectr forget --all` delete notes, snapshots, and
|
|
383
|
+
note vectors — everything means everything. Set `VECTR_AUDIT_LOG=<path>` for a
|
|
384
|
+
rotating local log of index/search/remember/recall events (off by default; it
|
|
385
|
+
records query text — that is its purpose — and is never transmitted). Full
|
|
386
|
+
policy: [docs/data-handling.md](docs/data-handling.md).
|
|
387
|
+
|
|
388
|
+
**Team mode (shared instance)** — one central daemon can serve a team on one
|
|
389
|
+
repo: `VECTR_API_KEY=<key> vectr start --host 0.0.0.0` on the server (a
|
|
390
|
+
non-loopback bind **refuses to start without a key**), then
|
|
391
|
+
`vectr connect --url http://<host>:<port> --api-key <key> --label <you>` on
|
|
392
|
+
each client to point the editor at it. Working memory is shared: a note one
|
|
393
|
+
agent stores, every connected agent can recall; `--label` attributes notes and
|
|
394
|
+
audit lines. Plain limits: one shared key means every holder is an equal,
|
|
395
|
+
trusted peer (no roles, no per-user permissions); the server operator can read
|
|
396
|
+
everything; search results reference the **server's** checkout, which may
|
|
397
|
+
differ from your local tree; vectr speaks plain HTTP — put TLS at a reverse
|
|
398
|
+
proxy or tunnel if the network isn't trusted.
|
|
356
399
|
|
|
357
400
|
---
|
|
358
401
|
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
<!-- mcp-name: io.github.swapnanil/vectr -->
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/swapnanil/vectr/main/assets/banner.svg" alt="vectr — semantic codebase search + persistent working memory for AI code editors" width="560">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
1
7
|
# Vectr
|
|
2
8
|
|
|
3
9
|
> **Semantic search and persistent memory for AI code editors.**
|
|
@@ -5,10 +11,10 @@
|
|
|
5
11
|
[](https://github.com/swapnanil/vectr/actions/workflows/ci.yml)
|
|
6
12
|
[](LICENSE)
|
|
7
13
|
[](https://www.python.org/downloads/)
|
|
8
|
-
[](CHANGELOG.md)
|
|
9
15
|
[](#14-mcp-tools)
|
|
10
16
|
|
|
11
|
-
Version 1.
|
|
17
|
+
Version 1.1.0 · Last updated 2026-07-11 · [CHANGELOG](CHANGELOG.md)
|
|
12
18
|
|
|
13
19
|
Vectr gives AI code editors two things they lack: **semantic codebase search** and **persistent working memory** — both served over MCP with zero configuration.
|
|
14
20
|
|
|
@@ -110,7 +116,7 @@ vectr start
|
|
|
110
116
|
- Ubuntu/Debian: `sudo add-apt-repository ppa:deadsnakes/ppa && sudo apt install python3.14 python3.14-venv`
|
|
111
117
|
- Windows: [python.org/downloads](https://www.python.org/downloads/)
|
|
112
118
|
|
|
113
|
-
`vectr start` returns immediately. Indexing runs in the background — run `vectr status` to check progress. On first run the embedding model downloads once (~
|
|
119
|
+
`vectr start` returns immediately. Indexing runs in the background — run `vectr status` to check progress. On first run the embedding model downloads once (~290 MB). Restart your AI code editor once to pick up the new MCP config.
|
|
114
120
|
|
|
115
121
|
**Docker (CI/servers)**
|
|
116
122
|
|
|
@@ -290,7 +296,7 @@ HTTP routes (Flask/FastAPI decorators, Express `app.get()`, Spring `@GetMapping`
|
|
|
290
296
|
|
|
291
297
|
| | Cost |
|
|
292
298
|
|---|---|
|
|
293
|
-
| Embedding model | $0.00 — one-time ~
|
|
299
|
+
| Embedding model | $0.00 — one-time ~290 MB download, cached at `~/.cache/vectr/` |
|
|
294
300
|
| Re-index (10k files, first run) | ~10 min on CPU; <5 sec on subsequent runs (mtime cache) |
|
|
295
301
|
| Incremental re-index per changed file | ~0.5 sec |
|
|
296
302
|
| vectr_search / vectr_recall | $0.00 — local inference only |
|
|
@@ -299,15 +305,52 @@ HTTP routes (Flask/FastAPI decorators, Express `app.get()`, Spring `@GetMapping`
|
|
|
299
305
|
|
|
300
306
|
## Security
|
|
301
307
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
308
|
+
The default is unchanged and stays the headline: **local, no API key, zero
|
|
309
|
+
config** — a solo developer on a personal machine. Out of the box, the daemon
|
|
310
|
+
binds to `127.0.0.1` only, CORS is restricted to localhost origins, each
|
|
311
|
+
workspace gets its own isolated DB directory, port, and process (owner-only
|
|
312
|
+
`0700` on POSIX), and the index and notes persist locally in `~/.cache/vectr/`.
|
|
313
|
+
|
|
314
|
+
Everything below is **opt-in**; enabling nothing changes nothing.
|
|
315
|
+
|
|
316
|
+
**Authentication** — set `VECTR_API_KEY` and every request to `/v1/*` and
|
|
317
|
+
`/mcp` must carry it (`X-Api-Key: <key>` or `Authorization: Bearer <key>`;
|
|
318
|
+
constant-time comparison; `/v1/health` stays open for liveness probes).
|
|
319
|
+
Generate a key with `vectr key`. When the key is set at start time, the editor
|
|
320
|
+
MCP configs vectr writes include the header automatically. Those files
|
|
321
|
+
(`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`) then hold the key in
|
|
322
|
+
plaintext — treat them as secrets and keep them out of shared or public
|
|
323
|
+
version control.
|
|
324
|
+
|
|
325
|
+
**Encryption at rest** — set `VECTR_ENCRYPT_KEY` (or store a passphrase in the
|
|
326
|
+
OS keychain: service `vectr`, username `encrypt-key`; requires
|
|
327
|
+
`pip install vectr[encryption]`) and note content, note titles, and snapshot
|
|
328
|
+
payloads are encrypted (Fernet, PBKDF2-derived key). Honest boundary: the
|
|
329
|
+
**code index is not encrypted** — the search engine needs readable chunk text
|
|
330
|
+
and vectors; protect it with OS full-disk encryption. Note tags/metadata stay
|
|
331
|
+
plaintext, and note embedding vectors (a lossy projection of note text) are
|
|
332
|
+
kept for semantic recall unless you set
|
|
333
|
+
`VECTR_ENCRYPT_DISABLE_NOTE_VECTORS=1`.
|
|
334
|
+
|
|
335
|
+
**Retention and audit** — notes are kept until you delete them; set
|
|
336
|
+
`VECTR_NOTES_TTL_DAYS` to auto-purge older notes at startup.
|
|
337
|
+
`vectr_forget(all=true)` / `vectr forget --all` delete notes, snapshots, and
|
|
338
|
+
note vectors — everything means everything. Set `VECTR_AUDIT_LOG=<path>` for a
|
|
339
|
+
rotating local log of index/search/remember/recall events (off by default; it
|
|
340
|
+
records query text — that is its purpose — and is never transmitted). Full
|
|
341
|
+
policy: [docs/data-handling.md](docs/data-handling.md).
|
|
342
|
+
|
|
343
|
+
**Team mode (shared instance)** — one central daemon can serve a team on one
|
|
344
|
+
repo: `VECTR_API_KEY=<key> vectr start --host 0.0.0.0` on the server (a
|
|
345
|
+
non-loopback bind **refuses to start without a key**), then
|
|
346
|
+
`vectr connect --url http://<host>:<port> --api-key <key> --label <you>` on
|
|
347
|
+
each client to point the editor at it. Working memory is shared: a note one
|
|
348
|
+
agent stores, every connected agent can recall; `--label` attributes notes and
|
|
349
|
+
audit lines. Plain limits: one shared key means every holder is an equal,
|
|
350
|
+
trusted peer (no roles, no per-user permissions); the server operator can read
|
|
351
|
+
everything; search results reference the **server's** checkout, which may
|
|
352
|
+
differ from your local tree; vectr speaks plain HTTP — put TLS at a reverse
|
|
353
|
+
proxy or tunnel if the network isn't trusted.
|
|
311
354
|
|
|
312
355
|
---
|
|
313
356
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Filesystem permission helpers for vectr's local state directories.
|
|
2
|
+
|
|
3
|
+
On a shared host, vectr's cache and state directories hold a plaintext code
|
|
4
|
+
index and working-memory notes. Restricting them to owner-only (0700) stops
|
|
5
|
+
other OS users on the same machine from reading them at the filesystem level —
|
|
6
|
+
the concrete defense for the scope-isolation model on multi-user hosts.
|
|
7
|
+
|
|
8
|
+
Best-effort and POSIX-scoped: on Windows, os.chmod cannot express a POSIX 0700
|
|
9
|
+
mode, so the chmod is effectively a near-no-op there and full-disk encryption /
|
|
10
|
+
NTFS ACLs are the right tool instead. A permission-tightening failure must never
|
|
11
|
+
stop the daemon, so every call here is guarded.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def secure_dir(path: str | Path, mode: int = 0o700) -> Path:
|
|
19
|
+
"""Create `path` (and parents) if needed, then restrict it to `mode`.
|
|
20
|
+
|
|
21
|
+
Idempotent and safe to call on every startup; never raises. Returns the
|
|
22
|
+
Path so callers can chain. Tightens pre-existing directories too.
|
|
23
|
+
"""
|
|
24
|
+
p = Path(path)
|
|
25
|
+
try:
|
|
26
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
except OSError:
|
|
28
|
+
return p
|
|
29
|
+
try:
|
|
30
|
+
p.chmod(mode)
|
|
31
|
+
except OSError:
|
|
32
|
+
pass # e.g. Windows, or a dir we do not own — best effort only
|
|
33
|
+
return p
|
|
@@ -53,7 +53,10 @@ class InstanceRegistry:
|
|
|
53
53
|
return {}
|
|
54
54
|
|
|
55
55
|
def _write(self, data: dict[str, Any]) -> None:
|
|
56
|
-
|
|
56
|
+
# ~/.vectr holds the instance registry, logs, and (opt-in) audit log —
|
|
57
|
+
# owner-only on POSIX hosts (see agent/fs_permissions.py).
|
|
58
|
+
from agent.fs_permissions import secure_dir
|
|
59
|
+
secure_dir(self._path.parent)
|
|
57
60
|
tmp = self._path.with_suffix(".tmp")
|
|
58
61
|
tmp.write_text(json.dumps(data, indent=2))
|
|
59
62
|
os.rename(tmp, self._path)
|
|
@@ -36,6 +36,8 @@ from __future__ import annotations
|
|
|
36
36
|
from agent.working_context_store._audit import (
|
|
37
37
|
_get_audit_logger,
|
|
38
38
|
audit,
|
|
39
|
+
reset_audit_client,
|
|
40
|
+
set_audit_client,
|
|
39
41
|
)
|
|
40
42
|
|
|
41
43
|
# Types and constants
|
|
@@ -61,6 +63,8 @@ __all__ = [
|
|
|
61
63
|
# Audit
|
|
62
64
|
"_get_audit_logger",
|
|
63
65
|
"audit",
|
|
66
|
+
"set_audit_client",
|
|
67
|
+
"reset_audit_client",
|
|
64
68
|
# Types and constants
|
|
65
69
|
"DEFAULT_KIND",
|
|
66
70
|
"VALID_KINDS",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Opt-in rotating audit logger for vectr working-context and index events.
|
|
2
|
+
|
|
3
|
+
OFF BY DEFAULT. Enable by setting VECTR_AUDIT_LOG to a file path; when unset,
|
|
4
|
+
nothing is recorded (a NullHandler). When enabled, logs remember/recall/forget/
|
|
5
|
+
purge/index/search events to that local file — never transmitted anywhere.
|
|
6
|
+
Rotates at 10 MB; keeps 3 backups.
|
|
7
|
+
|
|
8
|
+
In team mode, each line optionally carries a `client=<label>` attribution taken
|
|
9
|
+
from the connecting client's X-Vectr-Client header (see set_audit_client), so
|
|
10
|
+
the operator can see what each client indexed/recalled — still a local file on
|
|
11
|
+
the server host only.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import logging.handlers
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
from contextvars import ContextVar
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
# Guards lazy handler initialization: without it, two threads auditing
|
|
23
|
+
# concurrently can both see an empty handler list and both attach a file
|
|
24
|
+
# handler, duplicating every subsequent line.
|
|
25
|
+
_init_lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Per-request client attribution label (team mode). Set at the MCP dispatch
|
|
29
|
+
# boundary from the X-Vectr-Client header; read by audit() so every event a
|
|
30
|
+
# request triggers is attributed. A ContextVar (not a global) so concurrent
|
|
31
|
+
# requests never bleed each other's label.
|
|
32
|
+
_audit_client: ContextVar[str] = ContextVar("vectr_audit_client", default="")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def set_audit_client(label: str):
|
|
36
|
+
"""Set the client-attribution label for audit lines emitted on this task.
|
|
37
|
+
Returns the ContextVar token so the caller can reset() it afterwards."""
|
|
38
|
+
return _audit_client.set(label or "")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reset_audit_client(token) -> None:
|
|
42
|
+
"""Restore the previous client-attribution label."""
|
|
43
|
+
try:
|
|
44
|
+
_audit_client.reset(token)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_audit_logger() -> logging.Logger:
|
|
50
|
+
"""Return the vectr audit logger (lazy-initialised, singleton per process).
|
|
51
|
+
|
|
52
|
+
Disabled unless VECTR_AUDIT_LOG names a file path (opt-in)."""
|
|
53
|
+
name = "vectr.audit"
|
|
54
|
+
log = logging.getLogger(name)
|
|
55
|
+
if log.handlers:
|
|
56
|
+
return log
|
|
57
|
+
|
|
58
|
+
with _init_lock:
|
|
59
|
+
if log.handlers: # another thread initialized while we waited
|
|
60
|
+
return log
|
|
61
|
+
return _init_audit_logger(log)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _init_audit_logger(log: logging.Logger) -> logging.Logger:
|
|
65
|
+
"""Attach the configured handler to `log` (caller holds _init_lock)."""
|
|
66
|
+
log_path_str = os.getenv("VECTR_AUDIT_LOG", "") # opt-in: unset ⇒ disabled
|
|
67
|
+
if not log_path_str:
|
|
68
|
+
log.addHandler(logging.NullHandler())
|
|
69
|
+
return log
|
|
70
|
+
|
|
71
|
+
log_path = Path(log_path_str)
|
|
72
|
+
# The audit file records query text — keep its directory owner-only (0700)
|
|
73
|
+
# on POSIX hosts, same as the rest of vectr's state.
|
|
74
|
+
try:
|
|
75
|
+
from agent.fs_permissions import secure_dir
|
|
76
|
+
secure_dir(log_path.parent)
|
|
77
|
+
except Exception:
|
|
78
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
81
|
+
str(log_path), maxBytes=10 * 1024 * 1024, backupCount=3, encoding="utf-8",
|
|
82
|
+
)
|
|
83
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%SZ"))
|
|
84
|
+
log.addHandler(handler)
|
|
85
|
+
log.setLevel(logging.INFO)
|
|
86
|
+
log.propagate = False
|
|
87
|
+
return log
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def audit(event: str, **kwargs) -> None:
|
|
91
|
+
"""Write one audit log entry: `event key=val key=val …` (no-op when disabled).
|
|
92
|
+
|
|
93
|
+
A non-empty client-attribution label (set_audit_client) is appended as
|
|
94
|
+
`client=<label>`."""
|
|
95
|
+
try:
|
|
96
|
+
parts = [event] + [f"{k}={v}" for k, v in kwargs.items()]
|
|
97
|
+
client = _audit_client.get()
|
|
98
|
+
if client:
|
|
99
|
+
parts.append(f"client={client}")
|
|
100
|
+
_get_audit_logger().info(" ".join(parts))
|
|
101
|
+
except Exception:
|
|
102
|
+
pass # audit failures must never crash the main path
|
|
@@ -48,9 +48,28 @@ class _NoteEncryptor:
|
|
|
48
48
|
return stored
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def _key_from_keyring() -> str:
|
|
52
|
+
"""Read the encryption passphrase from the OS keychain, if the optional
|
|
53
|
+
`keyring` dependency is installed and a value is stored under
|
|
54
|
+
service "vectr", username "encrypt-key". Returns "" on any failure —
|
|
55
|
+
keychain sourcing is a best-effort convenience, never a hard requirement.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
import keyring # optional dependency (pip install vectr[encryption])
|
|
59
|
+
except Exception:
|
|
60
|
+
return ""
|
|
61
|
+
try:
|
|
62
|
+
return keyring.get_password("vectr", "encrypt-key") or ""
|
|
63
|
+
except Exception:
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
|
|
51
67
|
def _build_encryptor() -> _NoteEncryptor | None:
|
|
52
|
-
"""Return a _NoteEncryptor
|
|
53
|
-
|
|
68
|
+
"""Return a _NoteEncryptor when an encryption passphrase is available, else
|
|
69
|
+
None (encryption off — the default). Sourcing precedence: the
|
|
70
|
+
VECTR_ENCRYPT_KEY environment variable wins; if it is unset, the OS keychain
|
|
71
|
+
is consulted (env or OS keychain, per the security design)."""
|
|
72
|
+
key = os.getenv("VECTR_ENCRYPT_KEY", "") or _key_from_keyring()
|
|
54
73
|
return _NoteEncryptor(key) if key else None
|
|
55
74
|
|
|
56
75
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
|
+
import os
|
|
8
9
|
import sqlite3
|
|
9
10
|
import time
|
|
10
11
|
from pathlib import Path
|
|
@@ -30,6 +31,11 @@ _NOTES_EMBED_MODEL_KEY = "embed_model"
|
|
|
30
31
|
# embed_fn per call during a one-time startup migration.
|
|
31
32
|
_NOTES_REEMBED_BATCH_SIZE = 256
|
|
32
33
|
|
|
34
|
+
# SQLite busy-wait for a contended write lock (team mode: concurrent clients +
|
|
35
|
+
# the CLI can share one workspace's notes DB). Intentionally NOT in config.yaml
|
|
36
|
+
# — a robustness/timeout knob, same category as the throughput constants above.
|
|
37
|
+
_SQLITE_BUSY_TIMEOUT_S = 5.0
|
|
38
|
+
|
|
33
39
|
|
|
34
40
|
class WorkingContextStore:
|
|
35
41
|
"""
|
|
@@ -80,7 +86,16 @@ class WorkingContextStore:
|
|
|
80
86
|
self._embed_query_fn = embed_query_fn or embed_fn # query-mode embed, defaults to embed_fn
|
|
81
87
|
self._embed_model = embed_model # current model name, for the embed-model stamp/migration
|
|
82
88
|
self._notes_col = None
|
|
83
|
-
|
|
89
|
+
# Strict encryption posture (VECTR_ENCRYPT_DISABLE_NOTE_VECTORS): when
|
|
90
|
+
# encryption is on, the note embedding vectors are a lossy plaintext
|
|
91
|
+
# projection of note content living in the Chroma store. Setting this
|
|
92
|
+
# omits them entirely — recall falls back to lexical SQL LIKE — so no
|
|
93
|
+
# representation of note content leaves the encrypted SQLite column.
|
|
94
|
+
strict_no_vectors = (
|
|
95
|
+
self._encryptor is not None
|
|
96
|
+
and os.getenv("VECTR_ENCRYPT_DISABLE_NOTE_VECTORS", "") == "1"
|
|
97
|
+
)
|
|
98
|
+
if embed_fn is not None and notes_chroma_client is not None and not strict_no_vectors:
|
|
84
99
|
try:
|
|
85
100
|
self._notes_col = notes_chroma_client.get_or_create_collection(
|
|
86
101
|
name="working_memory",
|
|
@@ -101,9 +116,15 @@ class WorkingContextStore:
|
|
|
101
116
|
)
|
|
102
117
|
|
|
103
118
|
def _conn(self) -> sqlite3.Connection:
|
|
104
|
-
conn = sqlite3.connect(str(self._db_path))
|
|
119
|
+
conn = sqlite3.connect(str(self._db_path), timeout=_SQLITE_BUSY_TIMEOUT_S)
|
|
105
120
|
conn.row_factory = sqlite3.Row
|
|
106
121
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
122
|
+
# Team mode: several clients (and the CLI) can hit one workspace's notes
|
|
123
|
+
# DB concurrently. WAL allows concurrent readers + one writer; busy_timeout
|
|
124
|
+
# makes a would-be second writer wait for the lock instead of immediately
|
|
125
|
+
# raising "database is locked". note_id is AUTOINCREMENT, so IDs stay
|
|
126
|
+
# unique under concurrent inserts once writes are serialized by the lock.
|
|
127
|
+
conn.execute(f"PRAGMA busy_timeout={int(_SQLITE_BUSY_TIMEOUT_S * 1000)}")
|
|
107
128
|
return conn
|
|
108
129
|
|
|
109
130
|
def _init_db(self) -> None:
|
|
@@ -214,7 +235,15 @@ class WorkingContextStore:
|
|
|
214
235
|
if stripped:
|
|
215
236
|
title = stripped[:80]
|
|
216
237
|
break
|
|
217
|
-
|
|
238
|
+
# Encrypt BOTH content and the (possibly content-derived) title: the
|
|
239
|
+
# default title is the first content line, so a plaintext title column
|
|
240
|
+
# would leak the very text encryption is meant to protect.
|
|
241
|
+
if self._encryptor:
|
|
242
|
+
stored_content = self._encryptor.encrypt(content)
|
|
243
|
+
stored_title = self._encryptor.encrypt(title)
|
|
244
|
+
else:
|
|
245
|
+
stored_content = content
|
|
246
|
+
stored_title = title
|
|
218
247
|
|
|
219
248
|
with self._conn() as conn:
|
|
220
249
|
# conflict resolution: if another note anchors the same code block, supersede it
|
|
@@ -241,7 +270,7 @@ class WorkingContextStore:
|
|
|
241
270
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1.0, ?, 1.0, ?, NULL, ?, ?)
|
|
242
271
|
""",
|
|
243
272
|
(workspace, stored_content, tags_json, priority, kind, now, now, session_id,
|
|
244
|
-
author_id, now, code_hash,
|
|
273
|
+
author_id, now, code_hash, stored_title),
|
|
245
274
|
)
|
|
246
275
|
note_id = cur.lastrowid
|
|
247
276
|
|
|
@@ -751,11 +780,17 @@ class WorkingContextStore:
|
|
|
751
780
|
return row[0] if row else 0
|
|
752
781
|
|
|
753
782
|
def forget_all(self, workspace: str) -> int:
|
|
754
|
-
"""Clear all notes for a workspace.
|
|
783
|
+
"""Clear all notes AND snapshots for a workspace.
|
|
784
|
+
|
|
785
|
+
Snapshots embed full note contents in their payload, so a purge that
|
|
786
|
+
deleted only the notes table would silently keep every note's text
|
|
787
|
+
alive in `snapshots` — "delete everything" must mean everything,
|
|
788
|
+
including the note embedding vectors in the Chroma collection."""
|
|
755
789
|
with self._conn() as conn:
|
|
756
790
|
deleted = conn.execute(
|
|
757
791
|
"DELETE FROM notes WHERE workspace = ?", (workspace,)
|
|
758
792
|
).rowcount
|
|
793
|
+
conn.execute("DELETE FROM snapshots WHERE workspace = ?", (workspace,))
|
|
759
794
|
if deleted > 0 and self._notes_col is not None:
|
|
760
795
|
try:
|
|
761
796
|
existing_ids = self._notes_col.get(include=[])["ids"]
|
|
@@ -767,13 +802,23 @@ class WorkingContextStore:
|
|
|
767
802
|
return deleted
|
|
768
803
|
|
|
769
804
|
def forget_all_workspaces(self) -> int:
|
|
770
|
-
"""Delete ALL notes
|
|
805
|
+
"""Delete ALL notes, snapshots, and note vectors across ALL workspaces
|
|
806
|
+
in this SQLite file.
|
|
771
807
|
|
|
772
|
-
Used by `vectr forget --all` to give a global clean slate
|
|
808
|
+
Used by `vectr forget --all` to give a global clean slate — the same
|
|
809
|
+
"everything means everything" contract as forget_all above.
|
|
773
810
|
Audit entry logged per deletion.
|
|
774
811
|
"""
|
|
775
812
|
with self._conn() as conn:
|
|
776
813
|
deleted = conn.execute("DELETE FROM notes").rowcount
|
|
814
|
+
conn.execute("DELETE FROM snapshots")
|
|
815
|
+
if self._notes_col is not None:
|
|
816
|
+
try:
|
|
817
|
+
existing_ids = self._notes_col.get(include=[])["ids"]
|
|
818
|
+
if existing_ids:
|
|
819
|
+
self._notes_col.delete(ids=existing_ids)
|
|
820
|
+
except Exception:
|
|
821
|
+
pass
|
|
777
822
|
audit("FORGET_ALL_WORKSPACES", deleted=deleted)
|
|
778
823
|
return deleted
|
|
779
824
|
|
|
@@ -846,6 +891,11 @@ class WorkingContextStore:
|
|
|
846
891
|
"retrieved_chunks": retrieved_chunks or [],
|
|
847
892
|
"session_id": session_id,
|
|
848
893
|
})
|
|
894
|
+
# The payload embeds decrypted note contents (recall() decrypts), so a
|
|
895
|
+
# plaintext snapshots table would bypass note encryption entirely.
|
|
896
|
+
# Encrypt the whole payload under the same key as note content.
|
|
897
|
+
if self._encryptor:
|
|
898
|
+
payload = self._encryptor.encrypt(payload)
|
|
849
899
|
with self._conn() as conn:
|
|
850
900
|
conn.execute(
|
|
851
901
|
"INSERT OR REPLACE INTO snapshots (snapshot_id, workspace, label, payload, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
@@ -868,7 +918,18 @@ class WorkingContextStore:
|
|
|
868
918
|
).fetchone()
|
|
869
919
|
if row is None:
|
|
870
920
|
return None
|
|
871
|
-
|
|
921
|
+
payload = row["payload"]
|
|
922
|
+
if self._encryptor:
|
|
923
|
+
# Tolerant decrypt: snapshots written before payload encryption (or
|
|
924
|
+
# before a key was configured) pass through unchanged.
|
|
925
|
+
payload = self._encryptor.decrypt(payload)
|
|
926
|
+
try:
|
|
927
|
+
return json.loads(payload)
|
|
928
|
+
except (json.JSONDecodeError, TypeError):
|
|
929
|
+
# Ciphertext without the (correct) key configured — unreadable by
|
|
930
|
+
# design; treat as not restorable rather than crashing the caller.
|
|
931
|
+
logger.warning("snapshot %s payload is not readable (encrypted with a different key?)", snapshot_id)
|
|
932
|
+
return None
|
|
872
933
|
|
|
873
934
|
# ------------------------------------------------------------------
|
|
874
935
|
# Eviction hints — which chunks can vectr re-retrieve in <50ms?
|
|
@@ -995,9 +1056,13 @@ class WorkingContextStore:
|
|
|
995
1056
|
|
|
996
1057
|
def _row_to_note(self, row: sqlite3.Row) -> WorkingNote:
|
|
997
1058
|
content = row["content"]
|
|
1059
|
+
keys = row.keys()
|
|
1060
|
+
title = row["title"] if "title" in keys else ""
|
|
998
1061
|
if self._encryptor:
|
|
999
1062
|
content = self._encryptor.decrypt(content)
|
|
1000
|
-
|
|
1063
|
+
# Tolerant decrypt: titles written before title-encryption (or before
|
|
1064
|
+
# encryption was enabled at all) are returned unchanged.
|
|
1065
|
+
title = self._encryptor.decrypt(title)
|
|
1001
1066
|
return WorkingNote(
|
|
1002
1067
|
note_id=row["note_id"],
|
|
1003
1068
|
workspace=row["workspace"],
|
|
@@ -1017,7 +1082,7 @@ class WorkingContextStore:
|
|
|
1017
1082
|
code_hash=row["code_hash"] if "code_hash" in keys else "",
|
|
1018
1083
|
superseded_by=row["superseded_by"] if "superseded_by" in keys else None,
|
|
1019
1084
|
superseded_at=row["superseded_at"] if "superseded_at" in keys else None,
|
|
1020
|
-
title=
|
|
1085
|
+
title=title,
|
|
1021
1086
|
)
|
|
1022
1087
|
|
|
1023
1088
|
def format_notes_for_llm(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""FastAPI application entry point. Port: 8765."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import hmac
|
|
4
5
|
import logging
|
|
5
6
|
import os
|
|
6
7
|
|
|
@@ -56,7 +57,7 @@ app = FastAPI(
|
|
|
56
57
|
"(search/locate/trace/map) plus a working-memory store (remember/recall/"
|
|
57
58
|
"snapshot) that survives context compaction and session boundaries."
|
|
58
59
|
),
|
|
59
|
-
version="1.0.
|
|
60
|
+
version="1.0.3",
|
|
60
61
|
lifespan=lifespan,
|
|
61
62
|
)
|
|
62
63
|
|
|
@@ -65,15 +66,16 @@ app.add_middleware(
|
|
|
65
66
|
allow_origins=["http://localhost", "http://127.0.0.1"],
|
|
66
67
|
allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?",
|
|
67
68
|
allow_methods=["GET", "POST"],
|
|
68
|
-
allow_headers=["Content-Type", "Authorization", "X-Api-Key"],
|
|
69
|
+
allow_headers=["Content-Type", "Authorization", "X-Api-Key", "X-Vectr-Client", "X-Session-ID"],
|
|
69
70
|
)
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
# Optional API key enforcement — only active when VECTR_API_KEY is set.
|
|
73
74
|
# Solo dev / personal machine: no key required by default.
|
|
74
|
-
# For shared
|
|
75
|
-
# Middleware reads the env var at request time (not
|
|
76
|
-
# be changed without restarting and tests can patch
|
|
75
|
+
# For shared-host or team (central instance) use: set VECTR_API_KEY and share it
|
|
76
|
+
# with connecting clients. Middleware reads the env var at request time (not
|
|
77
|
+
# import time) so the key can be changed without restarting and tests can patch
|
|
78
|
+
# os.environ cleanly.
|
|
77
79
|
@app.middleware("http")
|
|
78
80
|
async def require_api_key(request: Request, call_next) -> Response:
|
|
79
81
|
api_key = os.getenv("VECTR_API_KEY", "")
|
|
@@ -89,7 +91,10 @@ async def require_api_key(request: Request, call_next) -> Response:
|
|
|
89
91
|
request.headers.get("X-Api-Key", "")
|
|
90
92
|
or request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
|
91
93
|
)
|
|
92
|
-
|
|
94
|
+
# Constant-time comparison — never a plain `==`/`!=`, which leaks the key
|
|
95
|
+
# length and prefix via response timing. compare_digest requires both
|
|
96
|
+
# operands be the same type; encode to bytes so a unicode key is handled.
|
|
97
|
+
if not hmac.compare_digest(provided.encode("utf-8"), api_key.encode("utf-8")):
|
|
93
98
|
return Response(
|
|
94
99
|
content='{"error":"unauthorized","detail":"Set X-Api-Key or Authorization: Bearer <VECTR_API_KEY>"}',
|
|
95
100
|
status_code=401,
|