localmask 0.9.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.
cli.py ADDED
@@ -0,0 +1,2437 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ LocalMask Pro CLI — detect, review, and approve secrets in code repos.
4
+
5
+ The CLI is a thin client that communicates with the LocalMask cloud service.
6
+ All scanning and detection happens server-side. Secrets never leave the service.
7
+
8
+ Usage:
9
+ localmask connect https://localmask-pro-xxx.run.app
10
+ localmask scan https://github.com/org/repo --credential-id cred_xxx
11
+ localmask store-token ghp_xxx # store token securely, get credential_id
12
+ localmask set-key anthropic sk-ant-xxx # set AI API key on server
13
+ localmask status # list all scans
14
+ localmask status <scan_id> # single scan status
15
+ localmask review <scan_id> # interactive review via service
16
+ localmask approve-all <scan_id> # approve all detections + submit
17
+ localmask submit <scan_id> # submit for security approval
18
+ localmask publish <scan_id> <target_url> # publish masked repo to git
19
+ localmask ask <scan_id> # interactive AI chat about a scan
20
+ localmask ask <scan_id> "what are the risks" # single question mode
21
+ """
22
+ import argparse
23
+ import json
24
+ import os
25
+ import sys
26
+ import urllib.request
27
+ import urllib.error
28
+ from datetime import datetime
29
+
30
+ # ── Constants ────────────────────────────────────────────────────────────────
31
+ CONFIG_DIR = os.path.expanduser("~/.localmask")
32
+ CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
33
+
34
+ # ── Colors ───────────────────────────────────────────────────────────────────
35
+ RED = "\033[91m"
36
+ GREEN = "\033[92m"
37
+ YELLOW = "\033[93m"
38
+ BLUE = "\033[94m"
39
+ MAGENTA = "\033[95m"
40
+ CYAN = "\033[96m"
41
+ WHITE = "\033[97m"
42
+ BOLD = "\033[1m"
43
+ DIM = "\033[2m"
44
+ UNDERLINE = "\033[4m"
45
+ BG_RED = "\033[41m"
46
+ BG_GREEN = "\033[42m"
47
+ BG_YELLOW = "\033[43m"
48
+ BG_BLUE = "\033[44m"
49
+ RESET = "\033[0m"
50
+
51
+
52
+ # ═══════════════════════════════════════════════════════════════════════════════
53
+ # SERVICE CLIENT — communicates with LocalMask cloud service
54
+ # ═══════════════════════════════════════════════════════════════════════════════
55
+
56
+ class ServiceClient:
57
+ """HTTP client for the LocalMask service API."""
58
+
59
+ def __init__(self, base_url: str):
60
+ self.base_url = base_url.rstrip("/")
61
+
62
+ def _req(self, method: str, path: str, body: dict = None) -> dict:
63
+ url = f"{self.base_url}{path}"
64
+ data = json.dumps(body).encode() if body else None
65
+ req = urllib.request.Request(
66
+ url, data=data, method=method,
67
+ headers={"Content-Type": "application/json"} if data else {},
68
+ )
69
+ try:
70
+ with urllib.request.urlopen(req, timeout=120) as resp:
71
+ return json.loads(resp.read())
72
+ except urllib.error.HTTPError as e:
73
+ err_body = e.read().decode()
74
+ try:
75
+ err = json.loads(err_body)
76
+ raise SystemExit(f"{RED}Error {e.code}: {err.get('error', err_body)}{RESET}")
77
+ except json.JSONDecodeError:
78
+ raise SystemExit(f"{RED}Error {e.code}: {err_body[:200]}{RESET}")
79
+ except urllib.error.URLError as e:
80
+ raise SystemExit(f"{RED}Connection failed: {e.reason}{RESET}")
81
+
82
+ def health(self) -> dict:
83
+ return self._req("GET", "/health")
84
+
85
+ def store_credential(self, token: str, label: str = "") -> dict:
86
+ return self._req("POST", "/api/credentials", {
87
+ "token": token, "label": label,
88
+ })
89
+
90
+ def scan(self, repo_url, credential_id="", org="default",
91
+ sensitivity="standard", submitted_by="developer"):
92
+ return self._req("POST", "/api/repos/scan", {
93
+ "repo_url": repo_url, "credential_id": credential_id, "org": org,
94
+ "sensitivity": sensitivity, "submitted_by": submitted_by,
95
+ })
96
+
97
+ def list_repos(self, org=""):
98
+ qs = f"?org={org}" if org else ""
99
+ return self._req("GET", f"/api/repos{qs}")
100
+
101
+ def get_scan(self, scan_id):
102
+ return self._req("GET", f"/api/repos/{scan_id}")
103
+
104
+ def get_detections(self, scan_id):
105
+ return self._req("GET", f"/api/repos/{scan_id}/detections")
106
+
107
+ def post_review(self, scan_id, decisions, reviewer="developer"):
108
+ return self._req("POST", f"/api/repos/{scan_id}/review", {
109
+ "decisions": decisions, "reviewer": reviewer,
110
+ })
111
+
112
+ def submit(self, scan_id, submitted_by="developer"):
113
+ return self._req("POST", f"/api/repos/{scan_id}/submit", {
114
+ "submitted_by": submitted_by,
115
+ })
116
+
117
+ def ask(self, scan_id, question, provider="anthropic",
118
+ model="claude-sonnet-4-5", source="memory", git_url=""):
119
+ body = {
120
+ "question": question, "provider": provider,
121
+ "model": model, "only_findings": True,
122
+ "source": source,
123
+ }
124
+ if git_url:
125
+ body["git_url"] = git_url
126
+ return self._req("POST", f"/api/repos/{scan_id}/ask", body)
127
+
128
+ def ask_reset(self, scan_id):
129
+ return self._req("POST", f"/api/repos/{scan_id}/ask/reset", {})
130
+
131
+ def set_key(self, provider: str, key: str) -> dict:
132
+ return self._req("POST", "/api/settings", {provider: key})
133
+
134
+ def get_keys(self) -> dict:
135
+ return self._req("GET", "/api/settings")
136
+
137
+ def publish(self, scan_id: str, target_url: str,
138
+ token: str = "", credential_id: str = "",
139
+ username: str = "") -> dict:
140
+ body = {"target_url": target_url}
141
+ if credential_id:
142
+ body["credential_id"] = credential_id
143
+ elif token:
144
+ body["token"] = token
145
+ if username:
146
+ body["username"] = username
147
+ return self._req("POST", f"/api/repos/{scan_id}/publish", body)
148
+
149
+
150
+ def _load_config() -> dict:
151
+ if os.path.exists(CONFIG_FILE):
152
+ with open(CONFIG_FILE) as f:
153
+ return json.load(f)
154
+ return {}
155
+
156
+
157
+ def _save_config(cfg: dict):
158
+ os.makedirs(CONFIG_DIR, exist_ok=True)
159
+ with open(CONFIG_FILE, "w") as f:
160
+ json.dump(cfg, f, indent=2)
161
+
162
+
163
+ def _publish_policy() -> str:
164
+ """'review' (default) → publishing/auto-republish needs an approved review.
165
+ 'auto' → detections are auto-approved and the masked mirror publishes
166
+ without a manual gate."""
167
+ p = _load_config().get("publish_policy", "review")
168
+ return p if p in ("review", "auto") else "review"
169
+
170
+
171
+ def _pending_count(scan: dict) -> int:
172
+ """Detections not yet decided (approved/rejected) — i.e. unreviewed."""
173
+ return sum(1 for d in scan.get("detections", [])
174
+ if d.get("decision") in (None, "pending"))
175
+
176
+
177
+ def _approve_all_local(scan_id: str) -> dict:
178
+ """Approve every detection and mark the scan approved-for-publish. Returns
179
+ the scan. Used by `approve-all`, by `review` when nothing is left pending,
180
+ and by publish/sync under the 'auto' policy."""
181
+ from server_core import _get_or_load_scan, _persist_scan
182
+ scan = _get_or_load_scan(scan_id)
183
+ if not scan:
184
+ return {}
185
+ for d in scan.get("detections", []):
186
+ if d.get("decision") in (None, "pending"):
187
+ d["decision"] = "approved"
188
+ scan["status"] = "approved"
189
+ scan["reviewed_by"] = scan.get("reviewed_by") or "developer"
190
+ _persist_scan(scan_id)
191
+ return scan
192
+
193
+
194
+ def _is_approved(scan: dict) -> bool:
195
+ return scan.get("status") == "approved" and _pending_count(scan) == 0
196
+
197
+
198
+ def _get_client() -> ServiceClient:
199
+ cfg = _load_config()
200
+ url = cfg.get("service_url")
201
+ if not url:
202
+ raise SystemExit(
203
+ f"{RED}Not connected to a service. Run:{RESET}\n"
204
+ f" localmask connect <service-url>\n"
205
+ )
206
+ return ServiceClient(url)
207
+
208
+
209
+ def _is_connected() -> bool:
210
+ # The free edition is local-only — never route to a service. This stops a
211
+ # stale service_url (e.g. left over from a prior/Pro install) from hijacking
212
+ # local scans. Pro/Team/Enterprise may still use a hosted server.
213
+ try:
214
+ from localmask._edition import has_capability
215
+ if not has_capability("web_ui"):
216
+ return False
217
+ except Exception:
218
+ pass
219
+ return bool(_load_config().get("service_url"))
220
+
221
+
222
+ def _local_engine():
223
+ """In-process engine — lets scan/status/publish/sync/teach work with no
224
+ server (the free edition ships no web server). Returns a LocalMaskEngine."""
225
+ from server_core import LocalMaskEngine
226
+ return LocalMaskEngine()
227
+
228
+
229
+ def _count_occurrences(src: str, value: str) -> int:
230
+ """How many times the literal value appears across the scanned files —
231
+ the ground truth for 'is this value actually in the code'. Best-effort:
232
+ walks text files under src, skips .git and unreadable/binary files."""
233
+ if not value:
234
+ return 0
235
+ root = src if os.path.isabs(src) else os.path.join(os.getcwd(), src)
236
+ if not os.path.isdir(root):
237
+ return 0
238
+ total = 0
239
+ for dirpath, dirnames, filenames in os.walk(root):
240
+ dirnames[:] = [d for d in dirnames if d != ".git"]
241
+ for fn in filenames:
242
+ fp = os.path.join(dirpath, fn)
243
+ try:
244
+ if os.path.getsize(fp) > 5_000_000:
245
+ continue
246
+ with open(fp, "r", errors="ignore") as f:
247
+ total += f.read().count(value)
248
+ except (OSError, ValueError):
249
+ continue
250
+ return total
251
+
252
+
253
+ def _publish_error_help(err: str, target_url: str, had_token: bool):
254
+ """Turn a git push failure into a clear, actionable message (no traceback)."""
255
+ low = err.lower()
256
+ print(f" {RED}✗ Publish failed.{RESET}")
257
+ _auth = ("authentication failed", "invalid username or token",
258
+ "password authentication", "403", "could not read username",
259
+ "could not read password", "terminal prompts disabled",
260
+ "authentication required")
261
+ if any(s in low for s in _auth):
262
+ print(f" {YELLOW}Authentication was rejected by the remote.{RESET}")
263
+ if not had_token:
264
+ print(f" {DIM}No token was used.{RESET} GitHub/GitLab need a Personal "
265
+ f"Access Token with {BOLD}repo{RESET} scope to push. Either:")
266
+ print(f" 1) {CYAN}localmask store-token{RESET} "
267
+ f"{DIM}(type it hidden → get a credential id){RESET}")
268
+ print(f" {CYAN}localmask publish <scan> {target_url} -c <cred_id>{RESET}")
269
+ print(f" 2) or a one-off: {CYAN}localmask publish <scan> {target_url} "
270
+ f"--token <PAT>{RESET} {DIM}(leaks into shell history){RESET}")
271
+ else:
272
+ print(f" {DIM}The token was rejected — it may be expired, revoked, or "
273
+ f"missing the {RESET}{BOLD}repo{RESET}{DIM} scope. Create a fresh "
274
+ f"PAT and store it again with `localmask store-token`.{RESET}")
275
+ elif "not found" in low or "repository not found" in low or "does not exist" in low:
276
+ print(f" {YELLOW}The target repo doesn't exist yet.{RESET} Create an "
277
+ f"{BOLD}empty{RESET} repo at {CYAN}{target_url}{RESET} first "
278
+ f"(no README), then re-run publish.")
279
+ else:
280
+ print(f" {DIM}{err[:300]}{RESET}")
281
+ print(f" {DIM}Nothing was pushed; your secrets never left this machine.{RESET}")
282
+
283
+
284
+ def _print_grant_guide(target_url: str, scan_id: str):
285
+ """Simple next-steps: two ways to let the AI read the masked code. LocalMask
286
+ never hands the AI any credentials."""
287
+ print(f"\n {BOLD}Two ways to let your AI read the masked code:{RESET}")
288
+ print(f" {DIM}(It only ever sees ~[TOKEN]~ placeholders — no real secrets.){RESET}\n")
289
+ print(f" {BOLD}A) The AI reads the published masked git mirror{RESET}")
290
+ print(f" Give the AI its {BOLD}own{RESET} read access to {CYAN}{target_url}{RESET}")
291
+ print(f" (read-only collaborator, deploy key, or GitHub/GitLab App), then it")
292
+ print(f" {BOLD}clones/pulls that repo{RESET} — a copy on the AI's side, separate")
293
+ print(f" from your real code. LocalMask never shares your git token.")
294
+ print(f" {DIM}After you change code:{RESET} {CYAN}localmask sync {scan_id}{RESET} "
295
+ f"{DIM}re-masks and{RESET}")
296
+ print(f" {DIM}re-pushes the mirror (once approved); the AI does{RESET} "
297
+ f"{CYAN}git pull{RESET} {DIM}to get it.{RESET}\n")
298
+ print(f" {BOLD}B) The AI reads live from LocalMask — nothing published{RESET}")
299
+ print(f" In your AI editor's MCP config, the assistant calls LocalMask's")
300
+ print(f" {CYAN}get_detections{RESET} / {CYAN}get_file_masked{RESET} tools. No git repo, no")
301
+ print(f" pull — LocalMask serves the {BOLD}current{RESET} masked content each call")
302
+ print(f" (run {CYAN}localmask sync {scan_id}{RESET} {DIM}after code changes so it's fresh).{RESET}")
303
+ print(f"\n {DIM}The difference:{RESET} (A) the AI holds its own git copy and "
304
+ f"{BOLD}pulls{RESET} to update;")
305
+ print(f" (B) LocalMask streams the masked files live, always current, no repo.\n")
306
+
307
+
308
+ _ASK_DEFAULT_MODELS = {
309
+ "anthropic": "claude-sonnet-4-5", "openai": "gpt-4o", "gemini": "gemini-1.5-pro",
310
+ "grok": "grok-2-latest", "xai": "grok-2-latest", "groq": "llama-3.3-70b-versatile",
311
+ "together": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
312
+ "meta": "meta-llama/Llama-3.3-70B-Instruct-Turbo", "openrouter": "openai/gpt-4o",
313
+ }
314
+ _ASK_KEY_ENVS = {
315
+ "anthropic": ["ANTHROPIC_API_KEY"], "openai": ["OPENAI_API_KEY"],
316
+ "gemini": ["GEMINI_API_KEY", "GOOGLE_API_KEY"], "grok": ["XAI_API_KEY"],
317
+ "xai": ["XAI_API_KEY"], "groq": ["GROQ_API_KEY"], "together": ["TOGETHER_API_KEY"],
318
+ "openrouter": ["OPENROUTER_API_KEY"],
319
+ }
320
+
321
+
322
+ def _local_ask(args):
323
+ """Bring-your-own-key AI Q&A in the free edition. Masking + rehydration are
324
+ 100% local; only masked tokens go to the provider you choose with your key."""
325
+ from localmask.state import _new_session, _get_or_load_scan
326
+ from localmask import ask_local
327
+ scan = _get_or_load_scan(args.scan_id)
328
+ if not scan:
329
+ print(f"{RED}Scan not found: {args.scan_id}{RESET}"); sys.exit(1)
330
+
331
+ provider = args.provider.lower()
332
+ # resolve key: --api-key > provider-specific env > generic env > local store
333
+ key = args.api_key
334
+ for env in _ASK_KEY_ENVS.get(provider, []) + ["LOCALMASK_AI_KEY"]:
335
+ if not key:
336
+ key = os.environ.get(env, "")
337
+ if not key and provider != "dry":
338
+ from localmask.vault_store import get_local_ai_key
339
+ key = get_local_ai_key(provider) or ""
340
+ if provider != "dry" and not key:
341
+ envs = " / ".join(_ASK_KEY_ENVS.get(provider, ["LOCALMASK_AI_KEY"]))
342
+ print(f"{RED}No API key for {provider}.{RESET} Save one with "
343
+ f"`localmask set-key {provider}` (stored encrypted, typed hidden), "
344
+ f"or pass --api-key, or set {envs}.")
345
+ sys.exit(1)
346
+ model = args.model or _ASK_DEFAULT_MODELS.get(provider, "gpt-4o")
347
+ question = args.question or "Review this repository and flag the top security risks."
348
+
349
+ # ── Source = git: the AI reads the private masked git itself. LocalMask
350
+ # sends ONLY the masked question + the repo URL — no repo content from
351
+ # memory, no git credentials. (Use when the AI/agent has its own read
352
+ # access to the published masked mirror.) ──────────────────────────────
353
+ if getattr(args, "source", "memory") == "git":
354
+ git_url = args.git_url or scan.get("publish_target", "")
355
+ if not git_url:
356
+ print(f"{RED}No masked git URL.{RESET} Publish the mirror first "
357
+ f"(`localmask publish {args.scan_id} <url>`) or pass --git-url.")
358
+ sys.exit(1)
359
+ # Hydrate the vault only (value→token) so we can mask the question by
360
+ # found keys — we do NOT scan or load repo files here.
361
+ session = _new_session(scan["repo_url"], temp=False)
362
+ print(f" {CYAN}[LOCAL]{RESET} masking your question by found keys; the "
363
+ f"AI reads {CYAN}{git_url}{RESET} itself.")
364
+ print(f" {DIM}Only the masked question + the URL leave — no repo "
365
+ f"content, no keys.{RESET}", flush=True)
366
+ if provider == "dry":
367
+ from localmask.masking import _mask_text
368
+ print(f" {DIM}[dry-run] masked question →{RESET} "
369
+ f"{_mask_text(session, question)}")
370
+ return
371
+ try:
372
+ answer = ask_local.ask_over_git(session, question, git_url,
373
+ provider, key, model,
374
+ base_url=args.base_url)
375
+ except Exception as e:
376
+ print(f"{RED}Ask failed: {e}{RESET}"); sys.exit(1)
377
+ print(f"\n{answer}\n")
378
+ return
379
+
380
+ # ── Source = memory (default): mask the whole repo locally and include it in
381
+ # the prompt (self-contained; works with any AI, no git access needed).
382
+ print(f" {CYAN}[LOCAL]{RESET} masking repo, asking {provider} ({model}) "
383
+ f"with your key — only masked tokens leave...", flush=True)
384
+ session = _new_session(scan["repo_url"], temp=False)
385
+ from localmask.engine import _scan_dir
386
+ src = scan["repo_url"]
387
+ src = src if os.path.isabs(src) else os.path.join(os.getcwd(), src)
388
+ _scan_dir(session, src)
389
+ if provider == "dry":
390
+ print(f" {DIM}[dry-run] would ask: {question}{RESET}"); return
391
+ try:
392
+ answer = ask_local.ask(session, question, provider, key, model,
393
+ base_url=args.base_url)
394
+ except Exception as e:
395
+ print(f"{RED}Ask failed: {e}{RESET}"); sys.exit(1)
396
+ print(f"\n{answer}\n")
397
+
398
+
399
+ # ═══════════════════════════════════════════════════════════════════════════════
400
+ # DETECTION MODEL (remote only — values never leave server)
401
+ # ═══════════════════════════════════════════════════════════════════════════════
402
+
403
+ class Detection:
404
+ """Single detection instance (metadata only — no real secret values)."""
405
+ __slots__ = ("id", "token", "det_type", "line", "confidence",
406
+ "context_lines", "file", "decision",
407
+ "confidence_override", "reason", "timestamp")
408
+
409
+ def __init__(self, det_id, token, det_type, line, confidence, file,
410
+ context_lines=None):
411
+ self.id = det_id
412
+ self.token = token
413
+ self.det_type = det_type
414
+ self.line = line
415
+ self.confidence = confidence
416
+ self.context_lines = context_lines or []
417
+ self.file = file
418
+ self.decision = None
419
+ self.confidence_override = None
420
+ self.reason = None
421
+ self.timestamp = None
422
+
423
+
424
+ # ═══════════════════════════════════════════════════════════════════════════════
425
+ # HIERARCHICAL REVIEWER — remote service mode only
426
+ # ═══════════════════════════════════════════════════════════════════════════════
427
+
428
+ class HierarchicalReviewer:
429
+ """Interactive 3-level detection reviewer: Type -> Instances -> Single."""
430
+
431
+ def __init__(self, detections: list, repo_label: str = "",
432
+ save_callback=None, repo_root: str = "", mode: str = "SERVICE",
433
+ teach_callback=None):
434
+ self.repo_label = repo_label
435
+ self.repo_root = repo_root or os.getcwd()
436
+ self.detections = detections
437
+ self._save_callback = save_callback
438
+ self._teach_callback = teach_callback
439
+ self.mode = mode
440
+ self._regroup()
441
+
442
+ def _regroup(self):
443
+ """(Re)build the type groupings from self.detections."""
444
+ self.types = {}
445
+ for d in self.detections:
446
+ self.types.setdefault(d.det_type, []).append(d)
447
+ # Sort types by avg confidence (highest first)
448
+ self.type_order = sorted(
449
+ self.types.keys(),
450
+ key=lambda t: -(sum(d.confidence for d in self.types[t]) / len(self.types[t]))
451
+ )
452
+
453
+ def _file_link(self, rel_path: str, line: int = 0) -> str:
454
+ """Return clickable file link for VS Code terminal (cmd+click)."""
455
+ # Use relative path — VS Code terminal resolves it against cwd
456
+ return f"./{rel_path}:{line}"
457
+
458
+ def _clear(self):
459
+ print("\033[2J\033[H", end="", flush=True)
460
+
461
+ def _conf_bar(self, conf, width=18):
462
+ filled = int(conf * width)
463
+ return f"{YELLOW}{'█' * filled}{'░' * (width - filled)}{RESET}"
464
+
465
+ def _conf_color(self, conf):
466
+ if conf >= 0.9: return RED
467
+ if conf >= 0.7: return YELLOW
468
+ return DIM
469
+
470
+ def _status_icon(self, det):
471
+ if det.decision is True: return f"{GREEN}✓{RESET}"
472
+ if det.decision is False: return f"{RED}✗{RESET}"
473
+ return f"{DIM}→{RESET}"
474
+
475
+ def _input(self, prompt):
476
+ try:
477
+ return input(prompt).strip()
478
+ except (EOFError, KeyboardInterrupt):
479
+ return "q"
480
+
481
+ def _save_decisions(self):
482
+ if self._save_callback:
483
+ self._save_callback(self.detections)
484
+
485
+ def _teach_missed(self):
486
+ """Prompt for a secret the scanner missed, teach it, and reload the
487
+ detection list so the newly-masked value shows up immediately."""
488
+ self._clear()
489
+ print(f"\n{BOLD} Teach a missed secret{RESET}\n")
490
+ print(f" {DIM}Paste the exact value the scanner should have masked "
491
+ f"(blank to cancel).{RESET}\n")
492
+ value = self._input(f" {BOLD}→ Value: {RESET}")
493
+ if not value:
494
+ return
495
+ subtype = self._input(
496
+ f" {BOLD}→ Token type{RESET} {DIM}(e.g. API_KEY, PASSWORD; "
497
+ f"default SECRET): {RESET}") or "SECRET"
498
+ print(f"\n {DIM}Teaching + re-scanning…{RESET}")
499
+ try:
500
+ fresh, hits, added = self._teach_callback(value, subtype)
501
+ except Exception as e:
502
+ print(f" {RED}✗ Teach failed: {e}{RESET}")
503
+ self._input(" Press Enter to continue...")
504
+ return
505
+ if fresh is not None:
506
+ self.detections = fresh
507
+ self._regroup()
508
+ tok = subtype.upper().replace(" ", "_")
509
+ occ = f"{hits} occurrence" + ("s" if hits != 1 else "")
510
+ if hits and added:
511
+ print(f" {GREEN}✓ Found {occ} — {added} added to this review, "
512
+ f"masked as a {tok} token.{RESET}")
513
+ elif hits:
514
+ print(f" {YELLOW}⚠ Found {occ} in the source, but no new detection "
515
+ f"was added{RESET} — it may be inside an already-masked secret "
516
+ f"or in a skipped file.")
517
+ else:
518
+ print(f" {YELLOW}⚠ Not found in the scanned code{RESET} — "
519
+ f"check the value (whitespace/quotes?). Saved for future scans.")
520
+ self._input(" Press Enter to continue...")
521
+
522
+ # ── LEVEL 1: Type Selection ─────────────────────────────────────────────
523
+
524
+ def show_type_summary(self):
525
+ while True:
526
+ self._clear()
527
+ total = len(self.detections)
528
+ reviewed = sum(1 for d in self.detections if d.decision is not None)
529
+ approved = sum(1 for d in self.detections if d.decision is True)
530
+ rejected = sum(1 for d in self.detections if d.decision is False)
531
+
532
+ print(f"\n{BOLD}{'═' * 70}{RESET}")
533
+ print(f"{BOLD} LocalMask Pro — Interactive Review{RESET} {MAGENTA}[{self.mode}]{RESET}")
534
+ print(f"{BOLD}{'═' * 70}{RESET}")
535
+ print(f" {DIM}Repo:{RESET} {self.repo_label}")
536
+ print(f" {DIM}Total:{RESET} {total} detections across {len(self.types)} types")
537
+ print(f" {DIM}Progress:{RESET} {reviewed}/{total} "
538
+ f"{GREEN}✓{approved}{RESET} {RED}✗{rejected}{RESET} "
539
+ f"{DIM}→{total - reviewed} pending{RESET}")
540
+ print(f"{BOLD}{'═' * 70}{RESET}\n")
541
+
542
+ for i, t in enumerate(self.type_order, 1):
543
+ dets = self.types[t]
544
+ count = len(dets)
545
+ avg_conf = sum(d.confidence for d in dets) / count
546
+ done = sum(1 for d in dets if d.decision is not None)
547
+ bar = self._conf_bar(avg_conf)
548
+
549
+ status = ""
550
+ if done == count:
551
+ status = f" {GREEN}[done]{RESET}"
552
+ elif done > 0:
553
+ status = f" {YELLOW}[{done}/{count}]{RESET}"
554
+
555
+ print(f" {BOLD}[{i:>2}]{RESET} {CYAN}{t:<30}{RESET} "
556
+ f"{count:>3} detections {bar} {avg_conf:.0%}{status}")
557
+
558
+ teach_hint = " [T]each a missed secret" if self._teach_callback else ""
559
+ print(f"\n {DIM}Navigation: [1-{len(self.type_order)}] select type "
560
+ f"[S]ave{teach_hint} [Q]uit{RESET}")
561
+ print()
562
+
563
+ choice = self._input(f" {BOLD}→ Choose: {RESET}")
564
+
565
+ if choice.lower() == "q":
566
+ self._save_decisions()
567
+ where = "locally" if self.mode == "LOCAL" else "to service"
568
+ print(f"\n {GREEN}✓ Decisions saved {where}{RESET}\n")
569
+ return
570
+ if choice.lower() == "s":
571
+ self._save_decisions()
572
+ print(f"\n {GREEN}✓ Saved!{RESET}")
573
+ self._input(" Press Enter to continue...")
574
+ continue
575
+ if choice.lower() == "t" and self._teach_callback:
576
+ self._teach_missed()
577
+ continue
578
+
579
+ try:
580
+ idx = int(choice) - 1
581
+ if 0 <= idx < len(self.type_order):
582
+ self.show_instances(self.type_order[idx])
583
+ except ValueError:
584
+ pass
585
+
586
+ # ── LEVEL 2: Instance List ──────────────────────────────────────────────
587
+
588
+ def show_instances(self, det_type):
589
+ dets = self.types[det_type]
590
+
591
+ while True:
592
+ self._clear()
593
+ count = len(dets)
594
+ done = sum(1 for d in dets if d.decision is not None)
595
+ avg_conf = sum(d.confidence for d in dets) / count
596
+
597
+ print(f"\n{BOLD}{'═' * 70}{RESET}")
598
+ print(f"{BOLD} {det_type}{RESET} — {count} detections "
599
+ f"avg {avg_conf:.0%} conf [{done}/{count} reviewed]")
600
+ print(f"{'═' * 70}{RESET}\n")
601
+
602
+ print(f" {DIM}{'#':<4} {'St':>2} {'File:Line':<38} {'Conf':>5} Note{RESET}")
603
+ print(f" {'─' * 64}")
604
+
605
+ for i, d in enumerate(dets, 1):
606
+ icon = self._status_icon(d)
607
+ cc = self._conf_color(d.confidence)
608
+ link = self._file_link(d.file, d.line)
609
+
610
+ note = ""
611
+ if d.decision is True:
612
+ note = f"{GREEN}(approved){RESET}"
613
+ elif d.decision is False:
614
+ note = f"{RED}(rejected){RESET}"
615
+ elif d.confidence < 0.6:
616
+ note = f"{YELLOW}[LOW!]{RESET}"
617
+
618
+ print(f" {i:<4} {icon} {link} "
619
+ f"{cc}{d.confidence:.0%}{RESET} {note}")
620
+
621
+ print(f"\n {DIM}Navigation: [J]ump to # [A]pprove all [R]eject all [B]ack{RESET}")
622
+ print()
623
+
624
+ choice = self._input(f" {BOLD}→ Action: {RESET}")
625
+
626
+ if choice.lower() in ("b", "q"):
627
+ return
628
+ if choice.lower() == "a":
629
+ for d in dets:
630
+ if d.decision is None:
631
+ d.decision = True
632
+ d.timestamp = datetime.now().isoformat()
633
+ print(f" {GREEN}✓ Approved all pending in {det_type}{RESET}")
634
+ self._input(" Press Enter to continue...")
635
+ continue
636
+ if choice.lower() == "r":
637
+ for d in dets:
638
+ if d.decision is None:
639
+ d.decision = False
640
+ d.timestamp = datetime.now().isoformat()
641
+ print(f" {RED}✗ Rejected all pending in {det_type}{RESET}")
642
+ self._input(" Press Enter to continue...")
643
+ continue
644
+ if choice.lower() == "j":
645
+ num = self._input(f" {BOLD}→ Jump to #: {RESET}")
646
+ try:
647
+ idx = int(num) - 1
648
+ if 0 <= idx < len(dets):
649
+ self.review_instance(dets, idx)
650
+ except ValueError:
651
+ pass
652
+ continue
653
+
654
+ try:
655
+ idx = int(choice) - 1
656
+ if 0 <= idx < len(dets):
657
+ self.review_instance(dets, idx)
658
+ except ValueError:
659
+ pass
660
+
661
+ # ── LEVEL 3: Single Instance Review ─────────────────────────────────────
662
+
663
+ def review_instance(self, dets, idx):
664
+ while True:
665
+ if idx < 0:
666
+ idx = 0
667
+ if idx >= len(dets):
668
+ return
669
+
670
+ d = dets[idx]
671
+ self._clear()
672
+
673
+ icon = self._status_icon(d)
674
+ print(f"\n{BOLD}{'═' * 70}{RESET}")
675
+ print(f" {BOLD}#{d.det_type}{RESET} {icon} "
676
+ f"{DIM}#{idx + 1}/{len(dets)}{RESET} — {CYAN}{d.id}{RESET}")
677
+ print(f"{'═' * 70}{RESET}")
678
+ print(f" {DIM}File:{RESET} {self._file_link(d.file, d.line)}")
679
+ print(f" {DIM}Pattern:{RESET} {d.det_type}")
680
+ cc = self._conf_color(d.confidence)
681
+ print(f" {DIM}Confidence:{RESET} {cc}{d.confidence:.0%}{RESET}")
682
+ print()
683
+
684
+ self._show_context(d, context_lines=3)
685
+
686
+ print(f"\n {DIM}Will be masked as:{RESET} {GREEN}{d.token}{RESET}")
687
+
688
+ if d.decision is not None:
689
+ status = f"{GREEN}APPROVED{RESET}" if d.decision else f"{RED}REJECTED{RESET}"
690
+ print(f" {DIM}Current status:{RESET} {status}")
691
+ if d.reason:
692
+ print(f" {DIM}Reason:{RESET} {d.reason}")
693
+
694
+ print(f"\n {DIM}Actions:{RESET}")
695
+ print(f" {GREEN}[Y]{RESET}es approve {RED}[N]{RESET}o reject "
696
+ f"[E]dit confidence [R]eason")
697
+ print(f" [C]ontext expand "
698
+ f"[→] Next [←] Prev [B]ack")
699
+ print()
700
+
701
+ choice = self._input(
702
+ f" {BOLD}Instance {idx + 1}/{len(dets)} in {d.det_type} → {RESET}"
703
+ )
704
+
705
+ if choice.lower() == "y":
706
+ d.decision = True
707
+ d.timestamp = datetime.now().isoformat()
708
+ print(f" {GREEN}✓ Approved{RESET}")
709
+ idx += 1
710
+ if idx >= len(dets):
711
+ self._input(" All done in this type! Press Enter...")
712
+ return
713
+ continue
714
+
715
+ if choice.lower() == "n":
716
+ d.decision = False
717
+ d.timestamp = datetime.now().isoformat()
718
+ reason = self._input(f" {DIM}Reason (optional): {RESET}")
719
+ if reason:
720
+ d.reason = reason
721
+ print(f" {RED}✗ Rejected{RESET}")
722
+ idx += 1
723
+ if idx >= len(dets):
724
+ self._input(" All done in this type! Press Enter...")
725
+ return
726
+ continue
727
+
728
+ if choice.lower() == "e":
729
+ new_conf = self._input(f" {DIM}New confidence (0-100): {RESET}")
730
+ try:
731
+ val = int(new_conf) / 100.0
732
+ if 0 <= val <= 1:
733
+ d.confidence_override = val
734
+ d.confidence = val
735
+ print(f" {YELLOW}Confidence set to {val:.0%}{RESET}")
736
+ except ValueError:
737
+ pass
738
+ continue
739
+
740
+ if choice.lower() == "r":
741
+ reason = self._input(f" {DIM}Reason: {RESET}")
742
+ if reason:
743
+ d.reason = reason
744
+ print(f" {DIM}Reason saved{RESET}")
745
+ continue
746
+
747
+ if choice.lower() == "c":
748
+ self._clear()
749
+ print(f"\n {BOLD}{d.file}:{d.line}{RESET} — expanded context\n")
750
+ self._show_context(d, context_lines=20)
751
+ self._input("\n Press Enter to go back...")
752
+ continue
753
+
754
+ if choice in ("", "l", "right", "]"):
755
+ idx += 1
756
+ if idx >= len(dets):
757
+ self._input(" End of list! Press Enter...")
758
+ return
759
+ continue
760
+
761
+ if choice in ("h", "left", "["):
762
+ idx -= 1
763
+ continue
764
+
765
+ if choice.lower() in ("b", "q"):
766
+ return
767
+
768
+ def _show_context(self, det, context_lines=3):
769
+ """Show source code context from API metadata."""
770
+ max_width = 60
771
+
772
+ if not det.context_lines:
773
+ print(f" {DIM}(no context available){RESET}")
774
+ return
775
+
776
+ lines_data = det.context_lines
777
+ print(f" ┌{'─' * max_width}┐")
778
+ for cl in lines_data:
779
+ lineno = cl["lineno"]
780
+ text = cl["text"]
781
+ if len(text) > max_width - 8:
782
+ text = text[:max_width - 11] + "..."
783
+ if cl.get("is_target"):
784
+ marker = f" {RED}← DETECTED{RESET}"
785
+ print(f" │{BG_RED}{WHITE}{lineno:>4} │ {text:<{max_width - 7}}{RESET}│{marker}")
786
+ else:
787
+ print(f" │{DIM}{lineno:>4}{RESET} │ {text:<{max_width - 7}}│")
788
+ print(f" └{'─' * max_width}┘")
789
+
790
+ def run(self):
791
+ """Entry point — start the review."""
792
+ if not self.detections:
793
+ print(f"\n {GREEN}✓ No detections to review — repo looks clean!{RESET}\n")
794
+ return
795
+ self.show_type_summary()
796
+
797
+
798
+ # ═══════════════════════════════════════════════════════════════════════════════
799
+ # STATUS DISPLAY
800
+ # ═══════════════════════════════════════════════════════════════════════════════
801
+
802
+ STATUS_COLORS = {
803
+ "draft": DIM, "submitted": YELLOW, "under_review": BLUE,
804
+ "approved": GREEN, "rejected": RED, "published": GREEN,
805
+ }
806
+
807
+ def print_scan_list(repos):
808
+ """Print a table of all scans."""
809
+ print(f"\n{BOLD}{'═' * 80}{RESET}")
810
+ print(f"{BOLD} LocalMask Pro — Scanned Repositories{RESET}")
811
+ print(f"{BOLD}{'═' * 80}{RESET}\n")
812
+
813
+ if not repos:
814
+ print(f" {DIM}No scans found. Run: localmask scan <repo-url>{RESET}\n")
815
+ return
816
+
817
+ print(f" {DIM}{'Scan ID':<28} {'Status':<14} {'Dets':>5} {'By':<12} Repo{RESET}")
818
+ print(f" {'─' * 76}")
819
+
820
+ for r in repos:
821
+ sc = STATUS_COLORS.get(r["status"], DIM)
822
+ repo_short = r["repo_url"]
823
+ if len(repo_short) > 30:
824
+ repo_short = "..." + repo_short[-27:]
825
+ print(f" {r['scan_id']:<28} {sc}{r['status']:<14}{RESET} "
826
+ f"{r['detection_count']:>5} {r.get('submitted_by', ''):<12} {repo_short}")
827
+
828
+ print(f"\n {DIM}Use: localmask review <scan_id>{RESET}\n")
829
+
830
+
831
+ def print_scan_detail(scan):
832
+ """Print detailed status of a single scan."""
833
+ sc = STATUS_COLORS.get(scan["status"], DIM)
834
+
835
+ print(f"\n{BOLD}{'═' * 70}{RESET}")
836
+ print(f"{BOLD} Scan: {scan['scan_id']}{RESET}")
837
+ print(f"{BOLD}{'═' * 70}{RESET}")
838
+ print(f" {DIM}Repo:{RESET} {scan['repo_url']}")
839
+ print(f" {DIM}Status:{RESET} {sc}{BOLD}{scan['status'].upper()}{RESET}")
840
+ print(f" {DIM}Submitted:{RESET} {scan.get('submitted_by', '-')}")
841
+ print(f" {DIM}Reviewed:{RESET} {scan.get('reviewed_by') or '-'}")
842
+ if scan.get("review_comment"):
843
+ print(f" {DIM}Comment:{RESET} {scan['review_comment']}")
844
+ print(f" {DIM}Created:{RESET} {scan['created_at'][:19]}")
845
+
846
+ stats = scan.get("summary_stats", {})
847
+ print(f"\n {DIM}Files:{RESET} {stats.get('total_files', 0)}")
848
+ print(f" {DIM}Detections:{RESET} {stats.get('total_detections', 0)}")
849
+ print(f" {DIM}Reviewed:{RESET} {scan.get('decisions_made', 0)}/{stats.get('total_detections', 0)}")
850
+
851
+ by_type = stats.get("by_type", {})
852
+ if by_type:
853
+ print(f"\n {BOLD}By Type:{RESET}")
854
+ for t, count in sorted(by_type.items(), key=lambda x: -x[1]):
855
+ bar = "█" * min(count * 2, 30)
856
+ print(f" {t:<30} {count:>3} {YELLOW}{bar}{RESET}")
857
+
858
+ # Status workflow
859
+ steps = ["draft", "submitted", "under_review", "approved"]
860
+ current = scan["status"]
861
+ print(f"\n {BOLD}Workflow:{RESET}")
862
+ line = " "
863
+ for i, step in enumerate(steps):
864
+ if step == current:
865
+ line += f" {BG_BLUE}{WHITE} {step.upper()} {RESET}"
866
+ elif steps.index(current) > i if current in steps else False:
867
+ line += f" {GREEN}✓ {step}{RESET}"
868
+ else:
869
+ line += f" {DIM}○ {step}{RESET}"
870
+ if i < len(steps) - 1:
871
+ line += f" {DIM}→{RESET}"
872
+ if current == "rejected":
873
+ line = f" {RED}✗ REJECTED{RESET}"
874
+ elif current == "published":
875
+ line += f" {DIM}→{RESET} {GREEN}✓ PUBLISHED{RESET}"
876
+ print(line)
877
+ print(f"\n{'═' * 70}\n")
878
+
879
+
880
+ # ═══════════════════════════════════════════════════════════════════════════════
881
+ # INIT — set up LocalMask in a repo
882
+ # ═══════════════════════════════════════════════════════════════════════════════
883
+
884
+ def _find_localmask_paths() -> tuple[str, str]:
885
+ """Auto-detect mcp_server.py and python paths."""
886
+ # Check common locations
887
+ candidates = [
888
+ os.path.dirname(os.path.abspath(__file__)), # same dir as cli.py
889
+ os.path.expanduser("~/.localmask"),
890
+ "/usr/local/lib/localmask",
891
+ ]
892
+ mcp_path = ""
893
+ for d in candidates:
894
+ p = os.path.join(d, "mcp_server.py")
895
+ if os.path.isfile(p):
896
+ mcp_path = p
897
+ break
898
+
899
+ python_path = ""
900
+ if mcp_path:
901
+ venv_python = os.path.join(os.path.dirname(mcp_path), "venv", "bin", "python3")
902
+ if os.path.isfile(venv_python):
903
+ python_path = venv_python
904
+ if not python_path:
905
+ python_path = sys.executable
906
+
907
+ return mcp_path, python_path
908
+
909
+
910
+ def _gitignore_entries() -> list[str]:
911
+ """Lines to add to .gitignore for LocalMask config files."""
912
+ return [
913
+ "# LocalMask Pro — IDE integration (local only, not published)",
914
+ ".mcp.json",
915
+ "CLAUDE.md",
916
+ ".vscode/mcp.json",
917
+ ".github/copilot-instructions.md",
918
+ "localmask.sh",
919
+ ]
920
+
921
+
922
+ def _ensure_gitignore(repo_dir: str):
923
+ """Add LocalMask entries to .gitignore if not already present."""
924
+ gitignore_path = os.path.join(repo_dir, ".gitignore")
925
+ existing = ""
926
+ if os.path.isfile(gitignore_path):
927
+ existing = open(gitignore_path).read()
928
+
929
+ entries = _gitignore_entries()
930
+ to_add = [e for e in entries if e not in existing and not e.startswith("#")]
931
+ # Also check the comment
932
+ if entries[0] not in existing:
933
+ to_add = entries # add all including the comment
934
+
935
+ if not to_add:
936
+ return False # already present
937
+
938
+ with open(gitignore_path, "a") as f:
939
+ if existing and not existing.endswith("\n"):
940
+ f.write("\n")
941
+ f.write("\n".join(to_add) + "\n")
942
+ return True
943
+
944
+
945
+ _CLAUDE_MD_TEMPLATE = """\
946
+ # MANDATORY: Use LocalMask MCP Tools
947
+
948
+ This project has LocalMask Pro configured as an MCP server.
949
+
950
+ ## CRITICAL SECURITY RULES
951
+
952
+ 1. **NEVER read .env, config/settings.py, or any file that may contain secrets directly.** You MUST use the LocalMask MCP tools instead.
953
+ 2. **NEVER run grep, cat, or any command to search for secrets, passwords, API keys, or credentials.** The MCP tools handle this securely.
954
+ 3. **NEVER display real secret values.** Only display masked tokens like `~[PASSWORD_0]~`.
955
+ 4. **ALWAYS use the MCP tools listed below** for any task involving secrets, scanning, reviewing, publishing, or security.
956
+
957
+ The MCP tools ensure that real secret values NEVER leave this machine. If you read files directly or run grep, you will expose production secrets to the cloud.
958
+
959
+ ## MCP Tools Available
960
+
961
+ ### Scanning
962
+ - **`scan_repo`** — Scan for secrets. Use `source: "."` for current repo.
963
+ - **`sync_repo`** — Re-scan after git changes. Preserves existing reviews.
964
+
965
+ ### Interactive Review (preferred — runs locally, no tokens consumed)
966
+ - **`get_review_queue`** — Overview of all detection types with pending/approved/rejected counts and confidence.
967
+ - **`open_review_ui`** — Tells the developer to run the interactive terminal reviewer. No secrets leave the machine.
968
+ - **`review_detection`** — Approve or reject one detection by `det_id` (for quick fixes after terminal review).
969
+
970
+ ### Bulk Operations
971
+ - **`get_detections`** — Full detection summary with samples (grouped by type).
972
+ - **`bulk_review`** — Approve/reject multiple detections at once. Pass `decisions` as JSON string.
973
+
974
+ ### Other
975
+ - **`get_file_masked`** — View a file with secrets replaced by tokens.
976
+ - **`teach_value`** — Teach a missed secret. Pass `scan_id`, `value`, `action` ("mask"), `subtype`.
977
+ - **`submit_for_review`** — Submit scan for security team review.
978
+ - **`approve_scan`** — Security manager approves the scan.
979
+ - **`publish_masked_repo`** — Push masked repo to git remote. Needs `scan_id`, `target_url`, `username`.
980
+ - **`ask_about_scan`** — Ask AI about the code. AI only sees masked content.
981
+
982
+ ## Workflow — ALWAYS follow these steps in order
983
+
984
+ 1. **Scan**: `scan_repo(source=".")` → get `scan_id`
985
+ 2. **Review queue**: `get_review_queue(scan_id)` → show the developer a summary table of detection types
986
+ 3. **Interactive review** (in terminal — no tokens, no cloud):
987
+ - Call `open_review_ui(scan_id)` — this gives the developer a terminal command to run
988
+ - Tell the developer to run the command in their terminal
989
+ - The terminal UI lets them approve/reject each detection with full code context
990
+ - When they're done, call `get_review_queue(scan_id)` to check results
991
+ - If the developer spotted a missed secret, use `teach_value` then `sync_repo` to re-scan
992
+ 4. **Preview**: Use `get_file_masked` on key files to show the developer the masked output.
993
+ 5. **Publish**: Only after developer confirms → `publish_masked_repo`
994
+
995
+ **IMPORTANT**: NEVER skip the interactive review. NEVER approve-all and publish in one shot. Show each detection and let the developer decide.
996
+
997
+ ## Privacy Model
998
+
999
+ - Scanning runs 100% locally (regex + NER + local LLM)
1000
+ - Real values are stripped from all MCP responses
1001
+ - AI only sees masked tokens like `~[DB_CONNECTION_URL_3]~`
1002
+ - Token vault stays on the developer's machine
1003
+ - `ask_about_scan` responses are rehydrated locally
1004
+ """
1005
+
1006
+ _COPILOT_MD_TEMPLATE = """\
1007
+ # LocalMask Pro — Copilot Instructions
1008
+
1009
+ ## MANDATORY: Use MCP Tools
1010
+
1011
+ This project has LocalMask Pro installed as an MCP server. You MUST call the MCP tools directly — NEVER use shell commands, CLI wrappers, or try to scan files yourself.
1012
+
1013
+ When the user asks about secrets, scanning, reviewing, publishing, or masking — use the MCP tools listed below. Do NOT run `./localmask.sh`, `python cli.py`, or any shell command as a fallback.
1014
+
1015
+ ## Tool Quick Reference
1016
+
1017
+ | Action | MCP Tool | Required Args |
1018
+ |--------|----------|---------------|
1019
+ | Scan repo | `scan_repo` | `source: "."` |
1020
+ | Review queue overview | `get_review_queue` | `scan_id` |
1021
+ | Launch terminal reviewer | `open_review_ui` | `scan_id` |
1022
+ | Approve/reject one | `review_detection` | `scan_id`, `det_id`, `decision` |
1023
+ | List all detections | `get_detections` | `scan_id` |
1024
+ | View masked file | `get_file_masked` | `scan_id`, `path` |
1025
+ | Bulk approve/reject | `bulk_review` | `scan_id`, `decisions` (JSON string) |
1026
+ | Submit to security team | `submit_for_review` | `scan_id` |
1027
+ | Publish masked repo | `publish_masked_repo` | `scan_id`, `target_url`, `username` |
1028
+ | Ask AI about code | `ask_about_scan` | `scan_id`, `question` |
1029
+ | Re-scan after changes | `sync_repo` | `scan_id` |
1030
+
1031
+ ## Full Workflow
1032
+
1033
+ 1. `scan_repo(source=".")` — scan current workspace (100% local)
1034
+ 2. `get_review_queue(scan_id)` — show detection types overview
1035
+ 3. `open_review_ui(scan_id)` → developer reviews in terminal → `get_review_queue()` to check results
1036
+ 4. `submit_for_review(scan_id)` — submit for security team review
1037
+ 5. `publish_masked_repo(scan_id, target_url, username)` — push masked repo
1038
+ 6. `ask_about_scan(scan_id, question)` — AI sees only masked code
1039
+
1040
+ ## Privacy Architecture
1041
+
1042
+ All scanning runs locally. Real values are stripped from all responses.
1043
+ AI only sees masked tokens. Token vault stays on developer's machine.
1044
+ """
1045
+
1046
+
1047
+ def cmd_init(args):
1048
+ """Initialize LocalMask in the current repo."""
1049
+ repo_dir = os.getcwd()
1050
+
1051
+ # Check if we're in a git repo
1052
+ if not os.path.isdir(os.path.join(repo_dir, ".git")):
1053
+ print(f" {RED}✗ Not a git repository. Run 'localmask init' from your repo root.{RESET}")
1054
+ sys.exit(1)
1055
+
1056
+ # Resolve paths
1057
+ mcp_path = args.mcp_path
1058
+ python_path = args.python_path
1059
+ if not mcp_path or not python_path:
1060
+ auto_mcp, auto_python = _find_localmask_paths()
1061
+ mcp_path = mcp_path or auto_mcp
1062
+ python_path = python_path or auto_python
1063
+
1064
+ if not mcp_path:
1065
+ print(f" {RED}✗ Could not find mcp_server.py. Use --mcp-path to specify.{RESET}")
1066
+ sys.exit(1)
1067
+
1068
+ server_url = args.server
1069
+ org = args.org
1070
+ do_claude = args.claude_code and not args.no_claude_code
1071
+ do_copilot = args.copilot and not args.no_copilot
1072
+ do_vscode = args.vscode and not args.no_vscode
1073
+
1074
+ print(f"\n {BOLD}LocalMask Pro — Init{RESET}")
1075
+ print(f" {DIM}Repository:{RESET} {repo_dir}")
1076
+ print(f" {DIM}MCP server:{RESET} {mcp_path}")
1077
+ print(f" {DIM}Python:{RESET} {python_path}")
1078
+ print(f" {DIM}Server URL:{RESET} {server_url}")
1079
+ print(f" {DIM}Org:{RESET} {org}")
1080
+ print()
1081
+
1082
+ created = []
1083
+
1084
+ # 1. .mcp.json (Claude Code)
1085
+ if do_claude:
1086
+ mcp_json = {
1087
+ "mcpServers": {
1088
+ "localmask": {
1089
+ "command": python_path,
1090
+ "args": [mcp_path],
1091
+ "env": {
1092
+ "LOCALMASK_SERVER": server_url,
1093
+ "LOCALMASK_ORG": org,
1094
+ }
1095
+ }
1096
+ }
1097
+ }
1098
+ path = os.path.join(repo_dir, ".mcp.json")
1099
+ with open(path, "w") as f:
1100
+ json.dump(mcp_json, f, indent=2)
1101
+ f.write("\n")
1102
+ created.append(".mcp.json")
1103
+
1104
+ # 2. CLAUDE.md
1105
+ if do_claude:
1106
+ path = os.path.join(repo_dir, "CLAUDE.md")
1107
+ with open(path, "w") as f:
1108
+ f.write(_CLAUDE_MD_TEMPLATE)
1109
+ created.append("CLAUDE.md")
1110
+
1111
+ # 3. .vscode/mcp.json
1112
+ if do_vscode:
1113
+ vscode_dir = os.path.join(repo_dir, ".vscode")
1114
+ os.makedirs(vscode_dir, exist_ok=True)
1115
+ vscode_mcp = {
1116
+ "servers": {
1117
+ "localmask": {
1118
+ "type": "stdio",
1119
+ "command": python_path,
1120
+ "args": [mcp_path],
1121
+ "env": {
1122
+ "LOCALMASK_SERVER": server_url,
1123
+ "LOCALMASK_ORG": org,
1124
+ }
1125
+ }
1126
+ }
1127
+ }
1128
+ path = os.path.join(vscode_dir, "mcp.json")
1129
+ with open(path, "w") as f:
1130
+ json.dump(vscode_mcp, f, indent=2)
1131
+ f.write("\n")
1132
+ created.append(".vscode/mcp.json")
1133
+
1134
+ # Also ensure settings.json has MCP access
1135
+ settings_path = os.path.join(vscode_dir, "settings.json")
1136
+ settings = {}
1137
+ if os.path.isfile(settings_path):
1138
+ try:
1139
+ settings = json.loads(open(settings_path).read())
1140
+ except Exception:
1141
+ pass
1142
+ if "chat.mcp.access" not in settings:
1143
+ settings["chat.mcp.access"] = "all"
1144
+ with open(settings_path, "w") as f:
1145
+ json.dump(settings, f, indent=2)
1146
+ f.write("\n")
1147
+ if ".vscode/settings.json" not in created:
1148
+ created.append(".vscode/settings.json")
1149
+
1150
+ # 4. .github/copilot-instructions.md
1151
+ if do_copilot:
1152
+ gh_dir = os.path.join(repo_dir, ".github")
1153
+ os.makedirs(gh_dir, exist_ok=True)
1154
+ path = os.path.join(gh_dir, "copilot-instructions.md")
1155
+ with open(path, "w") as f:
1156
+ f.write(_COPILOT_MD_TEMPLATE)
1157
+ created.append(".github/copilot-instructions.md")
1158
+
1159
+ # 5. Update .gitignore
1160
+ if _ensure_gitignore(repo_dir):
1161
+ created.append(".gitignore (updated)")
1162
+
1163
+ # Print results
1164
+ for f in created:
1165
+ print(f" {GREEN}✓{RESET} {f}")
1166
+
1167
+ print(f"\n {GREEN}✓ LocalMask initialized{RESET}")
1168
+ print(f" {DIM}Config files are in .gitignore — they stay local, never published.{RESET}")
1169
+ print(f"\n {BOLD}Next steps:{RESET}")
1170
+ print(f" 1. Reload VS Code ({DIM}Cmd+Shift+P → Developer: Reload Window{RESET})")
1171
+ print(f" 2. Open the chat panel and ask: {CYAN}Scan this repo for secrets{RESET}")
1172
+ print()
1173
+
1174
+
1175
+ # ═══════════════════════════════════════════════════════════════════════════════
1176
+ # MAIN — command routing
1177
+ # ═══════════════════════════════════════════════════════════════════════════════
1178
+
1179
+ def main():
1180
+ parser = argparse.ArgumentParser(
1181
+ prog="localmask",
1182
+ description="LocalMask Pro CLI — service client for secret detection and masking",
1183
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1184
+ epilog="""
1185
+ Setup:
1186
+ localmask init Initialize LocalMask in current repo
1187
+ localmask connect <url> Connect to LocalMask service
1188
+ localmask store-token <token> Store git token securely, get credential_id
1189
+ localmask set-key <provider> <key> Set AI API key (anthropic, openai, gemini)
1190
+ localmask activate <license-key> Activate a Pro/Enterprise license
1191
+ localmask license Show current license tier and usage
1192
+
1193
+ Workflow:
1194
+ localmask scan <repo-url> Scan repo via service
1195
+ localmask status [scan_id] List scans or show scan detail
1196
+ localmask review <scan_id> Interactive review via service
1197
+ localmask submit <scan_id> Submit for security approval
1198
+ localmask approve-all <scan_id> Approve all detections + submit
1199
+ localmask publish <scan_id> <url> Publish masked repo to a git remote
1200
+ localmask ask <scan_id> [question] Ask AI about a scan (interactive or single question)
1201
+
1202
+ Git Integration:
1203
+ localmask sync <scan_id> Re-scan after git updates, preserve tokens
1204
+ localmask hook <scan_id> [-r path] Install git hook for auto-sync on commits
1205
+
1206
+ All scanning happens server-side. The CLI never has access to secret values.
1207
+ The AI only sees masked content — real secrets are replaced with tokens.
1208
+ """,
1209
+ )
1210
+ sub = parser.add_subparsers(dest="command")
1211
+
1212
+ # init
1213
+ init_p = sub.add_parser("init", help="Initialize LocalMask in current repo")
1214
+ init_p.add_argument("--server", default="http://localhost:8000",
1215
+ help="LocalMask server URL (default: http://localhost:8000)")
1216
+ init_p.add_argument("--org", default="my-organization",
1217
+ help="Organization ID (default: my-organization)")
1218
+ init_p.add_argument("--mcp-path", default="",
1219
+ help="Path to mcp_server.py (auto-detected if not set)")
1220
+ init_p.add_argument("--python-path", default="",
1221
+ help="Path to Python with MCP deps (auto-detected if not set)")
1222
+ init_p.add_argument("--claude-code", action="store_true", default=True,
1223
+ help="Generate .mcp.json + CLAUDE.md (default: on)")
1224
+ init_p.add_argument("--no-claude-code", action="store_true",
1225
+ help="Skip Claude Code config")
1226
+ init_p.add_argument("--copilot", action="store_true", default=True,
1227
+ help="Generate .github/copilot-instructions.md (default: on)")
1228
+ init_p.add_argument("--no-copilot", action="store_true",
1229
+ help="Skip Copilot config")
1230
+ init_p.add_argument("--vscode", action="store_true", default=True,
1231
+ help="Generate .vscode/mcp.json (default: on)")
1232
+ init_p.add_argument("--no-vscode", action="store_true",
1233
+ help="Skip VS Code MCP config")
1234
+
1235
+ # connect
1236
+ conn_p = sub.add_parser("connect", help="Connect to LocalMask service")
1237
+ conn_p.add_argument("url", help="Service URL (e.g. https://localmask-pro-xxx.run.app)")
1238
+
1239
+ # store-token
1240
+ store_p = sub.add_parser("store-token",
1241
+ help="Store a git token (free: encrypted locally; typed hidden)")
1242
+ store_p.add_argument("token", nargs="?", default="",
1243
+ help="Git PAT. Omit to type it hidden (recommended — "
1244
+ "an argument leaks into shell history).")
1245
+ store_p.add_argument("--label", default="", help="Optional label for this credential")
1246
+
1247
+ # scan
1248
+ scan_p = sub.add_parser("scan", help="Scan a repo for secrets")
1249
+ scan_p.add_argument("repo_url", help="Git repo URL (https://github.com/org/repo)")
1250
+ scan_p.add_argument("--sensitivity", "-s", default="standard",
1251
+ choices=["minimal", "standard", "strict"])
1252
+ scan_p.add_argument("--credential-id", "-c", default="",
1253
+ help="Credential ID from store-token (for private repos)")
1254
+ scan_p.add_argument("--org", default="default", help="Organization ID")
1255
+
1256
+ # status
1257
+ status_p = sub.add_parser("status", help="Show scan status")
1258
+ status_p.add_argument("scan_id", nargs="?", help="Scan ID (omit to list all)")
1259
+ status_p.add_argument("--org", default="", help="Filter by org")
1260
+
1261
+ # review
1262
+ review_p = sub.add_parser("review", help="Interactive hierarchical review")
1263
+ review_p.add_argument("scan_id", help="Scan ID from scan command")
1264
+
1265
+ # review-local (reads from file, no HTTP server needed)
1266
+ review_local_p = sub.add_parser("review-local",
1267
+ help="Interactive review from local file (MCP mode)")
1268
+ review_local_p.add_argument("scan_id", help="Scan ID from scan command")
1269
+
1270
+ # submit
1271
+ submit_p = sub.add_parser("submit", help="Submit scan for security approval")
1272
+ submit_p.add_argument("scan_id", help="Scan ID")
1273
+
1274
+ # approve-all
1275
+ approve_p = sub.add_parser("approve-all", help="Approve all detections + submit")
1276
+ approve_p.add_argument("scan_id", help="Scan ID")
1277
+
1278
+ # grant-ai — give an AI its own read-only access to the masked mirror
1279
+ grant_p = sub.add_parser(
1280
+ "grant-ai",
1281
+ help="Give an AI read-only access to the masked mirror (deploy key)")
1282
+ grant_p.add_argument("scan_id", help="Scan ID whose published mirror to grant on")
1283
+ grant_p.add_argument("--repo", default="",
1284
+ help="Masked repo URL (default: the scan's publish target)")
1285
+ grant_p.add_argument("--title", default="localmask-ai-readonly",
1286
+ help="Deploy-key title shown on GitHub")
1287
+ grant_p.add_argument("--collaborator", default="",
1288
+ help="Grant an existing account (the AI's own bot user) "
1289
+ "read-only — transfers NOTHING to the AI")
1290
+ grant_p.add_argument("--public", action="store_true",
1291
+ help="Make the masked mirror public (tokens only) — the "
1292
+ "AI reads it with no credential at all")
1293
+
1294
+ # set-key
1295
+ # config — read/set local settings (e.g. the publish approval policy)
1296
+ cfg_p = sub.add_parser("config",
1297
+ help="View or change settings (e.g. publish-policy)")
1298
+ cfg_p.add_argument("key", nargs="?", default="",
1299
+ help="Setting name, e.g. publish-policy")
1300
+ cfg_p.add_argument("value", nargs="?", default="",
1301
+ help="New value, e.g. review | auto")
1302
+
1303
+ key_p = sub.add_parser("set-key",
1304
+ help="Save an AI provider API key (free: encrypted locally; typed hidden)")
1305
+ key_p.add_argument("provider",
1306
+ choices=["anthropic", "openai", "gemini", "grok", "xai",
1307
+ "groq", "together", "meta", "openrouter"],
1308
+ help="AI provider name")
1309
+ key_p.add_argument("key", nargs="?", default="",
1310
+ help="API key. Omit to type it hidden (recommended — an "
1311
+ "argument leaks into shell history).")
1312
+
1313
+ # teach — add a secret the scanner missed (or ignore a false positive),
1314
+ # persisted so it applies on every future scan/sync of this repo.
1315
+ teach_p = sub.add_parser(
1316
+ "teach", help="Teach a missed secret (or ignore a false positive)")
1317
+ teach_p.add_argument("scan_id", help="Scan ID to apply the value to")
1318
+ teach_p.add_argument("value", help="The exact secret value the scanner missed")
1319
+ teach_p.add_argument("--subtype", "-s", default="SECRET",
1320
+ help="Token type/name for the masked value (e.g. API_KEY)")
1321
+ teach_p.add_argument("--allow", action="store_true",
1322
+ help="Instead of masking, mark this value as a false "
1323
+ "positive (never mask it)")
1324
+
1325
+ # publish
1326
+ pub_p = sub.add_parser("publish", help="Publish masked repo to a git remote")
1327
+ pub_p.add_argument("scan_id", help="Scan ID of an approved scan")
1328
+ pub_p.add_argument("target_url", help="Target git repo URL to push masked code to")
1329
+ pub_p.add_argument("--token", "-t", default="",
1330
+ help="Git PAT for pushing (or use --credential-id)")
1331
+ pub_p.add_argument("--credential-id", "-c", default="",
1332
+ help="Credential ID from store-token")
1333
+ pub_p.add_argument("--username", "-u", default="", help="Git username")
1334
+ pub_p.add_argument("--force", "-f", action="store_true",
1335
+ help="Publish even if the review isn't approved (one-off)")
1336
+ pub_p.add_argument("--yes", "-y", action="store_true",
1337
+ help="Create the masked repo without asking, if missing")
1338
+ pub_p.add_argument("--public", action="store_true",
1339
+ help="If creating the masked repo, make it public (default: private)")
1340
+
1341
+ # activate
1342
+ act_p = sub.add_parser("activate", help="Activate a LocalMask Pro license key")
1343
+ act_p.add_argument("license_key", help="License key (format: LM-TIER-key-checksum)")
1344
+
1345
+ # license
1346
+ sub.add_parser("license", help="Show current license tier and usage")
1347
+
1348
+ # sync
1349
+ sync_p = sub.add_parser("sync", help="Re-scan repo after git updates, preserve tokens & decisions")
1350
+ sync_p.add_argument("scan_id", help="Scan ID of a previously scanned repo")
1351
+ sync_p.add_argument("--credential-id", "-c", default="", help="Credential ID for private repos")
1352
+
1353
+ # hook
1354
+ hook_p = sub.add_parser("hook", help="Install git hook for auto-sync on commits")
1355
+ hook_p.add_argument("scan_id", help="Scan ID to sync on each commit")
1356
+ hook_p.add_argument("--repo", "-r", default=".", help="Path to git repo (default: current dir)")
1357
+ hook_p.add_argument("--type", "-t", default="post-commit",
1358
+ choices=["post-commit", "pre-push"],
1359
+ help="Hook type (default: post-commit)")
1360
+
1361
+ # ask
1362
+ ask_p = sub.add_parser("ask", help="Ask any AI about a scan (masked content only, your key)")
1363
+ ask_p.add_argument("scan_id", help="Scan ID to ask about")
1364
+ ask_p.add_argument("question", nargs="?", default="",
1365
+ help="Question (omit for interactive mode)")
1366
+ ask_p.add_argument("--provider", "-p", default="anthropic",
1367
+ help="anthropic | openai | gemini | grok | groq | together | meta | openrouter | dry")
1368
+ ask_p.add_argument("--api-key", "-k", default="",
1369
+ help="Your provider API key (or env: LOCALMASK_AI_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY / XAI_API_KEY)")
1370
+ ask_p.add_argument("--base-url", default="",
1371
+ help="Custom OpenAI-compatible endpoint (self-host, OpenRouter, etc.)")
1372
+ ask_p.add_argument("--model", "-m", default="",
1373
+ help="Model name (defaults per provider)")
1374
+ ask_p.add_argument("--source", "-s", default="memory",
1375
+ choices=["memory", "git"],
1376
+ help="Read from platform memory (default) or published masked git repo")
1377
+ ask_p.add_argument("--git-url", default="",
1378
+ help="Git URL of published masked repo (for --source git)")
1379
+
1380
+ # Local, no-key: turn an AI's tokenized answer back into real values.
1381
+ rehy_p = sub.add_parser("rehydrate",
1382
+ help="Rehydrate ~[TOKEN]~ back to real values (local, no AI key)")
1383
+ rehy_p.add_argument("scan_id", help="Scan ID whose vault to use")
1384
+ rehy_p.add_argument("file", nargs="?", default="",
1385
+ help="File to rehydrate (default: read stdin)")
1386
+
1387
+ maskt_p = sub.add_parser("mask-text",
1388
+ help="Mask secrets in arbitrary text using a scan's vault (local)")
1389
+ maskt_p.add_argument("scan_id", help="Scan ID whose vault to use")
1390
+ maskt_p.add_argument("file", nargs="?", default="",
1391
+ help="File to mask (default: read stdin)")
1392
+
1393
+ exp_p = sub.add_parser("export",
1394
+ help="Write the masked repo to a local folder the AI can read (no keys/permissions)")
1395
+ exp_p.add_argument("scan_id", help="Scan ID to export")
1396
+ exp_p.add_argument("output_dir", help="Folder to write masked files into")
1397
+
1398
+ proxy_p = sub.add_parser("proxy",
1399
+ help="Run the local AI proxy (prompt firewall, Pro)")
1400
+ proxy_p.add_argument("--port", type=int, default=None,
1401
+ help="Listen port (default 8100 or proxy.yaml)")
1402
+ proxy_p.add_argument("--use-model", action="store_true",
1403
+ help="Use the local AI model for masking (higher recall, slower)")
1404
+
1405
+ args = parser.parse_args()
1406
+
1407
+ if not args.command:
1408
+ parser.print_help()
1409
+ sys.exit(1)
1410
+
1411
+ if args.command == "proxy":
1412
+ if args.port:
1413
+ os.environ["LOCALMASK_PROXY_PORT"] = str(args.port)
1414
+ if getattr(args, "use_model", False):
1415
+ os.environ["LOCALMASK_PROXY_USE_MODEL"] = "true"
1416
+ try:
1417
+ from localmask.proxy import main as proxy_main
1418
+ except ImportError:
1419
+ print("The AI proxy is a Pro feature and isn't included in this "
1420
+ "edition.\nUpgrade at https://localmaskpro.com")
1421
+ sys.exit(1)
1422
+ proxy_main()
1423
+ return
1424
+
1425
+ # ── rehydrate (local, no key) ────────────────────────────────────────────
1426
+ if args.command == "rehydrate":
1427
+ from localmask.state import _new_session, _get_or_load_scan
1428
+ from localmask.masking import _rehydrate
1429
+ scan = _get_or_load_scan(args.scan_id)
1430
+ if not scan:
1431
+ print(f"{RED}Scan not found: {args.scan_id}{RESET}"); sys.exit(1)
1432
+ session = _new_session(scan["repo_url"], temp=False) # hydrates vault
1433
+ text = open(args.file).read() if args.file else sys.stdin.read()
1434
+ sys.stdout.write(_rehydrate(session, text))
1435
+ return
1436
+
1437
+ # ── mask-text (local) ────────────────────────────────────────────────────
1438
+ if args.command == "mask-text":
1439
+ from localmask.state import _new_session, _get_or_load_scan
1440
+ from localmask.engine import _scan_file
1441
+ scan = _get_or_load_scan(args.scan_id)
1442
+ if not scan:
1443
+ print(f"{RED}Scan not found: {args.scan_id}{RESET}"); sys.exit(1)
1444
+ session = _new_session(scan["repo_url"], temp=False)
1445
+ text = open(args.file).read() if args.file else sys.stdin.read()
1446
+ sys.stdout.write(_scan_file(session, text, "input.txt")["masked"])
1447
+ return
1448
+
1449
+ # ── export masked repo to a local folder (AI reads it, no keys) ──────────
1450
+ if args.command == "export":
1451
+ from localmask.state import _new_session, _get_or_load_scan
1452
+ from localmask.engine import _scan_dir
1453
+ from localmask.gitops import _should_publish
1454
+ scan = _get_or_load_scan(args.scan_id)
1455
+ if not scan:
1456
+ print(f"{RED}Scan not found: {args.scan_id}{RESET}"); sys.exit(1)
1457
+ session = _new_session(scan["repo_url"], temp=False)
1458
+ src = scan["repo_url"]
1459
+ src = src if os.path.isabs(src) else os.path.join(os.getcwd(), src)
1460
+ _scan_dir(session, src)
1461
+ out = os.path.expanduser(args.output_dir)
1462
+ written = 0
1463
+ for rel, d in session["files"].items():
1464
+ if not _should_publish(rel):
1465
+ continue
1466
+ path = os.path.join(out, rel)
1467
+ os.makedirs(os.path.dirname(path) or out, exist_ok=True)
1468
+ with open(path, "w") as f:
1469
+ f.write(d["masked"])
1470
+ written += 1
1471
+ print(f" {GREEN}✓ Exported {written} masked files to {out}{RESET}")
1472
+ print(f" {DIM}Point your AI tool / agent at this folder — it reads the "
1473
+ f"masked code with no keys, no repo permissions, no secrets.{RESET}")
1474
+ return
1475
+
1476
+ # ── ask (local, bring-your-own-key, any provider) ────────────────────────
1477
+ if args.command == "ask" and not _is_connected():
1478
+ _local_ask(args)
1479
+ return
1480
+
1481
+ # ── hosted-only commands: give a clear local-mode message (not a raw
1482
+ # "not connected" error) plus the free-edition alternative. ────────────
1483
+ if args.command == "store-token" and not _is_connected():
1484
+ import getpass
1485
+ token = (args.token or "").strip()
1486
+ if not token or token == "-":
1487
+ try:
1488
+ token = getpass.getpass(" Paste git token (hidden): ").strip()
1489
+ except (EOFError, KeyboardInterrupt):
1490
+ token = ""
1491
+ if not token:
1492
+ print(f" {RED}✗ No token provided.{RESET}")
1493
+ return
1494
+ from localmask.vault_store import store_local_credential
1495
+ cid = store_local_credential(token, args.label or "")
1496
+ print(f" {GREEN}✓ Stored (encrypted, local — 0600 SQLite){RESET}")
1497
+ print(f" {DIM}Credential ID:{RESET} {CYAN}{cid}{RESET}")
1498
+ print(f" {DIM}Use it:{RESET} localmask publish <scan> <url> -c {cid}")
1499
+ print(f" {DIM}or:{RESET} localmask scan <private-url> -c {cid}")
1500
+ if token.startswith("ghp_") and args.token:
1501
+ print(f" {YELLOW}⚠ You passed the token on the command line — it's "
1502
+ f"now in your shell history.{RESET} Run store-token with no "
1503
+ f"argument next time so it's typed hidden; and rotate this one.")
1504
+ return
1505
+
1506
+ # set-key (free): store the AI provider key encrypted locally so `ask`
1507
+ # reuses it. Typed hidden so it never lands in shell history.
1508
+ if args.command == "set-key" and not _is_connected():
1509
+ import getpass
1510
+ provider = args.provider.lower()
1511
+ key = (args.key or "").strip()
1512
+ passed_as_arg = bool(key)
1513
+ if not key or key == "-":
1514
+ try:
1515
+ key = getpass.getpass(f" Paste {provider} API key (hidden): ").strip()
1516
+ except (EOFError, KeyboardInterrupt):
1517
+ key = ""
1518
+ if not key:
1519
+ print(f" {RED}✗ No key provided.{RESET}")
1520
+ return
1521
+ from localmask.vault_store import set_local_ai_key
1522
+ set_local_ai_key(provider, key)
1523
+ print(f" {GREEN}✓ Saved {provider} key (encrypted, local — 0600 SQLite){RESET}")
1524
+ print(f" {DIM}Use it:{RESET} localmask ask <scan> \"...\" --provider {provider}")
1525
+ if passed_as_arg:
1526
+ print(f" {YELLOW}⚠ You passed the key on the command line — it's now "
1527
+ f"in your shell history.{RESET} Run `set-key {provider}` with no "
1528
+ f"key next time (typed hidden), and rotate this one.")
1529
+ return
1530
+
1531
+ # config (local): view or set settings, e.g. the publish approval policy.
1532
+ if args.command == "config":
1533
+ key = (args.key or "").replace("-", "_")
1534
+ if not key:
1535
+ cfg = _load_config()
1536
+ print(f" {BOLD}Settings{RESET} {DIM}({CONFIG_FILE}){RESET}")
1537
+ print(f" publish-policy = {CYAN}{_publish_policy()}{RESET} "
1538
+ f"{DIM}(review = approve before publishing · auto = "
1539
+ f"auto-approve + auto-publish){RESET}")
1540
+ return
1541
+ if key == "publish_policy":
1542
+ val = (args.value or "").lower()
1543
+ if val not in ("review", "auto"):
1544
+ print(f" {RED}✗ publish-policy must be 'review' or 'auto'.{RESET}")
1545
+ return
1546
+ cfg = _load_config(); cfg["publish_policy"] = val; _save_config(cfg)
1547
+ print(f" {GREEN}✓ publish-policy = {val}{RESET}")
1548
+ if val == "auto":
1549
+ print(f" {DIM}Detections auto-approve; sync/hook auto-republish "
1550
+ f"the masked mirror on every change.{RESET}")
1551
+ else:
1552
+ print(f" {DIM}Publishing now requires an approved review "
1553
+ f"(localmask review / approve-all).{RESET}")
1554
+ return
1555
+ print(f" {RED}✗ Unknown setting '{args.key}'.{RESET} Known: publish-policy")
1556
+ return
1557
+
1558
+ # grant-ai (local): give an AI its OWN read-only access to the masked mirror
1559
+ # via a per-repo SSH deploy key. LocalMask never shares your git token.
1560
+ if args.command == "grant-ai":
1561
+ from server_core import _get_or_load_scan
1562
+ from localmask.gitops import add_readonly_deploy_key, parse_git_target
1563
+ url = args.repo
1564
+ if not url:
1565
+ sc = _get_or_load_scan(args.scan_id)
1566
+ url = sc.get("publish_target", "") if sc else ""
1567
+ if not url:
1568
+ print(f" {RED}✗ No masked repo URL.{RESET} Publish the mirror first "
1569
+ f"(`localmask publish {args.scan_id} <url>`) or pass --repo.")
1570
+ return
1571
+ # ── Mode: public — no credential transferred at all (masked = safe) ──
1572
+ if args.public:
1573
+ from localmask.gitops import set_repo_public
1574
+ print(f"\n {CYAN}[LOCAL]{RESET} Making the masked mirror public "
1575
+ f"(tokens only, no secrets)…", flush=True)
1576
+ r = set_repo_public(url)
1577
+ if not r.get("ok"):
1578
+ print(f" {RED}✗ {r['message']}{RESET}"); return
1579
+ print(f" {GREEN}✓ Public.{RESET} Any AI reads it with "
1580
+ f"{BOLD}no credential{RESET} — nothing is transferred.")
1581
+ print(f" {DIM}The AI just clones:{RESET} git clone {r['https_url']}")
1582
+ return
1583
+ # ── Mode: collaborator — the AI uses its OWN account; nothing sent ────
1584
+ if args.collaborator:
1585
+ from localmask.gitops import add_readonly_collaborator
1586
+ print(f"\n {CYAN}[LOCAL]{RESET} Granting {CYAN}{args.collaborator}"
1587
+ f"{RESET} read-only on the masked mirror…", flush=True)
1588
+ r = add_readonly_collaborator(url, args.collaborator)
1589
+ if not r.get("ok"):
1590
+ print(f" {RED}✗ {r['message']}{RESET}"); return
1591
+ print(f" {GREEN}✓ Read-only access granted to "
1592
+ f"{args.collaborator}{RESET} {DIM}(their account, this repo "
1593
+ f"only){RESET}")
1594
+ print(f" {DIM}Nothing was transferred to the AI{RESET} — it signs in "
1595
+ f"with its own credentials and clones:")
1596
+ print(f" git clone {r['ssh_url']}")
1597
+ print(f" {DIM}(the invite may need one accept by that account.){RESET}")
1598
+ return
1599
+ # ── Default: dedicated per-repo read-only deploy key ─────────────────
1600
+ tgt = parse_git_target(url)
1601
+ safe = f"{tgt[1]}-{tgt[2]}" if tgt else "ai"
1602
+ key_path = os.path.join(CONFIG_DIR, "keys", f"{safe}-ai")
1603
+ print(f"\n {CYAN}[LOCAL]{RESET} Granting an AI read-only access to "
1604
+ f"{CYAN}{url}{RESET}…", flush=True)
1605
+ res = add_readonly_deploy_key(url, key_path, args.title)
1606
+ if not res.get("ok"):
1607
+ print(f" {RED}✗ {res['message']}{RESET}")
1608
+ return
1609
+ note = "already granted" if res.get("already") else "granted"
1610
+ print(f" {GREEN}✓ Read-only access {note}{RESET} "
1611
+ f"{DIM}(a per-repo deploy key — not your git token){RESET}")
1612
+ print(f" {DIM}Scope:{RESET} this key works {BOLD}only{RESET} for "
1613
+ f"{CYAN}{res['owner']}/{res['repo']}{RESET} — it can't touch your "
1614
+ f"other repos or your account, and it's read-only.\n")
1615
+ print(f" {BOLD}Hand your AI/agent these two things:{RESET}")
1616
+ print(f" {DIM}1) its private key:{RESET} {CYAN}{res['key_path']}{RESET}")
1617
+ print(f" {DIM}2) the clone command:{RESET}")
1618
+ print(f" {CYAN}{res['clone_cmd']}{RESET}\n")
1619
+ print(f" {DIM}The AI clones the masked mirror (tokens only, no secrets), "
1620
+ f"reads it, and authenticates as itself.{RESET}")
1621
+ print(f" {DIM}Keep it fresh:{RESET} localmask sync {args.scan_id} "
1622
+ f"{DIM}(re-masks + re-pushes; the AI does git pull){RESET}")
1623
+ print(f" {DIM}Revoke anytime:{RESET} gh repo deploy-key list --repo "
1624
+ f"{res['owner']}/{res['repo']} → gh repo deploy-key delete <id>\n")
1625
+ return
1626
+
1627
+ # approve-all (local): approve every detection and mark the scan approved,
1628
+ # so it can be published under the 'review' policy.
1629
+ if args.command == "approve-all" and not _is_connected():
1630
+ from server_core import _get_or_load_scan
1631
+ if not _get_or_load_scan(args.scan_id):
1632
+ print(f" {RED}✗ Scan not found: {args.scan_id}{RESET}")
1633
+ return
1634
+ scan = _approve_all_local(args.scan_id)
1635
+ n = len(scan.get("detections", []))
1636
+ print(f" {GREEN}✓ Approved all {n} detections{RESET} — scan is "
1637
+ f"{BOLD}approved{RESET} and ready to publish.")
1638
+ print(f" {DIM}Publish:{RESET} localmask publish {args.scan_id} <git-url>")
1639
+ return
1640
+
1641
+ if args.command == "submit" and not _is_connected():
1642
+ _hint = {
1643
+ "submit": "The submit/approval workflow is a hosted "
1644
+ "(Pro/Team) feature. In free, approve locally with "
1645
+ "`localmask review <scan>` or `localmask approve-all "
1646
+ "<scan>`.",
1647
+ }[args.command]
1648
+ print(f" {YELLOW}'{args.command}' needs the hosted service "
1649
+ f"(Pro/Team).{RESET}\n {_hint}")
1650
+ return
1651
+
1652
+ # ── connect ─────────────────────────────────────────────────────────────
1653
+ if args.command == "init":
1654
+ cmd_init(args)
1655
+
1656
+ elif args.command == "connect":
1657
+ url = args.url.rstrip("/")
1658
+ print(f" {DIM}Connecting to {url}...{RESET}", flush=True)
1659
+ client = ServiceClient(url)
1660
+ resp = client.health()
1661
+ if resp.get("status") == "ok":
1662
+ _save_config({"service_url": url})
1663
+ print(f" {GREEN}✓ Connected to LocalMask service{RESET}")
1664
+ print(f" {DIM}Saved to {CONFIG_FILE}{RESET}\n")
1665
+ else:
1666
+ print(f" {RED}✗ Service responded but health check failed{RESET}\n")
1667
+
1668
+ # ── store-token ─────────────────────────────────────────────────────────
1669
+ elif args.command == "store-token":
1670
+ client = _get_client()
1671
+ print(f" {DIM}Storing credential on service...{RESET}", flush=True)
1672
+ result = client.store_credential(args.token, args.label)
1673
+ cred_id = result["credential_id"]
1674
+ expires = result.get("expires_in", "1 hour")
1675
+
1676
+ # Save credential_id locally for convenience
1677
+ cfg = _load_config()
1678
+ cfg["credential_id"] = cred_id
1679
+ _save_config(cfg)
1680
+
1681
+ print(f" {GREEN}✓ Token stored securely{RESET}")
1682
+ print(f" {DIM}Credential ID:{RESET} {CYAN}{cred_id}{RESET}")
1683
+ print(f" {DIM}Expires in:{RESET} {expires}")
1684
+ print(f" {DIM}Saved to:{RESET} {CONFIG_FILE}")
1685
+ print(f"\n {DIM}Use with scan:{RESET}")
1686
+ print(f" localmask scan <repo-url> -c {cred_id}")
1687
+ print(f" localmask scan <repo-url> {DIM}(auto-uses saved credential){RESET}\n")
1688
+
1689
+ # ── scan ────────────────────────────────────────────────────────────────
1690
+ elif args.command == "scan":
1691
+ # Local, in-process scan (no server needed) — the default for the free
1692
+ # edition. Used whenever not connected to a service, or the target is a
1693
+ # local directory.
1694
+ if not _is_connected() or os.path.isdir(os.path.expanduser(args.repo_url)):
1695
+ print(f"\n {CYAN}[LOCAL]{RESET} Scanning on this machine "
1696
+ f"(nothing leaves your computer)...", flush=True)
1697
+ eng = _local_engine()
1698
+ # Private remote? Resolve a locally-stored credential into a token.
1699
+ token = ""
1700
+ if args.credential_id:
1701
+ from localmask.vault_store import get_local_credential
1702
+ token = get_local_credential(args.credential_id) or ""
1703
+ if not token:
1704
+ print(f" {RED}✗ Unknown credential id "
1705
+ f"'{args.credential_id}'.{RESET} Run "
1706
+ f"`localmask store-token` first.")
1707
+ return
1708
+ try:
1709
+ result = eng.scan_repo(
1710
+ source=os.path.expanduser(args.repo_url),
1711
+ sensitivity=args.sensitivity, org=args.org, token=token)
1712
+ except Exception as e:
1713
+ print(f" {RED}✗ Scan failed:{RESET} {str(e)[:300]}")
1714
+ if "clone failed" in str(e).lower() or "authentication" in str(e).lower():
1715
+ print(f" {DIM}For a private repo, add a token: "
1716
+ f"localmask store-token → scan <url> -c <cred_id>.{RESET}")
1717
+ return
1718
+ scan_id = result["scan_id"]
1719
+ stats = result.get("summary_stats", {})
1720
+ print(f" {GREEN}✓ Scan complete{RESET}\n")
1721
+ print(f" {BOLD}Scan ID:{RESET} {CYAN}{scan_id}{RESET}")
1722
+ print(f" {DIM}Files:{RESET} {stats.get('total_files', 0)}")
1723
+ print(f" {DIM}Detections:{RESET} {RED}{stats.get('total_detections', 0)}{RESET}")
1724
+ by_type = stats.get("by_type", {})
1725
+ if by_type:
1726
+ print(f"\n {BOLD}By type:{RESET}")
1727
+ for t, count in sorted(by_type.items(), key=lambda x: -x[1]):
1728
+ print(f" {t:28s} {RED}{count}{RESET}")
1729
+ print(f"\n {DIM}Next:{RESET} localmask status · "
1730
+ f"localmask publish {scan_id} --target <git-url>")
1731
+ return
1732
+
1733
+ client = _get_client()
1734
+
1735
+ # Resolve credential_id: explicit flag > saved config
1736
+ cred_id = args.credential_id
1737
+ if not cred_id:
1738
+ cred_id = _load_config().get("credential_id", "")
1739
+
1740
+ print(f"\n {MAGENTA}[SERVICE]{RESET} Scanning via cloud service...", flush=True)
1741
+ if cred_id:
1742
+ print(f" {DIM}Using credential:{RESET} {cred_id}")
1743
+
1744
+ result = client.scan(
1745
+ repo_url=args.repo_url, credential_id=cred_id,
1746
+ org=args.org, sensitivity=args.sensitivity,
1747
+ )
1748
+ scan_id = result["scan_id"]
1749
+ stats = result.get("summary_stats", {})
1750
+
1751
+ print(f" {GREEN}✓ Scan complete{RESET}\n")
1752
+ print(f" {BOLD}Scan ID:{RESET} {CYAN}{scan_id}{RESET}")
1753
+ print(f" {DIM}Files:{RESET} {stats.get('total_files', 0)}")
1754
+ print(f" {DIM}Detections:{RESET} {RED}{stats.get('total_detections', 0)}{RESET}")
1755
+ print(f" {DIM}Status:{RESET} draft")
1756
+
1757
+ by_type = stats.get("by_type", {})
1758
+ if by_type:
1759
+ print(f"\n {BOLD}By Type:{RESET}")
1760
+ for t, count in sorted(by_type.items(), key=lambda x: -x[1]):
1761
+ bar = "█" * min(count * 2, 30)
1762
+ print(f" {t:<30} {count:>3} {YELLOW}{bar}{RESET}")
1763
+
1764
+ print(f"\n {DIM}Next steps:{RESET}")
1765
+ print(f" localmask review {scan_id}")
1766
+ print(f" localmask approve-all {scan_id}")
1767
+ print(f" localmask submit {scan_id}\n")
1768
+
1769
+ # ── status ──────────────────────────────────────────────────────────────
1770
+ elif args.command == "status":
1771
+ if not _is_connected():
1772
+ eng = _local_engine()
1773
+ if args.scan_id:
1774
+ print_scan_detail(eng.get_scan(args.scan_id))
1775
+ else:
1776
+ scans = eng.list_scans(args.org)
1777
+ if not scans:
1778
+ print(f" {DIM}No local scans yet. Run: localmask scan <path>{RESET}")
1779
+ for s in scans:
1780
+ print(f" {CYAN}{s['scan_id']}{RESET} {s.get('repo_url','')} "
1781
+ f"{RED}{s.get('detection_count', 0)}{RESET} detections")
1782
+ return
1783
+ client = _get_client()
1784
+ if args.scan_id:
1785
+ scan = client.get_scan(args.scan_id)
1786
+ print_scan_detail(scan)
1787
+ else:
1788
+ data = client.list_repos(args.org)
1789
+ print_scan_list(data.get("repos", []))
1790
+
1791
+ # ── review ──────────────────────────────────────────────────────────────
1792
+ elif args.command == "review":
1793
+ scan_id = args.scan_id
1794
+ if not _is_connected():
1795
+ eng = _local_engine()
1796
+
1797
+ def _load_dets():
1798
+ data = eng.get_detections(scan_id)
1799
+ out = []
1800
+ for d in data.get("detections", []):
1801
+ det = Detection(
1802
+ det_id=d["det_id"], token=d["token"], det_type=d["type"],
1803
+ line=d.get("line", 0), confidence=d.get("confidence", 0.9),
1804
+ file=d.get("file", ""),
1805
+ context_lines=d.get("context_lines", []))
1806
+ if d.get("decision") == "approved":
1807
+ det.decision = True
1808
+ elif d.get("decision") == "rejected":
1809
+ det.decision = False
1810
+ out.append(det)
1811
+ return data.get("repo_url", scan_id), out
1812
+
1813
+ repo_url, dets = _load_dets()
1814
+ print(f" {GREEN}✓ {len(dets)} detections loaded (local){RESET}\n")
1815
+
1816
+ def save_local(detections):
1817
+ decisions = {det.id: ("approved" if det.decision else "rejected")
1818
+ for det in detections if det.decision is not None}
1819
+ if decisions:
1820
+ eng.review_detections(scan_id, decisions)
1821
+ print(f" {GREEN}✓ Saved {len(decisions)} decisions locally{RESET}")
1822
+ # If every detection has now been decided, mark the scan approved
1823
+ # so it can be published under the 'review' policy.
1824
+ from server_core import _get_or_load_scan, _persist_scan
1825
+ sc = _get_or_load_scan(scan_id)
1826
+ if sc:
1827
+ pend = _pending_count(sc)
1828
+ if pend == 0 and sc.get("detections"):
1829
+ sc["status"] = "approved"
1830
+ sc["reviewed_by"] = "developer"
1831
+ _persist_scan(scan_id)
1832
+ print(f" {GREEN}✓ All reviewed — scan approved for "
1833
+ f"publish.{RESET}")
1834
+ elif pend:
1835
+ print(f" {YELLOW}{pend} still pending{RESET} — approve "
1836
+ f"them (or `localmask approve-all {scan_id}`) "
1837
+ f"before publishing.")
1838
+
1839
+ def teach_local(value, subtype):
1840
+ # Persist to the repo lexicon, then re-scan in place so the
1841
+ # newly-masked value appears. Returns (refreshed_dets, hits,
1842
+ # added) — hits = occurrences in the code, added = new detections.
1843
+ from server_core import _get_or_load_scan
1844
+ from localmask.vault_store import get_vault_store, repo_id_for
1845
+ sc = _get_or_load_scan(scan_id)
1846
+ src = sc.get("repo_url", "") if sc else ""
1847
+ hits = _count_occurrences(src, value)
1848
+ get_vault_store(repo_id_for(src)).set_lexicon(
1849
+ value, action="mask", subtype=subtype)
1850
+ added = 0
1851
+ if hits:
1852
+ added = eng.sync_repo(
1853
+ scan_id, auto_republish=False).get("new_detections", 0)
1854
+ _, fresh = _load_dets()
1855
+ return fresh, hits, added
1856
+
1857
+ HierarchicalReviewer(dets, repo_url, save_callback=save_local,
1858
+ mode="LOCAL", teach_callback=teach_local).run()
1859
+ return
1860
+
1861
+ client = _get_client()
1862
+ print(f"\n {MAGENTA}[SERVICE]{RESET} Fetching detections for {scan_id}...", flush=True)
1863
+ data = client.get_detections(scan_id)
1864
+ repo_url = data.get("repo_url", scan_id)
1865
+
1866
+ dets = []
1867
+ for d in data["detections"]:
1868
+ det = Detection(
1869
+ det_id=d["det_id"], token=d["token"],
1870
+ det_type=d["type"], line=d["line"],
1871
+ confidence=d["confidence"], file=d["file"],
1872
+ context_lines=d.get("context_lines", []),
1873
+ )
1874
+ if d.get("decision") == "approved":
1875
+ det.decision = True
1876
+ elif d.get("decision") == "rejected":
1877
+ det.decision = False
1878
+ dets.append(det)
1879
+
1880
+ print(f" {GREEN}✓ {len(dets)} detections loaded{RESET}\n")
1881
+
1882
+ def save_to_service(detections):
1883
+ decisions = {}
1884
+ for det in detections:
1885
+ if det.decision is not None:
1886
+ decisions[det.id] = "approved" if det.decision else "rejected"
1887
+ if decisions:
1888
+ result = client.post_review(scan_id, decisions)
1889
+ print(f" {GREEN}✓ Synced {result.get('updated', 0)} decisions to service{RESET}")
1890
+
1891
+ reviewer = HierarchicalReviewer(
1892
+ dets, repo_url, save_callback=save_to_service
1893
+ )
1894
+ reviewer.run()
1895
+
1896
+ # ── review-local (file-based, no HTTP server needed) ─────────────────
1897
+ elif args.command == "review-local":
1898
+ scan_id = args.scan_id
1899
+ review_dir = os.path.expanduser("~/.localmask/reviews")
1900
+ review_file = os.path.join(review_dir, f"{scan_id}.json")
1901
+
1902
+ if not os.path.exists(review_file):
1903
+ print(f" {RED}✗ Review file not found: {review_file}{RESET}")
1904
+ print(f" {DIM}Make sure open_review_ui was called in the MCP server first.{RESET}")
1905
+ sys.exit(1)
1906
+
1907
+ print(f"\n {CYAN}[LOCAL]{RESET} Loading detections from file...", flush=True)
1908
+ with open(review_file) as f:
1909
+ data = json.load(f)
1910
+
1911
+ repo_url = data.get("repo_url", scan_id)
1912
+ dets = []
1913
+ for d in data["detections"]:
1914
+ det = Detection(
1915
+ det_id=d["det_id"], token=d["token"],
1916
+ det_type=d["type"], line=d["line"],
1917
+ confidence=d["confidence"], file=d["file"],
1918
+ context_lines=d.get("context_lines", []),
1919
+ )
1920
+ if d.get("decision") == "approved":
1921
+ det.decision = True
1922
+ elif d.get("decision") == "rejected":
1923
+ det.decision = False
1924
+ dets.append(det)
1925
+
1926
+ print(f" {GREEN}✓ {len(dets)} detections loaded{RESET}\n")
1927
+
1928
+ def save_to_file(detections):
1929
+ """Write decisions back to the review file for MCP sync_review to pick up."""
1930
+ for det in detections:
1931
+ for d in data["detections"]:
1932
+ if d["det_id"] == det.id:
1933
+ if det.decision is True:
1934
+ d["decision"] = "approved"
1935
+ elif det.decision is False:
1936
+ d["decision"] = "rejected"
1937
+ break
1938
+ with open(review_file, "w") as f:
1939
+ json.dump(data, f, indent=2)
1940
+ reviewed = sum(1 for det in detections if det.decision is not None)
1941
+ print(f" {GREEN}✓ Saved {reviewed} decisions to {review_file}{RESET}")
1942
+
1943
+ reviewer = HierarchicalReviewer(
1944
+ dets, repo_url, save_callback=save_to_file
1945
+ )
1946
+ reviewer.run()
1947
+
1948
+ # ── submit ──────────────────────────────────────────────────────────────
1949
+ elif args.command == "submit":
1950
+ client = _get_client()
1951
+ print(f" {DIM}Submitting {args.scan_id} for security review...{RESET}", flush=True)
1952
+ result = client.submit(args.scan_id)
1953
+ print(f" {GREEN}✓ Submitted!{RESET}")
1954
+ print(f" {DIM}Status:{RESET} {YELLOW}submitted{RESET} — waiting for security team approval")
1955
+ print(f"\n {DIM}The security manager can now review this in the dashboard.{RESET}\n")
1956
+
1957
+ # ── approve-all ─────────────────────────────────────────────────────────
1958
+ elif args.command == "approve-all":
1959
+ client = _get_client()
1960
+ print(f" {DIM}Fetching detections...{RESET}", flush=True)
1961
+ data = client.get_detections(args.scan_id)
1962
+ dets = data["detections"]
1963
+
1964
+ # Approve all pending
1965
+ decisions = {}
1966
+ for d in dets:
1967
+ if d["decision"] == "pending":
1968
+ decisions[d["det_id"]] = "approved"
1969
+
1970
+ if decisions:
1971
+ result = client.post_review(args.scan_id, decisions)
1972
+ print(f" {GREEN}✓ Approved {result.get('updated', 0)} detections{RESET}")
1973
+ else:
1974
+ print(f" {DIM}All detections already reviewed{RESET}")
1975
+
1976
+ # Submit for security approval
1977
+ print(f" {DIM}Submitting for security review...{RESET}", flush=True)
1978
+ result = client.submit(args.scan_id)
1979
+ print(f" {GREEN}✓ Submitted!{RESET}")
1980
+ print(f" {DIM}Status:{RESET} {YELLOW}submitted{RESET} — waiting for security team")
1981
+ print(f"\n {DIM}Next: security manager reviews in the dashboard{RESET}\n")
1982
+
1983
+ # ── set-key ─────────────────────────────────────────────────────────
1984
+ elif args.command == "set-key":
1985
+ client = _get_client()
1986
+ print(f" {DIM}Setting {args.provider} API key...{RESET}", flush=True)
1987
+ client.set_key(args.provider, args.key)
1988
+ print(f" {GREEN}✓ {args.provider} key saved on server{RESET}")
1989
+
1990
+ # Show which keys are configured
1991
+ keys = client.get_keys()
1992
+ configured = [k for k, v in keys.items() if v]
1993
+ if configured:
1994
+ print(f" {DIM}Configured:{RESET} {', '.join(configured)}")
1995
+ print(f"\n {DIM}Now you can use AI chat:{RESET}")
1996
+ print(f" localmask ask <scan_id> --provider {args.provider}\n")
1997
+
1998
+ # ── publish ─────────────────────────────────────────────────────────
1999
+ elif args.command == "publish":
2000
+ # Local, in-process publish (free edition, no server): scan → masked
2001
+ # git mirror the AI can read.
2002
+ if not _is_connected():
2003
+ eng = _local_engine()
2004
+ print(f"\n {CYAN}[LOCAL]{RESET} Publishing masked copy to "
2005
+ f"{args.target_url}...", flush=True)
2006
+ # The CLI runs per-process, so the in-memory session (masked file
2007
+ # contents) from an earlier `scan` is gone. Rebuild it from the
2008
+ # source repo, preserving token mappings, before pushing.
2009
+ from server_core import SESSIONS, _get_or_load_scan
2010
+ _sc = _get_or_load_scan(args.scan_id)
2011
+ if _sc and _sc.get("session_key") not in SESSIONS:
2012
+ eng.sync_repo(args.scan_id, auto_republish=False)
2013
+ _sc = _get_or_load_scan(args.scan_id)
2014
+ # ── Approval gate ────────────────────────────────────────────────
2015
+ # Under the default 'review' policy, the masked mirror only goes out
2016
+ # after the detections have been reviewed & approved. 'auto' skips
2017
+ # the gate (and auto-approves). --force overrides for a one-off.
2018
+ policy = _publish_policy()
2019
+ if _sc is not None and policy == "auto":
2020
+ _approve_all_local(args.scan_id)
2021
+ _sc = _get_or_load_scan(args.scan_id)
2022
+ elif _sc is not None and not getattr(args, "force", False) \
2023
+ and not _is_approved(_sc):
2024
+ pend = _pending_count(_sc)
2025
+ print(f" {YELLOW}⚠ Not published — this scan isn't approved "
2026
+ f"yet.{RESET}")
2027
+ print(f" {DIM}{pend} detection(s) still need review.{RESET} "
2028
+ f"Approve, then publish:")
2029
+ print(f" {CYAN}localmask review {args.scan_id}{RESET} "
2030
+ f"{DIM}# review each, or…{RESET}")
2031
+ print(f" {CYAN}localmask approve-all {args.scan_id}{RESET} "
2032
+ f"{DIM}# approve everything{RESET}")
2033
+ print(f" {DIM}Prefer no gate? {CYAN}localmask config "
2034
+ f"publish-policy auto{RESET}{DIM}, or one-off "
2035
+ f"{CYAN}--force{RESET}{DIM}.{RESET}")
2036
+ return
2037
+ # Remember the target so `sync` re-publishes on future changes.
2038
+ # Persist it — the CLI is per-process, so this must survive to disk.
2039
+ if _sc is not None:
2040
+ from server_core import _persist_scan
2041
+ _sc["publish_target"] = args.target_url
2042
+ if args.credential_id:
2043
+ _sc["credential_id"] = args.credential_id
2044
+ _persist_scan(args.scan_id)
2045
+ # Resolve a locally-stored credential id (from `store-token`) into a
2046
+ # token, so the token never has to be passed on the command line.
2047
+ token = args.token
2048
+ if not token and args.credential_id:
2049
+ from localmask.vault_store import get_local_credential
2050
+ token = get_local_credential(args.credential_id) or ""
2051
+ if not token:
2052
+ print(f" {RED}✗ Unknown credential id "
2053
+ f"'{args.credential_id}'.{RESET} Run "
2054
+ f"`localmask store-token` to add one.")
2055
+ return
2056
+ # Auto-create the masked repo if it doesn't exist — ask first
2057
+ # (unless --yes or the 'auto' policy). Then publish_scan creates it.
2058
+ create_if_missing = False
2059
+ from localmask.gitops import remote_repo_exists
2060
+ exists = remote_repo_exists(args.target_url, token)
2061
+ if exists is False:
2062
+ vis = "public" if args.public else "private"
2063
+ if args.yes or _publish_policy() == "auto":
2064
+ create_if_missing = True
2065
+ print(f" {DIM}Masked repo not found — creating a {vis} "
2066
+ f"one…{RESET}")
2067
+ else:
2068
+ try:
2069
+ ans = input(f" {YELLOW}Masked repo doesn't exist.{RESET} "
2070
+ f"Create it ({vis}) now? [Y/n]: ").strip().lower()
2071
+ except (EOFError, KeyboardInterrupt):
2072
+ ans = "n"
2073
+ if ans in ("", "y", "yes"):
2074
+ create_if_missing = True
2075
+ else:
2076
+ print(f" {DIM}Aborted — create it yourself, then "
2077
+ f"re-run publish.{RESET}")
2078
+ return
2079
+ try:
2080
+ result = eng.publish_scan(
2081
+ args.scan_id, args.target_url,
2082
+ token=token, credential_id="", username=args.username,
2083
+ create_if_missing=create_if_missing,
2084
+ private=not args.public)
2085
+ except Exception as e:
2086
+ _publish_error_help(str(e), args.target_url, bool(token))
2087
+ return
2088
+ if create_if_missing:
2089
+ print(f" {GREEN}✓ Created the masked repo{RESET}")
2090
+ print(f" {GREEN}✓ Published masked repo{RESET}")
2091
+ print(f" {DIM}Pushed to:{RESET} {result.get('pushed_to', args.target_url)}")
2092
+ print(f" {DIM}Files:{RESET} {result.get('files', '?')}")
2093
+ _print_grant_guide(args.target_url, args.scan_id)
2094
+ return
2095
+
2096
+ client = _get_client()
2097
+
2098
+ # Resolve credential_id from saved config if not provided
2099
+ cred_id = args.credential_id
2100
+ token = args.token
2101
+ if not cred_id and not token:
2102
+ cred_id = _load_config().get("credential_id", "")
2103
+
2104
+ # Verify scan status first
2105
+ scan = client.get_scan(args.scan_id)
2106
+ status = scan["status"]
2107
+ if status not in ("approved", "published"):
2108
+ print(f" {YELLOW}⚠ Scan status is '{status}' — typically only approved scans are published.{RESET}")
2109
+ try:
2110
+ confirm = input(f" {BOLD}Continue anyway? [y/N]: {RESET}").strip().lower()
2111
+ except (EOFError, KeyboardInterrupt):
2112
+ confirm = "n"
2113
+ if confirm != "y":
2114
+ print(f" {DIM}Aborted{RESET}\n")
2115
+ sys.exit(0)
2116
+
2117
+ print(f" {DIM}Publishing masked repo to {args.target_url}...{RESET}", flush=True)
2118
+ result = client.publish(
2119
+ args.scan_id, args.target_url,
2120
+ token=token, credential_id=cred_id,
2121
+ username=args.username,
2122
+ )
2123
+ print(f" {GREEN}✓ Published!{RESET}")
2124
+ print(f" {DIM}Pushed to:{RESET} {result.get('pushed_to', args.target_url)}")
2125
+ print(f" {DIM}Files:{RESET} {result.get('files', '?')}")
2126
+ _print_grant_guide(args.target_url, args.scan_id)
2127
+
2128
+ # ── teach (add a missed secret / ignore a false positive) ────────────
2129
+ elif args.command == "teach":
2130
+ scan_id = args.scan_id
2131
+ value = args.value
2132
+ action = "allow" if args.allow else "mask"
2133
+ if not _is_connected():
2134
+ from server_core import _get_or_load_scan
2135
+ from localmask.vault_store import get_vault_store, repo_id_for
2136
+ sc = _get_or_load_scan(scan_id)
2137
+ if not sc:
2138
+ print(f" {RED}✗ Scan not found: {scan_id}{RESET}")
2139
+ return
2140
+ src = sc.get("repo_url", "")
2141
+ # 0) Is the value actually present in the scanned code?
2142
+ hits = _count_occurrences(src, value)
2143
+ # 1) Persist to the repo's lexicon so it applies on every scan/sync.
2144
+ store = get_vault_store(repo_id_for(src))
2145
+ store.set_lexicon(value, action=action, subtype=args.subtype)
2146
+ verb = "ignore" if action == "allow" else "mask"
2147
+ print(f" {GREEN}✓ Taught: will always {verb} this value{RESET} "
2148
+ f"{DIM}(repo lexicon){RESET}")
2149
+ if hits == 0:
2150
+ print(f" {YELLOW}⚠ Not found in the scanned code{RESET} — "
2151
+ f"double-check the value (whitespace/quotes?). Saved to the "
2152
+ f"lexicon; it'll apply if it appears in a future scan.")
2153
+ return
2154
+ # 2) Re-scan in place so the change is visible now (tokens stay stable).
2155
+ eng = _local_engine()
2156
+ result = eng.sync_repo(scan_id, auto_republish=False)
2157
+ total = result.get("total_detections", 0)
2158
+ added = result.get("new_detections", 0)
2159
+ occ = f"{hits} occurrence" + ("s" if hits != 1 else "")
2160
+ if action == "mask" and added == 0:
2161
+ # Present in the text but the re-scan didn't add a detection.
2162
+ print(f" {YELLOW}⚠ Found {occ} in the source, but no new "
2163
+ f"detection was added.{RESET}")
2164
+ print(f" {DIM}It may sit inside an already-masked secret, or be "
2165
+ f"in a file LocalMask skips. If you just upgraded, reinstall "
2166
+ f"to refresh (see below).{RESET}")
2167
+ return
2168
+ print(f" {GREEN}✓ Found {occ}{RESET} — "
2169
+ f"{added} added to the review "
2170
+ f"({total} detections now, {CYAN}{scan_id}{RESET})")
2171
+ if action == "mask":
2172
+ print(f" {DIM}The value is now masked as a {args.subtype} "
2173
+ f"token; review/publish as usual.{RESET}")
2174
+ return
2175
+ client = _get_client()
2176
+ client._req("POST", f"/api/repos/{scan_id}/teach",
2177
+ {"value": value, "action": action, "subtype": args.subtype})
2178
+ print(f" {GREEN}✓ Taught (service).{RESET}")
2179
+
2180
+ # ── sync ────────────────────────────────────────────────────────────
2181
+ elif args.command == "sync":
2182
+ scan_id = args.scan_id
2183
+ print(f"\n {MAGENTA}[SYNC]{RESET} Re-scanning {scan_id}...", flush=True)
2184
+ print(f" {DIM}Pulling latest code, preserving token mappings...{RESET}")
2185
+
2186
+ if not _is_connected():
2187
+ eng = _local_engine()
2188
+ _tok = ""
2189
+ if args.credential_id:
2190
+ from localmask.vault_store import get_local_credential
2191
+ _tok = get_local_credential(args.credential_id) or ""
2192
+ result = eng.sync_repo(scan_id, token=_tok, auto_republish=False)
2193
+ # Re-push the masked mirror if this scan has a publish target — but
2194
+ # respect the approval gate. New/undecided detections since the last
2195
+ # approval hold the mirror until reviewed (unless policy is 'auto').
2196
+ from server_core import _get_or_load_scan
2197
+ _sc = _get_or_load_scan(scan_id)
2198
+ _target = _sc.get("publish_target", "") if _sc else ""
2199
+ policy = _publish_policy()
2200
+ _new_cnt = result.get("new_detections", 0)
2201
+ def _do_republish():
2202
+ try:
2203
+ return eng.publish_scan(
2204
+ scan_id, _target,
2205
+ credential_id=_sc.get("credential_id", ""))
2206
+ except Exception as e:
2207
+ return {"error": str(e)[:120]}
2208
+ if _target and not result.get("re_published"):
2209
+ if policy == "auto":
2210
+ _approve_all_local(scan_id)
2211
+ _sc = _get_or_load_scan(scan_id)
2212
+ result["re_published"] = _do_republish()
2213
+ elif _is_approved(_sc) and _new_cnt == 0:
2214
+ result["re_published"] = _do_republish()
2215
+ else:
2216
+ result["held_for_review"] = True
2217
+ else:
2218
+ client = _get_client()
2219
+ cred_id = args.credential_id or _load_config().get("credential_id", "")
2220
+ body = {"credential_id": cred_id} if cred_id else {}
2221
+ result = client._req("POST", f"/api/repos/{scan_id}/sync", body)
2222
+
2223
+ if result.get("error"):
2224
+ print(f" {RED}✗ {result['error']}{RESET}\n")
2225
+ else:
2226
+ total = result.get("total_detections", 0)
2227
+ new = result.get("new_detections", 0)
2228
+ removed = result.get("removed_detections", 0)
2229
+ carried = result.get("carried_decisions", 0)
2230
+ pending = result.get("pending_review", 0)
2231
+
2232
+ print(f" {GREEN}✓ Sync complete{RESET}\n")
2233
+ print(f" {BOLD}Scan ID:{RESET} {CYAN}{scan_id}{RESET}")
2234
+ print(f" {DIM}Total:{RESET} {total} detections")
2235
+ # Newly-detected secrets since the last scan — shown prominently.
2236
+ if new > 0:
2237
+ print(f" {BG_YELLOW}{BOLD} NEW {RESET} "
2238
+ f"{BOLD}{YELLOW}{new} new secret(s) detected since last "
2239
+ f"sync{RESET}")
2240
+ else:
2241
+ print(f" {DIM}New:{RESET} 0 new detections")
2242
+ if removed > 0:
2243
+ print(f" {DIM}Removed:{RESET} {removed} (no longer in code)")
2244
+ print(f" {DIM}Carried:{RESET} {carried} previous decisions preserved")
2245
+ if pending > 0:
2246
+ print(f" {YELLOW}Pending:{RESET} {pending} need review")
2247
+
2248
+ if result.get("re_published"):
2249
+ pub = result["re_published"]
2250
+ if pub.get("ok"):
2251
+ print(f"\n {GREEN}✓ Masked repo auto-republished to {pub.get('pushed_to', '?')}{RESET}")
2252
+ else:
2253
+ print(f"\n {YELLOW}⚠ Auto-republish failed:{RESET} "
2254
+ f"{pub.get('error', 'unknown')}")
2255
+ elif result.get("held_for_review"):
2256
+ print(f"\n {YELLOW}⚠ Masked mirror NOT updated — "
2257
+ f"held for review.{RESET}")
2258
+ print(f" {DIM}{new} new + {pending} pending detection(s) must be "
2259
+ f"approved before the mirror is republished.{RESET}")
2260
+ print(f" {CYAN}localmask review {scan_id}{RESET} or "
2261
+ f"{CYAN}localmask approve-all {scan_id}{RESET} then "
2262
+ f"{CYAN}localmask publish {scan_id} <url>{RESET}")
2263
+ print(f" {DIM}(auto-publish on every change: "
2264
+ f"{CYAN}localmask config publish-policy auto{RESET}{DIM}){RESET}")
2265
+
2266
+ if new > 0 and not result.get("held_for_review"):
2267
+ print(f"\n {DIM}Next: review new detections:{RESET}")
2268
+ print(f" localmask review {scan_id}")
2269
+ print()
2270
+
2271
+ # ── hook ────────────────────────────────────────────────────────────
2272
+ elif args.command == "hook":
2273
+ import stat as stat_mod
2274
+ repo_path = os.path.abspath(args.repo)
2275
+ scan_id = args.scan_id
2276
+ hook_type = args.type
2277
+
2278
+ hooks_dir = os.path.join(repo_path, ".git", "hooks")
2279
+ if not os.path.isdir(hooks_dir):
2280
+ print(f" {RED}✗ Not a git repo: {repo_path}{RESET}\n")
2281
+ sys.exit(1)
2282
+
2283
+ hook_path = os.path.join(hooks_dir, hook_type)
2284
+ localmask_bin = os.path.expanduser("~/.localmask/localmask")
2285
+
2286
+ hook_script = f"""#!/bin/bash
2287
+ # LocalMask Pro — auto-sync masked repo on {hook_type}
2288
+ # Scan ID: {scan_id}
2289
+
2290
+ echo "🔐 LocalMask: syncing masked repo..."
2291
+ if command -v localmask &>/dev/null; then
2292
+ localmask sync {scan_id} 2>&1 | tail -5
2293
+ elif [ -f "{localmask_bin}" ]; then
2294
+ "{localmask_bin}" sync {scan_id} 2>&1 | tail -5
2295
+ else
2296
+ echo "⚠ LocalMask not found — skipping sync"
2297
+ fi
2298
+ """
2299
+
2300
+ if os.path.exists(hook_path):
2301
+ with open(hook_path) as f:
2302
+ existing = f.read()
2303
+ if "LocalMask" in existing:
2304
+ print(f" {GREEN}✓ Hook already installed in {hook_type}{RESET}\n")
2305
+ sys.exit(0)
2306
+ with open(hook_path, "a") as f:
2307
+ f.write("\n" + hook_script)
2308
+ else:
2309
+ with open(hook_path, "w") as f:
2310
+ f.write(hook_script)
2311
+
2312
+ st = os.stat(hook_path)
2313
+ os.chmod(hook_path, st.st_mode | stat_mod.S_IEXEC | stat_mod.S_IXGRP | stat_mod.S_IXOTH)
2314
+
2315
+ print(f"\n {GREEN}✓ Git {hook_type} hook installed!{RESET}")
2316
+ print(f" {DIM}Repo:{RESET} {repo_path}")
2317
+ print(f" {DIM}Scan ID:{RESET} {scan_id}")
2318
+ print(f" {DIM}Hook:{RESET} {hook_path}")
2319
+ print(f"\n Every {hook_type.replace('-', ' ')} will auto-sync the masked repo.")
2320
+ print(f" {DIM}Token mappings are preserved — only new secrets need review.{RESET}\n")
2321
+
2322
+ # ── activate ────────────────────────────────────────────────────────
2323
+ elif args.command == "activate":
2324
+ # License activation is local — no server needed
2325
+ sys.path.insert(0, os.path.dirname(__file__))
2326
+ try:
2327
+ from licensing import LicenseManager
2328
+ mgr = LicenseManager()
2329
+ result = mgr.activate(args.license_key)
2330
+ if result.get("ok"):
2331
+ print(f" {GREEN}✓ License activated!{RESET}")
2332
+ print(f" {DIM}Tier:{RESET} {BOLD}{result['tier_name']}{RESET}")
2333
+ print(f" {DIM}Activated:{RESET} {result['activated_at'][:19]}")
2334
+ print(f"\n {DIM}You now have full access to LocalMask Pro.{RESET}\n")
2335
+ else:
2336
+ print(f" {RED}✗ Activation failed: {result.get('error', 'unknown')}{RESET}\n")
2337
+ except ImportError:
2338
+ print(f" {RED}✗ licensing.py not found. Ensure you're in the LocalMask directory.{RESET}\n")
2339
+
2340
+ # ── license ─────────────────────────────────────────────────────────
2341
+ elif args.command == "license":
2342
+ sys.path.insert(0, os.path.dirname(__file__))
2343
+ try:
2344
+ from licensing import LicenseManager
2345
+ mgr = LicenseManager()
2346
+ status = mgr.get_status()
2347
+ print(f"\n{BOLD}{'═' * 50}{RESET}")
2348
+ print(f"{BOLD} LocalMask Pro — License{RESET}")
2349
+ print(f"{BOLD}{'═' * 50}{RESET}")
2350
+ tier_color = GREEN if status["tier"] != "free" else DIM
2351
+ print(f" {DIM}Tier:{RESET} {tier_color}{BOLD}{status['tier_name']}{RESET}")
2352
+ print(f" {DIM}License:{RESET} {status['license_key']}")
2353
+ if status.get("activated_at"):
2354
+ print(f" {DIM}Activated:{RESET} {status['activated_at'][:19]}")
2355
+ print(f" {DIM}Custom rules:{RESET} {'Yes' if status['custom_rules'] else 'No'}")
2356
+ print(f"\n {BOLD}Usage Today:{RESET}")
2357
+ for action, info in status.get("usage_today", {}).items():
2358
+ limit_str = str(info['limit']) if info['limit'] != 'unlimited' else '∞'
2359
+ used = info['used']
2360
+ bar_len = 20
2361
+ if info['limit'] != 'unlimited' and info['limit'] > 0:
2362
+ filled = min(bar_len, int(used / info['limit'] * bar_len))
2363
+ bar = f"{YELLOW}{'█' * filled}{'░' * (bar_len - filled)}{RESET}"
2364
+ else:
2365
+ bar = f"{GREEN}∞{RESET}"
2366
+ print(f" {action:<8} {used:>3}/{limit_str:<10} {bar}")
2367
+ print(f"\n{'═' * 50}\n")
2368
+ except ImportError:
2369
+ print(f" {RED}✗ licensing.py not found.{RESET}\n")
2370
+
2371
+ # ── ask ──────────────────────────────────────────────────────────────
2372
+ elif args.command == "ask":
2373
+ client = _get_client()
2374
+ scan_id = args.scan_id
2375
+
2376
+ # Verify scan exists
2377
+ scan = client.get_scan(scan_id)
2378
+ src_label = "published git repo" if args.source == "git" else "platform memory"
2379
+ print(f"\n {MAGENTA}[AI]{RESET} Scan: {CYAN}{scan_id}{RESET} "
2380
+ f"Status: {BOLD}{scan['status']}{RESET} "
2381
+ f"Repo: {scan['repo_url']}")
2382
+ print(f" {DIM}Source: {src_label} — AI only sees masked content.{RESET}\n")
2383
+
2384
+ if args.question:
2385
+ # Single question mode
2386
+ print(f" {DIM}Asking...{RESET}", flush=True)
2387
+ r = client.ask(scan_id, args.question, args.provider, args.model,
2388
+ args.source, args.git_url)
2389
+ if r.get("error"):
2390
+ print(f" {RED}Error: {r['error']}{RESET}")
2391
+ else:
2392
+ print(f" {DIM}Provider:{RESET} {r.get('provider', '?')} "
2393
+ f"{DIM}Turn:{RESET} {r.get('turns', 1)}")
2394
+ if r.get("masked_question") != args.question:
2395
+ print(f" {DIM}Sent masked:{RESET} {r['masked_question']}")
2396
+ print(f"\n{r.get('answer', '')}\n")
2397
+ else:
2398
+ # Interactive mode
2399
+ print(f" {DIM}Interactive mode — type your questions. "
2400
+ f"Commands: /reset /quit{RESET}\n")
2401
+ turn = 0
2402
+ while True:
2403
+ try:
2404
+ q = input(f" {BOLD}You → {RESET}").strip()
2405
+ except (EOFError, KeyboardInterrupt):
2406
+ print(f"\n {DIM}Bye{RESET}\n")
2407
+ break
2408
+
2409
+ if not q:
2410
+ continue
2411
+ if q.lower() in ("/quit", "/exit", "/q"):
2412
+ print(f" {DIM}Bye{RESET}\n")
2413
+ break
2414
+ if q.lower() == "/reset":
2415
+ client.ask_reset(scan_id)
2416
+ turn = 0
2417
+ print(f" {GREEN}✓ Chat reset{RESET}\n")
2418
+ continue
2419
+
2420
+ print(f" {DIM}Thinking...{RESET}", flush=True)
2421
+ r = client.ask(scan_id, q, args.provider, args.model,
2422
+ args.source, args.git_url)
2423
+ if r.get("error"):
2424
+ print(f" {RED}Error: {r['error']}{RESET}\n")
2425
+ continue
2426
+
2427
+ turn = r.get("turns", turn + 1)
2428
+ answer = r.get("answer", "")
2429
+ print(f"\n {CYAN}AI ({r.get('provider','?')} · turn {turn}):{RESET}")
2430
+ # Indent the answer
2431
+ for line in answer.split("\n"):
2432
+ print(f" {line}")
2433
+ print()
2434
+
2435
+
2436
+ if __name__ == "__main__":
2437
+ main()