diff --git a/.github/workflows/global_etf_research_codegen.yml b/.github/workflows/global_etf_research_codegen.yml index e941b59..92b9bc5 100644 --- a/.github/workflows/global_etf_research_codegen.yml +++ b/.github/workflows/global_etf_research_codegen.yml @@ -74,6 +74,11 @@ jobs: "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@de13e486da1bdba60f425e576e944591fc97b809" \ ./ues-source ./ + - name: Verify audit-service authentication before research entry + if: inputs.execute == true + run: | + "$CASE_ROOT/venv/bin/python" -m scripts.run_global_etf_research_codegen --auth-preflight + - name: Run the fixed plan or execute path run: | set -euo pipefail @@ -90,7 +95,7 @@ jobs: umask 077 mkdir -p "$CASE_ROOT/artifact" chmod 700 "$CASE_ROOT/artifact" - python3 - "$HOME/.local/state/aiauditbridge/global-etf-review-20260917/result.json" "$CASE_ROOT/artifact/result.json" <<'PY' + python3 - "$HOME/.local/state/aiauditbridge/global-etf-review-20260917-auth-recovery-35124525442/result.json" "$CASE_ROOT/artifact/result.json" <<'PY' import json, sys from pathlib import Path source, target = map(Path, sys.argv[1:]) diff --git a/.github/workflows/vps_codex_service_ops.yml b/.github/workflows/vps_codex_service_ops.yml index cfe149b..27daa7c 100644 --- a/.github/workflows/vps_codex_service_ops.yml +++ b/.github/workflows/vps_codex_service_ops.yml @@ -63,7 +63,7 @@ jobs: CODEX_AUDIT_SSH_UNBAN_IP: ${{ inputs.ssh_unban_ip }} CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES: QuantStrategyLab/AIAuditBridge,QuantStrategyLab/BinancePlatform,QuantStrategyLab/CharlesSchwabPlatform,QuantStrategyLab/CnEquitySnapshotPipelines,QuantStrategyLab/CnEquityStrategies,QuantStrategyLab/CryptoLivePoolPipelines,QuantStrategyLab/CryptoStrategies,QuantStrategyLab/FirstradePlatform,QuantStrategyLab/HkEquitySnapshotPipelines,QuantStrategyLab/HkEquityStrategies,QuantStrategyLab/IBKRGatewayManager,QuantStrategyLab/InteractiveBrokersPlatform,QuantStrategyLab/LongBridgePlatform,QuantStrategyLab/MarketSignalSources,QuantStrategyLab/PoliticalEventTrackingResearch,QuantStrategyLab/QmtPlatform,QuantStrategyLab/QuantAdvisorResearch,QuantStrategyLab/QuantPlatformKit,QuantStrategyLab/QuantRuntimeSettings,QuantStrategyLab/QuantStrategyPlugins,QuantStrategyLab/ResearchSignalContextPipelines,QuantStrategyLab/SchwabTokenAutoRefresher,QuantStrategyLab/UsEquitySnapshotPipelines,QuantStrategyLab/UsEquityStrategies # workflow_dispatch emits protected-main workflow_ref claims; the deploy script pins delegated QPK code by exact job_workflow_ref SHA. - CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS: QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/research_input_readback.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/strategy_optimization_watcher.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/portfolio_research_proposal_diagnosis.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/dependency_audit.yml@refs/heads/main,QuantStrategyLab/CnEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/HkEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/UsEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/CryptoStrategies/.github/workflows/drift-check.yml@refs/heads/main + CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS: QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/research_input_readback.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/strategy_optimization_watcher.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/portfolio_research_proposal_diagnosis.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/dependency_audit.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/global_etf_research_codegen.yml@refs/heads/main,QuantStrategyLab/CnEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/HkEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/UsEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/CryptoStrategies/.github/workflows/drift-check.yml@refs/heads/main CODEX_AUDIT_SERVICE_ALLOWED_REFS: refs/heads/main # Exact canonical audit job plus immutable QPK `uses:` refs pinned by strategy drift callers. # Rotation tracked in #64; remove the old QPK SHA after final strategy-run verification. diff --git a/README.md b/README.md index f074be9..73a737a 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,10 @@ actual synthetic test status; its structured assessments remain advisory. `review_completed` proves a returned review and successful synthetic checks, not correct financial claims, profitability, or out-of-sample validation. -The new fixed state directory is `~/.local/state/aiauditbridge/global-etf-review-20260917`; -the old `global-etf-codegen-20260916` directory is not changed or replayed. +The old `global-etf-review-20260917` claim, result, and response are retained as the +authorization failure record; this explicitly authorized recovery uses +`~/.local/state/aiauditbridge/global-etf-review-20260917-auth-recovery-35124525442`. +The older `global-etf-codegen-20260916` directory is not changed or replayed. A claim without a terminal result remains unknown and is never retried automatically. Only the advisory result is projected to a seven-day GitHub artifact; source body and raw response stay private on VPS. No deployment, diff --git a/scripts/deploy_codex_audit_service.sh b/scripts/deploy_codex_audit_service.sh index bbba433..431acd3 100644 --- a/scripts/deploy_codex_audit_service.sh +++ b/scripts/deploy_codex_audit_service.sh @@ -11,7 +11,7 @@ ALLOWED_REPOSITORIES="${CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES:-QuantStrategyL # Direct review and strategy workflow identities are pinned to protected main because GitHub emits workflow_ref with the dispatch branch. # Delegated reusable code is constrained separately by the exact job_workflow_ref SHA below. # The ref allowlist retains PR merge refs because GitHub can preserve the incoming PR ref for reusable calls; _verify_github_oidc requires both allowlists. -ALLOWED_WORKFLOW_REFS="${CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS:-QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/research_input_readback.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/strategy_optimization_watcher.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/portfolio_research_proposal_diagnosis.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/dependency_audit.yml@refs/heads/main,QuantStrategyLab/CnEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/HkEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/UsEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/CryptoStrategies/.github/workflows/drift-check.yml@refs/heads/main}" +ALLOWED_WORKFLOW_REFS="${CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS:-QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/research_input_readback.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/strategy_optimization_watcher.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/portfolio_research_proposal_diagnosis.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/dependency_audit.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/global_etf_research_codegen.yml@refs/heads/main,QuantStrategyLab/CnEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/HkEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/UsEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/CryptoStrategies/.github/workflows/drift-check.yml@refs/heads/main}" ALLOWED_REFS="${CODEX_AUDIT_SERVICE_ALLOWED_REFS:-refs/heads/main}" ALLOWED_REPOSITORY_VISIBILITIES="${CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORY_VISIBILITIES:-public}" # Exact canonical audit job identity. Single source of truth for delegated drift code follows. diff --git a/scripts/run_global_etf_research_codegen.py b/scripts/run_global_etf_research_codegen.py index 9cce312..951a18c 100644 --- a/scripts/run_global_etf_research_codegen.py +++ b/scripts/run_global_etf_research_codegen.py @@ -43,7 +43,7 @@ "docs/research/global_etf_absolute_volatility.md", }) GLOBAL_ETF_TARGET_PATH = "src/us_equity_strategies/research/global_etf_absolute_volatility.py" -GLOBAL_ETF_STATE_ROOT = Path.home() / ".local/state/aiauditbridge/global-etf-review-20260917" +GLOBAL_ETF_STATE_ROOT = Path.home() / ".local/state/aiauditbridge/global-etf-review-20260917-auth-recovery-35124525442" GLOBAL_ETF_WORKFLOW_NAME = "Global ETF Candidate Review" GLOBAL_ETF_SOURCE_REPOSITORY = "QuantStrategyLab/AIAuditBridge" _REVISION = re.compile(r"^[0-9a-f]{40}$") @@ -365,6 +365,17 @@ def execute(prompt: str): return execute +def _auth_preflight() -> bool: + """Check the audit-service route before any bounded research work begins.""" + try: + from ai_gateway_client import AiGatewayClient, GatewayConfig + + AiGatewayClient(GatewayConfig.from_env()).get_health() + except Exception: + return False + return True + + def _run_global_candidate_tests(candidate_root: Path, *, baseline_root: Path) -> dict[str, str]: """Run the fixed Global profile through the shared Docker test helper.""" try: @@ -468,8 +479,13 @@ def plan() -> dict[str, Any]: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--execute", action="store_true", help="run only in the fixed main self-hosted workflow") + parser.add_argument("--auth-preflight", action="store_true", help="verify audit-service authentication only") parser.add_argument("--ues-repo-root", type=Path, default=Path("/opt/ues-source")) args = parser.parse_args(argv) + if args.auth_preflight: + passed = _auth_preflight() + print("auth_preflight_passed" if passed else "auth_preflight_failed") + return 0 if passed else 1 if not args.execute: print(json.dumps(plan(), ensure_ascii=False, sort_keys=True, separators=(",", ":"))) return 0 diff --git a/tests/test_global_etf_research_codegen.py b/tests/test_global_etf_research_codegen.py index 57ab6e2..1ce35ef 100644 --- a/tests/test_global_etf_research_codegen.py +++ b/tests/test_global_etf_research_codegen.py @@ -65,6 +65,40 @@ def test_global_codegen_docker_integration_fixture(tmp_path): class GlobalResearchCodegenTests(TestCase): + def test_auth_preflight_is_an_execute_only_gate_before_research_entry(self): + workflow = (Path(__file__).parents[1] / ".github/workflows/global_etf_research_codegen.yml").read_text() + preflight = " - name: Verify audit-service authentication before research entry\n" + entry = " - name: Run the fixed plan or execute path\n" + + self.assertIn(preflight, workflow) + self.assertIn(" if: inputs.execute == true\n", workflow.split(preflight, 1)[1].split(entry, 1)[0]) + self.assertLess(workflow.index(preflight), workflow.index(entry)) + + def test_auth_preflight_hides_exception_and_stops_before_research(self): + client = SimpleNamespace(get_health=lambda: (_ for _ in ()).throw(RuntimeError("sensitive detail"))) + output = io.StringIO() + with patch.dict(sys.modules, {"ai_gateway_client": SimpleNamespace( + AiGatewayClient=lambda config: client, GatewayConfig=SimpleNamespace(from_env=lambda: object()), + )}), patch.object(codegen, "run_global_etf_research_codegen_case") as run_research, redirect_stdout(output): + status = codegen.main(["--auth-preflight"]) + + self.assertEqual(status, 1) + self.assertEqual(output.getvalue().strip(), "auth_preflight_failed") + run_research.assert_not_called() + + def test_auth_preflight_only_reports_passed_after_health_check(self): + calls = [] + client = SimpleNamespace(get_health=lambda: calls.append("health")) + output = io.StringIO() + with patch.dict(sys.modules, {"ai_gateway_client": SimpleNamespace( + AiGatewayClient=lambda config: client, GatewayConfig=SimpleNamespace(from_env=lambda: object()), + )}), redirect_stdout(output): + status = codegen.main(["--auth-preflight"]) + + self.assertEqual(status, 0) + self.assertEqual(calls, ["health"]) + self.assertEqual(output.getvalue().strip(), "auth_preflight_passed") + def test_workflow_plan_branch_runs_without_execute_venv(self): workflow = (Path(__file__).parents[1] / ".github/workflows/global_etf_research_codegen.yml").read_text() step = workflow.split(" - name: Run the fixed plan or execute path\n", 1)[1] diff --git a/tests/test_oidc_reusable_workflow_auth.py b/tests/test_oidc_reusable_workflow_auth.py index 6845415..5ce869f 100644 --- a/tests/test_oidc_reusable_workflow_auth.py +++ b/tests/test_oidc_reusable_workflow_auth.py @@ -4,6 +4,7 @@ import time import unittest +from pathlib import Path from unittest.mock import patch from service import auth @@ -114,6 +115,50 @@ def test_direct_audit_bridge_caller_does_not_require_reusable_workflow(self) -> with self.assertRaisesRegex(PermissionError, "workflow_ref must be a string"): self._verify(payload, env) + def test_global_etf_codegen_workflow_is_direct_audit_bridge_caller(self) -> None: + workflow_ref = ( + "QuantStrategyLab/AIAuditBridge/.github/workflows/" + "global_etf_research_codegen.yml@refs/heads/main" + ) + payload: dict[str, object] = { + "aud": "quant-codex-audit", + "iss": auth.GITHUB_OIDC_ISSUER, + "exp": int(time.time()) + 300, + "repository": "QuantStrategyLab/AIAuditBridge", + "workflow_ref": workflow_ref, + "ref": "refs/heads/main", + } + env = { + "CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES": "QuantStrategyLab/AIAuditBridge", + "CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS": workflow_ref, + "CODEX_AUDIT_SERVICE_ALLOWED_REFS": "refs/heads/main", + "CODEX_AUDIT_SERVICE_ALLOWED_DIRECT_REPOSITORIES": "QuantStrategyLab/AIAuditBridge", + "CODEX_AUDIT_SERVICE_ALLOWED_JOB_WORKFLOW_REFS": "", + } + + self.assertEqual(self._verify(payload, env)["workflow_ref"], workflow_ref) + for rejected_workflow_ref, rejected_ref in ( + (workflow_ref.replace("refs/heads/main", "refs/heads/feature"), "refs/heads/feature"), + (workflow_ref.replace("global_etf_research_codegen.yml", "other.yml"), "refs/heads/main"), + ): + with self.subTest(workflow_ref=rejected_workflow_ref): + payload["workflow_ref"] = rejected_workflow_ref + payload["ref"] = rejected_ref + with self.assertRaises(PermissionError): + self._verify(payload, env) + + def test_global_etf_codegen_workflow_is_in_both_deployment_allowlists(self) -> None: + workflow_ref = ( + "QuantStrategyLab/AIAuditBridge/.github/workflows/" + "global_etf_research_codegen.yml@refs/heads/main" + ) + root = Path(__file__).parents[1] + deploy_script = (root / "scripts/deploy_codex_audit_service.sh").read_text() + ops_workflow = (root / ".github/workflows/vps_codex_service_ops.yml").read_text() + + self.assertIn(workflow_ref, deploy_script) + self.assertIn(workflow_ref, ops_workflow) + def test_strategy_drift_requires_trusted_qpk_reusable_workflow(self) -> None: qpk_job_ref = ( "QuantStrategyLab/QuantPlatformKit/.github/workflows/"