from __future__ import annotations from datetime import UTC, datetime from typing import Any from sqlmodel import Session, select from ..core.redaction import redact_message from ..core.time import utc_now from ..db.models import AppSetting, Connector from ..schemas.ai_remediation import ( RemediationApprovalProposal, RemediationApprovalRequirement, RemediationDryRunResult, RemediationDryRunStepResult, RemediationEvidence, RemediationPlan, RemediationPlanCollection, RemediationStep, RemediationVerificationCheck, ) from . import ai_security_reviewer from .audit_logger import log_event from .policy_engine import evaluate_policy from .secret_store import list_secret_status, secret_storage_status SOURCE_KEY = 'ai_remediation' MUTATION_TOKENS = ('delete', 'remove', 'prune', 'format', 'renew', 'create', 'update', 'rotate', 'redeploy', 'restart', 'resume', 'pause', 'retry') def _now() -> str: return datetime.now(UTC).isoformat() def _redact(value: Any) -> Any: if isinstance(value, str): lower = value.lower() if any(token in lower for token in ('token_value', 'secret_value', 'api_key=', 'password=', 'bearer ')): return '[REDACTED]' return redact_message(value) if isinstance(value, list): return [_redact(item) for item in value] if isinstance(value, dict): return {str(key): _redact(item) for key, item in value.items() if 'secret' not in str(key).lower() and 'token' not in str(key).lower()} return value def _setting_key(kind: str, plan_id: str) -> str: return f'{SOURCE_KEY}:{kind}:{plan_id}' def _get_status(session: Session, plan_id: str) -> str: row = session.get(AppSetting, _setting_key('plan', plan_id)) if not row: return 'draft' return str(row.value_json.get('status') or 'draft') def _set_status(session: Session, plan_id: str, status: str) -> dict[str, Any]: row = session.get(AppSetting, _setting_key('plan', plan_id)) if not row: row = AppSetting(key=_setting_key('plan', plan_id), is_secret=False) row.value_json = {'status': status, 'updatedAt': _now()} row.updated_at = utc_now() session.add(row) session.commit() _audit(session, f'ai.remediation.plan.{status}', plan_id, {'status': status}) plan = get_plan(session, plan_id) return {'success': True, 'id': plan_id, 'status': status, 'plan': plan.model_dump() if plan else None} def _audit(session: Session, action_id: str, target: str, metadata: dict[str, Any] | None = None) -> None: log_event( session, source='manual', identity='ai_remediation_planner', action_id=action_id, target=target, target_type='ai_remediation', risk='low', policy_decision='allowed', reason='AI Remediation Planner V1 non-executing workflow.', result='success', redacted_metadata=_redact(metadata or {}), ) def _evidence(source: str, label: str, value: Any = None) -> RemediationEvidence: redacted = _redact(value) return RemediationEvidence(source=source, label=label, value=redacted, redacted=redacted != value) def _check(label: str, endpoint: str, expected: str, source: str) -> RemediationVerificationCheck: return RemediationVerificationCheck(label=label, endpoint=endpoint, expectedResult=expected, source=source, readOnly=True) def _resource_sample(resources: list[str], limit: int = 5) -> str: sample = resources[:limit] if not sample: return 'unknown resource' suffix = f' (+{len(resources) - limit} more)' if len(resources) > limit else '' return ', '.join(sample) + suffix def _connector_setup_href_for_secret(session: Session, secret_ref: str) -> str: connector = session.exec(select(Connector).where(Connector.secret_ref == secret_ref)).first() if connector: return f'/setup?step=connectors&connector={connector.id}' return '/secrets' def _approval(session: Session, action_id: str, target: str, source: str = 'manual') -> RemediationApprovalRequirement: policy = evaluate_policy(session, action=action_id, target=target, source=source) return RemediationApprovalRequirement( actionId=action_id, target=target, reason=str(policy.get('reason') or 'Policy evaluation required.'), risk=str(policy.get('risk') or 'medium'), policyDecision=str(policy.get('decision') or 'blocked'), ) def _step( session: Session, step_id: str, label: str, description: str, step_type: str, *, href: str | None = None, action_id: str | None = None, target: str | None = None, source: str = 'manual', evidence: list[RemediationEvidence] | None = None, ) -> RemediationStep: policy = {'decision': 'allowed', 'risk': 'low'} if action_id and target: policy = evaluate_policy(session, action=action_id, target=target, source=source) return RemediationStep( id=step_id, label=label, description=description, type=step_type, # type: ignore[arg-type] actionId=action_id, target=target, href=href, policyDecision=str(policy.get('decision') or 'allowed'), risk=str(policy.get('risk') or 'low'), requiresApproval=policy.get('decision') == 'approval_required', canExecute=False, evidence=evidence or [], ) def _blocked(session: Session, step_id: str, label: str, action_id: str, target: str, description: str) -> RemediationStep: return _step(session, step_id, label, description, 'blocked_action', action_id=action_id, target=target) def _base_plan( session: Session, *, plan_id: str, title: str, description: str, source_type: str, source_id: str, severity: str, confidence: str, affected: list[str], prerequisites: list[str], steps: list[RemediationStep], verification: list[RemediationVerificationCheck], blocked: list[RemediationStep], approvals: list[RemediationApprovalRequirement], risk: str, impact: str, rollback: str, ) -> RemediationPlan: now = _now() return RemediationPlan( id=plan_id, title=str(_redact(title)), description=str(_redact(description)), sourceType=source_type, # type: ignore[arg-type] sourceId=source_id, severity=severity, # type: ignore[arg-type] confidence=confidence, # type: ignore[arg-type] status=_get_status(session, plan_id), # type: ignore[arg-type] affectedResources=[str(_redact(item)) for item in affected], prerequisites=[str(_redact(item)) for item in prerequisites], steps=steps, verificationChecks=verification, blockedActions=blocked, approvalRequirements=approvals, riskAssessment=str(_redact(risk)), estimatedImpact=str(_redact(impact)), rollbackNotes=str(_redact(rollback)), auditPreview=f'Plan {plan_id} can be reviewed or dry-run only; no remediation execution is available in V1.', createdAt=now, updatedAt=now, executable=False, ) def _generic_finding(finding_id: str) -> dict[str, Any]: title_map = { 'ai-sec-auth-public-host-without-authentik': ('Public host without Authentik mapping', 'high', 'auth_coverage', ['public_host']), 'ai-sec-npm-certificate-expiring-soon': ('NPM certificate expiring soon', 'medium', 'exposure', ['certificate']), 'ai-sec-npm-host-without-certificate': ('NPM public host without certificate', 'high', 'exposure', ['public_host']), 'ai-sec-npm-host-without-ssl-force': ('NPM host does not force SSL', 'medium', 'exposure', ['public_host']), 'ai-sec-auth-outpost-unavailable': ('Expected Authentik outpost unavailable', 'medium', 'auth_coverage', ['authentik']), 'ai-sec-policy-unraid-mover-voice-allowed': ('Unraid mover voice action is allowed', 'high', 'policies', ['unraid.mover.start']), 'ai-sec-media-pipeline-issue': ('Media pipeline needs review', 'medium', 'media', ['media_pipeline']), } title, severity, category, affected = title_map.get(finding_id, ('Security finding needs remediation plan', 'medium', 'unknown', ['unknown'])) return { 'id': finding_id, 'title': title, 'description': title, 'severity': severity, 'confidence': 'medium', 'category': category, 'affectedResources': affected, 'evidence': [], } def _plan_for_finding(session: Session, finding: dict[str, Any]) -> RemediationPlan: finding_id = str(finding.get('id') or 'unknown') title = str(_redact(finding.get('title') or finding_id)) severity = str(finding.get('severity') or 'medium') confidence = str(finding.get('confidence') or 'medium') affected = [str(item) for item in finding.get('affectedResources', [])] or ['unknown'] evidence = [_evidence(str(item.get('source', 'security')), str(item.get('label', 'evidence')), item.get('value', item.get('observed'))) for item in finding.get('evidence', []) if isinstance(item, dict)] if 'missing-secret' in finding_id or finding.get('category') == 'secrets': secret_ref = affected[0] setup_href = _connector_setup_href_for_secret(session, secret_ref) steps = [ _step(session, 'open-secrets', 'Open connector setup', 'Open the write-only connector configuration page.', 'open_page', href=setup_href, evidence=evidence), _step(session, 'configure-secret', 'Configure required secret', 'Enter the connector secret in setup. The planner never reads or stores the value.', 'configure_secret', href=setup_href), _step(session, 'test-connector', 'Test connector', 'Run the existing connector test after the secret is configured.', 'run_readonly_check', href=setup_href), _step(session, 'rerun-review', 'Re-run security review', 'Confirm the finding is resolved after connector status updates.', 'run_readonly_check', href='/ai/security-reviewer'), ] return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Remediate missing secret: {secret_ref}', description='Configure the missing connector secret through the write-only vault and validate with read-only checks.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Operator has the secret value outside AppOps.', 'Secret storage key is configured.'], steps=steps, verification=[_check('Connector status connected', '/api/connectors/{id}/test', 'connected or configured', 'connectors'), _check('Security finding resolved', '/api/ai/security/findings?scope=secrets', 'finding absent or acknowledged', 'ai_security')], blocked=[], approvals=[], risk='Low operational risk; primary risk is entering the wrong secret.', impact='Connector read operations become available after configuration.', rollback='Replace the secret with the previous value in the vault if validation fails.') if 'auth-public-host-without-authentik' in finding_id or 'authentik-missing-public-hosts' in finding_id: host_count = len(affected) host_label = 'public host' if host_count == 1 else 'public hosts' host_sample = _resource_sample(affected) host_evidence = evidence + [_evidence('authentik', 'affectedHosts', affected)] blocked = [ _blocked(session, 'blocked-npm-update', 'Automatic NPM modification blocked', 'npm.update_proxy_host', 'nginx_proxy_manager', 'NPM host changes are manual in V1.'), _blocked(session, 'blocked-auth-create', 'Automatic Authentik application/provider creation blocked', 'authentik.create_application', 'authentik', 'Identity-provider changes are manual in V1.'), ] approvals = [_approval(session, 'authentik.create_application', 'authentik'), _approval(session, 'npm.update_proxy_host', 'nginx_proxy_manager')] steps = [ _step(session, 'open-network', 'Open Exposure Center', f'Review the affected public host list host-by-host: {host_sample}.', 'open_page', href='/network', evidence=host_evidence), _step(session, 'review-npm-host', 'Review NPM proxy host', 'Confirm host-by-host whether each route should stay public and which access list or external auth applies.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'), _step(session, 'open-authentik', 'Open Authentik applications', 'Review existing Authentik applications/providers/outposts.', 'run_readonly_check', action_id='authentik.auth_coverage', target='authentik', href='/apps/authentik'), _step(session, 'manual-auth-change', 'Manually create or verify Authentik coverage', 'Make required Authentik/NPM changes outside this planner for every affected host that should be identity-protected.', 'manual_external_change', href='/apps/authentik'), _step(session, 'rerun-coverage', 'Re-run Auth Coverage', 'Confirm every affected host is protected or intentionally reviewed.', 'run_readonly_check', action_id='authentik.auth_coverage', target='authentik', href='/network'), ] return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Add identity coverage for {host_count} {host_label}', description=f'{title}: {host_sample}', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM and Authentik connectors are configured.', 'Operator can administer Authentik/NPM directly.', 'Each affected public host is reviewed host-by-host before external changes.'], steps=steps, verification=[_check('Auth coverage protected', '/api/apps/authentik/auth-coverage', 'each affected host is protected, likely_protected, or explicitly reviewed', 'authentik')], blocked=blocked, approvals=approvals, risk='High if left unresolved because public routes may bypass identity controls.', impact='Manual external changes may affect public access to one or more hosts.', rollback='Revert external Authentik/NPM changes in their native UIs if access breaks.') if 'certificate-expiring' in finding_id or 'without-certificate' in finding_id: blocked = [_blocked(session, 'blocked-cert-renew', 'Automatic certificate renew blocked', 'npm.renew_certificate', 'nginx_proxy_manager', 'Certificate renewal remains manual in NPM for V1.')] steps = [ _step(session, 'open-npm', 'Open NPM AppDetail certificate panel', 'Inspect certificate metadata and affected domains.', 'open_page', href='/apps/nginx_proxy_manager', evidence=evidence), _step(session, 'review-cert', 'Review certificate expiry', 'Validate expiry date, provider and covered domains.', 'run_readonly_check', action_id='npm.certificates', target='nginx_proxy_manager', href='/network'), _step(session, 'manual-renew', 'Manually renew in NPM if needed', 'Perform renewal in NPM directly; this planner will not call renew.', 'manual_external_change', href='/apps/nginx_proxy_manager'), _step(session, 'rerun-cert-check', 'Re-run NPM cert check', 'Refresh certificate warning data.', 'run_readonly_check', action_id='npm.expiring_certificates', target='nginx_proxy_manager', href='/network'), ] return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review expiring NPM certificate', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM connector can read certificate metadata.', 'Operator has NPM admin access.'], steps=steps, verification=[_check('Certificate no longer expiring', '/api/apps/nginx-proxy-manager/certificates/expiring?days=30', 'affected certificate absent', 'npm')], blocked=blocked, approvals=[_approval(session, 'npm.renew_certificate', 'nginx_proxy_manager')], risk='Medium; access can fail when TLS expires.', impact='Manual renewal may briefly affect the public host.', rollback='Restore previous certificate state in NPM if renewal introduces errors.') if 'without-ssl-force' in finding_id or 'ssl force' in title.lower() or 'force ssl' in title.lower(): steps = [ _step(session, 'open-network', 'Open Exposure Center', 'Inspect the affected NPM public host and its SSL policy.', 'open_page', href='/network', evidence=evidence), _step(session, 'review-proxy-host', 'Review proxy host SSL settings', 'Confirm Force SSL is disabled for the affected host and whether that is intentional.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'), _step(session, 'manual-ssl-change', 'Manually enable Force SSL in NPM if appropriate', 'Change Force SSL in NPM directly after checking the app still works over HTTPS.', 'manual_external_change', href='/apps/nginx_proxy_manager'), _step(session, 'rerun-exposure', 'Re-run exposure review', 'Confirm the SSL-force finding is resolved or explicitly acknowledged.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'), ] return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review NPM SSL enforcement', description=f'Force SSL is not enabled for: {_resource_sample(affected)}.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM connector can read proxy-host metadata.', 'Operator has NPM admin access.', 'HTTPS certificate is valid for the affected host.'], steps=steps, verification=[_check('Proxy host forces SSL', '/api/apps/nginx-proxy-manager/proxy-hosts', 'affected host has sslForced=true or finding acknowledged', 'npm')], blocked=[_blocked(session, 'blocked-npm-update', 'Automatic NPM proxy-host update blocked', 'npm.update_proxy_host', 'nginx_proxy_manager', 'Planner V1 does not mutate NPM proxy hosts.')], approvals=[_approval(session, 'npm.update_proxy_host', 'nginx_proxy_manager')], risk='Medium; users may reach the host over plain HTTP if Force SSL is disabled.', impact='Manual Force SSL changes can affect clients using HTTP-only URLs.', rollback='Disable Force SSL again in NPM if the host breaks, then review certificate and upstream settings.') if 'outpost-unavailable' in finding_id or 'outpost' in title.lower(): steps = [ _step(session, 'open-authentik', 'Open Authentik AppDetail', 'Inspect Authentik connector health and outpost metadata.', 'open_page', href='/apps/authentik', evidence=evidence), _step(session, 'review-outpost-health', 'Review outpost health', 'Read the Authentik outpost health summary and provider count.', 'run_readonly_check', action_id='authentik.outpost_health', target='authentik', href='/apps/authentik'), _step(session, 'manual-outpost-review', 'Verify outpost in Authentik', 'Use Authentik directly to check the embedded outpost, providers and integration health.', 'manual_external_change', href='/apps/authentik'), _step(session, 'rerun-auth-review', 'Re-run Authentik security review', 'Confirm outpost health is healthy or intentionally documented.', 'run_readonly_check', action_id='authentik.outpost_health', target='authentik', href='/ai/security-reviewer'), ] return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review Authentik outpost health', description='Authentik reports the expected outpost as unavailable or unknown.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Authentik connector is configured.', 'Operator can inspect Authentik outposts directly.'], steps=steps, verification=[_check('Outpost health is available', '/api/apps/authentik/outposts/health', 'available=true or explicitly reviewed', 'authentik')], blocked=[_blocked(session, 'blocked-auth-update', 'Automatic Authentik outpost mutation blocked', 'authentik.update_policy', 'authentik', 'Planner V1 does not mutate Authentik topology.')], approvals=[_approval(session, 'authentik.update_policy', 'authentik')], risk='Medium; unknown outpost health can make external auth coverage harder to trust.', impact='Manual Authentik changes can affect public authentication flows.', rollback='Revert outpost/provider changes in Authentik if application access is affected.') if finding.get('category') == 'media': steps = [ _step(session, 'open-media', 'Open Media Pipeline', 'Review connector status, stalled downloads and pipeline issues.', 'open_page', href='/media-pipeline', evidence=evidence), _step(session, 'run-diagnose', 'Run media pipeline diagnose', 'Use the existing read-only diagnose flow to collect current media pipeline state.', 'run_readonly_check', action_id='media_pipeline.diagnose', target='media-pipeline', href='/media-pipeline'), _step(session, 'review-connectors', 'Review missing media connectors', 'Configure only the connectors you intend to use; missing optional connectors should remain visible as partial coverage.', 'bind_connector', href='/setup?step=connectors'), _step(session, 'rerun-review', 'Re-run safety/security review', 'Confirm media finding is resolved, reduced to known partial coverage or acknowledged.', 'run_readonly_check', action_id='media_pipeline.diagnose', target='media-pipeline', href='/safety'), ] return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review media pipeline issue', description=str(_redact(finding.get('description') or title)), source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Media Pipeline route is reachable.', 'Operator knows which media connectors should be active in this deployment.'], steps=steps, verification=[_check('Media pipeline summary reviewed', '/api/media-pipeline/summary', 'status healthy, warning accepted, or partial coverage documented', 'media_pipeline'), _check('Media pipeline issues reviewed', '/api/media-pipeline/issues', 'no unexpected critical issues', 'media_pipeline')], blocked=[_blocked(session, 'blocked-deluge-delete-data', 'No delete/remove with data', 'deluge.remove_torrent_data', 'deluge', 'Delete-with-data remains blocked.')], approvals=[_approval(session, 'media_pipeline.diagnose', 'media-pipeline')], risk='Medium; media connector gaps can hide stalled downloads or processing failures.', impact='Read-only review has no runtime impact; any connector setup is manual.', rollback='Remove or replace connector settings manually if a configured connector is wrong.') if finding.get('category') == 'policies' or 'policy-' in finding_id or 'voice' in title.lower(): action_id = affected[0] if affected else 'unraid.mover.start' steps = [ _step(session, 'open-policies', 'Open Policies page', 'Inspect the risky policy rule.', 'open_page', href='/policies', evidence=evidence), _step(session, 'simulate-voice', 'Simulate voice policy decision', 'Use policy simulation for the affected action and target.', 'review_policy', action_id=action_id, target='unraid', source='voice', href='/policies'), _step(session, 'draft-policy', 'Create policy draft suggestion', 'Draft a safer blocked or approval-required setting for operator review.', 'documentation', href='/policies'), _step(session, 'manual-apply', 'Manual policy apply outside V1', 'Apply the policy change through an explicit future policy workflow, not this planner.', 'manual_external_change', href='/policies'), ] blocked = [_blocked(session, 'blocked-policy-update', 'Automatic policy apply blocked', 'authentik.update_policy', 'policy', 'Planner V1 does not mutate policies.')] return _base_plan(session, plan_id=f'rem-{finding_id}', title='Reduce risky voice policy', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Operator understands desired policy posture.', 'Policy changes are reviewed manually.'], steps=steps, verification=[_check('Policy no longer voice-allowed', '/api/policies/evaluate', 'blocked or approval_required for voice', 'policies')], blocked=blocked, approvals=[_approval(session, action_id, 'unraid', 'voice')], risk='High when voice can trigger state-changing operations.', impact='Safer policy can require more manual approvals.', rollback='Restore previous policy value from audit/config backup if needed.') if finding.get('category') == 'inventory' or 'critical-app-unprotected' in finding_id or 'needs review' in title.lower(): app_id = affected[0] steps = [ _step(session, 'open-inventory', 'Open Inventory/AppDetail', 'Review app import, category and criticality.', 'open_page', href='/inventory', evidence=evidence), _step(session, 'mark-protected', 'Mark protected manually or review import', 'Use existing inventory workflows to classify the app.', 'manual_external_change', href=f'/apps/{app_id}'), _step(session, 'review-policy-profile', 'Review policy profile', 'Confirm policy profile matches app criticality.', 'review_policy', href='/policies'), _step(session, 'rerun-review', 'Re-run safety/security review', 'Confirm protection finding is gone.', 'run_readonly_check', href='/ai/security-reviewer'), ] return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review critical app protection', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Inventory source is current.', 'Operator has enough context to classify the app.'], steps=steps, verification=[_check('App managed/protected', '/api/inventory/apps', 'protected=true or reviewed', 'inventory')], blocked=[_blocked(session, 'blocked-policy-mutation', 'Automatic policy mutation blocked', 'stack.redeploy', app_id, 'Planner does not mutate app policy profile.')], approvals=[], risk='High for critical apps without explicit protection.', impact='Manual classification affects future policy decisions.', rollback='Change classification back manually if misclassified.') if finding.get('category') == 'audit' or 'blocked-attempts' in finding_id: steps = [ _step(session, 'open-audit', 'Open Audit Log', 'Inspect repeated blocked/failed attempts.', 'open_page', href='/audit', evidence=evidence), _step(session, 'review-automation', 'Review automation or voice source', 'Identify whether attempts are expected or noisy.', 'documentation', href='/audit'), _step(session, 'review-policy', 'Review related policy', 'Confirm blocked behavior is intentional.', 'review_policy', href='/policies'), ] return _base_plan(session, plan_id=f'rem-{finding_id}', title='Triage repeated blocked attempts', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Recent audit events are available.'], steps=steps, verification=[_check('Blocked attempts decrease', '/api/audit', 'no repeated attempts in next window', 'audit')], blocked=[], approvals=[], risk='Medium; repeated denials can indicate noisy automation or probing.', impact='May require changing external automation or voice phrasing.', rollback='Re-enable previous automation only after policy review.') return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Review finding: {title}', description=str(_redact(finding.get('description') or title)), source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Review source finding evidence.'], steps=[_step(session, 'open-reviewer', 'Open Security Reviewer', 'Inspect finding evidence and links.', 'open_page', href='/ai/security-reviewer', evidence=evidence)], verification=[_check('Finding reviewed', '/api/ai/security/findings', 'status acknowledged or resolved', 'ai_security')], blocked=[], approvals=[], risk='Depends on source finding severity.', impact='No direct impact; V1 is plan-only.', rollback='Dismiss plan if not actionable.') def _plan_for_media_issue(session: Session, issue: dict[str, Any]) -> RemediationPlan: issue_id = str(issue.get('id') or issue.get('title') or 'media-issue').lower().replace(' ', '-') title = str(_redact(issue.get('title') or 'Media pipeline issue')) affected = [str(item) for item in issue.get('affectedConnectors', issue.get('affected', []))] or ['media_pipeline'] evidence = [_evidence('media_pipeline', 'issue', issue.get('evidence', []))] if 'widefrog' in title.lower() or 'widefrog' in affected: steps = [ _step(session, 'open-widefrog', 'Open Widefrog AppDetail', 'Review failed jobs and queue state.', 'open_page', href='/apps/widefrog', evidence=evidence), _step(session, 'review-failed-jobs', 'Review failed jobs', 'Inspect failure reason before retry.', 'run_readonly_check', action_id='widefrog.failed_jobs', target='widefrog', href='/apps/widefrog'), _step(session, 'retry-if-approved', 'Plan retry_failed through policy flow', 'Retry can only run through existing policy/approval flow outside this planner.', 'request_approval', action_id='widefrog.retry_failed', target='widefrog', href='/apps/widefrog'), _step(session, 'verify-queue', 'Verify queue/activity', 'Confirm failed count decreases after manual execution.', 'run_readonly_check', action_id='widefrog.queue_status', target='widefrog', href='/apps/widefrog'), ] blocked = [_blocked(session, 'blocked-delete-jobs', 'Delete jobs/media blocked', 'widefrog.delete_job', 'widefrog', 'Delete operations are forbidden.')] return _base_plan(session, plan_id='rem-media-widefrog-failed', title='Plan Widefrog failed jobs recovery', description=title, source_type='media_issue', source_id=issue_id, severity='medium', confidence='medium', affected=affected, prerequisites=['Widefrog connector configured.', 'Operator confirms retry is safe.'], steps=steps, verification=[_check('Failed jobs decrease', '/api/apps/widefrog/failed', 'failed count lower', 'widefrog')], blocked=blocked, approvals=[_approval(session, 'widefrog.retry_failed', 'widefrog')], risk='Medium; retry can repeat failed processing.', impact='May increase load on media processing.', rollback='Stop external retry workflow if failures repeat.') steps = [ _step(session, 'open-media', 'Open Media Pipeline', 'Review pipeline issue context.', 'open_page', href='/media-pipeline', evidence=evidence), _step(session, 'open-deluge', 'Open Deluge detail', 'Review stalled torrents without deleting data.', 'run_readonly_check', action_id='deluge.stalled_downloads', target='deluge', href='/apps/deluge'), _step(session, 'resume-specific', 'Plan resume specific torrent if policy allows', 'Resume must go through existing policy/approval flow outside this planner.', 'request_approval', action_id='deluge.resume_torrent', target='deluge', href='/apps/deluge'), _step(session, 'verify-stalled', 'Verify stalled count decreases', 'Re-run download checks after manual action.', 'run_readonly_check', action_id='deluge.stalled_downloads', target='deluge', href='/media-pipeline'), ] blocked = [_blocked(session, 'blocked-remove-data', 'No delete/remove with data', 'deluge.remove_torrent_data', 'deluge', 'Delete-with-data remains blocked.')] return _base_plan(session, plan_id='rem-media-deluge-stalled', title='Plan Deluge stalled downloads recovery', description=title, source_type='media_issue', source_id=issue_id, severity='medium', confidence='medium', affected=affected, prerequisites=['Deluge connector configured.', 'Operator identifies the specific stalled torrent.'], steps=steps, verification=[_check('Stalled downloads decrease', '/api/apps/deluge/stalled', 'stalled count lower', 'deluge')], blocked=blocked, approvals=[_approval(session, 'deluge.resume_torrent', 'deluge')], risk='Medium; resuming downloads can affect bandwidth and storage.', impact='Download queue may resume network/storage activity.', rollback='Pause the specific torrent through normal policy flow if needed.') def _inventory_plans(session: Session) -> list[RemediationPlan]: from .app_registry import list_active_apps plans: list[RemediationPlan] = [] for app in list_active_apps(session): if app.needs_review: finding = {'id': f'inventory-needs-review-{app.id}', 'title': f'Discovered app needs review: {app.name}', 'severity': app.criticality if app.criticality in {'low', 'medium', 'high', 'critical'} else 'medium', 'confidence': 'medium', 'category': 'inventory', 'affectedResources': [app.id], 'evidence': []} plans.append(_plan_for_finding(session, finding)) break return plans def _connector_plans(session: Session) -> list[RemediationPlan]: plans: list[RemediationPlan] = [] for connector in session.exec(select(Connector)).all(): if connector.status == 'missing_config': finding = {'id': f'connector-missing-config-{connector.id}', 'title': f'Connector missing config: {connector.name}', 'severity': 'medium', 'confidence': 'high', 'category': 'connectors', 'affectedResources': [connector.id], 'evidence': [{'source': 'connectors', 'label': 'status', 'value': connector.status}]} plans.append(_plan_for_finding(session, finding)) break return plans def _secret_storage_plan(session: Session) -> list[RemediationPlan]: status = secret_storage_status() if status.get('status') != 'warning' and status.get('productionReady') is not False: return [] plan = _base_plan( session, plan_id='rem-secret-storage-key-warning', title='Configure production secret storage key', description='Configure APPOPS_SECRET_KEY or APPOPS_SECRET_KEY_FILE and back it up with the database.', source_type='secret', source_id='secret-storage-key-warning', severity='medium', confidence='high', affected=['secret_storage'], prerequisites=['Operator can set deployment environment variables.', 'Backup process is available.'], steps=[ _step(session, 'open-settings', 'Open settings/secrets status', 'Review key source and production readiness.', 'open_page', href='/secrets'), _step(session, 'read-docs', 'Open Secret Storage docs', 'Follow production key setup guidance.', 'documentation', href='/docs/SECRET_STORAGE.md'), _step(session, 'configure-key', 'Configure APPOPS_SECRET_KEY', 'Set the key in deployment environment outside this planner.', 'manual_external_change'), _step(session, 'backup-db-key', 'Back up database with key', 'Back up /data/appops.db and the key material together.', 'documentation'), _step(session, 'runtime-health', 'Re-run runtime health', 'Confirm production readiness.', 'run_readonly_check', href='/settings'), ], verification=[_check('Runtime health production ready', '/api/runtime/info', 'secretStorage.productionReady=true', 'runtime')], blocked=[_blocked(session, 'blocked-key-rotation', 'Automated key rotation blocked', 'secrets.show', 'secret_storage', 'Key automation is not implemented in planner V1.')], approvals=[], risk='Medium; missing production key affects encrypted secret durability.', impact='Deployment environment change and backup process update.', rollback='Restore previous environment and database backup if startup fails.', ) return [plan] def generate_plans(session: Session, audit_run: bool = False, include_media_issues: bool | None = None) -> RemediationPlanCollection: if include_media_issues is None: include_media_issues = audit_run review = ai_security_reviewer.generate_review(session) findings = [finding.model_dump() for finding in review.findings] recommendations = [recommendation.model_dump() for recommendation in review.recommendations] finding_ids = {str(finding.get('id')) for finding in findings} plans = [_plan_for_finding(session, finding) for finding in findings if finding.get('status') != 'dismissed'] plans.extend( _plan_for_recommendation(session, recommendation) for recommendation in recommendations if recommendation.get('status') != 'dismissed' and str(recommendation.get('id') or '').removeprefix('rec-') not in finding_ids ) if include_media_issues: try: from . import media_pipeline_service issues = media_pipeline_service.issues(session).get('issues', []) except Exception as exc: # noqa: BLE001 issues = [{'id': 'media-unavailable', 'title': redact_message(str(exc)), 'affectedConnectors': ['media_pipeline']}] plans.extend(_plan_for_media_issue(session, issue) for issue in issues if isinstance(issue, dict)) plans.extend(_inventory_plans(session)) plans.extend(_connector_plans(session)) plans.extend(_secret_storage_plan(session)) deduped = list({plan.id: plan for plan in plans}.values()) if audit_run: _audit(session, 'ai.remediation.generate', 'all', {'plans': len(deduped)}) return RemediationPlanCollection(status='ok', generatedAt=_now(), total=len(deduped), plans=deduped) def _plan_for_recommendation(session: Session, recommendation: dict[str, Any]) -> RemediationPlan: source_id = str(recommendation.get('id') or 'recommendation') finding_id = source_id.removeprefix('rec-') finding = { 'id': finding_id, 'title': recommendation.get('title') or source_id, 'description': recommendation.get('description') or recommendation.get('suggestedAction') or source_id, 'severity': recommendation.get('severity') or 'medium', 'confidence': recommendation.get('confidence') or 'medium', 'category': 'unknown', 'affectedResources': recommendation.get('affectedResources', []), 'evidence': recommendation.get('evidence', []), } plan = _plan_for_finding(session, finding) return plan.model_copy(update={'id': f'rem-{source_id}', 'sourceType': 'security_recommendation', 'sourceId': source_id, 'status': _get_status(session, f'rem-{source_id}')}) def get_plans(session: Session) -> RemediationPlanCollection: return generate_plans(session) def get_plan(session: Session, plan_id: str) -> RemediationPlan | None: plan = next((item for item in generate_plans(session).plans if item.id == plan_id), None) if plan: return plan if plan_id.startswith('rem-ai-sec-'): return _plan_for_finding(session, _generic_finding(plan_id.removeprefix('rem-'))) return None def generate_from_finding(session: Session, finding_id: str) -> RemediationPlan: finding = ai_security_reviewer.get_finding(session, finding_id) or _generic_finding(finding_id) plan = _plan_for_finding(session, finding) _audit(session, 'ai.remediation.plan.generated', plan.id, {'sourceType': 'security_finding', 'sourceId': finding_id}) return plan def generate_from_recommendation(session: Session, recommendation_id: str) -> RemediationPlan: recommendation = next((item for item in ai_security_reviewer.get_recommendations(session) if item['id'] == recommendation_id), {'id': recommendation_id, 'title': recommendation_id, 'severity': 'medium', 'confidence': 'medium', 'affectedResources': []}) plan = _plan_for_recommendation(session, recommendation) _audit(session, 'ai.remediation.plan.generated', plan.id, {'sourceType': 'security_recommendation', 'sourceId': recommendation_id}) return plan def review_plan(session: Session, plan_id: str) -> dict[str, Any]: return _set_status(session, plan_id, 'reviewed') def dismiss_plan(session: Session, plan_id: str) -> dict[str, Any]: return _set_status(session, plan_id, 'dismissed') def dry_run(session: Session, plan_id: str) -> RemediationDryRunResult | None: plan = get_plan(session, plan_id) if not plan: return None step_results: list[RemediationDryRunStepResult] = [] approvals: list[RemediationApprovalRequirement] = [] blocked: list[RemediationApprovalRequirement] = [] reasons: list[str] = ['Planner V1 dry-run only; no steps were executed.'] for step in plan.steps + plan.blockedActions: decision = step.policyDecision risk = step.risk reason = 'No executable action attached.' if step.actionId and step.target: policy = evaluate_policy(session, action=step.actionId, target=step.target, source='manual') decision = str(policy.get('decision') or 'blocked') risk = str(policy.get('risk') or step.risk) reason = str(policy.get('reason') or 'Policy evaluated.') status = 'blocked' if decision == 'blocked' or step.type == 'blocked_action' else ('warning' if decision == 'approval_required' else 'pass') result = RemediationDryRunStepResult(stepId=step.id, label=step.label, actionId=step.actionId, target=step.target, policyDecision=decision, risk=risk, status=status, reason=reason, canExecute=False) step_results.append(result) if step.actionId and step.target and decision == 'approval_required': approvals.append(RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason=reason, risk=risk, policyDecision=decision)) if step.actionId and step.target and status == 'blocked': blocked.append(RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason=reason, risk=risk, policyDecision=decision)) missing_secret_names = [item.get('secretRef') for item in list_secret_status(session) if not item.get('hasSecret')] unresolved = [f'Missing secret: {name}' for name in missing_secret_names if name] if blocked: status = 'blocked' elif approvals or unresolved: status = 'warning' else: status = 'pass' result = RemediationDryRunResult(planId=plan.id, status=status, executed=False, reasons=reasons, prerequisites=plan.prerequisites, unresolvedBlockers=unresolved, blockedActions=blocked, approvalRequirements=approvals, verificationChecks=plan.verificationChecks, stepResults=step_results) _audit(session, 'ai.remediation.dry_run', plan_id, {'status': status, 'blocked': len(blocked), 'approvals': len(approvals)}) return result def create_approval_proposal(session: Session, plan_id: str) -> RemediationApprovalProposal | None: plan = get_plan(session, plan_id) if not plan: return None proposals = plan.approvalRequirements or [ RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason='Manual approval may be required by policy.', risk=step.risk, policyDecision=step.policyDecision) for step in plan.steps if step.actionId and step.target and step.policyDecision == 'approval_required' ] row = session.get(AppSetting, _setting_key('proposal', plan_id)) if not row: row = AppSetting(key=_setting_key('proposal', plan_id), is_secret=False) row.value_json = {'createdAt': _now(), 'proposals': [proposal.model_dump() for proposal in proposals], 'executed': False, 'approvalId': None} row.updated_at = utc_now() session.add(row) session.commit() _audit(session, 'ai.remediation.approval_proposal', plan_id, {'proposals': len(proposals), 'executed': False}) return RemediationApprovalProposal(planId=plan_id, created=True, executed=False, approvalId=None, proposals=proposals, message='Approval proposal created as planner draft only. No approval or action was executed.')