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.
- footprinter/__init__.py +8 -0
- footprinter/access.py +431 -0
- footprinter/api/__init__.py +1 -0
- footprinter/api/db.py +61 -0
- footprinter/api/entities.py +250 -0
- footprinter/api/search.py +47 -0
- footprinter/api/semantic.py +33 -0
- footprinter/api/server.py +66 -0
- footprinter/api/status.py +15 -0
- footprinter/bundled/__init__.py +0 -0
- footprinter/bundled/config.example.yaml +161 -0
- footprinter/bundled/patterns/context_patterns.yaml +18 -0
- footprinter/bundled/patterns/extensions.yaml +283 -0
- footprinter/bundled/patterns/filename_patterns.yaml +61 -0
- footprinter/bundled/patterns/mime_mappings.yaml +68 -0
- footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
- footprinter/bundled/patterns/security_patterns.yaml +27 -0
- footprinter/bundled/samples/hidden-client-file-sample.txt +2 -0
- footprinter/bundled/samples/opaque-project-file-sample.txt +2 -0
- footprinter/bundled/samples/visible-file-sample.txt +2 -0
- footprinter/cli/__init__.py +135 -0
- footprinter/cli/__main__.py +6 -0
- footprinter/cli/_common.py +327 -0
- footprinter/cli/_policy_helpers.py +646 -0
- footprinter/cli/_prompt.py +220 -0
- footprinter/cli/_sample_seed.py +204 -0
- footprinter/cli/api_cmd.py +32 -0
- footprinter/cli/connect.py +591 -0
- footprinter/cli/data.py +879 -0
- footprinter/cli/delete.py +128 -0
- footprinter/cli/ingest.py +543 -0
- footprinter/cli/mcp_cmd.py +750 -0
- footprinter/cli/mcp_setup.py +306 -0
- footprinter/cli/search.py +393 -0
- footprinter/cli/search_cmd.py +69 -0
- footprinter/cli/setup.py +2001 -0
- footprinter/cli/status.py +747 -0
- footprinter/cli/status_cmd.py +104 -0
- footprinter/cli/upsert.py +794 -0
- footprinter/cli/vectorize_cmd.py +215 -0
- footprinter/cli/view.py +322 -0
- footprinter/connectors/__init__.py +171 -0
- footprinter/connectors/config_utils.py +141 -0
- footprinter/db/__init__.py +37 -0
- footprinter/db/browser.py +198 -0
- footprinter/db/chats.py +602 -0
- footprinter/db/clients.py +307 -0
- footprinter/db/emails.py +279 -0
- footprinter/db/files.py +724 -0
- footprinter/db/folders.py +659 -0
- footprinter/db/messages.py +192 -0
- footprinter/db/policies.py +151 -0
- footprinter/db/projects.py +673 -0
- footprinter/db/search.py +573 -0
- footprinter/db/sql_utils.py +168 -0
- footprinter/db/status.py +320 -0
- footprinter/db/uploads.py +70 -0
- footprinter/ingest/__init__.py +0 -0
- footprinter/ingest/adapters/__init__.py +33 -0
- footprinter/ingest/adapters/browser.py +54 -0
- footprinter/ingest/adapters/chat.py +57 -0
- footprinter/ingest/adapters/ingest.py +146 -0
- footprinter/ingest/adapters/local_files.py +68 -0
- footprinter/ingest/adapters/local_folders.py +52 -0
- footprinter/ingest/adapters/protocol.py +174 -0
- footprinter/ingest/browser_indexer.py +216 -0
- footprinter/ingest/chat_dedup.py +156 -0
- footprinter/ingest/chat_indexer.py +487 -0
- footprinter/ingest/chat_parsers/__init__.py +8 -0
- footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
- footprinter/ingest/chat_parsers/claude_parser.py +161 -0
- footprinter/ingest/cli.py +827 -0
- footprinter/ingest/content_extractors.py +117 -0
- footprinter/ingest/database.py +36 -0
- footprinter/ingest/db/__init__.py +1 -0
- footprinter/ingest/db/connector_schema.py +47 -0
- footprinter/ingest/db/migration.py +315 -0
- footprinter/ingest/db/schema.py +1043 -0
- footprinter/ingest/db/security.py +6 -0
- footprinter/ingest/file_indexer.py +223 -0
- footprinter/ingest/file_scanner.py +277 -0
- footprinter/ingest/folder_indexer.py +226 -0
- footprinter/ingest/full_content_extractor.py +321 -0
- footprinter/ingest/orchestrator.py +112 -0
- footprinter/ingest/pipe_runner.py +200 -0
- footprinter/ingest/processing.py +165 -0
- footprinter/ingest/registry.py +186 -0
- footprinter/ingest/run_record.py +91 -0
- footprinter/ingest/status.py +346 -0
- footprinter/mcp/__init__.py +0 -0
- footprinter/mcp/__main__.py +5 -0
- footprinter/mcp/db.py +67 -0
- footprinter/mcp/errors.py +105 -0
- footprinter/mcp/extraction.py +226 -0
- footprinter/mcp/server.py +39 -0
- footprinter/mcp/tools/__init__.py +0 -0
- footprinter/mcp/tools/navigation.py +70 -0
- footprinter/mcp/tools/read.py +75 -0
- footprinter/mcp/tools/search.py +158 -0
- footprinter/mcp/tools/semantic.py +79 -0
- footprinter/mcp/tools/status.py +19 -0
- footprinter/paths.py +117 -0
- footprinter/permissions.py +1152 -0
- footprinter/semantic/__init__.py +13 -0
- footprinter/semantic/chunking.py +52 -0
- footprinter/semantic/embeddings.py +23 -0
- footprinter/semantic/hybrid_search.py +273 -0
- footprinter/semantic/vector_store.py +471 -0
- footprinter/services/__init__.py +49 -0
- footprinter/services/access_service.py +342 -0
- footprinter/services/chat_service.py +85 -0
- footprinter/services/client_service.py +267 -0
- footprinter/services/content_service.py +181 -0
- footprinter/services/email_service.py +89 -0
- footprinter/services/file_service.py +83 -0
- footprinter/services/folder_service.py +122 -0
- footprinter/services/includes.py +19 -0
- footprinter/services/ingest_service.py +231 -0
- footprinter/services/project_service.py +262 -0
- footprinter/services/roles.py +25 -0
- footprinter/services/search_service.py +177 -0
- footprinter/services/semantic_service.py +360 -0
- footprinter/services/status_service.py +18 -0
- footprinter/services/visit_service.py +65 -0
- footprinter/source_registry.py +194 -0
- footprinter/utils/__init__.py +7 -0
- footprinter/utils/hash_utils.py +59 -0
- footprinter/utils/logging_config.py +68 -0
- footprinter/utils/mime.py +30 -0
- footprinter/utils/text.py +6 -0
- footprinter/utils/time.py +11 -0
- footprinter/visibility.py +1264 -0
- footprinter_cli-1.0.0rc1.dist-info/LICENSE +21 -0
- footprinter_cli-1.0.0rc1.dist-info/METADATA +223 -0
- footprinter_cli-1.0.0rc1.dist-info/RECORD +138 -0
- footprinter_cli-1.0.0rc1.dist-info/WHEEL +5 -0
- footprinter_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
- footprinter_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""Shared MCP access-policy helpers used by ``setup.py`` and ``mcp_cmd.py``
|
|
2
|
+
(``fp mcp view/read/check/bulk``).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from footprinter.access import (
|
|
11
|
+
count_affected_entities,
|
|
12
|
+
recalculate_access,
|
|
13
|
+
recalculate_access_batched,
|
|
14
|
+
)
|
|
15
|
+
from footprinter.cli._common import connect_db, console, output_json
|
|
16
|
+
from footprinter.cli._prompt import SafeConfirm as Confirm
|
|
17
|
+
from footprinter.db.policies import (
|
|
18
|
+
PERMISSION_SETTINGS,
|
|
19
|
+
VISIBILITY_SETTINGS,
|
|
20
|
+
seed_access_policies, # noqa: F401 — re-exported; setup.py imports from here
|
|
21
|
+
set_permission_policy,
|
|
22
|
+
set_visibility_policy,
|
|
23
|
+
)
|
|
24
|
+
from footprinter.paths import get_db_path
|
|
25
|
+
|
|
26
|
+
CONFIRM_THRESHOLD = 100
|
|
27
|
+
"""Entity count above which policy changes require interactive confirmation."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Confirmation helper
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def confirm_recalculation(conn: sqlite3.Connection, scope: str, *, yes: bool = False) -> bool:
|
|
36
|
+
"""Count affected entities; if above threshold, prompt for confirmation.
|
|
37
|
+
|
|
38
|
+
Returns True to proceed, False to cancel.
|
|
39
|
+
"""
|
|
40
|
+
counts = count_affected_entities(conn, scope)
|
|
41
|
+
total = sum(counts.values())
|
|
42
|
+
if total <= CONFIRM_THRESHOLD:
|
|
43
|
+
return True
|
|
44
|
+
if yes:
|
|
45
|
+
return True
|
|
46
|
+
parts = [f"{c:,} {t}{'s' if c != 1 else ''}" for t, c in counts.items() if c]
|
|
47
|
+
console.print(f"\nThis will recalculate access on [bold]{total:,}[/bold] entities across {len(counts)} tables.")
|
|
48
|
+
for part in parts:
|
|
49
|
+
console.print(f" {part}")
|
|
50
|
+
return Confirm.ask("Proceed?", default=False)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Progress-aware recalculation
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def recalculate_with_progress(conn: sqlite3.Connection, scope: str) -> dict[str, int]:
|
|
59
|
+
"""Recalculate access with a Rich progress bar for large scopes.
|
|
60
|
+
|
|
61
|
+
If total affected entities <= CONFIRM_THRESHOLD, runs the fast unbatched
|
|
62
|
+
path and prints a one-line summary. Otherwise shows a Rich progress bar
|
|
63
|
+
with per-batch updates.
|
|
64
|
+
"""
|
|
65
|
+
counts = count_affected_entities(conn, scope)
|
|
66
|
+
total = sum(counts.values())
|
|
67
|
+
|
|
68
|
+
if total <= CONFIRM_THRESHOLD:
|
|
69
|
+
return recalculate_access(conn, scope)
|
|
70
|
+
|
|
71
|
+
from rich.progress import Progress
|
|
72
|
+
|
|
73
|
+
with Progress(console=console) as progress:
|
|
74
|
+
task = progress.add_task("Recalculating access…", total=total)
|
|
75
|
+
stats = recalculate_access_batched(
|
|
76
|
+
conn,
|
|
77
|
+
scope,
|
|
78
|
+
on_batch=lambda n: progress.advance(task, advance=n),
|
|
79
|
+
)
|
|
80
|
+
return stats
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# DB / path helpers
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_policy_db() -> sqlite3.Connection | None:
|
|
89
|
+
"""Open a connection to the Footprinter database for policy operations."""
|
|
90
|
+
return connect_db(get_db_path())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def normalize_path(path: str) -> str:
|
|
94
|
+
"""Convert absolute paths to ``~/…`` form for consistent config storage.
|
|
95
|
+
|
|
96
|
+
Strips trailing slashes and collapses double slashes via ``os.path.normpath``.
|
|
97
|
+
Paths not under ``$HOME`` are returned normalized but unchanged.
|
|
98
|
+
"""
|
|
99
|
+
normalized = os.path.normpath(path)
|
|
100
|
+
home = os.path.expanduser("~")
|
|
101
|
+
if normalized.startswith(home + os.sep):
|
|
102
|
+
normalized = "~" + normalized[len(home) :]
|
|
103
|
+
elif normalized == home:
|
|
104
|
+
normalized = "~"
|
|
105
|
+
return normalized
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def abbreviate_home(path: str) -> str:
|
|
109
|
+
"""Replace ``$HOME`` prefix with ``~`` for display."""
|
|
110
|
+
home = os.path.expanduser("~")
|
|
111
|
+
if path.startswith(home + os.sep):
|
|
112
|
+
return "~" + path[len(home) :]
|
|
113
|
+
elif path == home:
|
|
114
|
+
return "~"
|
|
115
|
+
return path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Single-path check helpers
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_file_path(conn: sqlite3.Connection, path: str, json_output: bool, verbose: bool = False) -> int:
|
|
124
|
+
"""Check resolved access for a single file path."""
|
|
125
|
+
from footprinter.permissions import resolve_permission_with_source
|
|
126
|
+
from footprinter.visibility import resolve_visibility_with_source
|
|
127
|
+
|
|
128
|
+
expanded = os.path.expanduser(os.path.normpath(path))
|
|
129
|
+
|
|
130
|
+
row = conn.execute(
|
|
131
|
+
"SELECT id, name, project_id FROM files WHERE path = ? AND status != 'removed'",
|
|
132
|
+
(expanded,),
|
|
133
|
+
).fetchone()
|
|
134
|
+
|
|
135
|
+
if row:
|
|
136
|
+
file_id = row["id"]
|
|
137
|
+
perm_val, perm_src = resolve_permission_with_source(conn, "file", file_id)
|
|
138
|
+
vis_val, vis_src = resolve_visibility_with_source(conn, "file", file_id)
|
|
139
|
+
perm_str = "allow" if perm_val else "deny"
|
|
140
|
+
found = True
|
|
141
|
+
client_id = None
|
|
142
|
+
if row["project_id"] is not None:
|
|
143
|
+
proj = conn.execute(
|
|
144
|
+
"SELECT client_id FROM projects WHERE id = ?",
|
|
145
|
+
(row["project_id"],),
|
|
146
|
+
).fetchone()
|
|
147
|
+
if proj:
|
|
148
|
+
client_id = proj["client_id"]
|
|
149
|
+
chain = build_policy_chain(conn, expanded, file_id, row["project_id"], client_id)
|
|
150
|
+
else:
|
|
151
|
+
# Check folders table before falling through to not-found
|
|
152
|
+
folder_row = conn.execute(
|
|
153
|
+
"SELECT id, name, path FROM folders WHERE path = ?",
|
|
154
|
+
(expanded,),
|
|
155
|
+
).fetchone()
|
|
156
|
+
if folder_row:
|
|
157
|
+
return check_folder(conn, expanded, json_output, verbose=verbose)
|
|
158
|
+
|
|
159
|
+
perm_str, perm_src = simulate_path_permission(conn, expanded)
|
|
160
|
+
vis_val, vis_src = simulate_path_visibility(conn, expanded)
|
|
161
|
+
found = False
|
|
162
|
+
file_id = None
|
|
163
|
+
chain = build_policy_chain(conn, expanded, None, None, None)
|
|
164
|
+
|
|
165
|
+
display_path = abbreviate_home(expanded)
|
|
166
|
+
|
|
167
|
+
if json_output:
|
|
168
|
+
data = {
|
|
169
|
+
"type": "file",
|
|
170
|
+
"path": display_path,
|
|
171
|
+
"file_id": file_id,
|
|
172
|
+
"found_in_db": found,
|
|
173
|
+
"permission": {"resolved": perm_str, "source": perm_src},
|
|
174
|
+
"visibility": {"resolved": vis_val, "source": vis_src},
|
|
175
|
+
"chain": chain,
|
|
176
|
+
}
|
|
177
|
+
output_json(data)
|
|
178
|
+
else:
|
|
179
|
+
console.print(f"\nAccess Check: [bold]{display_path}[/bold]")
|
|
180
|
+
if not found:
|
|
181
|
+
console.print(" [dim]Not found in files or folders — resolving from policy chain[/dim]")
|
|
182
|
+
console.print(" [dim]Tip: Use --folder for directory aggregate, --project for project ID[/dim]")
|
|
183
|
+
console.print()
|
|
184
|
+
console.print(f" Permission: [bold]{perm_str}[/bold] (from {perm_src})")
|
|
185
|
+
console.print(f" Visibility: [bold]{vis_val}[/bold] (from {vis_src})")
|
|
186
|
+
if chain:
|
|
187
|
+
console.print()
|
|
188
|
+
print_policy_chain(chain)
|
|
189
|
+
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def check_folder(conn: sqlite3.Connection, path: str, json_output: bool, verbose: bool) -> int:
|
|
194
|
+
"""Check resolved access for all files under a folder path."""
|
|
195
|
+
from footprinter.permissions import batch_resolve_permissions
|
|
196
|
+
from footprinter.visibility import batch_resolve_visibility
|
|
197
|
+
|
|
198
|
+
expanded = os.path.expanduser(os.path.normpath(path))
|
|
199
|
+
if not expanded.endswith(os.sep):
|
|
200
|
+
expanded += os.sep
|
|
201
|
+
|
|
202
|
+
rows = conn.execute(
|
|
203
|
+
"SELECT id, name FROM files WHERE path LIKE ? AND status != 'removed'",
|
|
204
|
+
(expanded + "%",),
|
|
205
|
+
).fetchall()
|
|
206
|
+
|
|
207
|
+
display_path = abbreviate_home(expanded)
|
|
208
|
+
file_count = len(rows)
|
|
209
|
+
|
|
210
|
+
if file_count == 0:
|
|
211
|
+
if json_output:
|
|
212
|
+
data = {
|
|
213
|
+
"type": "folder",
|
|
214
|
+
"folder": display_path,
|
|
215
|
+
"file_count": 0,
|
|
216
|
+
"permission_counts": {"allow": 0, "deny": 0},
|
|
217
|
+
"visibility_counts": {"visible": 0, "opaque": 0, "hidden": 0},
|
|
218
|
+
}
|
|
219
|
+
output_json(data)
|
|
220
|
+
else:
|
|
221
|
+
console.print(f"\nFolder Check: [bold]{display_path}[/bold] (0 files)")
|
|
222
|
+
console.print(" [dim]No indexed files in this folder.[/dim]")
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
ids = [r["id"] for r in rows]
|
|
226
|
+
perm_results = batch_resolve_permissions(conn, "file", ids)
|
|
227
|
+
vis_results = batch_resolve_visibility(conn, "file", ids)
|
|
228
|
+
|
|
229
|
+
perm_counts = {"allow": 0, "deny": 0}
|
|
230
|
+
vis_counts = {"visible": 0, "opaque": 0, "hidden": 0}
|
|
231
|
+
|
|
232
|
+
for aid in ids:
|
|
233
|
+
allowed, _ = perm_results.get(aid, (False, "baseline"))
|
|
234
|
+
perm_counts["allow" if allowed else "deny"] += 1
|
|
235
|
+
|
|
236
|
+
vis_state, _ = vis_results.get(aid, ("opaque", "baseline"))
|
|
237
|
+
vis_counts[vis_state] = vis_counts.get(vis_state, 0) + 1
|
|
238
|
+
|
|
239
|
+
if json_output:
|
|
240
|
+
data = {
|
|
241
|
+
"type": "folder",
|
|
242
|
+
"folder": display_path,
|
|
243
|
+
"file_count": file_count,
|
|
244
|
+
"permission_counts": perm_counts,
|
|
245
|
+
"visibility_counts": vis_counts,
|
|
246
|
+
}
|
|
247
|
+
if verbose:
|
|
248
|
+
file_details = []
|
|
249
|
+
for r in rows:
|
|
250
|
+
aid = r["id"]
|
|
251
|
+
allowed, p_src = perm_results.get(aid, (False, "baseline"))
|
|
252
|
+
vis_state, v_src = vis_results.get(aid, ("opaque", "baseline"))
|
|
253
|
+
file_details.append(
|
|
254
|
+
{
|
|
255
|
+
"name": r["name"],
|
|
256
|
+
"permission": "allow" if allowed else "deny",
|
|
257
|
+
"permission_source": p_src,
|
|
258
|
+
"visibility": vis_state,
|
|
259
|
+
"visibility_source": v_src,
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
data["files"] = file_details
|
|
263
|
+
output_json(data)
|
|
264
|
+
else:
|
|
265
|
+
console.print(f"\nFolder Check: [bold]{display_path}[/bold] ({file_count} files)")
|
|
266
|
+
console.print()
|
|
267
|
+
console.print(f" Permission: allow: {perm_counts['allow']} deny: {perm_counts['deny']}")
|
|
268
|
+
console.print(
|
|
269
|
+
f" Visibility: visible: {vis_counts['visible']} "
|
|
270
|
+
f"opaque: {vis_counts['opaque']} hidden: {vis_counts['hidden']}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if verbose:
|
|
274
|
+
console.print()
|
|
275
|
+
table = Table(title="Files")
|
|
276
|
+
table.add_column("Name", style="cyan")
|
|
277
|
+
table.add_column("Permission")
|
|
278
|
+
table.add_column("Source", style="dim")
|
|
279
|
+
table.add_column("Visibility")
|
|
280
|
+
table.add_column("Source", style="dim")
|
|
281
|
+
for r in rows:
|
|
282
|
+
aid = r["id"]
|
|
283
|
+
allowed, p_src = perm_results.get(aid, (False, "baseline"))
|
|
284
|
+
vis_state, v_src = vis_results.get(aid, ("opaque", "baseline"))
|
|
285
|
+
table.add_row(
|
|
286
|
+
r["name"],
|
|
287
|
+
"allow" if allowed else "deny",
|
|
288
|
+
p_src,
|
|
289
|
+
vis_state,
|
|
290
|
+
v_src,
|
|
291
|
+
)
|
|
292
|
+
console.print(table)
|
|
293
|
+
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def check_project(conn: sqlite3.Connection, project_id: int, json_output: bool) -> int:
|
|
298
|
+
"""Check resolved access for a project by ID."""
|
|
299
|
+
from footprinter.permissions import resolve_permission_with_source
|
|
300
|
+
from footprinter.visibility import resolve_visibility_with_source
|
|
301
|
+
|
|
302
|
+
row = conn.execute("SELECT id, project_name FROM projects WHERE id = ?", (project_id,)).fetchone()
|
|
303
|
+
if not row:
|
|
304
|
+
console.print(f"[red]Project not found:[/red] id={project_id}")
|
|
305
|
+
return 1
|
|
306
|
+
|
|
307
|
+
perm_val, perm_src = resolve_permission_with_source(conn, "project", project_id)
|
|
308
|
+
vis_val, vis_src = resolve_visibility_with_source(conn, "project", project_id)
|
|
309
|
+
perm_str = "allow" if perm_val else "deny"
|
|
310
|
+
|
|
311
|
+
if json_output:
|
|
312
|
+
data = {
|
|
313
|
+
"project_id": project_id,
|
|
314
|
+
"project_name": row["project_name"],
|
|
315
|
+
"permission": {"resolved": perm_str, "source": perm_src},
|
|
316
|
+
"visibility": {"resolved": vis_val, "source": vis_src},
|
|
317
|
+
}
|
|
318
|
+
output_json(data)
|
|
319
|
+
else:
|
|
320
|
+
console.print(f"\nProject Check: [bold]{row['project_name']}[/bold] (id={project_id})")
|
|
321
|
+
console.print()
|
|
322
|
+
console.print(f" Permission: [bold]{perm_str}[/bold] (from {perm_src})")
|
|
323
|
+
console.print(f" Visibility: [bold]{vis_val}[/bold] (from {vis_src})")
|
|
324
|
+
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def check_client(conn: sqlite3.Connection, client_id: int, json_output: bool) -> int:
|
|
329
|
+
"""Check resolved access for a client by ID."""
|
|
330
|
+
from footprinter.permissions import resolve_permission_with_source
|
|
331
|
+
from footprinter.visibility import resolve_visibility_with_source
|
|
332
|
+
|
|
333
|
+
row = conn.execute("SELECT id, name FROM clients WHERE id = ?", (client_id,)).fetchone()
|
|
334
|
+
if not row:
|
|
335
|
+
console.print(f"[red]Client not found:[/red] id={client_id}")
|
|
336
|
+
return 1
|
|
337
|
+
|
|
338
|
+
perm_val, perm_src = resolve_permission_with_source(conn, "client", client_id)
|
|
339
|
+
vis_val, vis_src = resolve_visibility_with_source(conn, "client", client_id)
|
|
340
|
+
perm_str = "allow" if perm_val else "deny"
|
|
341
|
+
|
|
342
|
+
if json_output:
|
|
343
|
+
data = {
|
|
344
|
+
"client_id": client_id,
|
|
345
|
+
"client_name": row["name"],
|
|
346
|
+
"permission": {"resolved": perm_str, "source": perm_src},
|
|
347
|
+
"visibility": {"resolved": vis_val, "source": vis_src},
|
|
348
|
+
}
|
|
349
|
+
output_json(data)
|
|
350
|
+
else:
|
|
351
|
+
console.print(f"\nClient Check: [bold]{row['name']}[/bold] (id={client_id})")
|
|
352
|
+
console.print()
|
|
353
|
+
console.print(f" Permission: [bold]{perm_str}[/bold] (from {perm_src})")
|
|
354
|
+
console.print(f" Visibility: [bold]{vis_val}[/bold] (from {vis_src})")
|
|
355
|
+
|
|
356
|
+
return 0
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# Policy chain / simulation
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def build_policy_chain(
|
|
365
|
+
conn: sqlite3.Connection,
|
|
366
|
+
path: str,
|
|
367
|
+
file_id: int | None,
|
|
368
|
+
project_id: int | None,
|
|
369
|
+
client_id: int | None,
|
|
370
|
+
) -> list[dict]:
|
|
371
|
+
"""Build diagnostic policy chain showing what policies exist at each scope level."""
|
|
372
|
+
from footprinter.permissions import BASELINE_PERMISSION
|
|
373
|
+
from footprinter.visibility import BASELINE_VISIBILITY
|
|
374
|
+
|
|
375
|
+
chain = []
|
|
376
|
+
|
|
377
|
+
# 1. File-level
|
|
378
|
+
if file_id is not None:
|
|
379
|
+
perm = conn.execute(
|
|
380
|
+
"SELECT setting FROM permission_policies WHERE scope = ?",
|
|
381
|
+
(f"file:{file_id}",),
|
|
382
|
+
).fetchone()
|
|
383
|
+
vis = conn.execute(
|
|
384
|
+
"SELECT setting FROM visibility_policies WHERE scope = ?",
|
|
385
|
+
(f"file:{file_id}",),
|
|
386
|
+
).fetchone()
|
|
387
|
+
chain.append(
|
|
388
|
+
{
|
|
389
|
+
"scope": f"file:{file_id}",
|
|
390
|
+
"permission": perm["setting"] if perm else None,
|
|
391
|
+
"visibility": vis["setting"] if vis else None,
|
|
392
|
+
}
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# 2. Folder prefix policies (longest first)
|
|
396
|
+
if path:
|
|
397
|
+
folder_perms = conn.execute(
|
|
398
|
+
"SELECT scope, setting FROM permission_policies WHERE scope LIKE 'folder:%' ORDER BY LENGTH(scope) DESC"
|
|
399
|
+
).fetchall()
|
|
400
|
+
folder_vis = conn.execute(
|
|
401
|
+
"SELECT scope, setting FROM visibility_policies WHERE scope LIKE 'folder:%' ORDER BY LENGTH(scope) DESC"
|
|
402
|
+
).fetchall()
|
|
403
|
+
|
|
404
|
+
folder_perm_map = {r["scope"]: r["setting"] for r in folder_perms}
|
|
405
|
+
folder_vis_map = {r["scope"]: r["setting"] for r in folder_vis}
|
|
406
|
+
all_folder_scopes = sorted(
|
|
407
|
+
set(folder_perm_map.keys()) | set(folder_vis_map.keys()),
|
|
408
|
+
key=lambda s: len(s),
|
|
409
|
+
reverse=True,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
for scope in all_folder_scopes:
|
|
413
|
+
prefix = scope[len("folder:") :]
|
|
414
|
+
expanded_prefix = os.path.expanduser(prefix)
|
|
415
|
+
if path.startswith(expanded_prefix):
|
|
416
|
+
chain.append(
|
|
417
|
+
{
|
|
418
|
+
"scope": scope,
|
|
419
|
+
"permission": folder_perm_map.get(scope),
|
|
420
|
+
"visibility": folder_vis_map.get(scope),
|
|
421
|
+
}
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# 3. Project-level
|
|
425
|
+
if project_id is not None:
|
|
426
|
+
perm = conn.execute(
|
|
427
|
+
"SELECT setting FROM permission_policies WHERE scope = ?",
|
|
428
|
+
(f"project:{project_id}",),
|
|
429
|
+
).fetchone()
|
|
430
|
+
vis = conn.execute(
|
|
431
|
+
"SELECT setting FROM visibility_policies WHERE scope = ?",
|
|
432
|
+
(f"project:{project_id}",),
|
|
433
|
+
).fetchone()
|
|
434
|
+
chain.append(
|
|
435
|
+
{
|
|
436
|
+
"scope": f"project:{project_id}",
|
|
437
|
+
"permission": perm["setting"] if perm else None,
|
|
438
|
+
"visibility": vis["setting"] if vis else None,
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# 4. Client-level
|
|
443
|
+
if client_id is not None:
|
|
444
|
+
perm = conn.execute(
|
|
445
|
+
"SELECT setting FROM permission_policies WHERE scope = ?",
|
|
446
|
+
(f"client:{client_id}",),
|
|
447
|
+
).fetchone()
|
|
448
|
+
vis = conn.execute(
|
|
449
|
+
"SELECT setting FROM visibility_policies WHERE scope = ?",
|
|
450
|
+
(f"client:{client_id}",),
|
|
451
|
+
).fetchone()
|
|
452
|
+
chain.append(
|
|
453
|
+
{
|
|
454
|
+
"scope": f"client:{client_id}",
|
|
455
|
+
"permission": perm["setting"] if perm else None,
|
|
456
|
+
"visibility": vis["setting"] if vis else None,
|
|
457
|
+
}
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# 5. Source: files
|
|
461
|
+
src_perm = conn.execute("SELECT setting FROM permission_policies WHERE scope = 'source:files'").fetchone()
|
|
462
|
+
src_vis = conn.execute("SELECT setting FROM visibility_policies WHERE scope = 'source:files'").fetchone()
|
|
463
|
+
chain.append(
|
|
464
|
+
{
|
|
465
|
+
"scope": "source:files",
|
|
466
|
+
"permission": src_perm["setting"] if src_perm else None,
|
|
467
|
+
"visibility": src_vis["setting"] if src_vis else None,
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# 6. Global
|
|
472
|
+
global_perm = conn.execute("SELECT setting FROM permission_policies WHERE scope = 'global'").fetchone()
|
|
473
|
+
global_vis = conn.execute("SELECT setting FROM visibility_policies WHERE scope = 'global'").fetchone()
|
|
474
|
+
chain.append(
|
|
475
|
+
{
|
|
476
|
+
"scope": "global",
|
|
477
|
+
"permission": global_perm["setting"] if global_perm else None,
|
|
478
|
+
"visibility": global_vis["setting"] if global_vis else None,
|
|
479
|
+
}
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# 7. Baseline
|
|
483
|
+
chain.append(
|
|
484
|
+
{
|
|
485
|
+
"scope": "baseline",
|
|
486
|
+
"permission": "allow" if BASELINE_PERMISSION else "deny",
|
|
487
|
+
"visibility": BASELINE_VISIBILITY,
|
|
488
|
+
}
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
return chain
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def print_policy_chain(chain: list[dict]) -> None:
|
|
495
|
+
"""Print the policy chain as a Rich table."""
|
|
496
|
+
table = Table(title="Policy Chain")
|
|
497
|
+
table.add_column("Scope", style="cyan")
|
|
498
|
+
table.add_column("Permission")
|
|
499
|
+
table.add_column("Visibility")
|
|
500
|
+
for entry in chain:
|
|
501
|
+
table.add_row(
|
|
502
|
+
entry["scope"],
|
|
503
|
+
entry.get("permission") or "-",
|
|
504
|
+
entry.get("visibility") or "-",
|
|
505
|
+
)
|
|
506
|
+
console.print(table)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def simulate_path_permission(conn: sqlite3.Connection, expanded_path: str) -> tuple[str, str]:
|
|
510
|
+
"""Simulate permission resolution for a path not in the database.
|
|
511
|
+
|
|
512
|
+
Walks folder prefix -> source:files -> global -> baseline.
|
|
513
|
+
"""
|
|
514
|
+
from footprinter.permissions import BASELINE_PERMISSION
|
|
515
|
+
|
|
516
|
+
rows = conn.execute(
|
|
517
|
+
"SELECT scope, setting FROM permission_policies WHERE scope LIKE 'folder:%' ORDER BY LENGTH(scope) DESC"
|
|
518
|
+
).fetchall()
|
|
519
|
+
for row in rows:
|
|
520
|
+
prefix = row["scope"][len("folder:") :]
|
|
521
|
+
expanded_prefix = os.path.expanduser(prefix)
|
|
522
|
+
if expanded_path.startswith(expanded_prefix):
|
|
523
|
+
return (row["setting"], row["scope"])
|
|
524
|
+
|
|
525
|
+
src = conn.execute("SELECT setting FROM permission_policies WHERE scope = 'source:files'").fetchone()
|
|
526
|
+
if src:
|
|
527
|
+
return (src["setting"], "source:files")
|
|
528
|
+
|
|
529
|
+
gl = conn.execute("SELECT setting FROM permission_policies WHERE scope = 'global'").fetchone()
|
|
530
|
+
if gl:
|
|
531
|
+
return (gl["setting"], "global")
|
|
532
|
+
|
|
533
|
+
return ("allow" if BASELINE_PERMISSION else "deny", "baseline")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def simulate_path_visibility(conn: sqlite3.Connection, expanded_path: str) -> tuple[str, str]:
|
|
537
|
+
"""Simulate visibility resolution for a path not in the database.
|
|
538
|
+
|
|
539
|
+
Walks folder prefix -> source:files -> global -> baseline.
|
|
540
|
+
"""
|
|
541
|
+
from footprinter.visibility import BASELINE_VISIBILITY
|
|
542
|
+
|
|
543
|
+
rows = conn.execute(
|
|
544
|
+
"SELECT scope, setting FROM visibility_policies WHERE scope LIKE 'folder:%' ORDER BY LENGTH(scope) DESC"
|
|
545
|
+
).fetchall()
|
|
546
|
+
for row in rows:
|
|
547
|
+
prefix = row["scope"][len("folder:") :]
|
|
548
|
+
expanded_prefix = os.path.expanduser(prefix)
|
|
549
|
+
if expanded_path.startswith(expanded_prefix):
|
|
550
|
+
return (row["setting"], row["scope"])
|
|
551
|
+
|
|
552
|
+
src = conn.execute("SELECT setting FROM visibility_policies WHERE scope = 'source:files'").fetchone()
|
|
553
|
+
if src:
|
|
554
|
+
return (src["setting"], "source:files")
|
|
555
|
+
|
|
556
|
+
gl = conn.execute("SELECT setting FROM visibility_policies WHERE scope = 'global'").fetchone()
|
|
557
|
+
if gl:
|
|
558
|
+
return (gl["setting"], "global")
|
|
559
|
+
|
|
560
|
+
return (BASELINE_VISIBILITY, "baseline")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
# Bulk helper
|
|
565
|
+
# ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def bulk_apply(
|
|
569
|
+
conn: sqlite3.Connection,
|
|
570
|
+
*,
|
|
571
|
+
folder: str | None,
|
|
572
|
+
project: int | None,
|
|
573
|
+
permission: str | None,
|
|
574
|
+
visibility: str | None,
|
|
575
|
+
dry_run: bool = False,
|
|
576
|
+
yes: bool = False,
|
|
577
|
+
) -> int:
|
|
578
|
+
"""Bulk set permission and/or visibility for a folder or project scope.
|
|
579
|
+
|
|
580
|
+
Returns 0 on success, 1 on validation error.
|
|
581
|
+
"""
|
|
582
|
+
if not folder and project is None:
|
|
583
|
+
console.print("[red]Specify a target:[/red] --folder or --project")
|
|
584
|
+
return 1
|
|
585
|
+
|
|
586
|
+
if not permission and not visibility:
|
|
587
|
+
console.print("[red]Specify at least one setting:[/red] --permission or --visibility")
|
|
588
|
+
return 1
|
|
589
|
+
|
|
590
|
+
if permission and permission not in PERMISSION_SETTINGS:
|
|
591
|
+
console.print(f"[red]Invalid permission:[/red] {permission}\n Valid: {', '.join(sorted(PERMISSION_SETTINGS))}")
|
|
592
|
+
return 1
|
|
593
|
+
if visibility and visibility not in VISIBILITY_SETTINGS:
|
|
594
|
+
console.print(f"[red]Invalid visibility:[/red] {visibility}\n Valid: {', '.join(sorted(VISIBILITY_SETTINGS))}")
|
|
595
|
+
return 1
|
|
596
|
+
|
|
597
|
+
if folder:
|
|
598
|
+
normalized = normalize_path(folder)
|
|
599
|
+
scope = f"folder:{normalized}"
|
|
600
|
+
target_label = f"folder [cyan]{normalized}[/cyan]"
|
|
601
|
+
else:
|
|
602
|
+
row = conn.execute("SELECT project_name FROM projects WHERE id = ?", (project,)).fetchone()
|
|
603
|
+
if not row:
|
|
604
|
+
console.print(f"[red]Project not found:[/red] id={project}")
|
|
605
|
+
return 1
|
|
606
|
+
scope = f"project:{project}"
|
|
607
|
+
target_label = f"project [cyan]{row['project_name']}[/cyan] (id={project})"
|
|
608
|
+
|
|
609
|
+
counts = count_affected_entities(conn, scope)
|
|
610
|
+
total = sum(counts.values())
|
|
611
|
+
parts = [f"{c:,} {t}{'s' if c != 1 else ''}" for t, c in counts.items() if c]
|
|
612
|
+
|
|
613
|
+
settings_desc = []
|
|
614
|
+
if permission:
|
|
615
|
+
settings_desc.append(f"permission=[bold]{permission}[/bold]")
|
|
616
|
+
if visibility:
|
|
617
|
+
settings_desc.append(f"visibility=[bold]{visibility}[/bold]")
|
|
618
|
+
|
|
619
|
+
console.print(
|
|
620
|
+
f"\nScope: [cyan]{scope}[/cyan] ({total:,} entities: {', '.join(parts)})"
|
|
621
|
+
if parts
|
|
622
|
+
else f"\nScope: [cyan]{scope}[/cyan] (0 entities)"
|
|
623
|
+
)
|
|
624
|
+
console.print(f" Setting: {', '.join(settings_desc)}")
|
|
625
|
+
|
|
626
|
+
if dry_run:
|
|
627
|
+
console.print("\n[dim]Dry run — no changes made.[/dim]")
|
|
628
|
+
return 0
|
|
629
|
+
|
|
630
|
+
if not yes and total > CONFIRM_THRESHOLD:
|
|
631
|
+
if not Confirm.ask("\nProceed?", default=False):
|
|
632
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
633
|
+
return 0
|
|
634
|
+
|
|
635
|
+
if permission:
|
|
636
|
+
set_permission_policy(conn, scope, permission)
|
|
637
|
+
if visibility:
|
|
638
|
+
set_visibility_policy(conn, scope, visibility)
|
|
639
|
+
|
|
640
|
+
stats = recalculate_with_progress(conn, scope)
|
|
641
|
+
console.print(f"\n[green]Applied[/green] to {target_label}")
|
|
642
|
+
if stats:
|
|
643
|
+
parts = [f"{c} {t}{'s' if c != 1 else ''}" for t, c in stats.items() if c]
|
|
644
|
+
if parts:
|
|
645
|
+
console.print(f" [dim]Recalculated: {', '.join(parts)}[/dim]")
|
|
646
|
+
return 0
|