Catalyst / admin/Synapse-Cortex 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Synapse-Cortex

public

Self Hosted ITSM Tool with RBAC/Tenanting and MFA

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Synapse-Cortex / Synapse-Cortexv2 / app / ai / investigator.py 9999 B · main
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""AI investigator: selects the most appropriate *enabled* Playbook for a
ticket by calling Claude with a tool whose `playbook_id` parameter is
structurally constrained to an enum of only the tenant's currently-enabled
playbook ids (plus a NONE sentinel). This prevents the model from selecting
a disabled or hallucinated playbook - a real correctness/security control,
not just a prompt hint.

The model is given ticket/asset context and per-playbook metadata only
(name, allowed target OS, approval level) - never the raw graph_json and
never credential data. It never writes commands itself; the caller extracts
the actual command list from the selected playbook's snapshot server-side
via app/ai/guardrails.parse_graph_to_commands.

get_anthropic_client() constructs the client lazily so a missing API key
surfaces as a clear error the first time an investigation is actually
attempted, not at import/app-boot time. It is exposed as a FastAPI
dependency (Depends(get_db)/Depends(get_session_user)) so tests can
override it via app.dependency_overrides with a fake client, mirroring the
get_executor() pattern in app/ai/executor.py.

It prefers the tenant's own Claude API key, saved via Admin -> AI Settings
and encrypted with the tenant's vault key, and falls back to the
ANTHROPIC_API_KEY environment variable when no tenant key is configured -
this keeps existing env-var-only deployments working unchanged.

It is used as a route's Depends(get_anthropic_client), so the missing-key
case raises HTTPException directly rather than InvestigatorError: FastAPI
only turns HTTPException into a clean JSON response when raised inside a
dependency - any other exception there becomes an unhandled 500 with no
detail, since the route body's own try/except never gets a chance to run.
"""
import os
import uuid
from dataclasses import dataclass
from typing import Optional

from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session

from .. import models
from ..database import get_db
from ..deps import get_session_user
from .vault import VaultError, decrypt_secret

MODEL_ID = "claude-opus-4-8"

NONE_SENTINEL = "NONE"


class InvestigatorError(RuntimeError):
    pass


def get_anthropic_client(
    db: Session = Depends(get_db),
    user: Optional[models.User] = Depends(get_session_user),
):
    if not user:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED)

    tenant = db.get(models.Tenant, user.tenant_id)
    api_key = None
    if tenant.anthropic_api_key_encrypted:
        try:
            api_key = decrypt_secret(tenant, tenant.anthropic_api_key_encrypted)
        except VaultError:
            api_key = None
    if not api_key:
        api_key = os.getenv("ANTHROPIC_API_KEY")

    if not api_key:
        raise HTTPException(
            status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=(
                "No Claude API key is configured. Save one from Admin → AI Settings, or set "
                "ANTHROPIC_API_KEY in the environment, to enable AI-driven playbook selection."
            ),
        )
    import anthropic

    try:
        return anthropic.Anthropic(api_key=api_key)
    except Exception as exc:
        raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail=f"Could not initialize the Claude API client: {exc}")


def test_api_key(api_key: str) -> tuple[bool, str]:
    """Makes a single, minimal (max_tokens=1) call to verify a Claude API
    key is valid. Never touches a tenant's stored key or the
    ANTHROPIC_API_KEY environment variable - the caller passes whichever
    key it wants validated. Client construction is wrapped in the same
    try/except as the request itself - it can also raise (e.g. an
    incompatible installed SDK/httpx pairing), and this is a user-facing
    "is this key good" check, so nothing here should ever surface as an
    unhandled 500."""
    import anthropic

    try:
        client = anthropic.Anthropic(api_key=api_key)
        client.messages.create(
            model=MODEL_ID,
            max_tokens=1,
            messages=[{"role": "user", "content": "ping"}],
        )
    except anthropic.AuthenticationError as exc:
        return False, f"Authentication failed: {exc}"
    except Exception as exc:  # anthropic.APIError and subclasses, network/client-construction errors
        return False, f"Claude API request failed: {exc}"
    return True, "Claude API key is valid."


@dataclass
class InvestigationResult:
    selected_playbook_id: Optional[uuid.UUID]
    rationale: str
    # Only set when the selected playbook is a command runner (freeform=true):
    # the exact single shell command the model proposes to run. It is NOT
    # executed as-is - a human reviews (and may edit) it, and it passes the
    # full guardrail engine (including the non-overridable forbidden-command
    # baseline) before anything runs. See app/routers/remediation.py.
    suggested_command: Optional[str] = None


def _build_tool(playbook_ids: list[str]) -> dict:
    return {
        "name": "select_playbook",
        "description": (
            "Select the single most appropriate remediation playbook for this ticket "
            "from the enabled playbooks provided, or NONE if none of them apply. "
            "If (and only if) the selected playbook has \"freeform\": true, also provide "
            "suggested_command with the exact single shell command that should be run to "
            "resolve the ticket - a human will review and may edit it before it executes."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "playbook_id": {
                    "type": "string",
                    "enum": playbook_ids + [NONE_SENTINEL],
                    "description": "The id of the selected playbook, or the literal string NONE.",
                },
                "rationale": {
                    "type": "string",
                    "description": "One or two sentences explaining why this playbook (or NONE) was selected.",
                },
                "suggested_command": {
                    "type": "string",
                    "description": (
                        "Only when the selected playbook has freeform=true: the exact single shell "
                        "command to run. Omit for any normal (fixed-command) playbook."
                    ),
                },
            },
            "required": ["playbook_id", "rationale"],
        },
    }


def investigate(
    *,
    client,
    ticket_context: dict,
    asset_context: Optional[dict],
    playbooks: list[dict],
) -> InvestigationResult:
    """`playbooks` must already be filtered to the tenant's currently
    *enabled* playbooks - the tool's enum is built directly from whatever is
    passed in here, so an un-filtered list would let the model select a
    disabled playbook. Each entry: {id, name, allowed_target_os,
    required_approval_level}."""
    if not playbooks:
        return InvestigationResult(
            selected_playbook_id=None, rationale="No enabled playbooks are configured for this tenant."
        )

    playbook_ids = [str(p["id"]) for p in playbooks]
    tool = _build_tool(playbook_ids)

    playbook_summaries = [
        {
            "id": str(p["id"]),
            "name": p["name"],
            "allowed_target_os": p["allowed_target_os"],
            "required_approval_level": p["required_approval_level"],
            "freeform": bool(p.get("freeform")),
        }
        for p in playbooks
    ]

    has_client_context = bool(asset_context and asset_context.get("client_context"))
    client_context_guidance = (
        "The linked asset includes a \"client_context\" note written by an operator - this is "
        "authoritative context about the client, the asset's operational role, and any handling "
        "constraints (e.g. maintenance windows, do-not-reboot rules, SLA sensitivity). Read it "
        "carefully and let it govern your choice: prefer a safer or read-only playbook, or select "
        "NONE (deferring to a human), if the client_context indicates the available fixes could be "
        "disruptive or unsafe for this particular asset. Reflect how it shaped your decision in the "
        "rationale.\n\n"
        if has_client_context
        else ""
    )

    prompt = (
        "You are triaging an IT support ticket to select the correct automated remediation "
        "playbook. Consider the ticket details, the linked asset (if any), and each playbook's "
        "allowed target OS. Call select_playbook with your choice.\n\n"
        f"{client_context_guidance}"
        f"Ticket:\n{ticket_context}\n\n"
        f"Asset:\n{asset_context or 'No asset linked to this ticket.'}\n\n"
        f"Enabled playbooks:\n{playbook_summaries}"
    )

    try:
        response = client.messages.create(
            model=MODEL_ID,
            max_tokens=1024,
            tools=[tool],
            tool_choice={"type": "tool", "name": "select_playbook"},
            messages=[{"role": "user", "content": prompt}],
        )
    except InvestigatorError:
        raise
    except Exception as exc:  # anthropic.APIError and subclasses
        raise InvestigatorError(f"AI investigation request failed: {exc}") from exc

    tool_use = next((block for block in response.content if block.type == "tool_use"), None)
    if tool_use is None:
        raise InvestigatorError("AI investigation did not return a playbook selection.")

    selection = tool_use.input.get("playbook_id")
    rationale = tool_use.input.get("rationale") or ""
    suggested_command = tool_use.input.get("suggested_command") or None

    if selection == NONE_SENTINEL or selection not in playbook_ids:
        return InvestigationResult(selected_playbook_id=None, rationale=rationale or "No suitable playbook was found.")

    return InvestigationResult(
        selected_playbook_id=uuid.UUID(selection), rationale=rationale, suggested_command=suggested_command
    )