Improve security

This commit is contained in:
2026-03-07 15:34:23 +00:00
parent add685a326
commit 611de57bf8
31 changed files with 3617 additions and 58 deletions

View File

@@ -1,10 +1,12 @@
import time
from django.http import JsonResponse
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import render
from django.urls import reverse
from django.views import View
from core.clients import transport
from core.models import (
AdapterHealthEvent,
AIRequest,
@@ -28,6 +30,9 @@ from core.models import (
Persona,
PersonIdentifier,
QueuedMessage,
CommandSecurityPolicy,
UserXmppOmemoState,
UserXmppSecuritySettings,
WorkspaceConversation,
WorkspaceMetricSnapshot,
)
@@ -459,3 +464,357 @@ class MemorySearchQueryAPI(SuperUserRequiredMixin, View):
],
}
)
def _parse_xmpp_jid(jid_str: str) -> dict:
"""Split a full JID (localpart@domain/resource) into components."""
raw = str(jid_str or "").strip()
bare, _, resource = raw.partition("/")
localpart, _, domain = bare.partition("@")
return {"full": raw, "bare": bare, "localpart": localpart, "domain": domain, "resource": resource}
def _to_bool(value, default=False):
if value is None:
return bool(default)
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on", "y"}:
return True
if text in {"0", "false", "no", "off", "n"}:
return False
return bool(default)
class SecurityPage(LoginRequiredMixin, View):
"""Security settings page for OMEMO and command-scope policy controls."""
template_name = "pages/security.html"
GLOBAL_SCOPE_KEY = "global.override"
# Allowed Services list used by both Global Scope Override and local scopes.
# Keep this in sync with the UI text on the Security page.
POLICY_SERVICES = ["xmpp", "whatsapp", "signal", "instagram", "web"]
# Override mode names as shown in the interface:
# - per_scope: local scope controls remain editable
# - on/off: global override forces each local scope value
OVERRIDE_OPTIONS = ("per_scope", "on", "off")
GLOBAL_OVERRIDE_FIELDS = (
"scope_enabled",
"require_omemo",
"require_trusted_fingerprint",
)
POLICY_SCOPES = [
("gateway.tasks", "Gateway .tasks commands", "Handles .tasks list/show/complete/undo over gateway channels."),
("gateway.approval", "Gateway approval commands", "Handles .approval/.codex/.claude approve/deny over gateway channels."),
("gateway.totp", "Gateway TOTP enrollment", "Controls TOTP enrollment/status commands over gateway channels."),
("tasks.submit", "Task submissions from chat", "Controls automatic task creation from inbound messages."),
("tasks.commands", "Task command verbs (.task/.undo/.epic)", "Controls explicit task command verbs."),
("command.bp", "Business plan command", "Controls Business Plan command execution."),
("command.codex", "Codex command", "Controls Codex command execution."),
("command.claude", "Claude command", "Controls Claude command execution."),
]
POLICY_GROUP_LABELS = {
"gateway": "Gateway",
"tasks": "Tasks",
"command": "Commands",
"agentic": "Agentic",
"other": "Other",
}
def _security_settings(self, request):
row, _ = UserXmppSecuritySettings.objects.get_or_create(user=request.user)
return row
def _parse_override_value(self, value):
option = str(value or "").strip().lower()
if option == "inherit":
# Backward-compat for existing persisted values.
option = "per_scope"
if option in self.OVERRIDE_OPTIONS:
return option
return "per_scope"
def _global_override_payload(self, request):
row, _ = CommandSecurityPolicy.objects.get_or_create(
user=request.user,
scope_key=self.GLOBAL_SCOPE_KEY,
defaults={
"enabled": True,
"allowed_services": [],
"allowed_channels": {},
"settings": {},
},
)
settings_payload = dict(row.settings or {})
values = {
"scope_enabled": self._parse_override_value(
settings_payload.get("scope_enabled")
),
"require_omemo": self._parse_override_value(
settings_payload.get("require_omemo")
),
"require_trusted_fingerprint": self._parse_override_value(
settings_payload.get("require_trusted_fingerprint")
),
}
allowed_services = [
str(value or "").strip().lower()
for value in (row.allowed_services or [])
if str(value or "").strip()
]
channel_rules = self._channel_rules_from_map(dict(row.allowed_channels or {}))
if not channel_rules:
channel_rules = [{"service": "xmpp", "pattern": ""}]
return {
"row": row,
"values": values,
"allowed_services": allowed_services,
"channel_rules": channel_rules,
}
def _apply_global_override(self, current_value: bool, option: str) -> bool:
normalized = self._parse_override_value(option)
if normalized == "on":
return True
if normalized == "off":
return False
return bool(current_value)
def _channel_rules_from_map(self, source_map):
rows = []
raw = dict(source_map or {})
for service_key, patterns in raw.items():
service_name = str(service_key or "").strip().lower()
if not service_name:
continue
if isinstance(patterns, list):
for pattern in patterns:
pattern_text = str(pattern or "").strip()
if pattern_text:
rows.append({
"service": service_name,
"pattern": pattern_text,
})
return rows
def _channels_map_from_post(self, request):
channel_services = request.POST.getlist("allowed_channel_service")
channel_patterns = request.POST.getlist("allowed_channel_pattern")
allowed_channels: dict[str, list[str]] = {}
for idx, raw_pattern in enumerate(channel_patterns):
pattern = str(raw_pattern or "").strip()
if not pattern:
continue
service_name = str(
channel_services[idx] if idx < len(channel_services) else ""
).strip().lower()
if not service_name:
service_name = "*"
allowed_channels.setdefault(service_name, [])
if pattern not in allowed_channels[service_name]:
allowed_channels[service_name].append(pattern)
return allowed_channels
def _scope_rows(self, request):
global_overrides = self._global_override_payload(request)["values"]
rows = {
str(item.scope_key or "").strip().lower(): item
for item in CommandSecurityPolicy.objects.filter(user=request.user).exclude(
scope_key=self.GLOBAL_SCOPE_KEY
)
}
payload = []
for scope_key, label, description in self.POLICY_SCOPES:
key = str(scope_key or "").strip().lower()
item = rows.get(key)
raw_allowed_services = [
str(value or "").strip().lower()
for value in (getattr(item, "allowed_services", []) or [])
if str(value or "").strip()
]
channel_rules = self._channel_rules_from_map(
dict(getattr(item, "allowed_channels", {}) or {})
)
if not channel_rules:
channel_rules = [{"service": "xmpp", "pattern": ""}]
enabled_locked = global_overrides["scope_enabled"] != "per_scope"
require_omemo_locked = global_overrides["require_omemo"] != "per_scope"
require_trusted_locked = (
global_overrides["require_trusted_fingerprint"] != "per_scope"
)
payload.append({
"scope_key": key,
"label": label,
"description": description,
"enabled": self._apply_global_override(
bool(getattr(item, "enabled", True)),
global_overrides["scope_enabled"],
),
"require_omemo": self._apply_global_override(
bool(getattr(item, "require_omemo", False)),
global_overrides["require_omemo"],
),
"require_trusted_fingerprint": self._apply_global_override(
bool(getattr(item, "require_trusted_omemo_fingerprint", False)),
global_overrides["require_trusted_fingerprint"],
),
"enabled_locked": enabled_locked,
"require_omemo_locked": require_omemo_locked,
"require_trusted_fingerprint_locked": require_trusted_locked,
"lock_help": "Set this field to 'Per Scope' in Global Scope Override to edit it here.",
"allowed_services": raw_allowed_services,
"channel_rules": channel_rules,
})
return payload
def _scope_group_key(self, scope_key: str) -> str:
key = str(scope_key or "").strip().lower()
if key in {"command.codex", "command.claude"}:
return "agentic"
if key.startswith("gateway."):
return "command"
if key.startswith("tasks."):
if key == "tasks.submit":
return "tasks"
return "command"
if key.startswith("command."):
return "command"
if ".commands" in key:
return "command"
if ".approval" in key:
return "command"
if ".totp" in key:
return "command"
if ".task" in key:
return "tasks"
return "other"
def _grouped_scope_rows(self, request):
rows = self._scope_rows(request)
grouped: dict[str, list[dict]] = {key: [] for key in self.POLICY_GROUP_LABELS}
for row in rows:
group_key = self._scope_group_key(row.get("scope_key"))
grouped.setdefault(group_key, [])
grouped[group_key].append(row)
payload = []
for group_key in ("tasks", "command", "agentic", "other"):
items = grouped.get(group_key) or []
if not items:
continue
payload.append({
"key": group_key,
"label": self.POLICY_GROUP_LABELS.get(group_key, group_key.title()),
"rows": items,
})
return payload
def post(self, request):
row = self._security_settings(request)
if "require_omemo" in request.POST:
row.require_omemo = _to_bool(request.POST.get("require_omemo"), False)
row.save(update_fields=["require_omemo", "updated_at"])
redirect_to = HttpResponseRedirect(reverse("security_settings"))
scope_key = str(request.POST.get("scope_key") or "").strip().lower()
if scope_key == self.GLOBAL_SCOPE_KEY:
global_row = self._global_override_payload(request)["row"]
settings_payload = dict(global_row.settings or {})
for field in self.GLOBAL_OVERRIDE_FIELDS:
settings_payload[field] = self._parse_override_value(
request.POST.get(f"global_{field}")
)
global_row.allowed_services = [
str(item or "").strip().lower()
for item in request.POST.getlist("allowed_services")
if str(item or "").strip()
]
global_row.allowed_channels = self._channels_map_from_post(request)
global_row.settings = settings_payload
global_row.save(
update_fields=[
"settings",
"allowed_services",
"allowed_channels",
"updated_at",
]
)
return redirect_to
if scope_key:
if str(request.POST.get("scope_change_mode") or "").strip() != "1":
return redirect_to
global_overrides = self._global_override_payload(request)["values"]
allowed_services = [
str(item or "").strip().lower()
for item in request.POST.getlist("allowed_services")
if str(item or "").strip()
]
allowed_channels = self._channels_map_from_post(request)
policy, _ = CommandSecurityPolicy.objects.get_or_create(
user=request.user,
scope_key=scope_key,
)
policy.allowed_services = allowed_services
policy.allowed_channels = allowed_channels
if global_overrides["scope_enabled"] == "per_scope":
policy.enabled = _to_bool(request.POST.get("policy_enabled"), True)
if global_overrides["require_omemo"] == "per_scope":
policy.require_omemo = _to_bool(
request.POST.get("policy_require_omemo"), False
)
if global_overrides["require_trusted_fingerprint"] == "per_scope":
policy.require_trusted_omemo_fingerprint = _to_bool(
request.POST.get("policy_require_trusted_fingerprint"),
False,
)
policy.save(
update_fields=[
"enabled",
"require_omemo",
"require_trusted_omemo_fingerprint",
"allowed_services",
"allowed_channels",
"updated_at",
]
)
return redirect_to
def get(self, request):
xmpp_state = transport.get_runtime_state("xmpp")
try:
omemo_row = UserXmppOmemoState.objects.get(user=request.user)
except UserXmppOmemoState.DoesNotExist:
omemo_row = None
security_settings = self._security_settings(request)
sender_jid = _parse_xmpp_jid(getattr(omemo_row, "last_sender_jid", "") or "")
omemo_plan = [
{
"label": "Component OMEMO active",
"done": bool(xmpp_state.get("omemo_enabled")),
"hint": "The gateway's OMEMO plugin must be loaded and initialised.",
},
{
"label": "OMEMO observed from your client",
"done": omemo_row is not None and omemo_row.status == "detected",
"hint": "Send any message with OMEMO enabled in your XMPP client.",
},
{
"label": "Client key on file",
"done": bool(getattr(omemo_row, "latest_client_key", "")),
"hint": "A device key (sid/rid) must be recorded from your client.",
},
{
"label": "Encryption required",
"done": security_settings.require_omemo,
"hint": "Enable 'Require OMEMO encryption' in Security Policy above to enforce this policy.",
},
]
return render(request, self.template_name, {
"xmpp_state": xmpp_state,
"omemo_row": omemo_row,
"security_settings": security_settings,
"global_override": self._global_override_payload(request),
"policy_services": self.POLICY_SERVICES,
"policy_rows": self._scope_rows(request),
"policy_groups": self._grouped_scope_rows(request),
"sender_jid": sender_jid,
"omemo_plan": omemo_plan,
})