Aller au contenu principal
737 tests automatisés 100 % des lignes couvertes Suite exécutable sans matériel Onze étapes livrées

La majorité des travaux de sécurité de l'IA ciblent le modèle. L'IA physique exige davantage : des invariants qui tiennent au niveau du protocole et au bout d'un fil, appliqués avant que l'actionneur ne bouge. Ce démonstrateur formule quatre revendications, et chacune se vérifie en lisant le code ou le câblage plutôt qu'une note d'intention.

Le code source complet est disponible sur github.com/thierrysays/governed-edge-ai : code sous Apache 2.0, documentation sous CC BY 4.0. Les fichiers de conception matérielle seront publiés sous CERN OHL-P v2 lorsqu'il y en aura ; il n'en existe aucun à ce jour.

Ce qui est testé, et ce qui reste une hypothèse

Le logiciel est complet et testé. Le matériel est une conception qui attend un banc. Aucune carte n'a jamais exécuté ce firmware, aucun relais n'a été câblé, aucune caméra n'a été montée. Toutes les valeurs de temporisation de la spécification de protocole sont des objectifs de conception, pas des mesures.

docs/architecture.md nomme ces quatre contraintes invariants A à D et précise ce qui applique chacune : le coprocesseur d'actionnement applique A en rejetant toute trame sans référence d'audit, le relais bistable applique B et reste le seul des quatre à tenir tous processeurs éteints, l'arbitre applique C en conservant les empreintes hors hôte et D en n'exposant aucun message entrant qui relâche son verrou. Les quatre sont structurelles, non procédurales.

#Cinq cartes, un rôle chacune

La règle d'organisation tient en une phrase : aucune carte ne décide et n'applique à la fois. C'est une propriété qui se vérifie en regardant le câblage, pas en lisant une politique.

01 Arduino UNO Q 4 Go

Témoin. Seconde observation indépendante dont le désaccord force un HALT. Voit par une webcam UVC sur USB, capteur et pile de pilotes différents de la carte de décision.

02 Arduino VENTUNO Q

Chemin de décision, explicitement révocable. Perception, filtre de gouvernance, journal d'audit SQLite et chaîne d'attestation SHA-256.

03 Arduino Alvik

Corps gouverné. Exécute, et refuse toute commande dépourvue de référence d'audit valide. MicroPython sur l'ESP32-S3, contrôleur moteur STM32F411.

04 Arduino UNO R4 WiFi

Arbitre de sécurité, hors de la chaîne de commande. Relais bistable, bouton d'inhibition, annonciateur, témoin des empreintes d'audit hors hôte. La seule carte qui applique.

05 Arduino Nesso N1

Console opérateur hors bande, alimentée sur batterie. Aucun firmware n'est écrit : c'est l'étape 13.

La colonne ci-dessous parle du code, pas du matériel

Aucune carte n'a été mise sous tension. Ce qui est marqué écrit et testé passe ses tests contre des modèles logiciels des cartes ; rien n'a rencontré une carte. Ce tableau ne décrit pas un banc en fonctionnement.

Carte Décide ? Applique ? État du code
UNO Q 4 GononnonService de perception écrit. Le rôle de témoin lui-même est l'étape 16, non écrit
VENTUNO QouinonÉcrit et testé
Alviknonsur lui-même seulementÉcrit et testé
UNO R4 WiFinonouiÉcrit et testé. La colle matérielle est la seule partie qu'aucun test ne peut piloter
Nesso N1nonpar levée signéeRien d'écrit
Le bus de sécurité n'appartient pas à l'hôte qui décide

Sur le bus I2C propre à l'arbitre, délibérément pas celui de l'hôte de décision : un Modulino Latch Relay dont le contact est en série avec l'alimentation des moteurs, plus des modules Distance et Movement destinés à produire des preuves en dehors de la chaîne de vision. Ces deux derniers relèvent de l'étape 15 et ne sont pas construits.

Le champ de vision fixe l'enveloppe de sécurité

Deux Arducam IMX219 8 Mpx, 62,2° de champ horizontal chacune. L'arrangement retenu place 52° entre les axes optiques, soit 26° de part et d'autre de l'axe médian : environ 114° de couverture et environ 10° de recouvrement au centre. Certains documents antérieurs arrondissent à « environ 120° » ; 114 est le chiffre sur lequel la décision a été prise.

Le recouvrement remplit une fonction précise. Une tolérance de montage de quelques degrés par caméra transformerait une jointure spécifiée exactement fermée en trou réel, juste devant le robot, au pire endroit possible. Il sert aussi d'autotest gratuit : un objet dans le coin partagé doit être vu par les deux voies, si bien qu'une caméra en panne, cognée ou défocalisée se signale par un désaccord plutôt que par du silence. L'angle d'écartement devient donc un paramètre calibré et documenté, car l'enveloppe de sécurité est définie par lui : un support qui bouge est un contrôle qui dérive en silence.

Conséquence à écrire noir sur blanc : il reste un secteur aveugle d'environ 246°, derrière et sur les côtés, que le journal d'audit ne peut pas distinguer d'un secteur vide. Cela appartient au modèle de menaces, dans ces termes.

Note de nomenclature : le simulateur du coprocesseur s'appelle toujours MockSTM32H5, héritage du STM32H5 de la VENTUNO Q sur lequel le démonstrateur a démarré. Le répertoire rt-control/ reste une spécification vide : l'arbitrage a été confié définitivement à l'UNO R4, une carte séparée avec son alimentation propre, plutôt qu'à un cœur partageant le circuit imprimé de l'hôte qu'il est censé arbitrer.

AJournaliser avant d'agir

Invariant A · linux-stack/governance/filter.py

La référence d'audit est un identifiant de ligne SQLite obtenu avant toute transmission de trame de commande. La structure interdit l'alternative : l'envoi se trouve à l'intérieur du bloc qui suit l'appel de journalisation, et le firmware du robot rejette toute commande qui ne porte pas de référence.

governance/filter.py
            # Governance invariant: log-before-act.
            # audit_ref must be non-zero; log_event() guarantees this by
            # returning the SQLite rowid (>= 1).
            audit_ref = self._logger.log_event(AuditEvent(
                session_id=self._session_id,
                actor="ai",
                detection_type=detection.detection_type,
                detection_label=detection.label,
                confidence=detection.confidence,
                command=action_type.name,
                command_sent=should_send,
                stm32_ack=None,
                notes=override_note,
            ))

            # Witness-before-act: the oversight node holds a digest covering
            # this row before the command frame is written.
            self._witness(audit_ref, command_sent=should_send)

            if should_send:
                command_sent_this_frame = True
                ack = self._send_command(detection, action_type, action_param, audit_ref)
                if ack is not None:
                    self._logger.update_stm32_ack(audit_ref, ack)

Le cas intéressant est l'échec d'écriture, que le chemin nominal laisse dans l'ombre. La ligne affirme déjà command_sent=1, et le journal est en ajout seul : l'enregistrement ne peut pas être retiré. Le filtre marque alors l'événement pour revue humaine, seule façon honnête d'inscrire qu'une trame a été composée sans jamais atteindre le fil.

La même règle, réappliquée à la frontière d'actionnement

Le corps gouverné n'exécute une commande moteur qu'après quatre portes, dans cet ordre exact. C'est ce qui distingue le démonstrateur d'une politique logicielle : un correctif appliqué à la carte de gouvernance pour contourner le journal produirait des trames sans référence d'audit, que le corps refuserait d'exécuter.

alvik-firmware/main.py
def _handle(msg, alvik, stdout, kill_switch_active: bool) -> None:
    # Governance gate 1: audit_ref must be non-zero (log-before-act)
    if msg.audit_ref == 0:
        stdout.write(encode_reject(msg.audit_ref, REJ_AUDIT_REF_ZERO))
        stdout.flush()
        return

    # Governance gate 2: kill-switch overrides all commands
    if kill_switch_active:
        stdout.write(encode_reject(msg.audit_ref, REJ_KILL_SWITCH_ACTIVE))
        stdout.flush()
        return

    # Governance gate 3: float32 confidence threshold (defence-in-depth)
    if msg.confidence < CONFIDENCE_THRESHOLD:
        stdout.write(encode_reject(msg.audit_ref, REJ_CONFIDENCE_BELOW_THRESHOLD))
        stdout.flush()
        return

    # Governance gate 4: unknown action type
    action_fn = MOTOR_MAP.get(msg.action_type)
    if action_fn is None:
        stdout.write(encode_reject(msg.audit_ref, REJ_UNKNOWN_ACTION))
        stdout.flush()
        return

Le firmware qualifie lui-même la porte 2 : KILL_PIN est une entrée de test locale et n'est pas un contrôle de gouvernance. Elle a brièvement été pilotée par l'arbitre, arrangement doublement fautif, et c'est cette erreur qui a produit l'invariant B.

Table de commandes par défaut · conservatrice par construction : toute étiquette inconnue déclenche HALT

Étiquette Commande Justification
personHALTPersonne dans l'espace de travail
robot_partHALTRisque d'auto-collision
toolHALTObjet étranger
stop (gesture)HALTCommande d'arrêt de l'opérateur
thumbs_up (gesture)MOVE_FORWARD · 50 %Autorisation explicite d'avancer
thumbs_down (gesture)MOVE_BACKWARD · 50 %Commande explicite de recul
swipe_left (gesture)TURN_LEFT · 45°Changement de cap demandé
swipe_right (gesture)TURN_RIGHT · 45°Changement de cap demandé
proximity_breach (pose)HALTViolation de proximité
(toute autre étiquette)HALTInconnu égale dangereux par défaut

CAttester avant d'agir

Invariant C · linux-stack/oversight/attestation.py · supervisor_link.py

Une tête de chaîne SHA-256 parvient à une carte que l'hôte de décision ne contrôle pas avant que la trame de commande ne soit écrite. C'est cet ordonnancement qui fait des empreintes conservées une preuve plutôt que le journal d'un journal.

oversight/attestation.py
    digest_0 = GENESIS                                  (32 zero bytes)
    digest_n = SHA-256( digest_{n-1} || canonical(row_n) )

La ligne est relue depuis SQLite plutôt que reconstruite depuis l'objet en mémoire : la chaîne doit s'engager sur ce que contient la base, non sur ce que le processus croit y avoir écrit. L'UNO R4 conserve 64 empreintes dans sa propre mémoire, soit 2,5 Ko des 32 Ko de SRAM du RA4M1. Recalculer la chaîne depuis la base et la comparer aux empreintes retenues détecte toute édition, suppression ou réordonnancement rétroactif, parce que la première ligne modifiée change toutes les empreintes suivantes.

Le sens de l'autorité est inscrit dans le protocole

Tout ce que la VENTUNO Q émet sur cette liaison est un rapport. Tout ce que l'UNO R4 renvoie est une instruction. Le niveau de gouvernance ne peut pas demander à son superviseur de se taire, et aucun type de message ne le permettrait : le protocole ne contient pas d'OVERRIDE_DENY.

Direction Message Fonction
VENTUNO Q → R4SUPERVISOR_HEARTBEATSignal de vie et compteurs de session, toutes les 500 ms
VENTUNO Q → R4ATTEST_DIGESTTête de chaîne d'audit, une fois par événement journalisé
VENTUNO Q → R4LATCH_REQUESTDemande de position du contact. Une demande, rien de plus
R4 → VENTUNO QOVERRIDE_ASSERTCesser d'émettre des commandes, avec un motif
R4 → VENTUNO QOVERRIDE_CLEARInhibition levée
R4 → VENTUNO QATTEST_ACKVerdict en direct du R4 sur la dernière empreinte
R4 → VENTUNO QLATCH_REPORTPosition commandée, rapportée et observée du contact
Le silence vaut veto

Si le R4 cesse de répondre au-delà du délai de liaison, le lien signale une inhibition avec le motif « signal de vie de gouvernance perdu ». Un superviseur injoignable n'est pas la preuve que la supervision est satisfaite. Symétriquement, le R4 verrouille une inhibition si le niveau de gouvernance cesse de rapporter pendant 2 secondes, et redémarrer le logiciel ne la lève pas.

Limite à énoncer plutôt qu'à masquer : le filtre n'attend pas l'ATTEST_ACK avant de transmettre. Un verdict de rupture de chaîne arrête la commande suivante, pas celle en vol. Bloquer sur l'acquittement placerait la liaison de supervision dans le budget de latence d'actionnement, ce qui est le mauvais arbitrage pour une liaison dont le métier est d'observer.

BL'application survit à son applicateur

Invariant B · linux-stack/oversight/latch.py · r4-supervisor/

L'arrêt est un contact de relais bistable dans l'alimentation des moteurs. Il conserve sa position sans aucun courant, si bien que couper l'alimentation de la carte qui l'a ouvert ne restaure pas la puissance moteur. Et il ne demande aucune coopération au robot, puisque le robot n'a aucune broche à lire. C'est le seul des quatre invariants qui tienne avec tous les processeurs du système éteints.

C'est la revendication la plus récente et la seule qui ait une histoire, laquelle vaut d'être racontée : la crédibilité du projet repose davantage sur elle que sur sa liste de fonctionnalités.

Ce que 611 tests au vert n'avaient pas vu

L'arrêt physique était auparavant un fil de signal allant de l'arbitre vers une broche du robot. Il souffrait de deux défauts. Il aurait relâché à la perte d'alimentation de l'arbitre : une ligne pilotée retombe quand sa carte meurt, donc une panne d'alimentation au superviseur aurait désisolé les moteurs. Un contrôle de sécurité qui cesse de s'appliquer à l'instant où sa propre carte meurt ne protège de rien. Et il exigeait la coopération du composant gouverné : il ne fonctionnait que parce que le firmware du robot choisissait de lire cette broche. Reflasher le robot et le contrôle s'évapore. C'était un module de gouvernance boulonné sur la chose même qu'il doit gouverner, ce qui viole la règle de conception du projet.

Les deux défauts ont été trouvés en raisonnant sur la conception, non en observant une panne : aucune carte n'a jamais été énergisée. C'est une nuance qui compte, et l'inverse serait plus impressionnant à raconter.

Aucun des deux n'apparaissait dans 611 tests au vert. Les doublures de test modélisaient une machine à états : il n'y avait pas d'alimentation à perdre. La couverture ne trouve pas un défaut dont le modèle n'a pas le vocabulaire.

tests/test_latch.py
    def test_open_contact_survives_a_power_cycle(self, latch, sim):
        """The regression that motivated the whole redesign.

        The GPIO kill line released when its board lost power, so a power cut
        at the oversight node un-isolated the motors. A bistable contact holds
        its position with no coil current at all.
        """
        latch.enforce_halt()
        assert sim.contact is LatchState.OPEN
        sim.power_cycle()
        assert sim.contact is LatchState.OPEN
        assert latch.poll().observed is LatchState.OPEN

Un second défaut, trouvé en rédigeant la notice de montage

L'arbitre relisait le contact sur une unique ligne de détection, et la question « que lit cette ligne quand son fil est coupé ? » avait pour réponse « ouvert », que l'arbitre rapporte comme les moteurs sont isolés. Une position physique devenait indiscernable d'une panne, et c'était précisément la position que ce module existe pour refuser d'affirmer à la légère.

L'observation est désormais antivalente : deux canaux opto-isolés câblés pour se contredire l'un l'autre. Toute panne se lit « je ne vois pas », et rien n'arrondit cela en « isolé ». Une panne d'observation coûte de la disponibilité, jamais de la sécurité.

oversight/latch.py
class LatchState(IntEnum):
    """Contact position, named for what it does to the motors.

    The contact is normally open and wired in series with the motor supply,
    so OPEN is the safe state and the power-on state of an unlatched relay.
    """

    OPEN = 0      # contact open, motor supply cut, HALT enforced
    CLOSED = 1    # contact closed, motor supply available
    UNKNOWN = 2   # no reading yet, or the sense line is unreadable


    @property
    def enforcing(self) -> bool:
        """True when the contact is observed open: motor supply actually cut.

        Deliberately false when the observation is UNKNOWN. An unreadable
        sense line is not evidence that the motors are isolated.
        """
        return self.observed is LatchState.OPEN

DUne supervision que son sujet ne révoque pas

Invariant D · r4-supervisor/supervisor_state.h · tests/test_security_oversight.py

L'inhibition se verrouille à l'arbitre, et aucun message sur aucune liaison ne la relâche : le protocole n'en contient aucun. Relâcher une inhibition est un acte physique sur cette carte. Le niveau de gouvernance peut demander l'ouverture du contact, ce qui est toujours honoré ; il peut demander sa fermeture, ce qui est refusé sans discussion tant qu'une inhibition tient.

Une revendication de ce genre ne vaut que par la tentative faite pour la casser. La suite de tests jette tout le vocabulaire sortant sur un nœud verrouillé, y compris les propres messages du R4 rejoués contre lui, et vérifie que ni l'inhibition ni la coupure d'alimentation moteur ne bougent.

tests/test_security_oversight.py
    def test_no_message_type_clears_an_override(self, node, channel):
        """The protocol has no OVERRIDE_DENY, and nothing the governance tier
        can send acts as one. Throw the whole outbound vocabulary at it."""
        node.press_button()
        node.release_button()
        assert node.override_active

        for frame in (
            encode(SupervisorHeartbeat(1, SystemState.ARMED, 1, 0)),
            encode(AttestDigest(1, b"\x01" * 32)),
            encode(OverrideClear(timestamp_us=1)),      # the R4's own message,
            encode(OverrideAssert(1, OverrideReason.REMOTE_CONSOLE)),  # replayed
        ):
            channel.write(frame)
        time.sleep(0.3)

        assert node.override_active is True
        assert node.motor_power_cut is True
Une machine à états assez petite pour être lue d'un trait

La machine à états de l'arbitre est un fichier C++ sans aucun en-tête Arduino, sans ordonnanceur, sans système de fichiers et sans pile réseau dans la construction par défaut. Elle compile sur poste et se vérifie contre son modèle de référence Python. L'inhibition ne lapse pas quand sa cause disparaît : un bouton pressé puis relâché, un signal de vie perdu, une empreinte manquante ou en recul verrouillent tous, et seule une action physique au tableau les relâche.

La limite est réelle et documentée : cette liaison vaut ce que vaut le câble. Un attaquant qui peut y écrire forge un OVERRIDE_CLEAR et lève le veto logiciel, et un test l'établit explicitement. Le contact du relais, lui, n'est atteignable par aucun message. C'est la raison d'être des deux chemins plutôt que d'un seul, et un contrôle dont les limites ne sont pas documentées est un contrôle sur lequel personne ne peut s'appuyer.

≡Onze étapes livrées, six conçues

Les onze premières étapes sont dans le dépôt et testées. Les six suivantes sont spécifiées et ordonnées par dépendance, mais non écrites. Les distinguer fait partie de la démonstration.

N° Travail État
1Journal d'audit SQLite en mode WAL, ajout seulLivré
2Codec IPC binaire, CRC-16/CCITT, analyseur incrémentalLivré
3Tableau de bord d'audit FastAPI en lectureLivré
4Simulateur du coprocesseur sur pseudo-terminalLivré
5Chaîne de perception, détections typées, moteurs d'inférence interchangeablesLivré
6Filtre de gouvernance, journaliser avant d'agirLivré
7Firmware Alvik, quatre portes de gouvernanceLivré
8Services UNO Q et VENTUNO Q, chaîne de bout en boutLivré
9Niveau de supervision sur UNO R4 WiFiLivré
10Réconciliation d'architecture et cinquième carteLivré
11Relais bistable, retrait de la ligne d'arrêt sur GPIOLivré
12R4 propriétaire du bus de gouvernance, glyphes d'annonciateurConçu
13Nesso N1 : flux de verdicts, affichage, levée signée, appairage de clésConçu
14Signature du journal d'audit, contresignée par la NessoConçu
15Modules Distance et Movement : preuves hors chaîne de visionConçu
16Témoin UNO Q et porte d'accord entre observationsConçu
17Firmware Zephyr STM32H5, travail moteur sous la millisecondeConçu, bloqué sur le brochage

✓Ce qui est mesuré

737 tests répartis sur deux modules, 100 % des lignes couvertes sur les deux, avec un seuil bloquant fixé à 98 %. Aucun matériel n'est requis pour exécuter la suite.

Module Tests Lignes couvertes Périmètre
linux-stack638100 %Codec IPC, simulateurs, perception, filtre de gouvernance, supervision, relais, attestation, firmware Alvik
audit-service99100 %Journal d'audit, API du tableau de bord, tests de fumée

Les doublures sont de vraies implémentations des machines à états, pilotées à travers des pseudo-terminaux, là où un bouchon renverrait des valeurs figées. Le chemin exercé en intégration continue est celui qui tournerait sur un banc. C'est une revendication plus forte que ne le permet du bouchonnage, et plus faible que de l'avoir fait tourner.

Tests de sécurité adverses. Ils attaquent la conception depuis quatre positions : hôte de gouvernance compromis, attaquant sur le câble de supervision, hôte compromis disposant d'un accès en écriture à la base, entrées hostiles sur l'une ou l'autre liaison. Ils affirment ce qui tient et ce qui ne tient pas.

Tests de parité du firmware. La machine à états C++ du R4 est compilée pour le poste avec -Wall -Wextra -Werror et confrontée au modèle de référence Python : trames identiques au bit près, séquences de verdicts identiques, transitions et constantes identiques. Deux implémentations d'une même machine à états dérivent, à moins que quelque chose ne les compare.

Ce que l'audit de sécurité a réellement attrapé

Une passe plus profonde que la chaîne qualité courante : bandit à tous les niveaux de sévérité plutôt qu'à partir de moyen, balayage de secrets, recherche d'appels dangereux, revue des surfaces SQL et réseau, vérification de bornes sur le C++ ajouté à l'étape 11. Trois défauts, aucun dans les parties du système dont on parle habituellement.

Un appel moteur en échec était acquitté comme un succès. La carte gouvernée interceptait toute exception du pilote moteur et répondait CommandAck quand même, si bien que le journal enregistrait stm32_ack = 1, ce qui signifie accepté et exécuté, pour un mouvement qui n'avait pas eu lieu. C'est la forme exacte de l'erreur que ce projet existe pour refuser : le composant à qui l'on demande d'agir rapportant qu'il a agi. La réponse est désormais SYSTEM_FAULT, et la tentative reste au dossier puisque la ligne était écrite avant l'envoi.

alvik-firmware/main.py
    try:
        action_fn(alvik, msg.action_param)
    except Exception:
        # Caught rather than propagated: a motor driver fault must not take
        # the governance loop down with it. The board stays responsive and
        # says what happened.
        stdout.write(encode_reject(msg.audit_ref, REJ_SYSTEM_FAULT))
        stdout.flush()
        return

    stdout.write(encode_ack(msg.audit_ref))

Pourquoi il avait survécu. alvik-firmware/main.py n'avait aucun test : un import inconditionnel de la bibliothèque Arduino le rendait non importable sous CPython. Les quatre portes de gouvernance de la carte gouvernée étaient donc le code le moins testé du dépôt, tout en étant parmi les plus critiques pour la sécurité. L'import est maintenant optionnel et le fichier compte treize tests.

Le service d'écoute TCP n'avait aucun plafond de longueur. Il écoute sur 0.0.0.0:9100, sur la carte qui détient le journal d'audit, et lisait un préfixe de longueur de quatre octets venant d'un pair non authentifié avant toute autre chose. Un pair émettant 0xFFFFFFFF lui faisait accumuler quatre gibioctets. Une trame malformée s'échappait aussi de la boucle d'acceptation et terminait le service d'écoute. C'est la troisième occurrence de la même classe de bug dans ce dépôt. La règle mérite d'être écrite plutôt que corrigée une quatrième fois : une longueur qui arrive avant ses données est une promesse, et une promesse venant d'un pair non authentifié demande une borne.

Deux messages du protocole étaient dans la spécification et pas dans le portage. LATCH_REQUEST et LATCH_REPORT existaient dans le modèle Python et pas dans le firmware, si bien que l'arbitre ne pouvait ni rapporter la position du contact ni accepter une demande de la changer. Le défaut du harnais est le plus instructif : la suite de parité ne comparait que les messages implémentés des deux côtés, donc un message que le firmware n'avait jamais reçu restait invisible et la suite restait verte. Elle énumère désormais les types du modèle et échoue sur tout type absent du firmware. Un harnais qui ne vérifie que ce qui existe ne peut pas trouver ce qui manque.

Ce que l'audit n'a pas trouvé, à consigner pour que la passe suivante sache où l'on a déjà regardé : aucun secret dans l'arborescence, aucun appel à eval, exec, pickle, os.system, shell=True ou yaml.load, aucune requête SQL construite par interpolation, aucun signalement de bandit à quelque sévérité que ce soit après correction, aucune CVE connue dans les dépendances. Une observation plutôt qu'un signalement : les dépendances sont déclarées par version minimale plutôt qu'épinglées, donc une future version peut changer le comportement sans commit ici.

La chaîne qualité complète tourne en intégration continue, sans matériel : ruff, mypy en mode strict, bandit et pip-audit, tous propres. Le workflow .github/workflows/qa.yml exécute la cible make qa du dépôt à chaque demande de tirage et à chaque poussée sur main : la règle ne dépend plus de ce qu'un contributeur pense à lancer sur sa propre machine.

!Ce que ce projet ne résout pas

Ces limites appartiennent à toute présentation sérieuse du démonstrateur. Les omettre contredirait l'argument que le projet défend.

Rien n'a jamais été mis sous tension.

Aucune carte n'a exécuté ce firmware, aucun relais n'a été câblé, aucune caméra n'a été montée. Temporisation des broches, matrice de LED, débit série, rebond de contact, adéquation de l'impulsion de bobine et seuils des deux canaux de détection restent non testés. Chaque valeur de temporisation de la spécification de protocole est un objectif de conception, pas une mesure.

La chaîne d'audit n'est pas signée.

Elle détecte l'altération de lignes déjà attestées. Un hôte contrôlant à la fois la base et la liaison peut forger une chaîne cohérente sur les lignes écrites après compromission. La signature est l'étape 14.

La liaison de supervision vaut exactement ce que vaut le câble.

Quiconque peut y écrire peut forger un message levant le veto logiciel, et un test le démontre précisément. Le contact du relais, lui, n'est atteignable par aucun message : c'est pourquoi il y a deux chemins.

L'accès physique au câblage moteur contourne tout.

L'application physique suppose la garde physique du banc. Le contrôle vise la compromission logicielle et la défaillance de composant, pas quelqu'un muni d'un tournevis.

Le seuil de confiance de 0,70 est un jugement d'ingénieur.

Aucune norme publiée n'établit de correspondance entre un score de confiance et une probabilité de blessure en collaboration homme-robot. Et rien ici ne constitue une preuve de sécurité fonctionnelle au sens de l'ISO 13849 ou de la CEI 61508.

▸L'exécuter soi-même

Sans aucun matériel, en une vingtaine de minutes.

terminal
git clone https://github.com/thierrysays/governed-edge-ai.git
cd governed-edge-ai
python3 -m venv .venv && source .venv/bin/activate
pip install -r linux-stack/requirements.txt -r audit-service/requirements.txt
make qa

Puis la pile complète contre les pairs simulés :

terminal
cd linux-stack
PYTHONPATH=../audit-service python3 -m governance.ventuno_q_service \
    --alvik mock --supervisor mock --db /tmp/audit.db

Le mode sans matériel est un drapeau explicite, jamais un repli silencieux : --source synthetic, --alvik mock, --supervisor mock. La distinction compte, car un système qui basculerait en silence sur des données factices continuerait de produire des détections plausibles alors que la perception réelle est hors service.

Avec les cartes sur l'établi, docs/deployment-guide.md mène d'une machine non configurée à un banc vérifié : nomenclature, flashage des deux firmwares, câblage du relais dans l'alimentation moteur, unités systemd, et une procédure en neuf tests pour vérifier que les contrôles de gouvernance fonctionnent réellement. Elle ne suppose aucune expérience préalable de l'embarqué.

Lire le code source, lancer les tests, ouvrir une issue

Le démonstrateur complet est public et documenté : spécification d'architecture, modèle de menaces, guide de déploiement, cartographie des contrôles de gouvernance et journal des décisions, y compris celles qui se sont révélées fausses. Code sous Apache 2.0, documentation sous CC BY 4.0.

github.com/thierrysays/governed-edge-ai ↗