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,1160 @@
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,
158
+ COALESCE(file.client_id, project.client_id) AS client_id
159
+ FROM files file
160
+ LEFT JOIN projects project ON file.project_id = project.id
161
+ WHERE file.id IN ({placeholders})
162
+ """,
163
+ item_ids,
164
+ )
165
+ files = {row["id"]: row for row in rows}
166
+
167
+ # Collect unique parent entity IDs for batch resolution
168
+ project_ids = set()
169
+ client_ids = set()
170
+ for row in files.values():
171
+ if row["project_id"]:
172
+ project_ids.add(row["project_id"])
173
+ if row["client_id"]:
174
+ client_ids.add(row["client_id"])
175
+
176
+ # Batch resolve parent entities
177
+ project_permissions = batch_resolve_permissions(conn, "project", list(project_ids)) if project_ids else {}
178
+ client_permissions = batch_resolve_permissions(conn, "client", list(client_ids)) if client_ids else {}
179
+
180
+ results = {}
181
+ for file_id in item_ids:
182
+ if file_id not in files:
183
+ results[file_id] = (False, "not_found")
184
+ continue
185
+
186
+ row = files[file_id]
187
+ policies: List[Tuple[Optional[bool], str]] = []
188
+
189
+ # 1. Item-level policy
190
+ item_scope = f"file:{file_id}"
191
+ if item_scope in all_policies:
192
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
193
+
194
+ # 2. Folder prefix match (most specific first)
195
+ path = row["path"] or ""
196
+ if path:
197
+ for scope, setting in folder_policies:
198
+ prefix = scope[len("folder:") :]
199
+ if prefix.startswith("~"):
200
+ prefix = os.path.expanduser(prefix)
201
+ if path.startswith(prefix):
202
+ policies.append((_resolve(setting), scope))
203
+ break
204
+
205
+ # 3. Project-level via full resolution (skip baseline)
206
+ project_id = row["project_id"]
207
+ if project_id and project_id in project_permissions:
208
+ allowed, src = project_permissions[project_id]
209
+ if src != "baseline":
210
+ policies.append((allowed, f"project:{project_id} (via {src})"))
211
+
212
+ # 4. Client-level via full resolution (skip baseline)
213
+ client_id = row["client_id"]
214
+ if client_id and client_id in client_permissions:
215
+ allowed, src = client_permissions[client_id]
216
+ if src != "baseline":
217
+ policies.append((allowed, f"client:{client_id} (via {src})"))
218
+
219
+ # 5. Source policy
220
+ source_scope = "source:files"
221
+ if source_scope in all_policies:
222
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
223
+
224
+ # Resolve: deny wins
225
+ for value, source in policies:
226
+ if value is False:
227
+ results[file_id] = (False, source)
228
+ break
229
+ else:
230
+ for value, source in policies:
231
+ if value is True:
232
+ results[file_id] = (True, source)
233
+ break
234
+ else:
235
+ results[file_id] = global_baseline
236
+
237
+ return results
238
+
239
+
240
+ def _batch_resolve_project_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
241
+ """Batch resolve permissions for projects."""
242
+ cursor.execute("SELECT scope, setting FROM permission_policies")
243
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
244
+
245
+ # Global policy fallback
246
+ if "global" in all_policies:
247
+ global_baseline = (_resolve(all_policies["global"]), "global")
248
+ else:
249
+ global_baseline = (BASELINE_PERMISSION, "baseline")
250
+
251
+ # Pre-fetch project data for client_id (chunked)
252
+ rows = _chunked_query(
253
+ cursor,
254
+ "SELECT id, client_id FROM projects WHERE id IN ({placeholders})",
255
+ item_ids,
256
+ )
257
+ projects = {row["id"]: row for row in rows}
258
+
259
+ results = {}
260
+ for project_id in item_ids:
261
+ policies: List[Tuple[Optional[bool], str]] = []
262
+
263
+ # 1. Project-level policy
264
+ proj_scope = f"project:{project_id}"
265
+ if proj_scope in all_policies:
266
+ policies.append((_resolve(all_policies[proj_scope]), proj_scope))
267
+
268
+ # 2. Client-level policy
269
+ if project_id in projects and projects[project_id]["client_id"]:
270
+ client_scope = f"client:{projects[project_id]['client_id']}"
271
+ if client_scope in all_policies:
272
+ policies.append((_resolve(all_policies[client_scope]), client_scope))
273
+
274
+ # 3. Source policy for projects
275
+ source_scope = "source:projects"
276
+ if source_scope in all_policies:
277
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
278
+
279
+ # Resolve: deny wins
280
+ for value, source in policies:
281
+ if value is False:
282
+ results[project_id] = (False, source)
283
+ break
284
+ else:
285
+ for value, source in policies:
286
+ if value is True:
287
+ results[project_id] = (True, source)
288
+ break
289
+ else:
290
+ results[project_id] = global_baseline
291
+
292
+ return results
293
+
294
+
295
+ def _batch_resolve_client_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
296
+ """Batch resolve permissions for clients."""
297
+ cursor.execute("SELECT scope, setting FROM permission_policies")
298
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
299
+
300
+ # Global policy fallback
301
+ if "global" in all_policies:
302
+ global_baseline = (_resolve(all_policies["global"]), "global")
303
+ else:
304
+ global_baseline = (BASELINE_PERMISSION, "baseline")
305
+
306
+ results = {}
307
+ for client_id in item_ids:
308
+ policies: List[Tuple[Optional[bool], str]] = []
309
+
310
+ # 1. Client-level policy
311
+ client_scope = f"client:{client_id}"
312
+ if client_scope in all_policies:
313
+ policies.append((_resolve(all_policies[client_scope]), client_scope))
314
+
315
+ # 2. Source policy for clients
316
+ source_scope = "source:clients"
317
+ if source_scope in all_policies:
318
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
319
+
320
+ # Resolve: deny wins
321
+ for value, source in policies:
322
+ if value is False:
323
+ results[client_id] = (False, source)
324
+ break
325
+ else:
326
+ for value, source in policies:
327
+ if value is True:
328
+ results[client_id] = (True, source)
329
+ break
330
+ else:
331
+ results[client_id] = global_baseline
332
+
333
+ return results
334
+
335
+
336
+ def _batch_resolve_email_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
337
+ """Batch resolve permissions for emails."""
338
+ conn = cursor.connection
339
+
340
+ cursor.execute("SELECT scope, setting FROM permission_policies")
341
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
342
+
343
+ # Global policy fallback
344
+ if "global" in all_policies:
345
+ global_baseline = (_resolve(all_policies["global"]), "global")
346
+ else:
347
+ global_baseline = (BASELINE_PERMISSION, "baseline")
348
+
349
+ rows = _chunked_query(
350
+ cursor,
351
+ """
352
+ SELECT email.id, email.account, email.project_id,
353
+ COALESCE(email.client_id, project.client_id) AS client_id
354
+ FROM emails email
355
+ LEFT JOIN projects project ON email.project_id = project.id
356
+ WHERE email.id IN ({placeholders})
357
+ """,
358
+ item_ids,
359
+ )
360
+ emails = {row["id"]: row for row in rows}
361
+
362
+ # Collect unique parent entity IDs for batch resolution
363
+ project_ids = set()
364
+ client_ids = set()
365
+ for row in emails.values():
366
+ if row["project_id"]:
367
+ project_ids.add(row["project_id"])
368
+ if row["client_id"]:
369
+ client_ids.add(row["client_id"])
370
+
371
+ # Batch resolve parent entities
372
+ project_permissions = batch_resolve_permissions(conn, "project", list(project_ids)) if project_ids else {}
373
+ client_permissions = batch_resolve_permissions(conn, "client", list(client_ids)) if client_ids else {}
374
+
375
+ results = {}
376
+ for email_id in item_ids:
377
+ if email_id not in emails:
378
+ results[email_id] = (False, "not_found")
379
+ continue
380
+
381
+ row = emails[email_id]
382
+ policies: List[Tuple[Optional[bool], str]] = []
383
+
384
+ # 1. Item-level policy
385
+ item_scope = f"email:{email_id}"
386
+ if item_scope in all_policies:
387
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
388
+
389
+ # 2. Project-level via full resolution (skip baseline)
390
+ project_id = row["project_id"]
391
+ if project_id and project_id in project_permissions:
392
+ allowed, src = project_permissions[project_id]
393
+ if src != "baseline":
394
+ policies.append((allowed, f"project:{project_id} (via {src})"))
395
+
396
+ # 3. Client-level via full resolution (skip baseline)
397
+ client_id = row["client_id"]
398
+ if client_id and client_id in client_permissions:
399
+ allowed, src = client_permissions[client_id]
400
+ if src != "baseline":
401
+ policies.append((allowed, f"client:{client_id} (via {src})"))
402
+
403
+ # 4. Account-level policy
404
+ account = row["account"] or ""
405
+ if account:
406
+ acct_scope = f"account:{account}"
407
+ if acct_scope in all_policies:
408
+ policies.append((_resolve(all_policies[acct_scope]), acct_scope))
409
+
410
+ # 5. Source policy
411
+ source_scope = "source:emails"
412
+ if source_scope in all_policies:
413
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
414
+
415
+ # Resolve: deny wins
416
+ for value, source in policies:
417
+ if value is False:
418
+ results[email_id] = (False, source)
419
+ break
420
+ else:
421
+ for value, source in policies:
422
+ if value is True:
423
+ results[email_id] = (True, source)
424
+ break
425
+ else:
426
+ results[email_id] = global_baseline
427
+
428
+ return results
429
+
430
+
431
+ def _batch_resolve_chat_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
432
+ """Batch resolve permissions for chats."""
433
+ conn = cursor.connection
434
+
435
+ cursor.execute("SELECT scope, setting FROM permission_policies")
436
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
437
+
438
+ # Global policy fallback
439
+ if "global" in all_policies:
440
+ global_baseline = (_resolve(all_policies["global"]), "global")
441
+ else:
442
+ global_baseline = (BASELINE_PERMISSION, "baseline")
443
+
444
+ rows = _chunked_query(
445
+ cursor,
446
+ """
447
+ SELECT chat.id, chat.account, chat.project_id,
448
+ COALESCE(chat.client_id, project.client_id) AS client_id
449
+ FROM chats chat
450
+ LEFT JOIN projects project ON chat.project_id = project.id
451
+ WHERE chat.id IN ({placeholders})
452
+ """,
453
+ item_ids,
454
+ )
455
+ convs = {row["id"]: row for row in rows}
456
+
457
+ # Collect unique parent entity IDs for batch resolution
458
+ project_ids = set()
459
+ client_ids = set()
460
+ for row in convs.values():
461
+ if row["project_id"]:
462
+ project_ids.add(row["project_id"])
463
+ if row["client_id"]:
464
+ client_ids.add(row["client_id"])
465
+
466
+ # Batch resolve parent entities
467
+ project_permissions = batch_resolve_permissions(conn, "project", list(project_ids)) if project_ids else {}
468
+ client_permissions = batch_resolve_permissions(conn, "client", list(client_ids)) if client_ids else {}
469
+
470
+ results = {}
471
+ for chat_id in item_ids:
472
+ if chat_id not in convs:
473
+ results[chat_id] = (False, "not_found")
474
+ continue
475
+
476
+ row = convs[chat_id]
477
+ policies: List[Tuple[Optional[bool], str]] = []
478
+
479
+ # 1. Item-level policy
480
+ item_scope = f"chat:{chat_id}"
481
+ if item_scope in all_policies:
482
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
483
+
484
+ # 2. Project-level via full resolution (skip baseline)
485
+ project_id = row["project_id"]
486
+ if project_id and project_id in project_permissions:
487
+ allowed, src = project_permissions[project_id]
488
+ if src != "baseline":
489
+ policies.append((allowed, f"project:{project_id} (via {src})"))
490
+
491
+ # 3. Client-level via full resolution (skip baseline)
492
+ client_id = row["client_id"]
493
+ if client_id and client_id in client_permissions:
494
+ allowed, src = client_permissions[client_id]
495
+ if src != "baseline":
496
+ policies.append((allowed, f"client:{client_id} (via {src})"))
497
+
498
+ # 4. Account-level policy
499
+ account = row["account"] or ""
500
+ if account:
501
+ acct_scope = f"account:{account}"
502
+ if acct_scope in all_policies:
503
+ policies.append((_resolve(all_policies[acct_scope]), acct_scope))
504
+
505
+ # 5. Source policy
506
+ source_scope = "source:chats"
507
+ if source_scope in all_policies:
508
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
509
+
510
+ # Resolve: deny wins
511
+ for value, source in policies:
512
+ if value is False:
513
+ results[chat_id] = (False, source)
514
+ break
515
+ else:
516
+ for value, source in policies:
517
+ if value is True:
518
+ results[chat_id] = (True, source)
519
+ break
520
+ else:
521
+ results[chat_id] = global_baseline
522
+
523
+ return results
524
+
525
+
526
+ def _batch_resolve_folder_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
527
+ """Batch resolve permissions for folders.
528
+
529
+ Resolution chain: folder:{id} → folder prefix → project:{id} → client:{id} → source:folders
530
+ """
531
+ conn = cursor.connection
532
+
533
+ cursor.execute("SELECT scope, setting FROM permission_policies")
534
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
535
+
536
+ # Global policy fallback
537
+ if "global" in all_policies:
538
+ global_baseline = (_resolve(all_policies["global"]), "global")
539
+ else:
540
+ global_baseline = (BASELINE_PERMISSION, "baseline")
541
+
542
+ folder_policies = [
543
+ (scope, setting)
544
+ for scope, setting in all_policies.items()
545
+ if scope.startswith("folder:") and is_folder_path_scope(scope)
546
+ ]
547
+ folder_policies.sort(key=lambda x: len(x[0]), reverse=True)
548
+
549
+ rows = _chunked_query(
550
+ cursor,
551
+ """
552
+ SELECT folder.id, folder.path, folder.project_id,
553
+ COALESCE(folder.client_id, project.client_id) AS client_id
554
+ FROM folders folder
555
+ LEFT JOIN projects project ON folder.project_id = project.id
556
+ WHERE folder.id IN ({placeholders})
557
+ """,
558
+ item_ids,
559
+ )
560
+ folders = {row["id"]: row for row in rows}
561
+
562
+ # Collect unique parent entity IDs for batch resolution
563
+ project_ids = set()
564
+ client_ids = set()
565
+ for row in folders.values():
566
+ if row["project_id"]:
567
+ project_ids.add(row["project_id"])
568
+ if row["client_id"]:
569
+ client_ids.add(row["client_id"])
570
+
571
+ # Batch resolve parent entities
572
+ project_permissions = batch_resolve_permissions(conn, "project", list(project_ids)) if project_ids else {}
573
+ client_permissions = batch_resolve_permissions(conn, "client", list(client_ids)) if client_ids else {}
574
+
575
+ results = {}
576
+ for folder_id in item_ids:
577
+ if folder_id not in folders:
578
+ results[folder_id] = (False, "not_found")
579
+ continue
580
+
581
+ row = folders[folder_id]
582
+ policies: List[Tuple[Optional[bool], str]] = []
583
+
584
+ # 1. Item-level policy
585
+ item_scope = f"folder:{folder_id}"
586
+ if item_scope in all_policies:
587
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
588
+
589
+ # 2. Folder prefix match
590
+ path = row["path"] or ""
591
+ if path:
592
+ for scope, setting in folder_policies:
593
+ prefix = scope[len("folder:") :]
594
+ if prefix.startswith("~"):
595
+ prefix = os.path.expanduser(prefix)
596
+ if path.startswith(prefix):
597
+ policies.append((_resolve(setting), scope))
598
+ break
599
+
600
+ # 3. Project-level via full resolution (skip baseline)
601
+ project_id = row["project_id"]
602
+ if project_id and project_id in project_permissions:
603
+ allowed, src = project_permissions[project_id]
604
+ if src != "baseline":
605
+ policies.append((allowed, f"project:{project_id} (via {src})"))
606
+
607
+ # 4. Client-level via full resolution (skip baseline)
608
+ client_id = row["client_id"]
609
+ if client_id and client_id in client_permissions:
610
+ allowed, src = client_permissions[client_id]
611
+ if src != "baseline":
612
+ policies.append((allowed, f"client:{client_id} (via {src})"))
613
+
614
+ # 5. Source policy
615
+ source_scope = "source:folders"
616
+ if source_scope in all_policies:
617
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
618
+
619
+ # Resolve: deny wins
620
+ for value, source in policies:
621
+ if value is False:
622
+ results[folder_id] = (False, source)
623
+ break
624
+ else:
625
+ for value, source in policies:
626
+ if value is True:
627
+ results[folder_id] = (True, source)
628
+ break
629
+ else:
630
+ results[folder_id] = global_baseline
631
+
632
+ return results
633
+
634
+
635
+ def _resolve(value: Optional[str]) -> Optional[bool]:
636
+ """Convert a permission value to bool or None (no policy)."""
637
+ if value == "allow":
638
+ return True
639
+ if value == "deny":
640
+ return False
641
+ return None # 'inherit' or NULL means no policy
642
+
643
+
644
+ def _get_policy(cursor, scope: str) -> Optional[bool]:
645
+ """Look up a permission_policies row."""
646
+ cursor.execute("SELECT setting FROM permission_policies WHERE scope = ?", (scope,))
647
+ row = cursor.fetchone()
648
+ if row:
649
+ return _resolve(row["setting"])
650
+ return None
651
+
652
+
653
+ def _get_global_baseline(cursor) -> Tuple[bool, str]:
654
+ """Get global policy or fall back to hardcoded baseline."""
655
+ row = cursor.execute("SELECT setting FROM permission_policies WHERE scope = 'global'").fetchone()
656
+ if row:
657
+ return (_resolve(row["setting"]), "global")
658
+ return (BASELINE_PERMISSION, "baseline")
659
+
660
+
661
+ def _resolve_parent_permission_with_source(
662
+ conn: sqlite3.Connection, item_type: str, item_id: int
663
+ ) -> Optional[Tuple[bool, str]]:
664
+ """Resolve parent entity permission, returning None if baseline.
665
+
666
+ This is used when resolving file permissions to check parent
667
+ entities (project, client). If the parent resolves to baseline,
668
+ we return None so that baseline doesn't propagate down the hierarchy.
669
+ """
670
+ allowed, source = resolve_permission_with_source(conn, item_type, item_id)
671
+ if source == "baseline":
672
+ return None
673
+ return (allowed, source)
674
+
675
+
676
+ def _can_read_file(cursor, file_id: int) -> bool:
677
+ """Resolve read permission for a file using policies."""
678
+ resolved, _ = _resolve_file_with_source(cursor, file_id)
679
+ return resolved
680
+
681
+
682
+ def _resolve_file_with_source(cursor, file_id: int) -> Tuple[bool, str]:
683
+ """Resolve file permission with source tracking."""
684
+ cursor.execute(
685
+ """
686
+ SELECT file.path, file.project_id,
687
+ COALESCE(file.client_id, project.client_id) AS client_id
688
+ FROM files file
689
+ LEFT JOIN projects project ON file.project_id = project.id
690
+ WHERE file.id = ?
691
+ """,
692
+ (file_id,),
693
+ )
694
+ row = cursor.fetchone()
695
+ if not row:
696
+ return (False, "not_found")
697
+
698
+ # Collect matching policies only (not baseline)
699
+ policies: List[Tuple[Optional[bool], str]] = []
700
+
701
+ # 1. Item-level policy (file:{id})
702
+ item_policy = _get_policy(cursor, f"file:{file_id}")
703
+ if item_policy is not None:
704
+ policies.append((item_policy, f"file:{file_id}"))
705
+
706
+ # 2. Folder prefix match (most specific first)
707
+ path = row["path"] or ""
708
+ if path:
709
+ cursor.execute(
710
+ """
711
+ SELECT scope, setting FROM permission_policies
712
+ WHERE scope LIKE 'folder:%'
713
+ ORDER BY LENGTH(scope) DESC
714
+ """
715
+ )
716
+ for folder_row in cursor.fetchall():
717
+ if not is_folder_path_scope(folder_row["scope"]):
718
+ continue
719
+ prefix = folder_row["scope"][len("folder:") :]
720
+ if prefix.startswith("~"):
721
+ prefix = os.path.expanduser(prefix)
722
+ if path.startswith(prefix):
723
+ policies.append((_resolve(folder_row["setting"]), folder_row["scope"]))
724
+ break # Only use most specific folder match
725
+
726
+ # 3. Project-level via full resolution (project:{id}) - skip baseline
727
+ project_id = row["project_id"]
728
+ if project_id:
729
+ conn = cursor.connection
730
+ result = _resolve_parent_permission_with_source(conn, "project", project_id)
731
+ if result:
732
+ allowed, src = result
733
+ policies.append((allowed, f"project:{project_id} (via {src})"))
734
+
735
+ # 4. Client-level via full resolution (client:{id}) - skip baseline
736
+ client_id = row["client_id"]
737
+ if client_id:
738
+ conn = cursor.connection
739
+ result = _resolve_parent_permission_with_source(conn, "client", client_id)
740
+ if result:
741
+ allowed, src = result
742
+ policies.append((allowed, f"client:{client_id} (via {src})"))
743
+
744
+ # 5. Source policy
745
+ source_policy = _get_policy(cursor, "source:files")
746
+ if source_policy is not None:
747
+ policies.append((source_policy, "source:files"))
748
+
749
+ # DENY-WINS RESOLUTION among matching policies only:
750
+ # If ANY policy is deny, return deny
751
+ for value, source in policies:
752
+ if value is False:
753
+ return (False, source)
754
+
755
+ # Otherwise, first allow policy wins
756
+ for value, source in policies:
757
+ if value is True:
758
+ return (True, source)
759
+
760
+ # No policies matched → use global policy or baseline
761
+ return _get_global_baseline(cursor)
762
+
763
+
764
+ def _can_read_email(cursor, email_id: int) -> bool:
765
+ """Resolve read permission for an email using policies."""
766
+ resolved, _ = _resolve_email_with_source(cursor, email_id)
767
+ return resolved
768
+
769
+
770
+ def _resolve_email_with_source(cursor, email_id: int) -> Tuple[bool, str]:
771
+ """Resolve email permission with source tracking.
772
+
773
+ Chain: email:{id} → project:{id} → client:{id} → account:{acct} → source:emails
774
+ """
775
+ cursor.execute(
776
+ """
777
+ SELECT email.account, email.project_id,
778
+ COALESCE(email.client_id, project.client_id) AS client_id
779
+ FROM emails email
780
+ LEFT JOIN projects project ON email.project_id = project.id
781
+ WHERE email.id = ?
782
+ """,
783
+ (email_id,),
784
+ )
785
+ row = cursor.fetchone()
786
+ if not row:
787
+ return (False, "not_found")
788
+
789
+ # Collect matching policies only (not baseline)
790
+ policies: List[Tuple[Optional[bool], str]] = []
791
+
792
+ # 1. Item-level policy (email:{id})
793
+ item_policy = _get_policy(cursor, f"email:{email_id}")
794
+ if item_policy is not None:
795
+ policies.append((item_policy, f"email:{email_id}"))
796
+
797
+ # 2. Project-level via full resolution (project:{id}) - skip baseline
798
+ project_id = row["project_id"]
799
+ if project_id:
800
+ conn = cursor.connection
801
+ result = _resolve_parent_permission_with_source(conn, "project", project_id)
802
+ if result:
803
+ allowed, src = result
804
+ policies.append((allowed, f"project:{project_id} (via {src})"))
805
+
806
+ # 3. Client-level via full resolution (client:{id}) - skip baseline
807
+ client_id = row["client_id"]
808
+ if client_id:
809
+ conn = cursor.connection
810
+ result = _resolve_parent_permission_with_source(conn, "client", client_id)
811
+ if result:
812
+ allowed, src = result
813
+ policies.append((allowed, f"client:{client_id} (via {src})"))
814
+
815
+ # 4. Account-level policy (e.g., account:personal)
816
+ account = row["account"] or ""
817
+ if account:
818
+ account_policy = _get_policy(cursor, f"account:{account}")
819
+ if account_policy is not None:
820
+ policies.append((account_policy, f"account:{account}"))
821
+
822
+ # 5. Source policy
823
+ source_policy = _get_policy(cursor, "source:emails")
824
+ if source_policy is not None:
825
+ policies.append((source_policy, "source:emails"))
826
+
827
+ # DENY-WINS RESOLUTION among matching policies only:
828
+ # If ANY policy is deny, return deny
829
+ for value, source in policies:
830
+ if value is False:
831
+ return (False, source)
832
+
833
+ # Otherwise, first allow policy wins
834
+ for value, source in policies:
835
+ if value is True:
836
+ return (True, source)
837
+
838
+ # No policies matched → use global policy or baseline
839
+ return _get_global_baseline(cursor)
840
+
841
+
842
+ def _can_read_chat(cursor, chat_id: int) -> bool:
843
+ """Resolve read permission for a chat using policies."""
844
+ resolved, _ = _resolve_chat_with_source(cursor, chat_id)
845
+ return resolved
846
+
847
+
848
+ def _resolve_chat_with_source(cursor, chat_id: int) -> Tuple[bool, str]:
849
+ """Resolve chat permission with source tracking.
850
+
851
+ Chain: chat:{id} → project:{id} → client:{id} → account:{acct} → source:chats
852
+ """
853
+ cursor.execute(
854
+ """
855
+ SELECT chat.account, chat.project_id,
856
+ COALESCE(chat.client_id, project.client_id) AS client_id
857
+ FROM chats chat
858
+ LEFT JOIN projects project ON chat.project_id = project.id
859
+ WHERE chat.id = ?
860
+ """,
861
+ (chat_id,),
862
+ )
863
+ row = cursor.fetchone()
864
+ if not row:
865
+ return (False, "not_found")
866
+
867
+ # Collect matching policies only (not baseline)
868
+ policies: List[Tuple[Optional[bool], str]] = []
869
+
870
+ # 1. Item-level policy (chat:{id})
871
+ item_policy = _get_policy(cursor, f"chat:{chat_id}")
872
+ if item_policy is not None:
873
+ policies.append((item_policy, f"chat:{chat_id}"))
874
+
875
+ # 2. Project-level via full resolution (project:{id}) - skip baseline
876
+ project_id = row["project_id"]
877
+ if project_id:
878
+ conn = cursor.connection
879
+ result = _resolve_parent_permission_with_source(conn, "project", project_id)
880
+ if result:
881
+ allowed, src = result
882
+ policies.append((allowed, f"project:{project_id} (via {src})"))
883
+
884
+ # 3. Client-level via full resolution (client:{id}) - skip baseline
885
+ client_id = row["client_id"]
886
+ if client_id:
887
+ conn = cursor.connection
888
+ result = _resolve_parent_permission_with_source(conn, "client", client_id)
889
+ if result:
890
+ allowed, src = result
891
+ policies.append((allowed, f"client:{client_id} (via {src})"))
892
+
893
+ # 4. Account-level policy (e.g., account:claude)
894
+ account = row["account"] or ""
895
+ if account:
896
+ account_policy = _get_policy(cursor, f"account:{account}")
897
+ if account_policy is not None:
898
+ policies.append((account_policy, f"account:{account}"))
899
+
900
+ # 5. Source policy (source:chats)
901
+ source_policy = _get_policy(cursor, "source:chats")
902
+ if source_policy is not None:
903
+ policies.append((source_policy, "source:chats"))
904
+
905
+ # DENY-WINS RESOLUTION among matching policies only:
906
+ # If ANY policy is deny, return deny
907
+ for value, source in policies:
908
+ if value is False:
909
+ return (False, source)
910
+
911
+ # Otherwise, first allow policy wins
912
+ for value, source in policies:
913
+ if value is True:
914
+ return (True, source)
915
+
916
+ # No policies matched → use global policy or baseline
917
+ return _get_global_baseline(cursor)
918
+
919
+
920
+ def _resolve_project_permission_with_source(cursor, project_id: int) -> Tuple[bool, str]:
921
+ """Resolve project permission with source tracking.
922
+
923
+ Chain: project:{id} -> client:{id} -> source:projects
924
+ """
925
+ cursor.execute(
926
+ """
927
+ SELECT client_id FROM projects WHERE id = ?
928
+ """,
929
+ (project_id,),
930
+ )
931
+ row = cursor.fetchone()
932
+ if not row:
933
+ return (BASELINE_PERMISSION, "not_found")
934
+
935
+ # Collect matching policies only (not baseline)
936
+ policies: List[Tuple[Optional[bool], str]] = []
937
+
938
+ # 1. Project-level policy (project:{id})
939
+ project_policy = _get_policy(cursor, f"project:{project_id}")
940
+ if project_policy is not None:
941
+ policies.append((project_policy, f"project:{project_id}"))
942
+
943
+ # 2. Client-level policy via full resolution (skip baseline)
944
+ client_id = row["client_id"]
945
+ if client_id:
946
+ conn = cursor.connection
947
+ result = _resolve_parent_permission_with_source(conn, "client", client_id)
948
+ if result:
949
+ allowed, src = result
950
+ policies.append((allowed, f"client:{client_id} (via {src})"))
951
+
952
+ # 3. Source policy for projects
953
+ source_policy = _get_policy(cursor, "source:projects")
954
+ if source_policy is not None:
955
+ policies.append((source_policy, "source:projects"))
956
+
957
+ # DENY-WINS RESOLUTION among matching policies only:
958
+ for value, source in policies:
959
+ if value is False:
960
+ return (False, source)
961
+
962
+ for value, source in policies:
963
+ if value is True:
964
+ return (True, source)
965
+
966
+ # No policies matched → use global policy or baseline
967
+ return _get_global_baseline(cursor)
968
+
969
+
970
+ def _resolve_client_permission_with_source(cursor, client_id: int) -> Tuple[bool, str]:
971
+ """Resolve client permission with source tracking.
972
+
973
+ Chain: client:{id} -> source:clients
974
+ """
975
+ cursor.execute("SELECT id FROM clients WHERE id = ?", (client_id,))
976
+ row = cursor.fetchone()
977
+ if not row:
978
+ return (BASELINE_PERMISSION, "not_found")
979
+
980
+ # Collect matching policies only (not baseline)
981
+ policies: List[Tuple[Optional[bool], str]] = []
982
+
983
+ # 1. Client-level policy (client:{id})
984
+ client_policy = _get_policy(cursor, f"client:{client_id}")
985
+ if client_policy is not None:
986
+ policies.append((client_policy, f"client:{client_id}"))
987
+
988
+ # 2. Source policy for clients
989
+ source_policy = _get_policy(cursor, "source:clients")
990
+ if source_policy is not None:
991
+ policies.append((source_policy, "source:clients"))
992
+
993
+ # DENY-WINS RESOLUTION among matching policies only:
994
+ for value, source in policies:
995
+ if value is False:
996
+ return (False, source)
997
+
998
+ for value, source in policies:
999
+ if value is True:
1000
+ return (True, source)
1001
+
1002
+ # No policies matched → use global policy or baseline
1003
+ return _get_global_baseline(cursor)
1004
+
1005
+
1006
+ def _resolve_folder_permission_with_source(cursor, folder_id: int) -> Tuple[bool, str]:
1007
+ """Resolve folder permission with source tracking.
1008
+
1009
+ Chain: folder:{id} → folder prefix → project:{id} → client:{id} → source:folders
1010
+ """
1011
+ cursor.execute(
1012
+ """
1013
+ SELECT folder.path, folder.project_id,
1014
+ COALESCE(folder.client_id, project.client_id) AS client_id
1015
+ FROM folders folder
1016
+ LEFT JOIN projects project ON folder.project_id = project.id
1017
+ WHERE folder.id = ?
1018
+ """,
1019
+ (folder_id,),
1020
+ )
1021
+ row = cursor.fetchone()
1022
+ if not row:
1023
+ return (BASELINE_PERMISSION, "not_found")
1024
+
1025
+ # Collect matching policies only (not baseline)
1026
+ policies: List[Tuple[Optional[bool], str]] = []
1027
+
1028
+ # 1. Item-level policy (folder:{id})
1029
+ item_policy = _get_policy(cursor, f"folder:{folder_id}")
1030
+ if item_policy is not None:
1031
+ policies.append((item_policy, f"folder:{folder_id}"))
1032
+
1033
+ # 2. Folder prefix match (most specific first)
1034
+ path = row["path"] or ""
1035
+ if path:
1036
+ cursor.execute(
1037
+ """
1038
+ SELECT scope, setting FROM permission_policies
1039
+ WHERE scope LIKE 'folder:%'
1040
+ ORDER BY LENGTH(scope) DESC
1041
+ """
1042
+ )
1043
+ for folder_row in cursor.fetchall():
1044
+ if not is_folder_path_scope(folder_row["scope"]):
1045
+ continue
1046
+ prefix = folder_row["scope"][len("folder:") :]
1047
+ if prefix.startswith("~"):
1048
+ prefix = os.path.expanduser(prefix)
1049
+ if path.startswith(prefix):
1050
+ policies.append((_resolve(folder_row["setting"]), folder_row["scope"]))
1051
+ break # Only use most specific folder match
1052
+
1053
+ # 3. Project-level via full resolution (skip baseline)
1054
+ project_id = row["project_id"]
1055
+ if project_id:
1056
+ conn = cursor.connection
1057
+ result = _resolve_parent_permission_with_source(conn, "project", project_id)
1058
+ if result:
1059
+ allowed, src = result
1060
+ policies.append((allowed, f"project:{project_id} (via {src})"))
1061
+
1062
+ # 4. Client-level via full resolution (skip baseline)
1063
+ client_id = row["client_id"]
1064
+ if client_id:
1065
+ conn = cursor.connection
1066
+ result = _resolve_parent_permission_with_source(conn, "client", client_id)
1067
+ if result:
1068
+ allowed, src = result
1069
+ policies.append((allowed, f"client:{client_id} (via {src})"))
1070
+
1071
+ # 5. Source policy
1072
+ source_policy = _get_policy(cursor, "source:folders")
1073
+ if source_policy is not None:
1074
+ policies.append((source_policy, "source:folders"))
1075
+
1076
+ # DENY-WINS RESOLUTION among matching policies only:
1077
+ for value, source in policies:
1078
+ if value is False:
1079
+ return (False, source)
1080
+
1081
+ for value, source in policies:
1082
+ if value is True:
1083
+ return (True, source)
1084
+
1085
+ # No policies matched → use global policy or baseline
1086
+ return _get_global_baseline(cursor)
1087
+
1088
+
1089
+ def _can_read_browser(cursor, browser_id: int) -> bool:
1090
+ """Resolve read permission for a browser history item using policies.
1091
+
1092
+ Browser history only has source-level policies (no item/folder/project/client hierarchy).
1093
+ """
1094
+ resolved, _ = _resolve_browser_with_source(cursor, browser_id)
1095
+ return resolved
1096
+
1097
+
1098
+ def _resolve_browser_with_source(cursor, browser_id: int) -> Tuple[bool, str]:
1099
+ """Resolve browser history permission with source tracking.
1100
+
1101
+ Browser history has no hierarchy - only source-level policy applies.
1102
+ Chain: source:browser → baseline
1103
+ """
1104
+ # Verify item exists
1105
+ cursor.execute("SELECT id FROM visits WHERE id = ?", (browser_id,))
1106
+ row = cursor.fetchone()
1107
+ if not row:
1108
+ return (False, "not_found")
1109
+
1110
+ # Only source policy applies
1111
+ source_policy = _get_policy(cursor, "source:browser")
1112
+ if source_policy is not None:
1113
+ return (source_policy, "source:browser")
1114
+
1115
+ # No policies matched → use global policy or baseline
1116
+ return _get_global_baseline(cursor)
1117
+
1118
+
1119
+ def _batch_resolve_browser_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
1120
+ """Batch resolve permissions for browser history items.
1121
+
1122
+ Since browser history only uses source-level policy, we can resolve once
1123
+ and apply to all items.
1124
+ """
1125
+ cursor.execute("SELECT scope, setting FROM permission_policies WHERE scope IN ('source:browser', 'global')")
1126
+ rows = {row["scope"]: row["setting"] for row in cursor.fetchall()}
1127
+
1128
+ if "source:browser" in rows:
1129
+ source_permission = _resolve(rows["source:browser"])
1130
+ source = "source:browser"
1131
+ else:
1132
+ source_permission = None
1133
+
1134
+ # Global policy fallback
1135
+ if "global" in rows:
1136
+ global_baseline = (_resolve(rows["global"]), "global")
1137
+ else:
1138
+ global_baseline = (BASELINE_PERMISSION, "baseline")
1139
+
1140
+ # Verify which items exist (chunked)
1141
+ if item_ids:
1142
+ existing_rows = _chunked_query(
1143
+ cursor,
1144
+ "SELECT id FROM visits WHERE id IN ({placeholders})",
1145
+ item_ids,
1146
+ )
1147
+ existing_ids = {row["id"] for row in existing_rows}
1148
+ else:
1149
+ existing_ids = set()
1150
+
1151
+ results = {}
1152
+ for item_id in item_ids:
1153
+ if item_id not in existing_ids:
1154
+ results[item_id] = (False, "not_found")
1155
+ elif source_permission is not None:
1156
+ results[item_id] = (source_permission, source)
1157
+ else:
1158
+ results[item_id] = global_baseline
1159
+
1160
+ return results