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