ghostcode 0.5.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.
ghostcode/cli.py ADDED
@@ -0,0 +1,986 @@
1
+ """GhostCode CLI — Privacy Proxy for Developers.
2
+
3
+ Commands:
4
+ ghost hide [files] Strip business context, produce anonymous code
5
+ ghost reveal [file] Restore original names from AI's response
6
+ ghost map [mapfile] Inspect a ghost map
7
+ ghost status Show GhostCode status and recent activity
8
+ ghost demo Run interactive demo (hackathon)
9
+ """
10
+
11
+ import os
12
+ import re
13
+ import sys
14
+ import tempfile
15
+ from datetime import datetime
16
+
17
+ import click
18
+
19
+ from . import __version__
20
+ from .audit.logger import AuditLogger
21
+ from .config import load_config
22
+ from .mapping.ghost_map import GhostMap
23
+ from .parsers.base import ParseResult
24
+ from .parsers.cpp_parser import CppParser
25
+ from .parsers.python_parser import PythonParser
26
+ from .transformers.comment_anonymizer import CommentAnonymizer
27
+ from .transformers.comment_stripper import CommentStripper
28
+ from .transformers.literal_scrubber import LiteralScrubber
29
+ from .transformers.symbol_renamer import SymbolRenamer
30
+ from .transformers.isolator import CppIsolator, PythonIsolator
31
+ from .transformers.multi_file import process_multiple_files
32
+ from .utils.clipboard import copy_to_clipboard, clipboard_available
33
+ from .risk_report import RiskAnalyzer, format_risk_report_cli
34
+
35
+ LANGUAGE_MAP = {
36
+ ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp",
37
+ ".c": "cpp", ".h": "cpp", ".hpp": "cpp", ".hxx": "cpp",
38
+ ".py": "python",
39
+ }
40
+
41
+ # ── Styled output helpers ───────────────────────────────────────────
42
+
43
+ BANNER = r"""
44
+ ________ __ ______ __
45
+ / ____/ /_ ____ _____/ /_/ ____/___ ____/ /__
46
+ / / __/ __ \/ __ \/ ___/ __/ / / __ \/ __ / _ \
47
+ / /_/ / / / / /_/ (__ ) /_/ /___/ /_/ / /_/ / __/
48
+ \____/_/ /_/\____/____/\__/\____/\____/\__,_/\___/
49
+ """
50
+
51
+
52
+ def _header(text: str):
53
+ """Print a styled section header."""
54
+ click.secho(f"\n{'=' * 54}", fg="cyan")
55
+ click.secho(f" {text}", fg="cyan", bold=True)
56
+ click.secho(f"{'=' * 54}", fg="cyan")
57
+
58
+
59
+ def _step(label: str, detail: str = "", done: bool = True):
60
+ """Print a step indicator."""
61
+ icon = click.style(" [+]", fg="green") if done else click.style(" [~]", fg="yellow")
62
+ msg = f"{icon} {label}"
63
+ if detail:
64
+ msg += click.style(f" {detail}", fg="white", dim=True)
65
+ click.echo(msg)
66
+
67
+
68
+ def _warn(msg: str):
69
+ """Print a warning."""
70
+ click.secho(f" [!] {msg}", fg="yellow")
71
+
72
+
73
+ def _error(msg: str):
74
+ """Print an error and exit."""
75
+ click.secho(f"\n ERROR: {msg}", fg="red", bold=True, err=True)
76
+ sys.exit(1)
77
+
78
+
79
+ def _info(label: str, value: str):
80
+ """Print an info line."""
81
+ click.echo(f" {click.style(label + ':', bold=True):30s} {value}")
82
+
83
+
84
+ def _divider():
85
+ click.secho(f" {'─' * 50}", fg="white", dim=True)
86
+
87
+
88
+ def _get_language(file_path: str) -> str | None:
89
+ _, ext = os.path.splitext(file_path)
90
+ return LANGUAGE_MAP.get(ext.lower())
91
+
92
+
93
+ def _get_parser(language: str):
94
+ if language == "cpp":
95
+ return CppParser()
96
+ if language == "python":
97
+ return PythonParser()
98
+ _error(f"Unsupported language '{language}'. Supported: C/C++, Python")
99
+
100
+
101
+ def _default_map_dir() -> str:
102
+ return os.path.join(".ghostcode", "maps")
103
+
104
+
105
+ def _generate_map_path(source_file: str) -> str:
106
+ base = os.path.splitext(os.path.basename(source_file))[0]
107
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
108
+ return os.path.join(_default_map_dir(), f"{base}_{timestamp}.json")
109
+
110
+
111
+ # ── Main CLI group ──────────────────────────────────────────────────
112
+
113
+ @click.group()
114
+ @click.version_option(version=__version__, prog_name="ghostcode")
115
+ def main():
116
+ """GhostCode — Privacy Proxy for Developers.
117
+
118
+ Strip business context from code before sending to AI.
119
+ Restore original names after getting AI's response.
120
+
121
+ Your code stays anonymous. Your logic stays intact.
122
+ """
123
+ pass
124
+
125
+
126
+ # ── HIDE command ────────────────────────────────────────────────────
127
+
128
+ @main.command()
129
+ @click.argument("file_paths", nargs=-1, type=click.Path(exists=True), required=True)
130
+ @click.option("--level", type=click.IntRange(1, 4), default=None,
131
+ help="Privacy level: 1=names+comments, 2=+literals, 3=+isolation, 4=+dimensions")
132
+ @click.option("--output", "-o", type=click.Path(), default=None,
133
+ help="Output file path (default: ghost_<filename>)")
134
+ @click.option("--map-file", type=click.Path(), default=None,
135
+ help="Map file path (default: .ghostcode/maps/<name>_<timestamp>.json)")
136
+ @click.option("--function", "-f", type=str, default=None,
137
+ help="Function to isolate (Level 3+).")
138
+ @click.option("--encrypt/--no-encrypt", default=None,
139
+ help="Encrypt the map file (default: from config)")
140
+ @click.option("--passphrase", type=str, default=None, hide_input=True,
141
+ help="Passphrase for map encryption (prompted if not provided)")
142
+ @click.option("--copy/--no-copy", "clipboard", default=True,
143
+ help="Copy ghost output to clipboard (default: yes)")
144
+ @click.option("--keep-comments", is_flag=True, default=False,
145
+ help="Anonymize comments instead of stripping them")
146
+ @click.option("--risk-report/--no-risk-report", "show_risk_report", default=True,
147
+ help="Show pre-send risk report (default: yes)")
148
+ def hide(file_paths: tuple[str, ...], level: int | None, output: str | None,
149
+ map_file: str | None, function: str | None,
150
+ encrypt: bool | None, passphrase: str | None,
151
+ clipboard: bool, keep_comments: bool, show_risk_report: bool):
152
+ """Strip business context from source file(s).
153
+
154
+ Privacy levels:
155
+ 1 — Rename user symbols + strip comments
156
+ 2 — + Scrub string/numeric literals (smart classification)
157
+ 3 — + Function isolation with dependency stubs
158
+ 4 — + Dimension generalization (loop bounds → constants)
159
+
160
+ Examples:
161
+ ghost hide app.py
162
+ ghost hide server.cpp --level 3 --function handle_request
163
+ ghost hide *.py --encrypt --level 4
164
+ """
165
+ # Load config
166
+ config = load_config()
167
+ audit = AuditLogger(enabled=config.enforce_audit)
168
+
169
+ # Resolve level
170
+ if level is None:
171
+ level = config.default_scrub_level
172
+ try:
173
+ level = config.validate_level(level)
174
+ except ValueError as e:
175
+ _error(str(e))
176
+
177
+ # Resolve encryption
178
+ use_encryption = encrypt if encrypt is not None else config.encrypt_maps
179
+
180
+ # Process first file (multi-file uses the first for primary output)
181
+ file_path = file_paths[0]
182
+
183
+ # Check banned patterns
184
+ for fp in file_paths:
185
+ if config.check_banned(fp):
186
+ _error(
187
+ f"'{fp}' matches a banned pattern in repo policy.\n"
188
+ f" Banned patterns: {', '.join(config.banned_patterns)}\n"
189
+ f" Contact your security team for exceptions."
190
+ )
191
+
192
+ language = _get_language(file_path)
193
+ if not language:
194
+ ext = os.path.splitext(file_path)[1]
195
+ _error(
196
+ f"Unsupported file type '{ext}'.\n"
197
+ f" Supported: .py, .cpp, .cc, .c, .h, .hpp"
198
+ )
199
+
200
+ # ── Header ─────────────────────────────────────────────────────
201
+ _header("GHOST HIDE")
202
+ level_labels = {
203
+ 1: "Names + Comments",
204
+ 2: "Names + Comments + Literals",
205
+ 3: "Names + Comments + Literals + Isolation",
206
+ 4: "Full (Names + Comments + Literals + Isolation + Dimensions)",
207
+ }
208
+ _info("Source", ", ".join(file_paths))
209
+ _info("Language", language.upper())
210
+ _info("Privacy Level", f"{level} — {level_labels[level]}")
211
+ if function:
212
+ _info("Isolate Function", function)
213
+ if use_encryption:
214
+ _info("Map Encryption", "ON")
215
+ click.echo()
216
+
217
+ # Read source
218
+ with open(file_path) as f:
219
+ source_code = f.read()
220
+
221
+ # ── Level 3+: Function Isolation ──────────────────────────────
222
+ isolated = False
223
+ if level >= 3 and function:
224
+ if language == "cpp":
225
+ isolator = CppIsolator()
226
+ else:
227
+ isolator = PythonIsolator()
228
+
229
+ isolated_source = isolator.isolate(source_code, function)
230
+ if isolated_source:
231
+ source_code = isolated_source
232
+ isolated = True
233
+ _step("Function isolated", f"'{function}' extracted with stubs")
234
+ else:
235
+ _warn(f"Function '{function}' not found — processing full file")
236
+
237
+ # ── Parse & Transform ─────────────────────────────────────────
238
+ parser = _get_parser(language)
239
+ ghost_map = GhostMap()
240
+ multi_mode = len(file_paths) > 1 and not isolated
241
+
242
+ if multi_mode:
243
+ # ── Multi-file: consistent tokens across all files ────────
244
+ _step("Multi-file mode", f"{len(file_paths)} files")
245
+
246
+ # Group files by language and process each group with correct parser
247
+ multi_results = []
248
+ lang_groups: dict[str, list[str]] = {}
249
+ for fp in file_paths:
250
+ lang = _get_language(fp) or language
251
+ lang_groups.setdefault(lang, []).append(fp)
252
+
253
+ for lang, group_files in lang_groups.items():
254
+ group_parser = _get_parser(lang)
255
+ group_results = process_multiple_files(
256
+ group_files, group_parser, ghost_map,
257
+ strip_comments=not keep_comments,
258
+ )
259
+ multi_results.extend(group_results)
260
+
261
+ _step("AST parsed & renamed",
262
+ f"{ghost_map.symbol_count} symbols across {len(file_paths)} files")
263
+
264
+ # Collect stats from all files
265
+ total_comments = sum(cc for _, _, _, cc in multi_results)
266
+ if keep_comments:
267
+ _step("Comments anonymized", f"{total_comments} anonymized")
268
+ else:
269
+ _step("Comments stripped", f"{total_comments} removed")
270
+
271
+ # Use first file as primary ghost output
272
+ ghost_source = multi_results[0][1]
273
+ comment_count = total_comments
274
+
275
+ # Write all files
276
+ all_outputs = []
277
+ for fpath, gsource, _, _ in multi_results:
278
+ oname = f"ghost_{os.path.basename(fpath)}"
279
+ with open(oname, "w") as f:
280
+ f.write(gsource)
281
+ all_outputs.append(oname)
282
+ _step("Files written", ", ".join(all_outputs))
283
+
284
+ else:
285
+ # ── Single-file pipeline ──────────────────────────────────
286
+ ext = os.path.splitext(file_path)[1]
287
+
288
+ with tempfile.NamedTemporaryFile(mode="w", suffix=ext, delete=False) as tmp:
289
+ tmp.write(source_code)
290
+ tmp_path = tmp.name
291
+
292
+ try:
293
+ # Step 1: Parse and handle comments
294
+ parse_result = parser.parse(tmp_path)
295
+ parse_result.file_path = file_path
296
+ parse_result.source_code = source_code
297
+ _step("AST parsed",
298
+ f"{len(parse_result.symbols)} symbols, "
299
+ f"{len(parse_result.comments)} comments")
300
+
301
+ if keep_comments:
302
+ # Keep comments — will anonymize after renaming
303
+ clean_source = source_code
304
+ comment_count = len(parse_result.comments)
305
+ saved_comments = parse_result.comments
306
+ else:
307
+ stripper = CommentStripper()
308
+ clean_source, comment_count = stripper.strip(
309
+ source_code, parse_result.comments
310
+ )
311
+ saved_comments = None
312
+ _step("Comments stripped", f"{comment_count} removed")
313
+
314
+ # Step 2: Re-parse clean source for accurate offsets
315
+ with open(tmp_path, "w") as f:
316
+ f.write(clean_source)
317
+
318
+ clean_parse = parser.parse(tmp_path)
319
+ clean_parse.file_path = file_path
320
+ clean_parse.source_code = clean_source
321
+
322
+ # Step 3: Rename symbols
323
+ renamer = SymbolRenamer(ghost_map)
324
+ ghost_source = renamer.rename(clean_parse)
325
+ _step("Symbols renamed", f"{ghost_map.symbol_count} mapped")
326
+
327
+ # Step 4: Anonymize comments (if keeping them)
328
+ if keep_comments and saved_comments:
329
+ anonymizer = CommentAnonymizer(ghost_map)
330
+ # Re-parse the ghost source to find comment positions
331
+ with open(tmp_path, "w") as f:
332
+ f.write(ghost_source)
333
+ ghost_parse = parser.parse(tmp_path)
334
+ ghost_source, anon_count = anonymizer.anonymize(
335
+ ghost_source, ghost_parse.comments
336
+ )
337
+ _step("Comments anonymized", f"{anon_count} anonymized")
338
+
339
+ finally:
340
+ os.unlink(tmp_path)
341
+
342
+ # ── Level 2+: Literal Scrubbing ──────────────────────────────
343
+ scrub_result = None
344
+ if level >= 2:
345
+ scrubber = LiteralScrubber(ghost_map)
346
+ original_names = {
347
+ entry.original for entry in ghost_map._entries.values()
348
+ }
349
+ scrubber.set_known_symbols(original_names)
350
+ scrub_result = scrubber.scrub(ghost_source, file_path)
351
+ ghost_source = scrub_result.source
352
+ _step("Literals scrubbed",
353
+ f"{len(scrub_result.scrubbed)} scrubbed, "
354
+ f"{len(scrub_result.flagged)} flagged, "
355
+ f"{len(scrub_result.kept)} kept")
356
+
357
+ # ── Level 4: Dimension Generalization ─────────────────────────
358
+ dim_count = 0
359
+ if level >= 4:
360
+ ghost_source, dim_count = _generalize_dimensions(
361
+ ghost_source, ghost_map, file_path
362
+ )
363
+ _step("Dimensions generalized", f"{dim_count} loop bounds")
364
+
365
+ # ── Output ────────────────────────────────────────────────────
366
+ if output is None:
367
+ base = os.path.basename(file_path)
368
+ output = f"ghost_{base}"
369
+
370
+ if map_file is None:
371
+ ext = ".ghost" if use_encryption else ".json"
372
+ map_file = _generate_map_path(file_path).replace(".json", ext)
373
+
374
+ if not multi_mode:
375
+ with open(output, "w") as f:
376
+ f.write(ghost_source)
377
+
378
+ # Store paths for one-click reveal: overwrite original + auto-detect ghost file
379
+ ghost_map._metadata["original_file"] = os.path.abspath(file_path)
380
+ ghost_map._metadata["ghost_file"] = os.path.abspath(output)
381
+ ghost_map.save(map_file, passphrase=passphrase)
382
+
383
+ # Clipboard
384
+ copied = False
385
+ if clipboard:
386
+ copied = copy_to_clipboard(ghost_source)
387
+ if copied:
388
+ _step("Copied to clipboard", "ready to paste into AI")
389
+ else:
390
+ _warn("Clipboard unavailable — copy manually from output file")
391
+
392
+ # ── Audit Log ─────────────────────────────────────────────────
393
+ all_warnings = ghost_map.warnings
394
+ audit.log_hide(
395
+ source_files=list(file_paths),
396
+ scrub_level=level,
397
+ function_isolated=function if isolated else None,
398
+ symbols_scrubbed=ghost_map.symbol_count,
399
+ literals_scrubbed=len(scrub_result.scrubbed) if scrub_result else 0,
400
+ literals_flagged=len(scrub_result.flagged) if scrub_result else 0,
401
+ literals_kept=len(scrub_result.kept) if scrub_result else 0,
402
+ comments_stripped=comment_count,
403
+ warnings=all_warnings,
404
+ ghost_output_path=output,
405
+ map_path=map_file,
406
+ ghost_output_content=ghost_source,
407
+ )
408
+
409
+ # ── Risk Report ───────────────────────────────────────────────
410
+ if show_risk_report:
411
+ analyzer = RiskAnalyzer()
412
+ risk_report = analyzer.analyze(
413
+ ghost_map=ghost_map,
414
+ ghost_source=ghost_source,
415
+ level=level,
416
+ comment_count=comment_count,
417
+ keep_comments=keep_comments,
418
+ scrub_result=scrub_result,
419
+ function_isolated=function if isolated else None,
420
+ dim_count=dim_count,
421
+ file_count=len(file_paths),
422
+ )
423
+ click.echo(format_risk_report_cli(risk_report))
424
+
425
+ # Emit JSON for VS Code extension parsing
426
+ import json as _json
427
+ click.echo(f"RISK_REPORT_JSON: {_json.dumps(risk_report.to_dict())}")
428
+
429
+ # ── Summary ───────────────────────────────────────────────────
430
+ click.echo()
431
+ _divider()
432
+ click.secho(" HIDE COMPLETE", fg="green", bold=True)
433
+ _divider()
434
+ _info("Symbols renamed", str(ghost_map.symbol_count))
435
+ if keep_comments:
436
+ _info("Comments anonymized", str(comment_count))
437
+ else:
438
+ _info("Comments stripped", str(comment_count))
439
+ if isolated:
440
+ _info("Function isolated", function)
441
+ if scrub_result:
442
+ _info("Literals scrubbed", str(len(scrub_result.scrubbed)))
443
+ if scrub_result.flagged:
444
+ _info("Literals flagged", str(len(scrub_result.flagged)))
445
+ if level >= 4:
446
+ _info("Dimensions generalized", str(dim_count))
447
+ if all_warnings:
448
+ _info("Warnings", str(len(all_warnings)))
449
+ if use_encryption:
450
+ _info("Encryption", "enabled")
451
+ click.echo()
452
+ _info("Ghost output", output)
453
+ _info("Map file", map_file)
454
+ if copied:
455
+ click.secho("\n >> Ghost output is on your clipboard. Paste it into your AI. <<",
456
+ fg="green", bold=True)
457
+ click.echo()
458
+
459
+ # Show literal scrub details if any flagged
460
+ if scrub_result and scrub_result.flagged:
461
+ scrubber = LiteralScrubber(ghost_map)
462
+ click.echo(scrubber.summary(scrub_result))
463
+
464
+
465
+ def _generalize_dimensions(source: str, ghost_map: GhostMap,
466
+ file_path: str) -> tuple[str, int]:
467
+ """Level 4: Replace loop bound literals with ghost constants."""
468
+ count = 0
469
+ pattern = re.compile(
470
+ r"(\bfor\s*\([^;]*;\s*\w+\s*[<>!=]+\s*)(\d+)(\s*;)"
471
+ )
472
+
473
+ def replacer(match):
474
+ nonlocal count
475
+ number = match.group(2)
476
+ num_val = int(number)
477
+ if num_val <= 1:
478
+ return match.group(0)
479
+ token = ghost_map.add_symbol(
480
+ original=number, kind="constant",
481
+ scope="loop_bound", source_file=file_path,
482
+ )
483
+ count += 1
484
+ return match.group(1) + token + match.group(3)
485
+
486
+ source = pattern.sub(replacer, source)
487
+
488
+ py_pattern = re.compile(r"(range\s*\()(\d+)(\s*\))")
489
+
490
+ def py_replacer(match):
491
+ nonlocal count
492
+ number = match.group(2)
493
+ num_val = int(number)
494
+ if num_val <= 1:
495
+ return match.group(0)
496
+ token = ghost_map.add_symbol(
497
+ original=number, kind="constant",
498
+ scope="loop_bound", source_file=file_path,
499
+ )
500
+ count += 1
501
+ return match.group(1) + token + match.group(3)
502
+
503
+ source = py_pattern.sub(py_replacer, source)
504
+ return source, count
505
+
506
+
507
+ # ── REVEAL command ──────────────────────────────────────────────────
508
+
509
+ @main.command()
510
+ @click.argument("file_path", type=click.Path(exists=True))
511
+ @click.option("--map-file", "-m", type=click.Path(exists=True), required=True,
512
+ help="Path to the ghost map file")
513
+ @click.option("--output", "-o", type=click.Path(), default=None,
514
+ help="Output file path for code (default: revealed_<filename>)")
515
+ @click.option("--sent", "-s", type=click.Path(exists=True), default=None,
516
+ help="Original ghost file sent to AI (for diff analysis)")
517
+ @click.option("--mode", type=click.Choice(["code", "ai-response"]), default="code",
518
+ help="Mode: 'code' for pure code files, 'ai-response' for full AI responses")
519
+ def reveal(file_path: str, map_file: str, output: str | None,
520
+ sent: str | None, mode: str):
521
+ """Restore original names from AI's response.
522
+
523
+ Two modes:
524
+ code — pure code file, simple token replacement
525
+ ai-response — full AI response with prose + code blocks
526
+
527
+ Examples:
528
+ ghost reveal fixed.cpp -m .ghostcode/maps/sample_20240101.json
529
+ ghost reveal ai_output.md -m map.json --mode ai-response --sent ghost_sample.cpp
530
+ """
531
+ from .reveal.code_revealer import CodeRevealer
532
+ from .reveal.diff_analyzer import DiffAnalyzer
533
+ from .reveal.explanation_translator import ExplanationTranslator
534
+
535
+ _header("GHOST REVEAL")
536
+ _info("Mode", mode)
537
+
538
+ # Load map
539
+ ghost_map = GhostMap.load(map_file)
540
+ _step("Map loaded", f"{ghost_map.symbol_count} symbols from {map_file}")
541
+
542
+ # Read input
543
+ with open(file_path) as f:
544
+ content = f.read()
545
+
546
+ revealer = CodeRevealer(ghost_map)
547
+
548
+ if mode == "code":
549
+ # ── Simple code reveal ────────────────────────────────────
550
+ # Auto-detect the original ghost file from map metadata for annotation
551
+ sent_code = None
552
+ ghost_file_path = ghost_map._metadata.get("ghost_file", "")
553
+ if sent:
554
+ with open(sent) as f:
555
+ sent_code = f.read()
556
+ elif ghost_file_path and os.path.exists(ghost_file_path):
557
+ with open(ghost_file_path) as f:
558
+ sent_code = f.read()
559
+ _step("Auto-detected ghost file", os.path.basename(ghost_file_path))
560
+
561
+ # Diff analysis (run before reveal so we can pass result through)
562
+ diff_result = None
563
+ if sent_code:
564
+ analyzer = DiffAnalyzer()
565
+ diff_result = analyzer.analyze(sent_code, content)
566
+
567
+ restored, count, new_symbols = revealer.reveal_code(
568
+ content, original_ghost=sent_code, diff_result=diff_result
569
+ )
570
+ _step("Symbols restored", f"{count} tokens → original names")
571
+
572
+ if new_symbols:
573
+ _warn(f"{len(new_symbols)} new symbols detected (AI-introduced):")
574
+ for sym in new_symbols:
575
+ click.echo(f" {sym} → NEW_{sym}")
576
+ conf_color = {"HIGH": "green", "MEDIUM": "yellow", "LOW": "red"}.get(
577
+ diff_result.confidence.value, "white"
578
+ )
579
+ _step("Diff analyzed",
580
+ f"{len(diff_result.changes)} changes, confidence: "
581
+ + click.style(diff_result.confidence.value, fg=conf_color))
582
+
583
+ # Output — overwrite original file by default
584
+ if output is None:
585
+ original_file = ghost_map._metadata.get("original_file", "")
586
+ if original_file and os.path.exists(os.path.dirname(original_file) or "."):
587
+ output = original_file
588
+ else:
589
+ base = os.path.basename(file_path)
590
+ output = f"revealed_{base}"
591
+
592
+ with open(output, "w") as f:
593
+ f.write(restored)
594
+
595
+ # Summary
596
+ click.echo()
597
+ _divider()
598
+ click.secho(" REVEAL COMPLETE", fg="green", bold=True)
599
+ _divider()
600
+ _info("Symbols restored", str(count))
601
+ _info("New symbols", f"{len(new_symbols)} (prefixed with NEW_)")
602
+ if diff_result:
603
+ _info("Confidence",
604
+ f"{diff_result.confidence.value} ({diff_result.confidence_score}/100)")
605
+ if diff_result.changes:
606
+ click.echo()
607
+ click.secho(" Changes detected:", bold=True)
608
+ for change in diff_result.changes:
609
+ icon = {
610
+ "modified": click.style("~", fg="yellow"),
611
+ "new_function": click.style("+", fg="green"),
612
+ "deleted_function": click.style("-", fg="red"),
613
+ "signature_change": click.style("!", fg="red"),
614
+ "new_variable": click.style("+", fg="green"),
615
+ "new_dependency": click.style("+", fg="cyan"),
616
+ }.get(change.type.value, "?")
617
+ click.echo(f" [{icon}] {change.detail}")
618
+ _info("Output", output)
619
+ click.echo()
620
+
621
+ else:
622
+ # ── AI response reveal (zone-aware) ───────────────────────
623
+ result = revealer.reveal_ai_response(content)
624
+ _step("Zones parsed", f"{result.symbols_restored} symbols restored")
625
+
626
+ # Explanation translator
627
+ stubs = [
628
+ token for token, entry in ghost_map._entries.items()
629
+ if "(stub)" in (entry.scope or "")
630
+ ]
631
+ translator = ExplanationTranslator(ghost_map, stubs=stubs)
632
+ annotated_explanation, annotations = translator.annotate(
633
+ result.restored_explanation
634
+ )
635
+ _step("Explanation translated", f"{len(annotations)} annotations")
636
+
637
+ # Diff analysis
638
+ diff_result = None
639
+ if sent:
640
+ with open(sent) as f:
641
+ sent_code = f.read()
642
+ analyzer = DiffAnalyzer()
643
+ diff_result = analyzer.analyze(sent_code, result.restored_code)
644
+ _step("Diff analyzed", f"confidence: {diff_result.confidence.value}")
645
+
646
+ # Output files
647
+ if output is None:
648
+ base = os.path.splitext(os.path.basename(file_path))[0]
649
+ output = f"revealed_{base}"
650
+
651
+ code_output = output + os.path.splitext(file_path)[1]
652
+ explanation_output = output + "_explanation.md"
653
+
654
+ if result.restored_code:
655
+ with open(code_output, "w") as f:
656
+ f.write(result.restored_code)
657
+
658
+ with open(explanation_output, "w") as f:
659
+ f.write(annotated_explanation)
660
+
661
+ # Summary
662
+ click.echo()
663
+ _divider()
664
+ click.secho(" REVEAL COMPLETE", fg="green", bold=True)
665
+ _divider()
666
+ _info("Symbols restored", str(result.symbols_restored))
667
+ _info("New symbols", str(len(result.new_symbols)))
668
+ _info("Annotations", str(len(annotations)))
669
+ if diff_result:
670
+ _info("Confidence",
671
+ f"{diff_result.confidence.value} ({diff_result.confidence_score}/100)")
672
+ if annotations:
673
+ click.echo()
674
+ click.secho(" Annotations:", bold=True)
675
+ for ann in annotations:
676
+ if ann.type == "naming_advice":
677
+ icon = click.style("~~", fg="yellow")
678
+ else:
679
+ icon = click.style("!!", fg="red")
680
+ click.echo(f" [{icon}] {ann.note[:80]}")
681
+ click.echo()
682
+ if result.restored_code:
683
+ _info("Code output", code_output)
684
+ _info("Explanation", explanation_output)
685
+ click.echo()
686
+
687
+
688
+ # ── MAP command ─────────────────────────────────────────────────────
689
+
690
+ @main.command("map")
691
+ @click.argument("map_file", type=click.Path(exists=True))
692
+ def show_map(map_file: str):
693
+ """Inspect a ghost map file."""
694
+ ghost_map = GhostMap.load(map_file)
695
+
696
+ _header(f"GHOST MAP — {os.path.basename(map_file)}")
697
+ _info("Total symbols", str(ghost_map.symbol_count))
698
+ _info("Files", ", ".join(ghost_map._metadata.get("files", [])))
699
+ click.echo()
700
+
701
+ forward = ghost_map.forward_map()
702
+ groups: dict[str, list[tuple[str, str]]] = {}
703
+ prefix_names = {
704
+ "gv": "Variables", "gf": "Functions", "gt": "Types",
705
+ "gc": "Constants", "gs": "Strings", "gm": "Macros",
706
+ "gn": "Namespaces",
707
+ }
708
+ prefix_colors = {
709
+ "gv": "white", "gf": "cyan", "gt": "magenta",
710
+ "gc": "yellow", "gs": "green", "gm": "red",
711
+ "gn": "blue",
712
+ }
713
+
714
+ for token, original in sorted(forward.items()):
715
+ prefix = token.split("_")[0]
716
+ groups.setdefault(prefix, []).append((token, original))
717
+
718
+ for prefix, items in groups.items():
719
+ label = prefix_names.get(prefix, prefix)
720
+ color = prefix_colors.get(prefix, "white")
721
+ click.secho(f" {label}:", fg=color, bold=True)
722
+ for token, original in items:
723
+ entry = ghost_map.get_entry(token)
724
+ scope = click.style(f" ({entry.scope})", dim=True) if entry and entry.scope else ""
725
+ click.echo(f" {click.style(token, fg=color)} → {original}{scope}")
726
+ click.echo()
727
+
728
+ if ghost_map.warnings:
729
+ click.secho(f" Warnings ({len(ghost_map.warnings)}):", fg="yellow", bold=True)
730
+ for w in ghost_map.warnings:
731
+ click.echo(f" {w['type']}: {w['symbol']} (line {w['line']})")
732
+
733
+
734
+ # ── STATUS command ──────────────────────────────────────────────────
735
+
736
+ @main.command()
737
+ def status():
738
+ """Show GhostCode status and recent activity."""
739
+ config = load_config()
740
+ audit = AuditLogger()
741
+
742
+ _header("GHOSTCODE STATUS")
743
+ _info("Version", __version__)
744
+
745
+ # Config info
746
+ click.echo()
747
+ click.secho(" Configuration:", bold=True)
748
+ from .config import _find_repo_config
749
+ repo_config_path = _find_repo_config()
750
+ if repo_config_path:
751
+ _info(" Repo policy", repo_config_path)
752
+ else:
753
+ _info(" Repo policy", click.style("none", dim=True))
754
+ user_config = os.path.join(os.path.expanduser("~"), ".ghostcode", "config.yaml")
755
+ if os.path.exists(user_config):
756
+ _info(" User config", user_config)
757
+ else:
758
+ _info(" User config", click.style("none", dim=True))
759
+ _info(" Min level", str(config.min_scrub_level))
760
+ _info(" Default level", str(config.default_scrub_level))
761
+ _info(" Encrypt maps", str(config.encrypt_maps))
762
+ _info(" Audit enforced", str(config.enforce_audit))
763
+ if config.banned_patterns:
764
+ _info(" Banned patterns", ", ".join(config.banned_patterns))
765
+
766
+ # Active maps
767
+ map_dir = os.path.join(".ghostcode", "maps")
768
+ click.echo()
769
+ if os.path.exists(map_dir):
770
+ maps = [f for f in os.listdir(map_dir)
771
+ if f.endswith(".json") or f.endswith(".ghost")]
772
+ if maps:
773
+ click.secho(f" Active Maps ({len(maps)}):", bold=True)
774
+ for m in sorted(maps, reverse=True)[:5]:
775
+ mpath = os.path.join(map_dir, m)
776
+ size = os.path.getsize(mpath)
777
+ if m.endswith(".ghost"):
778
+ tag = click.style("encrypted", fg="green")
779
+ else:
780
+ tag = click.style("plaintext", fg="yellow")
781
+ click.echo(f" {m} ({size} bytes, {tag})")
782
+ else:
783
+ click.secho(" Active Maps: none", dim=True)
784
+ else:
785
+ click.secho(" Active Maps: none", dim=True)
786
+
787
+ # Recent audit
788
+ entries = audit.get_recent_entries(5)
789
+ click.echo()
790
+ if entries:
791
+ click.secho(f" Recent Activity ({len(entries)}):", bold=True)
792
+ for entry in reversed(entries):
793
+ ts = entry.get("timestamp", "?")[:19]
794
+ action = entry.get("action", "?")
795
+ if action == "hide":
796
+ files = entry.get("source_files", [])
797
+ lvl = entry.get("scrub_level", "?")
798
+ syms = entry.get("symbols_scrubbed", 0)
799
+ click.echo(
800
+ f" {click.style(ts, dim=True)} "
801
+ f"{click.style('HIDE', fg='cyan')} "
802
+ f"L{lvl} {syms} symbols {', '.join(files)}"
803
+ )
804
+ elif action == "reveal":
805
+ restored = entry.get("symbols_restored", 0)
806
+ conf = entry.get("confidence", "?")
807
+ click.echo(
808
+ f" {click.style(ts, dim=True)} "
809
+ f"{click.style('REVEAL', fg='green')} "
810
+ f"{restored} restored confidence={conf}"
811
+ )
812
+ else:
813
+ click.secho(" Recent Activity: none", dim=True)
814
+ click.echo()
815
+
816
+
817
+ # ── DEMO command ────────────────────────────────────────────────────
818
+
819
+ @main.command()
820
+ @click.option("--lang", type=click.Choice(["cpp", "python"]), default="python",
821
+ help="Demo language (default: python)")
822
+ def demo(lang: str):
823
+ """Run an interactive GhostCode demo.
824
+
825
+ Shows the full hide → AI → reveal workflow with a real code sample.
826
+ Perfect for hackathon presentations and onboarding.
827
+ """
828
+ import time
829
+
830
+ # Find fixture
831
+ fixture_dir = os.path.join(os.path.dirname(__file__), "..", "tests", "fixtures")
832
+ if not os.path.exists(fixture_dir):
833
+ fixture_dir = os.path.join(os.path.dirname(__file__), "fixtures")
834
+ fixture = os.path.join(fixture_dir, f"sample.{'py' if lang == 'python' else 'cpp'}")
835
+
836
+ if not os.path.exists(fixture):
837
+ _error(f"Demo fixture not found at {fixture}. Run from project root.")
838
+
839
+ click.echo(click.style(BANNER, fg="cyan", bold=True))
840
+ click.secho(" Privacy Proxy for Developers", fg="white", bold=True)
841
+ click.secho(" Your code stays anonymous. Your logic stays intact.\n", fg="white", dim=True)
842
+
843
+ # Read original
844
+ with open(fixture) as f:
845
+ original = f.read()
846
+
847
+ # ── Step 1: Show original ─────────────────────────────────────
848
+ click.secho(" STEP 1: Your original code (CONFIDENTIAL)", fg="red", bold=True)
849
+ _divider()
850
+ _show_code_preview(original, lang, max_lines=20)
851
+ click.echo()
852
+ _pause("Press Enter to see what GhostCode does...")
853
+
854
+ # ── Step 2: Run hide ──────────────────────────────────────────
855
+ click.secho("\n STEP 2: ghost hide (stripping business context)", fg="cyan", bold=True)
856
+ _divider()
857
+
858
+ parser = _get_parser(lang if lang == "python" else "cpp")
859
+ ext = ".py" if lang == "python" else ".cpp"
860
+
861
+ with tempfile.NamedTemporaryFile(mode="w", suffix=ext, delete=False) as tmp:
862
+ tmp.write(original)
863
+ tmp_path = tmp.name
864
+
865
+ try:
866
+ parse_result = parser.parse(tmp_path)
867
+ parse_result.source_code = original
868
+ parse_result.file_path = f"sample{ext}"
869
+
870
+ # Strip comments
871
+ stripper = CommentStripper()
872
+ clean_source, comment_count = stripper.strip(original, parse_result.comments)
873
+ _step("Comments stripped", f"{comment_count} removed")
874
+ time.sleep(0.3)
875
+
876
+ # Re-parse
877
+ with open(tmp_path, "w") as f:
878
+ f.write(clean_source)
879
+ clean_parse = parser.parse(tmp_path)
880
+ clean_parse.source_code = clean_source
881
+ clean_parse.file_path = f"sample{ext}"
882
+
883
+ # Rename
884
+ ghost_map = GhostMap()
885
+ renamer = SymbolRenamer(ghost_map)
886
+ ghost_source = renamer.rename(clean_parse)
887
+ _step("Symbols renamed", f"{ghost_map.symbol_count} → ghost tokens")
888
+ time.sleep(0.3)
889
+
890
+ # Literal scrub
891
+ scrubber = LiteralScrubber(ghost_map)
892
+ original_names = {e.original for e in ghost_map._entries.values()}
893
+ scrubber.set_known_symbols(original_names)
894
+ scrub_result = scrubber.scrub(ghost_source, f"sample{ext}")
895
+ ghost_source = scrub_result.source
896
+ _step("Literals scrubbed",
897
+ f"{len(scrub_result.scrubbed)} domain fingerprints removed")
898
+ time.sleep(0.3)
899
+
900
+ finally:
901
+ os.unlink(tmp_path)
902
+
903
+ click.echo()
904
+ click.secho(" Ghost output (safe to send to ANY AI):", fg="green", bold=True)
905
+ _divider()
906
+ _show_code_preview(ghost_source, lang, max_lines=20)
907
+ click.echo()
908
+ _pause("Press Enter to see the mapping...")
909
+
910
+ # ── Step 3: Show map ──────────────────────────────────────────
911
+ click.secho("\n STEP 3: Ghost Map (your private key)", fg="magenta", bold=True)
912
+ _divider()
913
+ forward = ghost_map.forward_map()
914
+ shown = 0
915
+ for token, orig in sorted(forward.items()):
916
+ if shown >= 10:
917
+ remaining = len(forward) - shown
918
+ click.secho(f" ... and {remaining} more", dim=True)
919
+ break
920
+ entry = ghost_map.get_entry(token)
921
+ kind = entry.kind if entry else "?"
922
+ click.echo(
923
+ f" {click.style(token, fg='cyan')} → "
924
+ f"{click.style(orig, fg='white', bold=True)} "
925
+ f"{click.style(f'({kind})', dim=True)}"
926
+ )
927
+ shown += 1
928
+
929
+ click.echo()
930
+ _pause("Press Enter to see the reveal...")
931
+
932
+ # ── Step 4: Simulate AI response and reveal ───────────────────
933
+ click.secho("\n STEP 4: AI responds → ghost reveal restores your names", fg="green", bold=True)
934
+ _divider()
935
+
936
+ # Simulate: reveal the ghost source back (as if AI returned it unchanged)
937
+ from .reveal.code_revealer import CodeRevealer
938
+ revealer = CodeRevealer(ghost_map)
939
+ restored, count, new_syms = revealer.reveal_code(ghost_source)
940
+ _step("Symbols restored", f"{count} ghost tokens → original names")
941
+
942
+ click.echo()
943
+ click.secho(" Restored code (back to YOUR names):", fg="green", bold=True)
944
+ _divider()
945
+ _show_code_preview(restored, lang, max_lines=15)
946
+
947
+ # ── Final pitch ───────────────────────────────────────────────
948
+ click.echo()
949
+ _divider()
950
+ click.secho(" GhostCode doesn't make your code invisible.", fg="white", bold=True)
951
+ click.secho(" It makes your code anonymous.", fg="cyan", bold=True)
952
+ click.echo()
953
+ click.secho(" Features:", bold=True)
954
+ click.echo(" • AST-based parsing (not regex) — understands your code structure")
955
+ click.echo(" • 4 privacy levels — from name+comment stripping to full isolation")
956
+ click.echo(" • Smart literal classification — scrubs domains, keeps math constants")
957
+ click.echo(" • Zone-aware reveal — handles AI prose, code blocks, inline code")
958
+ click.echo(" • Map encryption — AES-encrypted mapping files")
959
+ click.echo(" • Audit logging — compliance-ready with SHA-256 hashes")
960
+ click.echo(" • Clipboard integration — paste directly into ChatGPT/Claude")
961
+ click.echo()
962
+ click.secho(" ghost hide app.py && paste into AI && ghost reveal response.md", fg="cyan")
963
+ click.echo()
964
+
965
+
966
+ def _show_code_preview(code: str, lang: str, max_lines: int = 20):
967
+ """Show a syntax-highlighted code preview."""
968
+ lines = code.split("\n")
969
+ for i, line in enumerate(lines[:max_lines]):
970
+ lineno = click.style(f" {i + 1:3d} │ ", dim=True)
971
+ click.echo(f" {lineno}{line}")
972
+ if len(lines) > max_lines:
973
+ click.secho(f" ... ({len(lines) - max_lines} more lines)", dim=True)
974
+
975
+
976
+ def _pause(msg: str):
977
+ """Pause for demo mode."""
978
+ click.secho(f" {msg}", fg="yellow", dim=True)
979
+ try:
980
+ input()
981
+ except (EOFError, KeyboardInterrupt):
982
+ click.echo()
983
+
984
+
985
+ if __name__ == "__main__":
986
+ main()