footprinter-cli 1.0.0__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 (134) hide show
  1. footprinter/__init__.py +8 -0
  2. footprinter/access.py +444 -0
  3. footprinter/api/__init__.py +1 -0
  4. footprinter/api/db.py +61 -0
  5. footprinter/api/entities.py +250 -0
  6. footprinter/api/search.py +47 -0
  7. footprinter/api/semantic.py +33 -0
  8. footprinter/api/server.py +66 -0
  9. footprinter/api/status.py +15 -0
  10. footprinter/bundled/__init__.py +0 -0
  11. footprinter/bundled/config.example.yaml +161 -0
  12. footprinter/bundled/patterns/context_patterns.yaml +18 -0
  13. footprinter/bundled/patterns/extensions.yaml +283 -0
  14. footprinter/bundled/patterns/filename_patterns.yaml +61 -0
  15. footprinter/bundled/patterns/mime_mappings.yaml +68 -0
  16. footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
  17. footprinter/bundled/patterns/security_patterns.yaml +27 -0
  18. footprinter/cli/__init__.py +128 -0
  19. footprinter/cli/__main__.py +6 -0
  20. footprinter/cli/_common.py +332 -0
  21. footprinter/cli/_policy_helpers.py +646 -0
  22. footprinter/cli/_prompt.py +220 -0
  23. footprinter/cli/api_cmd.py +32 -0
  24. footprinter/cli/connect.py +591 -0
  25. footprinter/cli/data.py +879 -0
  26. footprinter/cli/delete.py +128 -0
  27. footprinter/cli/ingest.py +579 -0
  28. footprinter/cli/mcp_cmd.py +750 -0
  29. footprinter/cli/mcp_setup.py +306 -0
  30. footprinter/cli/search.py +393 -0
  31. footprinter/cli/search_cmd.py +69 -0
  32. footprinter/cli/setup.py +1836 -0
  33. footprinter/cli/status.py +729 -0
  34. footprinter/cli/status_cmd.py +104 -0
  35. footprinter/cli/upsert.py +794 -0
  36. footprinter/cli/vectorize_cmd.py +215 -0
  37. footprinter/cli/view.py +322 -0
  38. footprinter/connectors/__init__.py +171 -0
  39. footprinter/connectors/config_utils.py +141 -0
  40. footprinter/db/__init__.py +37 -0
  41. footprinter/db/browser.py +198 -0
  42. footprinter/db/chats.py +610 -0
  43. footprinter/db/clients.py +307 -0
  44. footprinter/db/emails.py +279 -0
  45. footprinter/db/files.py +741 -0
  46. footprinter/db/folders.py +659 -0
  47. footprinter/db/messages.py +192 -0
  48. footprinter/db/policies.py +151 -0
  49. footprinter/db/projects.py +673 -0
  50. footprinter/db/search.py +573 -0
  51. footprinter/db/sql_utils.py +168 -0
  52. footprinter/db/status.py +320 -0
  53. footprinter/db/uploads.py +70 -0
  54. footprinter/ingest/__init__.py +0 -0
  55. footprinter/ingest/adapters/__init__.py +33 -0
  56. footprinter/ingest/adapters/browser.py +54 -0
  57. footprinter/ingest/adapters/chat.py +57 -0
  58. footprinter/ingest/adapters/ingest.py +146 -0
  59. footprinter/ingest/adapters/local_files.py +68 -0
  60. footprinter/ingest/adapters/local_folders.py +52 -0
  61. footprinter/ingest/adapters/protocol.py +174 -0
  62. footprinter/ingest/browser_indexer.py +216 -0
  63. footprinter/ingest/chat_dedup.py +156 -0
  64. footprinter/ingest/chat_indexer.py +515 -0
  65. footprinter/ingest/chat_parsers/__init__.py +8 -0
  66. footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
  67. footprinter/ingest/chat_parsers/claude_parser.py +161 -0
  68. footprinter/ingest/cli.py +827 -0
  69. footprinter/ingest/content_extractors.py +117 -0
  70. footprinter/ingest/database.py +36 -0
  71. footprinter/ingest/db/__init__.py +1 -0
  72. footprinter/ingest/db/connector_schema.py +47 -0
  73. footprinter/ingest/db/migration.py +328 -0
  74. footprinter/ingest/db/schema.py +1043 -0
  75. footprinter/ingest/db/security.py +6 -0
  76. footprinter/ingest/file_indexer.py +261 -0
  77. footprinter/ingest/file_scanner.py +277 -0
  78. footprinter/ingest/folder_indexer.py +226 -0
  79. footprinter/ingest/full_content_extractor.py +321 -0
  80. footprinter/ingest/orchestrator.py +125 -0
  81. footprinter/ingest/pipe_runner.py +217 -0
  82. footprinter/ingest/processing.py +165 -0
  83. footprinter/ingest/registry.py +201 -0
  84. footprinter/ingest/run_record.py +91 -0
  85. footprinter/ingest/status.py +346 -0
  86. footprinter/mcp/__init__.py +0 -0
  87. footprinter/mcp/__main__.py +5 -0
  88. footprinter/mcp/db.py +57 -0
  89. footprinter/mcp/errors.py +102 -0
  90. footprinter/mcp/extraction.py +226 -0
  91. footprinter/mcp/server.py +39 -0
  92. footprinter/mcp/tools/__init__.py +0 -0
  93. footprinter/mcp/tools/navigation.py +70 -0
  94. footprinter/mcp/tools/read.py +75 -0
  95. footprinter/mcp/tools/search.py +158 -0
  96. footprinter/mcp/tools/semantic.py +79 -0
  97. footprinter/mcp/tools/status.py +15 -0
  98. footprinter/paths.py +91 -0
  99. footprinter/permissions.py +1160 -0
  100. footprinter/semantic/__init__.py +13 -0
  101. footprinter/semantic/chunking.py +52 -0
  102. footprinter/semantic/embeddings.py +23 -0
  103. footprinter/semantic/hybrid_search.py +273 -0
  104. footprinter/semantic/vector_store.py +471 -0
  105. footprinter/services/__init__.py +49 -0
  106. footprinter/services/access_service.py +342 -0
  107. footprinter/services/chat_service.py +85 -0
  108. footprinter/services/client_service.py +267 -0
  109. footprinter/services/content_service.py +181 -0
  110. footprinter/services/email_service.py +89 -0
  111. footprinter/services/file_service.py +83 -0
  112. footprinter/services/folder_service.py +122 -0
  113. footprinter/services/includes.py +19 -0
  114. footprinter/services/ingest_service.py +231 -0
  115. footprinter/services/project_service.py +262 -0
  116. footprinter/services/roles.py +25 -0
  117. footprinter/services/search_service.py +177 -0
  118. footprinter/services/semantic_service.py +360 -0
  119. footprinter/services/status_service.py +18 -0
  120. footprinter/services/visit_service.py +65 -0
  121. footprinter/source_registry.py +194 -0
  122. footprinter/utils/__init__.py +7 -0
  123. footprinter/utils/hash_utils.py +59 -0
  124. footprinter/utils/logging_config.py +68 -0
  125. footprinter/utils/mime.py +30 -0
  126. footprinter/utils/text.py +6 -0
  127. footprinter/utils/time.py +11 -0
  128. footprinter/visibility.py +1272 -0
  129. footprinter_cli-1.0.0.dist-info/LICENSE +21 -0
  130. footprinter_cli-1.0.0.dist-info/METADATA +229 -0
  131. footprinter_cli-1.0.0.dist-info/RECORD +134 -0
  132. footprinter_cli-1.0.0.dist-info/WHEEL +5 -0
  133. footprinter_cli-1.0.0.dist-info/entry_points.txt +2 -0
  134. footprinter_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1272 @@
1
+ """
2
+ Visibility resolution for Claude metadata access.
3
+
4
+ Two-tier model:
5
+ - Policies: Explicit rules (file:*, folder:*, source:*, project:*, client:*)
6
+ - Baseline: Hardcoded fallback (BASELINE_VISIBILITY = 'opaque')
7
+
8
+ Most-restrictive-wins semantics applies ONLY among matching policies.
9
+ hidden > opaque > visible
10
+
11
+ Hierarchy layers (checked for policies):
12
+ file:{id} → folder prefix → folder FK → project:{id} → client:{id} → source:*
13
+ email:{id} → project:{id} → client:{id} → account:{acct} → source:emails
14
+ chat:{id} → project:{id} → client:{id} → account:{acct} → source:chats
15
+
16
+ Resolution:
17
+ 1. Collect explicit values from matching policies only
18
+ 2. If any policy is 'hidden' → return 'hidden'
19
+ 3. If any policy is 'opaque' → return 'opaque'
20
+ 4. If any policy is 'visible' → return 'visible'
21
+ 5. No policies matched → return BASELINE_VISIBILITY
22
+
23
+ Visibility states:
24
+ - 'hidden' - item doesn't exist to MCP (excluded from all results and counts)
25
+ - 'opaque' - appears with minimal info (id, content_type, source)
26
+ - 'visible' - full metadata returned
27
+
28
+ Hard Rule: Content read access can never exceed metadata visibility.
29
+ If an item is hidden or opaque, it cannot be read regardless of permission policy.
30
+ """
31
+
32
+ import os
33
+ import sqlite3
34
+ from typing import Dict, List, Literal, Optional, Tuple
35
+
36
+ from footprinter.db.policies import is_folder_path_scope
37
+ from footprinter.db.sql_utils import chunked_query as _chunked_query
38
+
39
+ VisibilityState = Literal["hidden", "opaque", "visible"]
40
+
41
+ # Hardcoded baseline - used when NO policies match
42
+ BASELINE_VISIBILITY: VisibilityState = "opaque"
43
+
44
+
45
+ def get_visibility(conn: sqlite3.Connection, item_type: str, item_id: int) -> VisibilityState:
46
+ """
47
+ Resolve visibility for an item.
48
+
49
+ Args:
50
+ conn: SQLite connection with row_factory = sqlite3.Row
51
+ item_type: 'file', 'email', 'chat', 'folder'
52
+ item_id: Row ID in the relevant table
53
+
54
+ Returns:
55
+ 'hidden', 'opaque', or 'visible'
56
+ """
57
+ cursor = conn.cursor()
58
+
59
+ if item_type == "file":
60
+ return _get_file_visibility(cursor, item_id)
61
+ elif item_type == "email":
62
+ return _get_email_visibility(cursor, item_id)
63
+ elif item_type == "chat":
64
+ return _get_chat_visibility(cursor, item_id)
65
+ elif item_type == "folder":
66
+ return _get_folder_visibility(cursor, item_id)
67
+ elif item_type == "visit":
68
+ return _get_browser_visibility(cursor, item_id)
69
+ elif item_type == "project":
70
+ resolved, _ = _resolve_project_visibility_with_source(cursor, item_id)
71
+ return resolved
72
+ elif item_type == "client":
73
+ resolved, _ = _resolve_client_visibility_with_source(cursor, item_id)
74
+ return resolved
75
+ else:
76
+ return BASELINE_VISIBILITY # Baseline for unknown types
77
+
78
+
79
+ def resolve_visibility_with_source(
80
+ conn: sqlite3.Connection, item_type: str, item_id: int
81
+ ) -> Tuple[VisibilityState, str]:
82
+ """
83
+ Resolve visibility and return the source that determined it.
84
+
85
+ Args:
86
+ conn: SQLite connection with row_factory = sqlite3.Row
87
+ item_type: 'file', 'email', 'chat', 'folder'
88
+ item_id: Row ID in the relevant table
89
+
90
+ Returns:
91
+ Tuple of (resolved_visibility, source_scope)
92
+ e.g., ('visible', "folder:~/Work") or ('opaque', "baseline")
93
+ """
94
+ cursor = conn.cursor()
95
+
96
+ if item_type == "file":
97
+ return _resolve_file_visibility_with_source(cursor, item_id)
98
+ elif item_type == "email":
99
+ return _resolve_email_visibility_with_source(cursor, item_id)
100
+ elif item_type == "chat":
101
+ return _resolve_chat_visibility_with_source(cursor, item_id)
102
+ elif item_type == "folder":
103
+ return _resolve_folder_visibility_with_source(cursor, item_id)
104
+ elif item_type == "project":
105
+ return _resolve_project_visibility_with_source(cursor, item_id)
106
+ elif item_type == "client":
107
+ return _resolve_client_visibility_with_source(cursor, item_id)
108
+ elif item_type == "visit":
109
+ return _resolve_browser_visibility_with_source(cursor, item_id)
110
+ else:
111
+ return (BASELINE_VISIBILITY, "baseline")
112
+
113
+
114
+ def batch_resolve_visibility(
115
+ conn: sqlite3.Connection, item_type: str, item_ids: List[int]
116
+ ) -> Dict[int, Tuple[VisibilityState, str]]:
117
+ """
118
+ Resolve visibility for multiple items efficiently.
119
+
120
+ Args:
121
+ conn: SQLite connection with row_factory = sqlite3.Row
122
+ item_type: 'file', 'email', 'chat', 'folder', 'project', 'client'
123
+ item_ids: List of row IDs
124
+
125
+ Returns:
126
+ Dict mapping item_id to (visibility_state, source) tuple
127
+ """
128
+ if not item_ids:
129
+ return {}
130
+
131
+ cursor = conn.cursor()
132
+
133
+ if item_type == "file":
134
+ return _batch_resolve_file_visibility(cursor, item_ids)
135
+ elif item_type == "project":
136
+ return _batch_resolve_project_visibility(cursor, item_ids)
137
+ elif item_type == "client":
138
+ return _batch_resolve_client_visibility(cursor, item_ids)
139
+ elif item_type == "email":
140
+ return _batch_resolve_email_visibility(cursor, item_ids)
141
+ elif item_type == "chat":
142
+ return _batch_resolve_chat_visibility(cursor, item_ids)
143
+ elif item_type == "folder":
144
+ return _batch_resolve_folder_visibility(cursor, item_ids)
145
+ elif item_type == "visit":
146
+ return _batch_resolve_browser_visibility(cursor, item_ids)
147
+ else:
148
+ return {id_: (BASELINE_VISIBILITY, "baseline") for id_ in item_ids}
149
+
150
+
151
+ def get_source_visibility(conn, scope: str) -> VisibilityState:
152
+ """Get the visibility state for a source scope (e.g. 'source:browser')."""
153
+ cursor = conn.cursor()
154
+ result = _get_policy(cursor, scope)
155
+ if result is not None:
156
+ return result
157
+ state, _ = _get_global_baseline(cursor)
158
+ return state
159
+
160
+
161
+ def _batch_resolve_file_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
162
+ """Batch resolve visibility for files."""
163
+ conn = cursor.connection
164
+
165
+ # Pre-fetch all visibility policies
166
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
167
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
168
+
169
+ # Global policy fallback
170
+ if "global" in all_policies:
171
+ global_baseline = (_resolve(all_policies["global"]), "global")
172
+ else:
173
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
174
+
175
+ # Pre-fetch folder policies sorted by length for prefix matching
176
+ # Exclude numeric-only suffixes (folder:{id}) — those are item-level scopes, not paths
177
+ folder_policies = [
178
+ (scope, setting)
179
+ for scope, setting in all_policies.items()
180
+ if scope.startswith("folder:") and is_folder_path_scope(scope)
181
+ ]
182
+ folder_policies.sort(key=lambda x: len(x[0]), reverse=True)
183
+
184
+ # Pre-fetch file data (chunked to stay under SQLite variable limit)
185
+ rows = _chunked_query(
186
+ cursor,
187
+ """
188
+ SELECT file.id, file.path, file.project_id, file.folder_id,
189
+ COALESCE(file.client_id, project.client_id) AS client_id
190
+ FROM files file
191
+ LEFT JOIN projects project ON file.project_id = project.id
192
+ WHERE file.id IN ({placeholders})
193
+ """,
194
+ item_ids,
195
+ )
196
+ files = {row["id"]: row for row in rows}
197
+
198
+ # Collect unique parent entity IDs for batch resolution
199
+ folder_ids = set()
200
+ project_ids = set()
201
+ client_ids = set()
202
+ for row in files.values():
203
+ if row["folder_id"]:
204
+ folder_ids.add(row["folder_id"])
205
+ if row["project_id"]:
206
+ project_ids.add(row["project_id"])
207
+ if row["client_id"]:
208
+ client_ids.add(row["client_id"])
209
+
210
+ # Batch resolve parent entities
211
+ folder_visibility = batch_resolve_visibility(conn, "folder", list(folder_ids)) if folder_ids else {}
212
+ project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
213
+ client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
214
+
215
+ results = {}
216
+ for file_id in item_ids:
217
+ if file_id not in files:
218
+ results[file_id] = (BASELINE_VISIBILITY, "not_found")
219
+ continue
220
+
221
+ row = files[file_id]
222
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
223
+
224
+ # 1. Item-level policy
225
+ item_scope = f"file:{file_id}"
226
+ if item_scope in all_policies:
227
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
228
+
229
+ # 2. Folder prefix match (most specific first)
230
+ path = row["path"] or ""
231
+ if path:
232
+ for scope, setting in folder_policies:
233
+ prefix = scope[len("folder:") :]
234
+ if prefix.startswith("~"):
235
+ prefix = os.path.expanduser(prefix)
236
+ if path.startswith(prefix):
237
+ policies.append((_resolve(setting), scope))
238
+ break
239
+
240
+ # 3. Folder FK via full resolution (skip baseline)
241
+ folder_id = row["folder_id"]
242
+ if folder_id and folder_id in folder_visibility:
243
+ state, src = folder_visibility[folder_id]
244
+ if src != "baseline":
245
+ policies.append((state, f"folder:{folder_id} (via {src})"))
246
+
247
+ # 4. Project-level via full resolution (skip baseline)
248
+ project_id = row["project_id"]
249
+ if project_id and project_id in project_visibility:
250
+ state, src = project_visibility[project_id]
251
+ if src != "baseline":
252
+ policies.append((state, f"project:{project_id} (via {src})"))
253
+
254
+ # 5. Client-level via full resolution (skip baseline)
255
+ client_id = row["client_id"]
256
+ if client_id and client_id in client_visibility:
257
+ state, src = client_visibility[client_id]
258
+ if src != "baseline":
259
+ policies.append((state, f"client:{client_id} (via {src})"))
260
+
261
+ # 6. Source policy
262
+ source_scope = "source:files"
263
+ if source_scope in all_policies:
264
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
265
+
266
+ # Resolve: most restrictive wins (hidden > opaque > visible)
267
+ for value, source in policies:
268
+ if value == "hidden":
269
+ results[file_id] = ("hidden", source)
270
+ break
271
+ else:
272
+ for value, source in policies:
273
+ if value == "opaque":
274
+ results[file_id] = ("opaque", source)
275
+ break
276
+ else:
277
+ for value, source in policies:
278
+ if value == "visible":
279
+ results[file_id] = ("visible", source)
280
+ break
281
+ else:
282
+ results[file_id] = global_baseline
283
+
284
+ return results
285
+
286
+
287
+ def _batch_resolve_project_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
288
+ """Batch resolve visibility for projects."""
289
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
290
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
291
+
292
+ # Global policy fallback
293
+ if "global" in all_policies:
294
+ global_baseline = (_resolve(all_policies["global"]), "global")
295
+ else:
296
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
297
+
298
+ # Pre-fetch project data for client_id (chunked)
299
+ rows = _chunked_query(
300
+ cursor,
301
+ "SELECT id, client_id FROM projects WHERE id IN ({placeholders})",
302
+ item_ids,
303
+ )
304
+ projects = {row["id"]: row for row in rows}
305
+
306
+ results = {}
307
+ for project_id in item_ids:
308
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
309
+
310
+ # 1. Project-level policy
311
+ proj_scope = f"project:{project_id}"
312
+ if proj_scope in all_policies:
313
+ policies.append((_resolve(all_policies[proj_scope]), proj_scope))
314
+
315
+ # 2. Client-level policy
316
+ if project_id in projects and projects[project_id]["client_id"]:
317
+ client_scope = f"client:{projects[project_id]['client_id']}"
318
+ if client_scope in all_policies:
319
+ policies.append((_resolve(all_policies[client_scope]), client_scope))
320
+
321
+ # 3. Source policy for projects
322
+ source_scope = "source:projects"
323
+ if source_scope in all_policies:
324
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
325
+
326
+ # Resolve: most restrictive wins
327
+ for value, source in policies:
328
+ if value == "hidden":
329
+ results[project_id] = ("hidden", source)
330
+ break
331
+ else:
332
+ for value, source in policies:
333
+ if value == "opaque":
334
+ results[project_id] = ("opaque", source)
335
+ break
336
+ else:
337
+ for value, source in policies:
338
+ if value == "visible":
339
+ results[project_id] = ("visible", source)
340
+ break
341
+ else:
342
+ results[project_id] = global_baseline
343
+
344
+ return results
345
+
346
+
347
+ def _batch_resolve_client_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
348
+ """Batch resolve visibility for clients."""
349
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
350
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
351
+
352
+ # Global policy fallback
353
+ if "global" in all_policies:
354
+ global_baseline = (_resolve(all_policies["global"]), "global")
355
+ else:
356
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
357
+
358
+ results = {}
359
+ for client_id in item_ids:
360
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
361
+
362
+ # 1. Client-level policy
363
+ client_scope = f"client:{client_id}"
364
+ if client_scope in all_policies:
365
+ policies.append((_resolve(all_policies[client_scope]), client_scope))
366
+
367
+ # 2. Source policy for clients
368
+ source_scope = "source:clients"
369
+ if source_scope in all_policies:
370
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
371
+
372
+ # Resolve: most restrictive wins
373
+ for value, source in policies:
374
+ if value == "hidden":
375
+ results[client_id] = ("hidden", source)
376
+ break
377
+ else:
378
+ for value, source in policies:
379
+ if value == "opaque":
380
+ results[client_id] = ("opaque", source)
381
+ break
382
+ else:
383
+ for value, source in policies:
384
+ if value == "visible":
385
+ results[client_id] = ("visible", source)
386
+ break
387
+ else:
388
+ results[client_id] = global_baseline
389
+
390
+ return results
391
+
392
+
393
+ def _batch_resolve_email_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
394
+ """Batch resolve visibility for emails."""
395
+ conn = cursor.connection
396
+
397
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
398
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
399
+
400
+ # Global policy fallback
401
+ if "global" in all_policies:
402
+ global_baseline = (_resolve(all_policies["global"]), "global")
403
+ else:
404
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
405
+
406
+ rows = _chunked_query(
407
+ cursor,
408
+ """
409
+ SELECT email.id, email.account, email.project_id,
410
+ COALESCE(email.client_id, project.client_id) AS client_id
411
+ FROM emails email
412
+ LEFT JOIN projects project ON email.project_id = project.id
413
+ WHERE email.id IN ({placeholders})
414
+ """,
415
+ item_ids,
416
+ )
417
+ emails = {row["id"]: row for row in rows}
418
+
419
+ # Collect unique parent entity IDs for batch resolution
420
+ project_ids = set()
421
+ client_ids = set()
422
+ for row in emails.values():
423
+ if row["project_id"]:
424
+ project_ids.add(row["project_id"])
425
+ if row["client_id"]:
426
+ client_ids.add(row["client_id"])
427
+
428
+ # Batch resolve parent entities
429
+ project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
430
+ client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
431
+
432
+ results = {}
433
+ for email_id in item_ids:
434
+ if email_id not in emails:
435
+ results[email_id] = (BASELINE_VISIBILITY, "not_found")
436
+ continue
437
+
438
+ row = emails[email_id]
439
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
440
+
441
+ # 1. Item-level policy
442
+ item_scope = f"email:{email_id}"
443
+ if item_scope in all_policies:
444
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
445
+
446
+ # 2. Project-level via full resolution (skip baseline)
447
+ project_id = row["project_id"]
448
+ if project_id and project_id in project_visibility:
449
+ state, src = project_visibility[project_id]
450
+ if src != "baseline":
451
+ policies.append((state, f"project:{project_id} (via {src})"))
452
+
453
+ # 3. Client-level via full resolution (skip baseline)
454
+ client_id = row["client_id"]
455
+ if client_id and client_id in client_visibility:
456
+ state, src = client_visibility[client_id]
457
+ if src != "baseline":
458
+ policies.append((state, f"client:{client_id} (via {src})"))
459
+
460
+ # 4. Account-level policy
461
+ account = row["account"] or ""
462
+ if account:
463
+ acct_scope = f"account:{account}"
464
+ if acct_scope in all_policies:
465
+ policies.append((_resolve(all_policies[acct_scope]), acct_scope))
466
+
467
+ # 5. Source policy
468
+ source_scope = "source:emails"
469
+ if source_scope in all_policies:
470
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
471
+
472
+ # Resolve: most restrictive wins
473
+ for value, source in policies:
474
+ if value == "hidden":
475
+ results[email_id] = ("hidden", source)
476
+ break
477
+ else:
478
+ for value, source in policies:
479
+ if value == "opaque":
480
+ results[email_id] = ("opaque", source)
481
+ break
482
+ else:
483
+ for value, source in policies:
484
+ if value == "visible":
485
+ results[email_id] = ("visible", source)
486
+ break
487
+ else:
488
+ results[email_id] = global_baseline
489
+
490
+ return results
491
+
492
+
493
+ def _batch_resolve_chat_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
494
+ """Batch resolve visibility for chats."""
495
+ conn = cursor.connection
496
+
497
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
498
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
499
+
500
+ # Global policy fallback
501
+ if "global" in all_policies:
502
+ global_baseline = (_resolve(all_policies["global"]), "global")
503
+ else:
504
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
505
+
506
+ rows = _chunked_query(
507
+ cursor,
508
+ """
509
+ SELECT chat.id, chat.account, chat.project_id,
510
+ COALESCE(chat.client_id, project.client_id) AS client_id
511
+ FROM chats chat
512
+ LEFT JOIN projects project ON chat.project_id = project.id
513
+ WHERE chat.id IN ({placeholders})
514
+ """,
515
+ item_ids,
516
+ )
517
+ convs = {row["id"]: row for row in rows}
518
+
519
+ # Collect unique parent entity IDs for batch resolution
520
+ project_ids = set()
521
+ client_ids = set()
522
+ for row in convs.values():
523
+ if row["project_id"]:
524
+ project_ids.add(row["project_id"])
525
+ if row["client_id"]:
526
+ client_ids.add(row["client_id"])
527
+
528
+ # Batch resolve parent entities
529
+ project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
530
+ client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
531
+
532
+ results = {}
533
+ for chat_id in item_ids:
534
+ if chat_id not in convs:
535
+ results[chat_id] = (BASELINE_VISIBILITY, "not_found")
536
+ continue
537
+
538
+ row = convs[chat_id]
539
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
540
+
541
+ # 1. Item-level policy
542
+ item_scope = f"chat:{chat_id}"
543
+ if item_scope in all_policies:
544
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
545
+
546
+ # 2. Project-level via full resolution (skip baseline)
547
+ project_id = row["project_id"]
548
+ if project_id and project_id in project_visibility:
549
+ state, src = project_visibility[project_id]
550
+ if src != "baseline":
551
+ policies.append((state, f"project:{project_id} (via {src})"))
552
+
553
+ # 3. Client-level via full resolution (skip baseline)
554
+ client_id = row["client_id"]
555
+ if client_id and client_id in client_visibility:
556
+ state, src = client_visibility[client_id]
557
+ if src != "baseline":
558
+ policies.append((state, f"client:{client_id} (via {src})"))
559
+
560
+ # 4. Account-level policy
561
+ account = row["account"] or ""
562
+ if account:
563
+ acct_scope = f"account:{account}"
564
+ if acct_scope in all_policies:
565
+ policies.append((_resolve(all_policies[acct_scope]), acct_scope))
566
+
567
+ # 5. Source policy
568
+ source_scope = "source:chats"
569
+ if source_scope in all_policies:
570
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
571
+
572
+ # Resolve: most restrictive wins
573
+ for value, source in policies:
574
+ if value == "hidden":
575
+ results[chat_id] = ("hidden", source)
576
+ break
577
+ else:
578
+ for value, source in policies:
579
+ if value == "opaque":
580
+ results[chat_id] = ("opaque", source)
581
+ break
582
+ else:
583
+ for value, source in policies:
584
+ if value == "visible":
585
+ results[chat_id] = ("visible", source)
586
+ break
587
+ else:
588
+ results[chat_id] = global_baseline
589
+
590
+ return results
591
+
592
+
593
+ def _batch_resolve_folder_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
594
+ """Batch resolve visibility for folders."""
595
+ conn = cursor.connection
596
+
597
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
598
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
599
+
600
+ # Global policy fallback
601
+ if "global" in all_policies:
602
+ global_baseline = (_resolve(all_policies["global"]), "global")
603
+ else:
604
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
605
+
606
+ folder_policies = [
607
+ (scope, setting)
608
+ for scope, setting in all_policies.items()
609
+ if scope.startswith("folder:") and is_folder_path_scope(scope)
610
+ ]
611
+ folder_policies.sort(key=lambda x: len(x[0]), reverse=True)
612
+
613
+ rows = _chunked_query(
614
+ cursor,
615
+ """
616
+ SELECT folder.id, folder.path, folder.project_id,
617
+ COALESCE(folder.client_id, project.client_id) AS client_id
618
+ FROM folders folder
619
+ LEFT JOIN projects project ON folder.project_id = project.id
620
+ WHERE folder.id IN ({placeholders})
621
+ """,
622
+ item_ids,
623
+ )
624
+ folders = {row["id"]: row for row in rows}
625
+
626
+ # Collect unique parent entity IDs for batch resolution
627
+ project_ids = set()
628
+ client_ids = set()
629
+ for row in folders.values():
630
+ if row["project_id"]:
631
+ project_ids.add(row["project_id"])
632
+ if row["client_id"]:
633
+ client_ids.add(row["client_id"])
634
+
635
+ # Batch resolve parent entities
636
+ project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
637
+ client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
638
+
639
+ results = {}
640
+ for folder_id in item_ids:
641
+ if folder_id not in folders:
642
+ results[folder_id] = (BASELINE_VISIBILITY, "not_found")
643
+ continue
644
+
645
+ row = folders[folder_id]
646
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
647
+
648
+ # 1. Item-level policy
649
+ item_scope = f"folder:{folder_id}"
650
+ if item_scope in all_policies:
651
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
652
+
653
+ # 2. Folder prefix match
654
+ path = row["path"] or ""
655
+ if path:
656
+ for scope, setting in folder_policies:
657
+ prefix = scope[len("folder:") :]
658
+ if prefix.startswith("~"):
659
+ prefix = os.path.expanduser(prefix)
660
+ if path.startswith(prefix):
661
+ policies.append((_resolve(setting), scope))
662
+ break
663
+
664
+ # 3. Project-level via full resolution (skip baseline)
665
+ project_id = row["project_id"]
666
+ if project_id and project_id in project_visibility:
667
+ state, src = project_visibility[project_id]
668
+ if src != "baseline":
669
+ policies.append((state, f"project:{project_id} (via {src})"))
670
+
671
+ # 4. Client-level via full resolution (skip baseline)
672
+ client_id = row["client_id"]
673
+ if client_id and client_id in client_visibility:
674
+ state, src = client_visibility[client_id]
675
+ if src != "baseline":
676
+ policies.append((state, f"client:{client_id} (via {src})"))
677
+
678
+ # 5. Source policy
679
+ source_scope = "source:folders"
680
+ if source_scope in all_policies:
681
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
682
+
683
+ # Resolve: most restrictive wins
684
+ for value, source in policies:
685
+ if value == "hidden":
686
+ results[folder_id] = ("hidden", source)
687
+ break
688
+ else:
689
+ for value, source in policies:
690
+ if value == "opaque":
691
+ results[folder_id] = ("opaque", source)
692
+ break
693
+ else:
694
+ for value, source in policies:
695
+ if value == "visible":
696
+ results[folder_id] = ("visible", source)
697
+ break
698
+ else:
699
+ results[folder_id] = global_baseline
700
+
701
+ return results
702
+
703
+
704
+ def _resolve(value: Optional[str]) -> Optional[VisibilityState]:
705
+ """Convert a visibility value to state or None (no policy)."""
706
+ if value in ("hidden", "opaque", "visible"):
707
+ return value
708
+ return None # 'inherit' or NULL means no policy
709
+
710
+
711
+ def _get_policy(cursor, scope: str) -> Optional[VisibilityState]:
712
+ """Look up a visibility_policies row."""
713
+ cursor.execute("SELECT setting FROM visibility_policies WHERE scope = ?", (scope,))
714
+ row = cursor.fetchone()
715
+ if row:
716
+ return _resolve(row["setting"])
717
+ return None
718
+
719
+
720
+ def _get_global_baseline(cursor) -> Tuple[VisibilityState, str]:
721
+ """Get global policy or fall back to hardcoded baseline."""
722
+ row = cursor.execute("SELECT setting FROM visibility_policies WHERE scope = 'global'").fetchone()
723
+ if row:
724
+ return (_resolve(row["setting"]), "global")
725
+ return (BASELINE_VISIBILITY, "baseline")
726
+
727
+
728
+ def _resolve_parent_with_source(
729
+ conn: sqlite3.Connection, item_type: str, item_id: int
730
+ ) -> Optional[Tuple[VisibilityState, str]]:
731
+ """Resolve parent entity visibility, returning None if baseline.
732
+
733
+ This is used when resolving file/folder visibility to check parent
734
+ entities (folder, project, client). If the parent resolves to baseline,
735
+ we return None so that baseline doesn't propagate down the hierarchy.
736
+ """
737
+ state, source = resolve_visibility_with_source(conn, item_type, item_id)
738
+ if source == "baseline":
739
+ return None
740
+ return (state, source)
741
+
742
+
743
+ def _get_file_visibility(cursor, file_id: int) -> VisibilityState:
744
+ """Resolve file visibility using policies."""
745
+ resolved, _ = _resolve_file_visibility_with_source(cursor, file_id)
746
+ return resolved
747
+
748
+
749
+ def _resolve_file_visibility_with_source(cursor, file_id: int) -> Tuple[VisibilityState, str]:
750
+ """Resolve file visibility with source tracking."""
751
+ cursor.execute(
752
+ """
753
+ SELECT file.path, file.project_id, file.folder_id,
754
+ COALESCE(file.client_id, project.client_id) AS client_id
755
+ FROM files file
756
+ LEFT JOIN projects project ON file.project_id = project.id
757
+ WHERE file.id = ?
758
+ """,
759
+ (file_id,),
760
+ )
761
+ row = cursor.fetchone()
762
+ if not row:
763
+ return (BASELINE_VISIBILITY, "not_found")
764
+
765
+ # Collect matching policies only (not baseline)
766
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
767
+
768
+ # 1. Item-level policy (file:{id})
769
+ item_policy = _get_policy(cursor, f"file:{file_id}")
770
+ if item_policy is not None:
771
+ policies.append((item_policy, f"file:{file_id}"))
772
+
773
+ # 2. Folder prefix match (most specific first)
774
+ path = row["path"] or ""
775
+ if path:
776
+ cursor.execute(
777
+ """
778
+ SELECT scope, setting FROM visibility_policies
779
+ WHERE scope LIKE 'folder:%'
780
+ ORDER BY LENGTH(scope) DESC
781
+ """
782
+ )
783
+ for folder_row in cursor.fetchall():
784
+ if not is_folder_path_scope(folder_row["scope"]):
785
+ continue
786
+ prefix = folder_row["scope"][len("folder:") :]
787
+ if prefix.startswith("~"):
788
+ prefix = os.path.expanduser(prefix)
789
+ if path.startswith(prefix):
790
+ policies.append((_resolve(folder_row["setting"]), folder_row["scope"]))
791
+ break # Only use most specific folder match
792
+
793
+ # 3. Folder-level via full resolution (folder:{id}) - skip baseline
794
+ folder_id = row["folder_id"]
795
+ if folder_id:
796
+ conn = cursor.connection
797
+ result = _resolve_parent_with_source(conn, "folder", folder_id)
798
+ if result:
799
+ state, src = result
800
+ policies.append((state, f"folder:{folder_id} (via {src})"))
801
+
802
+ # 4. Project-level via full resolution (project:{id}) - skip baseline
803
+ project_id = row["project_id"]
804
+ if project_id:
805
+ conn = cursor.connection
806
+ result = _resolve_parent_with_source(conn, "project", project_id)
807
+ if result:
808
+ state, src = result
809
+ policies.append((state, f"project:{project_id} (via {src})"))
810
+
811
+ # 5. Client-level via full resolution (client:{id}) - skip baseline
812
+ client_id = row["client_id"]
813
+ if client_id:
814
+ conn = cursor.connection
815
+ result = _resolve_parent_with_source(conn, "client", client_id)
816
+ if result:
817
+ state, src = result
818
+ policies.append((state, f"client:{client_id} (via {src})"))
819
+
820
+ # 6. Source policy
821
+ source_policy = _get_policy(cursor, "source:files")
822
+ if source_policy is not None:
823
+ policies.append((source_policy, "source:files"))
824
+
825
+ # MOST-RESTRICTIVE-WINS among matching policies only:
826
+ # hidden > opaque > visible
827
+ for value, source in policies:
828
+ if value == "hidden":
829
+ return ("hidden", source)
830
+
831
+ for value, source in policies:
832
+ if value == "opaque":
833
+ return ("opaque", source)
834
+
835
+ for value, source in policies:
836
+ if value == "visible":
837
+ return ("visible", source)
838
+
839
+ # No policies matched → use global policy or baseline
840
+ return _get_global_baseline(cursor)
841
+
842
+
843
+ def _get_email_visibility(cursor, email_id: int) -> VisibilityState:
844
+ """Resolve visibility for an email using policies."""
845
+ resolved, _ = _resolve_email_visibility_with_source(cursor, email_id)
846
+ return resolved
847
+
848
+
849
+ def _resolve_email_visibility_with_source(cursor, email_id: int) -> Tuple[VisibilityState, str]:
850
+ """Resolve email visibility with source tracking.
851
+
852
+ Chain: email:{id} → project:{id} → client:{id} → account:{acct} → source:emails
853
+ """
854
+ cursor.execute(
855
+ """
856
+ SELECT email.account, email.project_id,
857
+ COALESCE(email.client_id, project.client_id) AS client_id
858
+ FROM emails email
859
+ LEFT JOIN projects project ON email.project_id = project.id
860
+ WHERE email.id = ?
861
+ """,
862
+ (email_id,),
863
+ )
864
+ row = cursor.fetchone()
865
+ if not row:
866
+ return (BASELINE_VISIBILITY, "not_found")
867
+
868
+ # Collect matching policies only (not baseline)
869
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
870
+
871
+ # 1. Item-level policy (email:{id})
872
+ item_policy = _get_policy(cursor, f"email:{email_id}")
873
+ if item_policy is not None:
874
+ policies.append((item_policy, f"email:{email_id}"))
875
+
876
+ # 2. Project-level via full resolution (project:{id}) - skip baseline
877
+ project_id = row["project_id"]
878
+ if project_id:
879
+ conn = cursor.connection
880
+ result = _resolve_parent_with_source(conn, "project", project_id)
881
+ if result:
882
+ state, src = result
883
+ policies.append((state, f"project:{project_id} (via {src})"))
884
+
885
+ # 3. Client-level via full resolution (client:{id}) - skip baseline
886
+ client_id = row["client_id"]
887
+ if client_id:
888
+ conn = cursor.connection
889
+ result = _resolve_parent_with_source(conn, "client", client_id)
890
+ if result:
891
+ state, src = result
892
+ policies.append((state, f"client:{client_id} (via {src})"))
893
+
894
+ # 4. Account-level policy
895
+ account = row["account"] or ""
896
+ if account:
897
+ account_policy = _get_policy(cursor, f"account:{account}")
898
+ if account_policy is not None:
899
+ policies.append((account_policy, f"account:{account}"))
900
+
901
+ # 5. Source policy
902
+ source_policy = _get_policy(cursor, "source:emails")
903
+ if source_policy is not None:
904
+ policies.append((source_policy, "source:emails"))
905
+
906
+ # MOST-RESTRICTIVE-WINS among matching policies only:
907
+ # hidden > opaque > visible
908
+ for value, source in policies:
909
+ if value == "hidden":
910
+ return ("hidden", source)
911
+
912
+ for value, source in policies:
913
+ if value == "opaque":
914
+ return ("opaque", source)
915
+
916
+ for value, source in policies:
917
+ if value == "visible":
918
+ return ("visible", source)
919
+
920
+ # No policies matched → use global policy or baseline
921
+ return _get_global_baseline(cursor)
922
+
923
+
924
+ def _get_chat_visibility(cursor, chat_id: int) -> VisibilityState:
925
+ """Resolve visibility for a chat using policies."""
926
+ resolved, _ = _resolve_chat_visibility_with_source(cursor, chat_id)
927
+ return resolved
928
+
929
+
930
+ def _resolve_chat_visibility_with_source(cursor, chat_id: int) -> Tuple[VisibilityState, str]:
931
+ """Resolve chat visibility with source tracking.
932
+
933
+ Chain: chat:{id} → project:{id} → client:{id} → account:{acct} → source:chats
934
+ """
935
+ cursor.execute(
936
+ """
937
+ SELECT chat.account, chat.project_id,
938
+ COALESCE(chat.client_id, project.client_id) AS client_id
939
+ FROM chats chat
940
+ LEFT JOIN projects project ON chat.project_id = project.id
941
+ WHERE chat.id = ?
942
+ """,
943
+ (chat_id,),
944
+ )
945
+ row = cursor.fetchone()
946
+ if not row:
947
+ return (BASELINE_VISIBILITY, "not_found")
948
+
949
+ # Collect matching policies only (not baseline)
950
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
951
+
952
+ # 1. Item-level policy (chat:{id})
953
+ item_policy = _get_policy(cursor, f"chat:{chat_id}")
954
+ if item_policy is not None:
955
+ policies.append((item_policy, f"chat:{chat_id}"))
956
+
957
+ # 2. Project-level via full resolution (project:{id}) - skip baseline
958
+ project_id = row["project_id"]
959
+ if project_id:
960
+ conn = cursor.connection
961
+ result = _resolve_parent_with_source(conn, "project", project_id)
962
+ if result:
963
+ state, src = result
964
+ policies.append((state, f"project:{project_id} (via {src})"))
965
+
966
+ # 3. Client-level via full resolution (client:{id}) - skip baseline
967
+ client_id = row["client_id"]
968
+ if client_id:
969
+ conn = cursor.connection
970
+ result = _resolve_parent_with_source(conn, "client", client_id)
971
+ if result:
972
+ state, src = result
973
+ policies.append((state, f"client:{client_id} (via {src})"))
974
+
975
+ # 4. Account-level policy (e.g., account:claude)
976
+ account = row["account"] or ""
977
+ if account:
978
+ account_policy = _get_policy(cursor, f"account:{account}")
979
+ if account_policy is not None:
980
+ policies.append((account_policy, f"account:{account}"))
981
+
982
+ # 5. Source policy (source:chats)
983
+ source_policy = _get_policy(cursor, "source:chats")
984
+ if source_policy is not None:
985
+ policies.append((source_policy, "source:chats"))
986
+
987
+ # MOST-RESTRICTIVE-WINS among matching policies only:
988
+ # hidden > opaque > visible
989
+ for value, source in policies:
990
+ if value == "hidden":
991
+ return ("hidden", source)
992
+
993
+ for value, source in policies:
994
+ if value == "opaque":
995
+ return ("opaque", source)
996
+
997
+ for value, source in policies:
998
+ if value == "visible":
999
+ return ("visible", source)
1000
+
1001
+ # No policies matched → use global policy or baseline
1002
+ return _get_global_baseline(cursor)
1003
+
1004
+
1005
+ def _get_folder_visibility(cursor, folder_id: int) -> VisibilityState:
1006
+ """Resolve visibility for an indexed folder using policies."""
1007
+ resolved, _ = _resolve_folder_visibility_with_source(cursor, folder_id)
1008
+ return resolved
1009
+
1010
+
1011
+ def _resolve_folder_visibility_with_source(cursor, folder_id: int) -> Tuple[VisibilityState, str]:
1012
+ """Resolve folder visibility with source tracking."""
1013
+ cursor.execute(
1014
+ """
1015
+ SELECT folder.path, folder.project_id,
1016
+ COALESCE(folder.client_id, project.client_id) AS client_id
1017
+ FROM folders folder
1018
+ LEFT JOIN projects project ON folder.project_id = project.id
1019
+ WHERE folder.id = ?
1020
+ """,
1021
+ (folder_id,),
1022
+ )
1023
+ row = cursor.fetchone()
1024
+ if not row:
1025
+ return (BASELINE_VISIBILITY, "not_found")
1026
+
1027
+ # Collect matching policies only (not baseline)
1028
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
1029
+
1030
+ # 1. Item-level policy (folder:{id})
1031
+ item_policy = _get_policy(cursor, f"folder:{folder_id}")
1032
+ if item_policy is not None:
1033
+ policies.append((item_policy, f"folder:{folder_id}"))
1034
+
1035
+ # 2. Folder prefix match (most specific first)
1036
+ path = row["path"] or ""
1037
+ if path:
1038
+ cursor.execute(
1039
+ """
1040
+ SELECT scope, setting FROM visibility_policies
1041
+ WHERE scope LIKE 'folder:%'
1042
+ ORDER BY LENGTH(scope) DESC
1043
+ """
1044
+ )
1045
+ for folder_row in cursor.fetchall():
1046
+ if not is_folder_path_scope(folder_row["scope"]):
1047
+ continue
1048
+ prefix = folder_row["scope"][len("folder:") :]
1049
+ if prefix.startswith("~"):
1050
+ prefix = os.path.expanduser(prefix)
1051
+ if path.startswith(prefix):
1052
+ policies.append((_resolve(folder_row["setting"]), folder_row["scope"]))
1053
+ break # Only use most specific folder match
1054
+
1055
+ # 3. Project-level via full resolution (project:{id}) - skip baseline
1056
+ project_id = row["project_id"]
1057
+ if project_id:
1058
+ conn = cursor.connection
1059
+ result = _resolve_parent_with_source(conn, "project", project_id)
1060
+ if result:
1061
+ state, src = result
1062
+ policies.append((state, f"project:{project_id} (via {src})"))
1063
+
1064
+ # 4. Client-level via full resolution (client:{id}) - skip baseline
1065
+ client_id = row["client_id"]
1066
+ if client_id:
1067
+ conn = cursor.connection
1068
+ result = _resolve_parent_with_source(conn, "client", client_id)
1069
+ if result:
1070
+ state, src = result
1071
+ policies.append((state, f"client:{client_id} (via {src})"))
1072
+
1073
+ # 5. Source policy
1074
+ source_policy = _get_policy(cursor, "source:folders")
1075
+ if source_policy is not None:
1076
+ policies.append((source_policy, "source:folders"))
1077
+
1078
+ # MOST-RESTRICTIVE-WINS among matching policies only:
1079
+ # hidden > opaque > visible
1080
+ for value, source in policies:
1081
+ if value == "hidden":
1082
+ return ("hidden", source)
1083
+
1084
+ for value, source in policies:
1085
+ if value == "opaque":
1086
+ return ("opaque", source)
1087
+
1088
+ for value, source in policies:
1089
+ if value == "visible":
1090
+ return ("visible", source)
1091
+
1092
+ # No policies matched → use global policy or baseline
1093
+ return _get_global_baseline(cursor)
1094
+
1095
+
1096
+ def _resolve_project_visibility_with_source(cursor, project_id: int) -> Tuple[VisibilityState, str]:
1097
+ """Resolve project visibility with source tracking.
1098
+
1099
+ Chain: project:{id} -> client:{id} -> source:projects
1100
+ """
1101
+ cursor.execute(
1102
+ """
1103
+ SELECT client_id FROM projects WHERE id = ?
1104
+ """,
1105
+ (project_id,),
1106
+ )
1107
+ row = cursor.fetchone()
1108
+ if not row:
1109
+ return (BASELINE_VISIBILITY, "not_found")
1110
+
1111
+ # Collect matching policies only (not baseline)
1112
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
1113
+
1114
+ # 1. Project-level policy (project:{id})
1115
+ project_policy = _get_policy(cursor, f"project:{project_id}")
1116
+ if project_policy is not None:
1117
+ policies.append((project_policy, f"project:{project_id}"))
1118
+
1119
+ # 2. Client-level policy via full resolution (skip baseline)
1120
+ client_id = row["client_id"]
1121
+ if client_id:
1122
+ conn = cursor.connection
1123
+ result = _resolve_parent_with_source(conn, "client", client_id)
1124
+ if result:
1125
+ state, src = result
1126
+ policies.append((state, f"client:{client_id} (via {src})"))
1127
+
1128
+ # 3. Source policy for projects
1129
+ source_policy = _get_policy(cursor, "source:projects")
1130
+ if source_policy is not None:
1131
+ policies.append((source_policy, "source:projects"))
1132
+
1133
+ # MOST-RESTRICTIVE-WINS among matching policies only:
1134
+ # hidden > opaque > visible
1135
+ for value, source in policies:
1136
+ if value == "hidden":
1137
+ return ("hidden", source)
1138
+
1139
+ for value, source in policies:
1140
+ if value == "opaque":
1141
+ return ("opaque", source)
1142
+
1143
+ for value, source in policies:
1144
+ if value == "visible":
1145
+ return ("visible", source)
1146
+
1147
+ # No policies matched → use global policy or baseline
1148
+ return _get_global_baseline(cursor)
1149
+
1150
+
1151
+ def _resolve_client_visibility_with_source(cursor, client_id: int) -> Tuple[VisibilityState, str]:
1152
+ """Resolve client visibility with source tracking.
1153
+
1154
+ Chain: client:{id} -> source:clients
1155
+ """
1156
+ cursor.execute("SELECT id FROM clients WHERE id = ?", (client_id,))
1157
+ row = cursor.fetchone()
1158
+ if not row:
1159
+ return (BASELINE_VISIBILITY, "not_found")
1160
+
1161
+ # Collect matching policies only (not baseline)
1162
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
1163
+
1164
+ # 1. Client-level policy (client:{id})
1165
+ client_policy = _get_policy(cursor, f"client:{client_id}")
1166
+ if client_policy is not None:
1167
+ policies.append((client_policy, f"client:{client_id}"))
1168
+
1169
+ # 2. Source policy for clients
1170
+ source_policy = _get_policy(cursor, "source:clients")
1171
+ if source_policy is not None:
1172
+ policies.append((source_policy, "source:clients"))
1173
+
1174
+ # MOST-RESTRICTIVE-WINS among matching policies only:
1175
+ # hidden > opaque > visible
1176
+ for value, source in policies:
1177
+ if value == "hidden":
1178
+ return ("hidden", source)
1179
+
1180
+ for value, source in policies:
1181
+ if value == "opaque":
1182
+ return ("opaque", source)
1183
+
1184
+ for value, source in policies:
1185
+ if value == "visible":
1186
+ return ("visible", source)
1187
+
1188
+ # No policies matched → use global policy or baseline
1189
+ return _get_global_baseline(cursor)
1190
+
1191
+
1192
+ def _get_browser_visibility(cursor, browser_id: int) -> VisibilityState:
1193
+ """Resolve visibility for a browser history item using policies.
1194
+
1195
+ Browser history only has source-level policies (no item/folder/project/client hierarchy).
1196
+ """
1197
+ resolved, _ = _resolve_browser_visibility_with_source(cursor, browser_id)
1198
+ return resolved
1199
+
1200
+
1201
+ def _resolve_browser_visibility_with_source(cursor, browser_id: int) -> Tuple[VisibilityState, str]:
1202
+ """Resolve browser history visibility with source tracking.
1203
+
1204
+ Browser history has no hierarchy - only source-level policy applies.
1205
+ Chain: source:browser → baseline
1206
+ """
1207
+ # Verify item exists
1208
+ cursor.execute("SELECT id FROM visits WHERE id = ?", (browser_id,))
1209
+ row = cursor.fetchone()
1210
+ if not row:
1211
+ return (BASELINE_VISIBILITY, "not_found")
1212
+
1213
+ # Only source policy applies
1214
+ source_policy = _get_policy(cursor, "source:browser")
1215
+ if source_policy is not None:
1216
+ return (source_policy, "source:browser")
1217
+
1218
+ # No policies matched → use global policy or baseline
1219
+ return _get_global_baseline(cursor)
1220
+
1221
+
1222
+ def _batch_resolve_browser_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
1223
+ """Batch resolve visibility for browser history items.
1224
+
1225
+ Since browser history only uses source-level policy, we can resolve once
1226
+ and apply to all items.
1227
+ """
1228
+ cursor.execute("SELECT scope, setting FROM visibility_policies WHERE scope IN ('source:browser', 'global')")
1229
+ rows = {row["scope"]: row["setting"] for row in cursor.fetchall()}
1230
+
1231
+ if "source:browser" in rows:
1232
+ source_visibility = _resolve(rows["source:browser"])
1233
+ source = "source:browser"
1234
+ else:
1235
+ source_visibility = None
1236
+
1237
+ # Global policy fallback
1238
+ if "global" in rows:
1239
+ global_baseline = (_resolve(rows["global"]), "global")
1240
+ else:
1241
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
1242
+
1243
+ # Verify which items exist (chunked)
1244
+ if item_ids:
1245
+ existing_rows = _chunked_query(
1246
+ cursor,
1247
+ "SELECT id FROM visits WHERE id IN ({placeholders})",
1248
+ item_ids,
1249
+ )
1250
+ existing_ids = {row["id"] for row in existing_rows}
1251
+ else:
1252
+ existing_ids = set()
1253
+
1254
+ results = {}
1255
+ for item_id in item_ids:
1256
+ if item_id not in existing_ids:
1257
+ results[item_id] = (BASELINE_VISIBILITY, "not_found")
1258
+ elif source_visibility is not None:
1259
+ results[item_id] = (source_visibility, source)
1260
+ else:
1261
+ results[item_id] = global_baseline
1262
+
1263
+ return results
1264
+
1265
+
1266
+ def is_readable(visibility: VisibilityState) -> bool:
1267
+ """Check if an item with this visibility can be read.
1268
+
1269
+ Only visible items can have their content read.
1270
+ Hidden and opaque items are blocked at the visibility layer.
1271
+ """
1272
+ return visibility == "visible"