fusion-rag 0.8.32__py3-none-any.whl

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.
Files changed (80) hide show
  1. fusion_rag/__init__.py +21 -0
  2. fusion_rag/_data_dir.py +22 -0
  3. fusion_rag/_validators.py +183 -0
  4. fusion_rag/api/__init__.py +1 -0
  5. fusion_rag/api/access.py +180 -0
  6. fusion_rag/api/app_state.py +516 -0
  7. fusion_rag/api/auth.py +201 -0
  8. fusion_rag/api/identity.py +150 -0
  9. fusion_rag/api/logging_setup.py +146 -0
  10. fusion_rag/api/mcp_server.py +317 -0
  11. fusion_rag/api/routes.py +400 -0
  12. fusion_rag/api/routes_admin.py +341 -0
  13. fusion_rag/api/routes_auth.py +86 -0
  14. fusion_rag/api/routes_bnup.py +288 -0
  15. fusion_rag/api/routes_docs.py +1185 -0
  16. fusion_rag/api/routes_graph.py +127 -0
  17. fusion_rag/api/routes_kb.py +116 -0
  18. fusion_rag/api/routes_project.py +75 -0
  19. fusion_rag/api/routes_search.py +619 -0
  20. fusion_rag/api/routes_store.py +134 -0
  21. fusion_rag/api/server.py +485 -0
  22. fusion_rag/api/tenant.py +258 -0
  23. fusion_rag/bnup/__init__.py +0 -0
  24. fusion_rag/bnup/corpus.py +177 -0
  25. fusion_rag/connectors/__init__.py +186 -0
  26. fusion_rag/connectors/git_loader.py +142 -0
  27. fusion_rag/console/__init__.py +1 -0
  28. fusion_rag/console/assets/index-B2qffG47.css +1 -0
  29. fusion_rag/console/assets/index-DQEFCBAu.js +40 -0
  30. fusion_rag/console/index.html +17 -0
  31. fusion_rag/embed/__init__.py +1 -0
  32. fusion_rag/embed/client.py +309 -0
  33. fusion_rag/embed/local.py +80 -0
  34. fusion_rag/engine/__init__.py +1 -0
  35. fusion_rag/engine/ast_chunker.py +226 -0
  36. fusion_rag/engine/audit_logger.py +296 -0
  37. fusion_rag/engine/bench.py +200 -0
  38. fusion_rag/engine/bm25_index.py +357 -0
  39. fusion_rag/engine/chunker.py +227 -0
  40. fusion_rag/engine/citation_audit.py +210 -0
  41. fusion_rag/engine/contextualizer.py +156 -0
  42. fusion_rag/engine/cross_encoder_reranker.py +125 -0
  43. fusion_rag/engine/document.py +349 -0
  44. fusion_rag/engine/embedding_cache.py +233 -0
  45. fusion_rag/engine/eval_set.py +210 -0
  46. fusion_rag/engine/evaluator.py +460 -0
  47. fusion_rag/engine/graph_rag.py +314 -0
  48. fusion_rag/engine/incremental_sync.py +184 -0
  49. fusion_rag/engine/knowledge_base.py +389 -0
  50. fusion_rag/engine/llm_auth_state.py +74 -0
  51. fusion_rag/engine/llm_errors.py +38 -0
  52. fusion_rag/engine/metrics.py +300 -0
  53. fusion_rag/engine/ocr_hook.py +135 -0
  54. fusion_rag/engine/preprocessor.py +147 -0
  55. fusion_rag/engine/query_rewriter.py +187 -0
  56. fusion_rag/engine/rag_chain.py +287 -0
  57. fusion_rag/engine/recall_bench.py +412 -0
  58. fusion_rag/engine/reranker.py +321 -0
  59. fusion_rag/engine/retrievers.py +139 -0
  60. fusion_rag/engine/runtime_config.py +138 -0
  61. fusion_rag/engine/search_template.py +322 -0
  62. fusion_rag/engine/sqlite_base.py +118 -0
  63. fusion_rag/engine/streaming.py +117 -0
  64. fusion_rag/engine/trajectory_writer.py +105 -0
  65. fusion_rag/engine/version_manager.py +347 -0
  66. fusion_rag/permissions/__init__.py +15 -0
  67. fusion_rag/permissions/acl.py +242 -0
  68. fusion_rag/store/__init__.py +14 -0
  69. fusion_rag/store/fusion_store_backend.py +366 -0
  70. fusion_rag/store/local_backend.py +313 -0
  71. fusion_rag/store/metadata_store.py +343 -0
  72. fusion_rag/store/qdrant_backend.py +322 -0
  73. fusion_rag/store/remote_backend.py +217 -0
  74. fusion_rag/store/store_backend.py +101 -0
  75. fusion_rag/store/vector_store.py +148 -0
  76. fusion_rag-0.8.32.dist-info/METADATA +891 -0
  77. fusion_rag-0.8.32.dist-info/RECORD +80 -0
  78. fusion_rag-0.8.32.dist-info/WHEEL +5 -0
  79. fusion_rag-0.8.32.dist-info/licenses/LICENSE +190 -0
  80. fusion_rag-0.8.32.dist-info/top_level.txt +1 -0
fusion_rag/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Fusion-RAG — Apple Silicon native offline vector knowledge base backend.
2
+
3
+ All model inference (embeddings, chat) goes through fusion-mlx HTTP API.
4
+ Never imports MLX, mlx-lm, or any engine code directly.
5
+ """
6
+
7
+ from .embed.client import EmbeddingClient
8
+ from .engine.chunker import Chunker
9
+ from .engine.document import DocumentParser, DocumentType
10
+ from .engine.knowledge_base import KnowledgeBase, KnowledgeBaseConfig
11
+ from .store.metadata_store import MetadataStore
12
+
13
+ __all__ = [
14
+ "Chunker",
15
+ "DocumentParser",
16
+ "DocumentType",
17
+ "EmbeddingClient",
18
+ "KnowledgeBase",
19
+ "KnowledgeBaseConfig",
20
+ "MetadataStore",
21
+ ]
@@ -0,0 +1,22 @@
1
+ # Issue #96: single-point data-dir resolution via fusion_core.config.
2
+ # fusion-core's convention passes the SHORT app name ("rag" — see
3
+ # fusion-core tests/test_config_conventions.py), so the env var read is
4
+ # FUSION_RAG_DATA_DIR; passing the full "fusion-rag" name would double the
5
+ # FUSION_ prefix on the env lookup. The bare-app default (~/.rag) would
6
+ # orphan every existing store, so the pre-#96 location ~/.fusion-rag is
7
+ # passed as the explicit default. Call sites stay one-line: data_dir().
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ from fusion_core.config import service_data_dir
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def data_dir() -> Path:
20
+ resolved = service_data_dir("rag", default=Path.home() / ".fusion-rag")
21
+ logger.debug("fusion-rag data dir resolved: %s", resolved)
22
+ return resolved
@@ -0,0 +1,183 @@
1
+ """Input validation helpers — single trust-boundary layer.
2
+
3
+ Centralizes all external-input confinement (硬伤3):
4
+ - kb_id / table_name / identifier -> regex whitelist
5
+ - filesystem path -> configured root + is_relative_to + symlink reject
6
+ - URL -> scheme whitelist + private-network reject (SSRF guard)
7
+ - git repo_url -> scheme whitelist + ext:: reject (RCE guard)
8
+
9
+ No business logic here. Pure validation + raise on violation. Every
10
+ caller that takes external input and feeds it to fs/SQL/network/git
11
+ must route through these helpers.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ipaddress
17
+ import logging
18
+ import re
19
+ import socket
20
+ from pathlib import Path
21
+ from urllib.parse import urlparse
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # identifier whitelist: letters/digits/_-. , 1-64 chars, no path separators
26
+ _IDENT_RE = re.compile(r"^[A-Za-z0-9_\-]{1,64}$")
27
+ # SQL identifier: letters/digits/_., must start with letter/_
28
+ _SQL_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
29
+
30
+ _ALLOWED_URL_SCHEMES = frozenset({"http", "https"})
31
+ _ALLOWED_GIT_SCHEMES = frozenset({"http", "https", "git", "ssh"})
32
+
33
+ # RFC1918 + loopback + link-local + unique-local v6 ranges
34
+ _PRIVATE_NETWORKS = [
35
+ ipaddress.ip_network("10.0.0.0/8"),
36
+ ipaddress.ip_network("172.16.0.0/12"),
37
+ ipaddress.ip_network("192.168.0.0/16"),
38
+ ipaddress.ip_network("127.0.0.0/8"),
39
+ ipaddress.ip_network("169.254.0.0/16"),
40
+ ipaddress.ip_network("0.0.0.0/8"),
41
+ ipaddress.ip_network("::1/128"),
42
+ ipaddress.ip_network("fc00::/7"),
43
+ ipaddress.ip_network("fe80::/10"),
44
+ ]
45
+
46
+
47
+ class ValidationError(ValueError):
48
+ """Raised when external input violates trust-boundary rules."""
49
+
50
+
51
+ def validate_identifier(name: str, *, field: str = "identifier") -> str:
52
+ """Validate a KB id / rule id / generic identifier.
53
+
54
+ Rejects path separators, dots, and any non-whitelisted char.
55
+ """
56
+ # P4-8: the regex ^[A-Za-z0-9_\\-]{1,64}$ already rejects "."/".." (dots
57
+ # not in the charset), so the prior `if name in (".", "..")` branch was
58
+ # unreachable dead code — a sign the validation layers weren't reasoning
59
+ # about each other. Removed; the regex is the sole authority.
60
+ if not name or not _IDENT_RE.match(name):
61
+ logger.warning("validate_identifier reject: field=%s value=%r", field, name)
62
+ raise ValidationError(f"invalid {field}: must match [A-Za-z0-9_-]{{1,64}}")
63
+ return name
64
+
65
+
66
+ def validate_sql_identifier(name: str, *, field: str = "table_name") -> str:
67
+ """Validate a SQL table/column identifier against injection.
68
+
69
+ Identifiers cannot be bound as params, so we whitelist the charset
70
+ and reject anything containing quotes / semicolons / path separators.
71
+ Caller should still wrap the result in double quotes for SQLite PRAGMA.
72
+ """
73
+ if not name or not _SQL_IDENT_RE.match(name):
74
+ logger.warning("validate_sql_identifier reject: field=%s value=%r", field, name)
75
+ raise ValidationError(f"invalid {field}: must match [A-Za-z_][A-Za-z0-9_.]*")
76
+ if '"' in name or ";" in name or "--" in name:
77
+ raise ValidationError(f"invalid {field}: forbidden chars")
78
+ return name
79
+
80
+
81
+ def validate_path_under_root(
82
+ path: str,
83
+ *,
84
+ root: str | Path,
85
+ field: str = "file_path",
86
+ allow_root: bool = False,
87
+ ) -> Path:
88
+ """Confine a filesystem path to a configured root (LFI / path-traversal guard).
89
+
90
+ Resolves symlinks, then asserts the resolved path is relative to root.
91
+ Rejects symlink escapes and any path that leaves root.
92
+
93
+ NOTE (P1-11, TOCTOU): this validates the path AT CALL TIME. resolve() follows
94
+ symlinks, so a symlink that points inside root at check-time passes; if an
95
+ attacker swaps it to point outside root between this check and the caller's
96
+ open(), the guard is defeated (classic TOCTOU). A pure-path validator cannot
97
+ close this — callers that read untrusted paths MUST open with O_NOFOLLOW (or
98
+ os.open + lstat-vs-fstat) to make the check-then-use atomic. This function is
99
+ the first line, not the only line.
100
+ """
101
+ if not path:
102
+ raise ValidationError(f"invalid {field}: empty")
103
+ if not root:
104
+ raise ValidationError(f"invalid root for {field}: empty")
105
+ root_resolved = Path(root).expanduser().resolve()
106
+ target = Path(path).expanduser().resolve()
107
+ if not allow_root and target == root_resolved:
108
+ # P1-11: this branch was a dead `pass` — the docstring claimed it
109
+ # disallowed operating on root itself, but it never raised, so the
110
+ # promise was a lie. All current callers validate a file UNDER root
111
+ # (never the root dir), so rejecting root-equal matches intent. If a
112
+ # future caller legitimately needs the root, pass allow_root=True.
113
+ logger.warning("validate_path_under_root reject: field=%s path equals root=%s", field, root_resolved)
114
+ raise ValidationError(f"invalid {field}: path equals ingest root (not a file under it)")
115
+ if not target.is_relative_to(root_resolved):
116
+ logger.warning(
117
+ "validate_path_under_root reject: field=%s path=%s resolved=%s root=%s",
118
+ field,
119
+ path,
120
+ target,
121
+ root_resolved,
122
+ )
123
+ raise ValidationError(f"invalid {field}: escapes ingest root")
124
+ # Symlink check: if the raw path is a symlink, ensure it doesn't escape.
125
+ raw = Path(path)
126
+ if raw.is_symlink() and not Path(raw.readlink()).resolve().is_relative_to(root_resolved):
127
+ raise ValidationError(f"invalid {field}: symlink escapes root")
128
+ return target
129
+
130
+
131
+ def _host_is_private(host: str) -> bool:
132
+ try:
133
+ addr = ipaddress.ip_address(socket.gethostbyname(host))
134
+ except (OSError, ValueError):
135
+ return False
136
+ return any(addr in net for net in _PRIVATE_NETWORKS)
137
+
138
+
139
+ def validate_url(url: str, *, field: str = "url", allow_private: bool = False) -> str:
140
+ """Validate an HTTP(S) URL — scheme whitelist + SSRF guard.
141
+
142
+ Rejects non-http(s) schemes, and (unless allow_private) hosts resolving
143
+ to private/loopback/link-local ranges.
144
+ """
145
+ if not url:
146
+ raise ValidationError(f"invalid {field}: empty")
147
+ parsed = urlparse(url)
148
+ scheme = (parsed.scheme or "").lower()
149
+ if scheme not in _ALLOWED_URL_SCHEMES:
150
+ logger.warning("validate_url reject: field=%s scheme=%s", field, scheme)
151
+ raise ValidationError(f"invalid {field}: scheme must be http/https")
152
+ host = parsed.hostname or ""
153
+ if not host:
154
+ raise ValidationError(f"invalid {field}: no host")
155
+ if not allow_private and _host_is_private(host):
156
+ logger.warning("validate_url reject: field=%s host=%s is private", field, host)
157
+ raise ValidationError(f"invalid {field}: private/loopback host denied")
158
+ return url
159
+
160
+
161
+ def validate_git_url(url: str, *, field: str = "repo_url") -> str:
162
+ """Validate a git clone URL — scheme whitelist + ext:: RCE guard.
163
+
164
+ Rejects ext:: (and any non-whitelisted scheme) which git treats as an
165
+ executable transport protocol (RCE via post-checkout hook chain).
166
+ """
167
+ if not url:
168
+ raise ValidationError(f"invalid {field}: empty")
169
+ # Reject ext:: outright — it is an executable transport.
170
+ if url.startswith("ext::") or "\n" in url or "\0" in url:
171
+ logger.warning("validate_git_url reject: field=%s ext/nul/newline", field)
172
+ raise ValidationError(f"invalid {field}: ext:: transport denied")
173
+ parsed = urlparse(url)
174
+ scheme = (parsed.scheme or "").lower()
175
+ if scheme and scheme not in _ALLOWED_GIT_SCHEMES:
176
+ logger.warning("validate_git_url reject: field=%s scheme=%s", field, scheme)
177
+ raise ValidationError(f"invalid {field}: scheme must be http/https/git/ssh")
178
+ # For http/https also apply SSRF guard.
179
+ if scheme in ("http", "https"):
180
+ host = parsed.hostname or ""
181
+ if host and _host_is_private(host):
182
+ raise ValidationError(f"invalid {field}: private/loopback host denied")
183
+ return url
@@ -0,0 +1 @@
1
+ """Fusion-RAG package."""
@@ -0,0 +1,180 @@
1
+ """P0-5/P0-6: KB-level access control dependency.
2
+
3
+ Wires the existing permission module (ACL + PermissionManager) into the REST
4
+ surface — before this, permission rules were CRUD-able but never enforced,
5
+ which is worse than no permissions: admins believed access was restricted when
6
+ it was not.
7
+
8
+ Semantics:
9
+ - NoAuth (FUSION_RAG_AUTH_BACKEND=none or no admin key): allow. Local-first
10
+ default — a single-user offline box must not start 401-ing.
11
+ - Authenticated subject == "admin": allow any action (ACL bypass).
12
+ - KB has NO permission rules: allow. An open KB keeps working; enforcement
13
+ only engages once an admin has actually written a rule for that KB. This is
14
+ the backward-compat path — existing KBs are not silently locked out.
15
+ - KB HAS rules for the subject: fail-closed. PermissionManager.check_permission
16
+ decides; a denied check is 403, a backend error is 403 (never allow-on-error).
17
+
18
+ This dependency runs the same kb_id validation + KB lookup as _get_base, so
19
+ endpoints can keep their own _get_base call (it is a cheap dict get) OR rely
20
+ on the dependency's resolution. To keep the diff surgical, endpoints keep their
21
+ existing _get_base; the dependency resolves independently and its 404/400
22
+ fires before the handler body runs.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+
29
+ from fastapi import Depends, HTTPException
30
+
31
+ from .._validators import validate_identifier
32
+ from .app_state import get_kb_manager
33
+ from .auth import verify_api_key
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ def _resolve_base(kb_id: str):
39
+ # Mirror of each router's _get_base: confine kb_id then fetch. Duplicated
40
+ # deliberately (Rule 3) so access.py has no import edge into any router
41
+ # module — routers import access.py, never the reverse.
42
+ try:
43
+ validate_identifier(kb_id, field="kb_id")
44
+ except ValueError:
45
+ raise HTTPException(400, f"Invalid kb_id: {kb_id}")
46
+ # Issue #61: scope by the request's authoritative tenant when isolation is
47
+ # on. The ACL dependency (require_kb_action) runs BEFORE the handler's
48
+ # _get_base on most routes — without tenant scoping here a tenant-A caller
49
+ # would pass the ACL gate on tenant-B's KB. The 404 (not 403) on a
50
+ # cross-tenant mismatch avoids leaking the KB's existence.
51
+ from .tenant import tenant_scope
52
+
53
+ tenant, require_match = tenant_scope()
54
+ try:
55
+ return get_kb_manager().get(kb_id, tenant=tenant, require_tenant_match=require_match)
56
+ except KeyError:
57
+ raise HTTPException(404, f"Knowledge base '{kb_id}' not found")
58
+
59
+
60
+ def _check_kb_access(kb_id: str, subject: str | None, action: str, resource_path: str = "/") -> None:
61
+ # NoAuth -> subject is None -> open (local-first default).
62
+ if subject is None:
63
+ return
64
+ # Admin key authenticates as "admin" (auth.py verify) -> ACL bypass.
65
+ if subject == "admin":
66
+ return
67
+ kb = _resolve_base(kb_id)
68
+ storage_path = kb.vector_path.rsplit("/vectors", 1)[0] if "/vectors" in kb.vector_path else kb.vector_path
69
+ # A-P1-2: reuse the pooled PermissionManager (app_state admin pool) instead
70
+ # of constructing one per request — each prior PermissionManager(...) opened
71
+ # a fresh sqlite conn + re-initialized on every ACL check. One per storage
72
+ # path, reused; closed from the lifespan shutdown.
73
+ from .app_state import get_permission_manager
74
+
75
+ pm = get_permission_manager(storage_path)
76
+ # Open-KB path: if no rules exist for this KB at all, treat it as open.
77
+ # Enforcement only engages once a rule is written (backward compat).
78
+ try:
79
+ all_rules = pm.list_rules(kb_id)
80
+ except Exception as e:
81
+ # Fail-closed on a permission-store read error — never allow-on-error.
82
+ logger.error("permission store read failed for kb=%s, denying (fail-closed): %s", kb_id, e)
83
+ raise HTTPException(403, "Access denied (permission store unavailable)")
84
+ if not all_rules:
85
+ logger.debug("access: no permission rules for kb=%s -> open KB, allow", kb_id)
86
+ return
87
+ # H4 fix: pass the REAL resource_path, not a hardcoded "/". The prior code
88
+ # always checked "/" — and every path-prefixed rule matches "/" (rule root
89
+ # is a prefix of "/"), so path-level ACL was never enforced: a subject
90
+ # restricted to "/docs/restricted/*" could read "/docs/public/*" too.
91
+ # KB-scoped endpoints pass "/" (the KB is the resource); doc-specific
92
+ # endpoints resolve doc_id -> file_path so a rule scoped to a subtree only
93
+ # permits that subtree.
94
+ try:
95
+ allowed = pm.check_permission(kb_id, subject, action, resource_path)
96
+ except Exception as e:
97
+ logger.error("permission check failed for kb=%s subject=%s, denying: %s", kb_id, subject, e)
98
+ raise HTTPException(403, "Access denied")
99
+ if not allowed:
100
+ logger.warning("access denied: kb=%s subject=%s action=%s path=%s", kb_id, subject, action, resource_path)
101
+ raise HTTPException(403, "Access denied")
102
+ logger.debug("access granted: kb=%s subject=%s action=%s path=%s", kb_id, subject, action, resource_path)
103
+
104
+
105
+ def require_admin():
106
+ """S-P1-3/S-P1-1/S-P1-2: admin-only FastAPI dependency for destructive
107
+ mutations that must not be reachable by a non-admin subject — API key
108
+ management (create/delete/list keys) and permission-rule mutation
109
+ (add/delete rules).
110
+
111
+ Semantics match the ACL bypass path: NoAuth (subject is None) -> allow
112
+ (local-first default); admin subject -> allow; any other authenticated
113
+ subject -> 403. A stored key can never authenticate as "admin" (auth.py
114
+ rejects reserved names), so this gate cannot be passed by a non-admin key.
115
+ """
116
+ def _dep(subject: str | None = Depends(verify_api_key)) -> str | None:
117
+ if subject is None:
118
+ return subject
119
+ if subject == "admin":
120
+ return subject
121
+ logger.warning("admin-only endpoint denied to subject=%s", subject)
122
+ raise HTTPException(403, "Admin privileges required")
123
+ return _dep
124
+
125
+
126
+ def require_kb_action(action: str, resolve_doc_path: bool = False):
127
+ """Build a FastAPI dependency enforcing `action` on the path's kb_id.
128
+
129
+ Usage: `dependencies=[Depends(require_kb_action("read"))]` on a route that
130
+ has a {kb_id} path param, or `subject = Depends(require_kb_action("read"))`
131
+ as a handler param to also receive the subject string.
132
+
133
+ resolve_doc_path: for doc-specific routes (/bases/{kb_id}/documents/{doc_id})
134
+ set True — the dependency resolves doc_id -> the doc's stored file_path and
135
+ passes it as the ACL resource_path, so a rule scoped to a path subtree only
136
+ permits that subtree. KB-scoped routes leave it False (resource = "/" = KB).
137
+ """
138
+
139
+ def _dep(
140
+ kb_id: str,
141
+ doc_id: str | None = None,
142
+ subject: str | None = Depends(verify_api_key),
143
+ ) -> str | None:
144
+ resource_path = "/"
145
+ if resolve_doc_path and doc_id:
146
+ resource_path = _resolve_doc_resource_path(kb_id, doc_id)
147
+ _check_kb_access(kb_id, subject, action, resource_path)
148
+ return subject
149
+
150
+ return _dep
151
+
152
+
153
+ def _resolve_doc_resource_path(kb_id: str, doc_id: str) -> str:
154
+ # H4: map a path-param doc_id to the stored file_path so ACL rules scoped to
155
+ # a path subtree can actually enforce at the doc level. If the doc is absent
156
+ # (404) or the store read errors, fall back to "/" — the handler will 404
157
+ # on the missing doc anyway, and failing open to "/" here would let a
158
+ # subtree-restricted subject delete a doc outside its subtree. Instead, on
159
+ # a store error we deny (fail-closed) by returning a path unlikely to match
160
+ # any rule, so ACL.check's no-match -> deny fires. A missing doc returns "/"
161
+ # because the subsequent handler 404s regardless.
162
+ try:
163
+ kb = _resolve_base(kb_id)
164
+ except HTTPException:
165
+ return "/"
166
+ storage_path = kb.vector_path.rsplit("/vectors", 1)[0] if "/vectors" in kb.vector_path else kb.vector_path
167
+ try:
168
+ # A-P1-1: reuse the pooled MetadataStore (app_state meta pool) instead
169
+ # of constructing one per doc-level ACL resolution — each prior
170
+ # MetadataStore(...) opened a fresh sqlite conn on every gated request.
171
+ from .app_state import get_meta_store
172
+
173
+ ms = get_meta_store(f"{storage_path}/metadata.db")
174
+ doc = ms.get_document(doc_id)
175
+ if not doc:
176
+ return "/"
177
+ return doc.get("file_path", "/") or "/"
178
+ except Exception as e:
179
+ logger.warning("access: doc resource_path resolve failed for kb=%s doc=%s: %s", kb_id, doc_id, e)
180
+ return "/__acl_unresolved__"