Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,14 +336,16 @@ Audit-Codes folgen dem Muster `VERB_NOMEN` und sind **zentral** in `NodePilot.Co

Opt-in (`Llm:Enabled=false` default), OpenAI-kompatibler Endpunkt, Rate-Limit 20/min/IP. Drei Helfer + eine Activity; Details: `docs/claude-reference.md` + `docs/ai-features.md`.

**LLM-Proxy:** `Llm:Proxy:Mode` = `Off` (default, Direktverbindung) | `System` (Proxy des Dienstkontos) | `Custom` (`Address` + `BypassList`-Globs), dazu `Username`/`Password` bzw. `UseDefaultCredentials`. Ein Block für die ganze Installation, gilt für alle LLM-Aufrufe inkl. Test-Button. Sitzt bewusst **nicht** im `SocketsHttpHandler`, sondern in `LlmConfiguredProxy : IWebProxy` (liest `IOptionsMonitor` pro Request) — nur deshalb bleibt die Sektion hot-reloadable. Mit Proxy sieht `LlmConnectGuard` nur noch den Proxy-Endpunkt; Details + Begründung: `docs/claude-reference.md`.

**LLM-Profile:** Verbindungen liegen als benannte Profile unter `Llm:Profiles:<id>` (Objekt gekeyt nach unveränderlicher Id, kein Array — Secret-Erhalt matcht per Id und übersteht Rename/Reorder). `Llm:ActiveProfileId` wählt das eine aktive Profil; global bleiben nur diese beiden Keys, alles Verbindungsförmige inkl. `EnableToolCalling`/`ToolCallMaxDepth` sitzt im Profil. Kein „nimm das erste"-Fallback: passt nichts → 503 `LLM_NO_ACTIVE_PROFILE` (Boot läuft trotzdem, nur Warning). Ausgeliefert wird `"Profiles": {}` — ein Profil in der Basis-Config wäre über die UI nie löschbar (additive Provider-Kette), Delete-Versuch → 400 `LLM_PROFILE_NOT_DELETABLE`. **Keine scoped `ILlmClient`-Registrierung** (würde vor dem Action-Gate auflösen); Consumer nehmen `ILlmClientFactory`.

- **`POST /api/ai/generate-script`** (Admin/Op, SSE-Streaming — tippt live in Monaco) + **`POST /api/ai/generate-workflow`** (Admin/Op, JSON).
- **`POST /api/ai/chat`** (alle Rollen, SSE) — Workflow-Assistent: erklärt/ändert den aktuellen Workflow; Proposals nur Admin/Op, Merge per Node-ID aufs unredigierte Original (Secrets/Layout erhalten). Secrets werden vor jedem LLM-Call redigiert (`WorkflowSecretRedactor`). **Tool-Calling** opt-in am aktiven Profil (`Llm:Profiles:<id>:EnableToolCalling`): read-only Analyse- + Execution-Log-Tools, gecappt via `ToolCallMaxDepth` desselben Profils. Threads/Verlauf/Export clientseitig persistent.
- **Globaler AI-Chat / Wissens-Assistent** (`POST /api/ai/knowledge/ask`, SSE; `GET /api/ai/knowledge/capabilities`) — seitenweiter read-only Q&A in `/ai-chat`, canvas-frei. Vier admin-toggelbare Wissensquellen (Sektion `AiKnowledge`, hot-reloadbar, alle `false`-default außer Docs/Operational): **Docs** (`DocsEnabled`), **Operational** (`OperationalEnabled`, RBAC-folder-gescoped — liefert nur die Workflow-spezifische **Definition** (`get_workflow_definition`, secret-redigiert), **statische Analyse** (`analyze_workflow`) und **Cron-Voraussage** (`get_next_scheduled_fires`); reine Listen wie "welche Workflows/Läufe/Maschinen gibt es" werden über die DB-Quelle per text2sql beantwortet), **Source-Code** (`SourceCodeEnabled`, Admin/Op), **DB / text2sql** (`DbEnabled`, Admin/Op). DB-Tools (`list_db_tables`/`get_db_table`/`execute_readonly_sql`) über `ISqlKnowledgeReader`: Schema inkl. Provider/FKs ohne Secret-Spalten; zentraler Executor-Guard (64 KiB, Single-Statement, Read-only-Whitelist + Dangerous-Token/Routine-Block), geschützte Spaltenreferenzen vor Ausführung abgelehnt, Result-Masking + `IAuditDetailsRedactor`, Row-Cap 200, valides Truncation-JSON. DB-Tools Strict mit Best-Effort-Fallback; Audit nur Query-Anzahl/Fingerprint. Sources sind nur sichtbar, wenn das aktive Profil `EnableToolCalling` gesetzt hat.
- **`llmQuery`-Activity:** Engine-lokal, Prompt→Text; per-Node-Overrides `baseUrl`/`model`/`apiKey`/`maxTokens`/`temperature`/`timeoutSeconds`/`jsonMode`, **gated durch `Llm:Enabled`** (zentraler Kill-Switch). Teilt Transport + SSRF-Guard via `ILlmClientFactory`; einziger BaseUrl-Validierungspunkt ist `LlmEndpointGuard`.
- **Zwei Wire-Dialekte, kein Config-Key:** `LlmEndpointGuard.ResolveEndpoint` leitet aus dem `BaseUrl`-Pfad ab, wohin gepostet wird und wer antwortet — `…/responses` → `OpenAiResponsesLlmClient` (OpenAI Responses API), sonst `OpenAiCompatibleLlmClient`; endet der Pfad schon auf `/chat/completions`, wird **nichts** mehr angehängt. Gemeinsames HTTP-Plumbing in `LlmHttpTransport`. Die vier Quirk-Fallbacks (`max_tokens`→`max_completion_tokens`, `stream_options`, `response_format`, `strict`) sind Chat-Completions-only und im Responses-Client bewusst nicht vorhanden; dieser sendet immer `store: false`.
- **Hardening:** SSRF-Block (Cloud-Metadata), `UseProxy=false`, Klartext-ApiKey-Warning, Prompt-Injection-Mitigation (Schema-only, User-reviewed Insert). Drift-Schutz: `PromptCatalogDriftTest.cs`. Audit: `AI_*`-Codes.
- **Hardening:** SSRF-Block (Cloud-Metadata), Proxy nur nach Opt-in (`Llm:Proxy:Mode`, default `Off`), Klartext-ApiKey-/Proxy-Passwort-Warning, Prompt-Injection-Mitigation (Schema-only, User-reviewed Insert). Drift-Schutz: `PromptCatalogDriftTest.cs`. Audit: `AI_*`-Codes.

## Workflow Import/Export

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,12 @@ All settings live in [`src/NodePilot.Api/appsettings.json`](src/NodePilot.Api/ap
| `Llm:Profiles:<id>:TimeoutSeconds` | `90` | HTTP timeout |
| `Llm:Profiles:<id>:EnableToolCalling` | `false` | Enable chat read-only tool-calling (function-calling loop). Per profile — reliable function-calling is a property of the model |
| `Llm:Profiles:<id>:ToolCallMaxDepth` | `6` | Tool-loop depth cap (max LLM rounds with tool calls per turn, 1–10) |
| `Llm:Proxy:Mode` | `Off` | Outbound proxy for every LLM call. `Off` = direct, `System` = the proxy the service account's OS is configured with (incl. its own bypass rules), `Custom` = `Llm:Proxy:Address` |
| `Llm:Proxy:Address` | `""` | Proxy URL, e.g. `http://proxy.corp.local:8080`. Required for `Custom`, ignored otherwise |
| `Llm:Proxy:BypassList` | `[]` | Hosts reached directly, shell globs (`localhost`, `*.corp.local`). `Custom` only — `System` uses the OS bypass rules |
| `Llm:Proxy:Username` | `null` | Proxy Basic-auth user |
| `Llm:Proxy:Password` | `null` | Proxy password; prefer the env var `Llm__Proxy__Password` |
| `Llm:Proxy:UseDefaultCredentials` | `false` | Authenticate to the proxy with the service account's Windows credentials (NTLM/Kerberos) instead of user/password |

### Production deployment (set by the installer)

Expand Down
37 changes: 35 additions & 2 deletions docs/ai-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ Neu-Eintippen.
"MaxTokens": 32768,
"TimeoutSeconds": 300
}
},
"Proxy": {
"Mode": "Custom",
"Address": "http://proxy.firma.local:8080",
"BypassList": ["localhost"],
"UseDefaultCredentials": true
}
}
}
Expand All @@ -100,8 +106,35 @@ Neu-Eintippen.
| `EnableToolCalling` | `false` | Opt-in. Lässt die Chat-Assistenten read-only Analyse-Tools per OpenAI-Function-Calling callen (`tool_choice: auto`). Braucht ein Modell, das Function-Calling zuverlässig kann — viele kleine lokale Modelle nicht. **Pro Profil**, weil das eine Eigenschaft des Modells ist, nicht der Installation: beim Umschalten auf ein kleines lokales Modell wandert die Fähigkeit mit. |
| `ToolCallMaxDepth` | `6` | Max LLM-Runden mit Tool-Calls pro Chat-Turn (Loop-Guard, gültig 1–10). Lässt bei text2sql nach Schema-Discovery noch Raum für SQL-Korrekturen. In der letzten erlaubten Runde sendet der Server **keine** `tools` → erzwingt eine Text-Antwort. |

**Restart erforderlich**: nein — die Sektion ist hot-reloadable. Ein Save in der Admin-UI (inkl.
Profilwechsel) greift beim nächsten Aufruf.
**Outbound-Proxy (`Llm:Proxy:*`):**

Gilt für **alle** ausgehenden LLM-Aufrufe — Script-/Workflow-Generierung, beide Chats, die
`llmQuery`-Activity und den „Testen"-Button in den Settings. Ein Block pro Installation, nicht pro
Profil: der Fall „Cloud-Profil über den Proxy, lokales Ollama direkt" wird über `BypassList`
gelöst, und ein Handler bedeutet einen Connection-Pool.

| Key | Default | Erklärung |
|---|---|---|
| `Mode` | `Off` | `Off` = Direktverbindung (Verhalten vor Einführung des Proxys). `System` = der Proxy, mit dem das **Dienstkonto** konfiguriert ist (Windows: WinHTTP/WinINET), inklusive dessen eigener Ausnahmeliste. `Custom` = `Address` unten. |
| `Address` | `""` | Proxy-URL, z. B. `http://proxy.firma.local:8080`. **Pflicht bei `Custom`**, sonst ignoriert. Ein leerer Wert bei `Custom` wird schon beim Speichern abgelehnt, nicht erst beim nächsten Start. |
| `BypassList` | `[]` | Hosts, die am Proxy vorbei erreicht werden. Shell-Globs erlaubt (`localhost`, `*.intern`, `10.0.0.1`). Nur bei `Custom` — bei `System` gilt die Ausnahmeliste des Betriebssystems, weil ein Mischbetrieb die Frage „warum ging das nicht über den Proxy" unbeantwortbar machen würde. |
| `Username` | `null` | Für Proxies mit Basic-Auth. |
| `Password` | `null` | Verschlüsselt gespeichert wie jedes andere Settings-Secret. Klartext in der Config löst eine Startup-Hardening-Warnung aus; **empfohlen: Env-Var `Llm__Proxy__Password`**. |
| `UseDefaultCredentials` | `false` | Authentifiziert mit den Windows-Anmeldedaten des Dienstkontos (NTLM/Kerberos) statt mit `Username`/`Password` — der Normalfall bei domänenintegrierten Unternehmens-Proxies. Gilt für `System` **und** `Custom`. |

> **Sicherheitshinweis.** Sobald ein Proxy im Pfad liegt, löst **er** das Ziel-DNS auf, nicht mehr
> NodePilot. Der Connect-Zeit-SSRF-Guard (`LlmConnectGuard`) sieht dann nur noch den
> Proxy-Endpunkt; das Ziel ist weiterhin durch die Literal-Prüfung der `BaseUrl` geschützt, die bei
> jedem Speichern und beim Boot läuft. Bewusst **keine** Pflicht-Allow-Liste wie bei `restApi`: die
> LLM-`BaseUrl` ist ein einzelner, Admin-only konfigurierter Wert und keine aus Trigger-Payloads
> zusammengesetzte Per-Step-URL.

**Restart erforderlich**: nein — die Sektion ist hot-reloadable, inklusive `Llm:Proxy:*`. Ein Save
in der Admin-UI (inkl. Profilwechsel und Proxy-Umstellung) greift beim nächsten Aufruf. Der Proxy
wird pro Request aus der laufenden Konfiguration aufgelöst statt beim Bau des HTTP-Handlers —
genau deshalb bleibt die Sektion hot-reloadable, wo `RestApi` (Proxy fest im Handler) es nicht ist.
Eine Ausnahme bleibt `Mode: System`: Änderungen an den **Windows-Proxy-Einstellungen** selbst
greifen erst nach einem Dienst-Neustart, weil .NET die Systemkonfiguration prozessweit cacht.

### Wire-Dialekt (aus der `BaseUrl` abgeleitet)

Expand Down
8 changes: 7 additions & 1 deletion docs/claude-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,9 +356,15 @@ Background-Service-, Konfigurations- und Observability-Vertrag stehen vollständ
- **Nested-DTO-Validierung ist Handarbeit**: `Validator.TryValidateObject` rekursiert nicht in Collection-Elemente, `LlmSettingsDto.Validate` validiert daher jedes Profil explizit und meldet `Profiles[i].Feld`.
- **Dynamische ConfigKeys**: Die Llm-Adapter-Keys hängen von den Profil-Ids ab → `DelegateSettingsSectionAdapter` hat dafür einen `Func<IReadOnlyList<string>>`-Overload.

**Outbound-Proxy (`Llm:Proxy:*`)**: `Mode` = `Off` (default, Direktverbindung) | `System` (Proxy des **Dienstkontos**, Windows WinHTTP/WinINET inkl. dessen Bypass-Regeln) | `Custom` (`Address` + `BypassList`-Globs). Dazu `Username`/`Password` bzw. `UseDefaultCredentials` (NTLM/Kerberos, schlägt einen expliziten User). Ein Block pro Installation, nicht pro Profil — der Mischfall „Cloud über Proxy, lokales Ollama direkt" ist genau der Zweck der Bypass-Liste. Doku: [`ai-features.md`](ai-features.md).
- **Warum die Sektion trotzdem hot-reloadable bleibt**: Der Proxy sitzt **nicht** im `SocketsHttpHandler` (der wird einmal pro Handler-Lebensdauer gebaut — deshalb ist `RestApi` restart-pflichtig), sondern in `LlmConfiguredProxy : IWebProxy`, das `IOptionsMonitor<LlmOptions>.CurrentValue` **pro Request** liest. Der Handler trägt fix `UseProxy = true`; `Mode: Off` beantwortet `IsBypassed` für jedes Ziel mit `true` und ist damit verhaltensgleich mit dem früheren `UseProxy = false`.
- **Sicherheitsgrenze**: Mit Proxy im Pfad löst der Proxy das Ziel-DNS auf → `LlmConnectGuard.ConnectAsync` sieht nur noch den Proxy-Endpunkt, der Connect-Zeit-Schutz gegen Link-Local/Metadata deckt das **Ziel** nicht mehr ab. Bleibt: die Literal-Prüfung der `BaseUrl` in `LlmProfileValidation`, die bei jedem Save *und* beim Boot läuft. Bewusst **ohne** Pflicht-Allow-Liste (anders als `RestApi:AllowedHosts`) — die LLM-`BaseUrl` ist ein einzelner Admin-only-Wert, keine aus Trigger-Payloads gebaute Per-Step-URL.
- **Validierung an einer Stelle**: `LlmProfileValidation.ValidateProxy` (Custom-ohne-Adresse, Nicht-http(s), Metadata-Adresse) wird von `AddNodePilotAi` *und* `LlmConfigBootValidator` gefahren — ein Save, der durchgeht, kann den nächsten Boot nicht blockieren. Die Bypass-Globs teilen sich Engine und Ai über `NodePilot.Core.Net.ProxyBypassPattern`.
- **`Mode: System` cacht**: `HttpClient.DefaultProxy` liest die OS-Konfiguration prozessweit einmal — eine Änderung der Windows-Proxy-Einstellungen greift erst nach Dienst-Neustart. Das ist die einzige nicht-hot-reloadbare Ecke der Sektion.

**Hardening**:
- SSRF-Block für Cloud-Metadata-IPs in **jeder** `Llm:Profiles:<id>:BaseUrl` (nicht nur der aktiven — Profilwechsel ist ein restart-freier Save). Eine geteilte Regel für Boot *und* Save-Simulation: [LlmProfileValidation.cs](src/NodePilot.Ai/LlmProfileValidation.cs), aufgerufen von `AddNodePilotAi` und `LlmConfigBootValidator`. Einziger BaseUrl-Validierungspunkt bleibt [LlmEndpointGuard.cs](src/NodePilot.Ai/LlmEndpointGuard.cs) (`NormalizeAndValidateBaseUrl`/`IsCloudMetadataEndpoint`), plus Connect-Zeit-Guard `LlmConnectGuard` in [LlmServiceCollectionExtensions.cs](src/NodePilot.Ai/LlmServiceCollectionExtensions.cs). `Enabled=true` ohne auflösbares Profil ist bewusst nur eine **Warning** — KI ist opt-in und darf den Boot nicht blockieren.
- Fresh `SocketsHttpHandler` mit `UseProxy=false` (NICHT der `RestApiHttpClientProvider` — der hat SSRF-Guards die `127.0.0.1:11434` blocken würden)
- Eigener `SocketsHttpHandler` (NICHT der `RestApiHttpClientProvider` — der hat SSRF-Guards die `127.0.0.1:11434` blocken würden). Proxy-Verhalten kommt aus `Llm:Proxy:*` über `LlmConfiguredProxy`, default `Off` = Direktverbindung.
- Klartext-ApiKey je Profil löst Startup-Hardening-Warning aus, analog `Smtp:Password` ([SecurityHardeningWarnings.cs](src/NodePilot.Api/Hosting/SecurityHardeningWarnings.cs))
- `SettingsSchema.IsUnchangedSecretValue` behandelt `__unchanged__` **und** die Anzeige-Maske `"********"` als „unverändert" — vorher hätte ein Client, der die GET-Antwort zurück-PUTet, die Maske als neuen Key verschlüsselt und den echten still zerstört (gilt jetzt für alle Sektionen, auch `Smtp:Password`).

Expand Down
Loading
Loading