ipa-diagnose 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ipa_diagnose/__init__.py +10 -0
- ipa_diagnose/__main__.py +6 -0
- ipa_diagnose/ai/__init__.py +19 -0
- ipa_diagnose/ai/anthropic_provider.py +67 -0
- ipa_diagnose/ai/bedrock_provider.py +100 -0
- ipa_diagnose/ai/openai_provider.py +76 -0
- ipa_diagnose/ai/prompt.py +98 -0
- ipa_diagnose/ai/provider.py +101 -0
- ipa_diagnose/cli.py +190 -0
- ipa_diagnose/config.py +69 -0
- ipa_diagnose/engine/__init__.py +29 -0
- ipa_diagnose/engine/correlate.py +167 -0
- ipa_diagnose/engine/model.py +191 -0
- ipa_diagnose/engine/packs/__init__.py +3 -0
- ipa_diagnose/engine/packs/base.py +67 -0
- ipa_diagnose/engine/packs/certificates.py +731 -0
- ipa_diagnose/engine/packs/directory_server.py +718 -0
- ipa_diagnose/engine/packs/dns.py +700 -0
- ipa_diagnose/engine/packs/kerberos.py +517 -0
- ipa_diagnose/engine/packs/replication.py +683 -0
- ipa_diagnose/engine/registry.py +34 -0
- ipa_diagnose/engine/run.py +16 -0
- ipa_diagnose/evidence/__init__.py +17 -0
- ipa_diagnose/evidence/collect.py +145 -0
- ipa_diagnose/evidence/collectors/__init__.py +3 -0
- ipa_diagnose/evidence/collectors/base.py +54 -0
- ipa_diagnose/evidence/collectors/certmonger.py +208 -0
- ipa_diagnose/evidence/collectors/dns_lookup.py +221 -0
- ipa_diagnose/evidence/collectors/filesystem.py +194 -0
- ipa_diagnose/evidence/collectors/journal_dirsrv.py +201 -0
- ipa_diagnose/evidence/collectors/journal_krb5kdc.py +168 -0
- ipa_diagnose/evidence/collectors/journal_named.py +189 -0
- ipa_diagnose/evidence/collectors/journal_pki.py +160 -0
- ipa_diagnose/evidence/collectors/kerberos_client.py +307 -0
- ipa_diagnose/evidence/collectors/ldap_query.py +304 -0
- ipa_diagnose/evidence/collectors/registry.py +63 -0
- ipa_diagnose/evidence/collectors/replication_agreements.py +286 -0
- ipa_diagnose/evidence/healthcheck.py +120 -0
- ipa_diagnose/evidence/model.py +139 -0
- ipa_diagnose/privacy/__init__.py +10 -0
- ipa_diagnose/privacy/minimize.py +162 -0
- ipa_diagnose/privacy/preview.py +36 -0
- ipa_diagnose/privacy/redact.py +119 -0
- ipa_diagnose/render/__init__.py +4 -0
- ipa_diagnose/render/console.py +233 -0
- ipa_diagnose/render/json_output.py +69 -0
- ipa_diagnose/verify.py +165 -0
- ipa_diagnose-0.1.0.dist-info/METADATA +295 -0
- ipa_diagnose-0.1.0.dist-info/RECORD +53 -0
- ipa_diagnose-0.1.0.dist-info/WHEEL +5 -0
- ipa_diagnose-0.1.0.dist-info/entry_points.txt +2 -0
- ipa_diagnose-0.1.0.dist-info/licenses/LICENSE +190 -0
- ipa_diagnose-0.1.0.dist-info/top_level.txt +1 -0
ipa_diagnose/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""ipa-diagnose: evidence-grounded diagnostic and correlation engine for FreeIPA / Red Hat IdM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("ipa-diagnose")
|
|
9
|
+
except PackageNotFoundError: # pragma: no cover - only hit for an unpackaged checkout
|
|
10
|
+
__version__ = "0.0.0+unknown"
|
ipa_diagnose/__main__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from ipa_diagnose.ai.provider import (
|
|
2
|
+
AIProvider,
|
|
3
|
+
ProviderAuthError,
|
|
4
|
+
ProviderConnectionError,
|
|
5
|
+
ProviderError,
|
|
6
|
+
ProviderRateLimitError,
|
|
7
|
+
ProviderRefusalError,
|
|
8
|
+
ProviderTimeoutError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AIProvider",
|
|
13
|
+
"ProviderAuthError",
|
|
14
|
+
"ProviderConnectionError",
|
|
15
|
+
"ProviderError",
|
|
16
|
+
"ProviderRateLimitError",
|
|
17
|
+
"ProviderRefusalError",
|
|
18
|
+
"ProviderTimeoutError",
|
|
19
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Anthropic adapter. Requires the `anthropic` extra (`pip install ipa-diagnose[anthropic]`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ipa_diagnose.ai.provider import (
|
|
8
|
+
AIProvider,
|
|
9
|
+
AIRequest,
|
|
10
|
+
AIResponse,
|
|
11
|
+
ProviderAuthError,
|
|
12
|
+
ProviderConnectionError,
|
|
13
|
+
ProviderError,
|
|
14
|
+
ProviderRateLimitError,
|
|
15
|
+
ProviderRefusalError,
|
|
16
|
+
ProviderTimeoutError,
|
|
17
|
+
ProviderUnavailableError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AnthropicProvider(AIProvider):
|
|
22
|
+
provider_name = "anthropic"
|
|
23
|
+
|
|
24
|
+
def __init__(self, *, api_key: Optional[str], model: str, timeout: float):
|
|
25
|
+
self._api_key = api_key
|
|
26
|
+
self._model = model
|
|
27
|
+
self._timeout = timeout
|
|
28
|
+
|
|
29
|
+
def is_configured(self) -> bool:
|
|
30
|
+
return bool(self._api_key)
|
|
31
|
+
|
|
32
|
+
def generate(self, request: AIRequest) -> AIResponse:
|
|
33
|
+
if not self._api_key:
|
|
34
|
+
raise ProviderAuthError("ANTHROPIC_API_KEY is not set")
|
|
35
|
+
try:
|
|
36
|
+
import anthropic
|
|
37
|
+
except ImportError as e:
|
|
38
|
+
raise ProviderError(
|
|
39
|
+
"the 'anthropic' package is not installed (pip install 'ipa-diagnose[anthropic]')"
|
|
40
|
+
) from e
|
|
41
|
+
|
|
42
|
+
client = anthropic.Anthropic(api_key=self._api_key, timeout=min(request.timeout, self._timeout))
|
|
43
|
+
try:
|
|
44
|
+
message = client.messages.create(
|
|
45
|
+
model=self._model,
|
|
46
|
+
max_tokens=request.max_tokens,
|
|
47
|
+
system=request.system_prompt,
|
|
48
|
+
messages=[{"role": "user", "content": request.user_prompt}],
|
|
49
|
+
)
|
|
50
|
+
except anthropic.AuthenticationError as e:
|
|
51
|
+
raise ProviderAuthError(str(e)) from e
|
|
52
|
+
except anthropic.RateLimitError as e:
|
|
53
|
+
raise ProviderRateLimitError(str(e)) from e
|
|
54
|
+
except anthropic.APITimeoutError as e:
|
|
55
|
+
raise ProviderTimeoutError(str(e)) from e
|
|
56
|
+
except anthropic.APIConnectionError as e:
|
|
57
|
+
raise ProviderConnectionError(str(e)) from e
|
|
58
|
+
except anthropic.APIStatusError as e:
|
|
59
|
+
raise ProviderUnavailableError(str(e)) from e
|
|
60
|
+
|
|
61
|
+
if getattr(message, "stop_reason", None) == "refusal":
|
|
62
|
+
raise ProviderRefusalError("Claude declined to produce a response")
|
|
63
|
+
blocks = [b.text for b in message.content if getattr(b, "type", None) == "text"]
|
|
64
|
+
text = "\n".join(blocks).strip()
|
|
65
|
+
if not text:
|
|
66
|
+
raise ProviderRefusalError("Claude returned an empty response")
|
|
67
|
+
return AIResponse(text=text, provider_name=self.provider_name, model=self._model)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""AWS Bedrock adapter (Converse API). Requires the `bedrock` extra
|
|
2
|
+
(`pip install ipa-diagnose[bedrock]`, which installs boto3).
|
|
3
|
+
|
|
4
|
+
Uses boto3's standard credential chain (env vars, ~/.aws, EC2/ECS instance
|
|
5
|
+
role, assumed role) rather than requiring explicit keys - the realistic
|
|
6
|
+
default for this tool's audience is an IAM role already attached to the
|
|
7
|
+
host. See docs/ai-configuration.md for the cross-region inference-profile
|
|
8
|
+
gotcha this adapter's default model id already works around.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ipa_diagnose.ai.provider import (
|
|
14
|
+
AIProvider,
|
|
15
|
+
AIRequest,
|
|
16
|
+
AIResponse,
|
|
17
|
+
ProviderAuthError,
|
|
18
|
+
ProviderConnectionError,
|
|
19
|
+
ProviderError,
|
|
20
|
+
ProviderRateLimitError,
|
|
21
|
+
ProviderRefusalError,
|
|
22
|
+
ProviderTimeoutError,
|
|
23
|
+
ProviderUnavailableError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_RETRYABLE_BUT_STILL_UNAVAILABLE = {"ServiceUnavailableException", "InternalServerError", "ModelTimeoutException"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BedrockProvider(AIProvider):
|
|
30
|
+
provider_name = "bedrock"
|
|
31
|
+
|
|
32
|
+
def __init__(self, *, model_id: str, region: str, timeout: float):
|
|
33
|
+
self._model_id = model_id
|
|
34
|
+
self._region = region
|
|
35
|
+
self._timeout = timeout
|
|
36
|
+
self._client = None
|
|
37
|
+
|
|
38
|
+
def is_configured(self) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
import boto3
|
|
41
|
+
except ImportError:
|
|
42
|
+
return False
|
|
43
|
+
try:
|
|
44
|
+
session = boto3.Session()
|
|
45
|
+
return session.get_credentials() is not None
|
|
46
|
+
except Exception:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def _get_client(self):
|
|
50
|
+
if self._client is None:
|
|
51
|
+
import boto3
|
|
52
|
+
from botocore.config import Config
|
|
53
|
+
|
|
54
|
+
self._client = boto3.client(
|
|
55
|
+
"bedrock-runtime",
|
|
56
|
+
region_name=self._region,
|
|
57
|
+
config=Config(connect_timeout=self._timeout, read_timeout=self._timeout, retries={"max_attempts": 2}),
|
|
58
|
+
)
|
|
59
|
+
return self._client
|
|
60
|
+
|
|
61
|
+
def generate(self, request: AIRequest) -> AIResponse:
|
|
62
|
+
try:
|
|
63
|
+
import botocore
|
|
64
|
+
except ImportError as e:
|
|
65
|
+
raise ProviderError(
|
|
66
|
+
"the 'boto3' package is not installed (pip install 'ipa-diagnose[bedrock]')"
|
|
67
|
+
) from e
|
|
68
|
+
|
|
69
|
+
client = self._get_client()
|
|
70
|
+
try:
|
|
71
|
+
response = client.converse(
|
|
72
|
+
modelId=self._model_id,
|
|
73
|
+
system=[{"text": request.system_prompt}],
|
|
74
|
+
messages=[{"role": "user", "content": [{"text": request.user_prompt}]}],
|
|
75
|
+
inferenceConfig={"maxTokens": request.max_tokens},
|
|
76
|
+
)
|
|
77
|
+
except botocore.exceptions.ClientError as e:
|
|
78
|
+
code = e.response.get("Error", {}).get("Code", "")
|
|
79
|
+
if code in ("AccessDeniedException", "UnrecognizedClientException"):
|
|
80
|
+
raise ProviderAuthError(str(e)) from e
|
|
81
|
+
if code == "ThrottlingException":
|
|
82
|
+
raise ProviderRateLimitError(str(e)) from e
|
|
83
|
+
if code in _RETRYABLE_BUT_STILL_UNAVAILABLE:
|
|
84
|
+
raise ProviderUnavailableError(str(e)) from e
|
|
85
|
+
if code == "ValidationException":
|
|
86
|
+
raise ProviderError(f"Bedrock rejected the request: {e}") from e
|
|
87
|
+
raise ProviderUnavailableError(str(e)) from e
|
|
88
|
+
except botocore.exceptions.ConnectTimeoutError as e:
|
|
89
|
+
raise ProviderTimeoutError(str(e)) from e
|
|
90
|
+
except botocore.exceptions.EndpointConnectionError as e:
|
|
91
|
+
raise ProviderConnectionError(str(e)) from e
|
|
92
|
+
|
|
93
|
+
stop_reason = response.get("stopReason")
|
|
94
|
+
if stop_reason == "content_filtered":
|
|
95
|
+
raise ProviderRefusalError("Bedrock content filter declined to produce a response")
|
|
96
|
+
content = response.get("output", {}).get("message", {}).get("content", [])
|
|
97
|
+
text = "\n".join(block.get("text", "") for block in content if "text" in block).strip()
|
|
98
|
+
if not text:
|
|
99
|
+
raise ProviderRefusalError("Bedrock returned an empty response")
|
|
100
|
+
return AIResponse(text=text, provider_name=self.provider_name, model=self._model_id)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""OpenAI adapter. Requires the `openai` extra (`pip install ipa-diagnose[openai]`).
|
|
2
|
+
|
|
3
|
+
Import of the `openai` package is deferred into __init__ so this module can
|
|
4
|
+
be imported (e.g. by config.py's lazy factory) without the dependency being
|
|
5
|
+
installed - only actually *using* the openai provider requires it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from ipa_diagnose.ai.provider import (
|
|
13
|
+
AIProvider,
|
|
14
|
+
AIRequest,
|
|
15
|
+
AIResponse,
|
|
16
|
+
ProviderAuthError,
|
|
17
|
+
ProviderConnectionError,
|
|
18
|
+
ProviderError,
|
|
19
|
+
ProviderRateLimitError,
|
|
20
|
+
ProviderRefusalError,
|
|
21
|
+
ProviderTimeoutError,
|
|
22
|
+
ProviderUnavailableError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenAIProvider(AIProvider):
|
|
27
|
+
provider_name = "openai"
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, api_key: Optional[str], model: str, timeout: float):
|
|
30
|
+
self._api_key = api_key
|
|
31
|
+
self._model = model
|
|
32
|
+
self._timeout = timeout
|
|
33
|
+
|
|
34
|
+
def is_configured(self) -> bool:
|
|
35
|
+
return bool(self._api_key)
|
|
36
|
+
|
|
37
|
+
def generate(self, request: AIRequest) -> AIResponse:
|
|
38
|
+
if not self._api_key:
|
|
39
|
+
raise ProviderAuthError("OPENAI_API_KEY is not set")
|
|
40
|
+
try:
|
|
41
|
+
import openai
|
|
42
|
+
except ImportError as e:
|
|
43
|
+
raise ProviderError(
|
|
44
|
+
"the 'openai' package is not installed (pip install 'ipa-diagnose[openai]')"
|
|
45
|
+
) from e
|
|
46
|
+
|
|
47
|
+
client = openai.OpenAI(api_key=self._api_key, timeout=min(request.timeout, self._timeout))
|
|
48
|
+
try:
|
|
49
|
+
completion = client.chat.completions.create(
|
|
50
|
+
model=self._model,
|
|
51
|
+
max_tokens=request.max_tokens,
|
|
52
|
+
messages=[
|
|
53
|
+
{"role": "developer", "content": request.system_prompt},
|
|
54
|
+
{"role": "user", "content": request.user_prompt},
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
except openai.AuthenticationError as e:
|
|
58
|
+
raise ProviderAuthError(str(e)) from e
|
|
59
|
+
except openai.RateLimitError as e:
|
|
60
|
+
raise ProviderRateLimitError(str(e)) from e
|
|
61
|
+
except openai.APITimeoutError as e:
|
|
62
|
+
raise ProviderTimeoutError(str(e)) from e
|
|
63
|
+
except openai.APIConnectionError as e:
|
|
64
|
+
raise ProviderConnectionError(str(e)) from e
|
|
65
|
+
except openai.InternalServerError as e:
|
|
66
|
+
raise ProviderUnavailableError(str(e)) from e
|
|
67
|
+
except openai.APIStatusError as e:
|
|
68
|
+
raise ProviderUnavailableError(str(e)) from e
|
|
69
|
+
|
|
70
|
+
choice = completion.choices[0] if completion.choices else None
|
|
71
|
+
if choice is None or getattr(choice, "finish_reason", None) == "content_filter":
|
|
72
|
+
raise ProviderRefusalError("OpenAI declined to produce a response (content filter)")
|
|
73
|
+
text = (choice.message.content or "").strip()
|
|
74
|
+
if not text:
|
|
75
|
+
raise ProviderRefusalError("OpenAI returned an empty response")
|
|
76
|
+
return AIResponse(text=text, provider_name=self.provider_name, model=self._model)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Ties the privacy pipeline + AIProvider together, with a defense-in-depth
|
|
2
|
+
sanitizer on the way back out.
|
|
3
|
+
|
|
4
|
+
Structural safety guarantee (the important one): the rendered Actions section
|
|
5
|
+
in the CLI ALWAYS comes from `Diagnosis.actions` (deterministic Action
|
|
6
|
+
objects), never from AI output. An AI explanation is prose displayed
|
|
7
|
+
alongside that section, never a replacement for it. `sanitize_explanation`
|
|
8
|
+
below is a second, best-effort layer on top of that structural guarantee -
|
|
9
|
+
it catches an AI response that ignored its instructions and tried to invent
|
|
10
|
+
a new command anyway, and rejects the whole explanation rather than trying
|
|
11
|
+
to strip just the bad line (a partially-sanitized AI paragraph is more
|
|
12
|
+
misleading than falling back to the deterministic `why` text).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from ipa_diagnose.ai.provider import AIProvider, AIRequest, ProviderError
|
|
21
|
+
from ipa_diagnose.engine.model import Diagnosis
|
|
22
|
+
from ipa_diagnose.evidence.model import EvidenceBundle
|
|
23
|
+
from ipa_diagnose.privacy.minimize import build_ai_payload
|
|
24
|
+
|
|
25
|
+
# Deliberately NOT anchored to line-start (an earlier version was - a
|
|
26
|
+
# security review found that let prose-embedded suggestions like "you could
|
|
27
|
+
# try: getcert resubmit ..." slip through undetected) and deliberately a
|
|
28
|
+
# broad binary/keyword list rather than an exhaustive one, since the goal is
|
|
29
|
+
# "reject anything command-shaped we don't recognize as approved," not
|
|
30
|
+
# "recognize every dangerous command by name."
|
|
31
|
+
_COMMAND_LIKE = re.compile(
|
|
32
|
+
# Zero-width lookbehind for the boundary (start-of-string, whitespace, or
|
|
33
|
+
# a backtick) rather than a consuming group - otherwise match.group(0)
|
|
34
|
+
# includes the boundary character and would never equal an approved
|
|
35
|
+
# command string during comparison.
|
|
36
|
+
r"(?<![^\s`])(?:\$\s*)?(?:sudo\s+)?(?:/usr/(?:s?bin)/)?"
|
|
37
|
+
r"(ipa[\w-]*|getcert|kinit|klist|kvno|dsconf|dsctl|ldapmodify|ldapsearch|"
|
|
38
|
+
r"systemctl|service|reboot|shutdown|halt|poweroff|init\s+0|"
|
|
39
|
+
r"rm\b|mkfs[\w.]*|dd\b|userdel|groupdel|iptables|firewall-cmd|"
|
|
40
|
+
r"dnf|yum|rpm\b|certutil|db2index[\w.]*|chmod|chown|kill(?:all)?|"
|
|
41
|
+
r"curl|wget|python[\w.]*|perl|bash|sh\b|nc\b|ncat)\b",
|
|
42
|
+
re.IGNORECASE,
|
|
43
|
+
)
|
|
44
|
+
_CODE_SPAN = re.compile(r"`([^`\n]{1,200})`")
|
|
45
|
+
_MAX_EXPLANATION_CHARS = 4000
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _looks_approved(candidate: str, approved_commands: set) -> bool:
|
|
49
|
+
candidate = candidate.strip()
|
|
50
|
+
return any(candidate in cmd or cmd in candidate for cmd in approved_commands)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sanitize_explanation(text: str, diagnosis: Diagnosis) -> Optional[str]:
|
|
54
|
+
"""Returns the explanation if it looks like prose only, else None (caller
|
|
55
|
+
must fall back to the deterministic `why` text).
|
|
56
|
+
|
|
57
|
+
Two independent checks, either of which rejects the whole response:
|
|
58
|
+
1. Any command-shaped token (see _COMMAND_LIKE) appearing anywhere in
|
|
59
|
+
the text, not just at a line's start.
|
|
60
|
+
2. Any markdown-style inline code span (`` `...` ``) whose content isn't
|
|
61
|
+
one of the diagnosis's own approved commands - this catches the
|
|
62
|
+
common AI phrasing "run `<command>`" regardless of whether the
|
|
63
|
+
command inside the backticks matches a known binary name.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
if not text or not text.strip():
|
|
67
|
+
return None
|
|
68
|
+
if len(text) > _MAX_EXPLANATION_CHARS:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
approved_commands = {a.command for a in diagnosis.actions if a.command}
|
|
72
|
+
|
|
73
|
+
for span_match in _CODE_SPAN.finditer(text):
|
|
74
|
+
if not _looks_approved(span_match.group(1), approved_commands):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
for match in _COMMAND_LIKE.finditer(text):
|
|
78
|
+
if not _looks_approved(match.group(0), approved_commands):
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
return text.strip()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def explain_diagnosis(diagnosis: Diagnosis, bundle: EvidenceBundle, provider: AIProvider) -> Optional[str]:
|
|
85
|
+
"""Returns an AI explanation, or None on any failure/refusal/unsafe output -
|
|
86
|
+
callers must always have a local-explanation fallback ready (Diagnosis.why),
|
|
87
|
+
ipa-diagnose must remain fully useful with this returning None."""
|
|
88
|
+
|
|
89
|
+
if not provider.is_configured():
|
|
90
|
+
return None
|
|
91
|
+
payload = build_ai_payload(bundle, diagnosis)
|
|
92
|
+
try:
|
|
93
|
+
response = provider.generate(
|
|
94
|
+
AIRequest(system_prompt=payload.system_prompt, user_prompt=payload.user_prompt)
|
|
95
|
+
)
|
|
96
|
+
except ProviderError:
|
|
97
|
+
return None
|
|
98
|
+
return sanitize_explanation(response.text, diagnosis)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""AI provider abstraction.
|
|
2
|
+
|
|
3
|
+
Exactly one job: turn an already-computed, deterministic Diagnosis into more
|
|
4
|
+
readable prose. A provider implementation NEVER sees raw evidence beyond
|
|
5
|
+
what the privacy/redaction pipeline approved (see privacy/preview.py, which
|
|
6
|
+
renders the exact same payload this module sends - "ai-preview" and the real
|
|
7
|
+
call must never diverge). A provider MUST NOT be asked to produce root
|
|
8
|
+
causes, evidence, confidence, or commands - prompt.py's contract enforces
|
|
9
|
+
this by construction (it only ever asks for an explanation of given facts),
|
|
10
|
+
and render code must ignore/discard anything a provider response contains
|
|
11
|
+
that looks like a new claim (see ai/prompt.py `sanitize_explanation`).
|
|
12
|
+
|
|
13
|
+
Every concrete provider (OpenAI, Anthropic, Bedrock) is a thin ~20-40 line
|
|
14
|
+
adapter: build client, make one call, map that SDK's specific exceptions
|
|
15
|
+
into the ProviderError hierarchy below so the rest of the codebase never
|
|
16
|
+
branches on provider identity. See docs/ai-configuration.md for the research
|
|
17
|
+
backing the model/error-handling choices per provider.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import abc
|
|
23
|
+
import dataclasses
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProviderError(Exception):
|
|
27
|
+
"""Base class for all AI provider failures. Any of these must leave
|
|
28
|
+
ipa-diagnose fully functional in local-explanation mode - never fatal."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProviderAuthError(ProviderError):
|
|
32
|
+
"""Invalid/missing API key or credentials."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProviderRateLimitError(ProviderError):
|
|
36
|
+
"""Rate limited; retrying immediately will not help."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProviderTimeoutError(ProviderError):
|
|
40
|
+
"""Request exceeded the configured timeout."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ProviderConnectionError(ProviderError):
|
|
44
|
+
"""Network/connectivity failure reaching the provider."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ProviderRefusalError(ProviderError):
|
|
48
|
+
"""Provider returned a content-policy refusal or empty/invalid response."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProviderUnavailableError(ProviderError):
|
|
52
|
+
"""Provider-side server error (5xx) or service outage."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclasses.dataclass(frozen=True)
|
|
56
|
+
class AIRequest:
|
|
57
|
+
system_prompt: str
|
|
58
|
+
user_prompt: str
|
|
59
|
+
max_tokens: int = 700
|
|
60
|
+
timeout: float = 12.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclasses.dataclass(frozen=True)
|
|
64
|
+
class AIResponse:
|
|
65
|
+
text: str
|
|
66
|
+
provider_name: str
|
|
67
|
+
model: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AIProvider(abc.ABC):
|
|
71
|
+
"""Implemented by OpenAIProvider, AnthropicProvider, BedrockProvider."""
|
|
72
|
+
|
|
73
|
+
provider_name: str
|
|
74
|
+
|
|
75
|
+
@abc.abstractmethod
|
|
76
|
+
def generate(self, request: AIRequest) -> AIResponse:
|
|
77
|
+
"""Returns a response, or raises a ProviderError subclass. Must never
|
|
78
|
+
raise anything else - implementations should catch their SDK's own
|
|
79
|
+
exception types and re-raise as ProviderError subclasses."""
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
|
|
82
|
+
@abc.abstractmethod
|
|
83
|
+
def is_configured(self) -> bool:
|
|
84
|
+
"""True if required credentials/config are present, without making a
|
|
85
|
+
network call. Used to fail fast with a clear message before ever
|
|
86
|
+
reaching the provider."""
|
|
87
|
+
raise NotImplementedError
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class NoAIProvider(AIProvider):
|
|
91
|
+
"""The default. Explicitly refuses to run rather than silently no-op,
|
|
92
|
+
so callers always go through the same code path (try provider, catch
|
|
93
|
+
ProviderError, fall back to local explanation)."""
|
|
94
|
+
|
|
95
|
+
provider_name = "none"
|
|
96
|
+
|
|
97
|
+
def generate(self, request: AIRequest) -> AIResponse:
|
|
98
|
+
raise ProviderError("AI explanation is disabled (--no-ai or no provider configured)")
|
|
99
|
+
|
|
100
|
+
def is_configured(self) -> bool:
|
|
101
|
+
return False
|
ipa_diagnose/cli.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""ipa-diagnose command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Dict, Optional
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from ipa_diagnose import __version__
|
|
14
|
+
from ipa_diagnose.ai.prompt import explain_diagnosis
|
|
15
|
+
from ipa_diagnose.config import AIConfig, build_provider
|
|
16
|
+
from ipa_diagnose.engine.model import DiagnosisReport, PriorityBucket
|
|
17
|
+
from ipa_diagnose.engine.run import run_diagnosis
|
|
18
|
+
from ipa_diagnose.evidence.collect import collect_evidence
|
|
19
|
+
from ipa_diagnose.evidence.model import EvidenceBundle
|
|
20
|
+
from ipa_diagnose.privacy.minimize import build_ai_payload
|
|
21
|
+
from ipa_diagnose.privacy.preview import render_preview
|
|
22
|
+
from ipa_diagnose.render.console import render_report, render_verify
|
|
23
|
+
from ipa_diagnose.render.json_output import report_to_dict
|
|
24
|
+
from ipa_diagnose.verify import compare, default_state_path, load_previous_report, save_report
|
|
25
|
+
|
|
26
|
+
_EXPLAINABLE_PRIORITIES = {PriorityBucket.PRIMARY, PriorityBucket.SECONDARY_INDEPENDENT}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _add_common_args(parser: argparse.ArgumentParser) -> None:
|
|
30
|
+
parser.add_argument("--details", action="store_true", help="show full evidence, confidence, and all actions")
|
|
31
|
+
parser.add_argument("--json", action="store_true", help="print the report as JSON instead of formatted text")
|
|
32
|
+
parser.add_argument("--no-ai", action="store_true", help="never contact an AI provider, even if configured")
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--ai-provider", choices=["openai", "anthropic", "bedrock"], default=None,
|
|
35
|
+
help="override IPA_DIAGNOSE_AI_PROVIDER for this run",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--replay", metavar="FIXTURE_DIR", default=None,
|
|
39
|
+
help="read evidence from a fixture directory instead of the live host (development/testing)",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
44
|
+
# A single parser with one optional positional `command`, rather than
|
|
45
|
+
# argparse subparsers: subparsers each get their own copy of the common
|
|
46
|
+
# flags' defaults, which silently clobbers a flag given *before* the
|
|
47
|
+
# subcommand name (e.g. `ipa-diagnose --replay DIR diagnose` would reset
|
|
48
|
+
# --replay back to None). One shared namespace avoids that entirely.
|
|
49
|
+
parser = argparse.ArgumentParser(prog="ipa-diagnose", description="FreeIPA / Red Hat IdM diagnostic tool")
|
|
50
|
+
parser.add_argument("--version", action="version", version=f"ipa-diagnose {__version__}")
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"command",
|
|
53
|
+
nargs="?",
|
|
54
|
+
choices=["diagnose", "verify", "ai-preview"],
|
|
55
|
+
default="diagnose",
|
|
56
|
+
help="diagnose (default): run diagnosis. verify: check if previously diagnosed problems cleared. "
|
|
57
|
+
"ai-preview: show exactly what would be sent to the AI provider.",
|
|
58
|
+
)
|
|
59
|
+
_add_common_args(parser)
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ai_config_from_args(args: argparse.Namespace) -> AIConfig:
|
|
64
|
+
return AIConfig.from_env_and_args(no_ai=args.no_ai, provider_arg=args.ai_provider)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _collect_and_diagnose(args: argparse.Namespace) -> tuple[EvidenceBundle, DiagnosisReport]:
|
|
68
|
+
bundle = collect_evidence(replay_dir=args.replay)
|
|
69
|
+
report = run_diagnosis(bundle)
|
|
70
|
+
return bundle, report
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _maybe_explain(
|
|
74
|
+
report: DiagnosisReport, bundle: EvidenceBundle, args: argparse.Namespace, console: Console
|
|
75
|
+
) -> Dict[str, str]:
|
|
76
|
+
explanations: Dict[str, str] = {}
|
|
77
|
+
config = _ai_config_from_args(args)
|
|
78
|
+
if config.provider == "none":
|
|
79
|
+
return explanations
|
|
80
|
+
provider = build_provider(config)
|
|
81
|
+
if not provider.is_configured():
|
|
82
|
+
console.print(
|
|
83
|
+
f"[dim](AI provider '{config.provider}' is not configured - showing local explanation)[/dim]"
|
|
84
|
+
)
|
|
85
|
+
return explanations
|
|
86
|
+
for d in report.diagnoses:
|
|
87
|
+
if d.priority not in _EXPLAINABLE_PRIORITIES:
|
|
88
|
+
continue
|
|
89
|
+
text = explain_diagnosis(d, bundle, provider)
|
|
90
|
+
if text:
|
|
91
|
+
explanations[d.diagnosis_id] = text
|
|
92
|
+
else:
|
|
93
|
+
console.print(
|
|
94
|
+
f"[dim](AI explanation unavailable for '{d.title}' - showing local explanation)[/dim]"
|
|
95
|
+
)
|
|
96
|
+
return explanations
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_diagnose(args: argparse.Namespace, console: Console) -> int:
|
|
100
|
+
bundle, report = _collect_and_diagnose(args)
|
|
101
|
+
explanations = {} if args.json else _maybe_explain(report, bundle, args, console)
|
|
102
|
+
|
|
103
|
+
if args.json:
|
|
104
|
+
print(json.dumps(report_to_dict(report, explanations), indent=2))
|
|
105
|
+
else:
|
|
106
|
+
render_report(report, console, details=args.details, ai_explanations=explanations)
|
|
107
|
+
|
|
108
|
+
save_report(default_state_path(), report)
|
|
109
|
+
return _exit_code_for(report)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_verify(args: argparse.Namespace, console: Console) -> int:
|
|
113
|
+
bundle, report = _collect_and_diagnose(args)
|
|
114
|
+
previous = load_previous_report(default_state_path())
|
|
115
|
+
result = compare(previous, report)
|
|
116
|
+
|
|
117
|
+
if args.json:
|
|
118
|
+
print(
|
|
119
|
+
json.dumps(
|
|
120
|
+
{
|
|
121
|
+
"previous_generated_at": result.previous_generated_at,
|
|
122
|
+
"items": [
|
|
123
|
+
{"diagnosis_id": i.diagnosis_id, "title": i.title, "outcome": i.outcome.value, "detail": i.detail}
|
|
124
|
+
for i in result.items
|
|
125
|
+
],
|
|
126
|
+
"new_conditions": [d.title for d in result.new_conditions],
|
|
127
|
+
"current_report": report_to_dict(report),
|
|
128
|
+
},
|
|
129
|
+
indent=2,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
render_verify(result, console)
|
|
134
|
+
|
|
135
|
+
save_report(default_state_path(), report)
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_ai_preview(args: argparse.Namespace, console: Console) -> int:
|
|
140
|
+
bundle, report = _collect_and_diagnose(args)
|
|
141
|
+
explainable = [d for d in report.diagnoses if d.priority in _EXPLAINABLE_PRIORITIES]
|
|
142
|
+
if not explainable:
|
|
143
|
+
console.print("[green]No primary or independent problems to explain right now.[/green]")
|
|
144
|
+
return 0
|
|
145
|
+
for d in explainable:
|
|
146
|
+
payload = build_ai_payload(bundle, d)
|
|
147
|
+
render_preview(payload, console)
|
|
148
|
+
console.print()
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _exit_code_for(report: DiagnosisReport) -> int:
|
|
153
|
+
from ipa_diagnose.engine.model import OverallStatus
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
OverallStatus.HEALTHY: 0,
|
|
157
|
+
OverallStatus.DEGRADED: 1,
|
|
158
|
+
OverallStatus.CRITICAL: 2,
|
|
159
|
+
OverallStatus.UNKNOWN: 3,
|
|
160
|
+
}[report.overall_status]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def main(argv: Optional[list] = None) -> int:
|
|
164
|
+
parser = build_parser()
|
|
165
|
+
args = parser.parse_args(argv)
|
|
166
|
+
# highlight=False: rich's default ReprHighlighter auto-colors substrings
|
|
167
|
+
# that look like paths/numbers/quoted strings inside plain evidence text
|
|
168
|
+
# (e.g. a log message), which reads as noise, not signal, in this UI -
|
|
169
|
+
# all intentional styling here is explicit markup, not auto-detected.
|
|
170
|
+
console = Console(highlight=False)
|
|
171
|
+
|
|
172
|
+
command = args.command or "diagnose"
|
|
173
|
+
if os.name != "nt" and command in ("diagnose", "verify") and args.replay is None and os.geteuid() != 0:
|
|
174
|
+
console.print(
|
|
175
|
+
"[yellow]Warning: not running as root - live evidence collection (ipa-healthcheck, "
|
|
176
|
+
"journalctl, certmonger) will likely fail or be incomplete.[/yellow]\n"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
if command == "verify":
|
|
181
|
+
return cmd_verify(args, console)
|
|
182
|
+
if command == "ai-preview":
|
|
183
|
+
return cmd_ai_preview(args, console)
|
|
184
|
+
return cmd_diagnose(args, console)
|
|
185
|
+
except KeyboardInterrupt:
|
|
186
|
+
return 130
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
sys.exit(main())
|