G7 외부 증거 3-artifact 동시 시간창 오케스트레이터

soak / runtime / topology 세 캡처는 같은 public host 의 겹치는 시간창이어야 하는데,
지금까지는 운영자가 세 명령을 따로 띄우고 시계를 손으로 맞춰야 했다. 50분짜리
실행에서 한 번 어긋나면 처음부터 다시 해야 하는 취약점이라 한 명령으로 묶었다.
세 캡처를 동시에 시작하고 전부 끝나면 human pack 을 더해 checker 까지 잇는다.

게이트를 약화시키지 않았다. CLI 로 실증한 fail-closed 경계 4종:
- 동의/장치 없이 운영 실행     physical_microphone_consent_required  exit 2
- human pack 없이 운영 실행    human_voice_gain_pack_required        exit 2
- 50분 미만 시간창             production_window_too_short           exit 2
- 세 캡처 host 불일치          hosts_must_match:[...]                exit 2

--rehearse 는 마이크를 열지 않고(soak 을 --preflight-only 로) 배관만 확인하며
보고서의 gate_closed 는 항상 false 다. 실제 실행 전 실패를 먼저 뽑기 위한 모드다.

기대 provider 기본값은 2026-08-08 결정에 맞춰 local_whisper / melotts 다.

이것으로 G7 에서 기계로 할 수 있는 부분은 끝났다. 남은 것은 코드로 만들 수 없는
둘뿐이다. 명시 동의 하 물리 마이크 50분 발화, 그리고 참가자 30명·독립 평가자 2인의
blind human voice-gain pack.

검증: 오케스트레이터 20/20, SSOT FAIL 0, dashboard E2E 10/10, ruff clean.
This commit is contained in:
Yun Chan 2026-08-08 09:40:08 +09:00
parent af993961c4
commit f3491be63f
5 changed files with 604 additions and 4 deletions

View file

@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""G7 외부 종료 증거 3종을 하나의 겹치는 시간창에서 동시에 잡는 오케스트레이터.
`check-g7-external-proof.py` artifact 요구하고, 개는 **같은 public host
겹치는 시간창**이어야 한다. 지금까지는 운영자가 명령을 따로 띄우고 시계를 손으로 맞춰야 했다.
50분짜리 실행에서 그건 어긋나면 처음부터 다시 해야 하는 종류의 취약점이다.
스크립트가 하는 :
1. soak / runtime / topology 캡처를 **동시에** 시작하고 전부 끝날 때까지 기다린다.
2. 캡처가 같은 public host 보는지 시작 전에 확인한다(fail-closed).
3. 끝나면 human voice-gain pack 더해 `check-g7-external-proof.py` 그대로 돌린다.
스크립트는 게이트를 **약화시키지 않는다**. 물리 마이크 캡처는 `--confirm-physical-capture`
`--microphone-device` 있어야 시작하고, human pack 만들어 주지 않는다. 번째 artifact
실제 참가자와 독립 평가자가 있어야 한다.
`--rehearse` 마이크·사람 없이 배관만 확인하는 모드다. 짧은 시간창으로 러너를 실제로 띄워
인자·경로·산출 파일까지 검증하되, soak `--preflight-only` 돌려 장치를 열지 않는다. 실제 50
실행 전에 이걸로 먼저 실패를 뽑아내라.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Iterable, Sequence
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = REPO_ROOT / "scripts"
SOAK_SCRIPT = SCRIPTS / "soak-public-voice-websocket.py"
RUNTIME_SCRIPT = SCRIPTS / "capture-g7-runtime-evidence.py"
TOPOLOGY_SCRIPT = SCRIPTS / "capture-g7-topology-evidence.py"
CHECKER_SCRIPT = SCRIPTS / "check-g7-external-proof.py"
MIN_PRODUCTION_SECONDS = 3_000.0
RUNTIME_SAMPLE_MARGIN = 1
class WindowError(RuntimeError):
"""Fail-closed 오케스트레이션 오류."""
@dataclass(frozen=True)
class Leg:
name: str
argv: list[str]
output: Path
@dataclass(frozen=True)
class LegResult:
name: str
returncode: int
output: Path
@property
def ok(self) -> bool:
return self.returncode == 0
def host_of(url: str) -> str:
from urllib.parse import urlsplit
return (urlsplit(url).hostname or "").lower()
def assert_single_host(*urls: str) -> str:
"""세 캡처가 같은 public host 를 보는지 확인한다."""
hosts = {host_of(url) for url in urls if url}
hosts.discard("")
if len(hosts) != 1:
raise WindowError(f"hosts_must_match:{sorted(hosts)}")
return hosts.pop()
def sample_plan(duration_seconds: float, interval_seconds: float) -> int:
"""시간창을 덮기에 충분한 샘플 수. 모자라면 겹침 검증이 깨진다."""
if duration_seconds <= 0 or interval_seconds <= 0:
raise WindowError("invalid_sampling_plan")
return int(duration_seconds // interval_seconds) + RUNTIME_SAMPLE_MARGIN
def build_soak_leg(args: argparse.Namespace, output: Path) -> Leg:
argv = [
sys.executable,
"-X",
"utf8",
str(SOAK_SCRIPT),
"--wss-url",
args.wss_url,
"--origin",
args.origin,
"--expected-stt-provider",
args.expected_stt_provider,
"--expected-stt-model",
args.expected_stt_model,
"--expected-tts-provider",
args.expected_tts_provider,
"--expected-tts-model",
args.expected_tts_model,
"--evidence-output",
str(output),
]
if args.rehearse:
# 배관 확인 전용. 장치를 열지 않는다.
argv.append("--preflight-only")
return Leg("voice_soak_preflight", argv, output)
if not args.microphone_device or not args.confirm_physical_capture:
raise WindowError("physical_microphone_consent_required")
argv += [
"--microphone-device",
args.microphone_device,
"--confirm-physical-capture",
"--duration-seconds",
str(args.duration_seconds),
]
return Leg("voice_soak", argv, output)
def build_runtime_leg(args: argparse.Namespace, output: Path) -> Leg:
samples = sample_plan(args.duration_seconds, args.runtime_interval_seconds)
argv = [
sys.executable,
"-X",
"utf8",
str(RUNTIME_SCRIPT),
"--url",
args.admin_runtime_url,
"--samples",
str(samples),
"--interval-seconds",
str(args.runtime_interval_seconds),
"--evidence-output",
str(output),
]
return Leg("runtime", argv, output)
def build_topology_leg(args: argparse.Namespace, output: Path) -> Leg:
samples = sample_plan(args.duration_seconds, args.topology_interval_seconds)
argv = [
sys.executable,
"-X",
"utf8",
str(TOPOLOGY_SCRIPT),
"--compose-project",
args.compose_project,
"--public-host",
host_of(args.wss_url),
"--api-container",
args.api_container,
"--api-image-digest",
args.api_image_digest,
"--caddy-container",
args.caddy_container,
"--caddy-image-digest",
args.caddy_image_digest,
"--samples",
str(samples),
"--interval-seconds",
str(args.topology_interval_seconds),
"--evidence-output",
str(output),
]
return Leg("topology", argv, output)
def plan_legs(args: argparse.Namespace, out_dir: Path) -> list[Leg]:
assert_single_host(args.wss_url, args.origin, args.admin_runtime_url)
return [
build_soak_leg(args, out_dir / "voice-soak.json"),
build_runtime_leg(args, out_dir / "runtime.json"),
build_topology_leg(args, out_dir / "topology.json"),
]
def _run(leg: Leg, *, timeout: float, runner: Callable[..., Any]) -> LegResult:
completed = runner(
leg.argv,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
shell=False,
)
return LegResult(leg.name, int(completed.returncode), leg.output)
def run_window(
legs: Sequence[Leg],
*,
timeout: float,
runner: Callable[..., Any] | None = None,
) -> list[LegResult]:
"""세 캡처를 동시에 시작한다. 하나가 죽어도 나머지를 끝까지 돌린다."""
call = runner or subprocess.run
with ThreadPoolExecutor(max_workers=len(legs)) as pool:
futures = [
pool.submit(_run, leg, timeout=timeout, runner=call) for leg in legs
]
return [future.result() for future in futures]
def build_checker_argv(
results: Sequence[LegResult], human_pack: Path
) -> list[str]:
by_name = {result.name: result.output for result in results}
soak = by_name.get("voice_soak") or by_name.get("voice_soak_preflight")
if soak is None:
raise WindowError("soak_evidence_missing")
return [
sys.executable,
"-X",
"utf8",
str(CHECKER_SCRIPT),
"--voice-soak",
str(soak),
"--runtime",
str(by_name["runtime"]),
"--topology",
str(by_name["topology"]),
"--human-voice-gain",
str(human_pack),
"--json",
]
def summarize(
results: Sequence[LegResult],
*,
rehearse: bool,
checker_returncode: int | None,
) -> dict[str, Any]:
return {
"schema_version": "vignette.g7-external-proof-window.v1",
"mode": "rehearse" if rehearse else "production",
"legs": [
{"name": r.name, "returncode": r.returncode, "evidence": str(r.output)}
for r in results
],
"all_legs_passed": all(r.ok for r in results),
"checker_returncode": checker_returncode,
"gate_closed": checker_returncode == 0 and not rehearse,
"note": (
"rehearse 는 배관만 확인한다. 게이트는 실제 물리 마이크 50분 실행과 "
"독립 human voice-gain pack 이 있어야 닫힌다."
if rehearse
else "네 artifact 가 모두 통과해야 게이트가 닫힌다."
),
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--wss-url", required=True)
parser.add_argument("--origin", required=True)
parser.add_argument("--admin-runtime-url", required=True)
parser.add_argument("--compose-project", required=True)
parser.add_argument("--api-container", required=True)
parser.add_argument("--api-image-digest", required=True)
parser.add_argument("--caddy-container", required=True)
parser.add_argument("--caddy-image-digest", required=True)
parser.add_argument("--expected-stt-provider", default="local_whisper")
parser.add_argument("--expected-stt-model", default="large-v3")
parser.add_argument("--expected-tts-provider", default="melotts")
parser.add_argument("--expected-tts-model", default="melotts-korean")
parser.add_argument("--microphone-device", default="")
parser.add_argument("--confirm-physical-capture", action="store_true")
parser.add_argument("--duration-seconds", type=float, default=MIN_PRODUCTION_SECONDS)
parser.add_argument("--runtime-interval-seconds", type=float, default=100.0)
parser.add_argument("--topology-interval-seconds", type=float, default=100.0)
parser.add_argument("--human-voice-gain", type=Path)
parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument(
"--rehearse",
action="store_true",
help="마이크 없이 배관만 확인한다. 게이트를 닫지 않는다.",
)
return parser
def validate(args: argparse.Namespace) -> None:
if not args.rehearse and args.duration_seconds < MIN_PRODUCTION_SECONDS:
raise WindowError("production_window_too_short")
if not args.rehearse and args.human_voice_gain is None:
raise WindowError("human_voice_gain_pack_required")
def main(argv: Iterable[str] | None = None) -> int:
args = build_parser().parse_args(list(argv) if argv is not None else None)
try:
validate(args)
args.out_dir.mkdir(parents=True, exist_ok=True)
legs = plan_legs(args, args.out_dir)
except WindowError as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 2
results = run_window(legs, timeout=args.duration_seconds + 900)
checker_returncode: int | None = None
if all(result.ok for result in results) and args.human_voice_gain is not None:
checker = subprocess.run(
build_checker_argv(results, args.human_voice_gain),
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
shell=False,
)
checker_returncode = checker.returncode
report = summarize(
results, rehearse=args.rehearse, checker_returncode=checker_returncode
)
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if report["all_legs_passed"] else 1
if __name__ == "__main__": # pragma: no cover - CLI
raise SystemExit(main())