|
2 | 2 |
|
3 | 3 | import argparse |
4 | 4 | import datetime as dt |
| 5 | +import hashlib |
5 | 6 | import json |
6 | 7 | import os |
| 8 | +import re |
| 9 | +import subprocess |
7 | 10 | from collections import defaultdict |
8 | 11 | from collections.abc import Mapping |
9 | 12 | from dataclasses import dataclass |
|
96 | 99 | AI_POLICY_ALLOWED_KEYS = frozenset({"execution_allowed", "portfolio_allocation_allowed", "downstream_use"}) |
97 | 100 | AI_POLICY_FORBIDDEN_TERMS = frozenset({"live", "allocation", "broker", "execution", "order", "position", "account"}) |
98 | 101 | AI_POLICY_BLOCKING_TERMS = frozenset({"blocked", "not allowed", "do not", "never", "no "}) |
| 102 | +AI_SIGNAL_MANIFEST_NAME = "latest_signal.manifest.json" |
| 103 | +AI_SIGNAL_MANIFEST_PATH = "data/output/latest_signal.manifest.json" |
| 104 | +AI_SIGNAL_PATH = "data/output/latest_signal.json" |
| 105 | +AI_SIGNAL_PRODUCER_REPOSITORY = "QuantStrategyLab/ResearchSignalContextPipelines" |
| 106 | +AI_SIGNAL_PROVENANCE_WARNING = "ai_signal_provenance_untrusted" |
| 107 | +SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") |
| 108 | +GIT_SHA_PATTERN = re.compile(r"[0-9a-f]{40}") |
99 | 109 | _UNAVAILABLE_INPUT = object() |
100 | 110 |
|
101 | 111 | HORIZON_WINDOWS = { |
@@ -501,6 +511,114 @@ def load_ai_signal(path: str | Path | None, *, source_bytes: bytes | None = None |
501 | 511 | return payload |
502 | 512 |
|
503 | 513 |
|
| 514 | +def _ai_provenance_untrusted() -> None: |
| 515 | + raise AISignalValidationError(AI_SIGNAL_PROVENANCE_WARNING) |
| 516 | + |
| 517 | + |
| 518 | +def _git_output(repo: Path, *args: str) -> bytes: |
| 519 | + result = subprocess.run( |
| 520 | + ["git", "-C", str(repo), *args], |
| 521 | + check=False, |
| 522 | + stdout=subprocess.PIPE, |
| 523 | + stderr=subprocess.DEVNULL, |
| 524 | + ) |
| 525 | + if result.returncode != 0: |
| 526 | + _ai_provenance_untrusted() |
| 527 | + return result.stdout |
| 528 | + |
| 529 | + |
| 530 | +def _repository_from_remote(remote: str) -> str: |
| 531 | + value = remote.strip().removesuffix("/").removesuffix(".git") |
| 532 | + for prefix in ("https://github.com/", "git@github.com:", "ssh://git@github.com/"): |
| 533 | + if value.startswith(prefix): |
| 534 | + return value.removeprefix(prefix) |
| 535 | + return "" |
| 536 | + |
| 537 | + |
| 538 | +def load_trusted_ai_signal( |
| 539 | + path: str | Path | None, |
| 540 | + *, |
| 541 | + source_bytes: bytes | None = None, |
| 542 | +) -> dict[str, Any] | None: |
| 543 | + if path is None: |
| 544 | + return None |
| 545 | + signal_path = Path(path).resolve() |
| 546 | + signal_bytes = source_bytes |
| 547 | + if signal_bytes is None: |
| 548 | + try: |
| 549 | + signal_bytes = signal_path.read_bytes() |
| 550 | + except OSError: |
| 551 | + raise AISignalValidationError("ai_signal_unavailable") from None |
| 552 | + payload = load_ai_signal(signal_path, source_bytes=signal_bytes) |
| 553 | + if payload is None: |
| 554 | + return None |
| 555 | + manifest_path = signal_path.with_name(AI_SIGNAL_MANIFEST_NAME) |
| 556 | + try: |
| 557 | + manifest_bytes = manifest_path.read_bytes() |
| 558 | + manifest = json.loads(manifest_bytes.decode("utf-8")) |
| 559 | + except (OSError, UnicodeError, json.JSONDecodeError): |
| 560 | + _ai_provenance_untrusted() |
| 561 | + if not isinstance(manifest, Mapping): |
| 562 | + _ai_provenance_untrusted() |
| 563 | + required_keys = { |
| 564 | + "manifest_type", |
| 565 | + "schema_version", |
| 566 | + "artifact", |
| 567 | + "as_of", |
| 568 | + "generated_at", |
| 569 | + "expires_at", |
| 570 | + "mode", |
| 571 | + "producer", |
| 572 | + "input_digest", |
| 573 | + "policy", |
| 574 | + } |
| 575 | + artifact = manifest.get("artifact") |
| 576 | + producer = manifest.get("producer") |
| 577 | + policy = manifest.get("policy") |
| 578 | + if ( |
| 579 | + set(manifest) != required_keys |
| 580 | + or manifest.get("manifest_type") != "research_signal_context" |
| 581 | + or manifest.get("schema_version") != 2 |
| 582 | + or not isinstance(artifact, Mapping) |
| 583 | + or set(artifact) != {"path", "sha256"} |
| 584 | + or artifact.get("path") != AI_SIGNAL_PATH |
| 585 | + or not isinstance(artifact.get("sha256"), str) |
| 586 | + or SHA256_PATTERN.fullmatch(artifact["sha256"]) is None |
| 587 | + or artifact["sha256"] != hashlib.sha256(signal_bytes).hexdigest() |
| 588 | + or not isinstance(producer, Mapping) |
| 589 | + or set(producer) != {"repository", "commit_sha"} |
| 590 | + or producer.get("repository") != AI_SIGNAL_PRODUCER_REPOSITORY |
| 591 | + or not isinstance(producer.get("commit_sha"), str) |
| 592 | + or GIT_SHA_PATTERN.fullmatch(producer["commit_sha"]) is None |
| 593 | + or not isinstance(manifest.get("input_digest"), str) |
| 594 | + or re.fullmatch(r"sha256:[0-9a-f]{64}", manifest["input_digest"]) is None |
| 595 | + or not isinstance(policy, Mapping) |
| 596 | + or set(policy) != {"execution_allowed"} |
| 597 | + or policy.get("execution_allowed") is not False |
| 598 | + or any(manifest.get(key) != payload.get(key) for key in ("as_of", "generated_at", "expires_at", "mode")) |
| 599 | + ): |
| 600 | + _ai_provenance_untrusted() |
| 601 | + |
| 602 | + try: |
| 603 | + repo = Path(_git_output(signal_path.parent, "rev-parse", "--show-toplevel").decode("utf-8").strip()).resolve() |
| 604 | + signal_relative = signal_path.relative_to(repo).as_posix() |
| 605 | + manifest_relative = manifest_path.resolve().relative_to(repo).as_posix() |
| 606 | + head = _git_output(repo, "rev-parse", "HEAD").decode("ascii").strip() |
| 607 | + remote = _git_output(repo, "remote", "get-url", "origin").decode("utf-8").strip() |
| 608 | + except (UnicodeError, ValueError): |
| 609 | + _ai_provenance_untrusted() |
| 610 | + if ( |
| 611 | + signal_relative != AI_SIGNAL_PATH |
| 612 | + or manifest_relative != AI_SIGNAL_MANIFEST_PATH |
| 613 | + or GIT_SHA_PATTERN.fullmatch(head) is None |
| 614 | + or _repository_from_remote(remote) != AI_SIGNAL_PRODUCER_REPOSITORY |
| 615 | + or _git_output(repo, "show", f"{head}:{signal_relative}") != signal_bytes |
| 616 | + or _git_output(repo, "show", f"{head}:{manifest_relative}") != manifest_bytes |
| 617 | + ): |
| 618 | + _ai_provenance_untrusted() |
| 619 | + return payload |
| 620 | + |
| 621 | + |
504 | 622 |
|
505 | 623 | def load_theme_momentum(path: str | Path | None, *, source_bytes: bytes | None = None) -> dict[str, Any] | None: |
506 | 624 | if path is None: |
@@ -1721,7 +1839,7 @@ def build_advisory_report( |
1721 | 1839 | ai_quality_warnings.append("ai_signal_unavailable") |
1722 | 1840 | else: |
1723 | 1841 | try: |
1724 | | - candidate_ai_signal = load_ai_signal( |
| 1842 | + candidate_ai_signal = load_trusted_ai_signal( |
1725 | 1843 | ai_signal_path, |
1726 | 1844 | source_bytes=ai_bytes if isinstance(ai_bytes, bytes) else None, |
1727 | 1845 | ) |
|
0 commit comments