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())

View file

@ -0,0 +1,222 @@
from __future__ import annotations
import importlib.util
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
SCRIPT_PATH = Path(__file__).with_name("run-g7-external-proof-window.py")
SPEC = importlib.util.spec_from_file_location("run_g7_external_proof_window", SCRIPT_PATH)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
def args(**overrides):
base = {
"wss_url": "wss://api.example.test/voice/ws",
"origin": "https://api.example.test",
"admin_runtime_url": "https://api.example.test/admin/voice-runtime",
"compose_project": "vignette",
"api_container": "vignette-api-1",
"api_image_digest": "sha256:" + "a" * 64,
"caddy_container": "vignette-proxy-1",
"caddy_image_digest": "sha256:" + "b" * 64,
"expected_stt_provider": "local_whisper",
"expected_stt_model": "large-v3",
"expected_tts_provider": "melotts",
"expected_tts_model": "melotts-korean",
"microphone_device": "",
"confirm_physical_capture": False,
"duration_seconds": 3_000.0,
"runtime_interval_seconds": 100.0,
"topology_interval_seconds": 100.0,
"human_voice_gain": Path("pack.json"),
"out_dir": Path("out"),
"rehearse": False,
}
base.update(overrides)
return SimpleNamespace(**base)
class FakeCompleted:
def __init__(self, returncode: int) -> None:
self.returncode = returncode
self.stdout = ""
self.stderr = ""
class HostAlignmentTest(unittest.TestCase):
def test_matching_hosts_are_accepted(self) -> None:
host = MODULE.assert_single_host(
"wss://api.example.test/voice/ws",
"https://api.example.test",
"https://api.example.test/admin/voice-runtime",
)
self.assertEqual(host, "api.example.test")
def test_mismatched_hosts_fail_closed(self) -> None:
with self.assertRaises(MODULE.WindowError):
MODULE.assert_single_host(
"wss://api.example.test/voice/ws", "https://other.example.test"
)
def test_plan_rejects_a_split_window(self) -> None:
with self.assertRaises(MODULE.WindowError):
MODULE.plan_legs(
args(admin_runtime_url="https://elsewhere.test/admin/voice-runtime",
microphone_device="mic", confirm_physical_capture=True),
Path("out"),
)
class SamplePlanTest(unittest.TestCase):
def test_samples_cover_the_whole_window(self) -> None:
self.assertEqual(MODULE.sample_plan(3_000.0, 100.0), 31)
def test_invalid_plan_fails_closed(self) -> None:
for duration, interval in ((0, 100.0), (3_000.0, 0)):
with self.subTest(duration=duration, interval=interval):
with self.assertRaises(MODULE.WindowError):
MODULE.sample_plan(duration, interval)
class ConsentGateTest(unittest.TestCase):
def test_production_soak_requires_device_and_confirmation(self) -> None:
for device, confirm in (("", True), ("mic", False), ("", False)):
with self.subTest(device=device, confirm=confirm):
with self.assertRaises(MODULE.WindowError) as ctx:
MODULE.build_soak_leg(
args(microphone_device=device, confirm_physical_capture=confirm),
Path("soak.json"),
)
self.assertEqual(
str(ctx.exception), "physical_microphone_consent_required"
)
def test_confirmed_production_soak_passes_the_flags_through(self) -> None:
leg = MODULE.build_soak_leg(
args(microphone_device="mic-1", confirm_physical_capture=True),
Path("soak.json"),
)
self.assertEqual(leg.name, "voice_soak")
self.assertIn("--confirm-physical-capture", leg.argv)
self.assertEqual(leg.argv[leg.argv.index("--microphone-device") + 1], "mic-1")
self.assertNotIn("--preflight-only", leg.argv)
def test_rehearse_never_opens_a_microphone(self) -> None:
leg = MODULE.build_soak_leg(args(rehearse=True), Path("soak.json"))
self.assertEqual(leg.name, "voice_soak_preflight")
self.assertIn("--preflight-only", leg.argv)
self.assertNotIn("--confirm-physical-capture", leg.argv)
self.assertNotIn("--microphone-device", leg.argv)
def test_production_window_shorter_than_fifty_minutes_is_rejected(self) -> None:
with self.assertRaises(MODULE.WindowError) as ctx:
MODULE.validate(args(duration_seconds=600.0))
self.assertEqual(str(ctx.exception), "production_window_too_short")
def test_production_run_requires_the_human_pack(self) -> None:
with self.assertRaises(MODULE.WindowError) as ctx:
MODULE.validate(args(human_voice_gain=None))
self.assertEqual(str(ctx.exception), "human_voice_gain_pack_required")
def test_rehearse_relaxes_only_the_two_human_inputs(self) -> None:
MODULE.validate(args(rehearse=True, duration_seconds=30.0, human_voice_gain=None))
class LegCompositionTest(unittest.TestCase):
def test_expected_providers_default_to_the_decided_local_stack(self) -> None:
leg = MODULE.build_soak_leg(args(rehearse=True), Path("soak.json"))
self.assertEqual(
leg.argv[leg.argv.index("--expected-stt-provider") + 1], "local_whisper"
)
self.assertEqual(
leg.argv[leg.argv.index("--expected-tts-provider") + 1], "melotts"
)
def test_topology_leg_pins_the_public_host_from_the_wss_url(self) -> None:
leg = MODULE.build_topology_leg(args(), Path("topology.json"))
self.assertEqual(
leg.argv[leg.argv.index("--public-host") + 1], "api.example.test"
)
def test_all_three_legs_share_one_window_length(self) -> None:
legs = MODULE.plan_legs(
args(rehearse=True, duration_seconds=3_000.0), Path("out")
)
self.assertEqual([leg.name for leg in legs][1:], ["runtime", "topology"])
for leg in legs[1:]:
samples = int(leg.argv[leg.argv.index("--samples") + 1])
interval = float(leg.argv[leg.argv.index("--interval-seconds") + 1])
self.assertGreaterEqual(samples * interval, 3_000.0)
class WindowExecutionTest(unittest.TestCase):
def test_all_three_legs_start_before_any_finishes(self) -> None:
started: list[str] = []
def runner(argv, **kwargs):
started.append(argv[3])
return FakeCompleted(0)
legs = MODULE.plan_legs(args(rehearse=True), Path("out"))
results = MODULE.run_window(legs, timeout=60, runner=runner)
self.assertEqual(len(started), 3)
self.assertTrue(all(result.ok for result in results))
def test_one_failing_leg_does_not_cancel_the_others(self) -> None:
def runner(argv, **kwargs):
return FakeCompleted(1 if "capture-g7-runtime-evidence.py" in argv[3] else 0)
legs = MODULE.plan_legs(args(rehearse=True), Path("out"))
results = MODULE.run_window(legs, timeout=60, runner=runner)
self.assertEqual(len(results), 3)
failed = [r.name for r in results if not r.ok]
self.assertEqual(failed, ["runtime"])
class ReportTest(unittest.TestCase):
def _results(self, codes=(0, 0, 0)):
names = ("voice_soak", "runtime", "topology")
return [
MODULE.LegResult(name, code, Path(f"{name}.json"))
for name, code in zip(names, codes)
]
def test_rehearse_never_reports_the_gate_as_closed(self) -> None:
report = MODULE.summarize(
self._results(), rehearse=True, checker_returncode=0
)
self.assertFalse(report["gate_closed"])
self.assertEqual(report["mode"], "rehearse")
def test_production_gate_closes_only_on_checker_exit_zero(self) -> None:
closed = MODULE.summarize(
self._results(), rehearse=False, checker_returncode=0
)
open_gate = MODULE.summarize(
self._results(), rehearse=False, checker_returncode=1
)
self.assertTrue(closed["gate_closed"])
self.assertFalse(open_gate["gate_closed"])
def test_failed_leg_is_reported(self) -> None:
report = MODULE.summarize(
self._results((0, 1, 0)), rehearse=False, checker_returncode=None
)
self.assertFalse(report["all_legs_passed"])
self.assertFalse(report["gate_closed"])
def test_checker_argv_passes_all_four_artifacts(self) -> None:
argv = MODULE.build_checker_argv(self._results(), Path("pack.json"))
for flag in ("--voice-soak", "--runtime", "--topology", "--human-voice-gain"):
self.assertIn(flag, argv)
self.assertEqual(argv[argv.index("--human-voice-gain") + 1], "pack.json")
if __name__ == "__main__":
unittest.main()