@ictechgy/context-guard 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2038 @@
1
+ #!/usr/bin/env python3
2
+ """Default-off ContextGuard experimental feature registry.
3
+
4
+ The registry is intentionally passive: it records explicit project-local opt-in
5
+ state for experimental lanes, but it does not activate runtime behavior by
6
+ itself. Individual helpers must still require their own explicit experimental
7
+ flags before changing stable behavior.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ from dataclasses import asdict, dataclass
13
+ from datetime import datetime, timezone
14
+ import hashlib
15
+ import ipaddress
16
+ import json
17
+ import math
18
+ import re
19
+ import shlex
20
+ from pathlib import Path
21
+ import sys
22
+ from typing import Any, NoReturn
23
+ import unicodedata
24
+ from urllib.parse import urlparse
25
+
26
+ TOOL_NAME = "context-guard-experiments"
27
+ CONFIG_SCHEMA_VERSION = "contextguard.experiments.v1"
28
+ DEFAULT_CONFIG = Path(".context-guard") / "experiments.json"
29
+ MAX_CONTEXT_DIFF_INPUT_BYTES = 256_000
30
+ MAX_VISUAL_OCR_TEXT_BYTES = 64_000
31
+ MAX_LEARNED_COMPRESSION_INPUT_BYTES = 128_000
32
+ MAX_SELF_HOSTED_METRICS_INPUT_BYTES = 64_000
33
+ SELF_HOSTED_METRICS_SCHEMA_VERSION = "contextguard.bench.self-hosted-metrics.v1"
34
+ SELF_HOSTED_METRICS_KEY = "self_hosted_metrics"
35
+ SELF_HOSTED_METRICS_CLAIM_BOUNDARY = "self_hosted_metrics_only_not_hosted_api_token_or_cost_savings"
36
+ BENCH_RUN_EVIDENCE_SCHEMA_VERSION = "contextguard.bench.run-evidence.v1"
37
+ MAX_SELF_HOSTED_LABEL_CHARS = 120
38
+ MAX_SELF_HOSTED_LATENCY_MS = 7 * 24 * 60 * 60 * 1000
39
+ MAX_SELF_HOSTED_MEMORY_MB = 10_000_000
40
+ MAX_SELF_HOSTED_ENERGY_WH = 1_000_000
41
+ MAX_SELF_HOSTED_LOCAL_COST_USD = 1_000_000
42
+ MAX_SELF_HOSTED_TOKENS_PER_SECOND = 10_000_000
43
+ TOKEN_PROXY_BYTES_PER_TOKEN = 4
44
+ MAX_SELF_HOSTED_JSON_DEPTH = 100
45
+ MAX_SELF_HOSTED_JSON_NODES = 10_000
46
+ LOCAL_PROXY_SCHEMA_VERSION = "contextguard.experiments.local-proxy-plan.v1"
47
+ LOCAL_PROXY_DEFAULT_BIND_HOST = "127.0.0.1"
48
+ LOCAL_PROXY_DEFAULT_BIND_PORT = 0
49
+ LOCAL_PROXY_DEFAULT_TARGET_HOST = "127.0.0.1"
50
+ LOCAL_PROXY_DEFAULT_TARGET_PORT = 0
51
+ LOCAL_PROXY_LOCALHOST_NAMES = {"localhost"}
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Experiment:
56
+ id: str
57
+ name: str
58
+ summary: str
59
+ stability: str
60
+ default_enabled: bool
61
+ risk_level: str
62
+ claim_boundary: str
63
+ gate_requirements: tuple[str, ...]
64
+ runtime_status: str = "metadata-only"
65
+ commands: tuple[str, ...] = ()
66
+ opt_in_flags: tuple[str, ...] = ()
67
+ config_effect: str = (
68
+ "Registry enablement records project-local intent only; helpers still require explicit experimental flags."
69
+ )
70
+ evidence_contract: str = "Evidence is local metadata only unless a later story adds a measured runtime gate."
71
+
72
+ def to_json(self, *, enabled: bool = False) -> dict[str, Any]:
73
+ data = asdict(self)
74
+ for key in ("gate_requirements", "commands", "opt_in_flags"):
75
+ data[key] = list(getattr(self, key))
76
+ data["enabled"] = bool(enabled)
77
+ return data
78
+
79
+
80
+ EXPERIMENTS: tuple[Experiment, ...] = (
81
+ Experiment(
82
+ id="output-receipt-trim",
83
+ name="Receipt-backed output trimming",
84
+ summary="Opt-in digest output with local artifact receipts and exact re-expand instructions.",
85
+ stability="experimental",
86
+ default_enabled=False,
87
+ risk_level="low",
88
+ claim_boundary="Local output-size reduction only; no hosted API token/cost savings claim without provider-measured matched tasks.",
89
+ gate_requirements=("explicit opt-in", "local artifact receipt", "exact re-expand command"),
90
+ runtime_status="available-explicit-flags",
91
+ commands=(
92
+ "context-guard-trim-output --digest markdown --artifact-receipt -- <command>",
93
+ "context-guard-trim-output --digest json --artifact-receipt -- <command>",
94
+ ),
95
+ opt_in_flags=("--digest markdown|json", "--artifact-receipt"),
96
+ config_effect=(
97
+ "Registry enablement records project-local intent only; output trimming still runs only when the helper is "
98
+ "invoked with --digest markdown|json plus --artifact-receipt."
99
+ ),
100
+ evidence_contract=(
101
+ "Stores the exact sanitized full output as a local context-guard-artifact receipt and emits an exact "
102
+ "re-expand command before omitted details are relied on."
103
+ ),
104
+ ),
105
+ Experiment(
106
+ id="protected-zone-policy",
107
+ name="Protected-zone transform policy",
108
+ summary="Metadata policy that denies semantic rewrites for code, diffs, identifiers, hashes, paths, and other exact evidence.",
109
+ stability="experimental",
110
+ default_enabled=False,
111
+ risk_level="low",
112
+ claim_boundary="Policy metadata only; it does not prove provider cache or token savings.",
113
+ gate_requirements=("explicit opt-in", "protected-zone detection", "exact retrieval fallback"),
114
+ runtime_status="available-explicit-flags",
115
+ commands=(
116
+ "context-guard-compress --json --protected-policy",
117
+ "context-guard cost compile --json",
118
+ "context-guard-cost compile --json",
119
+ ),
120
+ opt_in_flags=("--protected-policy", "protected=true manifest sections for cost compile"),
121
+ config_effect=(
122
+ "Registry enablement records project-local intent only; protected-zone policy metadata still appears only "
123
+ "when explicit helper flags or protected manifest sections are used."
124
+ ),
125
+ evidence_contract=(
126
+ "Denies semantic/paraphrase rewrites for protected classes and requires structural transforms plus exact "
127
+ "artifact retrieval guidance for protected evidence."
128
+ ),
129
+ ),
130
+ Experiment(
131
+ id="context-diff-compaction",
132
+ name="Reviewable context-diff compaction",
133
+ summary="Dry-run advisory lane for human-reviewable compaction plans with stable exact handles.",
134
+ stability="experimental",
135
+ default_enabled=False,
136
+ risk_level="medium",
137
+ claim_boundary="Smaller local diffs are proxy evidence only; hosted savings require provider-measured matched tasks.",
138
+ gate_requirements=("explicit opt-in", "human-reviewable diff", "local receipt", "exact re-expand handle"),
139
+ runtime_status="available-dry-run",
140
+ commands=("context-guard experiments plan context-diff-compaction",),
141
+ opt_in_flags=("plan context-diff-compaction", "--receipt-id", "--reexpand-command"),
142
+ config_effect=(
143
+ "Registry enablement records project-local intent only; context-diff compaction remains a dry-run plan "
144
+ "unless a future story adds an explicit replacement command."
145
+ ),
146
+ evidence_contract=(
147
+ "Dry-run plans require human-reviewable hunks plus user-supplied exact receipt and re-expand handles before "
148
+ "any future lossy replacement can be reviewed."
149
+ ),
150
+ ),
151
+ Experiment(
152
+ id="visual-crop-ocr",
153
+ name="Visual crop/OCR evidence planning",
154
+ summary="Dry-run fixture lane for comparing full visual evidence with cropped or OCR-derived evidence.",
155
+ stability="experimental",
156
+ default_enabled=False,
157
+ risk_level="medium",
158
+ claim_boundary="Image/OCR byte reductions are proxy evidence until provider image/text token fields are measured.",
159
+ gate_requirements=("explicit opt-in", "original evidence preserved", "confidence/error notes", "missed-context guardrail"),
160
+ runtime_status="available-dry-run",
161
+ commands=("context-guard experiments plan visual-crop-ocr",),
162
+ opt_in_flags=(
163
+ "plan visual-crop-ocr",
164
+ "--full-evidence-receipt",
165
+ "--crop-bounds",
166
+ "--image-size",
167
+ "--ocr-text|--ocr-text-file",
168
+ "--ocr-confidence",
169
+ "--ocr-error-note",
170
+ "--missed-context-note",
171
+ ),
172
+ config_effect=(
173
+ "Registry enablement records project-local intent only; visual crop/OCR planning remains a dry-run "
174
+ "metadata surface and does not run OCR, crop images, call providers, or change stable behavior."
175
+ ),
176
+ evidence_contract=(
177
+ "Dry-run plans require retrievable full visual evidence plus crop/OCR confidence, error, and "
178
+ "missed-context guardrails before human review."
179
+ ),
180
+ ),
181
+ Experiment(
182
+ id="learned-compression",
183
+ name="Learned/synthetic compression safe gate",
184
+ summary="Deny-by-default dry-run safety gate for already-sanitized unprotected prose only.",
185
+ stability="experimental",
186
+ default_enabled=False,
187
+ risk_level="high",
188
+ claim_boundary="Semantic compression cannot claim savings or correctness without matched-task quality and provider token evidence.",
189
+ gate_requirements=("explicit opt-in", "sanitized unprotected prose only", "protected-zone denial", "exact fallback or receipt"),
190
+ runtime_status="available-dry-run",
191
+ commands=("context-guard experiments plan learned-compression",),
192
+ opt_in_flags=("plan learned-compression", "--sanitized", "--trusted-source", "--exact-fallback-receipt", "--reexpand-command"),
193
+ config_effect=(
194
+ "Registry enablement records project-local intent only; learned compression remains a dry-run policy check "
195
+ "and does not run learned compressors, embeddings, model calls, or replacements."
196
+ ),
197
+ evidence_contract=(
198
+ "Dry-run eligibility requires caller-asserted sanitized trusted prose, exact local fallback handles, and "
199
+ "denial of protected or prompt-like signals."
200
+ ),
201
+ ),
202
+ Experiment(
203
+ id="self-hosted-metrics-ledger",
204
+ name="Self-hosted metrics ledger",
205
+ summary="Dry-run checker for self-hosted/local metrics ledger sidecars kept separate from hosted API claims.",
206
+ stability="experimental",
207
+ default_enabled=False,
208
+ risk_level="low",
209
+ claim_boundary="Self-hosted memory/latency metrics must stay separate from hosted API token/cost claims.",
210
+ gate_requirements=("explicit opt-in", "separate ledger fields", "shifted-cost accounting"),
211
+ runtime_status="available-dry-run",
212
+ commands=("context-guard experiments plan self-hosted-metrics-ledger",),
213
+ opt_in_flags=(
214
+ "plan self-hosted-metrics-ledger",
215
+ "--input",
216
+ "--latency-ms",
217
+ "--peak-memory-mb",
218
+ "--quality-score",
219
+ "--energy-wh",
220
+ "--local-cost-usd",
221
+ "--tokens-per-second",
222
+ "--model-server",
223
+ "--optimization",
224
+ ),
225
+ config_effect=(
226
+ "Registry enablement records project-local intent only; self-hosted metrics planning remains a dry-run "
227
+ "ledger-preview surface and does not write ledgers or alter benchmark/report behavior."
228
+ ),
229
+ evidence_contract=(
230
+ "Real evidence belongs in context-guard-bench JSONL ledger sidecars; self-hosted metrics remain separate "
231
+ "from hosted API token/cost savings."
232
+ ),
233
+ ),
234
+ Experiment(
235
+ id="local-proxy",
236
+ name="Local proxy advisory lane",
237
+ summary="Dry-run localhost-only proxy advisory plan with no hidden forwarding or API-key persistence.",
238
+ stability="experimental",
239
+ default_enabled=False,
240
+ risk_level="high",
241
+ claim_boundary="Proxy metrics are diagnostic only; no hosted savings claim without provider-measured evidence.",
242
+ gate_requirements=("explicit opt-in", "localhost-only default", "no API-key persistence", "no hidden external forwarding"),
243
+ runtime_status="available-dry-run",
244
+ commands=("context-guard experiments plan local-proxy",),
245
+ opt_in_flags=(
246
+ "plan local-proxy",
247
+ "--bind-host",
248
+ "--bind-port",
249
+ "--target-host",
250
+ "--target-port",
251
+ "--upstream-url",
252
+ "--runtime-gate-ack",
253
+ "--external-forwarding-intent",
254
+ "--persist-api-key",
255
+ ),
256
+ config_effect=(
257
+ "Registry enablement records project-local intent only; local proxy planning remains a dry-run advisory "
258
+ "surface and does not bind sockets, forward traffic, persist API keys, or write ledgers."
259
+ ),
260
+ evidence_contract=(
261
+ "Dry-run plans require localhost-only bind/target metadata, explicit runtime gate acknowledgement before "
262
+ "any future forwarding, and no raw API-key persistence."
263
+ ),
264
+ ),
265
+ )
266
+
267
+ REGISTRY = {experiment.id: experiment for experiment in EXPERIMENTS}
268
+
269
+
270
+ class RegistryError(RuntimeError):
271
+ pass
272
+
273
+
274
+ def fail(message: str, code: int = 2) -> NoReturn:
275
+ print(f"{TOOL_NAME}: {message}", file=sys.stderr)
276
+ raise SystemExit(code)
277
+
278
+
279
+ def resolve_root(raw_root: str | None) -> Path:
280
+ root = Path(raw_root) if raw_root else Path.cwd()
281
+ try:
282
+ return root.expanduser().resolve()
283
+ except OSError as exc:
284
+ raise RegistryError(f"could not resolve root: {root}: {exc}") from exc
285
+
286
+
287
+ def resolve_config_path(root: Path, raw_config: str | None) -> Path:
288
+ if raw_config:
289
+ candidate = Path(raw_config).expanduser()
290
+ if not candidate.is_absolute():
291
+ candidate = root / candidate
292
+ else:
293
+ candidate = root / DEFAULT_CONFIG
294
+ try:
295
+ resolved = candidate.resolve(strict=False)
296
+ except OSError as exc:
297
+ raise RegistryError(f"could not resolve config path: {candidate}: {exc}") from exc
298
+ try:
299
+ resolved.relative_to(root)
300
+ except ValueError as exc:
301
+ raise RegistryError(f"config path must stay inside project root: {resolved}") from exc
302
+ return resolved
303
+
304
+
305
+ def load_config(path: Path) -> dict[str, Any]:
306
+ if not path.exists():
307
+ return {"schema_version": CONFIG_SCHEMA_VERSION, "enabled": []}
308
+ try:
309
+ data = json.loads(path.read_text(encoding="utf-8"))
310
+ except json.JSONDecodeError as exc:
311
+ raise RegistryError(f"could not parse config JSON: {path}: {exc.msg}") from exc
312
+ except OSError as exc:
313
+ raise RegistryError(f"could not read config: {path}: {exc}") from exc
314
+ if not isinstance(data, dict):
315
+ raise RegistryError(f"config must be a JSON object: {path}")
316
+ schema = data.get("schema_version")
317
+ if schema not in (None, CONFIG_SCHEMA_VERSION):
318
+ raise RegistryError(f"unsupported config schema_version: {schema!r}")
319
+ enabled = data.get("enabled", [])
320
+ if not isinstance(enabled, list) or not all(isinstance(item, str) for item in enabled):
321
+ raise RegistryError("config enabled must be a list of experiment ids")
322
+ return {"schema_version": CONFIG_SCHEMA_VERSION, "enabled": sorted(set(enabled))}
323
+
324
+
325
+ def write_config(path: Path, enabled: set[str]) -> dict[str, Any]:
326
+ data = {
327
+ "schema_version": CONFIG_SCHEMA_VERSION,
328
+ "updated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
329
+ "enabled": sorted(enabled),
330
+ }
331
+ try:
332
+ path.parent.mkdir(parents=True, exist_ok=True)
333
+ path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
334
+ except OSError as exc:
335
+ raise RegistryError(f"could not write config: {path}: {exc}") from exc
336
+ return data
337
+
338
+
339
+ def configured_enabled_set(config: dict[str, Any]) -> set[str]:
340
+ return set(config.get("enabled", []))
341
+
342
+
343
+ def enabled_set(config: dict[str, Any]) -> set[str]:
344
+ return {item for item in configured_enabled_set(config) if item in REGISTRY}
345
+
346
+
347
+ def unknown_enabled(config: dict[str, Any]) -> list[str]:
348
+ return sorted(item for item in set(config.get("enabled", [])) if item not in REGISTRY)
349
+
350
+
351
+ def registry_payload(*, config_path: Path, config: dict[str, Any], root: Path) -> dict[str, Any]:
352
+ enabled = enabled_set(config)
353
+ return {
354
+ "tool": TOOL_NAME,
355
+ "schema_version": CONFIG_SCHEMA_VERSION,
356
+ "root": str(root),
357
+ "config_path": str(config_path),
358
+ "default_off": True,
359
+ "note": "Experiments are opt-in metadata gates; enabling an experiment does not activate stable runtime behavior by itself.",
360
+ "unknown_enabled": unknown_enabled(config),
361
+ "experiments": [experiment.to_json(enabled=experiment.id in enabled) for experiment in EXPERIMENTS],
362
+ }
363
+
364
+
365
+ def emit_json(payload: dict[str, Any]) -> None:
366
+ print(json.dumps(payload, indent=2, sort_keys=True))
367
+
368
+
369
+ def emit_human(payload: dict[str, Any], *, include_details: bool = False) -> None:
370
+ print("ContextGuard experiments (default off; explicit opt-in required)")
371
+ print(f"Config: {payload['config_path']}")
372
+ print("Enabling an experiment records project-local intent only; helpers still require explicit experimental use.")
373
+ for experiment in payload["experiments"]:
374
+ state = "enabled" if experiment["enabled"] else "disabled"
375
+ print(f"- {experiment['id']}: {state} [{experiment['stability']}, risk={experiment['risk_level']}]")
376
+ if include_details:
377
+ print(f" {experiment['summary']}")
378
+ print(f" Runtime: {experiment['runtime_status']}")
379
+ if experiment["commands"]:
380
+ print(" Commands: " + "; ".join(experiment["commands"]))
381
+ if experiment["opt_in_flags"]:
382
+ print(" Opt-in flags: " + ", ".join(experiment["opt_in_flags"]))
383
+ print(f" Config effect: {experiment['config_effect']}")
384
+ print(f" Evidence contract: {experiment['evidence_contract']}")
385
+ print(f" Claim boundary: {experiment['claim_boundary']}")
386
+ if payload["unknown_enabled"]:
387
+ print("Unknown enabled ids in config: " + ", ".join(payload["unknown_enabled"]))
388
+
389
+
390
+ def require_known(experiment_id: str) -> Experiment:
391
+ try:
392
+ return REGISTRY[experiment_id]
393
+ except KeyError:
394
+ choices = ", ".join(sorted(REGISTRY))
395
+ fail(f"unknown experiment id {experiment_id!r}; known ids: {choices}")
396
+
397
+
398
+ def command_list(args: argparse.Namespace) -> int:
399
+ root, config_path, config = load_args_context(args)
400
+ payload = registry_payload(config_path=config_path, config=config, root=root)
401
+ if args.json:
402
+ emit_json(payload)
403
+ else:
404
+ emit_human(payload, include_details=True)
405
+ return 0
406
+
407
+
408
+ def command_status(args: argparse.Namespace) -> int:
409
+ root, config_path, config = load_args_context(args)
410
+ payload = registry_payload(config_path=config_path, config=config, root=root)
411
+ if args.json:
412
+ emit_json(payload)
413
+ else:
414
+ emit_human(payload, include_details=False)
415
+ return 0
416
+
417
+
418
+ def command_enable(args: argparse.Namespace) -> int:
419
+ require_known(args.experiment_id)
420
+ root, config_path, config = load_args_context(args)
421
+ enabled = configured_enabled_set(config)
422
+ changed = args.experiment_id not in enabled
423
+ enabled.add(args.experiment_id)
424
+ written = write_config(config_path, enabled)
425
+ payload = registry_payload(config_path=config_path, config=written, root=root)
426
+ payload["changed"] = changed
427
+ payload["experiment_id"] = args.experiment_id
428
+ if args.json:
429
+ emit_json(payload)
430
+ else:
431
+ print(f"enabled {args.experiment_id} in {config_path}")
432
+ return 0
433
+
434
+
435
+ def command_disable(args: argparse.Namespace) -> int:
436
+ require_known(args.experiment_id)
437
+ root, config_path, config = load_args_context(args)
438
+ enabled = configured_enabled_set(config)
439
+ changed = args.experiment_id in enabled
440
+ enabled.discard(args.experiment_id)
441
+ written = write_config(config_path, enabled)
442
+ payload = registry_payload(config_path=config_path, config=written, root=root)
443
+ payload["changed"] = changed
444
+ payload["experiment_id"] = args.experiment_id
445
+ if args.json:
446
+ emit_json(payload)
447
+ else:
448
+ print(f"disabled {args.experiment_id} in {config_path}")
449
+ return 0
450
+
451
+
452
+
453
+ DIFF_GIT_RE = re.compile(r"^diff --git (?P<old>\S+) (?P<new>\S+)$")
454
+ HUNK_RE = re.compile(r"^@@\s+-(?P<old_start>\d+)(?:,(?P<old_count>\d+))?\s+\+(?P<new_start>\d+)(?:,(?P<new_count>\d+))?\s+@@(?P<section>.*)$")
455
+
456
+
457
+ def read_bounded_input(args: argparse.Namespace) -> tuple[str, dict[str, Any]]:
458
+ source_label = args.source_label
459
+ if args.input:
460
+ path = Path(args.input)
461
+ source_label = source_label or str(path)
462
+ try:
463
+ with path.open("rb") as handle:
464
+ raw = handle.read(MAX_CONTEXT_DIFF_INPUT_BYTES + 1)
465
+ except OSError as exc:
466
+ raise RegistryError(f"could not read input: {path}: {exc}") from exc
467
+ else:
468
+ source_label = source_label or "stdin"
469
+ raw = sys.stdin.buffer.read(MAX_CONTEXT_DIFF_INPUT_BYTES + 1)
470
+ if not raw:
471
+ raise RegistryError("context-diff-compaction plan requires diff input on stdin or --input")
472
+ truncated = len(raw) > MAX_CONTEXT_DIFF_INPUT_BYTES
473
+ raw = raw[:MAX_CONTEXT_DIFF_INPUT_BYTES]
474
+ text = raw.decode("utf-8", errors="replace")
475
+ metadata = {
476
+ "source_label": source_label,
477
+ "bytes": len(raw),
478
+ "lines": len(text.splitlines()),
479
+ "sha256": hashlib.sha256(raw).hexdigest(),
480
+ "truncated": truncated,
481
+ "max_bytes": MAX_CONTEXT_DIFF_INPUT_BYTES,
482
+ }
483
+ return text, metadata
484
+
485
+
486
+ def strip_diff_prefix(path: str) -> str:
487
+ if path.startswith(("a/", "b/")):
488
+ return path[2:]
489
+ return path
490
+
491
+
492
+ def summarize_diff(text: str, *, max_files: int = 50, max_hunks: int = 200) -> dict[str, Any]:
493
+ files: list[dict[str, Any]] = []
494
+ current: dict[str, Any] | None = None
495
+ total_hunks = 0
496
+ lines = text.splitlines()
497
+ diff_header_count = 0
498
+ for line_number, line in enumerate(lines, start=1):
499
+ match = DIFF_GIT_RE.match(line)
500
+ if match:
501
+ diff_header_count += 1
502
+ if len(files) >= max_files:
503
+ current = None
504
+ continue
505
+ current = {
506
+ "old_path": strip_diff_prefix(match.group("old")),
507
+ "new_path": strip_diff_prefix(match.group("new")),
508
+ "diff_header_line": line_number,
509
+ "hunks": [],
510
+ }
511
+ files.append(current)
512
+ continue
513
+ hunk = HUNK_RE.match(line)
514
+ if hunk:
515
+ total_hunks += 1
516
+ if current is None:
517
+ if len(files) >= max_files:
518
+ continue
519
+ current = {"old_path": None, "new_path": None, "diff_header_line": None, "hunks": []}
520
+ files.append(current)
521
+ if len(current["hunks"]) < max_hunks:
522
+ current["hunks"].append(
523
+ {
524
+ "line": line_number,
525
+ "old_start": int(hunk.group("old_start")),
526
+ "old_count": int(hunk.group("old_count") or "1"),
527
+ "new_start": int(hunk.group("new_start")),
528
+ "new_count": int(hunk.group("new_count") or "1"),
529
+ "section": hunk.group("section").strip()[:120],
530
+ }
531
+ )
532
+ return {
533
+ "file_count": len(files),
534
+ "hunk_count": total_hunks,
535
+ "truncated_files": max(0, diff_header_count - len(files)),
536
+ "files": files,
537
+ }
538
+
539
+
540
+ def context_diff_plan_payload(args: argparse.Namespace) -> dict[str, Any]:
541
+ text, input_meta = read_bounded_input(args)
542
+ summary = summarize_diff(text)
543
+ receipt_id = args.receipt_id.strip() if args.receipt_id else None
544
+ reexpand_command = args.reexpand_command.strip() if args.reexpand_command else None
545
+ has_exact_handle = bool(receipt_id and reexpand_command)
546
+ readiness_blockers: list[str] = []
547
+ if not has_exact_handle:
548
+ readiness_blockers.append("missing_exact_receipt_or_reexpand_command")
549
+ if input_meta["truncated"]:
550
+ readiness_blockers.append("input_truncated")
551
+ if summary["file_count"] == 0 or summary["hunk_count"] == 0:
552
+ readiness_blockers.append("no_reviewable_diff_hunks")
553
+ status = (
554
+ "ready_for_human_review"
555
+ if not readiness_blockers
556
+ else "blocked_until_reviewable_diff"
557
+ if has_exact_handle
558
+ else "blocked_until_exact_receipt"
559
+ )
560
+ return {
561
+ "tool": TOOL_NAME,
562
+ "schema_version": CONFIG_SCHEMA_VERSION,
563
+ "experiment_id": "context-diff-compaction",
564
+ "mode": "dry_run",
565
+ "status": status,
566
+ "input": input_meta,
567
+ "transform_policy": {
568
+ "automatic_compaction": False,
569
+ "lossy_replacement_allowed": False,
570
+ "semantic_rewrite_allowed": False,
571
+ "human_review_required": True,
572
+ "stable_runtime_behavior_changed": False,
573
+ },
574
+ "exact_retrieval": {
575
+ "required": True,
576
+ "available": has_exact_handle,
577
+ "artifact_id": receipt_id,
578
+ "cli": reexpand_command,
579
+ "verified": False,
580
+ "note": "G003 records user-supplied handles for human review only; it does not verify local receipt storage.",
581
+ },
582
+ "review_plan": {
583
+ "summary": summary,
584
+ "readiness_blockers": readiness_blockers,
585
+ "bounded_loss_disclosure": (
586
+ "No compacted replacement was produced. Any future lossy replacement must keep this diff reviewable "
587
+ "and provide exact receipt/re-expand handles before use."
588
+ ),
589
+ "next_steps": [
590
+ "Store exact original evidence with context-guard-artifact or another local receipt before compacting.",
591
+ "Review file and hunk summaries against the original diff.",
592
+ "Do not claim hosted token/cost savings from this dry-run plan.",
593
+ ],
594
+ },
595
+ "claim_boundary": "Dry-run local planning only; no hosted API token/cost savings claim without provider-measured matched successful tasks.",
596
+ "compacted_replacement": None,
597
+ }
598
+
599
+
600
+ def command_plan_context_diff_compaction(args: argparse.Namespace) -> int:
601
+ payload = context_diff_plan_payload(args)
602
+ if args.json:
603
+ emit_json(payload)
604
+ else:
605
+ print("ContextGuard context-diff compaction plan (dry-run only)")
606
+ print("No compaction was performed and no replacement text was emitted.")
607
+ print(f"Status: {payload['status']}")
608
+ print(f"Input: {payload['input']['source_label']} lines={payload['input']['lines']} sha256={payload['input']['sha256']}")
609
+ print(
610
+ f"Review summary: files={payload['review_plan']['summary']['file_count']} "
611
+ f"hunks={payload['review_plan']['summary']['hunk_count']}"
612
+ )
613
+ if not payload["exact_retrieval"]["available"]:
614
+ print("Exact receipt/re-expand command required before any lossy replacement can be reviewed.")
615
+ else:
616
+ print("Exact retrieval handle supplied for human review only; verified=false.")
617
+ if payload["review_plan"]["readiness_blockers"]:
618
+ print(f"Readiness blockers: {', '.join(payload['review_plan']['readiness_blockers'])}")
619
+ print(payload["claim_boundary"])
620
+ return 0
621
+
622
+
623
+ def clean_values(values: list[str] | None) -> list[str]:
624
+ return [value.strip() for value in values or [] if value.strip()]
625
+
626
+
627
+ def parse_int_tuple(raw: str | None, *, count: int) -> tuple[int, ...] | None:
628
+ if raw is None or not raw.strip():
629
+ return None
630
+ parts = [part.strip() for part in raw.split(",")]
631
+ if len(parts) != count:
632
+ return None
633
+ try:
634
+ return tuple(int(part, 10) for part in parts)
635
+ except ValueError:
636
+ return None
637
+
638
+
639
+ def crop_payload(bounds: tuple[int, ...] | None, image_size: tuple[int, ...] | None) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
640
+ bounds_payload = None
641
+ image_payload = None
642
+ if bounds is not None:
643
+ x, y, width, height = bounds
644
+ bounds_payload = {"x": x, "y": y, "width": width, "height": height}
645
+ if image_size is not None:
646
+ width, height = image_size
647
+ image_payload = {"width": width, "height": height}
648
+ return bounds_payload, image_payload
649
+
650
+
651
+ def valid_crop_geometry(bounds: tuple[int, ...] | None, image_size: tuple[int, ...] | None) -> tuple[bool, bool]:
652
+ if bounds is None or image_size is None:
653
+ return False, False
654
+ x, y, crop_width, crop_height = bounds
655
+ image_width, image_height = image_size
656
+ if x < 0 or y < 0 or crop_width <= 0 or crop_height <= 0 or image_width <= 0 or image_height <= 0:
657
+ return False, False
658
+ if x + crop_width > image_width or y + crop_height > image_height:
659
+ return True, True
660
+ return True, False
661
+
662
+
663
+ def parse_confidence(raw: str | None) -> tuple[float | None, str | None]:
664
+ if raw is None or not raw.strip():
665
+ return None, "missing"
666
+ try:
667
+ value = float(raw)
668
+ except ValueError:
669
+ return None, "invalid"
670
+ if not (0.0 <= value <= 1.0):
671
+ return None, "invalid"
672
+ return value, None
673
+
674
+
675
+ def read_visual_ocr_text(args: argparse.Namespace) -> dict[str, Any]:
676
+ if args.ocr_text is not None and args.ocr_text_file is not None:
677
+ raise RegistryError("--ocr-text and --ocr-text-file are mutually exclusive")
678
+ if args.ocr_text_file is not None:
679
+ path = Path(args.ocr_text_file)
680
+ source_label = args.ocr_source_label.strip() if args.ocr_source_label else path.name
681
+ try:
682
+ with path.open("rb") as handle:
683
+ raw = handle.read(MAX_VISUAL_OCR_TEXT_BYTES + 1)
684
+ except OSError as exc:
685
+ raise RegistryError(f"could not read OCR text file: {path}: {exc}") from exc
686
+ source_type = "file"
687
+ elif args.ocr_text is not None:
688
+ raw = args.ocr_text.encode("utf-8")
689
+ source_label = args.ocr_source_label.strip() if args.ocr_source_label else "inline"
690
+ source_type = "inline"
691
+ else:
692
+ raw = b""
693
+ source_label = args.ocr_source_label.strip() if args.ocr_source_label else None
694
+ source_type = None
695
+
696
+ truncated = len(raw) > MAX_VISUAL_OCR_TEXT_BYTES
697
+ raw = raw[:MAX_VISUAL_OCR_TEXT_BYTES]
698
+ try:
699
+ text = raw.decode("utf-8")
700
+ valid_encoding = True
701
+ except UnicodeDecodeError:
702
+ text = raw.decode("utf-8", errors="replace")
703
+ valid_encoding = False
704
+ return {
705
+ "source_type": source_type,
706
+ "source_label": source_label,
707
+ "bytes": len(raw),
708
+ "lines": len(text.splitlines()),
709
+ "sha256": hashlib.sha256(raw).hexdigest() if raw else None,
710
+ "truncated": truncated,
711
+ "max_bytes": MAX_VISUAL_OCR_TEXT_BYTES,
712
+ "valid_utf8": valid_encoding,
713
+ "text_preview": text,
714
+ "has_text": bool(text.strip()),
715
+ }
716
+
717
+
718
+ def visual_crop_ocr_plan_payload(args: argparse.Namespace) -> dict[str, Any]:
719
+ full_receipt = args.full_evidence_receipt.strip() if args.full_evidence_receipt else None
720
+ full_label = args.full_evidence_label.strip() if args.full_evidence_label else None
721
+ missed_context_notes = clean_values(args.missed_context_note)
722
+ ocr_error_notes = clean_values(args.ocr_error_note)
723
+ crop_label = args.crop_label.strip() if args.crop_label else None
724
+
725
+ bounds = parse_int_tuple(args.crop_bounds, count=4)
726
+ image_size = parse_int_tuple(args.image_size, count=2)
727
+ bounds_payload, image_payload = crop_payload(bounds, image_size)
728
+ crop_fields_present = any(value is not None and str(value).strip() for value in (args.crop_label, args.crop_bounds, args.image_size))
729
+ crop_geometry_valid, crop_exceeds = valid_crop_geometry(bounds, image_size)
730
+ crop_complete = bool(crop_label and crop_geometry_valid and not crop_exceeds)
731
+
732
+ ocr_text = read_visual_ocr_text(args)
733
+ confidence, confidence_error = parse_confidence(args.ocr_confidence)
734
+ ocr_fields_present = any(
735
+ [
736
+ args.ocr_text is not None,
737
+ args.ocr_text_file is not None,
738
+ args.ocr_confidence is not None,
739
+ bool(ocr_error_notes),
740
+ ]
741
+ )
742
+ ocr_complete = bool(
743
+ ocr_text["has_text"]
744
+ and ocr_text["valid_utf8"]
745
+ and not ocr_text["truncated"]
746
+ and confidence_error is None
747
+ and ocr_error_notes
748
+ )
749
+
750
+ blockers: list[str] = []
751
+ if not full_receipt:
752
+ blockers.append("missing_full_evidence_receipt")
753
+ if not missed_context_notes:
754
+ blockers.append("missing_missed_context_note")
755
+ if not crop_complete and not ocr_complete:
756
+ blockers.append("missing_derived_evidence")
757
+
758
+ if crop_fields_present and (not crop_label or not crop_geometry_valid):
759
+ blockers.append("invalid_crop_bounds")
760
+ elif crop_fields_present and crop_exceeds:
761
+ blockers.append("crop_exceeds_image_bounds")
762
+
763
+ if ocr_fields_present:
764
+ if confidence_error == "missing":
765
+ blockers.append("missing_ocr_confidence")
766
+ elif confidence_error == "invalid":
767
+ blockers.append("invalid_ocr_confidence")
768
+ if not ocr_error_notes:
769
+ blockers.append("missing_ocr_error_note")
770
+ if not ocr_text["has_text"]:
771
+ blockers.append("missing_ocr_text")
772
+ if not ocr_text["valid_utf8"]:
773
+ blockers.append("invalid_ocr_text_encoding")
774
+ if ocr_text["truncated"]:
775
+ blockers.append("ocr_text_truncated")
776
+
777
+ # Preserve stable ordering while avoiding duplicates when incomplete derived
778
+ # evidence also contributed path-specific blockers.
779
+ blockers = list(dict.fromkeys(blockers))
780
+ status = "ready_for_human_review" if not blockers else "blocked_until_visual_evidence"
781
+
782
+ return {
783
+ "tool": TOOL_NAME,
784
+ "schema_version": CONFIG_SCHEMA_VERSION,
785
+ "experiment_id": "visual-crop-ocr",
786
+ "mode": "dry_run",
787
+ "status": status,
788
+ "external_services": {
789
+ "called": False,
790
+ "ocr_service": None,
791
+ "image_service": None,
792
+ "network": False,
793
+ },
794
+ "full_visual_evidence": {
795
+ "required": True,
796
+ "available": bool(full_receipt),
797
+ "receipt_id": full_receipt,
798
+ "label": full_label,
799
+ "verified": False,
800
+ "note": "G004 records user-supplied full visual evidence handles only; it does not verify receipt storage.",
801
+ },
802
+ "derived_evidence": {
803
+ "crop": {
804
+ "available": crop_complete,
805
+ "label": crop_label,
806
+ "bounds": bounds_payload,
807
+ "image_size": image_payload,
808
+ "source": "user_supplied_metadata" if crop_fields_present else None,
809
+ },
810
+ "ocr": {
811
+ "available": ocr_complete,
812
+ "source_type": ocr_text["source_type"],
813
+ "source_label": ocr_text["source_label"],
814
+ "text_preview": ocr_text["text_preview"] if ocr_text["has_text"] else None,
815
+ "metadata": {
816
+ "bytes": ocr_text["bytes"],
817
+ "lines": ocr_text["lines"],
818
+ "sha256": ocr_text["sha256"],
819
+ "truncated": ocr_text["truncated"],
820
+ "max_bytes": ocr_text["max_bytes"],
821
+ "valid_utf8": ocr_text["valid_utf8"],
822
+ },
823
+ "confidence": confidence,
824
+ "error_notes": ocr_error_notes,
825
+ },
826
+ },
827
+ "guardrails": {
828
+ "original_evidence_required": True,
829
+ "full_visual_evidence_must_remain_available": True,
830
+ "external_ocr_service_allowed": False,
831
+ "external_image_service_allowed": False,
832
+ "human_review_required": True,
833
+ "missed_context_review_required": True,
834
+ "confidence_error_notes_required_for_ocr": True,
835
+ "stable_runtime_behavior_changed": False,
836
+ "candidate_replacement_allowed": False,
837
+ },
838
+ "review_plan": {
839
+ "readiness_blockers": blockers,
840
+ "missed_context_notes": missed_context_notes,
841
+ "next_steps": [
842
+ "Keep full visual evidence retrievable before relying on cropped or OCR-derived evidence.",
843
+ "Review crop bounds and OCR text against the original evidence for missed context.",
844
+ "Do not claim hosted image/text token or cost savings from this dry-run plan.",
845
+ ],
846
+ },
847
+ "claim_boundary": (
848
+ "Dry-run visual/OCR fixture planning only; no hosted visual/text token or cost savings claim without "
849
+ "provider-measured matched successful tasks."
850
+ ),
851
+ "candidate_replacement": None,
852
+ }
853
+
854
+
855
+ def command_plan_visual_crop_ocr(args: argparse.Namespace) -> int:
856
+ payload = visual_crop_ocr_plan_payload(args)
857
+ if args.json:
858
+ emit_json(payload)
859
+ else:
860
+ print("ContextGuard visual crop/OCR plan (dry-run only)")
861
+ print("No external OCR/image service was called and no replacement evidence was emitted.")
862
+ print(f"Status: {payload['status']}")
863
+ print(f"Full evidence available: {payload['full_visual_evidence']['available']} verified=false")
864
+ print(
865
+ "Derived evidence: "
866
+ f"crop={payload['derived_evidence']['crop']['available']} "
867
+ f"ocr={payload['derived_evidence']['ocr']['available']}"
868
+ )
869
+ if payload["review_plan"]["readiness_blockers"]:
870
+ print(f"Readiness blockers: {', '.join(payload['review_plan']['readiness_blockers'])}")
871
+ print(payload["claim_boundary"])
872
+ return 0
873
+
874
+
875
+ SECRET_LABEL_KEY_RE = (
876
+ r"[A-Za-z0-9_.-]*(?:"
877
+ r"api[-_]?key|apikey|token|secret|password|passwd|pwd|client[-_]?secret|"
878
+ r"auth|authorization|bearer|basic|pass|credential|credentials|signature|sig|"
879
+ r"x[-_]?amz[-_]?[a-z0-9_.-]*|aws[a-z0-9_.-]*|(?:aws[-_]?)?access[-_]?key(?:[-_]?id)?|"
880
+ r"private[-_]?key|privatekey|pgp[-_]?private[-_]?key|pgpprivatekey|ssh[-_]?key|sshkey"
881
+ r")[A-Za-z0-9_.-]*"
882
+ )
883
+ SECRET_LABEL_VALUE_RE = r"(?:'[^']*'|\"[^\"]*\"|[^\s,}&#;]+)"
884
+ SECRET_LABEL_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
885
+ (re.compile(r"(?i)\bAuthorization\s*:\s*(?:Bearer|Basic|AWS|AWS4-HMAC-SHA256)\s+[^\s,}\]]+(?:\s+[A-Za-z0-9_-]+=[^\s,}\]]+)*"), "Authorization: [REDACTED]"),
886
+ (re.compile(r"(?i)\b(?:Bearer|Basic)\s*(?:[:=]\s*)?[A-Za-z0-9._~+/=-]+"), "[REDACTED]"),
887
+ (re.compile(r"(?i)\b(?:AWS|AWS4-HMAC-SHA256)\s+[A-Za-z0-9,=:/+._~%-]+"), "[REDACTED]"),
888
+ (re.compile(rf"(?i)([?&#;]({SECRET_LABEL_KEY_RE})=)[^\s?&#;]+"), r"\1[REDACTED]"),
889
+ (
890
+ re.compile(rf"(?i)(^|[\s{{,?&#;])([\"']?(?:{SECRET_LABEL_KEY_RE})[\"']?\s*[:=]\s*){SECRET_LABEL_VALUE_RE}"),
891
+ r"\1\2[REDACTED]",
892
+ ),
893
+ (
894
+ re.compile(rf"(?i)(^|[\s\"'])(--(?:{SECRET_LABEL_KEY_RE})(?:\s+|=))(?:'[^']*'|\"[^\"]*\"|[^\s\"']+)"),
895
+ r"\1\2[REDACTED]",
896
+ ),
897
+ (re.compile(r"(?i)(^|[\s\"'])((?:-u|--user)(?:\s+|=))(?:'[^']*'|\"[^\"]*\"|[^\s\"']+)"), r"\1\2[REDACTED]"),
898
+ (re.compile(rf"(?i)(^|[/\\\s{{,?&#;\[\(<])({SECRET_LABEL_KEY_RE}(?:[:=][^\s,}}&#;\]\)\\/]*)?)"), r"\1[REDACTED]"),
899
+ (re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"), "[REDACTED]"),
900
+ (re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "[REDACTED]"),
901
+ (re.compile(r"glpat-[A-Za-z0-9_-]{12,}"), "[REDACTED]"),
902
+ (re.compile(r"xox[abprs]-[A-Za-z0-9-]{10,}"), "[REDACTED]"),
903
+ (re.compile(r"(?:AKIA|ASIA)[0-9A-Z]{16}"), "[REDACTED]"),
904
+ (re.compile(r"(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}"), "[REDACTED]"),
905
+ (re.compile(r"sk-(?:ant|proj)-[A-Za-z0-9_-]{12,}"), "[REDACTED]"),
906
+ (re.compile(r"npm_[A-Za-z0-9]{20,}"), "[REDACTED]"),
907
+ (re.compile(r"AIza[0-9A-Za-z_\-]{20,}"), "[REDACTED]"),
908
+ (re.compile(r"SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"), "[REDACTED]"),
909
+ (re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), "[REDACTED]"),
910
+ (re.compile(r"([a-z][a-z0-9+.-]*://)[^/\s@]+@", re.IGNORECASE), r"\1[REDACTED]@"),
911
+ )
912
+
913
+
914
+ def sanitize_self_hosted_text(value: Any) -> str:
915
+ text = "" if value is None else str(value)
916
+ text = "".join(" " if unicodedata.category(ch)[0] == "C" else ch for ch in text)
917
+ text = " ".join(text.split())
918
+ for pattern, replacement in SECRET_LABEL_PATTERNS:
919
+ text = pattern.sub(replacement, text)
920
+ text = re.sub(r"\[REDACTED\]\]+", "[REDACTED]", text)
921
+ text = re.sub(r"(?:\[REDACTED\]\s*){2,}", "[REDACTED]", text)
922
+ if len(text) > MAX_SELF_HOSTED_LABEL_CHARS:
923
+ text = text[: MAX_SELF_HOSTED_LABEL_CHARS - 12].rstrip() + "…[truncated]"
924
+ return text
925
+
926
+
927
+ def sanitize_self_hosted_label(value: Any) -> str | None:
928
+ if not isinstance(value, str):
929
+ return None
930
+ text = sanitize_self_hosted_text(value)
931
+ if not text:
932
+ return None
933
+ return text
934
+
935
+
936
+ def sanitize_self_hosted_ignored_key(value: Any) -> str:
937
+ if not isinstance(value, str):
938
+ return "non_string_key"
939
+ text = sanitize_self_hosted_text(value)
940
+ if not text:
941
+ return "empty_key"
942
+ if "[REDACTED]" in text:
943
+ return "redacted_key"
944
+ return text
945
+
946
+
947
+ def normalize_self_hosted_metric(value: Any, *, maximum: float) -> float | None:
948
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
949
+ return None
950
+ number = float(value)
951
+ if not math.isfinite(number) or number < 0 or number > maximum:
952
+ return None
953
+ return number
954
+
955
+
956
+ SELF_HOSTED_METRIC_LIMITS: dict[str, float] = {
957
+ "latency_ms": MAX_SELF_HOSTED_LATENCY_MS,
958
+ "peak_memory_mb": MAX_SELF_HOSTED_MEMORY_MB,
959
+ "quality_score": 1.0,
960
+ "energy_wh": MAX_SELF_HOSTED_ENERGY_WH,
961
+ "local_cost_usd": MAX_SELF_HOSTED_LOCAL_COST_USD,
962
+ "tokens_per_second": MAX_SELF_HOSTED_TOKENS_PER_SECOND,
963
+ }
964
+ SELF_HOSTED_LABEL_KEYS = ("model_server", "optimization", "quality_metric", "hardware", "runtime", "dataset")
965
+
966
+
967
+ def normalize_self_hosted_metrics(raw: Any, *, source: str) -> tuple[dict[str, Any] | None, list[str], list[str]]:
968
+ invalid_keys: list[str] = []
969
+ ignored_keys: list[str] = []
970
+ if not isinstance(raw, dict):
971
+ return None, ["self_hosted_metrics_not_object"], ignored_keys
972
+ metrics: dict[str, float] = {}
973
+ labels: dict[str, str] = {}
974
+ availability = {key: False for key in SELF_HOSTED_METRIC_LIMITS}
975
+ for key, value in raw.items():
976
+ if key in SELF_HOSTED_METRIC_LIMITS:
977
+ metric = normalize_self_hosted_metric(value, maximum=SELF_HOSTED_METRIC_LIMITS[key])
978
+ if metric is None:
979
+ invalid_keys.append(key)
980
+ else:
981
+ metrics[key] = metric
982
+ availability[key] = True
983
+ elif key in SELF_HOSTED_LABEL_KEYS:
984
+ label = sanitize_self_hosted_label(value)
985
+ if label is not None:
986
+ labels[key] = label
987
+ elif value is not None:
988
+ invalid_keys.append(key)
989
+ else:
990
+ ignored_keys.append(sanitize_self_hosted_ignored_key(key))
991
+ if not metrics:
992
+ return None, invalid_keys, ignored_keys
993
+ return {
994
+ "schema_version": SELF_HOSTED_METRICS_SCHEMA_VERSION,
995
+ "source": source,
996
+ "metrics": metrics,
997
+ "labels": labels,
998
+ "measurement_availability": availability,
999
+ "claim_boundary": {
1000
+ "id": SELF_HOSTED_METRICS_CLAIM_BOUNDARY,
1001
+ "hosted_api_token_savings_claim_allowed": False,
1002
+ "hosted_api_cost_savings_claim_allowed": False,
1003
+ "requires_provider_measured_matched_tasks_for_hosted_claims": True,
1004
+ "reason": (
1005
+ "Self-hosted local/model-server latency, memory, quality, energy, and local cost metrics "
1006
+ "are not hosted API token or cost telemetry."
1007
+ ),
1008
+ },
1009
+ }, invalid_keys, ignored_keys
1010
+
1011
+
1012
+ def cli_self_hosted_metrics(args: argparse.Namespace) -> dict[str, Any]:
1013
+ raw: dict[str, Any] = {}
1014
+ for arg_name, metric_name in (
1015
+ ("latency_ms", "latency_ms"),
1016
+ ("peak_memory_mb", "peak_memory_mb"),
1017
+ ("quality_score", "quality_score"),
1018
+ ("energy_wh", "energy_wh"),
1019
+ ("local_cost_usd", "local_cost_usd"),
1020
+ ("tokens_per_second", "tokens_per_second"),
1021
+ ):
1022
+ value = getattr(args, arg_name)
1023
+ if value is not None:
1024
+ raw[metric_name] = value
1025
+ for arg_name in SELF_HOSTED_LABEL_KEYS:
1026
+ value = getattr(args, arg_name)
1027
+ if value is not None:
1028
+ raw[arg_name] = value
1029
+ return raw
1030
+
1031
+
1032
+ def reject_non_finite_json_constant(value: str) -> NoReturn:
1033
+ raise ValueError(f"non-finite JSON value {value}")
1034
+
1035
+
1036
+ def has_non_finite_json_number(value: Any) -> bool:
1037
+ stack: list[tuple[Any, int]] = [(value, 0)]
1038
+ visited = 0
1039
+ while stack:
1040
+ item, depth = stack.pop()
1041
+ visited += 1
1042
+ if depth > MAX_SELF_HOSTED_JSON_DEPTH or visited > MAX_SELF_HOSTED_JSON_NODES:
1043
+ return True
1044
+ if isinstance(item, bool):
1045
+ continue
1046
+ if isinstance(item, float):
1047
+ if not math.isfinite(item):
1048
+ return True
1049
+ elif isinstance(item, list):
1050
+ stack.extend((child, depth + 1) for child in item)
1051
+ elif isinstance(item, dict):
1052
+ stack.extend((child, depth + 1) for child in item.values())
1053
+ return False
1054
+
1055
+
1056
+ def read_self_hosted_payload(args: argparse.Namespace) -> tuple[Any, dict[str, Any]]:
1057
+ source_label = sanitize_self_hosted_text(args.source_label) if args.source_label else None
1058
+ if args.input:
1059
+ path = Path(args.input)
1060
+ source_label = source_label or sanitize_self_hosted_text(path)
1061
+ try:
1062
+ with path.open("rb") as handle:
1063
+ raw = handle.read(MAX_SELF_HOSTED_METRICS_INPUT_BYTES + 1)
1064
+ except OSError as exc:
1065
+ safe_path = sanitize_self_hosted_text(path)
1066
+ detail = exc.strerror or exc.__class__.__name__
1067
+ if exc.errno is not None:
1068
+ detail = f"{detail} (errno {exc.errno})"
1069
+ raise RegistryError(f"could not read self-hosted metrics input: {safe_path}: {detail}") from exc
1070
+ else:
1071
+ source_label = source_label or "stdin"
1072
+ raw = sys.stdin.buffer.read(MAX_SELF_HOSTED_METRICS_INPUT_BYTES + 1)
1073
+ if len(raw) > MAX_SELF_HOSTED_METRICS_INPUT_BYTES:
1074
+ return None, {
1075
+ "source_label": source_label,
1076
+ "bytes": MAX_SELF_HOSTED_METRICS_INPUT_BYTES,
1077
+ "sha256": hashlib.sha256(raw[:MAX_SELF_HOSTED_METRICS_INPUT_BYTES]).hexdigest(),
1078
+ "truncated": True,
1079
+ "max_bytes": MAX_SELF_HOSTED_METRICS_INPUT_BYTES,
1080
+ "envelope_source": None,
1081
+ "invalid_metric_keys": [],
1082
+ "ignored_keys": [],
1083
+ }
1084
+ if not raw.strip():
1085
+ return None, {
1086
+ "source_label": source_label,
1087
+ "bytes": len(raw),
1088
+ "sha256": hashlib.sha256(raw).hexdigest(),
1089
+ "truncated": False,
1090
+ "max_bytes": MAX_SELF_HOSTED_METRICS_INPUT_BYTES,
1091
+ "envelope_source": None,
1092
+ "invalid_metric_keys": [],
1093
+ "ignored_keys": [],
1094
+ }
1095
+ text = raw.decode("utf-8", errors="replace")
1096
+ try:
1097
+ payload = json.loads(text, parse_constant=reject_non_finite_json_constant)
1098
+ except json.JSONDecodeError as exc:
1099
+ raise RegistryError(f"could not parse self-hosted metrics JSON: {exc.msg}") from exc
1100
+ except ValueError as exc:
1101
+ raise RegistryError(f"could not parse self-hosted metrics JSON: {exc}") from exc
1102
+ except RecursionError as exc:
1103
+ raise RegistryError("could not parse self-hosted metrics JSON: nesting too deep") from exc
1104
+ if has_non_finite_json_number(payload):
1105
+ raise RegistryError("could not parse self-hosted metrics JSON: non-finite JSON number")
1106
+ return payload, {
1107
+ "source_label": source_label,
1108
+ "bytes": len(raw),
1109
+ "sha256": hashlib.sha256(raw).hexdigest(),
1110
+ "truncated": False,
1111
+ "max_bytes": MAX_SELF_HOSTED_METRICS_INPUT_BYTES,
1112
+ "envelope_source": None,
1113
+ "invalid_metric_keys": [],
1114
+ "ignored_keys": [],
1115
+ }
1116
+
1117
+
1118
+ def select_self_hosted_envelope(payload: Any) -> tuple[Any, str | None, list[str]]:
1119
+ if not isinstance(payload, dict):
1120
+ return None, None, ["input_not_object"]
1121
+ ignored: list[str] = []
1122
+ if SELF_HOSTED_METRICS_KEY in payload:
1123
+ return payload.get(SELF_HOSTED_METRICS_KEY), f"explicit_provider_payload.{SELF_HOSTED_METRICS_KEY}", ignored
1124
+ metrics = payload.get("metrics")
1125
+ if isinstance(metrics, dict) and SELF_HOSTED_METRICS_KEY in metrics:
1126
+ return metrics.get(SELF_HOSTED_METRICS_KEY), f"explicit_provider_payload.metrics.{SELF_HOSTED_METRICS_KEY}", ignored
1127
+ if any(isinstance(key, str) and key.startswith("self_hosted_") for key in payload):
1128
+ ignored.append("incidental_self_hosted_keys")
1129
+ return None, None, ignored
1130
+
1131
+
1132
+ def self_hosted_metrics_plan_payload(args: argparse.Namespace) -> dict[str, Any]:
1133
+ cli_metrics = cli_self_hosted_metrics(args)
1134
+ if cli_metrics:
1135
+ raw_metrics = cli_metrics
1136
+ source = "cli_flags"
1137
+ ignored_envelope_keys = []
1138
+ input_meta = {
1139
+ "source_label": sanitize_self_hosted_text(args.source_label) if args.source_label else "cli_flags",
1140
+ "bytes": 0,
1141
+ "sha256": None,
1142
+ "truncated": False,
1143
+ "max_bytes": MAX_SELF_HOSTED_METRICS_INPUT_BYTES,
1144
+ "envelope_source": source,
1145
+ "invalid_metric_keys": [],
1146
+ "ignored_keys": [],
1147
+ }
1148
+ elif args.input or not sys.stdin.isatty():
1149
+ raw_payload, input_meta = read_self_hosted_payload(args)
1150
+ raw_metrics, source, ignored_envelope_keys = select_self_hosted_envelope(raw_payload)
1151
+ else:
1152
+ raw_metrics = {}
1153
+ source = None
1154
+ ignored_envelope_keys = []
1155
+ input_meta = {
1156
+ "source_label": sanitize_self_hosted_text(args.source_label) if args.source_label else "cli_flags",
1157
+ "bytes": 0,
1158
+ "sha256": None,
1159
+ "truncated": False,
1160
+ "max_bytes": MAX_SELF_HOSTED_METRICS_INPUT_BYTES,
1161
+ "envelope_source": source,
1162
+ "invalid_metric_keys": [],
1163
+ "ignored_keys": [],
1164
+ }
1165
+ if input_meta["truncated"]:
1166
+ sidecar = None
1167
+ invalid_keys: list[str] = []
1168
+ ignored_keys = ignored_envelope_keys
1169
+ elif raw_metrics is None:
1170
+ sidecar = None
1171
+ invalid_keys = []
1172
+ ignored_keys = ignored_envelope_keys
1173
+ else:
1174
+ sidecar, invalid_keys, ignored_keys = normalize_self_hosted_metrics(raw_metrics, source=source or "missing_explicit_envelope")
1175
+ input_meta["envelope_source"] = source
1176
+ input_meta["invalid_metric_keys"] = sorted(set(invalid_keys))
1177
+ input_meta["ignored_keys"] = sorted(set(ignored_keys + ignored_envelope_keys))
1178
+ blockers: list[str] = []
1179
+ if input_meta["truncated"]:
1180
+ blockers.append("input_truncated")
1181
+ if source is None:
1182
+ blockers.append("missing_explicit_self_hosted_metrics_envelope")
1183
+ if sidecar is None:
1184
+ blockers.append("missing_self_hosted_metrics")
1185
+ if invalid_keys:
1186
+ blockers.append("invalid_self_hosted_metrics")
1187
+ blockers = list(dict.fromkeys(blockers))
1188
+ ready = not blockers
1189
+ ledger_preview = None
1190
+ if sidecar is not None:
1191
+ ledger_preview = {
1192
+ "schema_version": BENCH_RUN_EVIDENCE_SCHEMA_VERSION,
1193
+ "date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1194
+ "claude_version": "dry-run",
1195
+ "task_id": "self-hosted-metrics-dry-run",
1196
+ "variant": "self-hosted-metrics-ledger",
1197
+ "transform_id": "self-hosted-metrics-ledger",
1198
+ "success": None,
1199
+ "primary_tokens_measured": False,
1200
+ "primary_tokens": 0,
1201
+ "primary_cost_measured": False,
1202
+ "primary_cost_usd": 0.0,
1203
+ "provider_cached_tokens": None,
1204
+ "provider_cached_tokens_measured": False,
1205
+ "wall_time_seconds": 0.0,
1206
+ "external_tokens_measured": False,
1207
+ "external_tokens": 0,
1208
+ "external_cost_measured": False,
1209
+ "external_cost_usd": 0.0,
1210
+ "total_cost_with_shift_usd": None,
1211
+ "artifacts_used": 0,
1212
+ "bytes_before": 0,
1213
+ "bytes_after": 0,
1214
+ "hook_triggers": 0,
1215
+ "turns": 0,
1216
+ "notes": "dry-run preview; no ledger file written",
1217
+ "measurement_availability": {
1218
+ "primary_tokens": False,
1219
+ "primary_cost": False,
1220
+ "external_tokens": False,
1221
+ "external_cost": False,
1222
+ "shifted_cost": False,
1223
+ "provider_cache": False,
1224
+ "byte_metrics": False,
1225
+ "wall_time": False,
1226
+ "self_hosted_metrics": True,
1227
+ },
1228
+ "self_hosted_metrics": sidecar,
1229
+ "proxy_metrics": {
1230
+ "byte_metrics_observed": False,
1231
+ "token_proxy": "chars_div_4",
1232
+ "bytes_per_token": TOKEN_PROXY_BYTES_PER_TOKEN,
1233
+ "claim_boundary": "proxy_only_not_hosted_token_savings",
1234
+ },
1235
+ }
1236
+ return {
1237
+ "tool": TOOL_NAME,
1238
+ "schema_version": CONFIG_SCHEMA_VERSION,
1239
+ "experiment_id": "self-hosted-metrics-ledger",
1240
+ "mode": "dry_run",
1241
+ "status": "ready_for_ledger_review" if ready else "blocked_until_metrics",
1242
+ "input": input_meta,
1243
+ "policy": {
1244
+ "default_off": True,
1245
+ "ledger_write_performed": False,
1246
+ "hosted_api_token_savings_claim_allowed": False,
1247
+ "hosted_api_cost_savings_claim_allowed": False,
1248
+ "stable_runtime_behavior_changed": False,
1249
+ },
1250
+ "self_hosted_metrics": sidecar,
1251
+ "ledger_preview": ledger_preview,
1252
+ "review_plan": {
1253
+ "readiness_blockers": blockers,
1254
+ "next_steps": [
1255
+ "Record real run evidence with context-guard-bench --ledger-jsonl when benchmark data exists.",
1256
+ "Keep self-hosted local metrics out of hosted API token/cost savings claims.",
1257
+ "Use provider-measured matched successful tasks for hosted API savings claims.",
1258
+ ],
1259
+ },
1260
+ "claim_boundary": (
1261
+ "Dry-run self-hosted metrics ledger preview only; local/model-server metrics are diagnostic sidecars "
1262
+ "and are not hosted API token or cost savings evidence."
1263
+ ),
1264
+ }
1265
+
1266
+
1267
+ def command_plan_self_hosted_metrics_ledger(args: argparse.Namespace) -> int:
1268
+ payload = self_hosted_metrics_plan_payload(args)
1269
+ if args.json:
1270
+ emit_json(payload)
1271
+ else:
1272
+ print("ContextGuard self-hosted metrics ledger preview (dry-run only)")
1273
+ print("No ledger file was written and no hosted API token/cost savings claim is allowed from these metrics.")
1274
+ print(f"Status: {payload['status']}")
1275
+ if payload["review_plan"]["readiness_blockers"]:
1276
+ print(f"Readiness blockers: {', '.join(payload['review_plan']['readiness_blockers'])}")
1277
+ print(payload["claim_boundary"])
1278
+ return 0
1279
+
1280
+
1281
+ def sanitize_local_proxy_value(value: Any) -> str:
1282
+ return sanitize_self_hosted_text(value)
1283
+
1284
+
1285
+ def local_proxy_secret_like(value: Any) -> bool:
1286
+ if value is None:
1287
+ return False
1288
+ return "[REDACTED]" in sanitize_local_proxy_value(value)
1289
+
1290
+
1291
+ def is_localhost_host(value: Any) -> bool:
1292
+ if not isinstance(value, str):
1293
+ return False
1294
+ host = value.strip().strip("[]").lower().rstrip(".")
1295
+ if host in LOCAL_PROXY_LOCALHOST_NAMES:
1296
+ return True
1297
+ try:
1298
+ return ipaddress.ip_address(host).is_loopback
1299
+ except ValueError:
1300
+ return False
1301
+
1302
+
1303
+ def normalize_local_proxy_host(value: Any, *, default: str) -> tuple[str, bool, bool]:
1304
+ if value is None or str(value).strip() == "":
1305
+ host = default
1306
+ else:
1307
+ host = str(value).strip().strip("[]")
1308
+ sanitized = sanitize_local_proxy_value(host)
1309
+ return sanitized, is_localhost_host(host), "[REDACTED]" in sanitized
1310
+
1311
+
1312
+ def normalize_local_proxy_port(value: Any, *, default: int) -> tuple[int, bool]:
1313
+ if value is None or value == "":
1314
+ return default, True
1315
+ if isinstance(value, bool):
1316
+ return default, False
1317
+ try:
1318
+ port = int(value)
1319
+ except (TypeError, ValueError):
1320
+ return default, False
1321
+ return port, 0 <= port <= 65535
1322
+
1323
+
1324
+ def read_local_proxy_payload(args: argparse.Namespace) -> tuple[dict[str, Any], dict[str, Any]]:
1325
+ if not args.input:
1326
+ return {}, {
1327
+ "source_label": "cli_flags",
1328
+ "bytes": 0,
1329
+ "sha256": None,
1330
+ "truncated": False,
1331
+ "ignored_keys": [],
1332
+ }
1333
+ path = Path(args.input)
1334
+ safe_path = sanitize_local_proxy_value(path)
1335
+ try:
1336
+ with path.open("rb") as handle:
1337
+ raw = handle.read(MAX_SELF_HOSTED_METRICS_INPUT_BYTES + 1)
1338
+ except OSError as exc:
1339
+ detail = exc.strerror or exc.__class__.__name__
1340
+ if exc.errno is not None:
1341
+ detail = f"{detail} (errno {exc.errno})"
1342
+ raise RegistryError(f"could not read local-proxy input: {safe_path}: {detail}") from exc
1343
+ if len(raw) > MAX_SELF_HOSTED_METRICS_INPUT_BYTES:
1344
+ return {}, {
1345
+ "source_label": safe_path,
1346
+ "bytes": MAX_SELF_HOSTED_METRICS_INPUT_BYTES,
1347
+ "sha256": hashlib.sha256(raw[:MAX_SELF_HOSTED_METRICS_INPUT_BYTES]).hexdigest(),
1348
+ "truncated": True,
1349
+ "ignored_keys": [],
1350
+ }
1351
+ if not raw.strip():
1352
+ return {}, {
1353
+ "source_label": safe_path,
1354
+ "bytes": len(raw),
1355
+ "sha256": hashlib.sha256(raw).hexdigest(),
1356
+ "truncated": False,
1357
+ "ignored_keys": [],
1358
+ }
1359
+ text = raw.decode("utf-8", errors="replace")
1360
+ try:
1361
+ payload = json.loads(text, parse_constant=reject_non_finite_json_constant)
1362
+ except json.JSONDecodeError as exc:
1363
+ raise RegistryError(f"could not parse local-proxy JSON: {exc.msg}") from exc
1364
+ except ValueError as exc:
1365
+ raise RegistryError(f"could not parse local-proxy JSON: {exc}") from exc
1366
+ except RecursionError as exc:
1367
+ raise RegistryError("could not parse local-proxy JSON: nesting too deep") from exc
1368
+ if has_non_finite_json_number(payload):
1369
+ raise RegistryError("could not parse local-proxy JSON: non-finite JSON number")
1370
+ if not isinstance(payload, dict):
1371
+ return {}, {
1372
+ "source_label": safe_path,
1373
+ "bytes": len(raw),
1374
+ "sha256": hashlib.sha256(raw).hexdigest(),
1375
+ "truncated": False,
1376
+ "ignored_keys": ["input_not_object"],
1377
+ }
1378
+ envelope = payload.get("local_proxy", payload)
1379
+ ignored = []
1380
+ if not isinstance(envelope, dict):
1381
+ envelope = {}
1382
+ ignored.append("local_proxy_not_object")
1383
+ allowed = {
1384
+ "bind_host",
1385
+ "bind_port",
1386
+ "target_host",
1387
+ "target_port",
1388
+ "upstream_url",
1389
+ "ledger_jsonl",
1390
+ "proxy_label",
1391
+ "api_key",
1392
+ "authorization_header",
1393
+ "persist_api_key",
1394
+ "external_forwarding_intent",
1395
+ "runtime_gate_ack",
1396
+ }
1397
+ ignored.extend(sanitize_self_hosted_ignored_key(key) for key in envelope if key not in allowed)
1398
+ return dict(envelope), {
1399
+ "source_label": safe_path,
1400
+ "bytes": len(raw),
1401
+ "sha256": hashlib.sha256(raw).hexdigest(),
1402
+ "truncated": False,
1403
+ "ignored_keys": sorted(set(ignored)),
1404
+ }
1405
+
1406
+
1407
+ def coalesce_local_proxy_value(args: argparse.Namespace, payload: dict[str, Any], attr: str, key: str) -> Any:
1408
+ value = getattr(args, attr)
1409
+ return value if value is not None else payload.get(key)
1410
+
1411
+
1412
+ def coalesce_local_proxy_bool(args: argparse.Namespace, payload: dict[str, Any], attr: str, key: str) -> bool:
1413
+ if getattr(args, attr):
1414
+ return True
1415
+ return bool(payload.get(key))
1416
+
1417
+
1418
+ def local_proxy_plan_payload(args: argparse.Namespace) -> dict[str, Any]:
1419
+ input_payload, input_meta = read_local_proxy_payload(args)
1420
+ bind_host_raw = coalesce_local_proxy_value(args, input_payload, "bind_host", "bind_host")
1421
+ bind_port_raw = coalesce_local_proxy_value(args, input_payload, "bind_port", "bind_port")
1422
+ target_host_raw = coalesce_local_proxy_value(args, input_payload, "target_host", "target_host")
1423
+ target_port_raw = coalesce_local_proxy_value(args, input_payload, "target_port", "target_port")
1424
+ upstream_url_raw = coalesce_local_proxy_value(args, input_payload, "upstream_url", "upstream_url")
1425
+ ledger_jsonl_raw = coalesce_local_proxy_value(args, input_payload, "ledger_jsonl", "ledger_jsonl")
1426
+ proxy_label_raw = coalesce_local_proxy_value(args, input_payload, "proxy_label", "proxy_label")
1427
+ api_key_raw = coalesce_local_proxy_value(args, input_payload, "api_key", "api_key")
1428
+ authorization_raw = coalesce_local_proxy_value(args, input_payload, "authorization_header", "authorization_header")
1429
+ persist_api_key = coalesce_local_proxy_bool(args, input_payload, "persist_api_key", "persist_api_key")
1430
+ external_forwarding_intent = coalesce_local_proxy_bool(
1431
+ args,
1432
+ input_payload,
1433
+ "external_forwarding_intent",
1434
+ "external_forwarding_intent",
1435
+ )
1436
+ runtime_gate_ack = coalesce_local_proxy_bool(args, input_payload, "runtime_gate_ack", "runtime_gate_ack")
1437
+
1438
+ upstream_url = sanitize_local_proxy_value(upstream_url_raw) if upstream_url_raw else None
1439
+ upstream_host = None
1440
+ upstream_url_valid = True
1441
+ upstream_localhost = True
1442
+ upstream_secret_like = False
1443
+ if upstream_url_raw:
1444
+ upstream_secret_like = local_proxy_secret_like(upstream_url_raw)
1445
+ try:
1446
+ parsed = urlparse(str(upstream_url_raw))
1447
+ upstream_host = parsed.hostname
1448
+ except ValueError:
1449
+ upstream_url_valid = False
1450
+ upstream_host = None
1451
+ else:
1452
+ if upstream_host:
1453
+ upstream_localhost = is_localhost_host(upstream_host)
1454
+ else:
1455
+ upstream_url_valid = False
1456
+ upstream_localhost = False
1457
+ try:
1458
+ upstream_port = parsed.port
1459
+ except ValueError:
1460
+ upstream_url_valid = False
1461
+ upstream_port = None
1462
+ if upstream_port is not None and target_port_raw is None:
1463
+ target_port_raw = upstream_port
1464
+ if upstream_host and target_host_raw is None:
1465
+ target_host_raw = upstream_host
1466
+
1467
+ bind_host, bind_localhost, bind_secret_like = normalize_local_proxy_host(
1468
+ bind_host_raw,
1469
+ default=LOCAL_PROXY_DEFAULT_BIND_HOST,
1470
+ )
1471
+ target_host, target_localhost, target_secret_like = normalize_local_proxy_host(
1472
+ target_host_raw,
1473
+ default=LOCAL_PROXY_DEFAULT_TARGET_HOST,
1474
+ )
1475
+ bind_port, bind_port_valid = normalize_local_proxy_port(bind_port_raw, default=LOCAL_PROXY_DEFAULT_BIND_PORT)
1476
+ target_port, target_port_valid = normalize_local_proxy_port(target_port_raw, default=LOCAL_PROXY_DEFAULT_TARGET_PORT)
1477
+ ledger_jsonl = sanitize_local_proxy_value(ledger_jsonl_raw) if ledger_jsonl_raw else None
1478
+ proxy_label = sanitize_local_proxy_value(proxy_label_raw) if proxy_label_raw else "local-proxy-dry-run"
1479
+ api_key_provided = api_key_raw is not None and str(api_key_raw).strip() != ""
1480
+ authorization_header_provided = authorization_raw is not None and str(authorization_raw).strip() != ""
1481
+ secret_like_fields: list[str] = []
1482
+ for field, raw in (
1483
+ ("bind_host", bind_host_raw),
1484
+ ("bind_port", bind_port_raw),
1485
+ ("target_host", target_host_raw),
1486
+ ("target_port", target_port_raw),
1487
+ ("upstream_url", upstream_url_raw),
1488
+ ("ledger_jsonl", ledger_jsonl_raw),
1489
+ ("proxy_label", proxy_label_raw),
1490
+ ("api_key", api_key_raw),
1491
+ ("authorization_header", authorization_raw),
1492
+ ):
1493
+ if raw is not None and local_proxy_secret_like(raw):
1494
+ secret_like_fields.append(field)
1495
+ if bind_secret_like and "bind_host" not in secret_like_fields:
1496
+ secret_like_fields.append("bind_host")
1497
+ if target_secret_like and "target_host" not in secret_like_fields:
1498
+ secret_like_fields.append("target_host")
1499
+ if upstream_secret_like and "upstream_url" not in secret_like_fields:
1500
+ secret_like_fields.append("upstream_url")
1501
+
1502
+ blockers: list[str] = []
1503
+ if input_meta["truncated"]:
1504
+ blockers.append("input_truncated")
1505
+ if not bind_port_valid:
1506
+ blockers.append("invalid_bind_port")
1507
+ if not target_port_valid:
1508
+ blockers.append("invalid_target_port")
1509
+ if upstream_url_raw and not upstream_url_valid:
1510
+ blockers.append("invalid_upstream_url")
1511
+ if not bind_localhost:
1512
+ blockers.append("non_localhost_bind_host")
1513
+ if not target_localhost:
1514
+ blockers.append("non_localhost_target_host")
1515
+ if upstream_url_raw and not upstream_localhost:
1516
+ blockers.append("non_localhost_upstream_url")
1517
+ if api_key_provided or authorization_header_provided:
1518
+ blockers.append("api_key_material_provided")
1519
+ if persist_api_key:
1520
+ blockers.append("api_key_persistence_requested")
1521
+ if external_forwarding_intent:
1522
+ blockers.append("external_forwarding_intent_not_allowed")
1523
+ if not runtime_gate_ack:
1524
+ blockers.append("missing_runtime_gate_ack")
1525
+ if secret_like_fields:
1526
+ blockers.append("secret_like_proxy_metadata")
1527
+ blockers = list(dict.fromkeys(blockers))
1528
+ ready = not blockers
1529
+
1530
+ return {
1531
+ "tool": TOOL_NAME,
1532
+ "schema_version": CONFIG_SCHEMA_VERSION,
1533
+ "experiment_id": "local-proxy",
1534
+ "mode": "dry_run",
1535
+ "status": "ready_for_runtime_review" if ready else "blocked_until_local_proxy_constraints",
1536
+ "input": input_meta,
1537
+ "policy": {
1538
+ "default_off": True,
1539
+ "dry_run_only": True,
1540
+ "localhost_only": True,
1541
+ "runtime_gate_required_before_forwarding": True,
1542
+ "runtime_gate_acknowledged": runtime_gate_ack,
1543
+ "stable_runtime_behavior_changed": False,
1544
+ },
1545
+ "bind": {
1546
+ "host": bind_host,
1547
+ "port": bind_port,
1548
+ "localhost_only": bind_localhost,
1549
+ },
1550
+ "target": {
1551
+ "host": target_host,
1552
+ "port": target_port,
1553
+ "upstream_url": upstream_url,
1554
+ "localhost_only": target_localhost,
1555
+ },
1556
+ "network_actions": {
1557
+ "listener_started": False,
1558
+ "outbound_forwarding_attempted": False,
1559
+ "dns_lookup_attempted": False,
1560
+ "external_services_called": False,
1561
+ },
1562
+ "api_key_persistence": {
1563
+ "api_key_material_provided": api_key_provided,
1564
+ "authorization_header_provided": authorization_header_provided,
1565
+ "requested": persist_api_key,
1566
+ "performed": False,
1567
+ "allowed_by_default": False,
1568
+ },
1569
+ "ledger_preview": {
1570
+ "schema_version": LOCAL_PROXY_SCHEMA_VERSION,
1571
+ "ledger_jsonl": ledger_jsonl,
1572
+ "ledger_write_performed": False,
1573
+ "proxy_label": proxy_label,
1574
+ "claim_boundary": "local_proxy_advisory_only_not_hosted_token_or_cost_savings",
1575
+ },
1576
+ "forwarding": {
1577
+ "external_forwarding_intent": external_forwarding_intent,
1578
+ "hidden_external_forwarding": False,
1579
+ "runtime_gate_acknowledged": runtime_gate_ack,
1580
+ "future_runtime_gate_required": True,
1581
+ },
1582
+ "redaction": {
1583
+ "secret_like_fields": sorted(set(secret_like_fields)),
1584
+ "raw_api_key_output": False,
1585
+ },
1586
+ "review_plan": {
1587
+ "readiness_blockers": blockers,
1588
+ "next_steps": [
1589
+ "Keep any real proxy runtime behind a separate future runtime gate.",
1590
+ "Use localhost-only bind and target defaults for advisory review.",
1591
+ "Do not persist API keys or forward externally from this dry-run planner.",
1592
+ ],
1593
+ },
1594
+ "claim_boundary": (
1595
+ "Dry-run local proxy advisory preview only; no listener, forwarding, API-key persistence, ledger write, "
1596
+ "or hosted API token/cost savings claim is performed."
1597
+ ),
1598
+ }
1599
+
1600
+
1601
+ def command_plan_local_proxy(args: argparse.Namespace) -> int:
1602
+ payload = local_proxy_plan_payload(args)
1603
+ if args.json:
1604
+ emit_json(payload)
1605
+ else:
1606
+ print("ContextGuard local proxy plan (dry-run only)")
1607
+ print("No listener was started, no traffic was forwarded, no API key was persisted, and no ledger was written.")
1608
+ print(f"Status: {payload['status']}")
1609
+ print(f"Bind: {payload['bind']['host']}:{payload['bind']['port']} localhost_only={payload['bind']['localhost_only']}")
1610
+ print(
1611
+ f"Target: {payload['target']['host']}:{payload['target']['port']} "
1612
+ f"localhost_only={payload['target']['localhost_only']}"
1613
+ )
1614
+ if payload["review_plan"]["readiness_blockers"]:
1615
+ print(f"Readiness blockers: {', '.join(payload['review_plan']['readiness_blockers'])}")
1616
+ print(payload["claim_boundary"])
1617
+ return 0
1618
+
1619
+
1620
+ LEARNED_CODE_FENCE_RE = re.compile(r"(?m)^\s*(?:```|~~~)")
1621
+ LEARNED_DIFF_RE = re.compile(r"(?m)^\s*(diff --git |@@\s+-|--- |\+\+\+ |[+-].*)")
1622
+ LEARNED_IDENTIFIER_RE = re.compile(
1623
+ r"\b(?:"
1624
+ r"_*[A-Za-z]+_[A-Za-z0-9_]*"
1625
+ r"|_*[a-z]+[A-Z][A-Za-z0-9]*"
1626
+ r"|_*[A-Z][a-z]+[A-Z][A-Za-z0-9]*"
1627
+ r"|_*[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+"
1628
+ r"|_*[A-Z][A-Z0-9_]{2,}"
1629
+ r")\b"
1630
+ )
1631
+ LEARNED_PATH_RE = re.compile(
1632
+ r"(?x)(?:"
1633
+ r"(?<![\w.-])/(?:[A-Za-z0-9._@%+=:-]+/)*[A-Za-z0-9._@%+=:-]+"
1634
+ r"|"
1635
+ r"\b[A-Za-z]:\\(?:[^\\\s:\"'<>|]+\\)*[^\\\s:\"'<>|]+"
1636
+ r"|"
1637
+ r"(?<![\w.-])(?:\.{1,2}/)+[A-Za-z0-9._@%+=:-]+(?:/[A-Za-z0-9._@%+=:-]+)*\b"
1638
+ r"|"
1639
+ r"\b(?:\.{1,2}/)?(?:[A-Za-z0-9._@%+=:-]+/)+[A-Za-z0-9._@%+=:-]+\b"
1640
+ r"|"
1641
+ r"\b[A-Za-z0-9._-]+\.(?:py|js|ts|tsx|jsx|go|rs|java|kt|swift|json|ya?ml|toml|md|txt|log|sh|bash|zsh|sql|html|css)\b"
1642
+ r")"
1643
+ )
1644
+ LEARNED_HASH_RE = re.compile(r"\b(?:sha256:[0-9a-fA-F]{32,64}|[0-9a-fA-F]{7,64})\b")
1645
+ LEARNED_STACK_FRAME_RE = re.compile(
1646
+ r"(?m)^\s*(?:File\s+\"[^\"]+\",\s+line\s+\d+,\s+in\s+\S+|at\s+\S+.*\([^)]*:\d+(?::\d+)?\))"
1647
+ )
1648
+ LEARNED_JSON_KEY_RE = re.compile(r"""(?x)"(?:[^"\\]|\\.)*"\s*:|'(?:[^'\\]|\\.)*'\s*:""")
1649
+ LEARNED_QUOTED_STRING_RE = re.compile(
1650
+ r'''(?x)"""(?:.|\n)*?"""|''' + r"""'''(?:.|\n)*?'''|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'"""
1651
+ )
1652
+ LEARNED_NUMERIC_CONSTANT_RE = re.compile(
1653
+ r"(?<![\w.])(?:[vV]?\d+(?:\.\d+)*|[-+]?0x[0-9A-Fa-f]+)(?![\w.])"
1654
+ )
1655
+ LEARNED_PROMPT_LIKE_RE = re.compile(
1656
+ r"(?imx)(?:"
1657
+ r"\b(?:ignore|disregard|forget)\s+(?:all\s+)?(?:the\s+)?(?:above|earlier|previous|prior)\s+instructions?\b"
1658
+ r"|^\s*(?:system|developer|user|assistant)\s*:"
1659
+ r"|\b(?:system|developer|user|assistant)\s+instructions?\b"
1660
+ r"|\b(?:system|developer)\s+message\b"
1661
+ r"|\byou\s+are\s+(?:now\s+)?(?:chatgpt|a\s+\w+|\w+)\b"
1662
+ r"|\bact\s+as\b"
1663
+ r"|\bjailbreak\b"
1664
+ r"|\bdo\s+not\s+follow\b"
1665
+ r"|\boverride\s+instructions\b"
1666
+ r")"
1667
+ )
1668
+ LEARNED_URL_RE = re.compile(
1669
+ r"(?i)\b(?:https?://|(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,24})(?:/|\b)"
1670
+ )
1671
+ LEARNED_CODE_LIKE_RE = re.compile(
1672
+ r"(?mx)^\s*(?:"
1673
+ r"(?:from\s+\S+\s+import\s+\S+|import\s+\S+|def\s+[A-Za-z_]\w*\s*\(|class\s+[A-Za-z_]\w*\s*(?:\(|:)|"
1674
+ r"function\s+[A-Za-z_$][\w$]*\s*\(|(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=)"
1675
+ r"|(?:if|elif|else|for|while|try|except|finally|with)\b.*:"
1676
+ r"|(?:print|raise|return|yield|assert)\b(?:\s*\(|\s+\S+)"
1677
+ r"|[A-Za-z_][A-Za-z0-9_]*\s*(?:=|==|!=|<=|>=|\+=|-=|\*=|/=)\s*\S+"
1678
+ r"|.*[{};]\s*$"
1679
+ r"|(?:ls|cp|mv|rm|sudo|curl|wget|chmod|chown|git|npm|npx|pnpm|yarn|python3?|pip|node|bash|sh|zsh|cat|grep|sed|awk|make|cargo|pytest|tox|uv|ruff|mypy|pyright|docker|kubectl)(?:\s+(?:-\S+|\S+))*"
1680
+ r"|<[/!]?[A-Za-z][A-Za-z0-9-]*(?:\s+[^<>]*)?>"
1681
+ r")"
1682
+ )
1683
+ LEARNED_INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
1684
+ LEARNED_NON_TEXT_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ufffd]")
1685
+ LEARNED_WORD_RE = re.compile(r"\b[\w.-]+\b")
1686
+ LEARNED_ARTIFACT_ID_RE = re.compile(r"^[a-f0-9]{16,64}$")
1687
+
1688
+
1689
+ def read_learned_input(args: argparse.Namespace) -> tuple[str, dict[str, Any]]:
1690
+ source_label = args.source_label
1691
+ if args.input:
1692
+ path = Path(args.input)
1693
+ source_label = source_label or path.name
1694
+ try:
1695
+ with path.open("rb") as handle:
1696
+ raw = handle.read(MAX_LEARNED_COMPRESSION_INPUT_BYTES + 1)
1697
+ except OSError as exc:
1698
+ raise RegistryError(f"could not read learned-compression input: {path}: {exc}") from exc
1699
+ else:
1700
+ source_label = source_label or "stdin"
1701
+ raw = sys.stdin.buffer.read(MAX_LEARNED_COMPRESSION_INPUT_BYTES + 1)
1702
+ truncated = len(raw) > MAX_LEARNED_COMPRESSION_INPUT_BYTES
1703
+ raw = raw[:MAX_LEARNED_COMPRESSION_INPUT_BYTES]
1704
+ text = raw.decode("utf-8", errors="replace")
1705
+ metadata = {
1706
+ "source_label": source_label,
1707
+ "bytes": len(raw),
1708
+ "lines": len(text.splitlines()),
1709
+ "sha256": hashlib.sha256(raw).hexdigest() if raw else None,
1710
+ "truncated": truncated,
1711
+ "max_bytes": MAX_LEARNED_COMPRESSION_INPUT_BYTES,
1712
+ }
1713
+ return text, metadata
1714
+
1715
+
1716
+ def learned_content_type(text: str, counts: dict[str, int]) -> str:
1717
+ stripped = text.strip()
1718
+ if not stripped:
1719
+ return "empty"
1720
+ if counts["non_text_input"]:
1721
+ return "non_text"
1722
+ if counts["protected_json_key"]:
1723
+ return "json"
1724
+ if counts["protected_diff"]:
1725
+ return "diff"
1726
+ if counts["protected_code_fence"] or counts["protected_code_like"] or counts["protected_identifier"] >= 3:
1727
+ return "code"
1728
+ return "prose"
1729
+
1730
+
1731
+ def learned_signal_counts(text: str) -> dict[str, int]:
1732
+ words = LEARNED_WORD_RE.findall(text)
1733
+ numeric_count = len(LEARNED_NUMERIC_CONSTANT_RE.findall(text))
1734
+ code_like_count = len(LEARNED_CODE_LIKE_RE.findall(text)) + len(LEARNED_INLINE_CODE_RE.findall(text))
1735
+ numeric_density_high = 1 if words and numeric_count >= 3 and numeric_count / len(words) >= 0.20 else 0
1736
+ return {
1737
+ "protected_code_fence": len(LEARNED_CODE_FENCE_RE.findall(text)),
1738
+ "protected_diff": len(LEARNED_DIFF_RE.findall(text)),
1739
+ "protected_identifier": len(LEARNED_IDENTIFIER_RE.findall(text)),
1740
+ "protected_path": len(LEARNED_PATH_RE.findall(text)),
1741
+ "protected_hash": len(LEARNED_HASH_RE.findall(text)),
1742
+ "protected_stack_frame": len(LEARNED_STACK_FRAME_RE.findall(text)),
1743
+ "protected_json_key": len(LEARNED_JSON_KEY_RE.findall(text)),
1744
+ "protected_numeric_constant": numeric_count,
1745
+ "protected_quoted_string": len(LEARNED_QUOTED_STRING_RE.findall(text)),
1746
+ "prompt_like_instruction": len(LEARNED_PROMPT_LIKE_RE.findall(text)),
1747
+ "url_or_endpoint": len(LEARNED_URL_RE.findall(text)),
1748
+ "protected_code_like": code_like_count,
1749
+ "non_text_input": len(LEARNED_NON_TEXT_RE.findall(text)),
1750
+ "numeric_density_high": numeric_density_high,
1751
+ }
1752
+
1753
+
1754
+ def valid_learned_reexpand_command(receipt_id: str | None, command: str | None) -> tuple[bool, str | None]:
1755
+ if not receipt_id or not command:
1756
+ return False, "missing_exact_fallback"
1757
+ if not LEARNED_ARTIFACT_ID_RE.fullmatch(receipt_id):
1758
+ return False, "invalid_reexpand_command"
1759
+ if any(token in command for token in (";", "|", "&", ">", "<", "`", "$", "\n", "\r")):
1760
+ return False, "invalid_reexpand_command"
1761
+ try:
1762
+ argv = shlex.split(command)
1763
+ except ValueError:
1764
+ return False, "invalid_reexpand_command"
1765
+ if len(argv) < 4:
1766
+ return False, "invalid_reexpand_command"
1767
+ if argv == ["context-guard-artifact", "get", receipt_id, "--full"]:
1768
+ return True, None
1769
+ if argv == ["context-guard", "artifact", "get", receipt_id, "--full"]:
1770
+ return True, None
1771
+ return False, "invalid_reexpand_command"
1772
+
1773
+
1774
+ def learned_compression_plan_payload(args: argparse.Namespace) -> dict[str, Any]:
1775
+ text, input_meta = read_learned_input(args)
1776
+ receipt_id = args.exact_fallback_receipt.strip() if args.exact_fallback_receipt else None
1777
+ reexpand_command = args.reexpand_command.strip() if args.reexpand_command else None
1778
+ reexpand_valid, fallback_blocker = valid_learned_reexpand_command(receipt_id, reexpand_command)
1779
+ counts = learned_signal_counts(text)
1780
+ content_type = learned_content_type(text, counts)
1781
+
1782
+ blockers: list[str] = []
1783
+ if not text.strip():
1784
+ blockers.append("missing_input")
1785
+ if input_meta["truncated"]:
1786
+ blockers.append("input_truncated")
1787
+ if not args.sanitized:
1788
+ blockers.append("missing_sanitized_assertion")
1789
+ if not args.trusted_source:
1790
+ blockers.append("untrusted_input")
1791
+ if fallback_blocker:
1792
+ blockers.append(fallback_blocker)
1793
+ if content_type != "prose" and text.strip():
1794
+ blockers.append("non_prose_input")
1795
+ for blocker, count in counts.items():
1796
+ if count:
1797
+ blockers.append(blocker)
1798
+ blockers = list(dict.fromkeys(blockers))
1799
+ ready = not blockers
1800
+ return {
1801
+ "tool": TOOL_NAME,
1802
+ "schema_version": CONFIG_SCHEMA_VERSION,
1803
+ "experiment_id": "learned-compression",
1804
+ "mode": "dry_run",
1805
+ "status": "ready_for_human_review" if ready else "blocked_until_safe_input",
1806
+ "input": input_meta,
1807
+ "policy": {
1808
+ "deny_by_default": True,
1809
+ "runtime_compression_allowed": False,
1810
+ "eligible_for_human_review": ready,
1811
+ "human_review_required": True,
1812
+ "stable_runtime_behavior_changed": False,
1813
+ },
1814
+ "sanitization": {
1815
+ "required": True,
1816
+ "caller_asserted": bool(args.sanitized),
1817
+ "verified": False,
1818
+ },
1819
+ "trust": {
1820
+ "required": True,
1821
+ "caller_asserted": bool(args.trusted_source),
1822
+ "verified": False,
1823
+ },
1824
+ "exact_fallback": {
1825
+ "required": True,
1826
+ "available": bool(receipt_id and reexpand_command and reexpand_valid),
1827
+ "receipt_id": receipt_id,
1828
+ "cli": reexpand_command,
1829
+ "verified": False,
1830
+ },
1831
+ "protected_signal_scan": {
1832
+ "content_type": content_type,
1833
+ "counts": counts,
1834
+ },
1835
+ "review_plan": {
1836
+ "readiness_blockers": blockers,
1837
+ "protected_signals": [name for name, count in counts.items() if count],
1838
+ "next_steps": [
1839
+ "Keep exact fallback receipt and re-expand command available before considering any future summary.",
1840
+ "Reject learned compression for protected, prompt-like, untrusted, or non-prose input.",
1841
+ "Do not claim hosted token/cost savings from this dry-run policy check.",
1842
+ ],
1843
+ },
1844
+ "claim_boundary": (
1845
+ "Dry-run learned-compression policy check only; no hosted token/cost savings claim without "
1846
+ "provider-measured matched successful tasks."
1847
+ ),
1848
+ "candidate_replacement": None,
1849
+ }
1850
+
1851
+
1852
+ def command_plan_learned_compression(args: argparse.Namespace) -> int:
1853
+ payload = learned_compression_plan_payload(args)
1854
+ if args.json:
1855
+ emit_json(payload)
1856
+ else:
1857
+ print("ContextGuard learned/synthetic compression gate (dry-run only)")
1858
+ print("No learned compressor/model/provider was called and no replacement text was emitted.")
1859
+ print(f"Status: {payload['status']}")
1860
+ print(f"Input: {payload['input']['source_label']} lines={payload['input']['lines']} sha256={payload['input']['sha256']}")
1861
+ if payload["review_plan"]["readiness_blockers"]:
1862
+ print(f"Readiness blockers: {', '.join(payload['review_plan']['readiness_blockers'])}")
1863
+ print(payload["claim_boundary"])
1864
+ return 0
1865
+
1866
+
1867
+ def add_common_args(parser: argparse.ArgumentParser) -> None:
1868
+ parser.add_argument("--root", help="Project root for default project-local experiment config (default: cwd).")
1869
+ parser.add_argument("--config", help="Project-local config path. Relative paths resolve under --root; absolute paths must stay inside --root.")
1870
+ parser.add_argument("--json", action="store_true", help="Emit JSON output.")
1871
+
1872
+
1873
+ def load_args_context(args: argparse.Namespace) -> tuple[Path, Path, dict[str, Any]]:
1874
+ root = resolve_root(args.root)
1875
+ config_path = resolve_config_path(root, args.config)
1876
+ return root, config_path, load_config(config_path)
1877
+
1878
+
1879
+ def build_parser() -> argparse.ArgumentParser:
1880
+ parser = argparse.ArgumentParser(
1881
+ prog=TOOL_NAME,
1882
+ description="Inspect and manage default-off ContextGuard experimental feature opt-ins.",
1883
+ )
1884
+ sub = parser.add_subparsers(dest="command", required=True)
1885
+
1886
+ list_parser = sub.add_parser("list", help="List known experiments and metadata.")
1887
+ add_common_args(list_parser)
1888
+ list_parser.set_defaults(func=command_list)
1889
+
1890
+ status_parser = sub.add_parser("status", help="Show project-local experiment enablement status.")
1891
+ add_common_args(status_parser)
1892
+ status_parser.set_defaults(func=command_status)
1893
+
1894
+ enable_parser = sub.add_parser("enable", help="Enable one experiment in project-local config.")
1895
+ enable_parser.add_argument("experiment_id")
1896
+ add_common_args(enable_parser)
1897
+ enable_parser.set_defaults(func=command_enable)
1898
+
1899
+ disable_parser = sub.add_parser("disable", help="Disable one experiment in project-local config.")
1900
+ disable_parser.add_argument("experiment_id")
1901
+ add_common_args(disable_parser)
1902
+ disable_parser.set_defaults(func=command_disable)
1903
+
1904
+ plan_parser = sub.add_parser("plan", help="Run read-only dry-run planners for experimental lanes.")
1905
+ plan_sub = plan_parser.add_subparsers(dest="plan_command", required=True)
1906
+
1907
+ context_diff = plan_sub.add_parser(
1908
+ "context-diff-compaction",
1909
+ help="Dry-run a reviewable context-diff compaction plan without emitting a replacement.",
1910
+ )
1911
+ context_diff.add_argument("--input", help="Read diff text from a file instead of stdin.")
1912
+ context_diff.add_argument("--source-label", help="Safe label to use for the input source in reports.")
1913
+ context_diff.add_argument("--receipt-id", help="User-supplied exact receipt/artifact id for human review readiness.")
1914
+ context_diff.add_argument("--reexpand-command", help="User-supplied exact re-expand command for human review readiness.")
1915
+ context_diff.add_argument("--json", action="store_true", help="Emit JSON output.")
1916
+ context_diff.set_defaults(func=command_plan_context_diff_compaction)
1917
+
1918
+ visual_ocr = plan_sub.add_parser(
1919
+ "visual-crop-ocr",
1920
+ help="Dry-run visual crop/OCR evidence metadata without calling OCR or image services.",
1921
+ )
1922
+ visual_ocr.add_argument("--full-evidence-receipt", help="User-supplied receipt/id for the original full visual evidence.")
1923
+ visual_ocr.add_argument("--full-evidence-label", help="Safe label for the full visual evidence.")
1924
+ visual_ocr.add_argument("--crop-label", help="Safe label for the cropped region or crop fixture.")
1925
+ visual_ocr.add_argument("--crop-bounds", help="Crop bounds as x,y,width,height integers.")
1926
+ visual_ocr.add_argument("--image-size", help="Original image size as width,height integers.")
1927
+ visual_ocr.add_argument("--ocr-text", help="Bounded OCR fixture text supplied inline.")
1928
+ visual_ocr.add_argument("--ocr-text-file", help="Read bounded OCR fixture text from a UTF-8 text file.")
1929
+ visual_ocr.add_argument("--ocr-source-label", help="Safe label for OCR text source; defaults to inline or file basename.")
1930
+ visual_ocr.add_argument("--ocr-confidence", help="OCR confidence as a finite decimal from 0.0 to 1.0.")
1931
+ visual_ocr.add_argument("--ocr-error-note", action="append", help="Known OCR error/uncertainty note. Repeatable.")
1932
+ visual_ocr.add_argument("--missed-context-note", action="append", help="Potential context outside crop/OCR text. Repeatable.")
1933
+ visual_ocr.add_argument("--json", action="store_true", help="Emit JSON output.")
1934
+ visual_ocr.set_defaults(func=command_plan_visual_crop_ocr)
1935
+
1936
+ self_hosted = plan_sub.add_parser(
1937
+ "self-hosted-metrics-ledger",
1938
+ help="Dry-run self-hosted/local metrics ledger sidecar evidence without writing a ledger.",
1939
+ )
1940
+ self_hosted.add_argument("--input", help="Read an explicit self_hosted_metrics JSON envelope from a file instead of stdin.")
1941
+ self_hosted.add_argument("--source-label", help="Safe label to use for the input source in reports.")
1942
+ self_hosted.add_argument("--latency-ms", type=float, default=None, help="Local/model-server latency in milliseconds.")
1943
+ self_hosted.add_argument("--peak-memory-mb", type=float, default=None, help="Peak local/model-server memory in MiB/MB.")
1944
+ self_hosted.add_argument("--quality-score", type=float, default=None, help="Quality score from 0.0 to 1.0.")
1945
+ self_hosted.add_argument("--energy-wh", type=float, default=None, help="Diagnostic local energy use in watt-hours.")
1946
+ self_hosted.add_argument("--local-cost-usd", type=float, default=None, help="Diagnostic local/self-hosted cost in USD.")
1947
+ self_hosted.add_argument("--tokens-per-second", type=float, default=None, help="Diagnostic local throughput.")
1948
+ self_hosted.add_argument("--model-server", help="Sanitized label for local model server/runtime.")
1949
+ self_hosted.add_argument("--optimization", help="Sanitized label for the local optimization under test.")
1950
+ self_hosted.add_argument("--quality-metric", help="Sanitized label for quality metric.")
1951
+ self_hosted.add_argument("--hardware", help="Sanitized local hardware label.")
1952
+ self_hosted.add_argument("--runtime", help="Sanitized local runtime label.")
1953
+ self_hosted.add_argument("--dataset", help="Sanitized dataset label.")
1954
+ self_hosted.add_argument("--json", action="store_true", help="Emit JSON output.")
1955
+ self_hosted.set_defaults(func=command_plan_self_hosted_metrics_ledger)
1956
+
1957
+ local_proxy = plan_sub.add_parser(
1958
+ "local-proxy",
1959
+ help="Dry-run a localhost-only local proxy advisory plan without starting a proxy.",
1960
+ )
1961
+ local_proxy.add_argument("--input", help="Read a local_proxy JSON envelope from a file instead of CLI flags.")
1962
+ local_proxy.add_argument("--bind-host", help="Advisory bind host; must be localhost/loopback.")
1963
+ local_proxy.add_argument("--bind-port", default=None, help="Advisory bind port; 0 means unspecified/ephemeral.")
1964
+ local_proxy.add_argument("--target-host", help="Advisory target host; must be localhost/loopback.")
1965
+ local_proxy.add_argument("--target-port", default=None, help="Advisory target port; 0 means unspecified.")
1966
+ local_proxy.add_argument("--upstream-url", help="Advisory upstream URL; host must be localhost/loopback.")
1967
+ local_proxy.add_argument("--ledger-jsonl", help="Advisory ledger path preview; dry-run only, not written.")
1968
+ local_proxy.add_argument("--proxy-label", help="Safe label for this local proxy plan.")
1969
+ local_proxy.add_argument("--api-key", help="Blocked/redacted API key material; never persisted or emitted raw.")
1970
+ local_proxy.add_argument("--authorization-header", help="Blocked/redacted Authorization header; never persisted or emitted raw.")
1971
+ local_proxy.add_argument("--persist-api-key", action="store_true", help="Declare API-key persistence intent; blocked by default.")
1972
+ local_proxy.add_argument(
1973
+ "--external-forwarding-intent",
1974
+ action="store_true",
1975
+ help="Declare future external forwarding intent; blocked in this dry-run planner.",
1976
+ )
1977
+ local_proxy.add_argument(
1978
+ "--runtime-gate-ack",
1979
+ action="store_true",
1980
+ help="Acknowledge that any future forwarding needs a separate runtime gate.",
1981
+ )
1982
+ local_proxy.add_argument("--json", action="store_true", help="Emit JSON output.")
1983
+ local_proxy.set_defaults(func=command_plan_local_proxy)
1984
+
1985
+ learned = plan_sub.add_parser(
1986
+ "learned-compression",
1987
+ help="Dry-run a deny-by-default learned/synthetic compression safety gate.",
1988
+ )
1989
+ learned.add_argument("--input", help="Read candidate prose from a text file instead of stdin.")
1990
+ learned.add_argument("--source-label", help="Safe label to use for the input source in reports.")
1991
+ learned.add_argument("--sanitized", action="store_true", help="Assert input is already sanitized.")
1992
+ learned.add_argument("--trusted-source", action="store_true", help="Assert input came from a trusted source.")
1993
+ learned.add_argument("--exact-fallback-receipt", help="Local exact fallback receipt id for the original text.")
1994
+ learned.add_argument("--reexpand-command", help="Local exact re-expand command bound to the receipt id.")
1995
+ learned.add_argument("--json", action="store_true", help="Emit JSON output.")
1996
+ learned.set_defaults(func=command_plan_learned_compression)
1997
+
1998
+ return parser
1999
+
2000
+
2001
+ def normalize_negative_csv_option_values(argv: list[str] | None) -> list[str] | None:
2002
+ """Keep negative comma-separated option values portable across Python versions.
2003
+
2004
+ Python 3.11/3.12 argparse treats a value such as ``-1,0,20,10`` after an
2005
+ option as another option token rather than as the option's value. Python
2006
+ 3.14 accepts the same test input, so normalize the small set of CSV-valued
2007
+ options that intentionally accepts negative numbers for validation.
2008
+ """
2009
+ if argv is None:
2010
+ argv = sys.argv[1:]
2011
+ normalized: list[str] = []
2012
+ pending_csv_option: str | None = None
2013
+ csv_options = {"--crop-bounds"}
2014
+ for token in argv:
2015
+ if pending_csv_option is not None:
2016
+ normalized.append(f"{pending_csv_option}={token}")
2017
+ pending_csv_option = None
2018
+ continue
2019
+ if token in csv_options:
2020
+ pending_csv_option = token
2021
+ continue
2022
+ normalized.append(token)
2023
+ if pending_csv_option is not None:
2024
+ normalized.append(pending_csv_option)
2025
+ return normalized
2026
+
2027
+
2028
+ def main(argv: list[str] | None = None) -> int:
2029
+ parser = build_parser()
2030
+ args = parser.parse_args(normalize_negative_csv_option_values(argv))
2031
+ try:
2032
+ return int(args.func(args))
2033
+ except RegistryError as exc:
2034
+ fail(str(exc))
2035
+
2036
+
2037
+ if __name__ == "__main__":
2038
+ raise SystemExit(main())