Skip to content

Commit bf1a807

Browse files
committed
feat: add audit investigation cli
1 parent 5e89356 commit bf1a807

3 files changed

Lines changed: 110 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ python -m pytest -q
110110
python scripts/train_model.py --output-dir artifacts --model-version v1
111111
python scripts/evaluate_model.py artifacts/model-v1.json
112112
python scripts/release.py --model-version v1
113+
python scripts/investigate_release.py artifacts/audit.jsonl
113114
```
114115

115116
Training metadata includes the dataset, random seed, test split, quality metrics, model version, artifact path, SHA-256 digest, signature, public key, source revision, and builder identity. Local runs use `source_revision=local`; GitHub Actions records `GITHUB_SHA`.
@@ -194,7 +195,7 @@ flowchart TD
194195

195196
## Roadmap
196197

197-
Phase 2 has started with a read-only investigator contract integrated into audit records and covered by end-to-end release scenarios. Its output is structured, evidence-cited, schema-validated, and advisory, with missing and contradictory evidence flagged explicitly. An optional local Ollama transport is timeout-bound and receives structured evidence only. The intelligence layer must match the deterministic decision, has no unrestricted shell or Kubernetes access, and is not part of the final release authority.
198+
Phase 2 has started with a read-only investigator contract integrated into audit records and covered by end-to-end release scenarios. `scripts/investigate_release.py` can regenerate an advisory investigation report from an existing audit log. Its output is structured, evidence-cited, schema-validated, and advisory, with missing and contradictory evidence flagged explicitly. An optional local Ollama transport is timeout-bound and receives structured evidence only. The intelligence layer must match the deterministic decision, has no unrestricted shell or Kubernetes access, and is not part of the final release authority.
198199

199200
## Development rule
200201

scripts/investigate_release.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Generate an advisory investigation report from a saved release audit."""
2+
3+
import argparse
4+
import json
5+
import sys
6+
from pathlib import Path
7+
from typing import Any
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
if str(ROOT) not in sys.path:
11+
sys.path.insert(0, str(ROOT))
12+
13+
from intelligence.investigator import ReleaseInvestigator
14+
from policy.engine import Decision, PolicyResult, ReleaseEvidence
15+
from quality.evaluator import QualityMetrics
16+
17+
18+
def main() -> int:
19+
parser = argparse.ArgumentParser(description=__doc__)
20+
parser.add_argument("audit", type=Path)
21+
arguments = parser.parse_args()
22+
record = _load_latest_record(arguments.audit)
23+
evidence = _release_evidence(record["evidence"])
24+
result = PolicyResult(
25+
decision=Decision(record["decision"]),
26+
reasons=tuple(record.get("reasons", ())),
27+
policy_version=record["policy_version"],
28+
)
29+
report = ReleaseInvestigator().investigate(evidence, result)
30+
print(json.dumps(report.model_dump(mode="json"), indent=2, sort_keys=True))
31+
return 0
32+
33+
34+
def _load_latest_record(path: Path) -> dict[str, Any]:
35+
try:
36+
lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
37+
if not lines:
38+
raise ValueError("audit log is empty")
39+
payload = json.loads(lines[-1])
40+
except (OSError, json.JSONDecodeError) as error:
41+
raise SystemExit(f"unable to read audit log: {path}") from error
42+
if not isinstance(payload, dict) or "evidence" not in payload:
43+
raise SystemExit("audit record must contain evidence")
44+
return payload
45+
46+
47+
def _release_evidence(payload: dict[str, Any]) -> ReleaseEvidence:
48+
metrics = payload.get("quality_metrics")
49+
quality_metrics = QualityMetrics(**metrics) if isinstance(metrics, dict) else None
50+
return ReleaseEvidence(
51+
quality_passed=payload["quality_passed"],
52+
drift_psi=payload["drift_psi"],
53+
critical_vulnerabilities=payload["critical_vulnerabilities"],
54+
artifact_integrity_valid=payload["artifact_integrity_valid"],
55+
artifact_signature_valid=payload["artifact_signature_valid"],
56+
provenance_valid=payload["provenance_valid"],
57+
dependency_scan_passed=payload["dependency_scan_passed"],
58+
container_scan_passed=payload["container_scan_passed"],
59+
release_id=payload.get("release_id", ""),
60+
model_name=payload.get("model_name", ""),
61+
model_version=payload.get("model_version", ""),
62+
artifact_path=payload.get("artifact_path", ""),
63+
artifact_sha256=payload.get("artifact_sha256", ""),
64+
quality_metrics=quality_metrics,
65+
drift_status=payload.get("drift_status", ""),
66+
high_vulnerabilities=payload.get("high_vulnerabilities", 0),
67+
dependency_scan_critical=payload.get("dependency_scan_critical", 0),
68+
container_scan_critical=payload.get("container_scan_critical", 0),
69+
evidence_references=tuple(payload.get("evidence_references", ())),
70+
metadata_consistent=payload.get("metadata_consistent", True),
71+
)
72+
73+
74+
if __name__ == "__main__":
75+
sys.exit(main())

tests/scenarios/test_release_scenarios.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,36 @@ def test_blocked_audit_investigation_cites_failure_field(tmp_path: Path) -> None
146146
assert record["investigation"]["advisory_only"] is True
147147
assert ["artifact_signature_valid"] in [
148148
finding["evidence_fields"] for finding in record["investigation"]["findings"]
149-
]
149+
]
150+
151+
152+
def test_investigation_cli_reads_existing_audit(tmp_path: Path) -> None:
153+
audit = tmp_path / "audit.jsonl"
154+
release = subprocess.run(
155+
[
156+
sys.executable,
157+
"scripts/release.py",
158+
"--model-version",
159+
"scenario-v2",
160+
"--output-dir",
161+
str(tmp_path),
162+
"--audit-path",
163+
str(audit),
164+
],
165+
check=False,
166+
capture_output=True,
167+
text=True,
168+
)
169+
investigation = subprocess.run(
170+
[sys.executable, "scripts/investigate_release.py", str(audit)],
171+
check=False,
172+
capture_output=True,
173+
text=True,
174+
)
175+
176+
assert release.returncode == 0, release.stderr
177+
assert investigation.returncode == 0, investigation.stderr
178+
report = json.loads(investigation.stdout)
179+
assert report["decision"] == "PROMOTE"
180+
assert report["advisory_only"] is True
181+
assert report["findings"][0]["evidence_fields"]

0 commit comments

Comments
 (0)