Improve security
This commit is contained in:
21
CLAUDE.md
21
CLAUDE.md
@@ -25,3 +25,24 @@ AI coding tools (Copilot, Claude) will reuse any values they see in context. A r
|
|||||||
Before committing test files, verify no identifier matches a real person:
|
Before committing test files, verify no identifier matches a real person:
|
||||||
- No number outside the reserved fictitious ranges above
|
- No number outside the reserved fictitious ranges above
|
||||||
- No name that corresponds to a real contact used as a literal identifier
|
- No name that corresponds to a real contact used as a literal identifier
|
||||||
|
|
||||||
|
## Naming: Avoid Ambiguous Role Labels
|
||||||
|
|
||||||
|
**Never use "User", "Bot", "Us", or "Them" as role labels without qualification — these terms are context-dependent and misleading in this codebase.**
|
||||||
|
|
||||||
|
GIA acts in multiple roles simultaneously:
|
||||||
|
- It is a Django **User** (account holder) from the perspective of external services (XMPP, WhatsApp, Signal).
|
||||||
|
- It is a **component** (gateway/bot) from the perspective of contacts.
|
||||||
|
- The human who owns and operates the GIA instance is the **account holder** or **operator** (not "user", which collides with `User` model).
|
||||||
|
- Remote people the system communicates with are **contacts**.
|
||||||
|
|
||||||
|
Preferred terms:
|
||||||
|
|
||||||
|
| Avoid | Prefer |
|
||||||
|
| ------------------ | --------------------------------------------------------------- |
|
||||||
|
| "User" (ambiguous) | "account holder" or "operator" (for the Django `User`) |
|
||||||
|
| "Bot" | "component" or "gateway" (for the XMPP/transport layer) |
|
||||||
|
| "Us" | name the specific actor: "GIA", "the component", "the operator" |
|
||||||
|
| "Them" | "contact" or "remote party" |
|
||||||
|
|
||||||
|
Apply this in: comments, template labels, log messages, and variable names.
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ make auth
|
|||||||
Optional static token helper:
|
Optional static token helper:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make token
|
make token TOKEN_USER=<your_username>
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6) Logs and health checks
|
## 6) Logs and health checks
|
||||||
|
|||||||
3
Makefile
3
Makefile
@@ -1,5 +1,6 @@
|
|||||||
QUADLET_MGR := ./scripts/quadlet/manage.sh
|
QUADLET_MGR := ./scripts/quadlet/manage.sh
|
||||||
MODULES ?= core.tests
|
MODULES ?= core.tests
|
||||||
|
TOKEN_USER ?= m
|
||||||
STACK_ID_CLEAN := $(shell sid="$${GIA_STACK_ID:-$${STACK_ID:-}}"; sid=$$(printf "%s" "$$sid" | tr -cs 'a-zA-Z0-9._-' '-' | sed 's/^-*//; s/-*$$//'); printf "%s" "$$sid")
|
STACK_ID_CLEAN := $(shell sid="$${GIA_STACK_ID:-$${STACK_ID:-}}"; sid=$$(printf "%s" "$$sid" | tr -cs 'a-zA-Z0-9._-' '-' | sed 's/^-*//; s/-*$$//'); printf "%s" "$$sid")
|
||||||
STACK_SUFFIX := $(if $(STACK_ID_CLEAN),_$(STACK_ID_CLEAN),)
|
STACK_SUFFIX := $(if $(STACK_ID_CLEAN),_$(STACK_ID_CLEAN),)
|
||||||
APP_CONTAINER := gia$(STACK_SUFFIX)
|
APP_CONTAINER := gia$(STACK_SUFFIX)
|
||||||
@@ -56,7 +57,7 @@ auth:
|
|||||||
|
|
||||||
token:
|
token:
|
||||||
@if podman ps --format '{{.Names}}' | grep -qx "$(APP_CONTAINER)"; then \
|
@if podman ps --format '{{.Names}}' | grep -qx "$(APP_CONTAINER)"; then \
|
||||||
podman exec "$(APP_CONTAINER)" sh -lc "cd /code && . /venv/bin/activate && python manage.py addstatictoken m"; \
|
podman exec "$(APP_CONTAINER)" sh -lc "cd /code && . /venv/bin/activate && python manage.py addstatictoken $(TOKEN_USER)"; \
|
||||||
else \
|
else \
|
||||||
echo "Container '$(APP_CONTAINER)' is not running. Start the stack first with 'make run'." >&2; \
|
echo "Container '$(APP_CONTAINER)' is not running. Start the stack first with 'make run'." >&2; \
|
||||||
exit 125; \
|
exit 125; \
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ XMPP_JID = getenv("XMPP_JID")
|
|||||||
XMPP_USER_DOMAIN = getenv("XMPP_USER_DOMAIN", "")
|
XMPP_USER_DOMAIN = getenv("XMPP_USER_DOMAIN", "")
|
||||||
XMPP_PORT = int(getenv("XMPP_PORT", "8888") or 8888)
|
XMPP_PORT = int(getenv("XMPP_PORT", "8888") or 8888)
|
||||||
XMPP_SECRET = getenv("XMPP_SECRET")
|
XMPP_SECRET = getenv("XMPP_SECRET")
|
||||||
|
XMPP_OMEMO_DATA_DIR = getenv("XMPP_OMEMO_DATA_DIR", "")
|
||||||
|
|
||||||
EVENT_LEDGER_DUAL_WRITE = getenv("EVENT_LEDGER_DUAL_WRITE", "false").lower() in trues
|
EVENT_LEDGER_DUAL_WRITE = getenv("EVENT_LEDGER_DUAL_WRITE", "false").lower() in trues
|
||||||
CAPABILITY_ENFORCEMENT_ENABLED = (
|
CAPABILITY_ENFORCEMENT_ENABLED = (
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ urlpatterns = [
|
|||||||
notifications.NotificationsUpdate.as_view(),
|
notifications.NotificationsUpdate.as_view(),
|
||||||
name="notifications_update",
|
name="notifications_update",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"settings/security/",
|
||||||
|
system.SecurityPage.as_view(),
|
||||||
|
name="security_settings",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"settings/system/",
|
"settings/system/",
|
||||||
system.SystemSettings.as_view(),
|
system.SystemSettings.as_view(),
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import urlsplit
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlparse, urlsplit
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
@@ -16,9 +20,18 @@ from slixmpp.xmlstream import register_stanza_plugin
|
|||||||
from slixmpp.xmlstream.stanzabase import ET
|
from slixmpp.xmlstream.stanzabase import ET
|
||||||
|
|
||||||
from core.clients import ClientBase, transport
|
from core.clients import ClientBase, transport
|
||||||
|
from core.gateway.commands import (
|
||||||
|
GatewayCommandContext,
|
||||||
|
GatewayCommandRoute,
|
||||||
|
dispatch_gateway_command,
|
||||||
|
)
|
||||||
from core.messaging import ai, history, replies, reply_sync, utils
|
from core.messaging import ai, history, replies, reply_sync, utils
|
||||||
from core.models import (
|
from core.models import (
|
||||||
ChatSession,
|
ChatSession,
|
||||||
|
CodexPermissionRequest,
|
||||||
|
CodexRun,
|
||||||
|
DerivedTask,
|
||||||
|
ExternalSyncEvent,
|
||||||
Manipulation,
|
Manipulation,
|
||||||
PatternMitigationAutoSettings,
|
PatternMitigationAutoSettings,
|
||||||
PatternMitigationCorrection,
|
PatternMitigationCorrection,
|
||||||
@@ -28,6 +41,7 @@ from core.models import (
|
|||||||
Person,
|
Person,
|
||||||
PersonIdentifier,
|
PersonIdentifier,
|
||||||
User,
|
User,
|
||||||
|
UserXmppOmemoState,
|
||||||
WorkspaceConversation,
|
WorkspaceConversation,
|
||||||
)
|
)
|
||||||
from core.security.attachments import (
|
from core.security.attachments import (
|
||||||
@@ -40,6 +54,7 @@ URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
|
|||||||
EMOJI_ONLY_PATTERN = re.compile(
|
EMOJI_ONLY_PATTERN = re.compile(
|
||||||
r"^[\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F\u200D\u2640-\u2642\u2764]+$"
|
r"^[\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F\u200D\u2640-\u2642\u2764]+$"
|
||||||
)
|
)
|
||||||
|
TOTP_BASE32_SECRET_RE = re.compile(r"^[A-Z2-7]{16,}$")
|
||||||
|
|
||||||
|
|
||||||
def _clean_url(value):
|
def _clean_url(value):
|
||||||
@@ -129,6 +144,135 @@ def _parse_greentext_reaction(body_text):
|
|||||||
return {"quoted_text": quoted, "emoji": emoji}
|
return {"quoted_text": quoted, "emoji": emoji}
|
||||||
|
|
||||||
|
|
||||||
|
def _omemo_plugin_available() -> bool:
|
||||||
|
try:
|
||||||
|
import importlib
|
||||||
|
return importlib.util.find_spec("slixmpp_omemo") is not None
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_sender_omemo_client_key(stanza) -> dict:
|
||||||
|
"""Extract OMEMO client key info from an encrypted stanza."""
|
||||||
|
ns = "eu.siacs.conversations.axolotl"
|
||||||
|
header = stanza.xml.find(f".//{{{ns}}}header")
|
||||||
|
if header is None:
|
||||||
|
return {"status": "no_omemo"}
|
||||||
|
sid = str(header.attrib.get("sid") or "").strip()
|
||||||
|
key_el = header.find(f"{{{ns}}}key")
|
||||||
|
rid = str(key_el.attrib.get("rid") or "").strip() if key_el is not None else ""
|
||||||
|
if sid or rid:
|
||||||
|
return {"status": "detected", "client_key": f"sid:{sid},rid:{rid}"}
|
||||||
|
return {"status": "no_omemo"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OMEMO storage + plugin implementation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
try:
|
||||||
|
from omemo.storage import Just, Maybe, Nothing, Storage as _OmemoStorageBase
|
||||||
|
from slixmpp_omemo import XEP_0384 as _XEP_0384Base
|
||||||
|
from slixmpp_omemo.base_session_manager import TrustLevel as _OmemoTrustLevel
|
||||||
|
from slixmpp.plugins.base import register_plugin as _slixmpp_register_plugin
|
||||||
|
_OMEMO_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_OMEMO_AVAILABLE = False
|
||||||
|
_OmemoStorageBase = object
|
||||||
|
_XEP_0384Base = object
|
||||||
|
_OmemoTrustLevel = None
|
||||||
|
_slixmpp_register_plugin = None
|
||||||
|
|
||||||
|
|
||||||
|
if _OMEMO_AVAILABLE:
|
||||||
|
class _OmemoStorage(_OmemoStorageBase):
|
||||||
|
"""JSON-file-backed OMEMO key storage."""
|
||||||
|
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._path = path
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
self._data: dict = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
self._data = {}
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
||||||
|
with open(self._path, "w") as f:
|
||||||
|
json.dump(self._data, f)
|
||||||
|
|
||||||
|
async def _load(self, key: str) -> Maybe:
|
||||||
|
if key in self._data:
|
||||||
|
return Just(self._data[key])
|
||||||
|
return Nothing()
|
||||||
|
|
||||||
|
async def _store(self, key: str, value) -> None:
|
||||||
|
self._data[key] = value
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
async def _delete(self, key: str) -> None:
|
||||||
|
self._data.pop(key, None)
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
class _GiaOmemoPlugin(_XEP_0384Base):
|
||||||
|
"""Concrete XEP-0384 OMEMO plugin for the GIA XMPP gateway component.
|
||||||
|
|
||||||
|
Uses BTBV (blind trust before verification) – appropriate for a
|
||||||
|
server-side bridge that processes messages on behalf of users.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "xep_0384"
|
||||||
|
description = "OMEMO Encryption (GIA gateway)"
|
||||||
|
dependencies = {"xep_0004", "xep_0030", "xep_0060", "xep_0163", "xep_0280", "xep_0334"}
|
||||||
|
default_config = {
|
||||||
|
"fallback_message": "This message is OMEMO encrypted.",
|
||||||
|
"data_dir": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def plugin_init(self) -> None:
|
||||||
|
data_dir = str(self.config.get("data_dir") or "").strip()
|
||||||
|
if not data_dir:
|
||||||
|
data_dir = str(Path(settings.BASE_DIR) / "xmpp_omemo_data")
|
||||||
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
self._storage_impl = _OmemoStorage(os.path.join(data_dir, "omemo.json"))
|
||||||
|
super().plugin_init()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def storage(self) -> _OmemoStorageBase:
|
||||||
|
return self._storage_impl
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _btbv_enabled(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _devices_blindly_trusted(self, blindly_trusted, identifier):
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
"OMEMO: blindly trusted %d new device(s)", len(blindly_trusted)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _prompt_manual_trust(self, manually_trusted, identifier):
|
||||||
|
"""Auto-trust all undecided devices (gateway mode)."""
|
||||||
|
import logging
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.info(
|
||||||
|
"OMEMO: auto-trusting %d undecided device(s) (gateway mode)",
|
||||||
|
len(manually_trusted),
|
||||||
|
)
|
||||||
|
session_manager = await self.get_session_manager()
|
||||||
|
for device in manually_trusted:
|
||||||
|
try:
|
||||||
|
await session_manager.set_trust(
|
||||||
|
device.bare_jid,
|
||||||
|
device.device_id,
|
||||||
|
device.identity_key,
|
||||||
|
_OmemoTrustLevel.BLINDLY_TRUSTED.value,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("OMEMO set_trust failed for %s: %s", device.bare_jid, exc)
|
||||||
|
|
||||||
|
|
||||||
class XMPPComponent(ComponentXMPP):
|
class XMPPComponent(ComponentXMPP):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -147,6 +291,8 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
self.log = logs.get_logger("XMPP")
|
self.log = logs.get_logger("XMPP")
|
||||||
|
|
||||||
super().__init__(jid, secret, server, port)
|
super().__init__(jid, secret, server, port)
|
||||||
|
# Enable message IDs so the OMEMO plugin can associate encrypted stanzas.
|
||||||
|
self.use_message_ids = True
|
||||||
# Use one reconnect strategy (our backoff loop) to avoid reconnect churn.
|
# Use one reconnect strategy (our backoff loop) to avoid reconnect churn.
|
||||||
self.auto_reconnect = False
|
self.auto_reconnect = False
|
||||||
# Register chat state plugins
|
# Register chat state plugins
|
||||||
@@ -297,6 +443,470 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
)
|
)
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
|
def _derived_omemo_fingerprint(self, jid: str) -> str:
|
||||||
|
import hashlib
|
||||||
|
return hashlib.sha256(f"xmpp-omemo-key:{jid}".encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
def _get_omemo_plugin(self):
|
||||||
|
"""Return the active XEP-0384 plugin instance, or None if not loaded."""
|
||||||
|
try:
|
||||||
|
return self["xep_0384"]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _bootstrap_omemo_for_authentic_channel(self):
|
||||||
|
jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
|
||||||
|
omemo_plugin = self._get_omemo_plugin()
|
||||||
|
omemo_enabled = omemo_plugin is not None
|
||||||
|
status = "active" if omemo_enabled else "not_available"
|
||||||
|
reason = "OMEMO plugin active" if omemo_enabled else "xep_0384 plugin not loaded"
|
||||||
|
fingerprint = self._derived_omemo_fingerprint(jid)
|
||||||
|
if omemo_enabled:
|
||||||
|
try:
|
||||||
|
import asyncio as _asyncio
|
||||||
|
session_manager = await _asyncio.wait_for(
|
||||||
|
omemo_plugin.get_session_manager(), timeout=15.0
|
||||||
|
)
|
||||||
|
own_devices = await session_manager.get_own_device_information()
|
||||||
|
if own_devices:
|
||||||
|
key_bytes = own_devices[0].identity_key
|
||||||
|
fingerprint = ":".join(f"{b:02X}" for b in key_bytes)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning("OMEMO: could not read own device fingerprint: %s", exc)
|
||||||
|
self.log.info(
|
||||||
|
"OMEMO bootstrap: jid=%s enabled=%s status=%s fingerprint=%s",
|
||||||
|
jid, omemo_enabled, status, fingerprint,
|
||||||
|
)
|
||||||
|
transport.update_runtime_state(
|
||||||
|
"xmpp",
|
||||||
|
omemo_target_jid=jid,
|
||||||
|
omemo_fingerprint=fingerprint,
|
||||||
|
omemo_enabled=omemo_enabled,
|
||||||
|
omemo_status=status,
|
||||||
|
omemo_status_reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _record_sender_omemo_state(self, user, *, sender_jid, recipient_jid, message_stanza):
|
||||||
|
parsed = _extract_sender_omemo_client_key(message_stanza)
|
||||||
|
status = str(parsed.get("status") or "no_omemo")
|
||||||
|
client_key = str(parsed.get("client_key") or "")
|
||||||
|
await sync_to_async(UserXmppOmemoState.objects.update_or_create)(
|
||||||
|
user=user,
|
||||||
|
defaults={
|
||||||
|
"status": status,
|
||||||
|
"latest_client_key": client_key,
|
||||||
|
"last_sender_jid": str(sender_jid or ""),
|
||||||
|
"last_target_jid": str(recipient_jid or ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
_approval_event_prefix = "codex_approval"
|
||||||
|
|
||||||
|
_APPROVAL_PROVIDER_COMMANDS = {
|
||||||
|
".claude": "claude",
|
||||||
|
".codex": "codex_cli",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_request_provider(self, request):
|
||||||
|
event = getattr(request, "external_sync_event", None)
|
||||||
|
if event is None:
|
||||||
|
return ""
|
||||||
|
return str(getattr(event, "provider", "") or "").strip()
|
||||||
|
|
||||||
|
_ACTION_TO_STATUS = {"approve": "approved", "reject": "denied"}
|
||||||
|
|
||||||
|
async def _apply_approval_decision(self, request, decision, sym):
|
||||||
|
status = self._ACTION_TO_STATUS.get(decision, decision)
|
||||||
|
request.status = status
|
||||||
|
await sync_to_async(request.save)(update_fields=["status"])
|
||||||
|
run = None
|
||||||
|
if request.codex_run_id:
|
||||||
|
run = await sync_to_async(CodexRun.objects.get)(pk=request.codex_run_id)
|
||||||
|
run.status = "approved_waiting_resume" if status == "approved" else status
|
||||||
|
await sync_to_async(run.save)(update_fields=["status"])
|
||||||
|
if request.external_sync_event_id:
|
||||||
|
evt = await sync_to_async(ExternalSyncEvent.objects.get)(pk=request.external_sync_event_id)
|
||||||
|
evt.status = "ok"
|
||||||
|
await sync_to_async(evt.save)(update_fields=["status"])
|
||||||
|
user = await sync_to_async(User.objects.get)(pk=request.user_id)
|
||||||
|
task = None
|
||||||
|
if run is not None and run.task_id:
|
||||||
|
task = await sync_to_async(DerivedTask.objects.get)(pk=run.task_id)
|
||||||
|
ikey = f"{self._approval_event_prefix}:{request.approval_key}:{status}"
|
||||||
|
await sync_to_async(ExternalSyncEvent.objects.get_or_create)(
|
||||||
|
idempotency_key=ikey,
|
||||||
|
defaults={
|
||||||
|
"user": user,
|
||||||
|
"task": task,
|
||||||
|
"provider": "codex_cli",
|
||||||
|
"status": "pending",
|
||||||
|
"payload": {},
|
||||||
|
"error": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _approval_list_pending(self, user, scope, sym):
|
||||||
|
requests = await sync_to_async(list)(
|
||||||
|
CodexPermissionRequest.objects.filter(
|
||||||
|
user=user, status="pending"
|
||||||
|
).order_by("-requested_at")[:20]
|
||||||
|
)
|
||||||
|
sym(f"pending={len(requests)}")
|
||||||
|
for req in requests:
|
||||||
|
sym(f" {req.approval_key}: {req.summary}")
|
||||||
|
|
||||||
|
async def _approval_status(self, user, approval_key, sym):
|
||||||
|
try:
|
||||||
|
req = await sync_to_async(
|
||||||
|
CodexPermissionRequest.objects.get
|
||||||
|
)(user=user, approval_key=approval_key)
|
||||||
|
sym(f"status={req.status} key={req.approval_key}")
|
||||||
|
except CodexPermissionRequest.DoesNotExist:
|
||||||
|
sym(f"approval_key_not_found:{approval_key}")
|
||||||
|
|
||||||
|
async def _handle_approval_command(self, user, body, sender_jid, sym):
|
||||||
|
command = body.strip()
|
||||||
|
for prefix, expected_provider in self._APPROVAL_PROVIDER_COMMANDS.items():
|
||||||
|
if command.startswith(prefix + " ") or command == prefix:
|
||||||
|
sub = command[len(prefix):].strip()
|
||||||
|
parts = sub.split()
|
||||||
|
if len(parts) >= 2 and parts[0] in ("approve", "reject"):
|
||||||
|
action, approval_key = parts[0], parts[1]
|
||||||
|
try:
|
||||||
|
req = await sync_to_async(
|
||||||
|
CodexPermissionRequest.objects.select_related(
|
||||||
|
"external_sync_event"
|
||||||
|
).get
|
||||||
|
)(user=user, approval_key=approval_key)
|
||||||
|
except CodexPermissionRequest.DoesNotExist:
|
||||||
|
sym(f"approval_key_not_found:{approval_key}")
|
||||||
|
return True
|
||||||
|
provider = self._resolve_request_provider(req)
|
||||||
|
if not provider.startswith(expected_provider):
|
||||||
|
sym(f"approval_key_not_for_provider:{approval_key} provider={provider}")
|
||||||
|
return True
|
||||||
|
await self._apply_approval_decision(req, action, sym)
|
||||||
|
sym(f"{action}d: {approval_key}")
|
||||||
|
return True
|
||||||
|
sym(f"usage: {prefix} approve|reject <key>")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not command.startswith(".approval"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
rest = command[len(".approval"):].strip()
|
||||||
|
|
||||||
|
if rest.split() and rest.split()[0] in ("approve", "reject"):
|
||||||
|
parts = rest.split()
|
||||||
|
action = parts[0]
|
||||||
|
approval_key = parts[1] if len(parts) > 1 else ""
|
||||||
|
if not approval_key:
|
||||||
|
sym("usage: .approval approve|reject <key>")
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
req = await sync_to_async(
|
||||||
|
CodexPermissionRequest.objects.select_related(
|
||||||
|
"external_sync_event"
|
||||||
|
).get
|
||||||
|
)(user=user, approval_key=approval_key)
|
||||||
|
except CodexPermissionRequest.DoesNotExist:
|
||||||
|
sym(f"approval_key_not_found:{approval_key}")
|
||||||
|
return True
|
||||||
|
await self._apply_approval_decision(req, action, sym)
|
||||||
|
sym(f"{action}d: {approval_key}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if rest.startswith("list-pending"):
|
||||||
|
scope = rest[len("list-pending"):].strip() or "mine"
|
||||||
|
await self._approval_list_pending(user, scope, sym)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if rest.startswith("status "):
|
||||||
|
approval_key = rest[len("status "):].strip()
|
||||||
|
await self._approval_status(user, approval_key, sym)
|
||||||
|
return True
|
||||||
|
|
||||||
|
sym(
|
||||||
|
"approval: .approval approve|reject <key> | "
|
||||||
|
".approval list-pending [all] | "
|
||||||
|
".approval status <key>"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _handle_tasks_command(self, user, body, sym):
|
||||||
|
command = body.strip()
|
||||||
|
if not command.startswith(".tasks"):
|
||||||
|
return False
|
||||||
|
rest = command[len(".tasks"):].strip()
|
||||||
|
|
||||||
|
if rest.startswith("list"):
|
||||||
|
parts = rest.split()
|
||||||
|
status_filter = parts[1] if len(parts) > 1 else "open"
|
||||||
|
limit = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 10
|
||||||
|
tasks = await sync_to_async(list)(
|
||||||
|
DerivedTask.objects.filter(
|
||||||
|
user=user, status_snapshot=status_filter
|
||||||
|
).order_by("-id")[:limit]
|
||||||
|
)
|
||||||
|
if not tasks:
|
||||||
|
sym(f"no {status_filter} tasks")
|
||||||
|
else:
|
||||||
|
for t in tasks:
|
||||||
|
sym(f"#{t.reference_code} [{t.status_snapshot}] {t.title}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if rest.startswith("show "):
|
||||||
|
ref = rest[len("show "):].strip().lstrip("#")
|
||||||
|
try:
|
||||||
|
task = await sync_to_async(DerivedTask.objects.get)(
|
||||||
|
user=user, reference_code=ref
|
||||||
|
)
|
||||||
|
sym(f"#{task.reference_code} {task.title}")
|
||||||
|
sym(f"status: {task.status_snapshot}")
|
||||||
|
except DerivedTask.DoesNotExist:
|
||||||
|
sym(f"task_not_found:#{ref}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if rest.startswith("complete "):
|
||||||
|
ref = rest[len("complete "):].strip().lstrip("#")
|
||||||
|
try:
|
||||||
|
task = await sync_to_async(DerivedTask.objects.get)(
|
||||||
|
user=user, reference_code=ref
|
||||||
|
)
|
||||||
|
task.status_snapshot = "completed"
|
||||||
|
await sync_to_async(task.save)(update_fields=["status_snapshot"])
|
||||||
|
sym(f"completed #{ref}")
|
||||||
|
except DerivedTask.DoesNotExist:
|
||||||
|
sym(f"task_not_found:#{ref}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if rest.startswith("undo "):
|
||||||
|
ref = rest[len("undo "):].strip().lstrip("#")
|
||||||
|
try:
|
||||||
|
task = await sync_to_async(DerivedTask.objects.get)(
|
||||||
|
user=user, reference_code=ref
|
||||||
|
)
|
||||||
|
await sync_to_async(task.delete)()
|
||||||
|
sym(f"removed #{ref}")
|
||||||
|
except DerivedTask.DoesNotExist:
|
||||||
|
sym(f"task_not_found:#{ref}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
sym(
|
||||||
|
"tasks: .tasks list [status] [limit] | "
|
||||||
|
".tasks show #<ref> | "
|
||||||
|
".tasks complete #<ref> | "
|
||||||
|
".tasks undo #<ref>"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _extract_totp_secret_candidate(self, command_text: str) -> str:
|
||||||
|
text = str(command_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
lowered = text.lower()
|
||||||
|
if lowered.startswith("otpauth://"):
|
||||||
|
parsed = urlparse(text)
|
||||||
|
query = parse_qs(parsed.query or "")
|
||||||
|
return str((query.get("secret") or [""])[0] or "").strip()
|
||||||
|
if lowered.startswith(".totp"):
|
||||||
|
rest = text[len(".totp"):].strip()
|
||||||
|
if not rest:
|
||||||
|
return ""
|
||||||
|
parts = rest.split(maxsplit=1)
|
||||||
|
action = str(parts[0] or "").strip().lower()
|
||||||
|
if action in {"enroll", "set"} and len(parts) > 1:
|
||||||
|
return str(parts[1] or "").strip()
|
||||||
|
if action in {"status", "help"}:
|
||||||
|
return ""
|
||||||
|
return rest
|
||||||
|
compact = text.replace(" ", "").strip().upper()
|
||||||
|
if TOTP_BASE32_SECRET_RE.match(compact):
|
||||||
|
return compact
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def _handle_totp_command(self, user, body, sym):
|
||||||
|
command = str(body or "").strip()
|
||||||
|
lowered = command.lower()
|
||||||
|
if lowered.startswith(".totp status"):
|
||||||
|
exists = await sync_to_async(
|
||||||
|
lambda: __import__(
|
||||||
|
"django_otp.plugins.otp_totp.models",
|
||||||
|
fromlist=["TOTPDevice"],
|
||||||
|
)
|
||||||
|
.TOTPDevice.objects.filter(user=user, confirmed=True)
|
||||||
|
.exists()
|
||||||
|
)()
|
||||||
|
sym("totp: configured" if exists else "totp: not configured")
|
||||||
|
return True
|
||||||
|
if lowered == ".totp help":
|
||||||
|
sym("totp: .totp enroll <base32-secret|otpauth-uri> | .totp status")
|
||||||
|
return True
|
||||||
|
|
||||||
|
secret_candidate = self._extract_totp_secret_candidate(command)
|
||||||
|
if not secret_candidate:
|
||||||
|
if lowered.startswith(".totp"):
|
||||||
|
sym("usage: .totp enroll <base32-secret|otpauth-uri>")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
normalized = str(secret_candidate).replace(" ", "").strip().upper()
|
||||||
|
try:
|
||||||
|
key_bytes = base64.b32decode(normalized, casefold=True)
|
||||||
|
except Exception:
|
||||||
|
sym("totp: invalid secret format")
|
||||||
|
return True
|
||||||
|
if len(key_bytes) < 10:
|
||||||
|
sym("totp: secret too short")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _save_device():
|
||||||
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
|
|
||||||
|
device = (
|
||||||
|
TOTPDevice.objects.filter(user=user)
|
||||||
|
.order_by("-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if device is None:
|
||||||
|
device = TOTPDevice(user=user, name="gateway")
|
||||||
|
device.key = key_bytes.hex()
|
||||||
|
device.confirmed = True
|
||||||
|
device.step = 30
|
||||||
|
device.t0 = 0
|
||||||
|
device.digits = 6
|
||||||
|
device.tolerance = 1
|
||||||
|
device.drift = 0
|
||||||
|
device.save()
|
||||||
|
return device.name
|
||||||
|
|
||||||
|
device_name = await sync_to_async(_save_device)()
|
||||||
|
sym(f"totp: enrolled for user={user.username} device={device_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _route_gateway_command(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
sender_user,
|
||||||
|
body,
|
||||||
|
sender_jid,
|
||||||
|
recipient_jid,
|
||||||
|
local_message,
|
||||||
|
message_meta,
|
||||||
|
sym,
|
||||||
|
):
|
||||||
|
command_text = str(body or "").strip()
|
||||||
|
|
||||||
|
async def _contacts_handler(_ctx, emit):
|
||||||
|
persons = await sync_to_async(list)(Person.objects.filter(user=sender_user).order_by("name"))
|
||||||
|
if not persons:
|
||||||
|
emit("No contacts found.")
|
||||||
|
return True
|
||||||
|
emit("Contacts: " + ", ".join([p.name for p in persons]))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _help_handler(_ctx, emit):
|
||||||
|
for line in self._gateway_help_lines():
|
||||||
|
emit(line)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _whoami_handler(_ctx, emit):
|
||||||
|
emit(str(sender_user.__dict__))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _approval_handler(_ctx, emit):
|
||||||
|
return await self._handle_approval_command(sender_user, command_text, sender_jid, emit)
|
||||||
|
|
||||||
|
async def _tasks_handler(_ctx, emit):
|
||||||
|
return await self._handle_tasks_command(sender_user, command_text, emit)
|
||||||
|
|
||||||
|
async def _totp_handler(_ctx, emit):
|
||||||
|
return await self._handle_totp_command(sender_user, command_text, emit)
|
||||||
|
|
||||||
|
routes = [
|
||||||
|
GatewayCommandRoute(
|
||||||
|
name="contacts",
|
||||||
|
scope_key="gateway.contacts",
|
||||||
|
matcher=lambda text: str(text or "").strip().lower() == ".contacts",
|
||||||
|
handler=_contacts_handler,
|
||||||
|
),
|
||||||
|
GatewayCommandRoute(
|
||||||
|
name="help",
|
||||||
|
scope_key="gateway.help",
|
||||||
|
matcher=lambda text: str(text or "").strip().lower() == ".help",
|
||||||
|
handler=_help_handler,
|
||||||
|
),
|
||||||
|
GatewayCommandRoute(
|
||||||
|
name="whoami",
|
||||||
|
scope_key="gateway.whoami",
|
||||||
|
matcher=lambda text: str(text or "").strip().lower() == ".whoami",
|
||||||
|
handler=_whoami_handler,
|
||||||
|
),
|
||||||
|
GatewayCommandRoute(
|
||||||
|
name="approval",
|
||||||
|
scope_key="gateway.approval",
|
||||||
|
matcher=lambda text: str(text or "").strip().lower().startswith(".approval")
|
||||||
|
or any(
|
||||||
|
str(text or "").strip().lower().startswith(prefix + " ")
|
||||||
|
or str(text or "").strip().lower() == prefix
|
||||||
|
for prefix in self._APPROVAL_PROVIDER_COMMANDS
|
||||||
|
),
|
||||||
|
handler=_approval_handler,
|
||||||
|
),
|
||||||
|
GatewayCommandRoute(
|
||||||
|
name="tasks",
|
||||||
|
scope_key="gateway.tasks",
|
||||||
|
matcher=lambda text: str(text or "").strip().lower().startswith(".tasks"),
|
||||||
|
handler=_tasks_handler,
|
||||||
|
),
|
||||||
|
GatewayCommandRoute(
|
||||||
|
name="totp",
|
||||||
|
scope_key="gateway.totp",
|
||||||
|
matcher=lambda text: bool(self._extract_totp_secret_candidate(text)),
|
||||||
|
handler=_totp_handler,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
handled = await dispatch_gateway_command(
|
||||||
|
context=GatewayCommandContext(
|
||||||
|
user=sender_user,
|
||||||
|
source_message=local_message,
|
||||||
|
service="xmpp",
|
||||||
|
channel_identifier=str(sender_jid or ""),
|
||||||
|
sender_identifier=str(sender_jid or ""),
|
||||||
|
message_text=command_text,
|
||||||
|
message_meta=dict(message_meta or {}),
|
||||||
|
payload={
|
||||||
|
"sender_jid": str(sender_jid or ""),
|
||||||
|
"recipient_jid": str(recipient_jid or ""),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
routes=routes,
|
||||||
|
emit=sym,
|
||||||
|
)
|
||||||
|
if not handled and command_text.startswith("."):
|
||||||
|
sym("No such command")
|
||||||
|
return handled
|
||||||
|
|
||||||
|
def _gateway_help_lines(self):
|
||||||
|
return [
|
||||||
|
"Gateway commands:",
|
||||||
|
" .contacts — list contacts",
|
||||||
|
" .whoami — show current user",
|
||||||
|
" .help — show this help",
|
||||||
|
" .totp enroll <secret|otpauth-uri> — enroll TOTP for this user",
|
||||||
|
" .totp status — show whether TOTP is configured",
|
||||||
|
"Approval commands:",
|
||||||
|
" .approval list-pending [all] — list pending approval requests",
|
||||||
|
" .approval approve <key> — approve a request",
|
||||||
|
" .approval reject <key> — reject a request",
|
||||||
|
" .approval status <key> — check request status",
|
||||||
|
"Task commands:",
|
||||||
|
" .tasks list [status] [limit] — list tasks",
|
||||||
|
" .tasks show #<ref> — show task details",
|
||||||
|
" .tasks complete #<ref> — mark task complete",
|
||||||
|
" .tasks undo #<ref> — remove task",
|
||||||
|
]
|
||||||
|
|
||||||
async def _handle_mitigation_command(self, sender_user, body, sym):
|
async def _handle_mitigation_command(self, sender_user, body, sym):
|
||||||
def parse_parts(raw):
|
def parse_parts(raw):
|
||||||
return [part.strip() for part in raw.split("|")]
|
return [part.strip() for part in raw.split("|")]
|
||||||
@@ -855,6 +1465,7 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
# This client connects as an external component, not a user client;
|
# This client connects as an external component, not a user client;
|
||||||
# XEP-0280 (carbons) is client-scoped and not valid here.
|
# XEP-0280 (carbons) is client-scoped and not valid here.
|
||||||
self.log.debug("Skipping carbons enable for component session")
|
self.log.debug("Skipping carbons enable for component session")
|
||||||
|
await self._bootstrap_omemo_for_authentic_channel()
|
||||||
|
|
||||||
async def _reconnect_loop(self):
|
async def _reconnect_loop(self):
|
||||||
try:
|
try:
|
||||||
@@ -1031,6 +1642,18 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
recipient_username = recipient_jid
|
recipient_username = recipient_jid
|
||||||
recipient_domain = recipient_jid
|
recipient_domain = recipient_jid
|
||||||
|
|
||||||
|
# Attempt to decrypt OMEMO-encrypted messages before body extraction.
|
||||||
|
original_msg = msg
|
||||||
|
omemo_plugin = self._get_omemo_plugin()
|
||||||
|
if omemo_plugin:
|
||||||
|
try:
|
||||||
|
if omemo_plugin.is_encrypted(msg):
|
||||||
|
decrypted, _ = await omemo_plugin.decrypt_message(msg)
|
||||||
|
msg = decrypted
|
||||||
|
self.log.debug("OMEMO: decrypted message from %s", sender_jid)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning("OMEMO: decryption failed from %s: %s", sender_jid, exc)
|
||||||
|
|
||||||
# Extract message body
|
# Extract message body
|
||||||
body = msg["body"] if msg["body"] else ""
|
body = msg["body"] if msg["body"] else ""
|
||||||
parsed_reaction = _extract_xmpp_reaction(msg)
|
parsed_reaction = _extract_xmpp_reaction(msg)
|
||||||
@@ -1157,36 +1780,55 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
self.log.warning(f"Unknown sender: {sender_username}")
|
self.log.warning(f"Unknown sender: {sender_username}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Record the sender's OMEMO state (uses the original, pre-decryption stanza).
|
||||||
|
try:
|
||||||
|
await self._record_sender_omemo_state(
|
||||||
|
sender_user,
|
||||||
|
sender_jid=sender_jid,
|
||||||
|
recipient_jid=recipient_jid,
|
||||||
|
message_stanza=original_msg,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning("OMEMO: failed to record sender state: %s", exc)
|
||||||
|
omemo_observation = _extract_sender_omemo_client_key(original_msg)
|
||||||
|
|
||||||
|
# Enforce mandatory encryption policy.
|
||||||
|
try:
|
||||||
|
from core.models import UserXmppSecuritySettings
|
||||||
|
sec_settings = await sync_to_async(
|
||||||
|
lambda: UserXmppSecuritySettings.objects.filter(user=sender_user).first()
|
||||||
|
)()
|
||||||
|
if sec_settings and sec_settings.require_omemo:
|
||||||
|
omemo_status = str(omemo_observation.get("status") or "")
|
||||||
|
if omemo_status != "detected":
|
||||||
|
sym(
|
||||||
|
"⚠ This gateway requires OMEMO encryption. "
|
||||||
|
"Your message was not delivered. "
|
||||||
|
"Please enable OMEMO in your XMPP client."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning("OMEMO policy check failed: %s", exc)
|
||||||
|
|
||||||
if recipient_jid == settings.XMPP_JID:
|
if recipient_jid == settings.XMPP_JID:
|
||||||
self.log.debug("Handling command message sent to gateway JID")
|
self.log.debug("Handling command message sent to gateway JID")
|
||||||
if body.startswith("."):
|
if body.startswith(".") or self._extract_totp_secret_candidate(body):
|
||||||
# Messaging the gateway directly
|
await self._route_gateway_command(
|
||||||
if body == ".contacts":
|
sender_user=sender_user,
|
||||||
# Lookup Person objects linked to sender
|
body=body,
|
||||||
persons = Person.objects.filter(user=sender_user)
|
sender_jid=sender_jid,
|
||||||
if not persons.exists():
|
recipient_jid=recipient_jid,
|
||||||
self.log.debug("No contacts found for %s", sender_username)
|
local_message=None,
|
||||||
sym("No contacts found.")
|
message_meta={
|
||||||
return
|
"xmpp": {
|
||||||
|
"sender_jid": str(sender_jid or ""),
|
||||||
# Construct contact list response
|
"recipient_jid": str(recipient_jid or ""),
|
||||||
contact_names = [person.name for person in persons]
|
"omemo_status": str(omemo_observation.get("status") or ""),
|
||||||
response_text = "Contacts: " + ", ".join(contact_names)
|
"omemo_client_key": str(omemo_observation.get("client_key") or ""),
|
||||||
sym(response_text)
|
}
|
||||||
elif body == ".help":
|
},
|
||||||
sym("Commands: .contacts, .whoami, .mitigation help")
|
sym=sym,
|
||||||
elif body.startswith(".mitigation"):
|
)
|
||||||
handled = await self._handle_mitigation_command(
|
|
||||||
sender_user,
|
|
||||||
body,
|
|
||||||
sym,
|
|
||||||
)
|
|
||||||
if not handled:
|
|
||||||
sym("Unknown mitigation command. Try .mitigation help")
|
|
||||||
elif body == ".whoami":
|
|
||||||
sym(str(sender_user.__dict__))
|
|
||||||
else:
|
|
||||||
sym("No such command")
|
|
||||||
else:
|
else:
|
||||||
self.log.debug("Handling routed message to contact")
|
self.log.debug("Handling routed message to contact")
|
||||||
if "|" in recipient_username:
|
if "|" in recipient_username:
|
||||||
@@ -1357,7 +1999,14 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
reply_source_message_id=str(
|
reply_source_message_id=str(
|
||||||
reply_ref.get("reply_source_message_id") or ""
|
reply_ref.get("reply_source_message_id") or ""
|
||||||
),
|
),
|
||||||
message_meta={},
|
message_meta={
|
||||||
|
"xmpp": {
|
||||||
|
"sender_jid": str(sender_jid or ""),
|
||||||
|
"recipient_jid": str(recipient_jid or ""),
|
||||||
|
"omemo_status": str(omemo_observation.get("status") or ""),
|
||||||
|
"omemo_client_key": str(omemo_observation.get("client_key") or ""),
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
self.log.debug("Stored outbound XMPP message in history")
|
self.log.debug("Stored outbound XMPP message in history")
|
||||||
await self.ur.message_received(
|
await self.ur.message_received(
|
||||||
@@ -1513,6 +2162,32 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
msg.xml.append(oob_element)
|
msg.xml.append(oob_element)
|
||||||
|
|
||||||
self.log.debug("Sending XMPP message: %s", msg.xml)
|
self.log.debug("Sending XMPP message: %s", msg.xml)
|
||||||
|
|
||||||
|
# Attempt OMEMO encryption for text-only messages (not attachments).
|
||||||
|
if not attachment_url:
|
||||||
|
omemo_plugin = self._get_omemo_plugin()
|
||||||
|
if omemo_plugin:
|
||||||
|
try:
|
||||||
|
from slixmpp.jid import JID as _JID
|
||||||
|
encrypted_msgs, enc_errors = await omemo_plugin.encrypt_message(
|
||||||
|
msg, _JID(recipient_jid)
|
||||||
|
)
|
||||||
|
if enc_errors:
|
||||||
|
self.log.debug(
|
||||||
|
"OMEMO: non-critical encryption errors for %s: %s",
|
||||||
|
recipient_jid, enc_errors,
|
||||||
|
)
|
||||||
|
if encrypted_msgs:
|
||||||
|
for enc_msg in encrypted_msgs.values():
|
||||||
|
enc_msg.send()
|
||||||
|
self.log.debug("OMEMO: sent encrypted message to %s", recipient_jid)
|
||||||
|
return msg_id
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.debug(
|
||||||
|
"OMEMO: encryption not available for %s, sending plaintext: %s",
|
||||||
|
recipient_jid, exc,
|
||||||
|
)
|
||||||
|
|
||||||
msg.send()
|
msg.send()
|
||||||
return msg_id
|
return msg_id
|
||||||
|
|
||||||
@@ -1834,6 +2509,23 @@ class XMPPClient(ClientBase):
|
|||||||
self.client.register_plugin("xep_0085") # Chat State Notifications
|
self.client.register_plugin("xep_0085") # Chat State Notifications
|
||||||
self.client.register_plugin("xep_0363") # HTTP File Upload
|
self.client.register_plugin("xep_0363") # HTTP File Upload
|
||||||
|
|
||||||
|
self._omemo_plugin_registered = False
|
||||||
|
if _OMEMO_AVAILABLE:
|
||||||
|
try:
|
||||||
|
data_dir = str(getattr(settings, "XMPP_OMEMO_DATA_DIR", "") or "").strip()
|
||||||
|
if not data_dir:
|
||||||
|
data_dir = str(Path(settings.BASE_DIR) / "xmpp_omemo_data")
|
||||||
|
# Register our concrete plugin class under the "xep_0384" name so
|
||||||
|
# that slixmpp's dependency resolver finds it.
|
||||||
|
_slixmpp_register_plugin(_GiaOmemoPlugin)
|
||||||
|
self.client.register_plugin("xep_0384", pconfig={"data_dir": data_dir})
|
||||||
|
self._omemo_plugin_registered = True
|
||||||
|
self.log.info("OMEMO: xep_0384 plugin registered, data_dir=%s", data_dir)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning("OMEMO: failed to register xep_0384 plugin: %s", exc)
|
||||||
|
else:
|
||||||
|
self.log.warning("OMEMO: slixmpp_omemo not available, OMEMO disabled")
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
if not self._enabled or self.client is None:
|
if not self._enabled or self.client is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from core.commands.registry import get as get_handler
|
|||||||
from core.commands.registry import register
|
from core.commands.registry import register
|
||||||
from core.messaging.reply_sync import is_mirrored_origin
|
from core.messaging.reply_sync import is_mirrored_origin
|
||||||
from core.models import CommandAction, CommandChannelBinding, CommandProfile, Message
|
from core.models import CommandAction, CommandChannelBinding, CommandProfile, Message
|
||||||
|
from core.security.command_policy import CommandSecurityContext, evaluate_command_policy
|
||||||
from core.tasks.chat_defaults import ensure_default_source_for_chat
|
from core.tasks.chat_defaults import ensure_default_source_for_chat
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
@@ -318,12 +319,21 @@ def _matches_trigger(profile: CommandProfile, text: str) -> bool:
|
|||||||
async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
|
async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
|
||||||
ensure_handlers_registered()
|
ensure_handlers_registered()
|
||||||
trigger_message = await sync_to_async(
|
trigger_message = await sync_to_async(
|
||||||
lambda: Message.objects.filter(id=ctx.message_id).first()
|
lambda: Message.objects.select_related("user", "session", "session__identifier")
|
||||||
|
.filter(id=ctx.message_id)
|
||||||
|
.first()
|
||||||
)()
|
)()
|
||||||
if trigger_message is None:
|
if trigger_message is None:
|
||||||
return []
|
return []
|
||||||
if is_mirrored_origin(trigger_message.message_meta):
|
if is_mirrored_origin(trigger_message.message_meta):
|
||||||
return []
|
return []
|
||||||
|
effective_service, effective_channel = _effective_bootstrap_scope(ctx, trigger_message)
|
||||||
|
security_context = CommandSecurityContext(
|
||||||
|
service=effective_service,
|
||||||
|
channel_identifier=effective_channel,
|
||||||
|
message_meta=dict(getattr(trigger_message, "message_meta", {}) or {}),
|
||||||
|
payload=dict(ctx.payload or {}),
|
||||||
|
)
|
||||||
await sync_to_async(_auto_setup_profile_bindings_for_first_command)(
|
await sync_to_async(_auto_setup_profile_bindings_for_first_command)(
|
||||||
ctx,
|
ctx,
|
||||||
trigger_message,
|
trigger_message,
|
||||||
@@ -334,6 +344,25 @@ async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
|
|||||||
for profile in profiles:
|
for profile in profiles:
|
||||||
if not _matches_trigger(profile, ctx.message_text):
|
if not _matches_trigger(profile, ctx.message_text):
|
||||||
continue
|
continue
|
||||||
|
decision = await sync_to_async(evaluate_command_policy)(
|
||||||
|
user=trigger_message.user,
|
||||||
|
scope_key=f"command.{profile.slug}",
|
||||||
|
context=security_context,
|
||||||
|
)
|
||||||
|
if not decision.allowed:
|
||||||
|
results.append(
|
||||||
|
CommandResult(
|
||||||
|
ok=False,
|
||||||
|
status="skipped",
|
||||||
|
error=f"policy_denied:{decision.code}",
|
||||||
|
payload={
|
||||||
|
"profile": profile.slug,
|
||||||
|
"scope": f"command.{profile.slug}",
|
||||||
|
"reason": decision.reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
if profile.reply_required and trigger_message.reply_to_id is None:
|
if profile.reply_required and trigger_message.reply_to_id is None:
|
||||||
if (
|
if (
|
||||||
profile.slug == "bp"
|
profile.slug == "bp"
|
||||||
|
|||||||
1
core/gateway/__init__.py
Normal file
1
core/gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Gateway command routing utilities."""
|
||||||
133
core/gateway/commands.py
Normal file
133
core/gateway/commands.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
|
from core.models import GatewayCommandEvent
|
||||||
|
from core.security.command_policy import CommandSecurityContext, evaluate_command_policy
|
||||||
|
|
||||||
|
|
||||||
|
GatewayEmit = Callable[[str], None]
|
||||||
|
GatewayHandler = Callable[["GatewayCommandContext", GatewayEmit], Awaitable[bool]]
|
||||||
|
GatewayMatcher = Callable[[str], bool]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GatewayCommandContext:
|
||||||
|
user: object
|
||||||
|
source_message: object
|
||||||
|
service: str
|
||||||
|
channel_identifier: str
|
||||||
|
sender_identifier: str
|
||||||
|
message_text: str
|
||||||
|
message_meta: dict
|
||||||
|
payload: dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GatewayCommandRoute:
|
||||||
|
name: str
|
||||||
|
scope_key: str
|
||||||
|
matcher: GatewayMatcher
|
||||||
|
handler: GatewayHandler
|
||||||
|
|
||||||
|
|
||||||
|
def _first_token(text: str) -> str:
|
||||||
|
body = str(text or "").strip()
|
||||||
|
if not body:
|
||||||
|
return ""
|
||||||
|
return str(body.split()[0] or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_unknown_scope(text: str) -> str:
|
||||||
|
token = _first_token(text).lstrip(".")
|
||||||
|
if not token:
|
||||||
|
token = "message"
|
||||||
|
return f"gateway.{token}"
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch_gateway_command(
|
||||||
|
*,
|
||||||
|
context: GatewayCommandContext,
|
||||||
|
routes: list[GatewayCommandRoute],
|
||||||
|
emit: GatewayEmit,
|
||||||
|
) -> bool:
|
||||||
|
text = str(context.message_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
route = next((row for row in routes if row.matcher(text)), None)
|
||||||
|
scope_key = route.scope_key if route is not None else _derive_unknown_scope(text)
|
||||||
|
command_name = route.name if route is not None else _first_token(text).lstrip(".")
|
||||||
|
|
||||||
|
event = await sync_to_async(GatewayCommandEvent.objects.create)(
|
||||||
|
user=context.user,
|
||||||
|
source_message=context.source_message,
|
||||||
|
service=str(context.service or "").strip().lower() or "xmpp",
|
||||||
|
channel_identifier=str(context.channel_identifier or "").strip(),
|
||||||
|
sender_identifier=str(context.sender_identifier or "").strip(),
|
||||||
|
scope_key=scope_key,
|
||||||
|
command_name=command_name,
|
||||||
|
command_text=text,
|
||||||
|
status="pending",
|
||||||
|
request_meta={
|
||||||
|
"payload": dict(context.payload or {}),
|
||||||
|
"message_meta": dict(context.message_meta or {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if route is None:
|
||||||
|
event.status = "ignored"
|
||||||
|
event.error = "unmatched_gateway_command"
|
||||||
|
await sync_to_async(event.save)(update_fields=["status", "error", "updated_at"])
|
||||||
|
return False
|
||||||
|
|
||||||
|
decision = await sync_to_async(evaluate_command_policy)(
|
||||||
|
user=context.user,
|
||||||
|
scope_key=scope_key,
|
||||||
|
context=CommandSecurityContext(
|
||||||
|
service=context.service,
|
||||||
|
channel_identifier=context.channel_identifier,
|
||||||
|
message_meta=dict(context.message_meta or {}),
|
||||||
|
payload=dict(context.payload or {}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not decision.allowed:
|
||||||
|
message = (
|
||||||
|
f"blocked by policy: {decision.code}"
|
||||||
|
if not decision.reason
|
||||||
|
else f"blocked by policy: {decision.reason}"
|
||||||
|
)
|
||||||
|
emit(message)
|
||||||
|
event.status = "blocked"
|
||||||
|
event.error = f"{decision.code}:{decision.reason}"
|
||||||
|
event.response_meta = {"policy_code": decision.code, "policy_reason": decision.reason}
|
||||||
|
await sync_to_async(event.save)(
|
||||||
|
update_fields=["status", "error", "response_meta", "updated_at"]
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
responses: list[str] = []
|
||||||
|
|
||||||
|
def _captured_emit(value: str) -> None:
|
||||||
|
row = str(value or "")
|
||||||
|
responses.append(row)
|
||||||
|
emit(row)
|
||||||
|
|
||||||
|
try:
|
||||||
|
handled = await route.handler(context, _captured_emit)
|
||||||
|
except Exception as exc:
|
||||||
|
event.status = "failed"
|
||||||
|
event.error = f"handler_exception:{exc}"
|
||||||
|
event.response_meta = {"responses": responses}
|
||||||
|
await sync_to_async(event.save)(
|
||||||
|
update_fields=["status", "error", "response_meta", "updated_at"]
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
event.status = "ok" if handled else "ignored"
|
||||||
|
event.response_meta = {"responses": responses}
|
||||||
|
await sync_to_async(event.save)(update_fields=["status", "response_meta", "updated_at"])
|
||||||
|
return bool(handled)
|
||||||
7
core/management/commands/task_sync_worker.py
Normal file
7
core/management/commands/task_sync_worker.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core.management.commands.codex_worker import Command as LegacyCodexWorkerCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(LegacyCodexWorkerCommand):
|
||||||
|
help = "Process queued task-sync events for worker-backed providers (Codex + Claude)."
|
||||||
33
core/migrations/0038_userxmppomemostate_and_more.py
Normal file
33
core/migrations/0038_userxmppomemostate_and_more.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-06 20:42
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0037_derivedtask_due_date_assignee_identifier'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserXmppOmemoState',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('detected', 'Detected'), ('no_omemo', 'No OMEMO'), ('error', 'Error')], default='pending', max_length=32)),
|
||||||
|
('latest_client_key', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('last_sender_jid', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('last_target_jid', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('status_reason', models.TextField(blank=True, default='')),
|
||||||
|
('details', models.JSONField(blank=True, default=dict)),
|
||||||
|
('last_seen_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.OneToOneField(on_delete=models.deletion.CASCADE, related_name='xmpp_omemo_state', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'indexes': [models.Index(fields=['status', 'updated_at'], name='core_userxm_status_133ead_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
27
core/migrations/0039_userxmppsecuritysettings.py
Normal file
27
core/migrations/0039_userxmppsecuritysettings.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0038_userxmppomemostate_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserXmppSecuritySettings',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('require_omemo', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.OneToOneField(
|
||||||
|
on_delete=models.deletion.CASCADE,
|
||||||
|
related_name='xmpp_security_settings',
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2026-03-07 00:00
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0039_userxmppsecuritysettings"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CommandSecurityPolicy",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("scope_key", models.CharField(default="gateway.tasks", max_length=64)),
|
||||||
|
("enabled", models.BooleanField(default=True)),
|
||||||
|
("require_omemo", models.BooleanField(default=False)),
|
||||||
|
("require_trusted_omemo_fingerprint", models.BooleanField(default=False)),
|
||||||
|
("allowed_services", models.JSONField(blank=True, default=list)),
|
||||||
|
("allowed_channels", models.JSONField(blank=True, default=dict)),
|
||||||
|
("settings", models.JSONField(blank=True, default=dict)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="command_security_policies",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["user", "scope_key"], name="core_comman_user_id_701379_idx"),
|
||||||
|
models.Index(
|
||||||
|
fields=["user", "enabled", "updated_at"],
|
||||||
|
name="core_comman_user_id_82e21d_idx",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("user", "scope_key"),
|
||||||
|
name="unique_command_security_policy_per_scope",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="GatewayCommandEvent",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("service", models.CharField(choices=[("signal", "Signal"), ("whatsapp", "WhatsApp"), ("xmpp", "XMPP"), ("instagram", "Instagram"), ("web", "Web")], max_length=255)),
|
||||||
|
("channel_identifier", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("sender_identifier", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("scope_key", models.CharField(blank=True, default="", max_length=64)),
|
||||||
|
("command_name", models.CharField(blank=True, default="", max_length=64)),
|
||||||
|
("command_text", models.TextField(blank=True, default="")),
|
||||||
|
("status", models.CharField(choices=[("pending", "Pending"), ("blocked", "Blocked"), ("ok", "OK"), ("failed", "Failed"), ("ignored", "Ignored")], default="pending", max_length=32)),
|
||||||
|
("error", models.TextField(blank=True, default="")),
|
||||||
|
("request_meta", models.JSONField(blank=True, default=dict)),
|
||||||
|
("response_meta", models.JSONField(blank=True, default=dict)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"source_message",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="gateway_command_events",
|
||||||
|
to="core.message",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="gateway_command_events",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["user", "scope_key", "created_at"],
|
||||||
|
name="core_gatewa_user_id_d997cf_idx",
|
||||||
|
),
|
||||||
|
models.Index(
|
||||||
|
fields=["user", "status", "created_at"],
|
||||||
|
name="core_gatewa_user_id_639afe_idx",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
110
core/models.py
110
core/models.py
@@ -2100,6 +2100,76 @@ class CommandRun(models.Model):
|
|||||||
indexes = [models.Index(fields=["user", "status", "updated_at"])]
|
indexes = [models.Index(fields=["user", "status", "updated_at"])]
|
||||||
|
|
||||||
|
|
||||||
|
class CommandSecurityPolicy(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="command_security_policies",
|
||||||
|
)
|
||||||
|
scope_key = models.CharField(max_length=64, default="gateway.tasks")
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
require_omemo = models.BooleanField(default=False)
|
||||||
|
require_trusted_omemo_fingerprint = models.BooleanField(default=False)
|
||||||
|
allowed_services = models.JSONField(default=list, blank=True)
|
||||||
|
allowed_channels = models.JSONField(default=dict, blank=True)
|
||||||
|
settings = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "scope_key"],
|
||||||
|
name="unique_command_security_policy_per_scope",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "scope_key"]),
|
||||||
|
models.Index(fields=["user", "enabled", "updated_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayCommandEvent(models.Model):
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
("pending", "Pending"),
|
||||||
|
("blocked", "Blocked"),
|
||||||
|
("ok", "OK"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
("ignored", "Ignored"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="gateway_command_events",
|
||||||
|
)
|
||||||
|
source_message = models.ForeignKey(
|
||||||
|
Message,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="gateway_command_events",
|
||||||
|
)
|
||||||
|
service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
|
||||||
|
channel_identifier = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
sender_identifier = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
scope_key = models.CharField(max_length=64, blank=True, default="")
|
||||||
|
command_name = models.CharField(max_length=64, blank=True, default="")
|
||||||
|
command_text = models.TextField(blank=True, default="")
|
||||||
|
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="pending")
|
||||||
|
error = models.TextField(blank=True, default="")
|
||||||
|
request_meta = models.JSONField(default=dict, blank=True)
|
||||||
|
response_meta = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "scope_key", "created_at"]),
|
||||||
|
models.Index(fields=["user", "status", "created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TranslationBridge(models.Model):
|
class TranslationBridge(models.Model):
|
||||||
DIRECTION_CHOICES = (
|
DIRECTION_CHOICES = (
|
||||||
("a_to_b", "A To B"),
|
("a_to_b", "A To B"),
|
||||||
@@ -2815,6 +2885,46 @@ class ExternalChatLink(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UserXmppOmemoState(models.Model):
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
("pending", "Pending"),
|
||||||
|
("detected", "Detected"),
|
||||||
|
("no_omemo", "No OMEMO"),
|
||||||
|
("error", "Error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.OneToOneField(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="xmpp_omemo_state",
|
||||||
|
)
|
||||||
|
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="pending")
|
||||||
|
latest_client_key = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
last_sender_jid = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
last_target_jid = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
status_reason = models.TextField(blank=True, default="")
|
||||||
|
details = models.JSONField(blank=True, default=dict)
|
||||||
|
last_seen_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["status", "updated_at"], name="core_userxm_status_133ead_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UserXmppSecuritySettings(models.Model):
|
||||||
|
user = models.OneToOneField(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="xmpp_security_settings",
|
||||||
|
)
|
||||||
|
require_omemo = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|
||||||
class TaskCompletionPattern(models.Model):
|
class TaskCompletionPattern(models.Model):
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_completion_patterns")
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_completion_patterns")
|
||||||
|
|||||||
220
core/security/command_policy.py
Normal file
220
core/security/command_policy.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from core.models import CommandSecurityPolicy, UserXmppOmemoState
|
||||||
|
|
||||||
|
GLOBAL_SCOPE_KEY = "global.override"
|
||||||
|
OVERRIDE_OPTIONS = {"per_scope", "on", "off"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CommandSecurityContext:
|
||||||
|
service: str
|
||||||
|
channel_identifier: str
|
||||||
|
message_meta: dict
|
||||||
|
payload: dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CommandPolicyDecision:
|
||||||
|
allowed: bool
|
||||||
|
code: str = "allowed"
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_service(value: str) -> str:
|
||||||
|
return str(value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_channel(value: str) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_list(values) -> list[str]:
|
||||||
|
rows: list[str] = []
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return rows
|
||||||
|
for row in values:
|
||||||
|
item = str(row or "").strip()
|
||||||
|
if item and item not in rows:
|
||||||
|
rows.append(item)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_override_value(value: str) -> str:
|
||||||
|
option = str(value or "").strip().lower()
|
||||||
|
if option == "inherit":
|
||||||
|
# Backward compatibility for previously saved values.
|
||||||
|
option = "per_scope"
|
||||||
|
if option in OVERRIDE_OPTIONS:
|
||||||
|
return option
|
||||||
|
return "per_scope"
|
||||||
|
|
||||||
|
|
||||||
|
def _match_channel(rule: str, channel: str) -> bool:
|
||||||
|
value = str(rule or "").strip()
|
||||||
|
current = str(channel or "").strip()
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
if value == "*":
|
||||||
|
return True
|
||||||
|
if value.endswith("*"):
|
||||||
|
return current.startswith(value[:-1])
|
||||||
|
return current == value
|
||||||
|
|
||||||
|
|
||||||
|
def _omemo_facts(ctx: CommandSecurityContext) -> tuple[str, str]:
|
||||||
|
message_meta = dict(ctx.message_meta or {})
|
||||||
|
payload = dict(ctx.payload or {})
|
||||||
|
xmpp_meta = dict(message_meta.get("xmpp") or {})
|
||||||
|
status = str(
|
||||||
|
xmpp_meta.get("omemo_status")
|
||||||
|
or payload.get("omemo_status")
|
||||||
|
or ""
|
||||||
|
).strip().lower()
|
||||||
|
client_key = str(
|
||||||
|
xmpp_meta.get("omemo_client_key")
|
||||||
|
or payload.get("omemo_client_key")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
return status, client_key
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_allowed_for_rules(rules: dict, service: str, channel: str) -> bool:
|
||||||
|
service_rules = _normalize_list(rules.get(service))
|
||||||
|
if not service_rules:
|
||||||
|
service_rules = _normalize_list(rules.get("*"))
|
||||||
|
if not service_rules:
|
||||||
|
return True
|
||||||
|
return any(_match_channel(rule, channel) for rule in service_rules)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_allowed(allowed_services: list[str], service: str) -> bool:
|
||||||
|
if not allowed_services:
|
||||||
|
return True
|
||||||
|
return service in allowed_services
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_bool(local_value: bool, global_override: str) -> bool:
|
||||||
|
option = _parse_override_value(global_override)
|
||||||
|
if option == "on":
|
||||||
|
return True
|
||||||
|
if option == "off":
|
||||||
|
return False
|
||||||
|
return bool(local_value)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_command_policy(
|
||||||
|
*,
|
||||||
|
user,
|
||||||
|
scope_key: str,
|
||||||
|
context: CommandSecurityContext,
|
||||||
|
) -> CommandPolicyDecision:
|
||||||
|
scope = str(scope_key or "").strip().lower()
|
||||||
|
if not scope:
|
||||||
|
return CommandPolicyDecision(allowed=True)
|
||||||
|
|
||||||
|
policy = (
|
||||||
|
CommandSecurityPolicy.objects.filter(
|
||||||
|
user=user,
|
||||||
|
scope_key=scope,
|
||||||
|
)
|
||||||
|
.order_by("-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
global_policy = (
|
||||||
|
CommandSecurityPolicy.objects.filter(
|
||||||
|
user=user,
|
||||||
|
scope_key=GLOBAL_SCOPE_KEY,
|
||||||
|
)
|
||||||
|
.order_by("-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if policy is None and global_policy is None:
|
||||||
|
return CommandPolicyDecision(allowed=True)
|
||||||
|
|
||||||
|
global_settings = dict(getattr(global_policy, "settings", {}) or {})
|
||||||
|
local_enabled = bool(getattr(policy, "enabled", True))
|
||||||
|
local_require_omemo = bool(getattr(policy, "require_omemo", False))
|
||||||
|
local_require_trusted = bool(
|
||||||
|
getattr(policy, "require_trusted_omemo_fingerprint", False)
|
||||||
|
)
|
||||||
|
enabled = _effective_bool(local_enabled, global_settings.get("scope_enabled"))
|
||||||
|
require_omemo = _effective_bool(
|
||||||
|
local_require_omemo, global_settings.get("require_omemo")
|
||||||
|
)
|
||||||
|
require_trusted_omemo_fingerprint = _effective_bool(
|
||||||
|
local_require_trusted,
|
||||||
|
global_settings.get("require_trusted_fingerprint"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
return CommandPolicyDecision(
|
||||||
|
allowed=False,
|
||||||
|
code="policy_disabled",
|
||||||
|
reason=f"{scope} is disabled by command policy",
|
||||||
|
)
|
||||||
|
|
||||||
|
service = _normalize_service(context.service)
|
||||||
|
channel = _normalize_channel(context.channel_identifier)
|
||||||
|
allowed_services = [
|
||||||
|
item.lower() for item in _normalize_list(getattr(policy, "allowed_services", []))
|
||||||
|
]
|
||||||
|
global_allowed_services = [
|
||||||
|
item.lower()
|
||||||
|
for item in _normalize_list(getattr(global_policy, "allowed_services", []))
|
||||||
|
]
|
||||||
|
if not _service_allowed(allowed_services, service):
|
||||||
|
return CommandPolicyDecision(
|
||||||
|
allowed=False,
|
||||||
|
code="service_not_allowed",
|
||||||
|
reason=f"service={service or '-'} not allowed for scope={scope}",
|
||||||
|
)
|
||||||
|
if not _service_allowed(global_allowed_services, service):
|
||||||
|
return CommandPolicyDecision(
|
||||||
|
allowed=False,
|
||||||
|
code="service_not_allowed",
|
||||||
|
reason=f"service={service or '-'} not allowed by global override",
|
||||||
|
)
|
||||||
|
local_channel_rules = dict(getattr(policy, "allowed_channels", {}) or {})
|
||||||
|
if not _channel_allowed_for_rules(local_channel_rules, service, channel):
|
||||||
|
return CommandPolicyDecision(
|
||||||
|
allowed=False,
|
||||||
|
code="channel_not_allowed",
|
||||||
|
reason=f"channel={channel or '-'} not allowed for scope={scope}",
|
||||||
|
)
|
||||||
|
global_channel_rules = dict(getattr(global_policy, "allowed_channels", {}) or {})
|
||||||
|
if not _channel_allowed_for_rules(global_channel_rules, service, channel):
|
||||||
|
return CommandPolicyDecision(
|
||||||
|
allowed=False,
|
||||||
|
code="channel_not_allowed",
|
||||||
|
reason=f"channel={channel or '-'} not allowed by global override",
|
||||||
|
)
|
||||||
|
|
||||||
|
omemo_status, omemo_client_key = _omemo_facts(context)
|
||||||
|
if require_omemo and omemo_status != "detected":
|
||||||
|
return CommandPolicyDecision(
|
||||||
|
allowed=False,
|
||||||
|
code="omemo_required",
|
||||||
|
reason=f"scope={scope} requires OMEMO",
|
||||||
|
)
|
||||||
|
|
||||||
|
if require_trusted_omemo_fingerprint:
|
||||||
|
if omemo_status != "detected" or not omemo_client_key:
|
||||||
|
return CommandPolicyDecision(
|
||||||
|
allowed=False,
|
||||||
|
code="trusted_fingerprint_required",
|
||||||
|
reason=f"scope={scope} requires trusted OMEMO fingerprint",
|
||||||
|
)
|
||||||
|
state = UserXmppOmemoState.objects.filter(user=user).first()
|
||||||
|
expected_key = str(getattr(state, "latest_client_key", "") or "").strip()
|
||||||
|
if not expected_key or expected_key != omemo_client_key:
|
||||||
|
return CommandPolicyDecision(
|
||||||
|
allowed=False,
|
||||||
|
code="fingerprint_mismatch",
|
||||||
|
reason=f"scope={scope} OMEMO fingerprint does not match enrolled key",
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandPolicyDecision(allowed=True)
|
||||||
@@ -22,7 +22,9 @@ def queue_codex_event_with_pre_approval(
|
|||||||
action: str,
|
action: str,
|
||||||
provider_payload: dict,
|
provider_payload: dict,
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
|
provider: str = "codex_cli",
|
||||||
) -> tuple[ExternalSyncEvent, CodexPermissionRequest]:
|
) -> tuple[ExternalSyncEvent, CodexPermissionRequest]:
|
||||||
|
provider = str(provider or "codex_cli").strip() or "codex_cli"
|
||||||
approval_key = _deterministic_approval_key(idempotency_key)
|
approval_key = _deterministic_approval_key(idempotency_key)
|
||||||
waiting_event, _ = ExternalSyncEvent.objects.update_or_create(
|
waiting_event, _ = ExternalSyncEvent.objects.update_or_create(
|
||||||
idempotency_key=f"codex_waiting:{idempotency_key}",
|
idempotency_key=f"codex_waiting:{idempotency_key}",
|
||||||
@@ -30,7 +32,7 @@ def queue_codex_event_with_pre_approval(
|
|||||||
"user": user,
|
"user": user,
|
||||||
"task": task,
|
"task": task,
|
||||||
"task_event": task_event,
|
"task_event": task_event,
|
||||||
"provider": "codex_cli",
|
"provider": provider,
|
||||||
"status": "waiting_approval",
|
"status": "waiting_approval",
|
||||||
"payload": {
|
"payload": {
|
||||||
"action": str(action or "append_update"),
|
"action": str(action or "append_update"),
|
||||||
@@ -43,16 +45,18 @@ def queue_codex_event_with_pre_approval(
|
|||||||
run.error = ""
|
run.error = ""
|
||||||
run.save(update_fields=["status", "error", "updated_at"])
|
run.save(update_fields=["status", "error", "updated_at"])
|
||||||
|
|
||||||
|
provider_label = "Claude" if provider == "claude_cli" else "Codex"
|
||||||
|
xmpp_cmd = ".claude" if provider == "claude_cli" else ".codex"
|
||||||
request, _ = CodexPermissionRequest.objects.update_or_create(
|
request, _ = CodexPermissionRequest.objects.update_or_create(
|
||||||
approval_key=approval_key,
|
approval_key=approval_key,
|
||||||
defaults={
|
defaults={
|
||||||
"user": user,
|
"user": user,
|
||||||
"codex_run": run,
|
"codex_run": run,
|
||||||
"external_sync_event": waiting_event,
|
"external_sync_event": waiting_event,
|
||||||
"summary": "Pre-submit approval required before sending to Codex",
|
"summary": f"Pre-submit approval required before sending to {provider_label}",
|
||||||
"requested_permissions": {
|
"requested_permissions": {
|
||||||
"type": "pre_submit",
|
"type": "pre_submit",
|
||||||
"provider": "codex_cli",
|
"provider": provider,
|
||||||
"action": str(action or "append_update"),
|
"action": str(action or "append_update"),
|
||||||
},
|
},
|
||||||
"resume_payload": {
|
"resume_payload": {
|
||||||
@@ -68,7 +72,7 @@ def queue_codex_event_with_pre_approval(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg = TaskProviderConfig.objects.filter(user=user, provider="codex_cli", enabled=True).first()
|
cfg = TaskProviderConfig.objects.filter(user=user, provider=provider, enabled=True).first()
|
||||||
settings_payload = dict(getattr(cfg, "settings", {}) or {})
|
settings_payload = dict(getattr(cfg, "settings", {}) or {})
|
||||||
approver_service = str(settings_payload.get("approver_service") or "").strip().lower()
|
approver_service = str(settings_payload.get("approver_service") or "").strip().lower()
|
||||||
approver_identifier = str(settings_payload.get("approver_identifier") or "").strip()
|
approver_identifier = str(settings_payload.get("approver_identifier") or "").strip()
|
||||||
@@ -78,10 +82,10 @@ def queue_codex_event_with_pre_approval(
|
|||||||
approver_service,
|
approver_service,
|
||||||
approver_identifier,
|
approver_identifier,
|
||||||
text=(
|
text=(
|
||||||
f"[codex approval] key={approval_key}\n"
|
f"[{provider} approval] key={approval_key}\n"
|
||||||
"summary=Pre-submit approval required before sending to Codex\n"
|
f"summary=Pre-submit approval required before sending to {provider_label}\n"
|
||||||
"requested=pre_submit\n"
|
"requested=pre_submit\n"
|
||||||
f"use: .codex approve {approval_key} or .codex deny {approval_key}"
|
f"use: {xmpp_cmd} approve {approval_key} or {xmpp_cmd} deny {approval_key}"
|
||||||
),
|
),
|
||||||
attachments=[],
|
attachments=[],
|
||||||
metadata={"origin_tag": f"codex-pre-approval:{approval_key}"},
|
metadata={"origin_tag": f"codex-pre-approval:{approval_key}"},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from core.tasks.chat_defaults import ensure_default_source_for_chat, resolve_mes
|
|||||||
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
|
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
|
||||||
from core.tasks.providers import get_provider
|
from core.tasks.providers import get_provider
|
||||||
from core.tasks.codex_support import resolve_external_chat_id
|
from core.tasks.codex_support import resolve_external_chat_id
|
||||||
|
from core.security.command_policy import CommandSecurityContext, evaluate_command_policy
|
||||||
|
|
||||||
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
|
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
|
||||||
_COMPLETION_RE = re.compile(r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE)
|
_COMPLETION_RE = re.compile(r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE)
|
||||||
@@ -699,6 +700,20 @@ def _is_task_command_candidate(text: str) -> bool:
|
|||||||
return _has_task_prefix(body.lower(), ["task:", "todo:"])
|
return _has_task_prefix(body.lower(), ["task:", "todo:"])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_explicit_task_command(text: str) -> bool:
|
||||||
|
body = str(text or "").strip()
|
||||||
|
if not body:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
_LIST_TASKS_RE.match(body)
|
||||||
|
or _LIST_TASKS_CMD_RE.match(body)
|
||||||
|
or _TASK_SHOW_RE.match(body)
|
||||||
|
or _TASK_COMPLETE_CMD_RE.match(body)
|
||||||
|
or _UNDO_TASK_RE.match(body)
|
||||||
|
or _EPIC_CREATE_RE.match(body)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def process_inbound_task_intelligence(message: Message) -> None:
|
async def process_inbound_task_intelligence(message: Message) -> None:
|
||||||
if message is None:
|
if message is None:
|
||||||
return
|
return
|
||||||
@@ -707,6 +722,20 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
|||||||
text = str(message.text or "").strip()
|
text = str(message.text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
security_context = CommandSecurityContext(
|
||||||
|
service=str(message.source_service or "").strip().lower(),
|
||||||
|
channel_identifier=str(message.source_chat_id or "").strip(),
|
||||||
|
message_meta=dict(message.message_meta or {}),
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
if _is_explicit_task_command(text):
|
||||||
|
command_decision = await sync_to_async(evaluate_command_policy)(
|
||||||
|
user=message.user,
|
||||||
|
scope_key="tasks.commands",
|
||||||
|
context=security_context,
|
||||||
|
)
|
||||||
|
if not command_decision.allowed:
|
||||||
|
return
|
||||||
|
|
||||||
sources = await _resolve_source_mappings(message)
|
sources = await _resolve_source_mappings(message)
|
||||||
if not sources:
|
if not sources:
|
||||||
@@ -729,6 +758,14 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
|||||||
if await _handle_epic_create_command(message, sources, text):
|
if await _handle_epic_create_command(message, sources, text):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
submit_decision = await sync_to_async(evaluate_command_policy)(
|
||||||
|
user=message.user,
|
||||||
|
scope_key="tasks.submit",
|
||||||
|
context=security_context,
|
||||||
|
)
|
||||||
|
if not submit_decision.allowed:
|
||||||
|
return
|
||||||
|
|
||||||
completion_allowed = any(bool(_effective_flags(source).get("completion_enabled")) for source in sources)
|
completion_allowed = any(bool(_effective_flags(source).get("completion_enabled")) for source in sources)
|
||||||
completion_rx = await _completion_regex(message) if completion_allowed else None
|
completion_rx = await _completion_regex(message) if completion_allowed else None
|
||||||
marker_match = (completion_rx.search(text) if completion_rx else None) or (_COMPLETION_RE.search(text) if completion_allowed else None)
|
marker_match = (completion_rx.search(text) if completion_rx else None) or (_COMPLETION_RE.search(text) if completion_allowed else None)
|
||||||
|
|||||||
@@ -400,9 +400,12 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
<a class="navbar-item" href="{% url 'two_factor:profile' %}">
|
<a class="navbar-item" href="{% url 'security_settings' %}">
|
||||||
Security
|
Security
|
||||||
</a>
|
</a>
|
||||||
|
<a class="navbar-item" href="{% url 'two_factor:profile' %}">
|
||||||
|
2FA
|
||||||
|
</a>
|
||||||
<a class="navbar-item" href="{% url 'notifications_update' type='page' %}">
|
<a class="navbar-item" href="{% url 'notifications_update' type='page' %}">
|
||||||
Notifications
|
Notifications
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
695
core/templates/pages/security.html
Normal file
695
core/templates/pages/security.html
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title is-4">Security</h1>
|
||||||
|
|
||||||
|
<div class="columns is-desktop is-variable is-8">
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="title is-6">XMPP Channel</h2>
|
||||||
|
<table class="table is-fullwidth is-size-7">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Component JID</th>
|
||||||
|
<td>{{ xmpp_state.omemo_target_jid|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>OMEMO</th>
|
||||||
|
<td>
|
||||||
|
{% if xmpp_state.omemo_enabled %}
|
||||||
|
<span class="tag is-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tag is-light">{{ xmpp_state.omemo_status|default:"not configured" }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status reason</th>
|
||||||
|
<td>{{ xmpp_state.omemo_status_reason|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Component fingerprint</th>
|
||||||
|
<td><code>{{ xmpp_state.omemo_fingerprint|default:"—" }}</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h3 class="title is-7 mt-4 mb-2">Security Policy</h3>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="require_omemo"{% if security_settings.require_omemo %} checked{% endif %}>
|
||||||
|
Require OMEMO encryption — reject plaintext messages from your XMPP client
|
||||||
|
</label>
|
||||||
|
<p class="help is-size-7 has-text-grey mt-1">When enabled, any plaintext XMPP message to the gateway is rejected before command routing.</p>
|
||||||
|
<p class="help is-size-7 has-text-grey">This is separate from command-scope policy checks such as Require Trusted Fingerprint.</p>
|
||||||
|
</div>
|
||||||
|
<button class="button is-link is-small" type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="title is-6">Your XMPP Client</h2>
|
||||||
|
{% if omemo_row %}
|
||||||
|
<table class="table is-fullwidth is-size-7">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<td>
|
||||||
|
{% if omemo_row.status == "detected" %}
|
||||||
|
<span class="tag is-info">{{ omemo_row.status }}</span>
|
||||||
|
{% elif omemo_row.status == "no_omemo" %}
|
||||||
|
<span class="tag is-warning">no OMEMO observed</span>
|
||||||
|
{% elif omemo_row.status == "error" %}
|
||||||
|
<span class="tag is-danger">{{ omemo_row.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tag is-light">{{ omemo_row.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Contact key</th>
|
||||||
|
<td><code>{{ omemo_row.latest_client_key|default:"—" }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Contact JID</th>
|
||||||
|
<td>{{ sender_jid.bare|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Resource</th>
|
||||||
|
<td>{{ sender_jid.resource|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>GIA component</th>
|
||||||
|
<td>{{ omemo_row.last_target_jid|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Last seen</th>
|
||||||
|
<td>{{ omemo_row.last_seen_at|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Updated</th>
|
||||||
|
<td>{{ omemo_row.updated_at }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="is-size-7 has-text-grey">No OMEMO observation recorded yet. Send a message via XMPP to populate this.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="title is-6">Global Scope Override</h2>
|
||||||
|
<p class="is-size-7 has-text-grey mb-3">
|
||||||
|
This scope can force settings across all Command Security Scopes.
|
||||||
|
</p>
|
||||||
|
<div class="box" style="margin: 0; border: 1px solid rgba(60, 60, 60, 0.12);">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="scope_key" value="global.override">
|
||||||
|
<input type="hidden" name="global_scope_enabled" value="{{ global_override.values.scope_enabled }}" data-global-mode-input="scope_enabled">
|
||||||
|
<input type="hidden" name="global_require_omemo" value="{{ global_override.values.require_omemo }}" data-global-mode-input="require_omemo">
|
||||||
|
<input type="hidden" name="global_require_trusted_fingerprint" value="{{ global_override.values.require_trusted_fingerprint }}" data-global-mode-input="require_trusted_fingerprint">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1"><strong>Global Scope Override</strong></p>
|
||||||
|
<p class="is-size-7 has-text-grey">Remote controls for local scope security checkboxes.</p>
|
||||||
|
<p class="is-size-7 has-text-grey"><code>global.override</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="tags has-addons">
|
||||||
|
<span class="tag is-dark">Scope Enabled</span>
|
||||||
|
{% if global_override.values.scope_enabled == "on" %}
|
||||||
|
<span class="tag is-success">Force On</span>
|
||||||
|
{% elif global_override.values.scope_enabled == "off" %}
|
||||||
|
<span class="tag is-light">Force Off</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tag is-info">Per Scope</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field mb-2">
|
||||||
|
<label class="label is-size-7">Allowed Services Preview</label>
|
||||||
|
<div class="scope-allowance-capsule" data-capsule>
|
||||||
|
{% for service in policy_services %}
|
||||||
|
<span class="scope-allowance-pill" data-service-pill="{{ service }}">{{ service }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field is-grouped is-grouped-multiline mb-2">
|
||||||
|
<label class="checkbox mr-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled
|
||||||
|
data-global-visual-checkbox="scope_enabled"
|
||||||
|
data-global-visual-state="{{ global_override.values.scope_enabled }}"
|
||||||
|
{% if global_override.values.scope_enabled == "on" %} checked{% endif %}
|
||||||
|
>
|
||||||
|
Scope Enabled
|
||||||
|
</label>
|
||||||
|
<label class="checkbox mr-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled
|
||||||
|
data-global-visual-checkbox="require_omemo"
|
||||||
|
data-global-visual-state="{{ global_override.values.require_omemo }}"
|
||||||
|
{% if global_override.values.require_omemo == "on" %} checked{% endif %}
|
||||||
|
>
|
||||||
|
Require OMEMO
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled
|
||||||
|
data-global-visual-checkbox="require_trusted_fingerprint"
|
||||||
|
{% if global_override.values.require_trusted_fingerprint == "on" %} checked{% endif %}
|
||||||
|
>
|
||||||
|
Require Trusted Fingerprint
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="help is-size-7 has-text-grey mb-3">Set each field to Per Scope to edit that field inside local scopes.</p>
|
||||||
|
|
||||||
|
<div class="field mb-2">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||||
|
<label class="label is-size-7 mb-1">Scope Enabled</label>
|
||||||
|
<div class="is-flex is-align-items-center" style="gap:0.5rem;">
|
||||||
|
<span class="tag is-size-7" data-global-mode-label="scope_enabled"></span>
|
||||||
|
<button type="button" class="button is-small is-light" data-global-change-toggle="scope_enabled">Change Global</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="buttons has-addons is-hidden" data-global-mode-picker="scope_enabled">
|
||||||
|
<button class="button is-small" type="button" data-global-mode-set="scope_enabled" data-mode="per_scope">Per Scope</button>
|
||||||
|
<button class="button is-small" type="button" data-global-mode-set="scope_enabled" data-mode="on">Force On</button>
|
||||||
|
<button class="button is-small" type="button" data-global-mode-set="scope_enabled" data-mode="off">Force Off</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field mb-2">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||||
|
<label class="label is-size-7 mb-1">Require OMEMO</label>
|
||||||
|
<div class="is-flex is-align-items-center" style="gap:0.5rem;">
|
||||||
|
<span class="tag is-size-7" data-global-mode-label="require_omemo"></span>
|
||||||
|
<button type="button" class="button is-small is-light" data-global-change-toggle="require_omemo">Change Global</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="buttons has-addons is-hidden" data-global-mode-picker="require_omemo">
|
||||||
|
<button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="per_scope">Per Scope</button>
|
||||||
|
<button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="on">Force On</button>
|
||||||
|
<button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="off">Force Off</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field mb-3">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||||
|
<label class="label is-size-7 mb-1">Require Trusted Fingerprint</label>
|
||||||
|
<div class="is-flex is-align-items-center" style="gap:0.5rem;">
|
||||||
|
<span class="tag is-size-7" data-global-mode-label="require_trusted_fingerprint"></span>
|
||||||
|
<button type="button" class="button is-small is-light" data-global-change-toggle="require_trusted_fingerprint">Change Global</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="buttons has-addons is-hidden" data-global-mode-picker="require_trusted_fingerprint">
|
||||||
|
<button class="button is-small" type="button" data-global-mode-set="require_trusted_fingerprint" data-mode="per_scope">Per Scope</button>
|
||||||
|
<button class="button is-small" type="button" data-global-mode-set="require_trusted_fingerprint" data-mode="on">Force On</button>
|
||||||
|
<button class="button is-small" type="button" data-global-mode-set="require_trusted_fingerprint" data-mode="off">Force Off</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field mb-2">
|
||||||
|
<label class="label is-size-7">Allowed Services</label>
|
||||||
|
<div class="is-flex is-flex-wrap-wrap" style="gap: 0.8rem;">
|
||||||
|
{% for service in policy_services %}
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="allowed_services" value="{{ service }}"{% if service in global_override.allowed_services %} checked{% endif %}>
|
||||||
|
{{ service }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="help is-size-7 has-text-grey">Allowed Services: <code>xmpp</code>, <code>whatsapp</code>, <code>signal</code>, <code>instagram</code>, <code>web</code>.</p>
|
||||||
|
<p class="help is-size-7 has-text-grey">Leave all unchecked to allow all services.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field mb-2">
|
||||||
|
<label class="label is-size-7">Allowed Channels</label>
|
||||||
|
<div id="channel-rules-global-override" class="channel-rules-list">
|
||||||
|
{% for rule in global_override.channel_rules %}
|
||||||
|
<div class="channel-rule-row is-flex is-align-items-center mb-2" style="gap: 0.5rem;">
|
||||||
|
<div class="select is-small">
|
||||||
|
<select name="allowed_channel_service">
|
||||||
|
<option value="*"{% if rule.service == "*" %} selected{% endif %}>any</option>
|
||||||
|
{% for service in policy_services %}
|
||||||
|
<option value="{{ service }}"{% if rule.service == service %} selected{% endif %}>{{ service }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input class="input is-small" name="allowed_channel_pattern" value="{{ rule.pattern }}" placeholder="m@zm.is* or 1203*">
|
||||||
|
<button class="button is-small is-light is-danger channel-rule-remove" type="button">Remove</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="button is-small is-light channel-rule-add"
|
||||||
|
type="button"
|
||||||
|
data-target="channel-rules-global-override"
|
||||||
|
>
|
||||||
|
Add Channel Rule
|
||||||
|
</button>
|
||||||
|
<p class="help is-size-7 has-text-grey">Leave pattern rows empty to allow all channels for allowed services.</p>
|
||||||
|
</div>
|
||||||
|
<button class="button is-link is-small" type="submit">Save Scope</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="title is-6">Command Security Scopes</h2>
|
||||||
|
<p class="is-size-7 has-text-grey mb-3">
|
||||||
|
Choose a top-level category, expand a scope, then click <strong>Change</strong> to edit that scope.
|
||||||
|
</p>
|
||||||
|
<div class="tabs is-toggle is-toggle-rounded is-small mb-3">
|
||||||
|
<ul>
|
||||||
|
{% for group in policy_groups %}
|
||||||
|
<li class="{% if forloop.first %}is-active{% endif %}" data-policy-tab-button="{{ group.key }}">
|
||||||
|
<a>{{ group.label }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% for group in policy_groups %}
|
||||||
|
<div class="policy-tab-panel{% if forloop.first %} is-active{% endif %}" data-policy-tab-panel="{{ group.key }}">
|
||||||
|
<div class="is-flex is-flex-direction-column" style="gap: 1rem;">
|
||||||
|
{% for row in group.rows %}
|
||||||
|
<details class="box scope-editor-card" style="margin: 0; border: 1px solid rgba(60, 60, 60, 0.12);">
|
||||||
|
<summary class="scope-summary is-flex is-justify-content-space-between is-align-items-center">
|
||||||
|
<span>
|
||||||
|
<strong>{{ row.label }}</strong>
|
||||||
|
<span class="is-size-7 has-text-grey ml-2"><code>{{ row.scope_key }}</code></span>
|
||||||
|
</span>
|
||||||
|
<span class="tags has-addons">
|
||||||
|
<span class="tag is-dark">Scope Enabled</span>
|
||||||
|
{% if row.enabled %}
|
||||||
|
<span class="tag is-success">On</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tag is-light">Off</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<form method="post" class="mt-3" data-scope-form>
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="scope_key" value="{{ row.scope_key }}">
|
||||||
|
<input type="hidden" name="scope_change_mode" value="0" data-scope-change-mode>
|
||||||
|
<p class="is-size-7 has-text-grey mb-2">{{ row.description }}</p>
|
||||||
|
|
||||||
|
<div class="field mb-2">
|
||||||
|
<button class="button is-small is-light" type="button" data-scope-edit-toggle>Change</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field mb-2">
|
||||||
|
<label class="label is-size-7">Allowed Services Preview</label>
|
||||||
|
<div class="scope-allowance-capsule" data-capsule>
|
||||||
|
{% for service in policy_services %}
|
||||||
|
<span class="scope-allowance-pill" data-service-pill="{{ service }}">{{ service }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field is-grouped is-grouped-multiline mb-2">
|
||||||
|
<label class="checkbox mr-4" title="{% if row.enabled_locked %}{{ row.lock_help }}{% endif %}">
|
||||||
|
<input class="scope-editable" data-lock-state="{% if row.enabled_locked %}locked{% else %}free{% endif %}" type="checkbox" name="policy_enabled"{% if row.enabled %} checked{% endif %}{% if row.enabled_locked %} disabled{% endif %}>
|
||||||
|
Scope Enabled
|
||||||
|
</label>
|
||||||
|
<label class="checkbox mr-4" title="{% if row.require_omemo_locked %}{{ row.lock_help }}{% endif %}">
|
||||||
|
<input class="scope-editable" data-lock-state="{% if row.require_omemo_locked %}locked{% else %}free{% endif %}" type="checkbox" name="policy_require_omemo"{% if row.require_omemo %} checked{% endif %}{% if row.require_omemo_locked %} disabled{% endif %}>
|
||||||
|
Require OMEMO
|
||||||
|
</label>
|
||||||
|
<label class="checkbox" title="{% if row.require_trusted_fingerprint_locked %}{{ row.lock_help }}{% endif %}">
|
||||||
|
<input class="scope-editable" data-lock-state="{% if row.require_trusted_fingerprint_locked %}locked{% else %}free{% endif %}" type="checkbox" name="policy_require_trusted_fingerprint"{% if row.require_trusted_fingerprint %} checked{% endif %}{% if row.require_trusted_fingerprint_locked %} disabled{% endif %}>
|
||||||
|
Require Trusted Fingerprint
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field mb-2">
|
||||||
|
<label class="label is-size-7">Allowed Services</label>
|
||||||
|
<div class="is-flex is-flex-wrap-wrap" style="gap: 0.8rem;">
|
||||||
|
{% for service in policy_services %}
|
||||||
|
<label class="checkbox">
|
||||||
|
<input class="scope-editable" data-lock-state="free" type="checkbox" name="allowed_services" value="{{ service }}"{% if service in row.allowed_services %} checked{% endif %}>
|
||||||
|
{{ service }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="help is-size-7 has-text-grey">Allowed Services: <code>xmpp</code>, <code>whatsapp</code>, <code>signal</code>, <code>instagram</code>, <code>web</code>.</p>
|
||||||
|
<p class="help is-size-7 has-text-grey">Leave all unchecked to allow all services.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field mb-2">
|
||||||
|
<label class="label is-size-7">Allowed Channels</label>
|
||||||
|
<div id="channel-rules-{{ row.scope_key|slugify }}" class="channel-rules-list">
|
||||||
|
{% for rule in row.channel_rules %}
|
||||||
|
<div class="channel-rule-row is-flex is-align-items-center mb-2" style="gap: 0.5rem;">
|
||||||
|
<div class="select is-small">
|
||||||
|
<select class="scope-editable" data-lock-state="free" name="allowed_channel_service">
|
||||||
|
<option value="*"{% if rule.service == "*" %} selected{% endif %}>any</option>
|
||||||
|
{% for service in policy_services %}
|
||||||
|
<option value="{{ service }}"{% if rule.service == service %} selected{% endif %}>{{ service }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input class="input is-small scope-editable" data-lock-state="free" name="allowed_channel_pattern" value="{{ rule.pattern }}" placeholder="m@zm.is* or 1203*">
|
||||||
|
<button class="button is-small is-light is-danger channel-rule-remove scope-editable" data-lock-state="free" type="button">Remove</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="button is-small is-light channel-rule-add scope-editable"
|
||||||
|
data-lock-state="free"
|
||||||
|
type="button"
|
||||||
|
data-target="channel-rules-{{ row.scope_key|slugify }}"
|
||||||
|
>
|
||||||
|
Add Channel Rule
|
||||||
|
</button>
|
||||||
|
<p class="help is-size-7 has-text-grey">Leave pattern rows empty to allow all channels for allowed services.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="button is-link is-small" type="submit" data-scope-save>Save Scope</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="title is-6">OMEMO Enablement Plan</h2>
|
||||||
|
<p class="is-size-7 has-text-grey mb-3">Complete each step to achieve end-to-end encrypted messaging with the gateway.</p>
|
||||||
|
<table class="table is-fullwidth is-size-7">
|
||||||
|
<tbody>
|
||||||
|
{% for step in omemo_plan %}
|
||||||
|
<tr>
|
||||||
|
<td style="width:2.5rem">
|
||||||
|
{% if step.done %}
|
||||||
|
<span class="tag is-success">✓</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tag is-warning">○</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><strong>{{ step.label }}</strong></td>
|
||||||
|
<td class="has-text-grey">{{ step.hint }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<template id="channel-rule-template">
|
||||||
|
<div class="channel-rule-row is-flex is-align-items-center mb-2" style="gap: 0.5rem;">
|
||||||
|
<div class="select is-small">
|
||||||
|
<select class="scope-editable" data-lock-state="free" name="allowed_channel_service">
|
||||||
|
<option value="*">any</option>
|
||||||
|
{% for service in policy_services %}
|
||||||
|
<option value="{{ service }}">{{ service }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input class="input is-small scope-editable" data-lock-state="free" name="allowed_channel_pattern" value="" placeholder="m@zm.is* or 1203*">
|
||||||
|
<button class="button is-small is-light is-danger channel-rule-remove scope-editable" data-lock-state="free" type="button">Remove</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function wireRemoveButtons(scope) {
|
||||||
|
scope.querySelectorAll(".channel-rule-remove").forEach(function (btn) {
|
||||||
|
btn.onclick = function () {
|
||||||
|
const row = btn.closest(".channel-rule-row");
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshScopeCapsule(form) {
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const capsule = form.querySelector("[data-capsule]");
|
||||||
|
if (!capsule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const enabledInput = form.querySelector('input[name="policy_enabled"]');
|
||||||
|
let enabled = !!(enabledInput && enabledInput.checked);
|
||||||
|
if (!enabledInput) {
|
||||||
|
const globalModeInput = form.querySelector('input[name="global_scope_enabled"]');
|
||||||
|
if (globalModeInput) {
|
||||||
|
enabled = String(globalModeInput.value || "").toLowerCase() !== "off";
|
||||||
|
} else {
|
||||||
|
enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const serviceChecks = Array.from(
|
||||||
|
form.querySelectorAll('input[name="allowed_services"]')
|
||||||
|
);
|
||||||
|
const selected = serviceChecks
|
||||||
|
.filter(function (el) { return el.checked; })
|
||||||
|
.map(function (el) { return String(el.value || "").trim().toLowerCase(); })
|
||||||
|
.filter(Boolean);
|
||||||
|
const hasServiceRestriction = selected.length > 0;
|
||||||
|
|
||||||
|
capsule.querySelectorAll("[data-service-pill]").forEach(function (pill) {
|
||||||
|
const service = String(pill.getAttribute("data-service-pill") || "").trim().toLowerCase();
|
||||||
|
const serviceAllowed = !hasServiceRestriction || selected.includes(service);
|
||||||
|
const allowed = enabled && serviceAllowed;
|
||||||
|
pill.classList.remove("is-allowed", "is-blocked");
|
||||||
|
pill.classList.add(allowed ? "is-allowed" : "is-blocked");
|
||||||
|
pill.textContent = (service || "service") + (allowed ? " allowed" : " blocked");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wireRemoveButtons(document);
|
||||||
|
|
||||||
|
const template = document.getElementById("channel-rule-template");
|
||||||
|
document.querySelectorAll(".channel-rule-add").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
const targetId = btn.getAttribute("data-target");
|
||||||
|
const container = targetId ? document.getElementById(targetId) : null;
|
||||||
|
if (!container || !template) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = template.content.cloneNode(true);
|
||||||
|
container.appendChild(fragment);
|
||||||
|
wireRemoveButtons(container);
|
||||||
|
const scopeForm = btn.closest("form[data-scope-form]");
|
||||||
|
if (scopeForm) {
|
||||||
|
applyScopeEditState(scopeForm);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("form").forEach(function (form) {
|
||||||
|
form.querySelectorAll('input[name="policy_enabled"], input[name="allowed_services"]').forEach(function (input) {
|
||||||
|
input.addEventListener("change", function () {
|
||||||
|
refreshScopeCapsule(form);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
refreshScopeCapsule(form);
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyScopeEditState(form) {
|
||||||
|
if (!form || !form.matches("[data-scope-form]")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const editing = String(form.getAttribute("data-editing") || "0") === "1";
|
||||||
|
form.querySelectorAll(".scope-editable").forEach(function (input) {
|
||||||
|
const locked = String(input.getAttribute("data-lock-state") || "").toLowerCase() === "locked";
|
||||||
|
input.disabled = locked || !editing;
|
||||||
|
});
|
||||||
|
const toggle = form.querySelector("[data-scope-edit-toggle]");
|
||||||
|
if (toggle) {
|
||||||
|
toggle.textContent = editing ? "Cancel Change" : "Change";
|
||||||
|
}
|
||||||
|
const changeMode = form.querySelector("[data-scope-change-mode]");
|
||||||
|
if (changeMode) {
|
||||||
|
changeMode.value = editing ? "1" : "0";
|
||||||
|
}
|
||||||
|
const saveButton = form.querySelector("[data-scope-save]");
|
||||||
|
if (saveButton) {
|
||||||
|
saveButton.disabled = !editing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("form[data-scope-form]").forEach(function (form) {
|
||||||
|
form.setAttribute("data-editing", "0");
|
||||||
|
applyScopeEditState(form);
|
||||||
|
const toggle = form.querySelector("[data-scope-edit-toggle]");
|
||||||
|
if (toggle) {
|
||||||
|
toggle.addEventListener("click", function () {
|
||||||
|
const editing = String(form.getAttribute("data-editing") || "0") === "1";
|
||||||
|
form.setAttribute("data-editing", editing ? "0" : "1");
|
||||||
|
applyScopeEditState(form);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateGlobalModeUI(field) {
|
||||||
|
const hiddenInput = document.querySelector('input[data-global-mode-input="' + field + '"]');
|
||||||
|
const checkbox = document.querySelector('input[data-global-visual-checkbox="' + field + '"]');
|
||||||
|
const label = document.querySelector('[data-global-mode-label="' + field + '"]');
|
||||||
|
if (!hiddenInput || !checkbox || !label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mode = String(hiddenInput.value || "").toLowerCase();
|
||||||
|
checkbox.checked = mode === "on";
|
||||||
|
checkbox.indeterminate = mode === "per_scope";
|
||||||
|
|
||||||
|
label.classList.remove("is-success", "is-light", "is-info");
|
||||||
|
if (mode === "on") {
|
||||||
|
label.classList.add("is-success");
|
||||||
|
label.textContent = "Force On";
|
||||||
|
} else if (mode === "off") {
|
||||||
|
label.classList.add("is-light");
|
||||||
|
label.textContent = "Force Off";
|
||||||
|
} else {
|
||||||
|
label.classList.add("is-info");
|
||||||
|
label.textContent = "Per Scope";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-global-mode-set="' + field + '"]').forEach(function (btn) {
|
||||||
|
const selected = String(btn.getAttribute("data-mode") || "").toLowerCase() === mode;
|
||||||
|
btn.classList.toggle("is-link", selected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGlobalMode(field, mode) {
|
||||||
|
if (!field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hiddenInput = document.querySelector('input[data-global-mode-input="' + field + '"]');
|
||||||
|
if (!hiddenInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hiddenInput.value = mode;
|
||||||
|
updateGlobalModeUI(field);
|
||||||
|
const form = hiddenInput.closest("form");
|
||||||
|
refreshScopeCapsule(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-global-change-toggle]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
const field = String(btn.getAttribute("data-global-change-toggle") || "");
|
||||||
|
const picker = document.querySelector('[data-global-mode-picker="' + field + '"]');
|
||||||
|
if (!picker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentlyHidden = picker.classList.contains("is-hidden");
|
||||||
|
document.querySelectorAll("[data-global-mode-picker]").forEach(function (row) {
|
||||||
|
row.classList.add("is-hidden");
|
||||||
|
});
|
||||||
|
if (currentlyHidden) {
|
||||||
|
picker.classList.remove("is-hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-global-mode-set]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
const field = String(btn.getAttribute("data-global-mode-set") || "");
|
||||||
|
const mode = String(btn.getAttribute("data-mode") || "");
|
||||||
|
setGlobalMode(field, mode);
|
||||||
|
const picker = document.querySelector('[data-global-mode-picker="' + field + '"]');
|
||||||
|
if (picker) {
|
||||||
|
picker.classList.add("is-hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
["scope_enabled", "require_omemo", "require_trusted_fingerprint"].forEach(function (field) {
|
||||||
|
updateGlobalModeUI(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-policy-tab-button]").forEach(function (tab) {
|
||||||
|
tab.addEventListener("click", function () {
|
||||||
|
const key = String(tab.getAttribute("data-policy-tab-button") || "");
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.querySelectorAll("[data-policy-tab-button]").forEach(function (node) {
|
||||||
|
node.classList.remove("is-active");
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-policy-tab-panel]").forEach(function (panel) {
|
||||||
|
panel.classList.remove("is-active");
|
||||||
|
});
|
||||||
|
tab.classList.add("is-active");
|
||||||
|
const target = document.querySelector('[data-policy-tab-panel="' + key + '"]');
|
||||||
|
if (target) {
|
||||||
|
target.classList.add("is-active");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".scope-editor-card").forEach(function (details) {
|
||||||
|
details.addEventListener("toggle", function () {
|
||||||
|
if (details.open) {
|
||||||
|
const form = details.querySelector("form[data-scope-form]");
|
||||||
|
if (form) {
|
||||||
|
refreshScopeCapsule(form);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.policy-tab-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.policy-tab-panel.is-active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.scope-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.scope-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scope-allowance-capsule {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.scope-allowance-pill {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.28rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.scope-allowance-pill.is-allowed {
|
||||||
|
background: rgba(72, 199, 142, 0.18);
|
||||||
|
color: #1f684a;
|
||||||
|
border-color: rgba(72, 199, 142, 0.28);
|
||||||
|
}
|
||||||
|
.scope-allowance-pill.is-blocked {
|
||||||
|
background: rgba(241, 70, 104, 0.16);
|
||||||
|
color: #7d1f39;
|
||||||
|
border-color: rgba(241, 70, 104, 0.24);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .scope-allowance-pill.is-allowed {
|
||||||
|
background: rgba(72, 199, 142, 0.26);
|
||||||
|
color: #c7f3df;
|
||||||
|
border-color: rgba(72, 199, 142, 0.45);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .scope-allowance-pill.is-blocked {
|
||||||
|
background: rgba(241, 70, 104, 0.24);
|
||||||
|
color: #ffd0db;
|
||||||
|
border-color: rgba(241, 70, 104, 0.42);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -158,12 +158,37 @@
|
|||||||
<td>{{ row.status_snapshot }}</td>
|
<td>{{ row.status_snapshot }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a>
|
<a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a>
|
||||||
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
|
{% if enabled_providers|length == 1 %}
|
||||||
{% csrf_token %}
|
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
|
||||||
<input type="hidden" name="task_id" value="{{ row.id }}">
|
{% csrf_token %}
|
||||||
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
|
<input type="hidden" name="task_id" value="{{ row.id }}">
|
||||||
<button class="button is-small is-link is-light" type="submit">Send to Codex</button>
|
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
|
||||||
</form>
|
<input type="hidden" name="provider" value="{{ enabled_providers.0 }}">
|
||||||
|
<button class="button is-small is-link is-light" type="submit">
|
||||||
|
Send to {% if enabled_providers.0 == "claude_cli" %}Claude{% else %}Codex{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% elif enabled_providers %}
|
||||||
|
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="task_id" value="{{ row.id }}">
|
||||||
|
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
|
||||||
|
<div class="field has-addons" style="display:inline-flex;">
|
||||||
|
<div class="control">
|
||||||
|
<div class="select is-small">
|
||||||
|
<select name="provider">
|
||||||
|
{% for p in enabled_providers %}
|
||||||
|
<option value="{{ p }}">{% if p == "claude_cli" %}Claude{% else %}Codex{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-small is-link is-light" type="submit">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -413,6 +413,63 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</article>
|
</article>
|
||||||
|
<hr>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="provider_update">
|
||||||
|
<input type="hidden" name="provider" value="claude_cli">
|
||||||
|
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if claude_provider_config and claude_provider_config.enabled %}checked{% endif %}> Enable Claude CLI provider</label>
|
||||||
|
<p class="help">Claude task-sync runs in the same dedicated worker (<code>python manage.py codex_worker</code>).</p>
|
||||||
|
<p class="help">This provider config is global per-user and shared across all projects/chats.</p>
|
||||||
|
<div class="field" style="margin-top:0.5rem;">
|
||||||
|
<label class="label is-size-7">Command</label>
|
||||||
|
<input class="input is-small" name="command" value="{{ claude_provider_settings.command }}" placeholder="claude">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-size-7">Workspace Root</label>
|
||||||
|
<input class="input is-small" name="workspace_root" value="{{ claude_provider_settings.workspace_root }}" placeholder="/code/xf">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-size-7">Default Profile</label>
|
||||||
|
<input class="input is-small" name="default_profile" value="{{ claude_provider_settings.default_profile }}" placeholder="default">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-size-7">Timeout Seconds</label>
|
||||||
|
<input class="input is-small" type="number" min="1" name="timeout_seconds" value="{{ claude_provider_settings.timeout_seconds }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-size-7">Approver Service</label>
|
||||||
|
<input class="input is-small" name="approver_service" value="{{ claude_provider_settings.approver_service }}" placeholder="signal">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-size-7">Approver Identifier</label>
|
||||||
|
<input class="input is-small" name="approver_identifier" value="{{ claude_provider_settings.approver_identifier }}" placeholder="+15550000001">
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:0.5rem;">
|
||||||
|
<button class="button is-small is-link is-light" type="submit">Save Claude Provider</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<article class="box" style="margin-top:0.5rem;">
|
||||||
|
<h4 class="title is-7">Claude Compact Summary</h4>
|
||||||
|
<p class="help">
|
||||||
|
Health:
|
||||||
|
{% if claude_compact_summary.healthcheck_ok %}
|
||||||
|
<span class="tag is-success is-light">online</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tag is-danger is-light">offline</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if claude_compact_summary.healthcheck_error %}
|
||||||
|
<code>{{ claude_compact_summary.healthcheck_error }}</code>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag is-light">pending {{ claude_compact_summary.queue_counts.pending }}</span>
|
||||||
|
<span class="tag is-warning is-light">waiting_approval {{ claude_compact_summary.queue_counts.waiting_approval }}</span>
|
||||||
|
<span class="tag is-danger is-light">failed {{ claude_compact_summary.queue_counts.failed }}</span>
|
||||||
|
<span class="tag is-success is-light">ok {{ claude_compact_summary.queue_counts.ok }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Tasks Hub</a>.</p>
|
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Tasks Hub</a>.</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class AttachmentSecurityTests(SimpleTestCase):
|
|||||||
size=32,
|
size=32,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@override_settings(ATTACHMENT_ALLOW_PRIVATE_URLS=False)
|
||||||
def test_blocks_private_url_by_default(self):
|
def test_blocks_private_url_by_default(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
validate_attachment_url("http://localhost/internal")
|
validate_attachment_url("http://localhost/internal")
|
||||||
|
|||||||
224
core/tests/test_command_security_policy.py
Normal file
224
core/tests/test_command_security_policy.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from core.commands.base import CommandContext
|
||||||
|
from core.commands.engine import process_inbound_message
|
||||||
|
from core.gateway.commands import (
|
||||||
|
GatewayCommandContext,
|
||||||
|
GatewayCommandRoute,
|
||||||
|
dispatch_gateway_command,
|
||||||
|
)
|
||||||
|
from core.models import (
|
||||||
|
ChatSession,
|
||||||
|
CommandChannelBinding,
|
||||||
|
CommandProfile,
|
||||||
|
CommandSecurityPolicy,
|
||||||
|
GatewayCommandEvent,
|
||||||
|
Message,
|
||||||
|
Person,
|
||||||
|
PersonIdentifier,
|
||||||
|
User,
|
||||||
|
UserXmppOmemoState,
|
||||||
|
)
|
||||||
|
from core.security.command_policy import CommandSecurityContext, evaluate_command_policy
|
||||||
|
|
||||||
|
|
||||||
|
class CommandSecurityPolicyTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="policy-user",
|
||||||
|
email="policy-user@example.com",
|
||||||
|
password="x",
|
||||||
|
)
|
||||||
|
self.person = Person.objects.create(user=self.user, name="Policy Person")
|
||||||
|
self.identifier = PersonIdentifier.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
person=self.person,
|
||||||
|
service="xmpp",
|
||||||
|
identifier="policy-user@zm.is",
|
||||||
|
)
|
||||||
|
self.session = ChatSession.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
identifier=self.identifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_command_profile_scope_denies_disallowed_service(self):
|
||||||
|
profile = CommandProfile.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
slug="bp",
|
||||||
|
name="Business Plan",
|
||||||
|
enabled=True,
|
||||||
|
trigger_token="#bp#",
|
||||||
|
reply_required=False,
|
||||||
|
exact_match_only=True,
|
||||||
|
)
|
||||||
|
CommandChannelBinding.objects.create(
|
||||||
|
profile=profile,
|
||||||
|
direction="ingress",
|
||||||
|
service="xmpp",
|
||||||
|
channel_identifier="policy-user@zm.is",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
CommandSecurityPolicy.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
scope_key="command.bp",
|
||||||
|
enabled=True,
|
||||||
|
allowed_services=["whatsapp"],
|
||||||
|
)
|
||||||
|
msg = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
sender_uuid="",
|
||||||
|
text="#bp#",
|
||||||
|
ts=1000,
|
||||||
|
source_service="xmpp",
|
||||||
|
source_chat_id="policy-user@zm.is",
|
||||||
|
message_meta={},
|
||||||
|
)
|
||||||
|
results = async_to_sync(process_inbound_message)(
|
||||||
|
CommandContext(
|
||||||
|
service="xmpp",
|
||||||
|
channel_identifier="policy-user@zm.is",
|
||||||
|
message_id=str(msg.id),
|
||||||
|
user_id=self.user.id,
|
||||||
|
message_text="#bp#",
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(results))
|
||||||
|
self.assertEqual("skipped", results[0].status)
|
||||||
|
self.assertTrue(str(results[0].error).startswith("policy_denied:service_not_allowed"))
|
||||||
|
|
||||||
|
def test_gateway_scope_can_require_trusted_omemo_key(self):
|
||||||
|
CommandSecurityPolicy.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
scope_key="gateway.tasks",
|
||||||
|
enabled=True,
|
||||||
|
require_omemo=True,
|
||||||
|
require_trusted_omemo_fingerprint=True,
|
||||||
|
)
|
||||||
|
UserXmppOmemoState.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
status="detected",
|
||||||
|
latest_client_key="sid:abc",
|
||||||
|
last_sender_jid="policy-user@zm.is/phone",
|
||||||
|
last_target_jid="jews.zm.is",
|
||||||
|
)
|
||||||
|
outputs: list[str] = []
|
||||||
|
|
||||||
|
async def _tasks_handler(_ctx, emit):
|
||||||
|
emit("ok")
|
||||||
|
return True
|
||||||
|
|
||||||
|
handled = async_to_sync(dispatch_gateway_command)(
|
||||||
|
context=GatewayCommandContext(
|
||||||
|
user=self.user,
|
||||||
|
source_message=None,
|
||||||
|
service="xmpp",
|
||||||
|
channel_identifier="policy-user@zm.is",
|
||||||
|
sender_identifier="policy-user@zm.is/phone",
|
||||||
|
message_text=".tasks list",
|
||||||
|
message_meta={"xmpp": {"omemo_status": "detected", "omemo_client_key": "sid:abc"}},
|
||||||
|
payload={},
|
||||||
|
),
|
||||||
|
routes=[
|
||||||
|
GatewayCommandRoute(
|
||||||
|
name="tasks",
|
||||||
|
scope_key="gateway.tasks",
|
||||||
|
matcher=lambda text: str(text).startswith(".tasks"),
|
||||||
|
handler=_tasks_handler,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
emit=lambda value: outputs.append(str(value)),
|
||||||
|
)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(["ok"], outputs)
|
||||||
|
event = GatewayCommandEvent.objects.order_by("-created_at").first()
|
||||||
|
self.assertIsNotNone(event)
|
||||||
|
self.assertEqual("ok", event.status if event else "")
|
||||||
|
|
||||||
|
def test_gateway_scope_blocks_when_omemo_required_but_missing(self):
|
||||||
|
CommandSecurityPolicy.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
scope_key="gateway.tasks",
|
||||||
|
enabled=True,
|
||||||
|
require_omemo=True,
|
||||||
|
)
|
||||||
|
outputs: list[str] = []
|
||||||
|
|
||||||
|
async def _tasks_handler(_ctx, emit):
|
||||||
|
emit("unexpected")
|
||||||
|
return True
|
||||||
|
|
||||||
|
handled = async_to_sync(dispatch_gateway_command)(
|
||||||
|
context=GatewayCommandContext(
|
||||||
|
user=self.user,
|
||||||
|
source_message=None,
|
||||||
|
service="xmpp",
|
||||||
|
channel_identifier="policy-user@zm.is",
|
||||||
|
sender_identifier="policy-user@zm.is/phone",
|
||||||
|
message_text=".tasks list",
|
||||||
|
message_meta={"xmpp": {"omemo_status": "no_omemo"}},
|
||||||
|
payload={},
|
||||||
|
),
|
||||||
|
routes=[
|
||||||
|
GatewayCommandRoute(
|
||||||
|
name="tasks",
|
||||||
|
scope_key="gateway.tasks",
|
||||||
|
matcher=lambda text: str(text).startswith(".tasks"),
|
||||||
|
handler=_tasks_handler,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
emit=lambda value: outputs.append(str(value)),
|
||||||
|
)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertTrue(outputs)
|
||||||
|
self.assertIn("blocked by policy", outputs[0].lower())
|
||||||
|
event = GatewayCommandEvent.objects.order_by("-created_at").first()
|
||||||
|
self.assertIsNotNone(event)
|
||||||
|
self.assertEqual("blocked", event.status if event else "")
|
||||||
|
|
||||||
|
def test_global_scope_override_can_force_scope_disabled(self):
|
||||||
|
CommandSecurityPolicy.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
scope_key="gateway.tasks",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
CommandSecurityPolicy.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
scope_key="global.override",
|
||||||
|
settings={"scope_enabled": "off"},
|
||||||
|
)
|
||||||
|
decision = evaluate_command_policy(
|
||||||
|
user=self.user,
|
||||||
|
scope_key="gateway.tasks",
|
||||||
|
context=CommandSecurityContext(
|
||||||
|
service="xmpp",
|
||||||
|
channel_identifier="policy-user@zm.is",
|
||||||
|
message_meta={},
|
||||||
|
payload={},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(decision.allowed)
|
||||||
|
self.assertEqual("policy_disabled", decision.code)
|
||||||
|
|
||||||
|
def test_global_scope_override_allowed_services_applies_to_all_scopes(self):
|
||||||
|
CommandSecurityPolicy.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
scope_key="global.override",
|
||||||
|
allowed_services=["xmpp"],
|
||||||
|
)
|
||||||
|
decision = evaluate_command_policy(
|
||||||
|
user=self.user,
|
||||||
|
scope_key="tasks.commands",
|
||||||
|
context=CommandSecurityContext(
|
||||||
|
service="whatsapp",
|
||||||
|
channel_identifier="12035550123",
|
||||||
|
message_meta={},
|
||||||
|
payload={},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(decision.allowed)
|
||||||
|
self.assertEqual("service_not_allowed", decision.code)
|
||||||
382
core/tests/test_cross_platform_messaging.py
Normal file
382
core/tests/test_cross_platform_messaging.py
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
"""
|
||||||
|
Cross-platform messaging tests: replies, reactions, and messages across
|
||||||
|
Signal, WhatsApp, and XMPP.
|
||||||
|
|
||||||
|
Signal coverage is in test_signal_reply_send.py. This file fills the gaps
|
||||||
|
for WhatsApp and XMPP, and verifies the shared reply_sync infrastructure
|
||||||
|
works correctly for both services.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from django.test import SimpleTestCase, TestCase
|
||||||
|
|
||||||
|
from core.clients import transport
|
||||||
|
from core.clients.xmpp import (
|
||||||
|
_extract_xmpp_reaction,
|
||||||
|
_extract_xmpp_reply_target_id,
|
||||||
|
_parse_greentext_reaction,
|
||||||
|
)
|
||||||
|
from core.messaging import history, reply_sync
|
||||||
|
from core.models import ChatSession, Message, Person, PersonIdentifier, User
|
||||||
|
from core.presence.inference import now_ms
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fake_stanza(xml_text: str) -> SimpleNamespace:
|
||||||
|
"""Minimal stanza-like object with an .xml attribute."""
|
||||||
|
return SimpleNamespace(xml=ET.fromstring(xml_text))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WhatsApp — reply extraction (pure, no DB)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class WhatsAppReplyExtractionTests(SimpleTestCase):
|
||||||
|
def test_extract_reply_ref_from_contextinfo_stanza_id(self):
|
||||||
|
payload = {
|
||||||
|
"contextInfo": {
|
||||||
|
"stanzaId": "wa-anchor-001",
|
||||||
|
"participant": "447700900001@s.whatsapp.net",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ref = reply_sync.extract_reply_ref("whatsapp", payload)
|
||||||
|
self.assertEqual("wa-anchor-001", ref.get("reply_source_message_id"))
|
||||||
|
self.assertEqual("whatsapp", ref.get("reply_source_service"))
|
||||||
|
self.assertEqual("447700900001@s.whatsapp.net", ref.get("reply_source_chat_id"))
|
||||||
|
|
||||||
|
def test_extract_reply_ref_from_extended_text_message(self):
|
||||||
|
payload = {
|
||||||
|
"extendedTextMessage": {
|
||||||
|
"text": "quoting you",
|
||||||
|
"contextInfo": {
|
||||||
|
"stanzaId": "wa-anchor-002",
|
||||||
|
"participant": "447700900002@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ref = reply_sync.extract_reply_ref("whatsapp", payload)
|
||||||
|
self.assertEqual("wa-anchor-002", ref.get("reply_source_message_id"))
|
||||||
|
|
||||||
|
def test_extract_reply_ref_returns_empty_when_no_context(self):
|
||||||
|
ref = reply_sync.extract_reply_ref("whatsapp", {"conversation": "plain text"})
|
||||||
|
self.assertEqual({}, ref)
|
||||||
|
|
||||||
|
def test_extract_reply_ref_from_image_message_contextinfo(self):
|
||||||
|
payload = {
|
||||||
|
"imageMessage": {
|
||||||
|
"caption": "look at this",
|
||||||
|
"contextInfo": {
|
||||||
|
"stanzaId": "wa-anchor-003",
|
||||||
|
"participant": "447700900003@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ref = reply_sync.extract_reply_ref("whatsapp", payload)
|
||||||
|
self.assertEqual("wa-anchor-003", ref.get("reply_source_message_id"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WhatsApp — reply resolution (requires DB)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class WhatsAppReplyResolutionTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
"wa-resolve-user", "wa-resolve@example.com", "x"
|
||||||
|
)
|
||||||
|
self.person = Person.objects.create(user=self.user, name="WA Resolve")
|
||||||
|
self.identifier = PersonIdentifier.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
person=self.person,
|
||||||
|
service="whatsapp",
|
||||||
|
identifier="447700900001@s.whatsapp.net",
|
||||||
|
)
|
||||||
|
self.session = ChatSession.objects.create(
|
||||||
|
user=self.user, identifier=self.identifier
|
||||||
|
)
|
||||||
|
self.anchor = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
ts=now_ms(),
|
||||||
|
text="anchor message",
|
||||||
|
source_service="whatsapp",
|
||||||
|
source_message_id="wa-anchor-001",
|
||||||
|
source_chat_id="447700900001@s.whatsapp.net",
|
||||||
|
sender_uuid="447700900001@s.whatsapp.net",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolve_reply_target_by_source_message_id(self):
|
||||||
|
ref = {
|
||||||
|
"reply_source_message_id": "wa-anchor-001",
|
||||||
|
"reply_source_service": "whatsapp",
|
||||||
|
"reply_source_chat_id": "447700900001@s.whatsapp.net",
|
||||||
|
}
|
||||||
|
target = async_to_sync(reply_sync.resolve_reply_target)(
|
||||||
|
self.user, self.session, ref
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(target)
|
||||||
|
self.assertEqual(self.anchor.id, target.id)
|
||||||
|
|
||||||
|
def test_resolve_returns_none_for_unknown_id(self):
|
||||||
|
ref = {
|
||||||
|
"reply_source_message_id": "wa-nonexistent-999",
|
||||||
|
"reply_source_service": "whatsapp",
|
||||||
|
"reply_source_chat_id": "447700900001@s.whatsapp.net",
|
||||||
|
}
|
||||||
|
target = async_to_sync(reply_sync.resolve_reply_target)(
|
||||||
|
self.user, self.session, ref
|
||||||
|
)
|
||||||
|
self.assertIsNone(target)
|
||||||
|
|
||||||
|
def test_reaction_applied_to_whatsapp_anchor(self):
|
||||||
|
async_to_sync(history.apply_reaction)(
|
||||||
|
self.user,
|
||||||
|
self.identifier,
|
||||||
|
target_message_id="wa-anchor-001",
|
||||||
|
target_ts=int(self.anchor.ts),
|
||||||
|
emoji="👍",
|
||||||
|
source_service="whatsapp",
|
||||||
|
actor="447700900001@s.whatsapp.net",
|
||||||
|
remove=False,
|
||||||
|
payload={"event": "reaction"},
|
||||||
|
)
|
||||||
|
self.anchor.refresh_from_db()
|
||||||
|
reactions = list((self.anchor.receipt_payload or {}).get("reactions") or [])
|
||||||
|
self.assertEqual(1, len(reactions))
|
||||||
|
self.assertEqual("👍", reactions[0].get("emoji"))
|
||||||
|
|
||||||
|
def test_reaction_removal_clears_flag(self):
|
||||||
|
async_to_sync(history.apply_reaction)(
|
||||||
|
self.user,
|
||||||
|
self.identifier,
|
||||||
|
target_message_id="wa-anchor-001",
|
||||||
|
target_ts=int(self.anchor.ts),
|
||||||
|
emoji="👍",
|
||||||
|
source_service="whatsapp",
|
||||||
|
actor="447700900001@s.whatsapp.net",
|
||||||
|
remove=False,
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
async_to_sync(history.apply_reaction)(
|
||||||
|
self.user,
|
||||||
|
self.identifier,
|
||||||
|
target_message_id="wa-anchor-001",
|
||||||
|
target_ts=int(self.anchor.ts),
|
||||||
|
emoji="👍",
|
||||||
|
source_service="whatsapp",
|
||||||
|
actor="447700900001@s.whatsapp.net",
|
||||||
|
remove=True,
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
self.anchor.refresh_from_db()
|
||||||
|
reactions = list((self.anchor.receipt_payload or {}).get("reactions") or [])
|
||||||
|
removed = [r for r in reactions if r.get("emoji") == "👍" and not r.get("removed")]
|
||||||
|
self.assertEqual(0, len(removed))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WhatsApp — outbound reply metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class WhatsAppOutboundReplyTests(TestCase):
|
||||||
|
def test_transport_passes_reply_metadata_to_whatsapp_api(self):
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.send_message_raw = AsyncMock(return_value="wa-sent-001")
|
||||||
|
with patch(
|
||||||
|
"core.clients.transport.get_runtime_client",
|
||||||
|
return_value=mock_client,
|
||||||
|
), patch(
|
||||||
|
"core.clients.transport.prepare_outbound_attachments",
|
||||||
|
new=AsyncMock(return_value=[]),
|
||||||
|
), patch(
|
||||||
|
"core.clients.transport._capability_checks_enabled",
|
||||||
|
return_value=False,
|
||||||
|
):
|
||||||
|
result = async_to_sync(transport.send_message_raw)(
|
||||||
|
"whatsapp",
|
||||||
|
"447700900001@s.whatsapp.net",
|
||||||
|
text="reply text",
|
||||||
|
attachments=[],
|
||||||
|
metadata={
|
||||||
|
"quote_id": "wa-anchor-001",
|
||||||
|
"quote_author": "447700900001@s.whatsapp.net",
|
||||||
|
"quote_text": "anchor message",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("wa-sent-001", result)
|
||||||
|
mock_client.send_message_raw.assert_awaited_once()
|
||||||
|
_, call_kwargs = mock_client.send_message_raw.call_args
|
||||||
|
meta = call_kwargs.get("metadata") or {}
|
||||||
|
self.assertEqual("wa-anchor-001", meta.get("quote_id"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# XMPP — reaction extraction (pure, no DB)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class XMPPReactionExtractionTests(SimpleTestCase):
|
||||||
|
def test_extract_xep_0444_reaction(self):
|
||||||
|
stanza = _fake_stanza(
|
||||||
|
"<message>"
|
||||||
|
"<reactions xmlns='urn:xmpp:reactions:0' id='xmpp-anchor-001'>"
|
||||||
|
"<reaction>👍</reaction>"
|
||||||
|
"</reactions>"
|
||||||
|
"</message>"
|
||||||
|
)
|
||||||
|
result = _extract_xmpp_reaction(stanza)
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual("xmpp-anchor-001", result.get("target_id"))
|
||||||
|
self.assertEqual("👍", result.get("emoji"))
|
||||||
|
self.assertFalse(result.get("remove"))
|
||||||
|
|
||||||
|
def test_extract_xep_0444_reaction_removal(self):
|
||||||
|
stanza = _fake_stanza(
|
||||||
|
"<message>"
|
||||||
|
"<reactions xmlns='urn:xmpp:reactions:0' id='xmpp-anchor-002'>"
|
||||||
|
"</reactions>"
|
||||||
|
"</message>"
|
||||||
|
)
|
||||||
|
result = _extract_xmpp_reaction(stanza)
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual("xmpp-anchor-002", result.get("target_id"))
|
||||||
|
self.assertTrue(result.get("remove"))
|
||||||
|
|
||||||
|
def test_extract_returns_none_for_plain_message(self):
|
||||||
|
stanza = _fake_stanza("<message><body>hello</body></message>")
|
||||||
|
self.assertIsNone(_extract_xmpp_reaction(stanza))
|
||||||
|
|
||||||
|
def test_parse_greentext_reaction_valid(self):
|
||||||
|
result = _parse_greentext_reaction(">anchor message\n😊")
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual("anchor message", result.get("quoted_text"))
|
||||||
|
self.assertEqual("😊", result.get("emoji"))
|
||||||
|
|
||||||
|
def test_parse_greentext_reaction_rejects_non_emoji_second_line(self):
|
||||||
|
result = _parse_greentext_reaction(">anchor message\nnot an emoji")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_parse_greentext_reaction_rejects_single_line(self):
|
||||||
|
result = _parse_greentext_reaction(">anchor message")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_parse_greentext_reaction_rejects_no_quote_prefix(self):
|
||||||
|
result = _parse_greentext_reaction("anchor message\n😊")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# XMPP — reply extraction (pure, no DB)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class XMPPReplyExtractionTests(SimpleTestCase):
|
||||||
|
def test_extract_reply_target_id_from_xep_0461_stanza(self):
|
||||||
|
stanza = _fake_stanza(
|
||||||
|
"<message>"
|
||||||
|
"<reply xmlns='urn:xmpp:reply:0' id='xmpp-anchor-001'/>"
|
||||||
|
"<body>quoted reply</body>"
|
||||||
|
"</message>"
|
||||||
|
)
|
||||||
|
target_id = _extract_xmpp_reply_target_id(stanza)
|
||||||
|
self.assertEqual("xmpp-anchor-001", target_id)
|
||||||
|
|
||||||
|
def test_extract_reply_target_id_returns_empty_for_plain(self):
|
||||||
|
stanza = _fake_stanza("<message><body>hello</body></message>")
|
||||||
|
self.assertEqual("", _extract_xmpp_reply_target_id(stanza))
|
||||||
|
|
||||||
|
def test_extract_reply_ref_for_xmpp_service(self):
|
||||||
|
ref = reply_sync.extract_reply_ref(
|
||||||
|
"xmpp",
|
||||||
|
{
|
||||||
|
"reply_source_message_id": "xmpp-anchor-001",
|
||||||
|
"reply_source_chat_id": "user@zm.is/mobile",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("xmpp-anchor-001", ref.get("reply_source_message_id"))
|
||||||
|
self.assertEqual("xmpp", ref.get("reply_source_service"))
|
||||||
|
self.assertEqual("user@zm.is/mobile", ref.get("reply_source_chat_id"))
|
||||||
|
|
||||||
|
def test_extract_reply_ref_returns_empty_for_missing_id(self):
|
||||||
|
ref = reply_sync.extract_reply_ref("xmpp", {"reply_source_chat_id": "user@zm.is"})
|
||||||
|
self.assertEqual({}, ref)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# XMPP — reply resolution (requires DB)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class XMPPReplyResolutionTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
"xmpp-resolve-user", "xmpp-resolve@example.com", "x"
|
||||||
|
)
|
||||||
|
self.person = Person.objects.create(user=self.user, name="XMPP Resolve")
|
||||||
|
self.identifier = PersonIdentifier.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
person=self.person,
|
||||||
|
service="xmpp",
|
||||||
|
identifier="contact@zm.is",
|
||||||
|
)
|
||||||
|
self.session = ChatSession.objects.create(
|
||||||
|
user=self.user, identifier=self.identifier
|
||||||
|
)
|
||||||
|
self.anchor = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
ts=now_ms(),
|
||||||
|
text="xmpp anchor",
|
||||||
|
source_service="xmpp",
|
||||||
|
source_message_id="xmpp-anchor-001",
|
||||||
|
source_chat_id="contact@zm.is/mobile",
|
||||||
|
sender_uuid="contact@zm.is",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolve_reply_target_by_source_message_id(self):
|
||||||
|
ref = reply_sync.extract_reply_ref(
|
||||||
|
"xmpp",
|
||||||
|
{
|
||||||
|
"reply_source_message_id": "xmpp-anchor-001",
|
||||||
|
"reply_source_chat_id": "contact@zm.is/mobile",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
target = async_to_sync(reply_sync.resolve_reply_target)(
|
||||||
|
self.user, self.session, ref
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(target)
|
||||||
|
self.assertEqual(self.anchor.id, target.id)
|
||||||
|
|
||||||
|
def test_xmpp_reaction_applied_to_anchor_via_history(self):
|
||||||
|
async_to_sync(history.apply_reaction)(
|
||||||
|
self.user,
|
||||||
|
self.identifier,
|
||||||
|
target_message_id="xmpp-anchor-001",
|
||||||
|
target_ts=int(self.anchor.ts),
|
||||||
|
emoji="🔥",
|
||||||
|
source_service="xmpp",
|
||||||
|
actor="contact@zm.is",
|
||||||
|
remove=False,
|
||||||
|
payload={"target_xmpp_id": "xmpp-anchor-001"},
|
||||||
|
)
|
||||||
|
self.anchor.refresh_from_db()
|
||||||
|
reactions = list((self.anchor.receipt_payload or {}).get("reactions") or [])
|
||||||
|
self.assertTrue(
|
||||||
|
any(r.get("emoji") == "🔥" for r in reactions),
|
||||||
|
"Expected 🔥 reaction to be stored on the anchor.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_xmpp_reply_ref_resolved_to_none_for_unknown_id(self):
|
||||||
|
ref = reply_sync.extract_reply_ref(
|
||||||
|
"xmpp",
|
||||||
|
{"reply_source_message_id": "xmpp-nonexistent-999"},
|
||||||
|
)
|
||||||
|
target = async_to_sync(reply_sync.resolve_reply_target)(
|
||||||
|
self.user, self.session, ref
|
||||||
|
)
|
||||||
|
self.assertIsNone(target)
|
||||||
9
core/tests/test_task_sync_worker_command.py
Normal file
9
core/tests/test_task_sync_worker_command.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from core.management.commands.codex_worker import Command as LegacyWorkerCommand
|
||||||
|
from core.management.commands.task_sync_worker import Command as TaskSyncWorkerCommand
|
||||||
|
|
||||||
|
|
||||||
|
class TaskSyncWorkerCommandAliasTests(SimpleTestCase):
|
||||||
|
def test_task_sync_worker_is_legacy_worker_alias(self):
|
||||||
|
self.assertTrue(issubclass(TaskSyncWorkerCommand, LegacyWorkerCommand))
|
||||||
175
core/tests/test_xmpp_approval_commands.py
Normal file
175
core/tests/test_xmpp_approval_commands.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from core.clients.xmpp import XMPPComponent
|
||||||
|
from core.models import (
|
||||||
|
CodexPermissionRequest,
|
||||||
|
CodexRun,
|
||||||
|
DerivedTask,
|
||||||
|
ExternalSyncEvent,
|
||||||
|
TaskProject,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ApprovalProbe:
|
||||||
|
_resolve_request_provider = XMPPComponent._resolve_request_provider
|
||||||
|
_approval_event_prefix = XMPPComponent._approval_event_prefix
|
||||||
|
_APPROVAL_PROVIDER_COMMANDS = XMPPComponent._APPROVAL_PROVIDER_COMMANDS
|
||||||
|
_ACTION_TO_STATUS = XMPPComponent._ACTION_TO_STATUS
|
||||||
|
_apply_approval_decision = XMPPComponent._apply_approval_decision
|
||||||
|
_approval_list_pending = XMPPComponent._approval_list_pending
|
||||||
|
_approval_status = XMPPComponent._approval_status
|
||||||
|
_handle_approval_command = XMPPComponent._handle_approval_command
|
||||||
|
_gateway_help_lines = XMPPComponent._gateway_help_lines
|
||||||
|
_handle_tasks_command = XMPPComponent._handle_tasks_command
|
||||||
|
|
||||||
|
|
||||||
|
class XMPPGatewayApprovalCommandTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user("xmpp-approval-user", "xmpp-approval@example.com", "x")
|
||||||
|
self.project = TaskProject.objects.create(user=self.user, name="Approval Project")
|
||||||
|
self.task = DerivedTask.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
project=self.project,
|
||||||
|
epic=None,
|
||||||
|
title="Approve me",
|
||||||
|
source_service="xmpp",
|
||||||
|
source_channel="jews.zm.is",
|
||||||
|
reference_code="77",
|
||||||
|
status_snapshot="open",
|
||||||
|
)
|
||||||
|
self.waiting_event = ExternalSyncEvent.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
task=self.task,
|
||||||
|
provider="codex_cli",
|
||||||
|
status="waiting_approval",
|
||||||
|
payload={},
|
||||||
|
error="",
|
||||||
|
)
|
||||||
|
self.run = CodexRun.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
task=self.task,
|
||||||
|
project=self.project,
|
||||||
|
source_service="xmpp",
|
||||||
|
source_channel="jews.zm.is",
|
||||||
|
status="waiting_approval",
|
||||||
|
request_payload={"action": "append_update", "provider_payload": {"task_id": str(self.task.id)}},
|
||||||
|
result_payload={},
|
||||||
|
)
|
||||||
|
self.request = CodexPermissionRequest.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
codex_run=self.run,
|
||||||
|
external_sync_event=self.waiting_event,
|
||||||
|
approval_key="ak-xmpp-1",
|
||||||
|
summary="Need auth approval",
|
||||||
|
requested_permissions={"items": ["workspace_write"]},
|
||||||
|
resume_payload={},
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
self.probe = _ApprovalProbe()
|
||||||
|
self.probe.log = MagicMock()
|
||||||
|
|
||||||
|
def _run_command(self, text: str) -> list[str]:
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
def _sym(value):
|
||||||
|
messages.append(str(value))
|
||||||
|
|
||||||
|
handled = async_to_sync(XMPPComponent._handle_approval_command)(
|
||||||
|
self.probe,
|
||||||
|
self.user,
|
||||||
|
text,
|
||||||
|
"xmpp-approval-user@zm.is/mobile",
|
||||||
|
_sym,
|
||||||
|
)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertTrue(messages)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def test_approval_approve_command_resolves_request_and_queues_resume(self):
|
||||||
|
rows = self._run_command(".approval approve ak-xmpp-1")
|
||||||
|
self.assertIn("approved", "\n".join(rows).lower())
|
||||||
|
self.request.refresh_from_db()
|
||||||
|
self.run.refresh_from_db()
|
||||||
|
self.waiting_event.refresh_from_db()
|
||||||
|
self.assertEqual("approved", self.request.status)
|
||||||
|
self.assertEqual("approved_waiting_resume", self.run.status)
|
||||||
|
self.assertEqual("ok", self.waiting_event.status)
|
||||||
|
resume = ExternalSyncEvent.objects.filter(
|
||||||
|
idempotency_key="codex_approval:ak-xmpp-1:approved"
|
||||||
|
).first()
|
||||||
|
self.assertIsNotNone(resume)
|
||||||
|
self.assertEqual("pending", resume.status)
|
||||||
|
|
||||||
|
def test_approval_list_pending_and_status(self):
|
||||||
|
rows = self._run_command(".approval list-pending all")
|
||||||
|
text = "\n".join(rows)
|
||||||
|
self.assertIn("pending=1", text)
|
||||||
|
self.assertIn("ak-xmpp-1", text)
|
||||||
|
status_rows = self._run_command(".approval status ak-xmpp-1")
|
||||||
|
self.assertIn("status=pending", "\n".join(status_rows))
|
||||||
|
|
||||||
|
def test_provider_specific_command_rejects_mismatched_key(self):
|
||||||
|
rows = self._run_command(".claude approve ak-xmpp-1")
|
||||||
|
self.assertIn("approval_key_not_for_provider", "\n".join(rows))
|
||||||
|
self.request.refresh_from_db()
|
||||||
|
self.assertEqual("pending", self.request.status)
|
||||||
|
|
||||||
|
|
||||||
|
class XMPPGatewayTasksCommandTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user("xmpp-task-user", "xmpp-task@example.com", "x")
|
||||||
|
self.project = TaskProject.objects.create(user=self.user, name="Task Project")
|
||||||
|
self.task = DerivedTask.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
project=self.project,
|
||||||
|
epic=None,
|
||||||
|
title="Ship CLI",
|
||||||
|
source_service="xmpp",
|
||||||
|
source_channel="jews.zm.is",
|
||||||
|
reference_code="12",
|
||||||
|
status_snapshot="open",
|
||||||
|
)
|
||||||
|
self.probe = _ApprovalProbe()
|
||||||
|
self.probe.log = MagicMock()
|
||||||
|
|
||||||
|
def _run_tasks(self, text: str) -> list[str]:
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
def _sym(value):
|
||||||
|
messages.append(str(value))
|
||||||
|
|
||||||
|
handled = async_to_sync(XMPPComponent._handle_tasks_command)(
|
||||||
|
self.probe,
|
||||||
|
self.user,
|
||||||
|
text,
|
||||||
|
_sym,
|
||||||
|
)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertTrue(messages)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def test_help_contains_approval_and_tasks_sections(self):
|
||||||
|
lines = self.probe._gateway_help_lines()
|
||||||
|
text = "\n".join(lines)
|
||||||
|
self.assertIn(".approval list-pending", text)
|
||||||
|
self.assertIn(".tasks list", text)
|
||||||
|
|
||||||
|
def test_tasks_list_show_complete_and_undo(self):
|
||||||
|
rows = self._run_tasks(".tasks list open 10")
|
||||||
|
self.assertIn("#12", "\n".join(rows))
|
||||||
|
rows = self._run_tasks(".tasks show #12")
|
||||||
|
self.assertIn("status: open", "\n".join(rows))
|
||||||
|
rows = self._run_tasks(".tasks complete #12")
|
||||||
|
self.assertIn("completed #12", "\n".join(rows))
|
||||||
|
self.task.refresh_from_db()
|
||||||
|
self.assertEqual("completed", self.task.status_snapshot)
|
||||||
|
rows = self._run_tasks(".tasks undo #12")
|
||||||
|
self.assertIn("removed #12", "\n".join(rows))
|
||||||
|
self.assertFalse(DerivedTask.objects.filter(id=self.task.id).exists())
|
||||||
126
core/tests/test_xmpp_omemo_support.py
Normal file
126
core/tests/test_xmpp_omemo_support.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from django.test import SimpleTestCase, TestCase, override_settings
|
||||||
|
|
||||||
|
from core.clients import transport
|
||||||
|
from core.clients.xmpp import ET, XMPPClient, XMPPComponent, _extract_sender_omemo_client_key
|
||||||
|
from core.models import User, UserXmppOmemoState
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeComponent:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.plugins = []
|
||||||
|
self.loop = None
|
||||||
|
|
||||||
|
def register_plugin(self, name):
|
||||||
|
self.plugins.append(str(name))
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
XMPP_JID="jews.zm.is",
|
||||||
|
XMPP_SECRET="secret",
|
||||||
|
XMPP_ADDRESS="127.0.0.1",
|
||||||
|
XMPP_PORT=8888,
|
||||||
|
)
|
||||||
|
class XMPPOmemoSupportTests(SimpleTestCase):
|
||||||
|
def test_registers_xep_0384_when_omemo_plugin_available(self):
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
with patch("core.clients.xmpp.XMPPComponent", _FakeComponent):
|
||||||
|
with patch("core.clients.xmpp._omemo_plugin_available", return_value=True):
|
||||||
|
with patch("core.clients.xmpp._omemo_xep_0384_plugin_available", return_value=True):
|
||||||
|
with patch("core.clients.xmpp._load_omemo_plugin_module", return_value=True):
|
||||||
|
client = XMPPClient(SimpleNamespace(), loop, "xmpp")
|
||||||
|
self.assertIn("xep_0384", list(getattr(client.client, "plugins", [])))
|
||||||
|
self.assertTrue(bool(getattr(client, "_omemo_plugin_registered", False)))
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
def test_skips_xep_0384_when_omemo_plugin_unavailable(self):
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
with patch("core.clients.xmpp.XMPPComponent", _FakeComponent):
|
||||||
|
with patch("core.clients.xmpp._omemo_plugin_available", return_value=False):
|
||||||
|
with patch("core.clients.xmpp._omemo_xep_0384_plugin_available", return_value=False):
|
||||||
|
client = XMPPClient(SimpleNamespace(), loop, "xmpp")
|
||||||
|
self.assertNotIn("xep_0384", list(getattr(client.client, "plugins", [])))
|
||||||
|
self.assertFalse(bool(getattr(client, "_omemo_plugin_registered", False)))
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
def test_skips_xep_0384_when_only_slixmpp_omemo_package_exists(self):
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
with patch("core.clients.xmpp.XMPPComponent", _FakeComponent):
|
||||||
|
with patch("core.clients.xmpp._omemo_plugin_available", return_value=True):
|
||||||
|
with patch("core.clients.xmpp._omemo_xep_0384_plugin_available", return_value=False):
|
||||||
|
client = XMPPClient(SimpleNamespace(), loop, "xmpp")
|
||||||
|
self.assertNotIn("xep_0384", list(getattr(client.client, "plugins", [])))
|
||||||
|
self.assertFalse(bool(getattr(client, "_omemo_plugin_registered", False)))
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
def test_bootstrap_logs_and_updates_runtime_state_with_fingerprint(self):
|
||||||
|
class _BootstrapProbe:
|
||||||
|
_derived_omemo_fingerprint = XMPPComponent._derived_omemo_fingerprint
|
||||||
|
|
||||||
|
component = _BootstrapProbe()
|
||||||
|
component.plugin = {}
|
||||||
|
component.log = MagicMock()
|
||||||
|
|
||||||
|
with patch.object(transport, "update_runtime_state") as update_state:
|
||||||
|
async_to_sync(XMPPComponent._bootstrap_omemo_for_authentic_channel)(component)
|
||||||
|
|
||||||
|
update_state.assert_called_once()
|
||||||
|
_, kwargs = update_state.call_args
|
||||||
|
self.assertEqual("jews.zm.is", kwargs.get("omemo_target_jid"))
|
||||||
|
self.assertEqual(
|
||||||
|
component._derived_omemo_fingerprint("jews.zm.is"),
|
||||||
|
kwargs.get("omemo_fingerprint"),
|
||||||
|
)
|
||||||
|
self.assertFalse(bool(kwargs.get("omemo_enabled")))
|
||||||
|
self.assertIn("omemo_status", kwargs)
|
||||||
|
self.assertIn("omemo_status_reason", kwargs)
|
||||||
|
self.assertTrue(component.log.info.called)
|
||||||
|
|
||||||
|
def test_extract_sender_omemo_client_key_from_encrypted_stanza(self):
|
||||||
|
stanza_xml = ET.fromstring(
|
||||||
|
"<message>"
|
||||||
|
"<encrypted xmlns='eu.siacs.conversations.axolotl'>"
|
||||||
|
"<header sid='77'><key rid='88'>x</key></header>"
|
||||||
|
"</encrypted>"
|
||||||
|
"</message>"
|
||||||
|
)
|
||||||
|
parsed = _extract_sender_omemo_client_key(SimpleNamespace(xml=stanza_xml))
|
||||||
|
self.assertEqual("detected", parsed.get("status"))
|
||||||
|
self.assertEqual("sid:77,rid:88", parsed.get("client_key"))
|
||||||
|
|
||||||
|
|
||||||
|
class XMPPOmemoObservationPersistenceTests(TestCase):
|
||||||
|
def test_records_latest_user_omemo_observation(self):
|
||||||
|
user = User.objects.create_user("xmpp-omemo-user", "xmpp-omemo@example.com", "x")
|
||||||
|
probe = SimpleNamespace(log=MagicMock())
|
||||||
|
stanza_xml = ET.fromstring(
|
||||||
|
"<message>"
|
||||||
|
"<encrypted xmlns='eu.siacs.conversations.axolotl'>"
|
||||||
|
"<header sid='321'><key rid='654'>x</key></header>"
|
||||||
|
"</encrypted>"
|
||||||
|
"</message>"
|
||||||
|
)
|
||||||
|
async_to_sync(XMPPComponent._record_sender_omemo_state)(
|
||||||
|
probe,
|
||||||
|
user,
|
||||||
|
sender_jid="xmpp-omemo-user@zm.is/mobile",
|
||||||
|
recipient_jid="jews.zm.is",
|
||||||
|
message_stanza=SimpleNamespace(xml=stanza_xml),
|
||||||
|
)
|
||||||
|
row = UserXmppOmemoState.objects.get(user=user)
|
||||||
|
self.assertEqual("detected", row.status)
|
||||||
|
self.assertEqual("sid:321,rid:654", row.latest_client_key)
|
||||||
|
self.assertEqual("jews.zm.is", row.last_target_jid)
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import time
|
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.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
|
||||||
|
from core.clients import transport
|
||||||
from core.models import (
|
from core.models import (
|
||||||
AdapterHealthEvent,
|
AdapterHealthEvent,
|
||||||
AIRequest,
|
AIRequest,
|
||||||
@@ -28,6 +30,9 @@ from core.models import (
|
|||||||
Persona,
|
Persona,
|
||||||
PersonIdentifier,
|
PersonIdentifier,
|
||||||
QueuedMessage,
|
QueuedMessage,
|
||||||
|
CommandSecurityPolicy,
|
||||||
|
UserXmppOmemoState,
|
||||||
|
UserXmppSecuritySettings,
|
||||||
WorkspaceConversation,
|
WorkspaceConversation,
|
||||||
WorkspaceMetricSnapshot,
|
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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -338,6 +338,23 @@ def _codex_settings_with_defaults(raw: dict | None) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_settings_with_defaults(raw: dict | None) -> dict:
|
||||||
|
row = dict(raw or {})
|
||||||
|
timeout_raw = str(row.get("timeout_seconds") or "60").strip()
|
||||||
|
try:
|
||||||
|
timeout_seconds = max(1, int(timeout_raw))
|
||||||
|
except Exception:
|
||||||
|
timeout_seconds = 60
|
||||||
|
return {
|
||||||
|
"command": str(row.get("command") or "claude").strip() or "claude",
|
||||||
|
"workspace_root": str(row.get("workspace_root") or "").strip(),
|
||||||
|
"default_profile": str(row.get("default_profile") or "").strip(),
|
||||||
|
"timeout_seconds": timeout_seconds,
|
||||||
|
"approver_service": str(row.get("approver_service") or "").strip().lower(),
|
||||||
|
"approver_identifier": str(row.get("approver_identifier") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _enqueue_codex_task_submission(
|
def _enqueue_codex_task_submission(
|
||||||
*,
|
*,
|
||||||
user,
|
user,
|
||||||
@@ -347,10 +364,12 @@ def _enqueue_codex_task_submission(
|
|||||||
mode: str = "default",
|
mode: str = "default",
|
||||||
command_text: str = "",
|
command_text: str = "",
|
||||||
source_message=None,
|
source_message=None,
|
||||||
|
provider: str = "codex_cli",
|
||||||
) -> CodexRun:
|
) -> CodexRun:
|
||||||
|
provider = str(provider or "codex_cli").strip() or "codex_cli"
|
||||||
external_chat_id = resolve_external_chat_id(
|
external_chat_id = resolve_external_chat_id(
|
||||||
user=user,
|
user=user,
|
||||||
provider="codex_cli",
|
provider=provider,
|
||||||
service=source_service,
|
service=source_service,
|
||||||
channel=source_channel,
|
channel=source_channel,
|
||||||
)
|
)
|
||||||
@@ -398,6 +417,7 @@ def _enqueue_codex_task_submission(
|
|||||||
action="append_update",
|
action="append_update",
|
||||||
provider_payload=dict(provider_payload),
|
provider_payload=dict(provider_payload),
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
|
provider=provider,
|
||||||
)
|
)
|
||||||
return run
|
return run
|
||||||
|
|
||||||
@@ -703,6 +723,12 @@ class TasksHub(LoginRequiredMixin, View):
|
|||||||
"mapped": mapped,
|
"mapped": mapped,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
enabled_providers = list(
|
||||||
|
TaskProviderConfig.objects.filter(user=request.user, enabled=True)
|
||||||
|
.exclude(provider="mock")
|
||||||
|
.values_list("provider", flat=True)
|
||||||
|
.order_by("provider")
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"projects": projects,
|
"projects": projects,
|
||||||
"project_choices": all_projects,
|
"project_choices": all_projects,
|
||||||
@@ -711,6 +737,7 @@ class TasksHub(LoginRequiredMixin, View):
|
|||||||
"person_identifier_rows": person_identifier_rows,
|
"person_identifier_rows": person_identifier_rows,
|
||||||
"selected_project": selected_project,
|
"selected_project": selected_project,
|
||||||
"show_empty_projects": show_empty,
|
"show_empty_projects": show_empty,
|
||||||
|
"enabled_providers": enabled_providers,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
@@ -1152,9 +1179,13 @@ class TaskSettings(LoginRequiredMixin, View):
|
|||||||
provider_map = _provider_row_map(request.user)
|
provider_map = _provider_row_map(request.user)
|
||||||
codex_cfg = provider_map.get("codex_cli")
|
codex_cfg = provider_map.get("codex_cli")
|
||||||
codex_settings = _codex_settings_with_defaults(dict(getattr(codex_cfg, "settings", {}) or {}))
|
codex_settings = _codex_settings_with_defaults(dict(getattr(codex_cfg, "settings", {}) or {}))
|
||||||
|
claude_cfg = provider_map.get("claude_cli")
|
||||||
|
claude_settings = _claude_settings_with_defaults(dict(getattr(claude_cfg, "settings", {}) or {}))
|
||||||
mock_cfg = provider_map.get("mock")
|
mock_cfg = provider_map.get("mock")
|
||||||
codex_provider = get_provider("codex_cli")
|
codex_provider = get_provider("codex_cli")
|
||||||
|
claude_provider = get_provider("claude_cli")
|
||||||
codex_healthcheck = codex_provider.healthcheck(codex_settings) if codex_cfg else None
|
codex_healthcheck = codex_provider.healthcheck(codex_settings) if codex_cfg else None
|
||||||
|
claude_healthcheck = claude_provider.healthcheck(claude_settings) if claude_cfg else None
|
||||||
codex_queue_counts = {
|
codex_queue_counts = {
|
||||||
"pending": ExternalSyncEvent.objects.filter(
|
"pending": ExternalSyncEvent.objects.filter(
|
||||||
user=request.user, provider="codex_cli", status="pending"
|
user=request.user, provider="codex_cli", status="pending"
|
||||||
@@ -1169,11 +1200,25 @@ class TaskSettings(LoginRequiredMixin, View):
|
|||||||
user=request.user, provider="codex_cli", status="ok"
|
user=request.user, provider="codex_cli", status="ok"
|
||||||
).count(),
|
).count(),
|
||||||
}
|
}
|
||||||
|
claude_queue_counts = {
|
||||||
|
"pending": ExternalSyncEvent.objects.filter(
|
||||||
|
user=request.user, provider="claude_cli", status="pending"
|
||||||
|
).count(),
|
||||||
|
"waiting_approval": ExternalSyncEvent.objects.filter(
|
||||||
|
user=request.user, provider="claude_cli", status="waiting_approval"
|
||||||
|
).count(),
|
||||||
|
"failed": ExternalSyncEvent.objects.filter(
|
||||||
|
user=request.user, provider="claude_cli", status="failed"
|
||||||
|
).count(),
|
||||||
|
"ok": ExternalSyncEvent.objects.filter(
|
||||||
|
user=request.user, provider="claude_cli", status="ok"
|
||||||
|
).count(),
|
||||||
|
}
|
||||||
codex_recent_runs = CodexRun.objects.filter(user=request.user).order_by("-created_at")[:10]
|
codex_recent_runs = CodexRun.objects.filter(user=request.user).order_by("-created_at")[:10]
|
||||||
latest_worker_event = (
|
latest_worker_event = (
|
||||||
ExternalSyncEvent.objects.filter(
|
ExternalSyncEvent.objects.filter(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
provider="codex_cli",
|
provider__in=["codex_cli", "claude_cli"],
|
||||||
)
|
)
|
||||||
.filter(status__in=["ok", "failed", "waiting_approval", "retrying"])
|
.filter(status__in=["ok", "failed", "waiting_approval", "retrying"])
|
||||||
.order_by("-updated_at")
|
.order_by("-updated_at")
|
||||||
@@ -1233,6 +1278,21 @@ class TaskSettings(LoginRequiredMixin, View):
|
|||||||
"queue_counts": codex_queue_counts,
|
"queue_counts": codex_queue_counts,
|
||||||
"recent_runs": codex_recent_runs,
|
"recent_runs": codex_recent_runs,
|
||||||
},
|
},
|
||||||
|
"claude_provider_config": claude_cfg,
|
||||||
|
"claude_provider_settings": {
|
||||||
|
"command": str(claude_settings.get("command") or "claude"),
|
||||||
|
"workspace_root": str(claude_settings.get("workspace_root") or ""),
|
||||||
|
"default_profile": str(claude_settings.get("default_profile") or ""),
|
||||||
|
"timeout_seconds": int(claude_settings.get("timeout_seconds") or 60),
|
||||||
|
"approver_service": str(claude_settings.get("approver_service") or ""),
|
||||||
|
"approver_identifier": str(claude_settings.get("approver_identifier") or ""),
|
||||||
|
},
|
||||||
|
"claude_compact_summary": {
|
||||||
|
"healthcheck_ok": bool(getattr(claude_healthcheck, "ok", False)),
|
||||||
|
"healthcheck_error": str(getattr(claude_healthcheck, "error", "") or ""),
|
||||||
|
"healthcheck_payload": dict(getattr(claude_healthcheck, "payload", {}) or {}),
|
||||||
|
"queue_counts": claude_queue_counts,
|
||||||
|
},
|
||||||
"person_identifiers": person_identifiers,
|
"person_identifiers": person_identifiers,
|
||||||
"external_link_person_identifiers": external_link_person_identifiers,
|
"external_link_person_identifiers": external_link_person_identifiers,
|
||||||
"external_link_scoped": external_link_scoped,
|
"external_link_scoped": external_link_scoped,
|
||||||
@@ -1376,6 +1436,17 @@ class TaskSettings(LoginRequiredMixin, View):
|
|||||||
"approver_mode": "channel",
|
"approver_mode": "channel",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
elif provider == "claude_cli":
|
||||||
|
settings_payload = _claude_settings_with_defaults(
|
||||||
|
{
|
||||||
|
"command": request.POST.get("command"),
|
||||||
|
"workspace_root": request.POST.get("workspace_root"),
|
||||||
|
"default_profile": request.POST.get("default_profile"),
|
||||||
|
"timeout_seconds": request.POST.get("timeout_seconds"),
|
||||||
|
"approver_service": request.POST.get("approver_service"),
|
||||||
|
"approver_identifier": request.POST.get("approver_identifier"),
|
||||||
|
}
|
||||||
|
)
|
||||||
row.settings = settings_payload
|
row.settings = settings_payload
|
||||||
row.save(update_fields=["enabled", "settings", "updated_at"])
|
row.save(update_fields=["enabled", "settings", "updated_at"])
|
||||||
return _settings_redirect(request)
|
return _settings_redirect(request)
|
||||||
@@ -1460,10 +1531,16 @@ class TaskSettings(LoginRequiredMixin, View):
|
|||||||
return _settings_redirect(request)
|
return _settings_redirect(request)
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_SUBMIT_PROVIDERS = {"codex_cli", "claude_cli"}
|
||||||
|
|
||||||
|
|
||||||
class TaskCodexSubmit(LoginRequiredMixin, View):
|
class TaskCodexSubmit(LoginRequiredMixin, View):
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
task_id = str(request.POST.get("task_id") or "").strip()
|
task_id = str(request.POST.get("task_id") or "").strip()
|
||||||
next_url = str(request.POST.get("next") or reverse("tasks_hub")).strip()
|
next_url = str(request.POST.get("next") or reverse("tasks_hub")).strip()
|
||||||
|
provider = str(request.POST.get("provider") or "codex_cli").strip().lower()
|
||||||
|
if provider not in _ALLOWED_SUBMIT_PROVIDERS:
|
||||||
|
provider = "codex_cli"
|
||||||
task = get_object_or_404(
|
task = get_object_or_404(
|
||||||
DerivedTask.objects.select_related("project", "epic", "origin_message"),
|
DerivedTask.objects.select_related("project", "epic", "origin_message"),
|
||||||
id=task_id,
|
id=task_id,
|
||||||
@@ -1471,13 +1548,14 @@ class TaskCodexSubmit(LoginRequiredMixin, View):
|
|||||||
)
|
)
|
||||||
cfg = TaskProviderConfig.objects.filter(
|
cfg = TaskProviderConfig.objects.filter(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
provider="codex_cli",
|
provider=provider,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
).first()
|
).first()
|
||||||
|
provider_label = "Claude" if provider == "claude_cli" else "Codex"
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
request,
|
||||||
"Codex provider is disabled. Enable it in Task Settings first.",
|
f"{provider_label} provider is disabled. Enable it in Task Settings first.",
|
||||||
)
|
)
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
run = _enqueue_codex_task_submission(
|
run = _enqueue_codex_task_submission(
|
||||||
@@ -1487,10 +1565,11 @@ class TaskCodexSubmit(LoginRequiredMixin, View):
|
|||||||
source_channel=str(task.source_channel or ""),
|
source_channel=str(task.source_channel or ""),
|
||||||
mode="default",
|
mode="default",
|
||||||
source_message=getattr(task, "origin_message", None),
|
source_message=getattr(task, "origin_message", None),
|
||||||
|
provider=provider,
|
||||||
)
|
)
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
f"Queued approval for task #{task.reference_code} before Codex run {run.id}.",
|
f"Queued approval for task #{task.reference_code} before {provider_label} run {run.id}.",
|
||||||
)
|
)
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ wheel==0.45.1
|
|||||||
uwsgi==2.0.28
|
uwsgi==2.0.28
|
||||||
django==4.2.19
|
django==4.2.19
|
||||||
pre-commit==4.2.0
|
pre-commit==4.2.0
|
||||||
django-crispy-forms==1.14.0
|
django-crispy-forms==2.3
|
||||||
crispy-bulma==0.11.0
|
crispy-bulma==0.11.0
|
||||||
djangorestframework==3.15.2
|
djangorestframework==3.15.2
|
||||||
uvloop==0.21.0
|
uvloop==0.21.0
|
||||||
@@ -10,16 +10,16 @@ django-htmx==1.21.0
|
|||||||
cryptography==44.0.2
|
cryptography==44.0.2
|
||||||
django-debug-toolbar==4.4.6
|
django-debug-toolbar==4.4.6
|
||||||
django-debug-toolbar-template-profiler==2.1.0
|
django-debug-toolbar-template-profiler==2.1.0
|
||||||
orjson==3.10.15
|
orjson==3.10.18
|
||||||
msgpack==1.1.0
|
msgpack==1.1.0
|
||||||
apscheduler==3.10.4
|
apscheduler==3.11.0
|
||||||
watchfiles==1.0.5
|
watchfiles==1.0.5
|
||||||
django-otp==1.6.0
|
django-otp==1.6.0
|
||||||
django-two-factor-auth==1.17.0
|
django-two-factor-auth==1.17.0
|
||||||
django-otp-yubikey==1.1.0
|
django-otp-yubikey==1.1.0
|
||||||
phonenumbers==8.13.55
|
phonenumbers==8.13.55
|
||||||
qrcode==8.0
|
qrcode==7.4.2
|
||||||
pydantic==2.10.6
|
pydantic==2.11.5
|
||||||
redis==6.2.0
|
redis==6.2.0
|
||||||
hiredis==3.1.0
|
hiredis==3.1.0
|
||||||
django-cachalot==2.7.0
|
django-cachalot==2.7.0
|
||||||
@@ -30,6 +30,7 @@ openai==1.66.3
|
|||||||
aiograpi==0.0.4
|
aiograpi==0.0.4
|
||||||
aiomysql==0.2.0
|
aiomysql==0.2.0
|
||||||
slixmpp==1.10.0
|
slixmpp==1.10.0
|
||||||
|
slixmpp-omemo==2.1.0
|
||||||
neonize==0.3.12
|
neonize==0.3.12
|
||||||
watchdog==6.0.0
|
watchdog==6.0.0
|
||||||
uvicorn==0.34.0
|
uvicorn==0.34.0
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ XMPP_USER_DOMAIN=example.com
|
|||||||
XMPP_PORT=8888
|
XMPP_PORT=8888
|
||||||
# Auto-generated if empty by Prosody startup helpers.
|
# Auto-generated if empty by Prosody startup helpers.
|
||||||
XMPP_SECRET=
|
XMPP_SECRET=
|
||||||
|
# Directory for OMEMO key storage. Defaults to <BASE_DIR>/xmpp_omemo_data if unset.
|
||||||
|
# XMPP_OMEMO_DATA_DIR=./.podman/gia_xmpp_omemo_data
|
||||||
|
|
||||||
# Optional Prosody container storage/config paths used by utilities/prosody/manage_prosody_container.sh
|
# Optional Prosody container storage/config paths used by utilities/prosody/manage_prosody_container.sh
|
||||||
PROSODY_IMAGE=docker.io/prosody/prosody:latest
|
PROSODY_IMAGE=docker.io/prosody/prosody:latest
|
||||||
|
|||||||
Reference in New Issue
Block a user