| 1 | """ |
| 2 | OVERSIGHT Rich CLI -- Interactive command-line interface with rich output. |
| 3 | |
| 4 | Provides the `oversight` command with colorful, structured output for all |
| 5 | Oversight Protocol operations: key management, sealing, opening, inspection, |
| 6 | attribution, and registry interaction. |
| 7 | |
| 8 | Entry point: main() |
| 9 | """ |
| 10 | |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | import argparse |
| 14 | import json |
| 15 | import os |
| 16 | import sys |
| 17 | import time |
| 18 | from pathlib import Path |
| 19 | from typing import Optional |
| 20 | |
| 21 | ROOT = Path(__file__).resolve().parent.parent |
| 22 | sys.path.insert(0, str(ROOT)) |
| 23 | |
| 24 | from rich.console import Console |
| 25 | from rich.panel import Panel |
| 26 | from rich.table import Table |
| 27 | from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn |
| 28 | from rich.prompt import Prompt, Confirm |
| 29 | from rich.text import Text |
| 30 | from rich.columns import Columns |
| 31 | from rich.rule import Rule |
| 32 | from rich import box |
| 33 | |
| 34 | import httpx |
| 35 | |
| 36 | from oversight_core import ( |
| 37 | ClassicIdentity, |
| 38 | Manifest, |
| 39 | Recipient, |
| 40 | WatermarkRef, |
| 41 | content_hash, |
| 42 | seal, |
| 43 | open_sealed, |
| 44 | beacon, |
| 45 | watermark, |
| 46 | l3_policy, |
| 47 | __version__ as core_version, |
| 48 | ) |
| 49 | from oversight_core.container import SealedFile |
| 50 | from oversight_core.fingerprint import ContentFingerprint |
| 51 | from oversight_core.safe_io import ( |
| 52 | atomic_write_bytes, |
| 53 | atomic_write_private_json, |
| 54 | atomic_write_text, |
| 55 | validate_output_path, |
| 56 | ) |
| 57 | |
| 58 | |
| 59 | CLI_VERSION = "0.4.5" |
| 60 | CONFIG_FILENAME = "config.json" |
| 61 | CONFIG_DIR_NAME = ".oversight" |
| 62 | |
| 63 | console = Console() |
| 64 | err_console = Console(stderr=True) |
| 65 | |
| 66 | |
| 67 | |
| 68 | def print_banner(): |
| 69 | """Display the startup banner with version info.""" |
| 70 | banner_text = Text() |
| 71 | banner_text.append("OVERSIGHT", style="bold bright_white") |
| 72 | banner_text.append(" PROTOCOL", style="bold cyan") |
| 73 | |
| 74 | version_line = Text() |
| 75 | version_line.append(f"cli v{CLI_VERSION}", style="dim") |
| 76 | version_line.append(" | ", style="dim") |
| 77 | version_line.append(f"core v{core_version}", style="dim") |
| 78 | version_line.append(" | ", style="dim") |
| 79 | version_line.append("Sealed Entity, Notarized Trust", style="dim italic") |
| 80 | |
| 81 | combined = Text() |
| 82 | combined.append(banner_text) |
| 83 | combined.append("\n") |
| 84 | combined.append(version_line) |
| 85 | |
| 86 | console.print(Panel( |
| 87 | combined, |
| 88 | border_style="cyan", |
| 89 | padding=(0, 2), |
| 90 | )) |
| 91 | |
| 92 | |
| 93 | |
| 94 | def find_config_dir() -> Optional[Path]: |
| 95 | """ |
| 96 | Search for .oversight/ directory. Order: |
| 97 | 1. Current working directory |
| 98 | 2. Parent directories (up to root) |
| 99 | 3. ~/.oversight/ |
| 100 | Returns the path if found, None otherwise. |
| 101 | """ |
| 102 | check = Path.cwd() |
| 103 | while True: |
| 104 | candidate = check / CONFIG_DIR_NAME |
| 105 | if candidate.is_dir(): |
| 106 | return candidate |
| 107 | parent = check.parent |
| 108 | if parent == check: |
| 109 | break |
| 110 | check = parent |
| 111 | |
| 112 | home_config = Path.home() / CONFIG_DIR_NAME |
| 113 | if home_config.is_dir(): |
| 114 | return home_config |
| 115 | |
| 116 | return None |
| 117 | |
| 118 | |
| 119 | def load_config() -> dict: |
| 120 | """Load config from discovered .oversight/ directory. Returns empty dict if not found.""" |
| 121 | config_dir = find_config_dir() |
| 122 | if config_dir is None: |
| 123 | return {} |
| 124 | config_file = config_dir / CONFIG_FILENAME |
| 125 | if not config_file.exists(): |
| 126 | return {"_config_dir": str(config_dir)} |
| 127 | try: |
| 128 | cfg = json.loads(config_file.read_text()) |
| 129 | cfg["_config_dir"] = str(config_dir) |
| 130 | return cfg |
| 131 | except (json.JSONDecodeError, OSError) as e: |
| 132 | err_console.print(f"[yellow]Warning: failed to read config: {e}[/]") |
| 133 | return {"_config_dir": str(config_dir)} |
| 134 | |
| 135 | |
| 136 | def save_config(config_dir: Path, config: dict) -> None: |
| 137 | """Write config to the given .oversight/ directory.""" |
| 138 | clean = {k: v for k, v in config.items() if not k.startswith("_")} |
| 139 | config_file = config_dir / CONFIG_FILENAME |
| 140 | config_file.write_text(json.dumps(clean, indent=2)) |
| 141 | |
| 142 | |
| 143 | def config_dir_from_cfg(cfg: dict) -> Optional[Path]: |
| 144 | """Extract the config directory path from a loaded config dict.""" |
| 145 | raw = cfg.get("_config_dir") |
| 146 | if raw: |
| 147 | return Path(raw) |
| 148 | return None |
| 149 | |
| 150 | |
| 151 | |
| 152 | def error_panel(message: str, suggestion: str = "") -> None: |
| 153 | """Print a red error panel with optional suggestion.""" |
| 154 | body = Text(message, style="bold red") |
| 155 | if suggestion: |
| 156 | body.append(f"\n\nSuggestion: {suggestion}", style="yellow") |
| 157 | console.print(Panel(body, title="[red]Error[/]", border_style="red", padding=(0, 2))) |
| 158 | |
| 159 | |
| 160 | def success(message: str) -> None: |
| 161 | console.print(f"[green][+][/] {message}") |
| 162 | |
| 163 | |
| 164 | def warn(message: str) -> None: |
| 165 | console.print(f"[yellow][!][/] {message}") |
| 166 | |
| 167 | |
| 168 | def info(message: str) -> None: |
| 169 | console.print(f"[cyan][*][/] {message}") |
| 170 | |
| 171 | |
| 172 | def format_hex_short(hex_str: str, length: int = 16) -> str: |
| 173 | """Shorten a hex string for display.""" |
| 174 | if len(hex_str) <= length: |
| 175 | return hex_str |
| 176 | return hex_str[:length] + "..." |
| 177 | |
| 178 | |
| 179 | |
| 180 | def cmd_init(args): |
| 181 | """Initialize a .oversight/ directory with config.""" |
| 182 | target = Path(args.path) if args.path else Path.cwd() |
| 183 | config_dir = target / CONFIG_DIR_NAME |
| 184 | |
| 185 | if config_dir.exists() and not args.force: |
| 186 | error_panel( |
| 187 | f"Directory already exists: {config_dir}", |
| 188 | "Use --force to reinitialize." |
| 189 | ) |
| 190 | sys.exit(1) |
| 191 | |
| 192 | config_dir.mkdir(parents=True, exist_ok=True) |
| 193 | (config_dir / "recipients").mkdir(exist_ok=True) |
| 194 | (config_dir / "fingerprints").mkdir(exist_ok=True) |
| 195 | |
| 196 | config = { |
| 197 | "issuer_identity": "", |
| 198 | "registry_url": args.registry_url or "http://localhost:8000", |
| 199 | "registry_domain": args.registry_domain or "oversightprotocol.dev", |
| 200 | "default_watermark": True, |
| 201 | "content_type": "application/octet-stream", |
| 202 | } |
| 203 | |
| 204 | save_config(config_dir, config) |
| 205 | |
| 206 | console.print(Panel( |
| 207 | Text.assemble( |
| 208 | ("Initialized .oversight/ directory\n\n", "bold green"), |
| 209 | ("Location: ", ""), |
| 210 | (str(config_dir), "cyan"), |
| 211 | ("\n\nCreated:\n", ""), |
| 212 | (" config.json ", "white"), |
| 213 | ("- project configuration\n", "dim"), |
| 214 | (" recipients/ ", "white"), |
| 215 | ("- recipient public keys\n", "dim"), |
| 216 | (" fingerprints/ ", "white"), |
| 217 | ("- content fingerprints", "dim"), |
| 218 | ), |
| 219 | title="[green]Init Complete[/]", |
| 220 | border_style="green", |
| 221 | padding=(0, 2), |
| 222 | )) |
| 223 | |
| 224 | if not config["issuer_identity"]: |
| 225 | warn("No issuer identity set. Run: oversight keys generate") |
| 226 | |
| 227 | |
| 228 | |
| 229 | def cmd_keys_generate(args): |
| 230 | """Generate a new identity keypair.""" |
| 231 | cfg = load_config() |
| 232 | config_dir = config_dir_from_cfg(cfg) |
| 233 | |
| 234 | identity_name = args.name or "identity" |
| 235 | |
| 236 | if args.out: |
| 237 | out_path = Path(args.out) |
| 238 | elif config_dir: |
| 239 | out_path = config_dir / f"{identity_name}.json" |
| 240 | else: |
| 241 | out_path = Path(f"{identity_name}.json") |
| 242 | |
| 243 | if out_path.exists() and not args.force: |
| 244 | error_panel( |
| 245 | f"Identity file already exists: {out_path}", |
| 246 | "Use --force to overwrite." |
| 247 | ) |
| 248 | sys.exit(1) |
| 249 | pub_path = out_path.with_suffix(".pub.json") |
| 250 | try: |
| 251 | validate_output_path(out_path, allow_existing=args.force) |
| 252 | validate_output_path(pub_path, input_paths=[out_path], allow_existing=args.force) |
| 253 | except (ValueError, FileExistsError) as exc: |
| 254 | error_panel(str(exc)) |
| 255 | sys.exit(1) |
| 256 | |
| 257 | with Progress( |
| 258 | SpinnerColumn(), |
| 259 | TextColumn("[progress.description]{task.description}"), |
| 260 | transient=True, |
| 261 | ) as progress: |
| 262 | task = progress.add_task("Generating X25519 + Ed25519 keypair...", total=None) |
| 263 | ident = ClassicIdentity.generate() |
| 264 | |
| 265 | priv_data = { |
| 266 | "id": identity_name, |
| 267 | "x25519_priv": ident.x25519_priv.hex(), |
| 268 | "x25519_pub": ident.x25519_pub.hex(), |
| 269 | "ed25519_priv": ident.ed25519_priv.hex(), |
| 270 | "ed25519_pub": ident.ed25519_pub.hex(), |
| 271 | } |
| 272 | |
| 273 | pub_data = { |
| 274 | "id": identity_name, |
| 275 | "x25519_pub": ident.x25519_pub.hex(), |
| 276 | "ed25519_pub": ident.ed25519_pub.hex(), |
| 277 | } |
| 278 | |
| 279 | atomic_write_private_json(out_path, priv_data) |
| 280 | atomic_write_text(pub_path, json.dumps(pub_data, indent=2)) |
| 281 | |
| 282 | if config_dir and not args.out: |
| 283 | cfg["issuer_identity"] = str(out_path) |
| 284 | save_config(config_dir, cfg) |
| 285 | |
| 286 | table = Table(title="Generated Identity", box=box.ROUNDED, border_style="green") |
| 287 | table.add_column("Field", style="cyan") |
| 288 | table.add_column("Value", style="white") |
| 289 | table.add_row("Name", identity_name) |
| 290 | table.add_row("Private key", str(out_path)) |
| 291 | table.add_row("Public key", str(pub_path)) |
| 292 | table.add_row("X25519 pub", format_hex_short(ident.x25519_pub.hex(), 32)) |
| 293 | table.add_row("Ed25519 pub", format_hex_short(ident.ed25519_pub.hex(), 32)) |
| 294 | table.add_row("Suite", "OSGT-CLASSIC-v1 (X25519 + Ed25519)") |
| 295 | console.print(table) |
| 296 | success("Identity generated. Share the .pub.json file with senders.") |
| 297 | |
| 298 | |
| 299 | |
| 300 | def cmd_keys_list(args): |
| 301 | """List all known identities and recipients.""" |
| 302 | cfg = load_config() |
| 303 | config_dir = config_dir_from_cfg(cfg) |
| 304 | |
| 305 | if not config_dir: |
| 306 | error_panel( |
| 307 | "No .oversight/ directory found.", |
| 308 | "Run: oversight init" |
| 309 | ) |
| 310 | sys.exit(1) |
| 311 | |
| 312 | identity_files = [ |
| 313 | f for f in config_dir.glob("*.json") |
| 314 | if not f.name.endswith(".pub.json") and f.name != CONFIG_FILENAME |
| 315 | ] |
| 316 | |
| 317 | table = Table(title="Identities", box=box.ROUNDED, border_style="cyan") |
| 318 | table.add_column("Name", style="bold") |
| 319 | table.add_column("Ed25519 Public Key", style="white") |
| 320 | table.add_column("X25519 Public Key", style="white") |
| 321 | table.add_column("File", style="dim") |
| 322 | |
| 323 | for f in identity_files: |
| 324 | try: |
| 325 | data = json.loads(f.read_text()) |
| 326 | is_active = str(f) == cfg.get("issuer_identity", "") |
| 327 | name = data.get("id", f.stem) |
| 328 | if is_active: |
| 329 | name = f"[green]{name} (active)[/green]" |
| 330 | table.add_row( |
| 331 | name, |
| 332 | format_hex_short(data.get("ed25519_pub", ""), 24), |
| 333 | format_hex_short(data.get("x25519_pub", ""), 24), |
| 334 | f.name, |
| 335 | ) |
| 336 | except (json.JSONDecodeError, OSError): |
| 337 | table.add_row(f.stem, "[red]error reading[/]", "", f.name) |
| 338 | |
| 339 | console.print(table) |
| 340 | |
| 341 | recipients_dir = config_dir / "recipients" |
| 342 | recipient_files = list(recipients_dir.glob("*.json")) if recipients_dir.exists() else [] |
| 343 | |
| 344 | if recipient_files: |
| 345 | rtable = Table(title="Recipients", box=box.ROUNDED, border_style="yellow") |
| 346 | rtable.add_column("ID", style="bold") |
| 347 | rtable.add_column("Ed25519 Public Key", style="white") |
| 348 | rtable.add_column("X25519 Public Key", style="white") |
| 349 | rtable.add_column("File", style="dim") |
| 350 | |
| 351 | for f in recipient_files: |
| 352 | try: |
| 353 | data = json.loads(f.read_text()) |
| 354 | rtable.add_row( |
| 355 | data.get("id", f.stem), |
| 356 | format_hex_short(data.get("ed25519_pub", ""), 24), |
| 357 | format_hex_short(data.get("x25519_pub", ""), 24), |
| 358 | f.name, |
| 359 | ) |
| 360 | except (json.JSONDecodeError, OSError): |
| 361 | rtable.add_row(f.stem, "[red]error reading[/]", "", f.name) |
| 362 | |
| 363 | console.print(rtable) |
| 364 | else: |
| 365 | info("No recipients imported. Use: oversight keys import <file>") |
| 366 | |
| 367 | |
| 368 | |
| 369 | def cmd_keys_import(args): |
| 370 | """Import a recipient's public key.""" |
| 371 | cfg = load_config() |
| 372 | config_dir = config_dir_from_cfg(cfg) |
| 373 | |
| 374 | if not config_dir: |
| 375 | error_panel( |
| 376 | "No .oversight/ directory found.", |
| 377 | "Run: oversight init" |
| 378 | ) |
| 379 | sys.exit(1) |
| 380 | |
| 381 | source = Path(args.file) |
| 382 | if not source.exists(): |
| 383 | error_panel(f"File not found: {source}") |
| 384 | sys.exit(1) |
| 385 | |
| 386 | try: |
| 387 | data = json.loads(source.read_text()) |
| 388 | except (json.JSONDecodeError, OSError) as e: |
| 389 | error_panel(f"Failed to parse key file: {e}") |
| 390 | sys.exit(1) |
| 391 | |
| 392 | if "x25519_pub" not in data: |
| 393 | error_panel( |
| 394 | "Key file missing x25519_pub field.", |
| 395 | "Ensure this is a valid Oversight public key (.pub.json)." |
| 396 | ) |
| 397 | sys.exit(1) |
| 398 | |
| 399 | recipients_dir = config_dir / "recipients" |
| 400 | recipients_dir.mkdir(exist_ok=True) |
| 401 | |
| 402 | name = data.get("id", source.stem) |
| 403 | dest = recipients_dir / f"{name}.pub.json" |
| 404 | |
| 405 | if dest.exists() and not args.force: |
| 406 | error_panel( |
| 407 | f"Recipient already exists: {dest}", |
| 408 | "Use --force to overwrite." |
| 409 | ) |
| 410 | sys.exit(1) |
| 411 | |
| 412 | dest.write_text(json.dumps(data, indent=2)) |
| 413 | success(f"Imported recipient '{name}' to {dest}") |
| 414 | |
| 415 | |
| 416 | |
| 417 | def cmd_seal(args): |
| 418 | """Seal a file for a recipient with full rich output.""" |
| 419 | cfg = load_config() |
| 420 | config_dir = config_dir_from_cfg(cfg) |
| 421 | |
| 422 | input_path = Path(args.input) |
| 423 | if not input_path.exists(): |
| 424 | error_panel(f"Input file not found: {input_path}") |
| 425 | sys.exit(1) |
| 426 | |
| 427 | issuer_key_path = args.issuer_key |
| 428 | if not issuer_key_path and cfg.get("issuer_identity"): |
| 429 | issuer_key_path = cfg["issuer_identity"] |
| 430 | if not issuer_key_path: |
| 431 | error_panel( |
| 432 | "No issuer identity specified.", |
| 433 | "Use --issuer-key or set issuer_identity in config. Run: oversight keys generate" |
| 434 | ) |
| 435 | sys.exit(1) |
| 436 | |
| 437 | issuer_key_path = Path(issuer_key_path) |
| 438 | if not issuer_key_path.exists(): |
| 439 | error_panel(f"Issuer key file not found: {issuer_key_path}") |
| 440 | sys.exit(1) |
| 441 | |
| 442 | recipient_pub_path = args.to |
| 443 | if not recipient_pub_path and config_dir: |
| 444 | rdir = config_dir / "recipients" |
| 445 | if rdir.exists(): |
| 446 | rfiles = list(rdir.glob("*.json")) |
| 447 | if len(rfiles) == 1: |
| 448 | recipient_pub_path = str(rfiles[0]) |
| 449 | |
| 450 | if not recipient_pub_path: |
| 451 | error_panel( |
| 452 | "No recipient specified.", |
| 453 | "Use --to <recipient.pub.json> or place a single key in .oversight/recipients/" |
| 454 | ) |
| 455 | sys.exit(1) |
| 456 | |
| 457 | recipient_pub_path = Path(recipient_pub_path) |
| 458 | if not recipient_pub_path.exists(): |
| 459 | if config_dir: |
| 460 | candidate = config_dir / "recipients" / f"{recipient_pub_path}.pub.json" |
| 461 | if candidate.exists(): |
| 462 | recipient_pub_path = candidate |
| 463 | else: |
| 464 | candidate = config_dir / "recipients" / str(recipient_pub_path) |
| 465 | if candidate.exists(): |
| 466 | recipient_pub_path = candidate |
| 467 | if not recipient_pub_path.exists(): |
| 468 | error_panel(f"Recipient key file not found: {recipient_pub_path}") |
| 469 | sys.exit(1) |
| 470 | |
| 471 | issuer = json.loads(issuer_key_path.read_text()) |
| 472 | rec_pub = json.loads(recipient_pub_path.read_text()) |
| 473 | plaintext = input_path.read_bytes() |
| 474 | |
| 475 | out_path = Path(args.out) if args.out else input_path.with_suffix(".sealed") |
| 476 | try: |
| 477 | validate_output_path(out_path, input_paths=[input_path, issuer_key_path, recipient_pub_path]) |
| 478 | except (ValueError, FileExistsError) as exc: |
| 479 | error_panel(str(exc)) |
| 480 | sys.exit(1) |
| 481 | |
| 482 | registry_url = args.registry_url or cfg.get("registry_url", "http://localhost:8000") |
| 483 | registry_domain = args.registry_domain or cfg.get("registry_domain", "oversightprotocol.dev") |
| 484 | issuer_id = args.issuer_id or issuer.get("id", "issuer") |
| 485 | do_watermark = args.watermark if args.watermark is not None else cfg.get("default_watermark", True) |
| 486 | content_type_val = args.content_type or cfg.get("content_type", "application/octet-stream") |
| 487 | |
| 488 | canonical_plaintext = plaintext |
| 489 | watermarks_for_manifest: list[WatermarkRef] = [] |
| 490 | fingerprint = None |
| 491 | mark_id = None |
| 492 | l3_decision = None |
| 493 | |
| 494 | with Progress( |
| 495 | SpinnerColumn(), |
| 496 | TextColumn("[progress.description]{task.description}"), |
| 497 | BarColumn(bar_width=30), |
| 498 | TaskProgressColumn(), |
| 499 | console=console, |
| 500 | ) as progress: |
| 501 | total_steps = 7 if do_watermark else 4 |
| 502 | task = progress.add_task("Sealing...", total=total_steps) |
| 503 | |
| 504 | if do_watermark: |
| 505 | try: |
| 506 | text = plaintext.decode("utf-8") |
| 507 | except UnicodeDecodeError: |
| 508 | warn("Input is not UTF-8 text; skipping watermarks.") |
| 509 | text = None |
| 510 | progress.advance(task, 3) |
| 511 | |
| 512 | if text is not None: |
| 513 | mark_id = watermark.new_mark_id() |
| 514 | l3_decision = l3_policy.decide_l3( |
| 515 | filename=str(input_path), |
| 516 | content_type=content_type_val, |
| 517 | text=text, |
| 518 | declared_class=args.document_class, |
| 519 | requested_mode=args.l3_mode, |
| 520 | ) |
| 521 | |
| 522 | progress.update(task, description="Evaluating L3 safety policy...") |
| 523 | if l3_decision.enabled: |
| 524 | progress.stop() |
| 525 | if not args.l3_ack: |
| 526 | console.print(Panel( |
| 527 | "L3 semantic watermarking changes visible prose. " |
| 528 | f"Class: [bold]{l3_decision.document_class}[/], " |
| 529 | f"mode: [bold]{l3_decision.mode}[/].\n\n" |
| 530 | "Enable only when you accept that the recipient copy " |
| 531 | "is textually non-identical to the canonical source.", |
| 532 | title="[yellow]L3 Disclosure[/]", |
| 533 | border_style="yellow", |
| 534 | )) |
| 535 | if not Confirm.ask("Acknowledge and apply L3?", default=False): |
| 536 | error_panel("L3 not acknowledged. Re-run with --l3-mode off or --l3-ack.") |
| 537 | sys.exit(1) |
| 538 | progress.start() |
| 539 | progress.update(task, description=f"Watermarking L3 ({l3_decision.mode})...") |
| 540 | text = l3_policy.apply_l3_safe(text, mark_id, mode=l3_decision.mode) |
| 541 | else: |
| 542 | progress.update(task, description=f"Skipping L3: {l3_decision.document_class}") |
| 543 | progress.advance(task) |
| 544 | |
| 545 | progress.update(task, description="Watermarking L2 (whitespace)...") |
| 546 | text = watermark.embed_ws(text, mark_id) |
| 547 | progress.advance(task) |
| 548 | |
| 549 | progress.update(task, description="Watermarking L1 (zero-width)...") |
| 550 | text = watermark.embed_zw(text, mark_id) |
| 551 | plaintext = text.encode("utf-8") |
| 552 | progress.advance(task) |
| 553 | |
| 554 | watermarks_for_manifest = [ |
| 555 | WatermarkRef(layer="L1_zero_width", mark_id=mark_id.hex()), |
| 556 | WatermarkRef(layer="L2_whitespace", mark_id=mark_id.hex()), |
| 557 | ] |
| 558 | if l3_decision and l3_decision.enabled: |
| 559 | watermarks_for_manifest.append( |
| 560 | WatermarkRef(layer=f"L3_semantic_{l3_decision.mode}", mark_id=mark_id.hex()) |
| 561 | ) |
| 562 | |
| 563 | progress.update(task, description="Building manifest...") |
| 564 | recipient_obj = Recipient( |
| 565 | recipient_id=rec_pub["id"], |
| 566 | x25519_pub=rec_pub["x25519_pub"], |
| 567 | ed25519_pub=rec_pub.get("ed25519_pub"), |
| 568 | ) |
| 569 | |
| 570 | manifest = Manifest.new( |
| 571 | original_filename=input_path.name, |
| 572 | content_hash=content_hash(plaintext), |
| 573 | size_bytes=len(plaintext), |
| 574 | issuer_id=issuer_id, |
| 575 | issuer_ed25519_pub_hex=issuer["ed25519_pub"], |
| 576 | recipient=recipient_obj, |
| 577 | registry_url=registry_url, |
| 578 | content_type=content_type_val, |
| 579 | ) |
| 580 | manifest.canonical_content_hash = content_hash(canonical_plaintext) |
| 581 | if l3_decision: |
| 582 | manifest.l3_policy = l3_decision.to_dict() |
| 583 | beacons = beacon.gen_beacons( |
| 584 | registry_domain=registry_domain, |
| 585 | file_id=manifest.file_id, |
| 586 | recipient_id=rec_pub["id"], |
| 587 | ) |
| 588 | manifest.watermarks = watermarks_for_manifest |
| 589 | manifest.beacons = [b.to_dict() for b in beacons] |
| 590 | progress.advance(task) |
| 591 | |
| 592 | progress.update(task, description="Computing content fingerprint...") |
| 593 | try: |
| 594 | fp_text = plaintext.decode("utf-8") |
| 595 | fingerprint = ContentFingerprint.from_text(fp_text) |
| 596 | except UnicodeDecodeError: |
| 597 | pass |
| 598 | progress.advance(task) |
| 599 | |
| 600 | progress.update(task, description="Encrypting (XChaCha20-Poly1305)...") |
| 601 | blob = seal( |
| 602 | plaintext=plaintext, |
| 603 | manifest=manifest, |
| 604 | issuer_ed25519_priv=bytes.fromhex(issuer["ed25519_priv"]), |
| 605 | recipient_x25519_pub=bytes.fromhex(rec_pub["x25519_pub"]), |
| 606 | ) |
| 607 | progress.advance(task) |
| 608 | |
| 609 | progress.update(task, description="Writing sealed file...") |
| 610 | atomic_write_bytes(out_path, blob) |
| 611 | |
| 612 | if fingerprint: |
| 613 | fp_path = out_path.with_suffix(".fingerprint.json") |
| 614 | atomic_write_text(fp_path, json.dumps({ |
| 615 | "file_id": manifest.file_id, |
| 616 | "recipient_id": rec_pub["id"], |
| 617 | "mark_id": mark_id.hex() if mark_id else None, |
| 618 | "canonical_content_hash": manifest.canonical_content_hash, |
| 619 | "l3_policy": manifest.l3_policy, |
| 620 | "fingerprint": fingerprint.to_dict(), |
| 621 | }, indent=2)) |
| 622 | |
| 623 | progress.advance(task) |
| 624 | |
| 625 | table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) |
| 626 | table.add_column("Key", style="cyan") |
| 627 | table.add_column("Value", style="white") |
| 628 | table.add_row("Output", str(out_path)) |
| 629 | table.add_row("Size", f"{len(blob):,} bytes") |
| 630 | table.add_row("File ID", manifest.file_id) |
| 631 | table.add_row("Issuer", issuer_id) |
| 632 | table.add_row("Recipient", rec_pub["id"]) |
| 633 | table.add_row("Watermarks", str(len(watermarks_for_manifest))) |
| 634 | if l3_decision: |
| 635 | table.add_row("L3 policy", f"{l3_decision.mode} ({l3_decision.document_class})") |
| 636 | table.add_row("Beacons", str(len(beacons))) |
| 637 | table.add_row("Suite", "OSGT-CLASSIC-v1") |
| 638 | if mark_id: |
| 639 | table.add_row("Mark ID", mark_id.hex()) |
| 640 | if fingerprint: |
| 641 | table.add_row("Fingerprint", f"{len(fingerprint.winnowing_fp)} winnow, {len(fingerprint.sentence_fp)} sentence hashes") |
| 642 | |
| 643 | console.print(Panel(table, title="[green]Sealed[/]", border_style="green", padding=(0, 1))) |
| 644 | |
| 645 | register_url = args.register or cfg.get("auto_register") |
| 646 | if register_url: |
| 647 | _do_register(register_url, manifest, beacons, watermarks_for_manifest, fingerprint) |
| 648 | |
| 649 | |
| 650 | def _do_register(register_url: str, manifest, beacons, watermarks_for_manifest, fingerprint): |
| 651 | """Register with the registry server.""" |
| 652 | reg_payload = { |
| 653 | "manifest": manifest.to_dict(), |
| 654 | "beacons": [b.to_dict() for b in beacons], |
| 655 | "watermarks": [w.__dict__ for w in watermarks_for_manifest], |
| 656 | } |
| 657 | if fingerprint: |
| 658 | reg_payload["fingerprint"] = fingerprint.to_dict() |
| 659 | try: |
| 660 | resp = httpx.post( |
| 661 | f"{register_url.rstrip('/')}/register", |
| 662 | json=reg_payload, |
| 663 | timeout=10, |
| 664 | ) |
| 665 | resp.raise_for_status() |
| 666 | data = resp.json() |
| 667 | success(f"Registered with {register_url}: tlog_index={data.get('tlog_index')}") |
| 668 | except Exception as e: |
| 669 | warn(f"Registry registration failed: {e}") |
| 670 | |
| 671 | |
| 672 | |
| 673 | def cmd_open(args): |
| 674 | """Open (decrypt) a sealed file.""" |
| 675 | cfg = load_config() |
| 676 | |
| 677 | input_path = Path(args.input) |
| 678 | if not input_path.exists(): |
| 679 | error_panel(f"Sealed file not found: {input_path}") |
| 680 | sys.exit(1) |
| 681 | |
| 682 | identity_path = args.identity |
| 683 | if not identity_path and cfg.get("issuer_identity"): |
| 684 | identity_path = cfg["issuer_identity"] |
| 685 | if not identity_path: |
| 686 | error_panel( |
| 687 | "No identity specified.", |
| 688 | "Use --identity <file> or set issuer_identity in config." |
| 689 | ) |
| 690 | sys.exit(1) |
| 691 | |
| 692 | identity_path = Path(identity_path) |
| 693 | if not identity_path.exists(): |
| 694 | error_panel(f"Identity file not found: {identity_path}") |
| 695 | sys.exit(1) |
| 696 | |
| 697 | out_path = Path(args.out) if args.out else input_path.with_suffix("") |
| 698 | try: |
| 699 | validate_output_path(out_path, input_paths=[input_path, identity_path]) |
| 700 | except (ValueError, FileExistsError) as exc: |
| 701 | error_panel(str(exc)) |
| 702 | sys.exit(1) |
| 703 | |
| 704 | ident = json.loads(identity_path.read_text()) |
| 705 | |
| 706 | with Progress( |
| 707 | SpinnerColumn(), |
| 708 | TextColumn("[progress.description]{task.description}"), |
| 709 | transient=True, |
| 710 | ) as progress: |
| 711 | task = progress.add_task("Decrypting...", total=None) |
| 712 | |
| 713 | try: |
| 714 | blob = input_path.read_bytes() |
| 715 | plaintext, manifest = open_sealed( |
| 716 | blob, |
| 717 | recipient_x25519_priv=bytes.fromhex(ident["x25519_priv"]), |
| 718 | ) |
| 719 | atomic_write_bytes(out_path, plaintext) |
| 720 | except ValueError as e: |
| 721 | error_panel( |
| 722 | f"Decryption failed: {e}", |
| 723 | "Verify you are using the correct recipient identity." |
| 724 | ) |
| 725 | sys.exit(1) |
| 726 | |
| 727 | table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) |
| 728 | table.add_column("Key", style="cyan") |
| 729 | table.add_column("Value", style="white") |
| 730 | table.add_row("Output", str(out_path)) |
| 731 | table.add_row("Size", f"{len(plaintext):,} bytes") |
| 732 | table.add_row("File ID", manifest.file_id) |
| 733 | table.add_row("Issuer", manifest.issuer_id) |
| 734 | table.add_row("Recipient", manifest.recipient.recipient_id if manifest.recipient else "unknown") |
| 735 | table.add_row("Watermarks", str(len(manifest.watermarks))) |
| 736 | table.add_row("Beacons", str(len(manifest.beacons))) |
| 737 | |
| 738 | console.print(Panel(table, title="[green]Decrypted[/]", border_style="green", padding=(0, 1))) |
| 739 | |
| 740 | |
| 741 | |
| 742 | def cmd_inspect(args): |
| 743 | """Display the manifest from a sealed file without decrypting.""" |
| 744 | input_path = Path(args.input) |
| 745 | if not input_path.exists(): |
| 746 | error_panel(f"Sealed file not found: {input_path}") |
| 747 | sys.exit(1) |
| 748 | |
| 749 | try: |
| 750 | blob = input_path.read_bytes() |
| 751 | sf = SealedFile.from_bytes(blob) |
| 752 | except ValueError as e: |
| 753 | error_panel(f"Failed to parse sealed file: {e}") |
| 754 | sys.exit(1) |
| 755 | |
| 756 | m = sf.manifest |
| 757 | sig_valid = m.verify() |
| 758 | |
| 759 | header = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) |
| 760 | header.add_column("Key", style="cyan") |
| 761 | header.add_column("Value", style="white") |
| 762 | header.add_row("File", str(input_path)) |
| 763 | header.add_row("Version", m.version) |
| 764 | header.add_row("Suite", m.suite) |
| 765 | header.add_row("File ID", m.file_id) |
| 766 | header.add_row("Issued At", time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(m.issued_at))) |
| 767 | |
| 768 | sig_style = "bold green" if sig_valid else "bold red" |
| 769 | sig_text = "VALID" if sig_valid else "INVALID" |
| 770 | header.add_row("Signature", f"[{sig_style}]{sig_text}[/]") |
| 771 | |
| 772 | console.print(Panel(header, title="[cyan]Manifest[/]", border_style="cyan", padding=(0, 1))) |
| 773 | |
| 774 | content = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) |
| 775 | content.add_column("Key", style="cyan") |
| 776 | content.add_column("Value", style="white") |
| 777 | content.add_row("Filename", m.original_filename) |
| 778 | content.add_row("Content Type", m.content_type) |
| 779 | content.add_row("Size", f"{m.size_bytes:,} bytes") |
| 780 | content.add_row("Content Hash", format_hex_short(m.content_hash, 32)) |
| 781 | |
| 782 | console.print(Panel(content, title="[cyan]Content[/]", border_style="cyan", padding=(0, 1))) |
| 783 | |
| 784 | ident_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) |
| 785 | ident_table.add_column("Key", style="cyan") |
| 786 | ident_table.add_column("Value", style="white") |
| 787 | ident_table.add_row("Issuer ID", m.issuer_id) |
| 788 | ident_table.add_row("Issuer Ed25519", format_hex_short(m.issuer_ed25519_pub, 32)) |
| 789 | if m.recipient: |
| 790 | ident_table.add_row("Recipient ID", m.recipient.recipient_id) |
| 791 | ident_table.add_row("Recipient X25519", format_hex_short(m.recipient.x25519_pub, 32)) |
| 792 | |
| 793 | console.print(Panel(ident_table, title="[cyan]Identities[/]", border_style="cyan", padding=(0, 1))) |
| 794 | |
| 795 | if m.watermarks: |
| 796 | wm_table = Table(box=box.ROUNDED, border_style="yellow") |
| 797 | wm_table.add_column("Layer", style="bold") |
| 798 | wm_table.add_column("Mark ID", style="white") |
| 799 | for w in m.watermarks: |
| 800 | wm_table.add_row(w.layer, w.mark_id) |
| 801 | console.print(wm_table) |
| 802 | |
| 803 | if m.beacons: |
| 804 | b_table = Table(box=box.ROUNDED, border_style="magenta") |
| 805 | b_table.add_column("Kind", style="bold") |
| 806 | b_table.add_column("Token ID", style="white") |
| 807 | b_table.add_column("URL", style="dim") |
| 808 | for b in m.beacons: |
| 809 | b_table.add_row( |
| 810 | b.get("kind", ""), |
| 811 | format_hex_short(b.get("token_id", ""), 20), |
| 812 | b.get("url", ""), |
| 813 | ) |
| 814 | console.print(b_table) |
| 815 | |
| 816 | if m.policy: |
| 817 | p_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) |
| 818 | p_table.add_column("Key", style="cyan") |
| 819 | p_table.add_column("Value", style="white") |
| 820 | for k, v in m.policy.items(): |
| 821 | p_table.add_row(k, str(v)) |
| 822 | console.print(Panel(p_table, title="[cyan]Policy[/]", border_style="cyan", padding=(0, 1))) |
| 823 | |
| 824 | if args.json: |
| 825 | console.print(Rule("Raw Manifest JSON")) |
| 826 | console.print_json(json.dumps(m.to_dict(), default=str)) |
| 827 | |
| 828 | |
| 829 | |
| 830 | def cmd_attribute(args): |
| 831 | """Run full 5-phase attribution on a leaked file.""" |
| 832 | cfg = load_config() |
| 833 | |
| 834 | leak_path = Path(args.leak) |
| 835 | if not leak_path.exists(): |
| 836 | error_panel(f"Leak file not found: {leak_path}") |
| 837 | sys.exit(1) |
| 838 | |
| 839 | text = leak_path.read_text(encoding="utf-8", errors="replace") |
| 840 | registry_url = args.registry or cfg.get("registry_url", "http://localhost:8000") |
| 841 | |
| 842 | fingerprints_path = args.fingerprints |
| 843 | if not fingerprints_path and cfg.get("_config_dir"): |
| 844 | fp_dir = Path(cfg["_config_dir"]) / "fingerprints" |
| 845 | if fp_dir.exists() and any(fp_dir.glob("*.fingerprint.json")): |
| 846 | fingerprints_path = str(fp_dir) |
| 847 | |
| 848 | console.print(Rule("[bold]Attribution Analysis[/]", style="red")) |
| 849 | console.print() |
| 850 | |
| 851 | with Progress( |
| 852 | SpinnerColumn(), |
| 853 | TextColumn("[progress.description]{task.description}"), |
| 854 | transient=True, |
| 855 | ) as progress: |
| 856 | progress.add_task("Phase 1: Extracting L1 + L2 marks...", total=None) |
| 857 | l1_marks = watermark.extract_zw(text) |
| 858 | l2_candidate, l2_conf, l2_bits, l2_needed = watermark.extract_ws_partial(text) |
| 859 | |
| 860 | l1_unique = list(set(l1_marks)) |
| 861 | direct_candidates: list[bytes] = list(l1_unique) |
| 862 | if l2_candidate and l2_conf >= 0.5: |
| 863 | if l2_candidate not in direct_candidates: |
| 864 | direct_candidates.append(l2_candidate) |
| 865 | |
| 866 | p1_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) |
| 867 | p1_table.add_column("Layer", style="cyan", width=8) |
| 868 | p1_table.add_column("Result", style="white") |
| 869 | |
| 870 | if l1_unique: |
| 871 | for m_id in l1_unique: |
| 872 | p1_table.add_row("L1", f"[green]{m_id.hex()}[/] ({len(l1_marks)} frames, {len(l1_unique)} unique)") |
| 873 | else: |
| 874 | p1_table.add_row("L1", "[red]No zero-width frames found (stripped?)[/]") |
| 875 | |
| 876 | if l2_conf >= 1.0: |
| 877 | p1_table.add_row("L2", f"[green]{l2_candidate.hex()}[/] ({l2_bits}/{l2_needed} bits, 100%)") |
| 878 | elif l2_conf > 0: |
| 879 | p1_table.add_row("L2", f"[yellow]{l2_candidate.hex()}[/] ({l2_bits}/{l2_needed} bits, {l2_conf:.0%} partial)") |
| 880 | else: |
| 881 | p1_table.add_row("L2", "[red]No trailing whitespace marks found (stripped?)[/]") |
| 882 | |
| 883 | console.print(Panel(p1_table, title="[bold]Phase 1: Direct Extraction[/]", border_style="cyan", padding=(0, 1))) |
| 884 | |
| 885 | registry_candidates: list[bytes] = [] |
| 886 | p2_results = [] |
| 887 | |
| 888 | with Progress( |
| 889 | SpinnerColumn(), |
| 890 | TextColumn("[progress.description]{task.description}"), |
| 891 | transient=True, |
| 892 | ) as progress: |
| 893 | progress.add_task(f"Phase 2: Querying registry at {registry_url}...", total=None) |
| 894 | |
| 895 | if direct_candidates: |
| 896 | for m_id in direct_candidates: |
| 897 | try: |
| 898 | resp = httpx.post( |
| 899 | f"{registry_url.rstrip('/')}/attribute", |
| 900 | json={"mark_id": m_id.hex(), "layer": "L1_zero_width"}, |
| 901 | timeout=10, |
| 902 | ) |
| 903 | data = resp.json() |
| 904 | if data.get("found"): |
| 905 | p2_results.append((m_id.hex(), data.get("recipient_id"), data.get("file_id"))) |
| 906 | except Exception as e: |
| 907 | p2_results.append((m_id.hex(), f"query failed: {e}", None)) |
| 908 | |
| 909 | try: |
| 910 | resp = httpx.get(f"{registry_url.rstrip('/')}/marks", timeout=10) |
| 911 | if resp.status_code == 200: |
| 912 | registry_data = resp.json() |
| 913 | for entry in registry_data.get("marks", []): |
| 914 | mid_bytes = bytes.fromhex(entry["mark_id"]) |
| 915 | if mid_bytes not in registry_candidates: |
| 916 | registry_candidates.append(mid_bytes) |
| 917 | except Exception: |
| 918 | pass |
| 919 | |
| 920 | p2_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) |
| 921 | p2_table.add_column("Field", style="cyan") |
| 922 | p2_table.add_column("Value", style="white") |
| 923 | |
| 924 | if p2_results: |
| 925 | for mark_hex, recipient, file_id in p2_results: |
| 926 | if file_id: |
| 927 | p2_table.add_row("Match", f"[green]{mark_hex}[/] -> recipient={recipient}, file={file_id}") |
| 928 | else: |
| 929 | p2_table.add_row("Query", f"{mark_hex}: {recipient}") |
| 930 | else: |
| 931 | p2_table.add_row("Result", "No direct candidates to query" if not direct_candidates else "No matches found") |
| 932 | |
| 933 | if registry_candidates: |
| 934 | p2_table.add_row("Registry", f"Fetched {len(registry_candidates)} candidate mark_id(s)") |
| 935 | |
| 936 | console.print(Panel(p2_table, title="[bold]Phase 2: Registry Query[/]", border_style="cyan", padding=(0, 1))) |
| 937 | |
| 938 | all_candidates = direct_candidates + [ |
| 939 | m_id for m_id in registry_candidates if m_id not in direct_candidates |
| 940 | ] |
| 941 | |
| 942 | p3_hits = [] |
| 943 | with Progress( |
| 944 | SpinnerColumn(), |
| 945 | TextColumn("[progress.description]{task.description}"), |
| 946 | transient=True, |
| 947 | ) as progress: |
| 948 | progress.add_task(f"Phase 3: L3 semantic verification ({len(all_candidates)} candidates)...", total=None) |
| 949 | if all_candidates: |
| 950 | p3_hits = watermark.verify_l3(text, all_candidates) |
| 951 | |
| 952 | p3_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) |
| 953 | p3_table.add_column("Field", style="cyan") |
| 954 | p3_table.add_column("Value", style="white") |
| 955 | |
| 956 | if p3_hits: |
| 957 | for mid, score, detail in p3_hits: |
| 958 | p3_table.add_row( |
| 959 | "L3 Match", |
| 960 | f"[green]{mid.hex()}[/] score={score:.2f} " |
| 961 | f"(synonyms={detail['synonyms_score']:.2f}, " |
| 962 | f"punct={detail['punctuation_hits']}, dict={detail['dict_version']})" |
| 963 | ) |
| 964 | elif not all_candidates: |
| 965 | p3_table.add_row("Result", "[yellow]No candidates available (L1/L2 stripped, registry unreachable?)[/]") |
| 966 | else: |
| 967 | p3_table.add_row("Result", f"[yellow]No candidates matched above threshold ({len(all_candidates)} tested)[/]") |
| 968 | |
| 969 | console.print(Panel(p3_table, title="[bold]Phase 3: Semantic Verification[/]", border_style="cyan", padding=(0, 1))) |
| 970 | |
| 971 | with Progress( |
| 972 | SpinnerColumn(), |
| 973 | TextColumn("[progress.description]{task.description}"), |
| 974 | transient=True, |
| 975 | ) as progress: |
| 976 | progress.add_task("Phase 4: Multi-layer Bayesian fusion...", total=None) |
| 977 | result = watermark.recover_marks_v2(text, all_candidates if all_candidates else None) |
| 978 | |
| 979 | if result["candidates"]: |
| 980 | fusion_table = Table(box=box.ROUNDED, border_style="green") |
| 981 | fusion_table.add_column("Mark ID", style="bold white") |
| 982 | fusion_table.add_column("Score", style="bold") |
| 983 | fusion_table.add_column("Layers", style="cyan") |
| 984 | |
| 985 | for mark_id_val, score, layers in result["candidates"]: |
| 986 | score_style = "green" if score >= 0.8 else "yellow" if score >= 0.5 else "red" |
| 987 | fusion_table.add_row( |
| 988 | mark_id_val.hex(), |
| 989 | f"[{score_style}]{score:.1%}[/]", |
| 990 | layers, |
| 991 | ) |
| 992 | |
| 993 | console.print(Panel(fusion_table, title="[bold]Phase 4: Fusion Results[/]", border_style="green", padding=(0, 1))) |
| 994 | |
| 995 | best = result["candidates"][0] |
| 996 | attribution_body = Text() |
| 997 | attribution_body.append(f"Mark ID: {best[0].hex()}\n", style="bold white") |
| 998 | attribution_body.append(f"Confidence: {best[1]:.1%}\n", style="bold green" if best[1] >= 0.8 else "bold yellow") |
| 999 | attribution_body.append(f"Evidence: {best[2]}\n", style="cyan") |
| 1000 | |
| 1001 | try: |
| 1002 | resp = httpx.post( |
| 1003 | f"{registry_url.rstrip('/')}/attribute", |
| 1004 | json={"mark_id": best[0].hex(), "layer": "fused"}, |
| 1005 | timeout=10, |
| 1006 | ) |
| 1007 | data = resp.json() |
| 1008 | if data.get("found"): |
| 1009 | attribution_body.append(f"File ID: {data['file_id']}\n", style="white") |
| 1010 | attribution_body.append(f"Recipient: {data['recipient_id']}\n", style="bold white") |
| 1011 | attribution_body.append(f"Issuer: {data['issuer_id']}\n", style="white") |
| 1012 | except Exception: |
| 1013 | pass |
| 1014 | |
| 1015 | console.print(Panel( |
| 1016 | attribution_body, |
| 1017 | title="[bold red]ATTRIBUTION[/]", |
| 1018 | border_style="red", |
| 1019 | padding=(0, 2), |
| 1020 | )) |
| 1021 | else: |
| 1022 | console.print(Panel( |
| 1023 | "[red]No marks recovered from any layer.[/]", |
| 1024 | title="[bold]Phase 4: Fusion[/]", |
| 1025 | border_style="red", |
| 1026 | padding=(0, 2), |
| 1027 | )) |
| 1028 | if result["diagnostics"]: |
| 1029 | for d in result["diagnostics"]: |
| 1030 | info(d) |
| 1031 | |
| 1032 | if fingerprints_path: |
| 1033 | with Progress( |
| 1034 | SpinnerColumn(), |
| 1035 | TextColumn("[progress.description]{task.description}"), |
| 1036 | transient=True, |
| 1037 | ) as progress: |
| 1038 | progress.add_task("Phase 5: Content fingerprint comparison...", total=None) |
| 1039 | leak_fp = ContentFingerprint.from_text(text) |
| 1040 | |
| 1041 | fp_dir = Path(fingerprints_path) |
| 1042 | if fp_dir.is_dir(): |
| 1043 | fp_files = list(fp_dir.glob("*.fingerprint.json")) |
| 1044 | elif fp_dir.is_file(): |
| 1045 | fp_files = [fp_dir] |
| 1046 | else: |
| 1047 | fp_files = [] |
| 1048 | |
| 1049 | if fp_files: |
| 1050 | fp_table = Table(box=box.ROUNDED, border_style="magenta") |
| 1051 | fp_table.add_column("File", style="dim") |
| 1052 | fp_table.add_column("Recipient", style="bold") |
| 1053 | fp_table.add_column("Winnow", style="white") |
| 1054 | fp_table.add_column("Sentence", style="white") |
| 1055 | fp_table.add_column("Combined", style="bold") |
| 1056 | fp_table.add_column("Verdict", style="bold") |
| 1057 | |
| 1058 | best_fp_match = None |
| 1059 | best_fp_score = 0.0 |
| 1060 | |
| 1061 | for fp_file in fp_files: |
| 1062 | try: |
| 1063 | fp_data = json.loads(fp_file.read_text()) |
| 1064 | stored_fp = ContentFingerprint.from_dict(fp_data["fingerprint"]) |
| 1065 | sim = leak_fp.similarity(stored_fp) |
| 1066 | recipient_id = fp_data.get("recipient_id", "unknown") |
| 1067 | fp_mark_id = fp_data.get("mark_id", "unknown") |
| 1068 | |
| 1069 | if sim["combined"] >= 0.1: |
| 1070 | verdict_style = ( |
| 1071 | "green" if sim["verdict"] == "MATCH" |
| 1072 | else "yellow" if sim["verdict"] == "LIKELY" |
| 1073 | else "red" |
| 1074 | ) |
| 1075 | fp_table.add_row( |
| 1076 | fp_file.name, |
| 1077 | recipient_id, |
| 1078 | f"{sim['winnowing']:.2f}", |
| 1079 | f"{sim['sentence']:.2f}", |
| 1080 | f"{sim['combined']:.2f}", |
| 1081 | f"[{verdict_style}]{sim['verdict']}[/]", |
| 1082 | ) |
| 1083 | |
| 1084 | if sim["combined"] > best_fp_score: |
| 1085 | best_fp_score = sim["combined"] |
| 1086 | best_fp_match = { |
| 1087 | "file": fp_file.name, |
| 1088 | "recipient_id": recipient_id, |
| 1089 | "mark_id": fp_mark_id, |
| 1090 | "similarity": sim, |
| 1091 | } |
| 1092 | except Exception as e: |
| 1093 | warn(f"Error reading {fp_file.name}: {e}") |
| 1094 | |
| 1095 | console.print(Panel(fp_table, title="[bold]Phase 5: Fingerprint Comparison[/]", border_style="magenta", padding=(0, 1))) |
| 1096 | |
| 1097 | if best_fp_match and best_fp_score >= 0.3: |
| 1098 | fp_body = Text() |
| 1099 | fp_body.append(f"Verdict: {best_fp_match['similarity']['verdict']}\n", style="bold") |
| 1100 | fp_body.append(f"Recipient: {best_fp_match['recipient_id']}\n", style="bold white") |
| 1101 | fp_body.append(f"Mark ID: {best_fp_match['mark_id']}\n", style="white") |
| 1102 | fp_body.append(f"Confidence: {best_fp_score:.1%}\n", style="green") |
| 1103 | fp_body.append(f"Winnowing: {best_fp_match['similarity']['winnowing']:.1%}\n", style="dim") |
| 1104 | fp_body.append(f"Sentence: {best_fp_match['similarity']['sentence']:.1%}", style="dim") |
| 1105 | |
| 1106 | console.print(Panel( |
| 1107 | fp_body, |
| 1108 | title="[bold magenta]FINGERPRINT ATTRIBUTION[/]", |
| 1109 | border_style="magenta", |
| 1110 | padding=(0, 2), |
| 1111 | )) |
| 1112 | else: |
| 1113 | info("No fingerprint files found to compare against.") |
| 1114 | |
| 1115 | |
| 1116 | |
| 1117 | def cmd_status(args): |
| 1118 | """Show config, identity, registry health, version info.""" |
| 1119 | cfg = load_config() |
| 1120 | config_dir = config_dir_from_cfg(cfg) |
| 1121 | |
| 1122 | status_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) |
| 1123 | status_table.add_column("Key", style="cyan") |
| 1124 | status_table.add_column("Value", style="white") |
| 1125 | status_table.add_row("CLI version", CLI_VERSION) |
| 1126 | status_table.add_row("Core version", core_version) |
| 1127 | |
| 1128 | if config_dir: |
| 1129 | status_table.add_row("Config dir", str(config_dir)) |
| 1130 | else: |
| 1131 | status_table.add_row("Config dir", "[yellow]not found (run: oversight init)[/]") |
| 1132 | |
| 1133 | if cfg.get("issuer_identity"): |
| 1134 | ident_path = Path(cfg["issuer_identity"]) |
| 1135 | if ident_path.exists(): |
| 1136 | try: |
| 1137 | ident_data = json.loads(ident_path.read_text()) |
| 1138 | status_table.add_row("Issuer ID", ident_data.get("id", "unknown")) |
| 1139 | status_table.add_row("Ed25519 pub", format_hex_short(ident_data.get("ed25519_pub", ""), 32)) |
| 1140 | except (json.JSONDecodeError, OSError): |
| 1141 | status_table.add_row("Issuer identity", f"[red]error reading {ident_path}[/]") |
| 1142 | else: |
| 1143 | status_table.add_row("Issuer identity", f"[yellow]file not found: {ident_path}[/]") |
| 1144 | else: |
| 1145 | status_table.add_row("Issuer identity", "[yellow]not configured[/]") |
| 1146 | |
| 1147 | registry_url = cfg.get("registry_url", "not configured") |
| 1148 | status_table.add_row("Registry URL", registry_url) |
| 1149 | status_table.add_row("Default watermark", str(cfg.get("default_watermark", "not set"))) |
| 1150 | |
| 1151 | if config_dir: |
| 1152 | rdir = config_dir / "recipients" |
| 1153 | rcount = len(list(rdir.glob("*.json"))) if rdir.exists() else 0 |
| 1154 | status_table.add_row("Recipients", str(rcount)) |
| 1155 | |
| 1156 | fdir = config_dir / "fingerprints" |
| 1157 | fcount = len(list(fdir.glob("*.fingerprint.json"))) if fdir.exists() else 0 |
| 1158 | status_table.add_row("Fingerprints", str(fcount)) |
| 1159 | |
| 1160 | console.print(Panel(status_table, title="[cyan]Oversight Status[/]", border_style="cyan", padding=(0, 1))) |
| 1161 | |
| 1162 | if registry_url and registry_url != "not configured": |
| 1163 | with Progress( |
| 1164 | SpinnerColumn(), |
| 1165 | TextColumn("[progress.description]{task.description}"), |
| 1166 | transient=True, |
| 1167 | ) as progress: |
| 1168 | progress.add_task(f"Checking registry at {registry_url}...", total=None) |
| 1169 | try: |
| 1170 | resp = httpx.get(f"{registry_url.rstrip('/')}/health", timeout=5) |
| 1171 | if resp.status_code == 200: |
| 1172 | health_data = resp.json() |
| 1173 | htable = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) |
| 1174 | htable.add_column("Key", style="cyan") |
| 1175 | htable.add_column("Value", style="white") |
| 1176 | htable.add_row("Status", f"[green]{health_data.get('status', 'ok')}[/]") |
| 1177 | htable.add_row("Service", health_data.get("service", "unknown")) |
| 1178 | htable.add_row("Version", health_data.get("version", "unknown")) |
| 1179 | htable.add_row("TLog size", str(health_data.get("tlog_size", 0))) |
| 1180 | console.print(Panel(htable, title="[green]Registry Health[/]", border_style="green", padding=(0, 1))) |
| 1181 | else: |
| 1182 | warn(f"Registry returned HTTP {resp.status_code}") |
| 1183 | except httpx.ConnectError: |
| 1184 | warn(f"Registry unreachable at {registry_url}") |
| 1185 | except Exception as e: |
| 1186 | warn(f"Registry check failed: {e}") |
| 1187 | |
| 1188 | |
| 1189 | |
| 1190 | def cmd_registry_start(args): |
| 1191 | """Start the local registry server.""" |
| 1192 | import subprocess |
| 1193 | |
| 1194 | host = args.host or "0.0.0.0" |
| 1195 | port = args.port or 8000 |
| 1196 | |
| 1197 | registry_script = ROOT / "registry" / "server.py" |
| 1198 | if not registry_script.exists(): |
| 1199 | error_panel( |
| 1200 | f"Registry server not found at {registry_script}", |
| 1201 | "Verify the Oversight installation is complete." |
| 1202 | ) |
| 1203 | sys.exit(1) |
| 1204 | |
| 1205 | info(f"Starting registry server on {host}:{port}") |
| 1206 | info(f"Server module: {registry_script}") |
| 1207 | console.print(Rule("Registry Server", style="cyan")) |
| 1208 | |
| 1209 | try: |
| 1210 | subprocess.run( |
| 1211 | [ |
| 1212 | sys.executable, "-m", "uvicorn", |
| 1213 | "registry.server:app", |
| 1214 | "--host", host, |
| 1215 | "--port", str(port), |
| 1216 | ], |
| 1217 | cwd=str(ROOT), |
| 1218 | ) |
| 1219 | except KeyboardInterrupt: |
| 1220 | info("Registry server stopped.") |
| 1221 | |
| 1222 | |
| 1223 | |
| 1224 | def build_parser() -> argparse.ArgumentParser: |
| 1225 | p = argparse.ArgumentParser( |
| 1226 | prog="oversight", |
| 1227 | description="Oversight Protocol CLI -- data provenance, attribution, and leak detection.", |
| 1228 | ) |
| 1229 | p.add_argument("--no-banner", action="store_true", help="suppress startup banner") |
| 1230 | sub = p.add_subparsers(dest="cmd") |
| 1231 | sub.add_parser("gui", help="launch the graphical desktop app") |
| 1232 | |
| 1233 | init_p = sub.add_parser("init", help="initialize .oversight/ directory") |
| 1234 | init_p.add_argument("--path", default=None, help="target directory (default: cwd)") |
| 1235 | init_p.add_argument("--registry-url", default=None, help="registry server URL") |
| 1236 | init_p.add_argument("--registry-domain", default=None, help="registry domain for beacons") |
| 1237 | init_p.add_argument("--force", action="store_true", help="overwrite existing config") |
| 1238 | |
| 1239 | keys_p = sub.add_parser("keys", help="key management") |
| 1240 | keys_sub = keys_p.add_subparsers(dest="keys_cmd") |
| 1241 | |
| 1242 | kg = keys_sub.add_parser("generate", help="generate a new identity keypair") |
| 1243 | kg.add_argument("--name", default=None, help="identity name (default: identity)") |
| 1244 | kg.add_argument("--out", default=None, help="output file path") |
| 1245 | kg.add_argument("--force", action="store_true", help="overwrite existing identity") |
| 1246 | |
| 1247 | kl = keys_sub.add_parser("list", help="list identities and recipients") |
| 1248 | |
| 1249 | ki = keys_sub.add_parser("import", help="import a recipient public key") |
| 1250 | ki.add_argument("file", help="path to recipient .pub.json file") |
| 1251 | ki.add_argument("--force", action="store_true", help="overwrite existing recipient") |
| 1252 | |
| 1253 | seal_p = sub.add_parser("seal", help="seal a file for a recipient") |
| 1254 | seal_p.add_argument("input", help="input file to seal") |
| 1255 | seal_p.add_argument("--to", default=None, help="recipient public key file or name") |
| 1256 | seal_p.add_argument("--issuer-key", default=None, help="issuer private key file") |
| 1257 | seal_p.add_argument("--issuer-id", default=None, help="issuer identifier") |
| 1258 | seal_p.add_argument("--registry-url", default=None, help="registry URL") |
| 1259 | seal_p.add_argument("--registry-domain", default=None, help="registry domain for beacons") |
| 1260 | seal_p.add_argument("--out", default=None, help="output file (default: <input>.sealed)") |
| 1261 | seal_p.add_argument("--content-type", default=None, help="MIME content type") |
| 1262 | seal_p.add_argument("--watermark", default=None, action="store_true", help="embed watermarks (default from config)") |
| 1263 | seal_p.add_argument("--no-watermark", dest="watermark", action="store_false", help="skip watermarks") |
| 1264 | seal_p.add_argument("--l3-mode", choices=("auto", "off", "full", "boilerplate"), default="auto", |
| 1265 | help="semantic L3 mode; auto disables L3 for wording-sensitive documents") |
| 1266 | seal_p.add_argument("--l3-ack", action="store_true", |
| 1267 | help="acknowledge enabled L3 makes recipient text non-identical") |
| 1268 | seal_p.add_argument("--document-class", |
| 1269 | choices=("auto", "prose", "legal", "regulatory", "technical_spec", |
| 1270 | "source_code", "sql", "log", "structured_data"), |
| 1271 | default="auto", |
| 1272 | help="declare document class for L3 safety decisions") |
| 1273 | seal_p.add_argument("--register", default=None, help="POST manifest to this registry URL") |
| 1274 | |
| 1275 | open_p = sub.add_parser("open", help="decrypt a sealed file") |
| 1276 | open_p.add_argument("input", help="sealed file to open") |
| 1277 | open_p.add_argument("--identity", default=None, help="recipient identity file") |
| 1278 | open_p.add_argument("--out", default=None, help="output file (default: strip .sealed)") |
| 1279 | |
| 1280 | inspect_p = sub.add_parser("inspect", help="show manifest without decrypting") |
| 1281 | inspect_p.add_argument("input", help="sealed file to inspect") |
| 1282 | inspect_p.add_argument("--json", action="store_true", help="also print raw JSON") |
| 1283 | |
| 1284 | attr_p = sub.add_parser("attribute", help="attribute a leaked file") |
| 1285 | attr_p.add_argument("leak", nargs="?", default=None, help="leaked text file") |
| 1286 | attr_p.add_argument("--leak", dest="leak_flag", default=None, help="leaked text file (alternative flag)") |
| 1287 | attr_p.add_argument("--registry", default=None, help="registry URL for lookups") |
| 1288 | attr_p.add_argument("--fingerprints", default=None, help="fingerprint file or directory") |
| 1289 | |
| 1290 | sub.add_parser("status", help="show config, identity, and registry health") |
| 1291 | |
| 1292 | reg_p = sub.add_parser("registry", help="registry server management") |
| 1293 | reg_sub = reg_p.add_subparsers(dest="registry_cmd") |
| 1294 | rs = reg_sub.add_parser("start", help="start the local registry server") |
| 1295 | rs.add_argument("--host", default=None, help="bind host (default: 0.0.0.0)") |
| 1296 | rs.add_argument("--port", type=int, default=None, help="bind port (default: 8000)") |
| 1297 | |
| 1298 | return p |
| 1299 | |
| 1300 | |
| 1301 | |
| 1302 | def main(): |
| 1303 | show_banner = "--no-banner" not in sys.argv |
| 1304 | argv = [a for a in sys.argv[1:] if a != "--no-banner"] |
| 1305 | |
| 1306 | parser = build_parser() |
| 1307 | args = parser.parse_args(argv) |
| 1308 | |
| 1309 | if not args.cmd: |
| 1310 | if show_banner: |
| 1311 | print_banner() |
| 1312 | parser.print_help() |
| 1313 | sys.exit(0) |
| 1314 | |
| 1315 | if args.cmd == "gui": |
| 1316 | from cli.gui import main as gui_main |
| 1317 | gui_main() |
| 1318 | return |
| 1319 | |
| 1320 | if show_banner: |
| 1321 | print_banner() |
| 1322 | |
| 1323 | if args.cmd == "init": |
| 1324 | cmd_init(args) |
| 1325 | |
| 1326 | elif args.cmd == "keys": |
| 1327 | if not args.keys_cmd: |
| 1328 | err_console.print("[red]Specify a keys subcommand: generate, list, import[/]") |
| 1329 | sys.exit(1) |
| 1330 | if args.keys_cmd == "generate": |
| 1331 | cmd_keys_generate(args) |
| 1332 | elif args.keys_cmd == "list": |
| 1333 | cmd_keys_list(args) |
| 1334 | elif args.keys_cmd == "import": |
| 1335 | cmd_keys_import(args) |
| 1336 | |
| 1337 | elif args.cmd == "seal": |
| 1338 | cmd_seal(args) |
| 1339 | |
| 1340 | elif args.cmd == "open": |
| 1341 | cmd_open(args) |
| 1342 | |
| 1343 | elif args.cmd == "inspect": |
| 1344 | cmd_inspect(args) |
| 1345 | |
| 1346 | elif args.cmd == "attribute": |
| 1347 | leak_file = args.leak or args.leak_flag |
| 1348 | if not leak_file: |
| 1349 | error_panel( |
| 1350 | "No leak file specified.", |
| 1351 | "Usage: oversight attribute <leak-file> or oversight attribute --leak <file>" |
| 1352 | ) |
| 1353 | sys.exit(1) |
| 1354 | args.leak = leak_file |
| 1355 | cmd_attribute(args) |
| 1356 | |
| 1357 | elif args.cmd == "status": |
| 1358 | cmd_status(args) |
| 1359 | |
| 1360 | elif args.cmd == "registry": |
| 1361 | if not args.registry_cmd: |
| 1362 | err_console.print("[red]Specify a registry subcommand: start[/]") |
| 1363 | sys.exit(1) |
| 1364 | if args.registry_cmd == "start": |
| 1365 | cmd_registry_start(args) |
| 1366 | |
| 1367 | else: |
| 1368 | parser.print_help() |
| 1369 | sys.exit(1) |
| 1370 | |
| 1371 | |
| 1372 | if __name__ == "__main__": |
| 1373 | main() |