diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5f215cda..96a364e3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -211,6 +211,10 @@ class Settings(BaseSettings): # concrete rendered script so a draft_template can be proposed. # Creates a persistent library artifact on accept, so Sonnet. "template_extraction": "standard", + # L1 AI tree builder (Phase 2A): per-node generation is latency-sensitive + # on a live call → Sonnet; classification is a short label task → Haiku. + "l1_realtime_build": "standard", + "l1_classify": "fast", } def get_model_for_action(self, action_type: str) -> str: diff --git a/backend/app/models/account.py b/backend/app/models/account.py index 6a081215..361a3818 100644 --- a/backend/app/models/account.py +++ b/backend/app/models/account.py @@ -67,7 +67,9 @@ class Account(Base): sso_provider: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # "saml" | "oidc" sso_config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) - # L1 AI tree builder — per-account allowlist of problem categories + # L1 AI tree builder — per-account allowlist of problem categories. + # Keep this server_default in sync with DEFAULT_L1_CATEGORIES in + # app/services/l1_category_service.py when adding/removing categories. enabled_l1_categories: Mapped[list[str]] = mapped_column( JSONB(), nullable=False, server_default=sa_text( diff --git a/backend/app/services/l1_category_service.py b/backend/app/services/l1_category_service.py new file mode 100644 index 00000000..cc6afdb5 --- /dev/null +++ b/backend/app/services/l1_category_service.py @@ -0,0 +1,69 @@ +"""L1 category allowlist + the always-forbidden hard floor. + +DEFAULT_L1_CATEGORIES seeds an account's enabled set. HARD_FLOOR_FORBIDDEN is a +category-independent safety floor the AI tree builder must never emit and admins +cannot enable. See spec §5.1/§5.2. +""" +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.account import Account + +# WARNING: keep in sync with Account.enabled_l1_categories server_default in +# app/models/account.py. The migration default (cb9e282267d2) is intentionally +# a frozen copy and is NOT updated when this list changes. +DEFAULT_L1_CATEGORIES: list[str] = [ + "password_reset", "account_lockout", "printer", "email_outlook_client", + "wifi_network_basics", "vpn_connect", "teams_zoom_av", + "browser_cache_cookies", "peripheral_reconnect", "os_restart_update", +] + +# Always-forbidden action classes (keys are stable identifiers; the human-readable +# phrasing lives in the builder system prompt). Admins cannot enable these. +HARD_FLOOR_FORBIDDEN: list[str] = [ + "registry_edit", "system_file_or_boot_edit", "data_or_disk_deletion", + "credential_or_mfa_change", "security_or_av_or_firewall_change", + "elevated_or_admin_script", "domain_dns_dhcp_change", + "server_or_production_config", "billing_or_license_change", +] + +# Substrings that, if present in a generated node's text, indicate a hard-floor +# violation. Used by ai_tree_builder per-node validation (defense in depth). +HARD_FLOOR_TEXT_PATTERNS: list[str] = [ + "regedit", "registry", "format ", "delete partition", "diskpart", + "reset password for", "disable firewall", "disable antivirus", "disable defender", + "run as administrator", "sudo ", "domain controller", "dns record", "dhcp scope", + "uninstall security", "bitlocker", +] + + +def is_category_enabled(category: str, enabled: list[str]) -> bool: + """A category is buildable only if explicitly enabled and not hard-floored.""" + if category in HARD_FLOOR_FORBIDDEN: + return False + return category in enabled + + +async def get_enabled_categories(account_id: UUID, db: AsyncSession) -> list[str]: + """Return the account's enabled L1 categories (``or []`` guards pre-default rows).""" + acct = (await db.execute(select(Account).where(Account.id == account_id))).scalar_one() + return list(acct.enabled_l1_categories or []) + + +async def set_enabled_categories( + account_id: UUID, categories: list[str], db: AsyncSession +) -> list[str]: + """Persist the enabled set, dropping anything unknown or hard-floored. + + Hard-floored keys (HARD_FLOOR_FORBIDDEN) are by design never present in + DEFAULT_L1_CATEGORIES, so the DEFAULT membership filter already excludes them. + If you ever add a key to DEFAULT_L1_CATEGORIES, verify it is not also in + HARD_FLOOR_FORBIDDEN. dict.fromkeys dedupes while preserving first-seen order. + """ + cleaned = list(dict.fromkeys(c for c in categories if c in DEFAULT_L1_CATEGORIES)) + acct = (await db.execute(select(Account).where(Account.id == account_id))).scalar_one() + acct.enabled_l1_categories = cleaned + await db.flush() + return cleaned diff --git a/backend/tests/test_l1_category_service.py b/backend/tests/test_l1_category_service.py new file mode 100644 index 00000000..b198b365 --- /dev/null +++ b/backend/tests/test_l1_category_service.py @@ -0,0 +1,16 @@ +from app.services.l1_category_service import ( + DEFAULT_L1_CATEGORIES, HARD_FLOOR_FORBIDDEN, is_category_enabled, +) + + +def test_defaults_and_hard_floor_present(): + assert "password_reset" in DEFAULT_L1_CATEGORIES + assert "registry_edit" in HARD_FLOOR_FORBIDDEN # representative forbidden action key + assert len(DEFAULT_L1_CATEGORIES) == 10 + + +def test_is_category_enabled(): + enabled = ["printer", "vpn_connect"] + assert is_category_enabled("printer", enabled) is True + assert is_category_enabled("registry_edit", enabled) is False + assert is_category_enabled("unknown", enabled) is False