524 lines
21 KiB
Python
Executable File
524 lines
21 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Explicit-root dry-run batch triage for local documents, images, and audio.
|
|
|
|
This wrapper is intentionally report-only. It requires a lane-scoped approved
|
|
root in a manifest, rejects request roots that broaden that approval, redacts raw
|
|
text/transcripts by default, and never mutates Obsidian, RAG/vector DBs, files,
|
|
routing, memory, services, or sends.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import hashlib
|
|
import ipaddress
|
|
import importlib.util
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
try:
|
|
import yaml # type: ignore
|
|
except Exception as exc: # pragma: no cover
|
|
raise SystemExit("PyYAML is required to read triage root manifests") from exc
|
|
|
|
LANES = (
|
|
"screenshots",
|
|
"receipts",
|
|
"downloads",
|
|
"obsidian_attachments",
|
|
"voice_memos",
|
|
"meeting_snippets",
|
|
)
|
|
AUDIO_LANES = {"voice_memos", "meeting_snippets"}
|
|
DOC_IMAGE_LANES = {"screenshots", "receipts", "downloads", "obsidian_attachments"}
|
|
SKIP_DIR_NAMES = {".git", ".obsidian", "__pycache__", ".cache", "cache", "chroma", "chromadb", "vector_db", "vectors"}
|
|
NPU_BUSY_PATH = Path("/sys/class/accel/accel0/device/npu_busy_time_us")
|
|
DEFAULT_WHISPER_URL = "http://127.0.0.1:18816/v1/audio/transcriptions"
|
|
MUTATIONS_FALSE = {
|
|
"obsidian": False,
|
|
"rag": False,
|
|
"vector_db": False,
|
|
"sends": False,
|
|
"file_moves": False,
|
|
"routing": False,
|
|
"memory": False,
|
|
"service_restarts": False,
|
|
}
|
|
ACTION_PATTERNS = {
|
|
"follow_up": re.compile(r"\b(follow up|follow-up|circle back|reply|respond)\b", re.I),
|
|
"date_or_deadline": re.compile(r"\b(deadline|due|by (?:mon|tue|wed|thu|fri|sat|sun)|20\d{2}[-/]\d{1,2}[-/]\d{1,2})\b", re.I),
|
|
"decision": re.compile(r"\b(decided|decision|approved|rejected|go with|choose)\b", re.I),
|
|
"task": re.compile(r"\b(todo|to-do|action item|assign|need to|please)\b", re.I),
|
|
}
|
|
|
|
|
|
class FailClosed(Exception):
|
|
pass
|
|
|
|
|
|
def sha256_text(text: str) -> str:
|
|
return "sha256:" + hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
h.update(chunk)
|
|
return "sha256:" + h.hexdigest()
|
|
|
|
|
|
def read_busy(path: Path = NPU_BUSY_PATH) -> int | None:
|
|
try:
|
|
return int(path.read_text().strip())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def validate_local_whisper_url(whisper_url: str) -> str:
|
|
"""Fail closed unless Whisper transcription stays on the approved loopback service."""
|
|
try:
|
|
parsed = urllib.parse.urlparse(whisper_url)
|
|
port = parsed.port
|
|
except ValueError as exc:
|
|
raise FailClosed("whisper_url_invalid") from exc
|
|
|
|
if parsed.scheme != "http":
|
|
raise FailClosed("whisper_url_scheme_not_http")
|
|
if parsed.username or parsed.password:
|
|
raise FailClosed("whisper_url_credentials_not_allowed")
|
|
if port != 18816:
|
|
raise FailClosed("whisper_url_port_not_approved")
|
|
|
|
host = (parsed.hostname or "").strip().lower()
|
|
if host == "localhost":
|
|
return whisper_url
|
|
try:
|
|
if ipaddress.ip_address(host).is_loopback:
|
|
return whisper_url
|
|
except ValueError:
|
|
pass
|
|
raise FailClosed("whisper_url_not_loopback")
|
|
|
|
|
|
def is_under(path: Path, root: Path) -> bool:
|
|
try:
|
|
path.resolve().relative_to(root.resolve())
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def load_manifest(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
raise FailClosed(f"manifest_missing:{path}")
|
|
data = yaml.safe_load(path.read_text())
|
|
if not isinstance(data, dict):
|
|
raise FailClosed("manifest_invalid:not_mapping")
|
|
if data.get("version") != 1:
|
|
raise FailClosed("manifest_invalid:version_must_be_1")
|
|
policy = data.get("policy") or {}
|
|
if policy.get("default_mode", "dry_run") != "dry_run":
|
|
raise FailClosed("policy_invalid:default_mode_not_dry_run")
|
|
for key, expected in {
|
|
"require_explicit_root": True,
|
|
"allow_external_uploads": False,
|
|
"allow_mutations": False,
|
|
"log_raw_text": False,
|
|
}.items():
|
|
if policy.get(key) is not expected:
|
|
raise FailClosed(f"policy_invalid:{key}")
|
|
if not isinstance(data.get("roots"), dict):
|
|
raise FailClosed("manifest_invalid:roots_missing")
|
|
return data
|
|
|
|
|
|
def resolve_lane_root(manifest: dict[str, Any], manifest_path: Path, lane: str, requested_root: str | None) -> tuple[dict[str, Any], Path, Path]:
|
|
lane_cfg = (manifest.get("roots") or {}).get(lane)
|
|
if not isinstance(lane_cfg, dict):
|
|
raise FailClosed(f"lane_missing:{lane}")
|
|
if lane_cfg.get("approved") is not True:
|
|
raise FailClosed(f"lane_unapproved:{lane}")
|
|
root_value = lane_cfg.get("root")
|
|
if not root_value:
|
|
raise FailClosed(f"root_missing:{lane}")
|
|
approved_root = Path(str(root_value)).expanduser()
|
|
if not approved_root.is_absolute():
|
|
approved_root = (manifest_path.parent / approved_root).resolve()
|
|
else:
|
|
approved_root = approved_root.resolve()
|
|
if not approved_root.exists() or not approved_root.is_dir():
|
|
raise FailClosed(f"approved_root_unavailable:{lane}")
|
|
|
|
selected_root = Path(requested_root).expanduser() if requested_root else approved_root
|
|
selected_root = selected_root.resolve()
|
|
if not selected_root.exists() or not selected_root.is_dir():
|
|
raise FailClosed(f"request_root_unavailable:{lane}")
|
|
if not is_under(selected_root, approved_root):
|
|
raise FailClosed(f"request_root_broadens_approval:{lane}")
|
|
return lane_cfg, approved_root, selected_root
|
|
|
|
|
|
def allowed_exts(lane_cfg: dict[str, Any]) -> set[str]:
|
|
return {str(e).lower() if str(e).startswith(".") else "." + str(e).lower() for e in lane_cfg.get("allowed_extensions", [])}
|
|
|
|
|
|
def iter_files(root: Path, approved_root: Path, exts: set[str], max_file_mb: float, max_age_days: float | None) -> tuple[list[Path], dict[str, int], int]:
|
|
skipped = {"extension": 0, "size": 0, "symlink_escape": 0, "not_regular_file": 0, "too_old": 0, "policy": 0}
|
|
accepted: list[Path] = []
|
|
files_seen = 0
|
|
now = time.time()
|
|
max_bytes = int(max_file_mb * 1024 * 1024)
|
|
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES and not d.startswith(".")]
|
|
current = Path(dirpath)
|
|
if not is_under(current, approved_root):
|
|
skipped["symlink_escape"] += 1
|
|
dirnames[:] = []
|
|
continue
|
|
for name in filenames:
|
|
path = current / name
|
|
if name.startswith("."):
|
|
skipped["policy"] += 1
|
|
continue
|
|
files_seen += 1
|
|
try:
|
|
resolved = path.resolve()
|
|
except Exception:
|
|
skipped["symlink_escape"] += 1
|
|
continue
|
|
if not is_under(resolved, approved_root):
|
|
skipped["symlink_escape"] += 1
|
|
continue
|
|
if not resolved.is_file():
|
|
skipped["not_regular_file"] += 1
|
|
continue
|
|
if resolved.suffix.lower() not in exts:
|
|
skipped["extension"] += 1
|
|
continue
|
|
try:
|
|
st = resolved.stat()
|
|
except OSError:
|
|
skipped["not_regular_file"] += 1
|
|
continue
|
|
if st.st_size > max_bytes:
|
|
skipped["size"] += 1
|
|
continue
|
|
if max_age_days is not None and now - st.st_mtime > max_age_days * 86400:
|
|
skipped["too_old"] += 1
|
|
continue
|
|
accepted.append(resolved)
|
|
accepted.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
return accepted, skipped, files_seen
|
|
|
|
|
|
def load_doc_triage_module(repo_root: Path):
|
|
module_path = repo_root / "openvino-doc-image-triage-npu" / "triage.py"
|
|
spec = importlib.util.spec_from_file_location("doc_image_triage", module_path)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError("doc_image_triage_import_failed")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules.setdefault("doc_image_triage", module)
|
|
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
return module
|
|
|
|
|
|
def fallback_doc_item(path: Path, root: Path, lane: str) -> dict[str, Any]:
|
|
sidecar = path.with_suffix(path.suffix + ".txt")
|
|
text = ""
|
|
if sidecar.exists() and sidecar.is_file():
|
|
text = sidecar.read_text(errors="replace")[:12000]
|
|
lower = text.lower()
|
|
category = "unknown_or_low_confidence"
|
|
if any(w in lower for w in ("receipt", "subtotal", "store")):
|
|
category = "receipt"
|
|
elif any(w in lower for w in ("invoice", "amount due", "payment due")):
|
|
category = "bill_or_invoice"
|
|
elif lane == "screenshots":
|
|
category = "screenshot_web_or_app"
|
|
reasons = [name for name, rx in ACTION_PATTERNS.items() if rx.search(text)]
|
|
return {
|
|
"basename": path.name,
|
|
"relative_path_hash": sha256_text(path.relative_to(root).as_posix()),
|
|
"file_id": sha256_file(path),
|
|
"media_type": infer_media_type(path),
|
|
"category": category,
|
|
"needs_attention": bool(reasons),
|
|
"reasons": sorted(reasons),
|
|
"raw_text_redacted": True,
|
|
"full_path_included": False,
|
|
"metadata": {"dates_count": len(set(re.findall(r"\b20\d{2}[-/]\d{1,2}[-/]\d{1,2}\b", text))), "amounts_count": len(set(re.findall(r"\$\s?\d+(?:\.\d{2})?", text))), "raw_values_redacted": True},
|
|
"processing": {"doc_image_triage": "fallback_cpu_sidecar_rules", "npu_verified": False},
|
|
}
|
|
|
|
|
|
def infer_media_type(path: Path) -> str:
|
|
if path.suffix.lower() == ".pdf":
|
|
return "pdf"
|
|
mt, _ = mimetypes.guess_type(path.name)
|
|
if mt and mt.startswith("image/"):
|
|
return "image"
|
|
if mt and mt.startswith("audio/"):
|
|
return "audio"
|
|
return "unknown"
|
|
|
|
|
|
def compact_doc_item(path: Path, root: Path, lane: str, triage_result: dict[str, Any]) -> dict[str, Any]:
|
|
pages = triage_result.get("pages") or []
|
|
first = pages[0] if pages else {}
|
|
cls = first.get("classification") or {}
|
|
attn = first.get("needs_attention") or {}
|
|
meta = first.get("metadata") or {}
|
|
device_summary = triage_result.get("processing_device_summary") or {}
|
|
item = {
|
|
"basename": path.name,
|
|
"relative_path_hash": sha256_text(path.relative_to(root).as_posix()),
|
|
"file_id": triage_result.get("file_id") or sha256_file(path),
|
|
"media_type": triage_result.get("media_type") or infer_media_type(path),
|
|
"category": cls.get("label") or "unknown_or_low_confidence",
|
|
"needs_attention": bool(attn.get("value")),
|
|
"reasons": attn.get("reasons") or [],
|
|
"raw_text_redacted": True,
|
|
"full_path_included": False,
|
|
"metadata": {
|
|
"dates_count": meta.get("dates_count", 0),
|
|
"amounts_count": meta.get("amounts_count", 0),
|
|
"raw_values_redacted": True,
|
|
},
|
|
"processing": {
|
|
"doc_image_triage": "openvino-doc-image-triage-npu",
|
|
"image_category_device": (cls.get("device") or "CPU"),
|
|
"needs_attention_device": attn.get("device") or "CPU",
|
|
"npu_verified": bool(device_summary.get("npu_verified")),
|
|
"npu_busy_delta_us": device_summary.get("npu_busy_delta_us"),
|
|
},
|
|
}
|
|
if lane == "receipts":
|
|
item["receipt_fields"] = {"vendor_present": bool((meta.get("detected_entities") or {}).get("org_present")), "amounts_count": item["metadata"]["amounts_count"], "dates_count": item["metadata"]["dates_count"]}
|
|
return item
|
|
|
|
|
|
def classify_transcript(text: str, lane: str) -> dict[str, Any]:
|
|
reasons = [name for name, rx in ACTION_PATTERNS.items() if rx.search(text)]
|
|
action_count = sum(1 for rx in (ACTION_PATTERNS["follow_up"], ACTION_PATTERNS["task"]) if rx.search(text))
|
|
decisions = 1 if ACTION_PATTERNS["decision"].search(text) else 0
|
|
followups = 1 if ACTION_PATTERNS["follow_up"].search(text) else 0
|
|
return {
|
|
"category": "meeting_snippet" if lane == "meeting_snippets" else "voice_memo",
|
|
"action_worthy": bool(reasons),
|
|
"reasons": sorted(reasons),
|
|
"action_items_count": action_count,
|
|
"decisions_count": decisions,
|
|
"followups_count": followups,
|
|
}
|
|
|
|
|
|
def multipart_transcribe(path: Path, whisper_url: str, timeout: float) -> dict[str, Any]:
|
|
whisper_url = validate_local_whisper_url(whisper_url)
|
|
boundary = "----NpuBatchTriage" + hashlib.sha256(path.name.encode()).hexdigest()[:12]
|
|
data = path.read_bytes()
|
|
body = (
|
|
f"--{boundary}\r\n"
|
|
f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'
|
|
"Content-Type: application/octet-stream\r\n\r\n"
|
|
).encode() + data + (
|
|
f"\r\n--{boundary}\r\n"
|
|
'Content-Disposition: form-data; name="model"\r\n\r\n'
|
|
"whisper-1\r\n"
|
|
f"--{boundary}--\r\n"
|
|
).encode()
|
|
before = read_busy()
|
|
req = urllib.request.Request(whisper_url, data=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
|
|
t0 = time.perf_counter()
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
raw = resp.read(1024 * 1024)
|
|
status = resp.status
|
|
parsed = json.loads(raw.decode())
|
|
after = read_busy()
|
|
text = str(parsed.get("text") or parsed.get("transcription") or "").strip()
|
|
service_delta = parsed.get("npu_busy_delta_us")
|
|
sysfs_delta = None if before is None or after is None else after - before
|
|
proof_delta = service_delta if isinstance(service_delta, int) else sysfs_delta
|
|
return {
|
|
"ok": status == 200 and bool(text),
|
|
"text": text,
|
|
"transcript_chars": len(text),
|
|
"duration_seconds": parsed.get("duration_seconds"),
|
|
"language": parsed.get("language"),
|
|
"npu_busy_delta_us": proof_delta,
|
|
"verified_npu": bool(proof_delta and proof_delta > 0),
|
|
"wall_ms": round((time.perf_counter() - t0) * 1000, 2),
|
|
}
|
|
|
|
|
|
def compact_audio_item(path: Path, root: Path, lane: str, no_npu: bool, whisper_url: str, timeout: float) -> dict[str, Any]:
|
|
transcript = ""
|
|
transcribed = False
|
|
npu_delta = 0
|
|
proof_ok = False
|
|
duration = None
|
|
language = None
|
|
error = None
|
|
if not no_npu:
|
|
try:
|
|
result = multipart_transcribe(path, whisper_url, timeout)
|
|
transcript = result["text"]
|
|
transcribed = result["ok"]
|
|
npu_delta = result.get("npu_busy_delta_us") or 0
|
|
proof_ok = bool(result.get("verified_npu"))
|
|
duration = result.get("duration_seconds")
|
|
language = result.get("language")
|
|
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
|
error = f"whisper_error:{type(exc).__name__}"
|
|
summary = classify_transcript(transcript, lane)
|
|
item = {
|
|
"basename": path.name,
|
|
"relative_path_hash": sha256_text(path.relative_to(root).as_posix()),
|
|
"file_id": sha256_file(path),
|
|
"media_type": "audio",
|
|
"duration_seconds": duration,
|
|
"transcribed": transcribed,
|
|
"transcript_chars": len(transcript),
|
|
"language": language,
|
|
**summary,
|
|
"npu_busy_delta_us": npu_delta,
|
|
"raw_transcript_logged": False,
|
|
"full_path_included": False,
|
|
}
|
|
if error:
|
|
item["error"] = error
|
|
return item
|
|
|
|
|
|
def process(args: argparse.Namespace) -> dict[str, Any]:
|
|
repo_root = Path(__file__).resolve().parents[1]
|
|
manifest_path = Path(args.manifest).expanduser().resolve()
|
|
manifest = load_manifest(manifest_path)
|
|
lane_cfg, approved_root, root = resolve_lane_root(manifest, manifest_path, args.lane, args.root)
|
|
exts = allowed_exts(lane_cfg)
|
|
if not exts:
|
|
raise FailClosed(f"extensions_missing:{args.lane}")
|
|
manifest_limit = int(lane_cfg.get("max_files", 50))
|
|
limit = min(args.limit if args.limit is not None else manifest_limit, manifest_limit)
|
|
files, skipped, files_seen = iter_files(root, approved_root, exts, float(lane_cfg.get("max_file_mb", 25)), args.max_age_days)
|
|
selected = files[:limit]
|
|
npu_before = read_busy()
|
|
|
|
items: list[dict[str, Any]] = []
|
|
errors: list[str] = []
|
|
doc_module = None
|
|
if args.lane in AUDIO_LANES and not args.no_npu:
|
|
validate_local_whisper_url(args.whisper_url)
|
|
if args.lane in DOC_IMAGE_LANES and not args.no_npu:
|
|
try:
|
|
doc_module = load_doc_triage_module(repo_root)
|
|
except Exception as exc:
|
|
errors.append(f"doc_triage_import_error:{type(exc).__name__}")
|
|
|
|
for path in selected:
|
|
try:
|
|
if args.lane in AUDIO_LANES:
|
|
item = compact_audio_item(path, root, args.lane, args.no_npu, args.whisper_url, args.timeout_seconds)
|
|
elif doc_module is not None:
|
|
opts = doc_module.TriageOptions(
|
|
dry_run=False,
|
|
include_ocr_text=False,
|
|
include_full_path=False,
|
|
use_embeddings=not args.no_npu,
|
|
allowed_roots=[approved_root],
|
|
timeout_seconds=args.timeout_seconds,
|
|
)
|
|
item = compact_doc_item(path, root, args.lane, doc_module.triage_file(path, opts))
|
|
else:
|
|
item = fallback_doc_item(path, root, args.lane)
|
|
if args.include_full_path:
|
|
item["full_path"] = str(path)
|
|
item["full_path_included"] = True
|
|
if args.include_raw_text:
|
|
item["raw_text_included"] = False
|
|
item["raw_text_note"] = "unsupported_by_batch_wrapper"
|
|
items.append(item)
|
|
except FailClosed:
|
|
raise
|
|
except Exception as exc:
|
|
errors.append(f"{path.name}:{type(exc).__name__}")
|
|
items.append({"basename": path.name, "ok": False, "error": type(exc).__name__, "raw_text_redacted": True, "full_path_included": False})
|
|
|
|
npu_after = read_busy()
|
|
sysfs_delta = None if npu_before is None or npu_after is None else npu_after - npu_before
|
|
item_deltas = [i.get("npu_busy_delta_us") for i in items if isinstance(i.get("npu_busy_delta_us"), int)]
|
|
claimed = not args.no_npu and any((d or 0) > 0 for d in item_deltas + ([sysfs_delta] if isinstance(sysfs_delta, int) else []))
|
|
proof_ok = claimed and bool(sysfs_delta is None or sysfs_delta > 0 or any((d or 0) > 0 for d in item_deltas))
|
|
return {
|
|
"ok": not errors,
|
|
"lane": args.lane,
|
|
"dry_run": True,
|
|
"approved_root": True,
|
|
"root_basename": root.name,
|
|
"files_seen": files_seen,
|
|
"files_processed": len(items),
|
|
"skipped": skipped,
|
|
"npu": {"claimed": claimed, "busy_delta_us": sysfs_delta, "proof_ok": proof_ok},
|
|
"mutations": MUTATIONS_FALSE.copy(),
|
|
"items": items,
|
|
"raw_content_redacted": not args.include_raw_text,
|
|
"full_paths_included": bool(args.include_full_path),
|
|
"errors": errors,
|
|
"gates": {
|
|
"external_uploads": False,
|
|
"private_root_broadening": False,
|
|
"obsidian_mutation": False,
|
|
"vector_db_mutation": False,
|
|
"outbound_sends": False,
|
|
"routing_changes": False,
|
|
},
|
|
}
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Explicit-root dry-run batch triage wrapper")
|
|
parser.add_argument("--manifest", required=True, help="lane approval manifest; missing/unapproved fails closed")
|
|
parser.add_argument("--lane", required=True, choices=LANES)
|
|
parser.add_argument("--root", help="optional narrower root under the manifest-approved lane root")
|
|
parser.add_argument("--dry-run", action="store_true", help="required; mutation modes are not implemented")
|
|
parser.add_argument("--limit", type=int, default=None)
|
|
parser.add_argument("--max-age-days", type=float, default=None)
|
|
parser.add_argument("--include-raw-text", action="store_true", help="kept redacted by this wrapper; present only for explicit operator attempts")
|
|
parser.add_argument("--include-full-path", action="store_true", help="operator-only local debugging")
|
|
parser.add_argument("--no-npu", action="store_true", help="CPU-only smoke; never claims NPU")
|
|
parser.add_argument("--json", action="store_true", help="emit compact JSON")
|
|
parser.add_argument("--pretty", action="store_true", help="pretty JSON for local debugging")
|
|
parser.add_argument("--whisper-url", default=DEFAULT_WHISPER_URL)
|
|
parser.add_argument("--timeout-seconds", type=float, default=20.0)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
if not args.dry_run:
|
|
print(json.dumps({"ok": False, "error": "dry_run_required", "mutations": MUTATIONS_FALSE}), file=sys.stderr)
|
|
return 2
|
|
if args.limit is not None and args.limit < 1:
|
|
print(json.dumps({"ok": False, "error": "limit_must_be_positive"}), file=sys.stderr)
|
|
return 2
|
|
try:
|
|
out = process(args)
|
|
except FailClosed as exc:
|
|
out = {"ok": False, "error": "fail_closed", "reason": str(exc), "dry_run": True, "mutations": MUTATIONS_FALSE.copy()}
|
|
print(json.dumps(out, indent=2 if args.pretty else None, sort_keys=True))
|
|
return 0 if out.get("ok") else 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|