Zion Boggan
repos/Oversight/cli/oversight_rich.py
zionboggan.com ↗
1373 lines · python
History for this file →
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()