From bdb212744cd4f9ae1ba3ba0eb139b78e3f6d53cb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 9 Jun 2026 09:43:21 +0100 Subject: [PATCH] Remove the cloud_context_privacy dial; fix cloud context to a lean posture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer decision: the cloud-context-privacy feature was bloat. The real fix for the "useless Assistant on cloud" problem was the earlier sensitivity recalibration (ordinary workloads = Internal, not redacted); the dial layered a configurable knob on top of an already-solved problem, guarding mostly-non-secret data on a destination the operator opted into, and demanded every model-bound path stay dial-aware (a standing leak surface). The privacy control users actually understand is the choice of model — cloud provider vs. local Ollama. Removed entirely: - AIConfig.CloudContextPrivacy dial + constants + GetCloudContextPrivacy / NormalizeCloudContextPrivacy, AND the now-dead legacy ShareOperationalContextWithCloud boolean + ShouldShareOperationalContextWithCloud (internal/config/ai.go); the config-load migration (persistence.go). - Both fields from the /api/settings/ai request/response, validation, and sync (ai_handlers.go) + the JSON contract snapshots. - The "Cloud model privacy" 3-option UI control, form field, presentation copy, and CloudContextPrivacy type (frontend), plus their tests. - The dial branching in the seam: chat/service.go cloudPrivacyLevel, CloudContextPolicy.Level + local_only suppression + the localOnly directive (context_prefetch.go), the inventory resourceLabel dial logic (resource_context*), and the modelboundary RedactLocalOnlyResourcesOnly option. Fixed lean posture (no setting): a cloud-routed model receives real infrastructure context, with two always-on invariants enforced by the model-boundary sanitizer — credentials are always stripped, and local-only/Restricted resources (the floor) never leave the local trust boundary. Local (Ollama) always full. The sanitizer's default is now the local-only floor; it remains the universal backstop installed on EVERY model-bound path (chat, session compaction, discovery/report/analysis via the shared helper). Kept the two standalone fixes from this effort: compaction now routes through the sanitizer, and directives no longer inject the "redacted by policy" placeholder. Governance: ai-runtime contract rewritten to a fixed-posture rule; api-contracts / frontend-primitives / agent-lifecycle / storage-recovery dial references removed. Tests updated to the floor-only behavior (local-only redacted, Sensitive flows, secrets stripped). Full internal/ai/..., config, api suites green; frontend type-check + tests + lint green. --- .../v6/internal/subsystems/agent-lifecycle.md | 9 +- .../v6/internal/subsystems/ai-runtime.md | 115 +++-------- .../v6/internal/subsystems/api-contracts.md | 16 +- .../subsystems/frontend-primitives.md | 21 +- .../internal/subsystems/storage-recovery.md | 9 +- .../Settings/AIRuntimeControlsSection.tsx | 34 +--- .../__tests__/settingsArchitecture.test.ts | 20 -- .../components/Settings/useAISettingsState.ts | 12 +- frontend-modern/src/types/ai.ts | 17 -- .../__tests__/aiSettingsPresentation.test.ts | 39 ---- .../src/utils/aiSettingsPresentation.ts | 59 ------ internal/ai/chat/context_prefetch.go | 61 +----- .../context_prefetch_cloud_context_test.go | 85 ++------ internal/ai/chat/service.go | 19 -- .../chat/service_execute_additional_test.go | 16 +- internal/ai/chat/session_compaction.go | 20 +- internal/ai/chat/session_compaction_test.go | 2 +- .../resource_policy_sanitizer.go | 41 ++-- .../resource_policy_sanitizer_test.go | 41 ++-- internal/ai/request_sanitizer_dial_test.go | 68 ------- internal/ai/resource_context.go | 42 ++-- internal/ai/resource_context_policy_model.go | 56 +----- .../ai/resource_context_policy_model_test.go | 73 +------ internal/ai/service.go | 18 +- internal/api/ai_handlers.go | 123 ++++-------- internal/api/ai_handlers_test.go | 187 ------------------ internal/api/contract_test.go | 12 -- internal/config/ai.go | 90 --------- internal/config/ai_config_test.go | 96 --------- internal/config/persistence.go | 22 +-- internal/config/persistence_ai_test.go | 76 ------- 31 files changed, 182 insertions(+), 1317 deletions(-) delete mode 100644 internal/ai/request_sanitizer_dial_test.go diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index cc6dcfda5..0f9553545 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1662,11 +1662,10 @@ Visible `stream_idle` workflow progress on that same legacy Assistant SSE route, and on `/api/ai/execute/stream`, is likewise Assistant/API transport liveness only. It must not be reused as agent heartbeat, enrollment progress, installer status, command websocket liveness, or fleet freshness evidence. -The `/api/settings/ai` `cloud_context_privacy` dial (and the legacy -`share_operational_context_with_cloud` boolean it supersedes) is an Assistant -privacy/runtime setting governing how much infrastructure context reaches cloud -models; it is not agent enrollment config, installer readiness, command -reachability, or any fleet-control capability signal. +The model-boundary sanitizer that governs how much Assistant infrastructure +context reaches cloud models (credentials and local-only resources always +withheld) is an AI-runtime privacy concern; it is not agent enrollment config, +installer readiness, command reachability, or any fleet-control capability signal. Patrol finding chat handoffs follow the same ownership split: when `/api/ai/chat` resolves a `finding_id` into model-only Patrol briefing, resource, or action context, the backend-enforced `autonomous_mode:false` diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 97b063c5c..5e4fdf3e9 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -101,72 +101,37 @@ deriving an older display status from `workflowStatusHistory`. and `internal/ai/providers/ollama.go` is the only layer that turns it into the Ollama `keep_alive` request field. An empty configured value means Pulse omits `keep_alive` so the Ollama server default applies. - Cloud context privacy is a runtime privacy option owned by this path. The - canonical operator control is the `cloud_context_privacy` dial in - `internal/config/ai.go` (`AIConfig.CloudContextPrivacy`, normalized through - `GetCloudContextPrivacy` / `NormalizeCloudContextPrivacy`) with three levels: - `full` (default), `redacted`, and `local_only`. It supersedes the legacy - boolean `AIConfig.ShareOperationalContextWithCloud` - (`ShouldShareOperationalContextWithCloud`), which is retained only for API - back-compat and is no longer read by the redaction seam. `/api/settings/ai` - keeps the legacy boolean in sync with the dial (`full` → true, - `redacted`/`local_only` → false), and `internal/config` config load migrates - the dial out of the legacy boolean for pre-dial configs (legacy on → `full`, - off/absent → `redacted`) without mutating the boolean, so existing installs - keep their current cloud behavior. A fresh install defaults to `full` so a - self-hosted Pulse answers with real resource detail out of the box. - The redaction seam reads the dial directly across THREE model-bound paths, - each resolving the dial and failing closed to `redacted` when no config - snapshot is available: the proactive prefetch and the model-boundary sanitizer - (`internal/ai/chat/service.go`, `cloudPrivacyLevel` once per turn), AND the - broad inventory-context builder (`internal/ai/resource_context.go` - `buildUnifiedResourceContextForModel` → `unifiedResourcePolicyContext.resourceLabel`). - The inventory builder MUST render resource display names through the dial, not - the unconditional `unifiedresources.ResourcePolicyLabel`: known-local (Ollama) - destinations always get real names; cloud destinations get real names only at - `full` and only for resources not routed `ResourceRoutingScopeLocalOnly` (the - same hard floor); an unknown/empty destination fails closed to the governed - label. All three paths enforce the same posture: - - `full`: the model-bound resource-policy sanitizer is invoked with - `modelboundary.RedactLocalOnlyResourcesOnly()`, so real identifiers (hostname, - IP, alias, name) for ordinary (Internal) and Sensitive (local-first) resources - reach the cloud model, while resources the policy engine routes - `ResourceRoutingScopeLocalOnly` (Restricted) stay redacted as a HARD FLOOR a - blanket dial must never override. Prompt-secret sanitation (credentials) always - runs — credentials never cross the boundary at any level. - - `redacted`: the sanitizer redacts every policied resource's identifiers as - before, and `internal/ai/chat/context_prefetch.go` surfaces the PII-free - operational context from `servicediscovery.FormatCloudSafeContext` (service - identity, access command, config/data/log paths, ports) for governed - resources, allow-listed through `modelboundary.AllowResourcePolicyText` so it - is not re-stripped. Identifying fields stay redacted. - - `local_only`: `context_prefetch.go` injects NO proactive infrastructure - context for the cloud turn (only a transparency directive telling the - Assistant to disclose the withholding and point at the `Cloud model privacy` - setting / a local model), and the sanitizer still redacts identifiers as a - backstop for any context arriving via tool results, handoff text, or user text. - Local (Ollama) routing is unaffected and always receives full context - (`RequestSanitizerForModel` returns nil for local models). + Cloud context privacy is a FIXED posture, not a user setting. Pulse is a + self-hosted homelab/SMB tool: when the operator points the Assistant at a cloud + model they have accepted that their (non-secret) infrastructure detail reaches + that provider, so the cloud model receives real resource context (names, IPs, + config) and the Assistant is actually useful. There is intentionally NO + `cloud_context_privacy` dial and NO `share_operational_context_with_cloud` + toggle — the privacy control users understand is the choice of model (a cloud + provider vs. a local Ollama model). Granular per-resource governance is an + enterprise concern, not the homelab default. + Two invariants always hold at the model boundary and are NOT configurable: + 1. Prompt-secret sanitation strips credentials (API keys, passwords, tokens) — + credentials never reach a cloud model. + 2. The local-only floor: resources the policy engine routes + `ResourceRoutingScopeLocalOnly` (Restricted sensitivity — tagged secret/pii, + PMG, k8s-secrets, ...) have their identifiers redacted and never leave the + local trust boundary. Everything else (Internal and Sensitive) flows. + Both are enforced by the model-boundary sanitizer + `modelboundary.RequestSanitizerForModel(model, provider)` (`internal/ai/modelboundary`), + which returns nil for local (Ollama) models — local always receives full context. UNIVERSAL backstop rule: EVERY code path that sends infrastructure-derived - content to an external model MUST install the dial-aware - `modelboundary.RequestSanitizerForModel` (with `RedactLocalOnlyResourcesOnly()` - at the `full` dial) before the provider request — not only the interactive - agentic loop. This explicitly includes session compaction - (`internal/ai/chat/session_compaction.go` `SummarizeSession`), which sends the - PERSISTED transcript (original user prompts and tool outputs carry raw - identifiers regardless of how the live turns were redacted). A new model-bound - request path that skips the sanitizer is a leak and is not permitted. (Static - capability probes with no resource content, e.g. the Patrol preflight self-test, - are exempt only because their payload is fixed and carries no identifiers.) - The dial-aware option (`RedactLocalOnlyResourcesOnly()` at `full`) is not only - the chat seam's concern: the shared service helper - `(*Service).requestSanitizerForModel` (`internal/ai/service.go`), used by + content to an external model MUST install `RequestSanitizerForModel` before the + provider request — not only the interactive agentic loop. This explicitly + includes session compaction (`internal/ai/chat/session_compaction.go` + `SummarizeSession`), which sends the PERSISTED transcript (original user prompts + and tool outputs carry raw identifiers), and the shared service helper + `(*Service).requestSanitizerForModel` (`internal/ai/service.go`) used by discovery analysis, the report and fleet narrators, quick analysis, and the - ExecuteAgentic paths, MUST resolve the dial and pass that option at `full` too — - otherwise those non-chat paths silently over-redact governed resources even when - the operator chose `full` (e.g. discovery cannot identify a governed service). - Honoring the dial there is functional parity, and the local-only floor still - protects must-not-leave resources. + ExecuteAgentic paths. A new model-bound request path that skips the sanitizer is + a leak and is not permitted. (Static capability probes with no resource content, + e.g. the Patrol preflight self-test, are exempt only because their payload is + fixed and carries no identifiers.) Redaction-placeholder hygiene: Pulse-authored model-bound directives (the resource-context handoff instructions in `internal/ai/chat/service.go` and `internal/ai/chat/plain_text_resource_context.go`) must NOT inject the literal @@ -176,28 +141,6 @@ deriving an older display status from `workflowStatusHistory`. ("a withheld or placeholder label") and must instruct the model not to repeat a withheld placeholder back to the user as the resource identity; the `current_resource` handle remains the authoritative target. - The dial must be operator-reachable, not config-file-only. `/api/settings/ai` - round-trips `cloud_context_privacy` field-by-field exactly like - `discovery_enabled`: `internal/api/ai_handlers.go` always serializes the - current value on the settings response from `settings.GetCloudContextPrivacy()` - (no `omitempty`, so a 3-option control can bind to the concrete value), - validates the optional request `*string` against - `config.NormalizeCloudContextPrivacy` (rejecting unknown values with 400), - leaves the persisted dial untouched when the field is omitted, and syncs the - legacy boolean from the chosen level. The operator surface is the `Cloud model - privacy` 3-option control in the Assistant runtime controls - (`frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx`), - bound to the canonical `useAISettingsState` form and the `AISettings` / - `AISettingsUpdateRequest` payload contract in - `frontend-modern/src/types/ai.ts` (consumed by - `frontend-modern/src/api/ai.ts`). The dial defaults to `full`, carries the - privacy-level and Ollama-always-full-context caveats in its help and per-option - copy (`getAISettingsCloudContextPrivacyHelpContent` / - `getAISettingsCloudContextPrivacyOptions` / - `getAISettingsCloudContextPrivacySummary` in - `frontend-modern/src/utils/aiSettingsPresentation.ts`), and must not be - reimplemented as a local-only browser flag or a bespoke fetch outside the - canonical settings payload. 3. Add or change Pulse Assistant request flow through `internal/api/ai_handler.go`, `frontend-modern/src/api/ai.ts`, and `frontend-modern/src/api/aiChat.ts` Assistant session compaction is a runtime-backed session workflow, not a local waiting message, transcript-only UI action, or stubbed summarize diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 9529abf9f..355eb3c1b 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -664,16 +664,12 @@ payload shape change when the portal presents compact client rows. then pacing the first backend `workflow_state` long enough for browser proof to verify immediate visible activity without opening a provider request. 34. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary. - The AI settings payload on `/api/settings/ai` round-trips the - `cloud_context_privacy` dial field-by-field alongside `discovery_enabled`: - the settings response always serializes the string (no `omitempty`) so the - operator UI can bind a 3-option control to its concrete value, the update - request carries it as an optional `*string` validated against the - `full`/`redacted`/`local_only` set (unknown values are rejected with 400), - and an omitted field leaves the persisted dial unchanged rather than - resetting it. The dial supersedes the legacy - `share_operational_context_with_cloud` boolean, which the handler keeps in - sync (`full` → true, otherwise false) and still serializes for back-compat. + The AI settings payload on `/api/settings/ai` carries no cloud-context-privacy + field: cloud context behavior is a fixed posture (real context to cloud, with + credentials and local-only resources always withheld), not a settings-payload + knob, so neither a `cloud_context_privacy` dial nor a + `share_operational_context_with_cloud` boolean is part of the request or + response contract. Legacy Assistant SSE routes in this handler that still use the older execute envelope, including `/api/ai/execute/stream` and `/api/ai/investigate-alert`, must preserve their existing top-level diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index cb2ca9c18..5e53c139c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -519,18 +519,15 @@ not a replacement status card, CTA band, or page-local nested card. such as workload discovery show the persisted option instead of falling back to the first option while the collapsed summary shows a different value. The Assistant runtime controls in - `frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx` — the - workload discovery `Toggle` and the `Cloud model privacy` 3-option dial — are - settings-shell chrome bound to the canonical `useAISettingsState` form and - `/api/settings/ai` payload, not local browser state. Each must bind to a - `state.form.*` field and round-trip through the field-by-field settings - payload: discovery via its `Toggle`, and cloud model privacy via the shared - `FormSelect` primitive bound to `state.form.cloudContextPrivacy` and the - `cloud_context_privacy` payload field (which mirrors `discovery_enabled`'s - field-by-field round-trip and defaults to `full`). Both must source their - label, help, option, and summary copy from - `frontend-modern/src/utils/aiSettingsPresentation.ts` rather than inlining - strings or reaching for a bespoke fetch outside the canonical payload. + `frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx` — e.g. the + workload discovery `Toggle` — are settings-shell chrome bound to the canonical + `useAISettingsState` form and `/api/settings/ai` payload, not local browser + state. Each must bind to a `state.form.*` field, round-trip through the + field-by-field settings payload, and source its label, help, and summary copy + from `frontend-modern/src/utils/aiSettingsPresentation.ts` rather than inlining + strings or reaching for a bespoke fetch outside the canonical payload. There is + no cloud-context-privacy control here: cloud context behavior is a fixed posture + (see `ai-runtime`), not an operator setting. 3. Add feature-specific presentation only when no shared primitive should own it. Feature surfaces under `frontend-modern/src/features/` that display product labels must consume the owning subsystem's presentation utilities diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 056c55a61..30a0ff1c4 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -582,11 +582,10 @@ recovery scope, or a storage/recovery-owned secret source. investigation streams, is likewise Assistant/API transport liveness only, not recovery acquisition progress, backup task freshness, restore readiness, provider health, or storage/recovery job status. - The `/api/settings/ai` `cloud_context_privacy` dial on that same handler - (and the legacy `share_operational_context_with_cloud` boolean it supersedes) - governs only how much Assistant infrastructure context reaches cloud models; - it is not a storage/recovery restore approval, backup freshness, - recovery-scope, or restore-command signal. + The AI-runtime model-boundary sanitizer that governs how much Assistant + infrastructure context reaches cloud models (credentials and local-only + resources always withheld) is not a storage/recovery restore approval, backup + freshness, recovery-scope, or restore-command signal. Patrol finding chat handoff execution controls in `internal/api/ai_handler.go` follow the same boundary: backend-forced `autonomous_mode:false` for `finding_id` handoffs with model-only Patrol briefing, resource, or action diff --git a/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx b/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx index 482fc550a..3acfc0ea4 100644 --- a/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx +++ b/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx @@ -1,6 +1,5 @@ -import { Component, For, Show } from 'solid-js'; +import { Component, Show } from 'solid-js'; import RefreshCwIcon from 'lucide-solid/icons/refresh-cw'; -import type { CloudContextPrivacy } from '@/types/ai'; import type { AIControlLevel } from '@/utils/aiControlLevelPresentation'; import type { AISettingsState } from '@/components/Settings/useAISettingsState'; import { HelpIcon } from '@/components/shared/HelpIcon'; @@ -15,10 +14,6 @@ import { } from '@/utils/aiControlLevelPresentation'; import { AI_SETTINGS_ASSISTANT_PERMISSIONS_TITLE, - AI_SETTINGS_CLOUD_CONTEXT_PRIVACY_LABEL, - getAISettingsCloudContextPrivacyHelpContent, - getAISettingsCloudContextPrivacyOptions, - getAISettingsCloudContextPrivacySummary, getAISettingsWorkloadDiscoveryHelpContent, getAISettingsWorkloadDiscoverySummary, } from '@/utils/aiSettingsPresentation'; @@ -158,33 +153,6 @@ export const AIRuntimeControlsSection: Component -
- - {AI_SETTINGS_CLOUD_CONTEXT_PRIVACY_LABEL} - - - } - value={state.form.cloudContextPrivacy} - onChange={(e) => - state.setForm('cloudContextPrivacy', e.currentTarget.value as CloudContextPrivacy) - } - disabled={state.saving()} - fieldBaseClass="flex flex-col gap-1.5" - labelClass="text-xs font-medium text-base-content" - selectBaseClass="min-h-10 sm:min-h-9 px-2 py-2 text-sm border border-border rounded bg-surface" - > - - {(option) => } - - -

- {getAISettingsCloudContextPrivacySummary(state.form.cloudContextPrivacy).text} -

-
-
{ expect(aiRuntimeControlsSectionSource).not.toContain("fetch('/api/discovery/run"); }); - it('keeps the cloud model privacy dial wired through the canonical settings state', () => { - // The "Cloud model privacy" dial must bind to the canonical AI settings - // form/store and round-trip the cloud_context_privacy API field rather than - // reaching for a bespoke fetch or local-only flag. - expect(aiRuntimeControlsSectionSource).toContain('AI_SETTINGS_CLOUD_CONTEXT_PRIVACY_LABEL'); - expect(aiRuntimeControlsSectionSource).toContain('getAISettingsCloudContextPrivacyHelpContent'); - expect(aiRuntimeControlsSectionSource).toContain('getAISettingsCloudContextPrivacyOptions'); - expect(aiRuntimeControlsSectionSource).toContain('state.form.cloudContextPrivacy'); - expect(aiRuntimeControlsSectionSource).toContain( - "state.setForm('cloudContextPrivacy', e.currentTarget.value as CloudContextPrivacy)", - ); - expect(aiSettingsStateSource).toContain('cloudContextPrivacy'); - expect(aiSettingsStateSource).toContain( - 'payload.cloud_context_privacy = form.cloudContextPrivacy;', - ); - expect(aiSettingsStateSource).toContain( - "cloudContextPrivacy: data.cloud_context_privacy ?? 'full',", - ); - }); - it('hydrates the Patrol preflight panel from the cached settings snapshot', () => { // The cached preflight outcome arrives on /api/settings/ai as // patrol_preflight; loadSettings and updateSettings must project it diff --git a/frontend-modern/src/components/Settings/useAISettingsState.ts b/frontend-modern/src/components/Settings/useAISettingsState.ts index 6265701aa..28b3c4c62 100644 --- a/frontend-modern/src/components/Settings/useAISettingsState.ts +++ b/frontend-modern/src/components/Settings/useAISettingsState.ts @@ -23,13 +23,7 @@ import { hasFeature, loadRuntimeCapabilities } from '@/stores/license'; import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; import { notificationStore } from '@/stores/notifications'; -import type { - AISettings as AISettingsType, - AIProvider, - AuthMethod, - CloudContextPrivacy, - ModelInfo, -} from '@/types/ai'; +import type { AISettings as AISettingsType, AIProvider, AuthMethod, ModelInfo } from '@/types/ai'; import { normalizeAIControlLevel, type AIControlLevel } from '@/utils/aiControlLevelPresentation'; import { getAIProviderDisplayName, getProviderFromModelId } from '@/utils/aiProviderPresentation'; import { @@ -309,7 +303,6 @@ export const useAISettingsState = () => { protectedGuests: '' as string, discoveryEnabled: false, discoveryIntervalHours: 0, - cloudContextPrivacy: 'full' as CloudContextPrivacy, }); const showUpgradePrompts = () => !presentationPolicyHidesUpgradePrompts(); @@ -385,7 +378,6 @@ export const useAISettingsState = () => { protectedGuests: '', discoveryEnabled: false, discoveryIntervalHours: 0, - cloudContextPrivacy: 'full' as CloudContextPrivacy, }); return; } @@ -418,7 +410,6 @@ export const useAISettingsState = () => { protectedGuests: Array.isArray(data.protected_guests) ? data.protected_guests.join(', ') : '', discoveryEnabled: data.discovery_enabled ?? false, discoveryIntervalHours: data.discovery_interval_hours ?? 0, - cloudContextPrivacy: data.cloud_context_privacy ?? 'full', }); const configured = new Set(); @@ -890,7 +881,6 @@ export const useAISettingsState = () => { payload.discovery_enabled = form.discoveryEnabled; payload.discovery_interval_hours = form.discoveryIntervalHours; - payload.cloud_context_privacy = form.cloudContextPrivacy; const updated = await AIAPI.updateSettings(payload); setSettings(updated); diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index c2afc8950..2840c3225 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -2,8 +2,6 @@ export type AIProvider = 'anthropic' | 'openai' | 'openrouter' | 'ollama' | 'deepseek' | 'gemini'; export type AuthMethod = 'api_key' | 'oauth'; -// Cloud context privacy dial - what infrastructure context cloud models may see. -export type CloudContextPrivacy = 'full' | 'redacted' | 'local_only'; export type PatrolReadinessStatus = 'ready' | 'warning' | 'not_ready'; export interface PatrolReadinessCheck { @@ -84,14 +82,6 @@ export interface AISettings { discovery_enabled?: boolean; discovery_interval_hours?: number; - // Cloud operational-context sharing - legacy boolean superseded by - // cloud_context_privacy; the backend keeps it in sync for back-compat. - share_operational_context_with_cloud?: boolean; - - // Cloud context privacy dial - canonical control for what infrastructure - // context cloud models may see. Always serialized by the backend. - cloud_context_privacy?: CloudContextPrivacy; - // Current Pulse Patrol runtime readiness for this settings snapshot patrol_readiness?: PatrolReadiness; // Most recent Patrol tool-call preflight result, recorded by Pulse so @@ -165,13 +155,6 @@ export interface AISettingsUpdateRequest { // AI Discovery settings discovery_enabled?: boolean; discovery_interval_hours?: number; - - // Cloud operational-context sharing - legacy boolean superseded by - // cloud_context_privacy; kept for back-compat with older API clients. - share_operational_context_with_cloud?: boolean; - - // Cloud context privacy dial - canonical control for cloud model context. - cloud_context_privacy?: CloudContextPrivacy; } export interface AITestResult { diff --git a/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts b/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts index fd80dc835..57aeaab50 100644 --- a/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts @@ -2,14 +2,10 @@ import { describe, expect, it } from 'vitest'; import { AI_SETTINGS_ASSISTANT_PERMISSIONS_TITLE, AI_SETTINGS_ASSISTANT_SESSIONS_TITLE, - AI_SETTINGS_CLOUD_CONTEXT_PRIVACY_LABEL, AI_SETTINGS_MODEL_OVERRIDES_TITLE, AI_SETTINGS_PANEL_DESCRIPTION, AI_SETTINGS_PANEL_TITLE, getAICredentialsClearErrorMessage, - getAISettingsCloudContextPrivacyHelpContent, - getAISettingsCloudContextPrivacyOptions, - getAISettingsCloudContextPrivacySummary, getAIOAuthErrorMessage, getAIChatSessionsEmptyState, getAIChatSessionsLoadErrorMessage, @@ -45,41 +41,6 @@ describe('aiSettingsPresentation', () => { expect(getAISettingsWorkloadDiscoverySummary()).toEqual({ text: 'Workload discovery stores concrete service context for Assistant chat and Patrol verification, so responses and findings can reference real services and commands instead of generic advice.', }); - expect(AI_SETTINGS_CLOUD_CONTEXT_PRIVACY_LABEL).toBe('Cloud model privacy'); - expect(getAISettingsCloudContextPrivacyHelpContent()).toEqual({ - title: 'What cloud models can see', - description: - 'When the Assistant runs on a cloud model (Anthropic, OpenAI, OpenRouter, and similar), this dial controls how much of your real infrastructure it includes in the request. Local models (Ollama) always receive full context regardless of this setting. Most self-hosted setups keep this on Full so the Assistant can answer with real detail; raise the privacy level if you would rather limit what leaves your network.', - }); - expect(getAISettingsCloudContextPrivacyOptions()).toEqual([ - { - value: 'full', - label: 'Full — answer with real detail', - description: - 'Cloud models receive your infrastructure context so the Assistant gives resource-specific answers instead of generic advice. Best for a private, self-hosted Pulse.', - }, - { - value: 'redacted', - label: 'Redacted — hide identifying detail', - description: - 'Cloud models receive operational context like service commands, paths, and ports, but identifying details such as hostnames, IP addresses, and aliases are removed before the request leaves Pulse.', - }, - { - value: 'local_only', - label: 'Local only — send nothing to cloud', - description: - 'Cloud models receive no infrastructure context. Switch the Assistant to a local model to get resource-specific answers.', - }, - ]); - expect(getAISettingsCloudContextPrivacySummary('full')).toEqual({ - text: 'Cloud models receive your infrastructure context so answers stay specific. Choose Redacted or Local only to limit what leaves Pulse.', - }); - expect(getAISettingsCloudContextPrivacySummary('redacted')).toEqual({ - text: 'Cloud models get cloud-safe operational details (commands, paths, ports); hostnames, IP addresses, and aliases are redacted.', - }); - expect(getAISettingsCloudContextPrivacySummary('local_only')).toEqual({ - text: 'No infrastructure context is sent to cloud models. Use a local model for resource-specific answers.', - }); expect(getAISettingsSetupDialogPresentation()).toEqual({ ariaLabel: 'Set up Assistant and Patrol', title: 'Set Up Assistant & Patrol', diff --git a/frontend-modern/src/utils/aiSettingsPresentation.ts b/frontend-modern/src/utils/aiSettingsPresentation.ts index ea7c36c95..4f01d64a9 100644 --- a/frontend-modern/src/utils/aiSettingsPresentation.ts +++ b/frontend-modern/src/utils/aiSettingsPresentation.ts @@ -1,5 +1,3 @@ -import type { CloudContextPrivacy } from '@/types/ai'; - export interface AISettingsReadinessPresentation { containerClassName: string; dotClassName: string; @@ -56,63 +54,6 @@ export function getAISettingsWorkloadDiscoverySummary() { } as const; } -export const AI_SETTINGS_CLOUD_CONTEXT_PRIVACY_LABEL = 'Cloud model privacy'; - -export function getAISettingsCloudContextPrivacyHelpContent() { - return { - title: 'What cloud models can see', - description: - 'When the Assistant runs on a cloud model (Anthropic, OpenAI, OpenRouter, and similar), this dial controls how much of your real infrastructure it includes in the request. Local models (Ollama) always receive full context regardless of this setting. Most self-hosted setups keep this on Full so the Assistant can answer with real detail; raise the privacy level if you would rather limit what leaves your network.', - } as const; -} - -export interface AISettingsCloudContextPrivacyOption { - value: CloudContextPrivacy; - label: string; - description: string; -} - -export function getAISettingsCloudContextPrivacyOptions(): readonly AISettingsCloudContextPrivacyOption[] { - return [ - { - value: 'full', - label: 'Full — answer with real detail', - description: - 'Cloud models receive your infrastructure context so the Assistant gives resource-specific answers instead of generic advice. Best for a private, self-hosted Pulse.', - }, - { - value: 'redacted', - label: 'Redacted — hide identifying detail', - description: - 'Cloud models receive operational context like service commands, paths, and ports, but identifying details such as hostnames, IP addresses, and aliases are removed before the request leaves Pulse.', - }, - { - value: 'local_only', - label: 'Local only — send nothing to cloud', - description: - 'Cloud models receive no infrastructure context. Switch the Assistant to a local model to get resource-specific answers.', - }, - ] as const; -} - -export function getAISettingsCloudContextPrivacySummary(value: CloudContextPrivacy) { - switch (value) { - case 'redacted': - return { - text: 'Cloud models get cloud-safe operational details (commands, paths, ports); hostnames, IP addresses, and aliases are redacted.', - } as const; - case 'local_only': - return { - text: 'No infrastructure context is sent to cloud models. Use a local model for resource-specific answers.', - } as const; - case 'full': - default: - return { - text: 'Cloud models receive your infrastructure context so answers stay specific. Choose Redacted or Local only to limit what leaves Pulse.', - } as const; - } -} - export function getAISettingsSetupDialogPresentation(): AISettingsSetupDialogPresentation { return { ariaLabel: 'Set up Assistant and Patrol', diff --git a/internal/ai/chat/context_prefetch.go b/internal/ai/chat/context_prefetch.go index 984329beb..62f66c342 100644 --- a/internal/ai/chat/context_prefetch.go +++ b/internal/ai/chat/context_prefetch.go @@ -7,46 +7,25 @@ import ( "strings" "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" - "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rs/zerolog/log" ) -// CloudContextPolicy governs how much governed-resource context reaches a -// cloud-routed model for this turn, driven by the AIConfig.CloudContextPrivacy -// dial. The zero value (no cloud routing) preserves the local-model behavior of -// injecting full context. +// CloudContextPolicy marks whether this turn routes to a cloud (external) model. +// On cloud turns, governed resources surface PII-free operational context (access +// commands, paths, ports) rather than raw identifiers; the model-boundary +// sanitizer is the authoritative gate that still strips local-only identifiers +// and credentials. The zero value (no cloud routing) injects full local context. type CloudContextPolicy struct { // CloudRouting reports that this turn routes to an external (cloud) provider. CloudRouting bool - // Level is the persisted cloud_context_privacy dial value (full | redacted | - // local_only). An empty or unknown value fails closed to redacted. - Level string -} - -// level normalizes the dial, failing closed to "redacted" for empty/unknown -// values so a missing setting never widens what reaches a cloud model. -func (p CloudContextPolicy) level() string { - switch p.Level { - case config.CloudContextPrivacyFull, config.CloudContextPrivacyRedacted, config.CloudContextPrivacyLocalOnly: - return p.Level - default: - return config.CloudContextPrivacyRedacted - } } // sharesCloudOperationalContext reports whether governed resources should have -// their PII-free operational context injected for this cloud turn. True for the -// full and redacted levels; local_only sends no infrastructure context at all. +// their PII-free operational context injected for this cloud turn. func (p CloudContextPolicy) sharesCloudOperationalContext() bool { - return p.CloudRouting && p.level() != config.CloudContextPrivacyLocalOnly -} - -// suppressesCloudContext reports whether ALL proactive infrastructure context -// must be withheld from this cloud turn (the local_only level). -func (p CloudContextPolicy) suppressesCloudContext() bool { - return p.CloudRouting && p.level() == config.CloudContextPrivacyLocalOnly + return p.CloudRouting } // ResourceMention represents a detected resource mention in a user message @@ -190,17 +169,6 @@ func (p *ContextPrefetcher) PrefetchWithCloudPolicy(ctx context.Context, message Int("mentions_found", len(mentions)). Msg("[ContextPrefetch] Found resource mentions in message") - // local_only: send NO infrastructure context to the cloud model. Resolve the - // mentions (so routing validation still recognizes them) but inject only a - // transparency directive instead of any resource detail, and skip discovery. - if cloudPolicy.suppressesCloudContext() { - log.Info().Int("mentions", len(mentions)).Msg("[ContextPrefetch] Cloud privacy=local_only; withholding all infra context") - return &PrefetchedContext{ - Mentions: mentions, - Summary: localOnlyCloudContextDirective(), - } - } - // Gather discovery data for each mention var discoveries []*tools.ResourceDiscoveryInfo if p.discoveryProvider != nil { @@ -979,18 +947,3 @@ func formatCloudSafeGovernedBlock(cloudSafe string) string { sb.WriteString("\nHostnames, IP addresses, aliases, and platform IDs remain withheld by canonical resource policy for cloud routing.\n\n") return sb.String() } - -// localOnlyCloudContextDirective is injected in place of all resource detail when -// the operator set Cloud model privacy to "local_only": no infrastructure context -// is sent to the cloud model. It instructs the Assistant to disclose this rather -// than silently giving a generic answer, and to point at the remedy. Pulse's -// privacy posture is only trustworthy when the withholding is visible to the user. -func localOnlyCloudContextDirective() string { - return strings.Join([]string{ - "=== PULSE CLOUD PRIVACY: LOCAL ONLY ===", - "No infrastructure context for the mentioned resources was sent to this cloud model because Cloud model privacy is set to \"Local only\".", - "In your reply, tell the user you cannot give resource-specific steps for this reason, and that they can either switch the Assistant to a local (Ollama) model or change Cloud model privacy in Settings → Assistant & Patrol to \"Full\" or \"Redacted\". Do not fabricate the withheld details.", - "", - "", - }, "\n") -} diff --git a/internal/ai/chat/context_prefetch_cloud_context_test.go b/internal/ai/chat/context_prefetch_cloud_context_test.go index e8bf8d8ed..ae41ff3bc 100644 --- a/internal/ai/chat/context_prefetch_cloud_context_test.go +++ b/internal/ai/chat/context_prefetch_cloud_context_test.go @@ -7,7 +7,6 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/ai/modelboundary" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" - "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) @@ -59,16 +58,15 @@ func homeAssistantDiscovery() *tools.ResourceDiscoveryInfo { } } -func TestPrefetcherCloudContext_RedactedSharesAccessPathWithoutPII(t *testing.T) { +func TestPrefetcherCloudContext_CloudSharesAccessPathWithoutPII(t *testing.T) { prefetcher := NewContextPrefetcher(newTestReadState(models.StateSnapshot{}), nil) - // The "redacted" level shares the PII-free operational context for governed - // resources: useful commands/paths/ports reach the model, identifying - // hostnames/IPs do not. + // On a cloud turn, governed resources surface the PII-free operational context: + // useful commands/paths/ports reach the model, identifying hostnames/IPs do not. summary, spans := prefetcher.formatContextSummaryWithPolicy( []ResourceMention{governedHomeAssistantMention()}, []*tools.ResourceDiscoveryInfo{homeAssistantDiscovery()}, - CloudContextPolicy{CloudRouting: true, Level: config.CloudContextPrivacyRedacted}, + CloudContextPolicy{CloudRouting: true}, ) // The operational access path reaches the model. @@ -107,51 +105,24 @@ func TestPrefetcherCloudContext_RedactedSharesAccessPathWithoutPII(t *testing.T) } } -func TestPrefetcherCloudContext_FullKeepsGovernedPIIFreeInPrefetch(t *testing.T) { +func TestPrefetcherCloudContext_CloudWithoutDiscoveryKeepsGovernedSummary(t *testing.T) { prefetcher := NewContextPrefetcher(newTestReadState(models.StateSnapshot{}), nil) - // Even at "full", the proactive prefetch keeps genuinely-governed resources - // PII-free (the model-boundary sanitizer is where full opens identifiers up). - // So a governed resource still surfaces only the cloud-safe operational span. - summary, spans := prefetcher.formatContextSummaryWithPolicy( - []ResourceMention{governedHomeAssistantMention()}, - []*tools.ResourceDiscoveryInfo{homeAssistantDiscovery()}, - CloudContextPolicy{CloudRouting: true, Level: config.CloudContextPrivacyFull}, - ) - - if !strings.Contains(summary, "pct exec 101 -- docker exec homeassistant") { - t.Fatalf("full prefetch must include the access path, got:\n%s", summary) - } - if strings.Contains(summary, "delly-ha-host") || strings.Contains(summary, "192.168.0.101") { - t.Fatalf("full prefetch must not leak governed PII, got:\n%s", summary) - } - if len(spans) != 1 { - t.Fatalf("expected one cloud-safe span at full, got %d: %#v", len(spans), spans) - } -} - -func TestPrefetcherCloudContext_RedactedWithoutDiscoveryKeepsGovernedSummary(t *testing.T) { - prefetcher := NewContextPrefetcher(newTestReadState(models.StateSnapshot{}), nil) - - // Redacted with no discovery data falls back to the terse governed summary — - // and never emits the obsolete opt-in transparency string. + // A cloud turn with no discovery data falls back to the terse governed summary. summary, spans := prefetcher.formatContextSummaryWithPolicy( []ResourceMention{governedHomeAssistantMention()}, nil, - CloudContextPolicy{CloudRouting: true, Level: config.CloudContextPrivacyRedacted}, + CloudContextPolicy{CloudRouting: true}, ) if strings.Contains(summary, "pct exec") { - t.Fatalf("redacted-without-discovery must withhold the access path, got:\n%s", summary) + t.Fatalf("cloud-without-discovery must withhold the access path, got:\n%s", summary) } if !strings.Contains(summary, unifiedresources.ResourcePolicyGovernedSummaryFooter()) { - t.Fatalf("redacted-without-discovery must keep the governed redaction, got:\n%s", summary) - } - if strings.Contains(summary, "Share operational context with cloud models") { - t.Fatalf("obsolete opt-in transparency string must not appear, got:\n%s", summary) + t.Fatalf("cloud-without-discovery must keep the governed redaction, got:\n%s", summary) } if len(spans) != 0 { - t.Fatalf("redacted-without-discovery must not return cloud-safe spans, got %#v", spans) + t.Fatalf("cloud-without-discovery must not return cloud-safe spans, got %#v", spans) } } @@ -163,7 +134,7 @@ func TestPrefetcherCloudContext_LocalRoutingUnaffected(t *testing.T) { summary, spans := prefetcher.formatContextSummaryWithPolicy( []ResourceMention{governedHomeAssistantMention()}, []*tools.ResourceDiscoveryInfo{homeAssistantDiscovery()}, - CloudContextPolicy{CloudRouting: false, Level: config.CloudContextPrivacyFull}, + CloudContextPolicy{CloudRouting: false}, ) if !strings.Contains(summary, unifiedresources.ResourcePolicyGovernedSummaryFooter()) { @@ -174,30 +145,12 @@ func TestPrefetcherCloudContext_LocalRoutingUnaffected(t *testing.T) { } } -func TestCloudContextPolicy_LevelSemantics(t *testing.T) { - cases := []struct { - name string - policy CloudContextPolicy - wantShares bool - wantSuppresses bool - }{ - {"full cloud", CloudContextPolicy{CloudRouting: true, Level: config.CloudContextPrivacyFull}, true, false}, - {"redacted cloud", CloudContextPolicy{CloudRouting: true, Level: config.CloudContextPrivacyRedacted}, true, false}, - {"local_only cloud", CloudContextPolicy{CloudRouting: true, Level: config.CloudContextPrivacyLocalOnly}, false, true}, - {"empty level fails closed to redacted", CloudContextPolicy{CloudRouting: true, Level: ""}, true, false}, - {"unknown level fails closed to redacted", CloudContextPolicy{CloudRouting: true, Level: "bogus"}, true, false}, - {"full but local routing shares nothing extra", CloudContextPolicy{CloudRouting: false, Level: config.CloudContextPrivacyFull}, false, false}, - {"local_only but local routing never suppresses", CloudContextPolicy{CloudRouting: false, Level: config.CloudContextPrivacyLocalOnly}, false, false}, +func TestCloudContextPolicy_SharesOnCloudRoutingOnly(t *testing.T) { + if !(CloudContextPolicy{CloudRouting: true}).sharesCloudOperationalContext() { + t.Fatal("cloud routing must share PII-free operational context") } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := tc.policy.sharesCloudOperationalContext(); got != tc.wantShares { - t.Fatalf("sharesCloudOperationalContext() = %v, want %v", got, tc.wantShares) - } - if got := tc.policy.suppressesCloudContext(); got != tc.wantSuppresses { - t.Fatalf("suppressesCloudContext() = %v, want %v", got, tc.wantSuppresses) - } - }) + if (CloudContextPolicy{CloudRouting: false}).sharesCloudOperationalContext() { + t.Fatal("local routing must not trigger the cloud-safe path") } } @@ -216,13 +169,13 @@ func (p *policiedResourceProvider) GetByType(t unifiedresources.ResourceType) [] } func TestCloudSafeContextSurvivesModelBoundarySanitizer(t *testing.T) { - // A sensitive guest whose hostname, IP, and alias the policy must redact for - // cloud routing. + // A local-only (Restricted) guest whose hostname and IP the floor must redact + // for cloud routing even though Pulse otherwise shares real identifiers. resource := unifiedresources.Resource{ ID: "system-container:node1:101", Type: unifiedresources.ResourceTypeSystemContainer, Name: "homeassistant", - Tags: []string{"sensitive"}, + Tags: []string{"secret"}, Identity: unifiedresources.ResourceIdentity{ Hostnames: []string{"delly-ha-host"}, IPAddresses: []string{"192.168.0.101"}, diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index 5f780d4d2..481cd7fd0 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -676,21 +676,11 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac Str("prompt", req.Prompt[:min(50, len(req.Prompt))]). Msg("[ChatService] Checking prefetcher") - // Resolve the cloud_context_privacy dial once for this turn. It governs both - // the proactive context prefetch and the model-boundary sanitizer below. - // Fail closed to "redacted" when no config snapshot is available so a missing - // setting never widens what reaches a cloud model. - cloudPrivacyLevel := config.CloudContextPrivacyRedacted - if cfgSnapshot != nil { - cloudPrivacyLevel = cfgSnapshot.GetCloudContextPrivacy() - } - mentionsFound := false var modelBoundaryAllowedCloudContext []string if prefetcher != nil { cloudContextPolicy := CloudContextPolicy{ CloudRouting: modelboundary.ModelUsesExternalProvider(selectedModel), - Level: cloudPrivacyLevel, } prefetchCtx := prefetcher.PrefetchWithCloudPolicy(ctx, req.Prompt, req.Mentions, cloudContextPolicy) if prefetchCtx != nil { @@ -869,15 +859,6 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac if len(modelBoundaryAllowedCloudContext) > 0 { sanitizerOptions = append(sanitizerOptions, modelboundary.AllowResourcePolicyText(modelBoundaryAllowedCloudContext...)) } - // Cloud model privacy = "full": the operator chose to share real resource - // identifiers, so the model-boundary identifier redaction narrows to the - // local-only floor (Restricted resources that must never leave local stay - // redacted even at full). Prompt-secret sanitation (credentials) still runs - // unconditionally. Local models never reach this sanitizer - // (RequestSanitizerForModel returns nil). - if cloudPrivacyLevel == config.CloudContextPrivacyFull { - sanitizerOptions = append(sanitizerOptions, modelboundary.RedactLocalOnlyResourcesOnly()) - } loop.SetRequestSanitizer(modelboundary.RequestSanitizerForModel(attempt.Model, unifiedResourceProvider, sanitizerOptions...)) loop.SetSuppressProviderErrorEvents(true) loop.SetSessionFSM(sessionFSM) diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index 1faeaf3ea..160441ac1 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -26,7 +26,6 @@ import ( type stubServiceProvider struct { streamFn func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error - chatFn func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) } type sessionAwareStateProvider struct { @@ -323,9 +322,6 @@ func TestService_ListSessionsRefreshesHandoffActionSummary(t *testing.T) { } func (s *stubServiceProvider) Chat(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) { - if s.chatFn != nil { - return s.chatFn(ctx, req) - } return &providers.ChatResponse{Content: "ok", Model: req.Model}, nil } @@ -2578,7 +2574,7 @@ func TestService_ExecuteStream_HandoffResourceRelationshipContextIsModelOnly(t * Type: unifiedresources.ResourceTypeStorage, Name: "secret-storage", Status: unifiedresources.StatusOnline, - Tags: []string{"backup"}, + Tags: []string{"secret"}, // Restricted -> local-only floor -> redacted for cloud }}, }} @@ -2601,12 +2597,10 @@ func TestService_ExecuteStream_HandoffResourceRelationshipContextIsModelOnly(t * loop := NewAgenticLoop(provider, executor, "system") svc := &Service{ - // Redacted cloud privacy: governed identities must be stripped at the - // external-provider boundary (the behavior this test pins). With the dial - // at "full" the local-only floor would still protect Restricted resources, - // but Sensitive ones would flow — that path is covered by the modelboundary - // sanitizer tests. - cfg: &config.AIConfig{ChatModel: "openai:test", CloudContextPrivacy: config.CloudContextPrivacyRedacted}, + // Both governed resources here are local-only (Restricted), so their + // identities must be stripped at the external-provider boundary — the + // local-only floor that always holds for cloud routing. + cfg: &config.AIConfig{ChatModel: "openai:test"}, sessions: store, executor: executor, agenticLoop: loop, diff --git a/internal/ai/chat/session_compaction.go b/internal/ai/chat/session_compaction.go index 0005c55a5..68054e8c0 100644 --- a/internal/ai/chat/session_compaction.go +++ b/internal/ai/chat/session_compaction.go @@ -12,7 +12,6 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/ai/modelboundary" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/safety" - "github.com/rcourtman/pulse-go-rewrite/internal/config" ) const ( @@ -143,20 +142,11 @@ func (s *Service) SummarizeSession(ctx context.Context, sessionID string) (map[s } // The transcript is built from PERSISTED messages (original user prompts and - // tool outputs), which carry raw resource identifiers regardless of how the - // live turns were redacted. Run it through the same dial-aware model-boundary - // sanitizer as a normal turn so compaction never leaks identifiers to a cloud - // model: redacted/local_only strip them, full keeps the local-only floor, and - // local (Ollama) is unaffected (RequestSanitizerForModel returns nil). - sanitizerOptions := []modelboundary.RequestSanitizerOption{} - cloudPrivacyLevel := config.CloudContextPrivacyRedacted - if cfgSnapshot != nil { - cloudPrivacyLevel = cfgSnapshot.GetCloudContextPrivacy() - } - if cloudPrivacyLevel == config.CloudContextPrivacyFull { - sanitizerOptions = append(sanitizerOptions, modelboundary.RedactLocalOnlyResourcesOnly()) - } - if sanitizer := modelboundary.RequestSanitizerForModel(requestModel, unifiedResourceProvider, sanitizerOptions...); sanitizer != nil { + // tool outputs), which carry raw resource identifiers. Run it through the same + // model-boundary sanitizer as a normal turn so compaction never leaks + // local-only identifiers or credentials to a cloud model (local Ollama is + // unaffected — RequestSanitizerForModel returns nil). + if sanitizer := modelboundary.RequestSanitizerForModel(requestModel, unifiedResourceProvider); sanitizer != nil { compactionRequest = sanitizer(compactionRequest) } diff --git a/internal/ai/chat/session_compaction_test.go b/internal/ai/chat/session_compaction_test.go index f947bef4a..e92801503 100644 --- a/internal/ai/chat/session_compaction_test.go +++ b/internal/ai/chat/session_compaction_test.go @@ -159,7 +159,7 @@ func TestServiceSummarizeSessionRedactsResourceIdentifiersForCloud(t *testing.T) started: true, sessions: store, provider: provider, - cfg: &config.AIConfig{ChatModel: "openrouter:test-model", CloudContextPrivacy: config.CloudContextPrivacyRedacted}, + cfg: &config.AIConfig{ChatModel: "openrouter:test-model"}, unifiedResourceProvider: unifiedProvider, } diff --git a/internal/ai/modelboundary/resource_policy_sanitizer.go b/internal/ai/modelboundary/resource_policy_sanitizer.go index 3b557859d..e1654e457 100644 --- a/internal/ai/modelboundary/resource_policy_sanitizer.go +++ b/internal/ai/modelboundary/resource_policy_sanitizer.go @@ -23,7 +23,6 @@ type allUnifiedResourceProvider interface { type requestSanitizerOptions struct { resourcePolicyAllowedText []string - localOnlyFloorOnly bool } // RequestSanitizerOption scopes model-bound sanitizer behavior for @@ -44,27 +43,20 @@ func AllowResourcePolicyText(values ...string) RequestSanitizerOption { } } -// RedactLocalOnlyResourcesOnly narrows the resource-identifier redaction pass to -// the hard floor: resources the policy engine routes local-only (Restricted -// sensitivity / "never leaves the local trust boundary"). Identifiers for all -// other resources — ordinary (Internal) and Sensitive (local-first) — may then -// reach a cloud model. It is the model-boundary half of the "full" -// cloud_context_privacy level: the operator chose to share real infrastructure -// detail, but an explicit local-only classification still must not be overridden -// by a blanket dial. Prompt-secret sanitation (API keys, passwords, tokens) ALWAYS -// still runs — credentials never cross the model boundary regardless of this -// option. Callers must only set this from the persisted privacy dial, never from -// raw user text. -func RedactLocalOnlyResourcesOnly() RequestSanitizerOption { - return func(opts *requestSanitizerOptions) { - opts.localOnlyFloorOnly = true - } -} - // RequestSanitizerForModel returns a sanitizer for non-local model traffic. // It is intentionally applied at the final provider transport boundary so // operator-entered prompts, handoff text, tool-result turns, and provider-bound // tool schemas cannot bypass prompt-secret or resource-policy sanitation. +// +// Pulse is a self-hosted homelab/SMB tool: a cloud-routed model receives real +// infrastructure context (names, IPs, config) so the Assistant is actually +// useful — the operator chose a cloud model. Two invariants always hold at this +// boundary regardless: prompt-secret sanitation strips credentials (API keys, +// passwords, tokens), and the resource-identifier redaction is scoped to the +// local-only floor — resources the policy engine routes local-only (Restricted: +// tagged secret/pii, PMG, k8s-secrets, ...) never leave the local trust boundary. +// Everything else (Internal and Sensitive) flows. Local (Ollama) models skip the +// sanitizer entirely. func RequestSanitizerForModel(model string, provider UnifiedResourceProvider, opts ...RequestSanitizerOption) func(providers.ChatRequest) providers.ChatRequest { if !ModelUsesExternalProvider(model) { return nil @@ -75,14 +67,7 @@ func RequestSanitizerForModel(model string, provider UnifiedResourceProvider, op opt(&options) } } - // At the "full" dial the operator opted into sharing real resource identifiers, - // so the redaction pass narrows to the local-only floor (resources that must - // never leave the local trust boundary); everything else flows. Otherwise the - // pass covers every policied resource. Prompt-secret sanitation below always runs. - resources := resourcePolicySanitizerResources(provider) - if options.localOnlyFloorOnly { - resources = localOnlyRoutedResources(resources) - } + resources := localOnlyRoutedResources(resourcePolicySanitizerResources(provider)) return func(req providers.ChatRequest) providers.ChatRequest { req = sanitizeProviderRequestForPromptSecrets(req) if len(resources) == 0 { @@ -156,8 +141,8 @@ func resourcesWithPolicy(resources []unifiedresources.Resource) []unifiedresourc } // localOnlyRoutedResources keeps only resources the policy engine routes -// local-only — the "never leaves the local trust boundary" floor that the "full" -// cloud_context_privacy dial must not override. +// local-only — the "never leaves the local trust boundary" floor for cloud-bound +// model requests. func localOnlyRoutedResources(resources []unifiedresources.Resource) []unifiedresources.Resource { filtered := make([]unifiedresources.Resource, 0, len(resources)) for _, resource := range resources { diff --git a/internal/ai/modelboundary/resource_policy_sanitizer_test.go b/internal/ai/modelboundary/resource_policy_sanitizer_test.go index c121e30ea..8512d025e 100644 --- a/internal/ai/modelboundary/resource_policy_sanitizer_test.go +++ b/internal/ai/modelboundary/resource_policy_sanitizer_test.go @@ -114,7 +114,7 @@ func TestRequestSanitizerForModelAllowsPulseGeneratedInventoryExportOnly(t *test ID: "vm-100", Type: unifiedresources.ResourceTypeVM, Name: "vm1", - Tags: []string{"sensitive"}, + Tags: []string{"secret"}, // Restricted -> local-only -> identity redacted in free text Identity: unifiedresources.ResourceIdentity{ Hostnames: []string{"vm1"}, }, @@ -149,15 +149,16 @@ func TestRequestSanitizerForModelAllowsPulseGeneratedInventoryExportOnly(t *test } } -func TestRequestSanitizerForModel_RedactLocalOnlyResourcesOnlyKeepsFloorAndSecrets(t *testing.T) { - // The "full" cloud_context_privacy level. A Sensitive (local-first) resource's - // identifiers may reach the cloud model, but a Restricted (local-only) resource - // stays redacted as a hard floor, and credentials are always stripped. +func TestRequestSanitizerForModel_FloorsLocalOnlyAndStripsSecrets(t *testing.T) { + // Pulse shares real infrastructure detail with cloud models, with two always-on + // invariants: a Sensitive (local-first) resource's identifiers flow, a Restricted + // (local-only) resource stays redacted as the hard floor, and credentials are + // always stripped. sensitiveVM := unifiedresources.Resource{ ID: "vm-200", Type: unifiedresources.ResourceTypeVM, Name: "finance-vm", - Tags: []string{"sensitive"}, // -> Sensitive -> local-first (flows at full) + Tags: []string{"sensitive"}, // -> Sensitive -> local-first (flows) Identity: unifiedresources.ResourceIdentity{ Hostnames: []string{"finance-vm.lan"}, IPAddresses: []string{"10.0.0.7"}, @@ -180,30 +181,22 @@ func TestRequestSanitizerForModel_RedactLocalOnlyResourcesOnlyKeepsFloorAndSecre System: "finance-vm.lan is 10.0.0.7; vault.lan is 10.0.0.9. Authorization: Bearer sk-leaked-secret-token", } - // Default (redacted): BOTH resources' identifiers are stripped. - redacted := RequestSanitizerForModel("openai:gpt-4o", provider)(req) - for _, identifier := range []string{"finance-vm.lan", "10.0.0.7", "vault.lan", "10.0.0.9"} { - if strings.Contains(redacted.System, identifier) { - t.Fatalf("default sanitizer must redact identifier %q, got: %s", identifier, redacted.System) - } - } - - // Full (local-only floor): the Sensitive resource's identifiers flow... - full := RequestSanitizerForModel("openai:gpt-4o", provider, RedactLocalOnlyResourcesOnly())(req) + out := RequestSanitizerForModel("openai:gpt-4o", provider)(req) + // The Sensitive resource's identifiers flow. for _, identifier := range []string{"finance-vm.lan", "10.0.0.7"} { - if !strings.Contains(full.System, identifier) { - t.Fatalf("full sanitizer must preserve sensitive (non-local-only) identifier %q, got: %s", identifier, full.System) + if !strings.Contains(out.System, identifier) { + t.Fatalf("sanitizer must preserve the sensitive (non-local-only) identifier %q, got: %s", identifier, out.System) } } - // ...but the Restricted (local-only) resource stays redacted as the hard floor... + // The Restricted (local-only) resource stays redacted as the hard floor. for _, identifier := range []string{"vault.lan", "10.0.0.9"} { - if strings.Contains(full.System, identifier) { - t.Fatalf("full sanitizer must keep the local-only floor for %q, got: %s", identifier, full.System) + if strings.Contains(out.System, identifier) { + t.Fatalf("sanitizer must keep the local-only floor for %q, got: %s", identifier, out.System) } } - // ...and the credential is STILL redacted, even at full. - if strings.Contains(full.System, "sk-leaked-secret-token") { - t.Fatalf("full sanitizer must still redact the bearer token, got: %s", full.System) + // The credential is always redacted. + if strings.Contains(out.System, "sk-leaked-secret-token") { + t.Fatalf("sanitizer must redact the bearer token, got: %s", out.System) } } diff --git a/internal/ai/request_sanitizer_dial_test.go b/internal/ai/request_sanitizer_dial_test.go deleted file mode 100644 index 8c28cd0b6..000000000 --- a/internal/ai/request_sanitizer_dial_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package ai - -import ( - "strings" - "testing" - - "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" - "github.com/rcourtman/pulse-go-rewrite/internal/config" - unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" -) - -// TestRequestSanitizerForModel_HonorsCloudPrivacyDial proves the shared sanitizer -// helper (used by discovery analysis, report/fleet narrators, quick analysis, and -// ExecuteAgentic) respects the cloud_context_privacy dial — not just the chat seam. -func TestRequestSanitizerForModel_HonorsCloudPrivacyDial(t *testing.T) { - resources := []unifiedresources.Resource{ - { - ID: "vm-1", Name: "finance-vm", Type: unifiedresources.ResourceTypeVM, - Status: unifiedresources.StatusOnline, Tags: []string{"sensitive"}, // -> Sensitive / local-first - Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"finance-vm.lan"}}, - Proxmox: &unifiedresources.ProxmoxData{VMID: 1, NodeName: "n1"}, - }, - { - ID: "agent/vault", Name: "vault", Type: unifiedresources.ResourceTypeAgent, - Status: unifiedresources.StatusOnline, Tags: []string{"secret"}, // -> Restricted / local-only floor - Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"vault.lan"}}, - }, - } - urp := &mockUnifiedResourceProvider{getAllFunc: func() []unifiedresources.Resource { - return append([]unifiedresources.Resource(nil), resources...) - }} - req := providers.ChatRequest{System: "finance-vm.lan and vault.lan. Authorization: Bearer sk-leak-secret-token"} - - sanitizerFor := func(level string) func(providers.ChatRequest) providers.ChatRequest { - s := &Service{cfg: &config.AIConfig{CloudContextPrivacy: level}, unifiedResourceProvider: urp} - return s.requestSanitizerForModel("openai:gpt-4o") - } - - // full: Sensitive (local-first) identifier flows; Restricted (local-only) stays - // redacted as the hard floor; secrets always redacted. - full := sanitizerFor(config.CloudContextPrivacyFull) - if full == nil { - t.Fatal("expected a sanitizer for an external model") - } - out := full(req).System - if !strings.Contains(out, "finance-vm.lan") { - t.Fatalf("full must keep the sensitive identifier, got: %s", out) - } - if strings.Contains(out, "vault.lan") { - t.Fatalf("full must keep the local-only floor (vault.lan redacted), got: %s", out) - } - if strings.Contains(out, "sk-leak-secret-token") { - t.Fatalf("full must still redact the bearer token, got: %s", out) - } - - // redacted: every policied identifier is redacted. - red := sanitizerFor(config.CloudContextPrivacyRedacted) - out = red(req).System - if strings.Contains(out, "finance-vm.lan") || strings.Contains(out, "vault.lan") { - t.Fatalf("redacted must redact all identifiers, got: %s", out) - } - - // local (Ollama): no sanitizer — local is always full. - localSvc := &Service{cfg: &config.AIConfig{CloudContextPrivacy: config.CloudContextPrivacyRedacted}, unifiedResourceProvider: urp} - if localSvc.requestSanitizerForModel("ollama:llama3") != nil { - t.Fatal("expected no sanitizer for a local Ollama model") - } -} diff --git a/internal/ai/resource_context.go b/internal/ai/resource_context.go index 63b57883c..80c0f4ae8 100644 --- a/internal/ai/resource_context.go +++ b/internal/ai/resource_context.go @@ -5,7 +5,6 @@ import ( "sort" "strings" - "github.com/rcourtman/pulse-go-rewrite/internal/config" unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rs/zerolog/log" ) @@ -52,17 +51,8 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s urp := s.unifiedResourceProvider ap := s.alertProvider agentServer := s.agentServer - cfg := s.cfg s.mu.RUnlock() - // Resolve the cloud_context_privacy dial for inventory-context redaction. - // Fail closed to "redacted" when no config snapshot is available so a missing - // setting never widens what reaches a cloud model. - cloudPrivacy := config.CloudContextPrivacyRedacted - if cfg != nil { - cloudPrivacy = cfg.GetCloudContextPrivacy() - } - if urp != nil { var sections []string stats := urp.GetStats() @@ -112,7 +102,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s workloads := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetWorkloads()) allResources := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetAll()) policyPosture := unifiedresources.SummarizePolicyPosture(allResources) - policyContext := buildUnifiedResourcePolicyContext(policyPosture, destinationModel, cloudPrivacy) + policyContext := buildUnifiedResourcePolicyContext(policyPosture, destinationModel) detailedInfrastructure := policyContext.filterDetailedResources(infrastructure) detailedWorkloads := policyContext.filterDetailedResources(workloads) byResourceID := make(map[string]unifiedresources.Resource, len(allResources)) @@ -189,7 +179,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } sections = append(sections, fmt.Sprintf("- **%s** (%s)%s%s [%s]", - policyContext.resourceLabel(node.Name, node.AISafeSummary, node.Policy), agentStatus, clusterInfo, metrics, node.Status)) + unifiedresources.ResourcePolicyLabel(node.Name, node.AISafeSummary, node.Policy), agentStatus, clusterInfo, metrics, node.Status)) } } @@ -210,7 +200,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } sections = append(sections, fmt.Sprintf("- **%s**%s%s [%s]", - policyContext.resourceLabel(host.Name, host.AISafeSummary, host.Policy), ips, metrics, host.Status)) + unifiedresources.ResourcePolicyLabel(host.Name, host.AISafeSummary, host.Policy), ips, metrics, host.Status)) } } @@ -227,7 +217,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } sections = append(sections, fmt.Sprintf("- **%s**%s [%s]", - policyContext.resourceLabel(host.Name, host.AISafeSummary, host.Policy), metrics, host.Status)) + unifiedresources.ResourcePolicyLabel(host.Name, host.AISafeSummary, host.Policy), metrics, host.Status)) } } @@ -250,7 +240,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } sections = append(sections, fmt.Sprintf("- **%s** (%d/%d containers running) [%s]", - policyContext.resourceLabel(host.Name, host.AISafeSummary, host.Policy), runningCount, containerCount, host.Status)) + unifiedresources.ResourcePolicyLabel(host.Name, host.AISafeSummary, host.Policy), runningCount, containerCount, host.Status)) } } @@ -269,7 +259,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s clusterInfo = fmt.Sprintf(" [cluster: %s]", name) } sections = append(sections, fmt.Sprintf("- **%s** (Cluster%s, %d nodes) [%s]", - policyContext.resourceLabel(cluster.Name, cluster.AISafeSummary, cluster.Policy), clusterInfo, nodeCount, cluster.Status)) + unifiedresources.ResourcePolicyLabel(cluster.Name, cluster.AISafeSummary, cluster.Policy), clusterInfo, nodeCount, cluster.Status)) } for _, node := range k8sNodes { @@ -295,7 +285,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } sections = append(sections, fmt.Sprintf("- **%s** (Node, %s)%s%s [%s]", - policyContext.resourceLabel(node.Name, node.AISafeSummary, node.Policy), agentStatus, clusterInfo, metrics, node.Status)) + unifiedresources.ResourcePolicyLabel(node.Name, node.AISafeSummary, node.Policy), agentStatus, clusterInfo, metrics, node.Status)) } } } @@ -327,7 +317,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s for _, parentID := range parentIDs { parentName := "unresolved parent resource" if parent, ok := infraMap[parentID]; ok { - parentName = policyContext.resourceLabel(parent.Name, parent.AISafeSummary, parent.Policy) + parentName = unifiedresources.ResourcePolicyLabel(parent.Name, parent.AISafeSummary, parent.Policy) } sections = append(sections, fmt.Sprintf("\n**On %s:**", parentName)) @@ -355,7 +345,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s ips := unifiedresources.ResourceIPSummary(workload, 2) sections = append(sections, fmt.Sprintf(" - **%s** (%s%s)%s [%s]", - policyContext.resourceLabel(workload.Name, workload.AISafeSummary, workload.Policy), typeLabel, vmidInfo, ips, workload.Status)) + unifiedresources.ResourcePolicyLabel(workload.Name, workload.AISafeSummary, workload.Policy), typeLabel, vmidInfo, ips, workload.Status)) } } @@ -367,7 +357,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s for _, workload := range noParent { ips := unifiedresources.ResourceIPSummary(workload, 2) sections = append(sections, fmt.Sprintf(" - **%s** (%s)%s [%s]", - policyContext.resourceLabel(workload.Name, workload.AISafeSummary, workload.Policy), workload.Type, ips, workload.Status)) + unifiedresources.ResourcePolicyLabel(workload.Name, workload.AISafeSummary, workload.Policy), workload.Type, ips, workload.Status)) } } } @@ -411,7 +401,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } sections = append(sections, fmt.Sprintf("- **%s**%s%s [%s]", - policyContext.resourceLabel(pool.Name, pool.AISafeSummary, pool.Policy), typeLabel, usage, pool.Status)) + unifiedresources.ResourcePolicyLabel(pool.Name, pool.AISafeSummary, pool.Policy), typeLabel, usage, pool.Status)) } } @@ -451,7 +441,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } sections = append(sections, fmt.Sprintf("- **%s**%s%s [%s]", - policyContext.resourceLabel(disk.Name, disk.AISafeSummary, disk.Policy), healthSummary, temperature, disk.Status)) + unifiedresources.ResourcePolicyLabel(disk.Name, disk.AISafeSummary, disk.Policy), healthSummary, temperature, disk.Status)) } } } @@ -474,7 +464,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s omittedLocalOnlyAlerts++ continue } - displayName = policyContext.resourceLabel(resource.Name, resource.AISafeSummary, resource.Policy) + displayName = unifiedresources.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, resource.Policy) message = unifiedresources.ResourcePolicyRedactedText(message, resource) } else if displayName == "" { displayName = resourceID @@ -562,7 +552,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s cpuPercent = unifiedMetricPercent(resource.Metrics.CPU) } sections = append(sections, fmt.Sprintf("%d. **%s** (%s): %.1f%%", - i+1, policyContext.resourceLabel(resource.Name, resource.AISafeSummary, resource.Policy), resource.Type, cpuPercent)) + i+1, unifiedresources.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, resource.Policy), resource.Type, cpuPercent)) } } @@ -575,7 +565,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s memPercent = unifiedMetricPercent(resource.Metrics.Memory) } sections = append(sections, fmt.Sprintf("%d. **%s** (%s): %.1f%%", - i+1, policyContext.resourceLabel(resource.Name, resource.AISafeSummary, resource.Policy), resource.Type, memPercent)) + i+1, unifiedresources.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, resource.Policy), resource.Type, memPercent)) } } @@ -588,7 +578,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s diskPercent = unifiedMetricPercent(resource.Metrics.Disk) } sections = append(sections, fmt.Sprintf("%d. **%s** (%s): %.1f%%", - i+1, policyContext.resourceLabel(resource.Name, resource.AISafeSummary, resource.Policy), resource.Type, diskPercent)) + i+1, unifiedresources.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, resource.Policy), resource.Type, diskPercent)) } } diff --git a/internal/ai/resource_context_policy_model.go b/internal/ai/resource_context_policy_model.go index 7545e226a..ecc82f860 100644 --- a/internal/ai/resource_context_policy_model.go +++ b/internal/ai/resource_context_policy_model.go @@ -12,8 +12,6 @@ import ( type unifiedResourcePolicyContext struct { posture *unifiedresources.PolicyPostureSummary externalModel bool - localModel bool - cloudPrivacy string sensitivityCounts map[unifiedresources.ResourceSensitivity]int routingCounts map[unifiedresources.ResourceRoutingScope]int localOnlyCount int @@ -21,12 +19,9 @@ type unifiedResourcePolicyContext struct { redactionLabels []string } -func buildUnifiedResourcePolicyContext(posture *unifiedresources.PolicyPostureSummary, destinationModel, cloudPrivacy string) unifiedResourcePolicyContext { - normalizedPrivacy, _ := config.NormalizeCloudContextPrivacy(cloudPrivacy) +func buildUnifiedResourcePolicyContext(posture *unifiedresources.PolicyPostureSummary, destinationModel string) unifiedResourcePolicyContext { context := unifiedResourcePolicyContext{ externalModel: unifiedResourceContextUsesExternalModel(destinationModel), - localModel: unifiedResourceContextUsesLocalModel(destinationModel), - cloudPrivacy: normalizedPrivacy, posture: posture, sensitivityCounts: map[unifiedresources.ResourceSensitivity]int{}, routingCounts: map[unifiedresources.ResourceRoutingScope]int{}, @@ -59,18 +54,6 @@ func unifiedResourceContextUsesExternalModel(destinationModel string) bool { return provider != config.AIProviderOllama } -// unifiedResourceContextUsesLocalModel reports whether the destination is a KNOWN -// local (Ollama) model. An empty/unknown destination is neither external nor -// local, so it fails closed to redaction rather than being treated as local. -func unifiedResourceContextUsesLocalModel(destinationModel string) bool { - destinationModel = strings.TrimSpace(destinationModel) - if destinationModel == "" { - return false - } - provider, _ := config.ParseModelString(destinationModel) - return provider == config.AIProviderOllama -} - func (context unifiedResourcePolicyContext) hasGovernedResources() bool { return context.posture != nil && context.posture.TotalResources > 0 } @@ -82,43 +65,6 @@ func (context unifiedResourcePolicyContext) includeResourceDetails(resource unif return resource.Policy.Routing.Scope != unifiedresources.ResourceRoutingScopeLocalOnly } -// resourceLabel renders a resource's display name for the model-bound inventory -// context, honoring the cloud_context_privacy dial. Local models always get the -// real name (local is always full). For cloud models, the "full" dial shows real -// names EXCEPT for resources the engine routes local-only (the hard floor, which -// stays redacted even at full); "redacted"/"local_only" fall back to the governed -// label. This is the inventory-context half of the dial — the prefetch and the -// model-boundary sanitizer enforce the same posture on their paths. -func (context unifiedResourcePolicyContext) resourceLabel(name, aiSafeSummary string, policy *unifiedresources.ResourcePolicy) string { - if context.allowsRealIdentifier(policy) { - if trimmed := strings.TrimSpace(name); trimmed != "" { - return trimmed - } - } - return unifiedresources.ResourcePolicyLabel(name, aiSafeSummary, policy) -} - -// allowsRealIdentifier reports whether the real resource identifier may be shown -// for this destination given the dial. Known-local (Ollama) => always. Cloud => -// only at "full" and only when the resource is not routed local-only. An -// unknown/empty destination fails closed to redaction (it could reach a cloud -// model), preserving the historical safe default for the no-destination path. -func (context unifiedResourcePolicyContext) allowsRealIdentifier(policy *unifiedresources.ResourcePolicy) bool { - if context.localModel { - return true - } - if !context.externalModel { - return false - } - if context.cloudPrivacy != config.CloudContextPrivacyFull { - return false - } - if policy != nil && policy.Routing.Scope == unifiedresources.ResourceRoutingScopeLocalOnly { - return false - } - return true -} - func (context unifiedResourcePolicyContext) filterDetailedResources(resources []unifiedresources.Resource) []unifiedresources.Resource { if !context.externalModel || len(resources) == 0 { return resources diff --git a/internal/ai/resource_context_policy_model_test.go b/internal/ai/resource_context_policy_model_test.go index 9bd24da20..d778509e4 100644 --- a/internal/ai/resource_context_policy_model_test.go +++ b/internal/ai/resource_context_policy_model_test.go @@ -49,7 +49,7 @@ func TestBuildUnifiedResourcePolicyContext(t *testing.T) { }, }) - context := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "", "redacted") + context := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "") if !context.hasGovernedResources() { t.Fatal("expected governed posture") @@ -104,7 +104,7 @@ func TestBuildUnifiedResourcePolicyContextExternalModel(t *testing.T) { } resources := unifiedresources.RefreshCanonicalMetadataSlice([]unifiedresources.Resource{localOnly, cloudSummary}) - context := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "openai:gpt-4o", "redacted") + context := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "openai:gpt-4o") if !context.externalModel { t.Fatal("expected external model handling") @@ -124,7 +124,7 @@ func TestBuildUnifiedResourcePolicyContextExternalModel(t *testing.T) { t.Fatalf("expected external handling summary, got %q", joined) } - localContext := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "ollama:llama3", "redacted") + localContext := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "ollama:llama3") if localContext.externalModel { t.Fatal("expected ollama destination to stay local") } @@ -132,70 +132,3 @@ func TestBuildUnifiedResourcePolicyContextExternalModel(t *testing.T) { t.Fatal("expected local model context to include local-only resource details") } } - -func TestUnifiedResourcePolicyContext_ResourceLabelDialAware(t *testing.T) { - resources := unifiedresources.RefreshCanonicalMetadataSlice([]unifiedresources.Resource{ - { // Sensitive -> local-first (shown at full) - ID: "vm-1", Name: "finance-vm", Type: unifiedresources.ResourceTypeVM, - Status: unifiedresources.StatusOnline, Tags: []string{"sensitive"}, - Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"finance.lan"}}, - }, - { // Restricted -> local-only (hard floor, redacted even at full) - ID: "agent-1", Name: "vault", Type: unifiedresources.ResourceTypeAgent, - Status: unifiedresources.StatusOnline, Tags: []string{"secret"}, - Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"vault.lan"}}, - }, - { // Internal -> cloud-summary (real name everywhere) - ID: "vm-2", Name: "web", Type: unifiedresources.ResourceTypeVM, - Status: unifiedresources.StatusOnline, - }, - }) - sens, restr, intern := resources[0], resources[1], resources[2] - posture := unifiedresources.SummarizePolicyPosture(resources) - label := func(ctx unifiedResourcePolicyContext, r unifiedresources.Resource) string { - return ctx.resourceLabel(r.Name, r.AISafeSummary, r.Policy) - } - // The governed (non-real-name) rendering for a redacted resource — the AI-safe - // summary when present, else the bare placeholder. The real-resource name must - // never equal this. - governed := func(r unifiedresources.Resource) string { - return unifiedresources.ResourcePolicyLabel(r.Name, r.AISafeSummary, r.Policy) - } - if governed(sens) == "finance-vm" || governed(restr) == "vault" { - t.Fatalf("test fixture invalid: governed labels should not be the real names (sens=%q restr=%q)", governed(sens), governed(restr)) - } - - // Local model: everything shows its real name (local is always full). - local := buildUnifiedResourcePolicyContext(posture, "ollama:llama3", "redacted") - if got := label(local, sens); got != "finance-vm" { - t.Fatalf("local sensitive label = %q, want real name", got) - } - if got := label(local, restr); got != "vault" { - t.Fatalf("local restricted label = %q, want real name", got) - } - - // Cloud + full: sensitive shows its real name, restricted stays governed - // (local-only hard floor), internal shows. - full := buildUnifiedResourcePolicyContext(posture, "openai:gpt-4o", "full") - if got := label(full, sens); got != "finance-vm" { - t.Fatalf("cloud-full sensitive label = %q, want real name", got) - } - if got := label(full, restr); got != governed(restr) { - t.Fatalf("cloud-full restricted label = %q, want governed floor %q", got, governed(restr)) - } - if got := label(full, intern); got != "web" { - t.Fatalf("cloud-full internal label = %q, want real name", got) - } - - // Cloud + redacted: both governed resources are redacted; internal still shows. - red := buildUnifiedResourcePolicyContext(posture, "openai:gpt-4o", "redacted") - if got := label(red, sens); got != governed(sens) { - t.Fatalf("cloud-redacted sensitive label = %q, want governed %q", got, governed(sens)) - } - if got := label(red, restr); got != governed(restr) { - t.Fatalf("cloud-redacted restricted label = %q, want governed %q", got, governed(restr)) - } - if got := label(red, intern); got != "web" { - t.Fatalf("cloud-redacted internal label = %q, want real name", got) - } -} diff --git a/internal/ai/service.go b/internal/ai/service.go index 9c8f38c50..4d8a33749 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -1807,24 +1807,8 @@ func (s *Service) QuickAnalysis(ctx context.Context, req QuickAnalysisRequest) ( func (s *Service) requestSanitizerForModel(model string) func(providers.ChatRequest) providers.ChatRequest { s.mu.RLock() urp := s.unifiedResourceProvider - cfg := s.cfg s.mu.RUnlock() - // Honor the cloud_context_privacy dial on every non-chat model-bound path - // (discovery analysis, report/fleet narrators, quick analysis, ExecuteAgentic), - // not just the interactive chat seam. Fail closed to redacted when no config - // snapshot. At "full" the redaction narrows to the local-only floor so real - // identifiers reach the model for ordinary/Sensitive resources — otherwise the - // dial would silently over-redact (e.g. discovery could not identify a governed - // service even though the operator chose full). - opts := []modelboundary.RequestSanitizerOption{} - cloudPrivacy := config.CloudContextPrivacyRedacted - if cfg != nil { - cloudPrivacy = cfg.GetCloudContextPrivacy() - } - if cloudPrivacy == config.CloudContextPrivacyFull { - opts = append(opts, modelboundary.RedactLocalOnlyResourcesOnly()) - } - return modelboundary.RequestSanitizerForModel(model, urp, opts...) + return modelboundary.RequestSanitizerForModel(model, urp) } // GetConfig returns a copy of the current AI config diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index ddaa9360e..3ead012ba 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -2302,17 +2302,6 @@ type AISettingsResponse struct { // Discovery settings DiscoveryEnabled bool `json:"discovery_enabled"` // true if discovery is enabled DiscoveryIntervalHours int `json:"discovery_interval_hours,omitempty"` // Hours between auto-scans (0 = manual only) - // Cloud operational-context sharing - when true, PII-free operational - // context (access commands, config/data/log paths, ports) for governed - // resources is shared with cloud models so the Assistant can give - // resource-specific guidance. Identifying fields stay redacted; local - // (Ollama) models always receive full context regardless of this flag. - ShareOperationalContextWithCloud bool `json:"share_operational_context_with_cloud"` - // Cloud context privacy dial - the canonical control for what infrastructure - // context cloud models may see: "full" (default), "redacted", or "local_only". - // Always serialized so the operator UI binds a 3-option control to the - // concrete value. Local (Ollama) models always receive full context. - CloudContextPrivacy string `json:"cloud_context_privacy"` // Current Patrol runtime readiness after this settings snapshot is applied. PatrolReadiness *PatrolReadinessResponse `json:"patrol_readiness,omitempty"` // Most recent Patrol tool-call preflight outcome, surfaced so the UI @@ -2409,14 +2398,6 @@ type AISettingsUpdateRequest struct { // Discovery settings DiscoveryEnabled *bool `json:"discovery_enabled,omitempty"` // Enable discovery DiscoveryIntervalHours *int `json:"discovery_interval_hours,omitempty"` // Hours between auto-scans (0 = manual only) - // Cloud operational-context sharing - opt in to share PII-free operational - // context for governed resources with cloud models (nil = don't update). - ShareOperationalContextWithCloud *bool `json:"share_operational_context_with_cloud,omitempty"` - // Cloud context privacy dial - canonical control for cloud model context - // ("full" | "redacted" | "local_only"; nil = don't update). When provided it - // supersedes share_operational_context_with_cloud and the handler keeps that - // legacy flag in sync (full -> true, redacted/local_only -> false). - CloudContextPrivacy *string `json:"cloud_context_privacy,omitempty"` } // AssistantEnabled reports whether the Pulse Assistant affordance should be @@ -2526,28 +2507,26 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R UseProactiveThresholds: settings.UseProactiveThresholds, AvailableModels: nil, // Now populated via /api/ai/models endpoint // Multi-provider configuration - AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), - OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), - OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter), - DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), - GeminiConfigured: settings.HasProvider(config.AIProviderGemini), - OllamaConfigured: settings.HasProvider(config.AIProviderOllama), - OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), - OllamaUsername: settings.OllamaUsername, - OllamaPasswordSet: settings.OllamaPassword != "", - OllamaKeepAlive: settings.GetOllamaKeepAlive(), - OpenAIBaseURL: settings.OpenAIBaseURL, - ConfiguredProviders: settings.GetConfiguredProviders(), - CostBudgetUSD30d: settings.CostBudgetUSD30d, - RequestTimeoutSeconds: settings.RequestTimeoutSeconds, - ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), - ProtectedGuests: settings.GetProtectedGuests(), - DiscoveryEnabled: settings.IsDiscoveryEnabled(), - DiscoveryIntervalHours: settings.DiscoveryIntervalHours, - ShareOperationalContextWithCloud: settings.ShouldShareOperationalContextWithCloud(), - CloudContextPrivacy: settings.GetCloudContextPrivacy(), - PatrolPreflight: cachedPatrolPreflightSnapshot(aiService), - PatrolReadiness: ptrToPatrolReadiness(h.buildPatrolReadiness(ctx, aiService, h.getPatrolService(ctx) != nil)), + AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), + OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), + OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter), + DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), + GeminiConfigured: settings.HasProvider(config.AIProviderGemini), + OllamaConfigured: settings.HasProvider(config.AIProviderOllama), + OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), + OllamaUsername: settings.OllamaUsername, + OllamaPasswordSet: settings.OllamaPassword != "", + OllamaKeepAlive: settings.GetOllamaKeepAlive(), + OpenAIBaseURL: settings.OpenAIBaseURL, + ConfiguredProviders: settings.GetConfiguredProviders(), + CostBudgetUSD30d: settings.CostBudgetUSD30d, + RequestTimeoutSeconds: settings.RequestTimeoutSeconds, + ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), + ProtectedGuests: settings.GetProtectedGuests(), + DiscoveryEnabled: settings.IsDiscoveryEnabled(), + DiscoveryIntervalHours: settings.DiscoveryIntervalHours, + PatrolPreflight: cachedPatrolPreflightSnapshot(aiService), + PatrolReadiness: ptrToPatrolReadiness(h.buildPatrolReadiness(ctx, aiService, h.getPatrolService(ctx) != nil)), }.NormalizeCollections() if err := utils.WriteJSONResponse(w, response); err != nil { @@ -2871,28 +2850,6 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt settings.DiscoveryIntervalHours = 24 } - // Handle cloud operational-context sharing (nil = don't update) - if req.ShareOperationalContextWithCloud != nil { - settings.ShareOperationalContextWithCloud = *req.ShareOperationalContextWithCloud - } - - // Handle the cloud context privacy dial (nil = don't update). When provided it - // is the canonical control and supersedes share_operational_context_with_cloud: - // the handler keeps the legacy flag (still read by the redaction seam until the - // dial is wired into the seam directly) in sync so behavior tracks the dial's - // full/redacted axis. The dial's deeper semantics (real identifiers on "full", - // dropping all infra context on "local_only") land in privacy-redesign - // increment 2; here local_only conservatively maps to the redacted seam state. - if req.CloudContextPrivacy != nil { - normalized, valid := config.NormalizeCloudContextPrivacy(*req.CloudContextPrivacy) - if !valid { - http.Error(w, "invalid cloud_context_privacy: must be full, redacted, or local_only", http.StatusBadRequest) - return - } - settings.CloudContextPrivacy = normalized - settings.ShareOperationalContextWithCloud = normalized == config.CloudContextPrivacyFull - } - if aiSettingsRequireModelResolution(settings) { resolvedModel, resolveErr := ai.ResolveConfiguredModel(r.Context(), settings) if resolveErr != nil { @@ -3003,27 +2960,25 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt UseProactiveThresholds: settings.UseProactiveThresholds, AvailableModels: nil, // Now populated via /api/ai/models endpoint // Multi-provider configuration - AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), - OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), - OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter), - DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), - GeminiConfigured: settings.HasProvider(config.AIProviderGemini), - OllamaConfigured: settings.HasProvider(config.AIProviderOllama), - OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), - OllamaUsername: settings.OllamaUsername, - OllamaPasswordSet: settings.OllamaPassword != "", - OllamaKeepAlive: settings.GetOllamaKeepAlive(), - OpenAIBaseURL: settings.OpenAIBaseURL, - ConfiguredProviders: settings.GetConfiguredProviders(), - RequestTimeoutSeconds: settings.RequestTimeoutSeconds, - ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), - ProtectedGuests: settings.GetProtectedGuests(), - DiscoveryEnabled: settings.DiscoveryEnabled, - DiscoveryIntervalHours: settings.DiscoveryIntervalHours, - ShareOperationalContextWithCloud: settings.ShouldShareOperationalContextWithCloud(), - CloudContextPrivacy: settings.GetCloudContextPrivacy(), - PatrolReadiness: ptrToPatrolReadiness(patrolReadiness), - PatrolPreflight: cachedPatrolPreflightSnapshot(aiService), + AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), + OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), + OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter), + DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), + GeminiConfigured: settings.HasProvider(config.AIProviderGemini), + OllamaConfigured: settings.HasProvider(config.AIProviderOllama), + OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), + OllamaUsername: settings.OllamaUsername, + OllamaPasswordSet: settings.OllamaPassword != "", + OllamaKeepAlive: settings.GetOllamaKeepAlive(), + OpenAIBaseURL: settings.OpenAIBaseURL, + ConfiguredProviders: settings.GetConfiguredProviders(), + RequestTimeoutSeconds: settings.RequestTimeoutSeconds, + ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), + ProtectedGuests: settings.GetProtectedGuests(), + DiscoveryEnabled: settings.DiscoveryEnabled, + DiscoveryIntervalHours: settings.DiscoveryIntervalHours, + PatrolReadiness: ptrToPatrolReadiness(patrolReadiness), + PatrolPreflight: cachedPatrolPreflightSnapshot(aiService), }.NormalizeCollections() if err := utils.WriteJSONResponse(w, response); err != nil { diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 29ea3bbe2..c6fb6c6da 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -494,193 +494,6 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) { } } -func TestAISettingsHandler_ShareOperationalContextWithCloud_RoundTrip(t *testing.T) { - t.Parallel() - - tmp := t.TempDir() - cfg := &config.Config{DataPath: tmp} - persistence := config.NewConfigPersistence(tmp) - - handler := newTestAISettingsHandler(cfg, persistence, nil) - - getShareFlag := func(t *testing.T) bool { - t.Helper() - req := newLoopbackRequest(http.MethodGet, "/api/settings/ai", nil) - rec := httptest.NewRecorder() - handler.HandleGetAISettings(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String()) - } - // The field is always serialized (no omitempty) so the operator UI can - // bind a toggle to its concrete value. - if !strings.Contains(rec.Body.String(), `"share_operational_context_with_cloud":`) { - t.Fatalf("expected share_operational_context_with_cloud in GET body, got %s", rec.Body.String()) - } - var resp AISettingsResponse - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - return resp.ShareOperationalContextWithCloud - } - - updateShareFlag := func(t *testing.T, req AISettingsUpdateRequest) AISettingsResponse { - t.Helper() - body, _ := json.Marshal(req) - httpReq := newLoopbackRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body)) - rec := httptest.NewRecorder() - handler.HandleUpdateAISettings(rec, httpReq) - if rec.Code != http.StatusOK { - t.Fatalf("PUT status = %d, body=%s", rec.Code, rec.Body.String()) - } - var resp AISettingsResponse - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - return resp - } - - // Default off before any save. - if getShareFlag(t) { - t.Fatalf("expected default share_operational_context_with_cloud=false") - } - - // Enabling AI alongside the opt-in must round-trip the flag as true. - resp := updateShareFlag(t, AISettingsUpdateRequest{ - Enabled: ptr(true), - Model: ptr("ollama:llama3"), - OllamaBaseURL: ptr("http://localhost:11434"), - ShareOperationalContextWithCloud: ptr(true), - }) - if !resp.ShareOperationalContextWithCloud { - t.Fatalf("expected PUT response share_operational_context_with_cloud=true, got %+v", resp) - } - if !getShareFlag(t) { - t.Fatalf("expected persisted share_operational_context_with_cloud=true after enabling") - } - - // Omitting the field (nil pointer) must leave the persisted opt-in untouched. - resp = updateShareFlag(t, AISettingsUpdateRequest{Model: ptr("ollama:llama3")}) - if !resp.ShareOperationalContextWithCloud { - t.Fatalf("expected omitted field to preserve opt-in, got %+v", resp) - } - if !getShareFlag(t) { - t.Fatalf("expected persisted opt-in to survive an unrelated save") - } - - // Explicit false turns the opt-in back off. - resp = updateShareFlag(t, AISettingsUpdateRequest{ShareOperationalContextWithCloud: ptr(false)}) - if resp.ShareOperationalContextWithCloud { - t.Fatalf("expected PUT response share_operational_context_with_cloud=false, got %+v", resp) - } - if getShareFlag(t) { - t.Fatalf("expected persisted share_operational_context_with_cloud=false after opt-out") - } -} - -func TestAISettingsHandler_CloudContextPrivacy_RoundTrip(t *testing.T) { - t.Parallel() - - tmp := t.TempDir() - cfg := &config.Config{DataPath: tmp} - persistence := config.NewConfigPersistence(tmp) - - handler := newTestAISettingsHandler(cfg, persistence, nil) - - getSettings := func(t *testing.T) AISettingsResponse { - t.Helper() - req := newLoopbackRequest(http.MethodGet, "/api/settings/ai", nil) - rec := httptest.NewRecorder() - handler.HandleGetAISettings(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String()) - } - // The dial is always serialized (no omitempty) so the operator UI can bind - // a 3-option control to its concrete value. - if !strings.Contains(rec.Body.String(), `"cloud_context_privacy":`) { - t.Fatalf("expected cloud_context_privacy in GET body, got %s", rec.Body.String()) - } - var resp AISettingsResponse - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - return resp - } - - updateSettings := func(t *testing.T, req AISettingsUpdateRequest) (AISettingsResponse, int) { - t.Helper() - body, _ := json.Marshal(req) - httpReq := newLoopbackRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body)) - rec := httptest.NewRecorder() - handler.HandleUpdateAISettings(rec, httpReq) - var resp AISettingsResponse - if rec.Code == http.StatusOK { - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - } - return resp, rec.Code - } - - // A fresh install defaults to the "full" posture. - if got := getSettings(t).CloudContextPrivacy; got != config.CloudContextPrivacyFull { - t.Fatalf("expected default cloud_context_privacy=full, got %q", got) - } - - // Selecting "redacted" persists and syncs the legacy share flag off (the - // redaction seam still reads it until the dial is wired into the seam directly). - resp, code := updateSettings(t, AISettingsUpdateRequest{ - Enabled: ptr(true), - Model: ptr("ollama:llama3"), - OllamaBaseURL: ptr("http://localhost:11434"), - CloudContextPrivacy: ptr(config.CloudContextPrivacyRedacted), - }) - if code != http.StatusOK { - t.Fatalf("PUT redacted status = %d", code) - } - if resp.CloudContextPrivacy != config.CloudContextPrivacyRedacted { - t.Fatalf("expected response cloud_context_privacy=redacted, got %q", resp.CloudContextPrivacy) - } - if resp.ShareOperationalContextWithCloud { - t.Fatalf("expected redacted to sync legacy share flag off") - } - if got := getSettings(t); got.CloudContextPrivacy != config.CloudContextPrivacyRedacted || got.ShareOperationalContextWithCloud { - t.Fatalf("expected persisted redacted + share off, got %+v", got) - } - - // "full" persists and syncs the legacy share flag on. - resp, code = updateSettings(t, AISettingsUpdateRequest{CloudContextPrivacy: ptr(config.CloudContextPrivacyFull)}) - if code != http.StatusOK { - t.Fatalf("PUT full status = %d", code) - } - if resp.CloudContextPrivacy != config.CloudContextPrivacyFull || !resp.ShareOperationalContextWithCloud { - t.Fatalf("expected full + share on, got %+v", resp) - } - - // "local_only" persists and (until increment 2) maps the legacy seam to off. - resp, code = updateSettings(t, AISettingsUpdateRequest{CloudContextPrivacy: ptr(config.CloudContextPrivacyLocalOnly)}) - if code != http.StatusOK { - t.Fatalf("PUT local_only status = %d", code) - } - if resp.CloudContextPrivacy != config.CloudContextPrivacyLocalOnly || resp.ShareOperationalContextWithCloud { - t.Fatalf("expected local_only + share off, got %+v", resp) - } - - // Omitting the field leaves the persisted dial untouched. - resp, code = updateSettings(t, AISettingsUpdateRequest{Model: ptr("ollama:llama3")}) - if code != http.StatusOK { - t.Fatalf("PUT omitted status = %d", code) - } - if resp.CloudContextPrivacy != config.CloudContextPrivacyLocalOnly { - t.Fatalf("expected omitted field to preserve local_only, got %q", resp.CloudContextPrivacy) - } - - // An unrecognized value is rejected rather than silently coerced. - _, code = updateSettings(t, AISettingsUpdateRequest{CloudContextPrivacy: ptr("bogus")}) - if code != http.StatusBadRequest { - t.Fatalf("expected 400 for invalid cloud_context_privacy, got %d", code) - } -} - func TestAISettingsHandler_GetSettingsClampsPaidControlsToEntitlements(t *testing.T) { t.Parallel() diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index d22324f6a..92372ae67 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -1335,8 +1335,6 @@ func TestContract_AISettingsUpdateProviderResolutionJSONSnapshot(t *testing.T) { "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, - "share_operational_context_with_cloud":false, - "cloud_context_privacy":"full", "patrol_readiness":{ "status":"warning", "ready":true, @@ -1481,8 +1479,6 @@ func TestContract_AISettingsBYOKOverrideDoesNotExposeQuickstartInventoryJSONSnap "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, - "share_operational_context_with_cloud":false, - "cloud_context_privacy":"full", "patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]} }` @@ -3488,8 +3484,6 @@ func TestContract_HostedAISettingsDoesNotAutoBootstrapQuickstartJSONSnapshot(t * "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, - "share_operational_context_with_cloud":false, - "cloud_context_privacy":"full", "patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]} }` @@ -3553,8 +3547,6 @@ func TestContract_AISettingsRetiredQuickstartAliasJSONSnapshot(t *testing.T) { "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, - "share_operational_context_with_cloud":false, - "cloud_context_privacy":"full", "patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]} }` @@ -3623,8 +3615,6 @@ func TestContract_AISettingsOllamaAuthJSONSnapshot(t *testing.T) { "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, - "share_operational_context_with_cloud":false, - "cloud_context_privacy":"full", "patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]} }` @@ -4186,8 +4176,6 @@ func TestContract_HostedTenantAISettingsDoesNotAutoBootstrapQuickstartJSONSnapsh "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, - "share_operational_context_with_cloud":false, - "cloud_context_privacy":"full", "patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]} }` diff --git a/internal/config/ai.go b/internal/config/ai.go index fda44c975..b95bd32ab 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -104,36 +104,6 @@ type AIConfig struct { DiscoveryEnabled bool `json:"discovery_enabled"` // Enable infrastructure discovery DiscoveryIntervalHours int `json:"discovery_interval_hours,omitempty"` // Hours between automatic re-scans (0 = manual only, default: 0) - // Cloud operational-context sharing - controls whether PII-free operational - // context (service identity, access commands, config/data/log paths, port - // numbers) for governed resources may be sent to CLOUD models. Default false: - // cloud-routed governed resources are redacted to a terse summary, which makes - // the Assistant unable to give resource-specific guidance on cloud models. - // Opting in shares the cloud-safe operational context while genuinely - // identifying fields (hostname, IP, alias, platform ID) stay redacted. Local - // (Ollama) models always receive full context and are unaffected by this flag. - // - // Deprecated: superseded by CloudContextPrivacy. Retained as the load-bearing - // behavior field the redaction seam still reads until the dial is wired into - // the seam directly (privacy-redesign increment 2). CloudContextPrivacy is the - // canonical operator control; the settings handler keeps this field in sync - // (full -> true, redacted/local_only -> false) and config load migrates the - // dial out of it for pre-dial configs. - ShareOperationalContextWithCloud bool `json:"share_operational_context_with_cloud,omitempty"` - - // CloudContextPrivacy is the single privacy dial governing what infrastructure - // context cloud models may see. It supersedes the boolean - // ShareOperationalContextWithCloud with three explicit levels: - // - "full" (default) cloud models get the same operational context as - // local models so the Assistant can answer with real detail. - // - "redacted" cloud models get the PII-free operational context only - // (today's opt-in behavior): commands/paths/ports yes, - // identifying hostnames/IPs/aliases stripped. - // - "local_only" no infrastructure context is sent to cloud models at all; - // use a local (Ollama) model for resource-specific answers. - // Local (Ollama) models always receive full context regardless of this dial. - // Empty normalizes to "full" via GetCloudContextPrivacy. - CloudContextPrivacy string `json:"cloud_context_privacy,omitempty"` } // AIProvider constants @@ -147,21 +117,6 @@ const ( AIProviderQuickstart = "quickstart" // Retired Pulse-hosted proxy marker retained only for legacy config migration. ) -// Cloud context privacy dial constants. These are the canonical values for -// AIConfig.CloudContextPrivacy — the single dial controlling what infrastructure -// context cloud models may see. Local (Ollama) models always receive full context. -const ( - // CloudContextPrivacyFull - cloud models receive the same operational context - // as local models so the Assistant answers with real, resource-specific detail. - CloudContextPrivacyFull = "full" - // CloudContextPrivacyRedacted - cloud models receive only PII-free operational - // context (commands/paths/ports); identifying hostnames/IPs/aliases are stripped. - CloudContextPrivacyRedacted = "redacted" - // CloudContextPrivacyLocalOnly - no infrastructure context is sent to cloud - // models; resource-specific answers require a local model. - CloudContextPrivacyLocalOnly = "local_only" -) - // AI Control Level constants const ( // ControlLevelReadOnly - AI can only query infrastructure, no control tools available @@ -267,10 +222,6 @@ func NewDefaultAIConfig() *AIConfig { // Default to critical-only so alert-triggered investigations stay // token-conservative out of the box. Operators can opt warnings in. PatrolAlertTriggerMinSeverity: AlertTriggerSeverityCritical, - // Cloud context privacy defaults to "full": a self-hosted Pulse should - // answer with real resource detail on cloud models out of the box. - // Operators who want redaction or a local-only posture pick it explicitly. - CloudContextPrivacy: CloudContextPrivacyFull, } } @@ -949,44 +900,3 @@ func (c *AIConfig) GetDiscoveryInterval() time.Duration { } return time.Duration(c.DiscoveryIntervalHours) * time.Hour } - -// ShouldShareOperationalContextWithCloud reports whether PII-free operational -// context for governed resources may be sent to cloud models. Nil-safe and -// defaults to false so cloud routing keeps the terse governed redaction unless -// the operator explicitly opts in. -func (c *AIConfig) ShouldShareOperationalContextWithCloud() bool { - if c == nil { - return false - } - return c.ShareOperationalContextWithCloud -} - -// NormalizeCloudContextPrivacy validates and canonicalizes a cloud context -// privacy value. It returns the normalized value and whether the input was a -// recognized level. Empty input is treated as the default ("full") and reported -// as valid; any other unrecognized value reports invalid (so the settings API -// can reject hand-crafted payloads) while still returning the safe default. -func NormalizeCloudContextPrivacy(value string) (string, bool) { - switch strings.ToLower(strings.TrimSpace(value)) { - case "": - return CloudContextPrivacyFull, true - case CloudContextPrivacyFull: - return CloudContextPrivacyFull, true - case CloudContextPrivacyRedacted: - return CloudContextPrivacyRedacted, true - case CloudContextPrivacyLocalOnly: - return CloudContextPrivacyLocalOnly, true - default: - return CloudContextPrivacyFull, false - } -} - -// GetCloudContextPrivacy returns the canonical cloud context privacy level, -// normalizing empty/unknown values to the default ("full"). Nil-safe. -func (c *AIConfig) GetCloudContextPrivacy() string { - if c == nil { - return CloudContextPrivacyFull - } - normalized, _ := NormalizeCloudContextPrivacy(c.CloudContextPrivacy) - return normalized -} diff --git a/internal/config/ai_config_test.go b/internal/config/ai_config_test.go index c415ee969..2e8059301 100644 --- a/internal/config/ai_config_test.go +++ b/internal/config/ai_config_test.go @@ -2,103 +2,10 @@ package config import ( "encoding/json" - "strings" "testing" "time" ) -func TestAIConfig_ShouldShareOperationalContextWithCloud(t *testing.T) { - if (*AIConfig)(nil).ShouldShareOperationalContextWithCloud() { - t.Fatalf("nil config must not share operational context with cloud") - } - - // Default (opt-out): a fresh config must not share operational context. - if NewDefaultAIConfig().ShouldShareOperationalContextWithCloud() { - t.Fatalf("default config must keep cloud operational-context sharing off") - } - if (&AIConfig{}).ShouldShareOperationalContextWithCloud() { - t.Fatalf("zero-value config must keep cloud operational-context sharing off") - } - - // Explicit opt-in is honored. - if !(&AIConfig{ShareOperationalContextWithCloud: true}).ShouldShareOperationalContextWithCloud() { - t.Fatalf("opt-in config must report cloud operational-context sharing on") - } - - // The flag round-trips through JSON and is omitted when off. - off, err := json.Marshal(&AIConfig{}) - if err != nil { - t.Fatalf("marshal off: %v", err) - } - if got := string(off); strings.Contains(got, "share_operational_context_with_cloud") { - t.Fatalf("off flag must be omitted from JSON, got %q", got) - } - on, err := json.Marshal(&AIConfig{ShareOperationalContextWithCloud: true}) - if err != nil { - t.Fatalf("marshal on: %v", err) - } - var decoded AIConfig - if err := json.Unmarshal(on, &decoded); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if !decoded.ShouldShareOperationalContextWithCloud() { - t.Fatalf("on flag must round-trip through JSON, got %q", string(on)) - } -} - -func TestAIConfig_GetCloudContextPrivacy(t *testing.T) { - // Nil-safe and defaults to the homelab-friendly "full" posture. - if got := (*AIConfig)(nil).GetCloudContextPrivacy(); got != CloudContextPrivacyFull { - t.Fatalf("nil config cloud privacy = %q, want %q", got, CloudContextPrivacyFull) - } - // A fresh config opts into full context so the Assistant answers with detail. - if got := NewDefaultAIConfig().GetCloudContextPrivacy(); got != CloudContextPrivacyFull { - t.Fatalf("default config cloud privacy = %q, want %q", got, CloudContextPrivacyFull) - } - // Empty/unset normalizes to full. - if got := (&AIConfig{}).GetCloudContextPrivacy(); got != CloudContextPrivacyFull { - t.Fatalf("zero-value cloud privacy = %q, want %q", got, CloudContextPrivacyFull) - } - - cases := []struct { - in string - want string - }{ - {CloudContextPrivacyFull, CloudContextPrivacyFull}, - {CloudContextPrivacyRedacted, CloudContextPrivacyRedacted}, - {CloudContextPrivacyLocalOnly, CloudContextPrivacyLocalOnly}, - {" Redacted ", CloudContextPrivacyRedacted}, // trimmed + lowercased - {"LOCAL_ONLY", CloudContextPrivacyLocalOnly}, - {"bogus", CloudContextPrivacyFull}, // unknown values fall back to the safe default - } - for _, tc := range cases { - if got := (&AIConfig{CloudContextPrivacy: tc.in}).GetCloudContextPrivacy(); got != tc.want { - t.Fatalf("GetCloudContextPrivacy(%q) = %q, want %q", tc.in, got, tc.want) - } - } -} - -func TestNormalizeCloudContextPrivacy(t *testing.T) { - cases := []struct { - in string - want string - wantValid bool - }{ - {"", CloudContextPrivacyFull, true}, - {"full", CloudContextPrivacyFull, true}, - {"redacted", CloudContextPrivacyRedacted, true}, - {"local_only", CloudContextPrivacyLocalOnly, true}, - {" FULL ", CloudContextPrivacyFull, true}, - {"nonsense", CloudContextPrivacyFull, false}, - } - for _, tc := range cases { - got, valid := NormalizeCloudContextPrivacy(tc.in) - if got != tc.want || valid != tc.wantValid { - t.Fatalf("NormalizeCloudContextPrivacy(%q) = (%q, %v), want (%q, %v)", tc.in, got, valid, tc.want, tc.wantValid) - } - } -} - func TestEffectiveControlLevelForEntitlement(t *testing.T) { tests := []struct { name string @@ -1004,9 +911,6 @@ func TestNewDefaultAIConfig(t *testing.T) { if !config.PatrolAnomalyTriggersEnabled { t.Error("Default anomaly-triggered patrols should be enabled") } - if config.CloudContextPrivacy != CloudContextPrivacyFull { - t.Errorf("Default cloud context privacy should be %q, got %q", CloudContextPrivacyFull, config.CloudContextPrivacy) - } } func TestAIConfig_PatrolEventTriggerSettings(t *testing.T) { diff --git a/internal/config/persistence.go b/internal/config/persistence.go index bdd469541..e7eb5c7b9 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -2130,25 +2130,7 @@ func (c *ConfigPersistence) LoadAIConfig() (*AIConfig, error) { } migratedQuickstartAliases := settings.NormalizeQuickstartModelAliases() - // Migration: derive the cloud_context_privacy dial from the legacy - // share_operational_context_with_cloud toggle for configs persisted before - // the dial existed. This preserves each existing install's current cloud - // behavior (legacy on -> "full", legacy off/absent -> "redacted") instead of - // silently adopting the fresh-install default ("full"). The legacy boolean is - // left untouched so the redaction seam that still reads it behaves identically - // until the dial is wired into the seam directly (privacy-redesign increment 2). - migratedCloudContextPrivacy := false - if _, cloudPrivacyPresent := legacyRaw["cloud_context_privacy"]; !cloudPrivacyPresent { - legacyShare, _ := decodeOptionalJSONBool(legacyRaw["share_operational_context_with_cloud"]) - if legacyShare { - settings.CloudContextPrivacy = CloudContextPrivacyFull - } else { - settings.CloudContextPrivacy = CloudContextPrivacyRedacted - } - migratedCloudContextPrivacy = true - } - - if migratedPlaintext || migratedLegacyFields || migratedControlLevel || migratedPatrolTriggerFields || migratedQuickstartAliases || migratedCloudContextPrivacy { + if migratedPlaintext || migratedLegacyFields || migratedControlLevel || migratedPatrolTriggerFields || migratedQuickstartAliases { jsonData, err := json.Marshal(*settings) if err != nil { return nil, fmt.Errorf("marshal ai config migration rewrite: %w", err) @@ -2161,8 +2143,6 @@ func (c *ConfigPersistence) LoadAIConfig() (*AIConfig, error) { Bool("legacy_fields_migrated", migratedLegacyFields). Bool("patrol_trigger_fields_migrated", migratedPatrolTriggerFields). Bool("quickstart_aliases_migrated", migratedQuickstartAliases). - Bool("cloud_context_privacy_migrated", migratedCloudContextPrivacy). - Str("cloud_context_privacy", settings.CloudContextPrivacy). Bool("plaintext_migrated", migratedPlaintext). Msg("Migrated AI configuration") } diff --git a/internal/config/persistence_ai_test.go b/internal/config/persistence_ai_test.go index b64e6dabe..2421d7166 100644 --- a/internal/config/persistence_ai_test.go +++ b/internal/config/persistence_ai_test.go @@ -309,82 +309,6 @@ func TestPersistence_AIConfig_MigratesLegacyPatrolEventTriggerToggle(t *testing. assert.False(t, loaded.PatrolEventTriggersEnabled) } -func TestPersistence_AIConfig_MigratesCloudContextPrivacyFromLegacyShareToggle(t *testing.T) { - writeLegacy := func(t *testing.T, p *ConfigPersistence, dir string, legacy map[string]interface{}) { - t.Helper() - data, err := json.Marshal(legacy) - require.NoError(t, err) - if p.crypto != nil { - data, err = p.crypto.Encrypt(data) - require.NoError(t, err) - } - require.NoError(t, os.WriteFile(filepath.Join(dir, "ai.enc"), data, 0o600)) - } - - t.Run("legacy share on derives full and leaves the legacy flag untouched", func(t *testing.T) { - tempDir := t.TempDir() - p := NewConfigPersistence(tempDir) - writeLegacy(t, p, tempDir, map[string]interface{}{ - "enabled": true, - "share_operational_context_with_cloud": true, - }) - - loaded, err := p.LoadAIConfig() - require.NoError(t, err) - assert.Equal(t, CloudContextPrivacyFull, loaded.CloudContextPrivacy) - // The redaction seam still reads the legacy boolean until increment 2, so - // migration must preserve it byte-for-byte. - assert.True(t, loaded.ShareOperationalContextWithCloud) - }) - - t.Run("legacy share absent derives redacted preserving current behavior", func(t *testing.T) { - tempDir := t.TempDir() - p := NewConfigPersistence(tempDir) - writeLegacy(t, p, tempDir, map[string]interface{}{"enabled": true}) - - loaded, err := p.LoadAIConfig() - require.NoError(t, err) - assert.Equal(t, CloudContextPrivacyRedacted, loaded.CloudContextPrivacy) - assert.False(t, loaded.ShareOperationalContextWithCloud) - }) - - t.Run("legacy share explicit off derives redacted", func(t *testing.T) { - tempDir := t.TempDir() - p := NewConfigPersistence(tempDir) - writeLegacy(t, p, tempDir, map[string]interface{}{ - "enabled": true, - "share_operational_context_with_cloud": false, - }) - - loaded, err := p.LoadAIConfig() - require.NoError(t, err) - assert.Equal(t, CloudContextPrivacyRedacted, loaded.CloudContextPrivacy) - }) - - t.Run("explicit dial value is preserved and not re-derived", func(t *testing.T) { - tempDir := t.TempDir() - p := NewConfigPersistence(tempDir) - writeLegacy(t, p, tempDir, map[string]interface{}{ - "enabled": true, - "share_operational_context_with_cloud": true, // would imply "full" if re-derived - "cloud_context_privacy": CloudContextPrivacyLocalOnly, - }) - - loaded, err := p.LoadAIConfig() - require.NoError(t, err) - assert.Equal(t, CloudContextPrivacyLocalOnly, loaded.CloudContextPrivacy) - }) - - t.Run("fresh install with no config file defaults to full", func(t *testing.T) { - tempDir := t.TempDir() - p := NewConfigPersistence(tempDir) - - loaded, err := p.LoadAIConfig() - require.NoError(t, err) - assert.Equal(t, CloudContextPrivacyFull, loaded.CloudContextPrivacy) - }) -} - func TestPersistence_AIConfig_NormalizesGranularPatrolTriggerSettingsOnSave(t *testing.T) { tempDir := t.TempDir() p := NewConfigPersistence(tempDir)