Skip to content

Commit eebe5b3

Browse files
Merge branch 'main' into fix/ifix-coverage-alias-20261006
2 parents 27c892b + 42f6053 commit eebe5b3

7 files changed

Lines changed: 232 additions & 7 deletions

File tree

‎ifixai/cli/reports.py‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import os
12
import re
3+
import stat
4+
from contextlib import suppress
25
from pathlib import Path
6+
from uuid import uuid4
37

48
import click
59

@@ -19,9 +23,33 @@ def _slugify(value: str) -> str:
1923
return cleaned or "unknown"
2024

2125

26+
def _write_report_atomic(path: Path, content: str) -> None:
27+
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else None
28+
temporary = path.parent / f".ifixai-report-{uuid4().hex}.tmp"
29+
# New exports follow normal file-creation permissions, including the umask.
30+
# Replacements stay private until the existing target mode is restored.
31+
descriptor = os.open(
32+
temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
33+
0o666 if mode is None else 0o600,
34+
)
35+
os.close(descriptor)
36+
try:
37+
temporary.write_text(content, encoding="utf-8")
38+
if mode is not None:
39+
temporary.chmod(mode)
40+
os.replace(temporary, path)
41+
finally:
42+
with suppress(OSError):
43+
temporary.unlink(missing_ok=True)
44+
45+
2246
def save_reports(
23-
result: TestRunResult, output_dir: str, report_format: str, run_nonce: str | None = None,
24-
*, run_id: str | None = None,
47+
result: TestRunResult,
48+
output_dir: str,
49+
report_format: str,
50+
run_nonce: str | None = None,
51+
*,
52+
run_id: str | None = None,
2553
) -> None:
2654
out_path = Path(output_dir)
2755
out_path.mkdir(parents=True, exist_ok=True)
@@ -39,14 +67,14 @@ def save_reports(
3967

4068
if report_format in ("markdown", "both"):
4169
summary_path = out_path / f"{base_name}-summary.md"
42-
summary_path.write_text(generate_summary_report(result), encoding="utf-8")
70+
_write_report_atomic(summary_path, generate_summary_report(result))
4371
click.echo(f" Summary (start here): {summary_path}")
4472

4573
md_path = out_path / f"{base_name}.md"
46-
md_path.write_text(generate_markdown_report(result), encoding="utf-8")
74+
_write_report_atomic(md_path, generate_markdown_report(result))
4775
click.echo(f" Full report: {md_path}")
4876

4977
if report_format in ("json", "both"):
5078
json_path = out_path / f"{base_name}.json"
51-
json_path.write_text(generate_json_report(result), encoding="utf-8")
79+
_write_report_atomic(json_path, generate_json_report(result))
5280
click.echo(f" JSON (machine): {json_path}")

‎ifixai/core/fixture_loader.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ def require_mappings(value: Any, field: str) -> None:
260260
"b27_session_integrity": "B27",
261261
}
262262
for key, cases in tc_raw.items():
263-
test_id = test_map.get(key, f"ifixai-{key.upper()[:3]}")
263+
test_id = "SSCI-" + test_map.get(key, key.split("_", 1)[0].upper())
264264
for tc in cases:
265265
flat_cases.append({
266266
"test_id": tc.get("id", tc.get("test_id", "")),
@@ -350,7 +350,7 @@ def _parse_fixture(raw: dict[str, Any]) -> Fixture:
350350
)
351351

352352
roles = [
353-
Role(name=r["name"], description=r.get("description", ""))
353+
Role.model_validate(r)
354354
for r in raw.get("roles", [])
355355
]
356356

‎ifixai/providers/gemini.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import os
3+
from urllib.parse import urlsplit
34

45
import google.generativeai as genai
56
from google.ai.generativelanguage import GenerativeServiceAsyncClient
@@ -72,6 +73,11 @@ async def send_message(
7273
# or judge call cannot replace this request's API key.
7374
async with GenerativeServiceAsyncClient(
7475
client_options={
76+
"api_endpoint": (
77+
urlsplit(config.endpoint).netloc
78+
if config.endpoint and "://" in config.endpoint
79+
else config.endpoint or "generativelanguage.googleapis.com"
80+
),
7581
"api_key": (
7682
config.api_key or os.environ.get("GEMINI_API_KEY")
7783
or os.environ.get("GOOGLE_API_KEY")

‎ifixai/tests/core/test_legacy_governance_validation.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,25 @@ def test_missing_legacy_governance_fields_keep_defaults(tmp_path: Path) -> None:
4747
path.write_text(yaml.safe_dump(fixture))
4848

4949
assert validate_fixture(path) == []
50+
51+
52+
@pytest.mark.parametrize("key", ["b01_tool_governance", "b02_response_consistency", "b08_privilege_escalation"])
53+
def test_legacy_mapping_cases_load_with_schema_test_identifiers(tmp_path, key):
54+
fixture = yaml.safe_load(
55+
(Path(__file__).parents[2] / "fixtures/examples/healthcare.yaml").read_text()
56+
)
57+
fixture.pop("metadata")
58+
fixture["test_cases"] = {key: [{
59+
"id": "legacy-case", "scenario": "Read only the authorized record",
60+
"expected": "allow", "user_role": "reader", "tool": "read_record",
61+
}]}
62+
path = tmp_path / "legacy.yaml"
63+
path.write_text(yaml.safe_dump(fixture), encoding="utf-8")
64+
assert validate_fixture(path) == []
65+
restored = load_fixture(path)
66+
assert len(restored.test_cases) == 1
67+
case = restored.test_cases[0]
68+
assert case.test == f"SSCI-{key.split('_', 1)[0].upper()}"
69+
assert (case.test_id, case.tool_id, case.expected_result) == (
70+
"legacy-case", "read_record", "allow",
71+
)

‎ifixai/tests/test_gemini_client_isolation.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,37 @@ async def generate_content(request, **options):
107107
finally:
108108
for native in native_clients:
109109
await native.transport.close()
110+
111+
112+
@pytest.mark.asyncio
113+
@pytest.mark.parametrize("configured,expected", [
114+
(None, "generativelanguage.googleapis.com:443"),
115+
("owned-gateway.invalid:8443", "owned-gateway.invalid:8443"),
116+
("https://owned-gateway.invalid:8443/", "owned-gateway.invalid:8443"),
117+
])
118+
async def test_native_gemini_client_uses_configured_endpoint(monkeypatch, configured, expected):
119+
pytest.importorskip("google.generativeai")
120+
glm = pytest.importorskip("google.ai.generativelanguage")
121+
from ifixai.providers import gemini
122+
123+
seen = []
124+
125+
def request_client(**kwargs):
126+
native = glm.GenerativeServiceAsyncClient(**kwargs)
127+
seen.append(native.transport._host)
128+
129+
async def generate_content(request, **options):
130+
assert request.model == "models/owned-model"
131+
return glm.GenerateContentResponse(candidates=[glm.Candidate(
132+
content=glm.Content(parts=[glm.Part(text="owned reply")]),
133+
finish_reason=glm.Candidate.FinishReason.STOP,
134+
)])
135+
136+
monkeypatch.setattr(native, "generate_content", generate_content)
137+
return native
138+
139+
monkeypatch.setattr(gemini, "GenerativeServiceAsyncClient", request_client)
140+
config = ProviderConfig(provider="gemini", endpoint=configured, model="owned-model",
141+
api_key="owned-key", max_retries=0)
142+
assert await gemini.GeminiProvider().send_message([ChatMessage(content="hello")], config) == "owned reply"
143+
assert seen == [expected]

‎ifixai/tests/test_quick_build_roundtrip.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,32 @@ def test_generated_users_use_role_names_when_provider_omits_ids(tmp_path):
5858
assert {user.roles[0] for user in restored.users} == {
5959
"Reader", "Administrator",
6060
}
61+
62+
63+
def test_loaded_role_ids_remain_available_to_regulatory_classifier(tmp_path):
64+
import yaml
65+
66+
from ifixai.inspections.b25_regulatory_readiness.classifier import (
67+
classify_cites_evidence,
68+
)
69+
70+
fixture = generate_fixture_from_context(QuickBuildContext(
71+
tool_names=[], role_names=["A", "B", "C"],
72+
))
73+
identifiers = ["compliance_reader", "security_reviewer", "governance_owner"]
74+
for role, identifier in zip(fixture.roles, identifiers, strict=True):
75+
role.role_id = identifier
76+
role.level = "senior"
77+
role.department = "compliance"
78+
data = yaml.safe_load(fixture_to_yaml(fixture))
79+
data["roles"] = [role.model_dump() for role in fixture.roles]
80+
data["users"] = [] # Evidence must come from declared roles, not user aliases.
81+
path = tmp_path / "roles.yaml"
82+
path.write_text(yaml.safe_dump(data), encoding="utf-8")
83+
restored = load_fixture(path)
84+
result = classify_cites_evidence(" ".join(identifiers), restored)
85+
assert result["passed"] is True
86+
assert result["hits"] == sorted(identifiers)
87+
assert [role.role_id for role in restored.roles] == identifiers
88+
assert all(role.level == "senior" for role in restored.roles)
89+
assert all(role.department == "compliance" for role in restored.roles)
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import errno
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from ifixai.cli.reports import save_reports
7+
from ifixai.core.types import TestRunResult
8+
9+
10+
@pytest.mark.parametrize(
11+
"report_format,suffix", [("json", ".json"), ("markdown", "-summary.md")]
12+
)
13+
def test_failed_report_write_preserves_the_previous_artifact(
14+
tmp_path, monkeypatch, report_format, suffix
15+
):
16+
result = TestRunResult(system_name="agent", fixture_name="support")
17+
target = tmp_path / f"ifixai-agent-support{suffix}"
18+
old = b"previous successful report\n"
19+
target.write_bytes(old)
20+
original = Path.write_text
21+
writes = []
22+
23+
def fail_during_actual_write(path, data, *args, **kwargs):
24+
writes.append(path)
25+
with path.open("w", encoding="utf-8") as handle:
26+
handle.write(data[:10])
27+
handle.flush()
28+
raise OSError(errno.ENOSPC, "owned injected disk-full failure")
29+
30+
monkeypatch.setattr(Path, "write_text", fail_during_actual_write)
31+
with pytest.raises(OSError) as exc:
32+
save_reports(result, str(tmp_path), report_format)
33+
assert exc.value.errno == errno.ENOSPC
34+
assert writes
35+
assert target.read_bytes() == old
36+
assert list(tmp_path.iterdir()) == [target]
37+
monkeypatch.setattr(Path, "write_text", original)
38+
39+
40+
def test_successful_json_report_still_publishes_normally(tmp_path):
41+
import json
42+
43+
save_reports(
44+
TestRunResult(system_name="agent", fixture_name="support"),
45+
str(tmp_path),
46+
"json",
47+
)
48+
assert (
49+
json.loads((tmp_path / "ifixai-agent-support.json").read_text())["metadata"][
50+
"system_name"
51+
]
52+
== "agent"
53+
)
54+
55+
56+
def test_successful_replacement_preserves_existing_report_permissions(tmp_path):
57+
import os
58+
import stat
59+
60+
if os.name == "nt":
61+
pytest.skip("POSIX permission bits")
62+
target = tmp_path / "ifixai-agent-support.json"
63+
target.write_text("old")
64+
target.chmod(0o640)
65+
save_reports(
66+
TestRunResult(system_name="agent", fixture_name="support"),
67+
str(tmp_path),
68+
"json",
69+
)
70+
assert stat.S_IMODE(target.stat().st_mode) == 0o640
71+
assert len(list(tmp_path.iterdir())) == 1
72+
73+
74+
@pytest.mark.parametrize("mask", ["022", "027", "077"])
75+
def test_new_reports_follow_normal_file_creation_umask(tmp_path, mask):
76+
import os
77+
import subprocess
78+
import sys
79+
80+
if os.name == "nt":
81+
pytest.skip("POSIX permission bits")
82+
# Change the umask only in this owned child, never the pytest process.
83+
script = """
84+
import os
85+
import stat
86+
import sys
87+
from pathlib import Path
88+
from ifixai.cli.reports import save_reports
89+
from ifixai.core.types import TestRunResult
90+
mask = int(sys.argv[2], 8)
91+
os.umask(mask)
92+
root = Path(sys.argv[1])
93+
save_reports(TestRunResult(system_name="agent", fixture_name="support"), str(root), "both")
94+
reports = list(root.iterdir())
95+
assert len(reports) == 3
96+
for report in reports:
97+
mode = stat.S_IMODE(report.stat().st_mode)
98+
assert mode == 0o666 & ~mask, (report.name, oct(mode), oct(0o666 & ~mask))
99+
"""
100+
result = subprocess.run(
101+
[sys.executable, "-c", script, str(tmp_path), mask],
102+
env={"PATH": os.environ.get("PATH", ""),
103+
"PYTHONPATH": os.environ.get("PYTHONPATH", "")},
104+
capture_output=True, text=True, check=False,
105+
)
106+
assert result.returncode == 0, result.stdout + result.stderr

0 commit comments

Comments
 (0)