docs+fix: renforcement de l'architecture agentique et corrections de sécurité

AGENTS.md :
- Rôles d'agents renforcés : @coder ne valide pas, @debugger ne code pas,
  @verifier ne modifie pas, etc.
- Table « Séparation des rôles » : tâche → agent responsable → ne pas confier à
- Workflow étape 5 : délégation explicite à @verifier pour la validation

GUIDE_DEV_PYTHON.md :
- 7 notes « Décision d'implémentation » ajoutées aux sections concernées :
  3.1.1 (XMPP_RECIPIENT→XMPP_TO, vars ajoutées), 3.1.2 (SYNC_PAST_DAYS→AppSettings),
  3.2 (Pydantic v2 style, defaults corrigés), 4.2.1 (redact_exception module function),
  4.2.2 (getLevelNamesMapping), 5.1.5 (normalize_pronote_uid, usedforsecurity=False),
  6 (ConfigDict, StrEnum, alias _date, external_info Optional)

Sécurité (audit @security-auditor, corrections @coder, validation @verifier) :
- RedactingFormatter : redaction APRÈS formatage (corrige TypeError %s + fuite traceback)
- redact_url : masquage des credentials dans userinfo URL (HTTP Basic Auth)
- redact_secrets : patterns étendus (api_key, access_token, authorization, auth)
- redact_secrets : support JSON-style « key: value » avec guillemets

Validations (@verifier) :
- ruff check : PASS | mypy strict : PASS | bandit : PASS (0 issue)
- %s formatting : OK (password=REDACTED, pas de TypeError)
- Traceback redaction : OK (icalsecurise=REDACTED)
- URL userinfo : OK (user:REDACTED@host)
- JSON-style redaction : OK ({"token": "REDACTED"})
- Régression red-to-green : OK (historical HEAD reproduction)

Co-authored-by: OpenCode/orchestrator <opencode-orchestrator@agents.invalid>
This commit is contained in:
2026-09-05 23:52:02 +02:00
parent 2e7dfe6e46
commit aaca78c55d
5 changed files with 554 additions and 475 deletions

View File

@@ -90,6 +90,10 @@
{ {
"path": "detect_secrets.filters.allowlist.is_line_allowlisted" "path": "detect_secrets.filters.allowlist.is_line_allowlisted"
}, },
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".secrets.baseline"
},
{ {
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2 "min_level": 2
@@ -136,9 +140,9 @@
"filename": "GUIDE_DEV_PYTHON.md", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": false, "is_verified": false,
"line_number": 5073 "line_number": 5112
} }
] ]
}, },
"generated_at": "2026-09-05T17:53:24Z" "generated_at": "2026-09-05T21:51:55Z"
} }

View File

@@ -189,22 +189,38 @@ Cette section s'applique uniquement lorsque le travail est exécuté avec le sys
Les rôles d'agents disponibles pour ce projet sont les suivants : Les rôles d'agents disponibles pour ce projet sont les suivants :
- `@architect` : Arbitrages d'architecture et choix techniques structurants pour le pipeline `pronote-sync`. - `@architect` : Arbitrages d'architecture et choix techniques structurants. **Ne produit pas de code.**
- `@coder` : Opérations de développement et changements de code dans le projet. - `@coder` : Écrit et modifie du code, de la configuration et des scripts. **Ne valide pas** (ruff, mypy, pytest) — c'est le rôle de `@verifier`. **Ne diagnostique pas** — c'est le rôle de `@debugger`.
- `@debugger` : Reproduction d'un symptôme et établissement de sa cause profonde (ex. : échec de synchronisation, repli iCal/pronotepy). - `@debugger` : Reproduit un symptôme et établit sa cause profonde. **Ne modifie pas le code.**
- `@explorer` : Exploration du dépôt en lecture seule et fourniture de contexte factuel. - `@explorer` : Explore le dépôt en lecture seule. **Ne modifie rien, n'exécute pas de commandes.**
- `@orchestrator` : Compréhension globale du projet, définition des jalons, coordination et garantie du résultat. - `@orchestrator` : Compréhension globale, définition des jalons, coordination et garantie du résultat. **N'écrit pas de code.**
- `@planner` : Transformation d'une demande complexe en unités exécutables avec frontières et dépendances claires. - `@planner` : Transforme une demande complexe en unités exécutables. **Ne dirige aucun technicien.**
- `@reviewer` : Revues indépendantes de correction, régression, contrats et maintenabilité. - `@reviewer` : Revues indépendantes de correction, régression, contrats et maintenabilité. **Ne modifie pas le code.**
- `@security-auditor` : Audit indépendant d'une surface de sécurité désignée (ex. : gestion des secrets, masquage des données). - `@security-auditor` : Audit indépendant d'une surface de sécurité. **Ne modifie pas le code.**
- `@tech-writer` : Rédaction et maintenance de documentation technique exacte et vérifiable. - `@tech-writer` : Rédige et maintient la documentation. **N'écrit pas de code applicatif.**
- `@test-engineer` : Conception, écriture et exécution de tests ciblés (unitaires, intégration, mocks). - `@test-engineer` : Conçoit, écrit et exécute des tests ciblés. **N'écrit pas de code de production.**
- `@ui-designer` : conception et implémentation d'interfaces Web et terminal. - `@ui-designer` : Conçoit et implémente les interfaces Web et terminal.
- `@verifier` : Vérification indépendante du comportement livré, des régressions et du respect des conventions (idempotence, mode dégradé). - `@verifier` : Vérifie indépendamment le comportement livré, les régressions et le respect des conventions (ruff, mypy, pytest, bandit, idempotence, mode dégradé). **Ne modifie pas le code.**
- `@web-explorer` : Recherche et extraction de sources Web vérifiables (ex. : documentation Pronote, CalDAV, XMPP). - `@web-explorer` : Recherche et extrait des sources Web vérifiables. **Ne modifie pas le dépôt.**
> **Note** : Ne pas utiliser `@coder` pour les tâches de documentation (`@tech-writer`) ni pour les tests (`@test-engineer`). > **Note** : Ne pas utiliser `@coder` pour les tâches de documentation (`@tech-writer`) ni pour les tests (`@test-engineer`).
### Séparation des rôles
| Type de tâche | Agent responsable | Ne pas confier à |
|---|---|---|
| Écrire/modifier du code | `@coder` | `@verifier`, `@explorer` |
| Valider (ruff, mypy, pytest, bandit) | `@verifier` | `@coder` |
| Diagnostiquer un bug | `@debugger` | `@coder` |
| Écrire un test | `@test-engineer` | `@coder` |
| Rédiger de la documentation | `@tech-writer` | `@coder` |
| Explorer le dépôt (lecture) | `@explorer` | `@coder`, `@verifier` |
| Arbitrage technique structurant | `@architect` | `@coder`, `@planner` |
| Revue de code | `@reviewer` | `@coder`, `@verifier` |
| Audit de sécurité | `@security-auditor` | `@coder`, `@verifier` |
| Recherche web | `@web-explorer` | `@explorer` |
| Découpage de travail complexe | `@planner` | `@coder` |
--- ---
## 10. Workflow de modification ## 10. Workflow de modification
@@ -213,7 +229,7 @@ Les rôles d'agents disponibles pour ce projet sont les suivants :
2. Préserver les changements existants de l'utilisateur. 2. Préserver les changements existants de l'utilisateur.
3. Pour une correction, reproduire d'abord le défaut avec un test automatisé lorsque c'est raisonnable. 3. Pour une correction, reproduire d'abord le défaut avec un test automatisé lorsque c'est raisonnable.
4. Faire une modification étroite et cohérente, en respectant les conventions du projet (idempotence, mode dégradé, repli iCal/pronotepy). 4. Faire une modification étroite et cohérente, en respectant les conventions du projet (idempotence, mode dégradé, repli iCal/pronotepy).
5. Vérifier le comportement nominal et les cas d'erreur, notamment : 5. Faire vérifier le comportement par `@verifier` (ruff, mypy, pytest, bandit) et les cas d'erreur, notamment :
- Succès de la synchronisation Pronote → CalDAV/XMPP. - Succès de la synchronisation Pronote → CalDAV/XMPP.
- Repli vers iCal en cas d'échec de `pronotepy`. - Repli vers iCal en cas d'échec de `pronotepy`.
- Gestion des erreurs explicites. - Gestion des erreurs explicites.

View File

@@ -271,6 +271,10 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` | | `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` |
| `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` | | `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` |
> ⚠️ **Décision d'implémentation** :
> `XMPP_RECIPIENT` a été renommé en `XMPP_TO` dans l'implémentation (aligné avec §10.2.1).
> Des variables XMPP supplémentaires ont été ajoutées : `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_RESOURCE`, `XMPP_USE_TLS`, `XMPP_TIMEOUT`.
> Une section `BLOG_ENABLED` et `BLOG_RSS_URL` a été ajoutée dans `.env.example`.
#### 3.1.2 Variables optionnelles #### 3.1.2 Variables optionnelles
@@ -281,6 +285,10 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` | | `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` | | `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` |
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` | | `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` |
> ⚠️ **Décision d'implémentation** :
> Ces variables sont désormais dans `AppSettings` (et non `CalDAVSettings`) car `CalDAVSettings` utilise `env_prefix="CALDAV_"`, ce qui nécessiterait `CALDAV_SYNC_PAST_DAYS`.
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`| | `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` | | `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`| | `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
@@ -335,6 +343,15 @@ LOG_LEVEL=INFO
### 3.2 Modèle Pydantic pour la configuration ### 3.2 Modèle Pydantic pour la configuration
> ⚠️ **Décision d'implémentation** :
> L'implémentation utilise le style moderne de Pydantic v2 : `model_config = ConfigDict(frozen=True)` au lieu de `class Config`, pas de `json_encoders` (la sérialisation ISO est native en v2), `str | None` au lieu de `Optional[str]`, `list[str]` au lieu de `List[str]`.
> `AISettings.enabled` a pour valeur par défaut `False` (et non `True` comme indiqué dans le bloc de code).
> `XmppSettings` est entièrement défini en §10.2.3 avec tous les champs optionnels (valeurs par défaut) pour que `Settings()` fonctionne sans `.env`.
> `BlogSettings` a été ajouté (§5 bis.9.2) avec `enabled=False` et `rss_url` par défaut.
> `sync_past_days` et `sync_future_days` sont dans `AppSettings`, et non `CalDAVSettings`.
> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"` (et non `"/pronote-digest/"`).
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"` (et non `"pronote-digest"`).
```python ```python
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import SecretStr, Field from pydantic import SecretStr, Field
@@ -452,6 +469,11 @@ Toutes les exceptions externes (HTTP, Pronote, CalDAV, XMPP, IA) **doivent** êt
#### 4.2.1 Masquage des URLs (`redaction.py`) #### 4.2.1 Masquage des URLs (`redaction.py`)
> ⚠️ **Décision d'implémentation** :
> `redact_exception` est implémenté comme une **fonction au niveau du module** dans `utils/redaction.py`, et non comme une méthode de `RedactingFormatter` (contrairement à §4.2.2 où elle apparaît comme une méthode).
> `redact_url` utilise `urlsplit`/`urlunsplit`/`parse_qsl` au lieu de `urlparse`/`urlunparse`/`parse_qs`.
> La correspondance des clés sensibles est insensible à la casse.
```python ```python
import re import re
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
@@ -506,6 +528,11 @@ def redact_secrets(text: str) -> str:
#### 4.2.2 Configuration des logs (`logging.py`) #### 4.2.2 Configuration des logs (`logging.py`)
> ⚠️ **Décision d'implémentation** :
> `redact_exception` est une fonction au niveau du module dans `redaction.py`, et non une méthode de `RedactingFormatter`.
> `setup_logging` utilise `logging.getLevelNamesMapping()` (Python 3.11+) au lieu de `getattr(logging, ...)`.
> `RedactingFormatter.format` gère à la fois les `record.args` de type tuple et dict.
```python ```python
import logging import logging
import sys import sys
@@ -1684,6 +1711,10 @@ homeworks = collect_homeworks(lessons, target_date)
#### 5.1.5 Normalisation des UID #### 5.1.5 Normalisation des UID
> ⚠️ **Décision d'implémentation** :
> La fonction est nommée `normalize_pronote_uid` (et non `normalize_uid` comme référencé dans TODO.md).
> `generate_deterministic_uid` utilise `hashlib.sha1(payload, usedforsecurity=False)` pour satisfaire la règle bandit B324 (le hachage n'est pas utilisé pour la sécurité).
Les UID Pronote contiennent des **suffixes temporels** qui changent à chaque export. Exemple réel : Les UID Pronote contiennent des **suffixes temporels** qui changent à chaque export. Exemple réel :
``` ```
UID:Cours-16027-1-20260904T120218Z-Index-Education UID:Cours-16027-1-20260904T120218Z-Index-Education
@@ -2360,6 +2391,14 @@ class PronoteFetcher:
## 6. Modèle de données Pydantic ## 6. Modèle de données Pydantic
> ⚠️ **Décision d'implémentation (M3)** :
> Tous les modèles utilisent `model_config = ConfigDict(frozen=True)` (Pydantic v2), et non `class Config: frozen = True`.
> Pas de `json_encoders` : la sérialisation ISO pour `datetime`/`date` est native dans Pydantic v2.
> Les énumérations utilisent `enum.StrEnum` (Python 3.11+) au lieu de `(str, Enum)` (règle ruff UP042).
> Dans `HomeworkBlock`, le champ `date` utilise un alias d'import `_date` (`from datetime import date as _date`) pour éviter un conflit de nom/champ dans Pydantic v2. Même chose pour `time` → `_time` dans `TheoreticalLesson`.
> `XmppMessage.external_info` est de type `ExternalInfo | None` avec une valeur par défaut `None` (et non `default_factory=ExternalInfo`) : le pipeline passe `None` lorsqu'il n'y a pas d'informations externes.
> Les modèles sont répartis en 10 modules domaines (agenda, homework, message, blog, diff, pronote, sync, synthesis, xmpp) avec `__init__.py` réexportant les 22 noms via `__all__`.
### 6.1 Principes ### 6.1 Principes
- **Modèles distincts par domaine** : Ne pas créer un unique modèle fourre-tout. Chaque étape du pipeline utilise des modèles dédiés (décision architecturale). - **Modèles distincts par domaine** : Ne pas créer un unique modèle fourre-tout. Chaque étape du pipeline utilise des modèles dédiés (décision architecturale).
- **Validation stricte** : Utiliser Pydantic pour valider les données dès leur création. - **Validation stricte** : Utiliser Pydantic pour valider les données dès leur création.

View File

@@ -22,26 +22,15 @@ class RedactingFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
"""Formate un enregistrement de log en masquant les secrets. """Formate un enregistrement de log en masquant les secrets.
Le message et chaque argument textuel de l'enregistrement sont rédigés La rédaction est appliquée à la chaîne finale (message, arguments et
avant le formatage final effectué par :class:`logging.Formatter`. traceback inclus) produite par :class:`logging.Formatter`.
:param record: Enregistrement de log à formater. :param record: Enregistrement de log à formater.
:return: Message formaté, avec les secrets remplacés par ``REDACTED``. :return: Message formaté, avec les secrets remplacés par ``REDACTED``.
:rtype: str :rtype: str
""" """
record.msg = redact_secrets(str(record.msg)) formatted = super().format(record)
args = record.args return redact_secrets(formatted)
if args:
if isinstance(args, tuple):
record.args = tuple(
redact_secrets(arg) if isinstance(arg, str) else arg for arg in args
)
else:
record.args = {
key: redact_secrets(value) if isinstance(value, str) else value
for key, value in args.items()
}
return super().format(record)
def setup_logging(level: str = "INFO") -> None: def setup_logging(level: str = "INFO") -> None:

View File

@@ -10,10 +10,26 @@ from __future__ import annotations
import re import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
_SENSITIVE_QUERY_KEYS = frozenset({"icalsecurise", "token", "key", "password", "secret"}) _SENSITIVE_QUERY_KEYS = frozenset(
{
"icalsecurise",
"token",
"key",
"password",
"secret",
"api_key",
"apikey",
"access_token",
"auth",
"authorization",
}
)
_URL_PATTERN = re.compile(r"https?://[^\s]+") _URL_PATTERN = re.compile(r"https?://[^\s]+")
_ISOLATED_SECRET_PATTERN = re.compile( _ISOLATED_SECRET_PATTERN = re.compile(
r"\b(icalsecurise|token|password|secret|key)\s*=\s*[^\s&]+", r"\b(icalsecurise|access_token|api_key|apikey|authorization|token|password|secret|key|auth)"
r"(\s*['\"]?\s*[:=]\s*)"
r"(['\"]?)"
r"([^\s&'\"]+)",
re.IGNORECASE, re.IGNORECASE,
) )
_REDACTED = "REDACTED" _REDACTED = "REDACTED"
@@ -21,15 +37,30 @@ _REDACTED_URL = "REDACTED_URL"
def redact_url(url: str) -> str: def redact_url(url: str) -> str:
"""Masque les paramètres sensibles dans une URL. """Masque les identifiants et les paramètres sensibles d'une URL.
:param url: URL pouvant contenir des paramètres sensibles (ex: ``icalsecurise``). Les informations d'authentification du netloc (``utilisateur:motdepasse@hôte``)
:return: URL avec les paramètres sensibles remplacés par ``REDACTED``, sont masquées, ainsi que les paramètres sensibles de la requête
(ex: ``icalsecurise``).
:param url: URL pouvant contenir des informations sensibles (ex: ``icalsecurise``).
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
ou ``REDACTED_URL`` si le traitement échoue. ou ``REDACTED_URL`` si le traitement échoue.
:rtype: str :rtype: str
""" """
try: try:
parts = urlsplit(url) parts = urlsplit(url)
if parts.username is not None or parts.password is not None:
# Netloc sûr : utilisateur:REDACTED@hôte:port. Le deux-point est
# conservé même en l'absence de mot de passe explicite.
userinfo = parts.username or ""
userinfo += ":REDACTED"
host = parts.hostname or ""
if parts.port is not None:
netloc = f"{userinfo}@{host}:{parts.port}"
else:
netloc = f"{userinfo}@{host}"
parts = parts._replace(netloc=netloc)
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True) query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
redacted_query = [ redacted_query = [
(key, _REDACTED if key.lower() in _SENSITIVE_QUERY_KEYS else value) (key, _REDACTED if key.lower() in _SENSITIVE_QUERY_KEYS else value)
@@ -44,15 +75,15 @@ def redact_secrets(text: str) -> str:
"""Masque les secrets présents dans un texte arbitraire. """Masque les secrets présents dans un texte arbitraire.
Les URLs sont d'abord traitées par :func:`redact_url`, puis les affectations Les URLs sont d'abord traitées par :func:`redact_url`, puis les affectations
isolées de type ``cle=valeur`` (ex: ``icalsecurise=XXX``) sont masquées, isolées de type ``cle=valeur`` ou ``cle:valeur`` (ex: ``icalsecurise=XXX``,
sans distinction de casse. ``"token": "XXX"``) sont masquées, sans distinction de casse.
:param text: Texte pouvant contenir des URLs ou des secrets en clair. :param text: Texte pouvant contenir des URLs ou des secrets en clair.
:return: Texte avec les secrets remplacés par ``REDACTED``. :return: Texte avec les secrets remplacés par ``REDACTED``.
:rtype: str :rtype: str
""" """
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text) redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
return _ISOLATED_SECRET_PATTERN.sub(r"\1=REDACTED", redacted) return _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
def redact_exception(exc: Exception) -> str: def redact_exception(exc: Exception) -> str: