From 6a68fa5fe13a8fbb095e39cc558b04a65628f154 Mon Sep 17 00:00:00 2001 From: Roland Salardon Date: Fri, 21 Aug 2026 01:44:52 +0200 Subject: [PATCH] docs(phase8): preserve experimental private corpus executor Consolidate Phase 8 at Level 1. - document uv, Herdr, routing, worktrees and confidentiality findings; - preserve the private corpus executor, contracts, tests and EI subset wiring; - mark all private-corpus artifacts EXPERIMENTAL / NOT OPERATIONAL; - define the human roddy fallback with subset-first authorization; - record the systemd finalization debt for GPT-5.6 Sol / High; - exclude WvW, private logs, runtime artifacts, tokens and diagnostics. The private executor remains forbidden for the real private corpus. No GitHub governance or Phase 9 work is included. Signed-off-by: Roland Salardon --- .../spec-phase8-private-corpus-executor.md | 116 ++++ .../specs/spec-private-corpus-access/SPEC.md | 67 +++ .../private-access-protocol.md | 141 +++++ .../verification-and-git-guardrails.md | 63 +++ docs/agentic/README.md | 13 + docs/agentic/backlog.md | 26 + docs/agentic/current-state.md | 109 ++-- ...e8-environment-prerequisites-2026-08-20.md | 51 ++ .../phase8-final-checkpoint-2026-08-21.md | 122 +++++ docs/agentic/phase8-integration-manifest.md | 56 ++ docs/agentic/private-corpus-executor.md | 49 ++ ops/private-corpus/README.md | 32 ++ ops/private-corpus/contract.json | 13 + ops/private-corpus/executor.py | 494 ++++++++++++++++++ .../gw2analytics-private-corpus.sudoers | 3 + .../gw2analytics-private-corpus@.service | 24 + ops/private-corpus/private-test-registry.json | 9 + scripts/ei-parity/ei_diff.py | 60 ++- .../private_corpus/test_executor_contract.py | 271 ++++++++++ tests/private_corpus/test_mounted_input.py | 17 + tests/scripts/test_agentic_infrastructure.py | 72 +++ tools/install-private-corpus-executor.sh | 55 ++ 22 files changed, 1803 insertions(+), 60 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-phase8-private-corpus-executor.md create mode 100644 _bmad-output/specs/spec-private-corpus-access/SPEC.md create mode 100644 _bmad-output/specs/spec-private-corpus-access/private-access-protocol.md create mode 100644 _bmad-output/specs/spec-private-corpus-access/verification-and-git-guardrails.md create mode 100644 docs/agentic/phase8-environment-prerequisites-2026-08-20.md create mode 100644 docs/agentic/phase8-final-checkpoint-2026-08-21.md create mode 100644 docs/agentic/phase8-integration-manifest.md create mode 100644 docs/agentic/private-corpus-executor.md create mode 100644 ops/private-corpus/README.md create mode 100644 ops/private-corpus/contract.json create mode 100644 ops/private-corpus/executor.py create mode 100644 ops/private-corpus/gw2analytics-private-corpus.sudoers create mode 100644 ops/private-corpus/gw2analytics-private-corpus@.service create mode 100644 ops/private-corpus/private-test-registry.json create mode 100644 tests/private_corpus/test_executor_contract.py create mode 100644 tests/private_corpus/test_mounted_input.py create mode 100644 tools/install-private-corpus-executor.sh diff --git a/_bmad-output/implementation-artifacts/spec-phase8-private-corpus-executor.md b/_bmad-output/implementation-artifacts/spec-phase8-private-corpus-executor.md new file mode 100644 index 00000000..b77244d9 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-phase8-private-corpus-executor.md @@ -0,0 +1,116 @@ +--- + +> **EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS** +title: 'Exécuteur privé éphémère Phase 8' +type: 'feature' +created: '2026-08-20' +status: 'in-review' +baseline_commit: '175f1f7a4785145e5d91878d5728cc52a4422b29' +review_loop_iteration: 0 +context: + - '{project-root}/_bmad-output/specs/spec-private-corpus-access/SPEC.md' + - '{project-root}/_bmad-output/specs/spec-private-corpus-access/private-access-protocol.md' + - '{project-root}/_bmad-output/specs/spec-private-corpus-access/verification-and-git-guardrails.md' +--- + + + +## Intent + +**Problem:** Les validations réelles ont besoin de logs de combat privés, mais +`gw2agent`, les panes Herdr et les worktrees ne doivent jamais y accéder par +défaut. Aucun mécanisme exécutable et vérifiable ne matérialise encore cette +frontière. + +**Approach:** Installer un wrapper root-owned invoquable seulement par `roddy` +via sudo, qui valide une requête structurée à usage unique et démarre une unité +systemd éphémère. Elle monte un corpus synthétique de test en lecture seule, +dans son namespace, et n'exécute que trois profils fermés. + +## Boundaries & Constraints + +**Always:** Préserver `WvW/` sans lecture, énumération, déplacement, +modification ni permission; utiliser seulement un corpus synthétique hors +dépôt; `subset` est le défaut et `full` exige le jeton de confirmation explicite +prévu; refuser argv libre, shell, `bash -c`, worktree non enregistré et profil +inconnu; exécuter sous `gw2agent` avec groupe de lecture supplémentaire présent +seulement dans l'unité; monter les données read-only sous `/run`; journaliser +seulement les métadonnées non sensibles; supprimer runtime et autorisation sur +succès, erreur, annulation et reboot. + +**Ask First:** Toute installation ou modification hors des trois artefacts +root-owned explicitement listés, toute permission sur le corpus réel, toute +migration de WvW, tout premier accès réel, toute nouvelle commande autorisée, +ou toute sortie qui pourrait contenir des données privées. + +**Never:** ACL permanente ou appartenance permanente de `gw2agent` au groupe +privé; montage dans dépôt/worktree ou pane Herdr; copie persistante; profil +générique; GitHub, stash ou gouvernance Git/GitHub. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|---------------|---------------------------|----------------| +| subset | demande `subset`, sélection synthétique opaque, profil connu | unité isolée lit seulement la sélection et retourne un résumé non sensible | jeton consommé et runtime supprimé | +| full | demande `full` avec confirmation explicite | unité isolée lit le corpus synthétique entier read-only | refus sans confirmation `full` | +| refus | profil, worktree, référence ou argument non conforme | aucune unité ni montage; audit minimal | code d'erreur générique, sans chemin ni nom de fichier | +| interruption | timeout, kill ou échec du profil | arrêt de l'unité et retrait du runtime | aucune reprise automatique | + + + +## Code Map + +- `scripts/ei-parity/ei_diff.py` -- dépend aujourd'hui d'un corpus et d'un préflight full; fournir une entrée explicite subset sans rapport persistant. +- `libs/gw2_evtc_parser/src/gw2_evtc_parser/__main__.py` -- CLI existante; profils privés construisent son argv et n'exposent pas `inspect-zip` ou `dump-agents`. +- `tests/scripts/test_agentic_infrastructure.py` -- garde-fous documentaires existants à étendre sans données réelles. +- `.gitignore` -- ignore déjà certains artefacts EI; compléter plus tard, sans la traiter comme barrière suffisante. + +## Tasks & Acceptance + +**Execution:** +- [ ] `ops/private-corpus/` -- ajouter les sources versionnées : schéma de requête, registre privé de test, profils fermés, wrapper et template systemd. +- [ ] `tools/install-private-corpus-executor.sh` -- installer de façon idempotente les trois artefacts root-owned autorisés : wrapper, template système et règle sudo limitée à `roddy`. +- [ ] `scripts/ei-parity/ei_diff.py` -- accepter un répertoire/manifest explicitement injecté et permettre une validation subset sans préflight full ni sortie persistante. +- [ ] `tests/private_corpus/` -- créer corpus, sélection, autorité et runner synthétiques; vérifier refus, subset, full, lecture seule, isolation, nettoyage et redaction. +- [ ] `tests/scripts/test_agentic_infrastructure.py` et documentation Phase 8 -- verrouiller les invariants, profils et chemins runtime sans révéler de donnée réelle. + +**Acceptance Criteria:** +- Given `gw2agent` hors unité, when it sonde le corpus synthétique, then l'accès est refusé et aucun runtime privé n'est visible. +- Given une autorisation subset valide, when le profil `parser-validation-readonly` s'exécute, then seule la sélection est lisible et toute écriture échoue. +- Given une demande full sans confirmation explicite, when le wrapper la valide, then aucune unité n'est créée; with confirmation explicite, then le profil fermé s'exécute sur le corpus synthétique entier. +- Given un profil inconnu, argv libre, shell, worktree hors registre ou jeton réutilisé, when le wrapper reçoit la demande, then il échoue avant tout montage. +- Given fin, erreur, timeout ou reboot simulé, when l'exécution s'arrête, then montage, runtime, sortie brute et autorisation sont absents. +- Given le profil EI subset, when il reçoit un chemin et manifeste synthétiques injectés, then il n'exige pas le corpus complet et conserve ses rapports détaillés dans le runtime. + +## Design Notes + +Profils initiaux : `parser-validation-readonly` exécute des tests parser +ciblés; `ei-parity-readonly` exécute seulement la comparaison EI à entrées et +manifest injectés; `pytest-private-fixture` exécute un sous-ensemble pytest +borné. Les trois profils construisent l'argv eux-mêmes et retournent seulement +un statut et des compteurs non sensibles. + +## Verification + +**Commands:** +- `uv run pytest tests/private_corpus tests/scripts/test_agentic_infrastructure.py -q` -- attendu : tous les garde-fous synthétiques passent. +- `uv run ruff check ops/private-corpus tools/install-private-corpus-executor.sh scripts/ei-parity/ei_diff.py tests/private_corpus` -- attendu : aucune violation applicable. +- `uv run ruff format --check ops/private-corpus tools/install-private-corpus-executor.sh scripts/ei-parity/ei_diff.py tests/private_corpus` -- attendu : format conforme. + +**Manual checks (if no CLI):** +- Sous `roddy`, valider la liste sudo exacte et la création/arrêt d'une unité de test; inspecter uniquement les métadonnées de montage et d'audit synthétiques. + +## Statut de clôture Phase 8 + +**EXPERIMENTAL / NOT OPERATIONAL.** Les contrats, tests locaux et diagnostics +synthétiques sont conservés, mais les essais hôte `subset` et `full` retournent +encore `unit-failed/service`. Ne pas utiliser cet exécuteur avec `WvW/` réel. +Le repli temporaire est l'exécution d'une commande précise sous `roddy` après +autorisation humaine explicite, avec `subset` par défaut, `full` séparément +confirmé, résultat redacted seulement et nettoyage des temporaires. + +La reprise future doit d'abord distinguer `systemd-start`, `sandbox-bind`, +`tool-exec` et `profile-exit` avec les seuls statut et code retour redacted, +puis satisfaire les critères de passage à OPERATIONAL du checkpoint Phase 8. +Le statut `in-review` signifie que cette base n'est pas un livrable accepté ni +une autorisation d'intégration opérationnelle. diff --git a/_bmad-output/specs/spec-private-corpus-access/SPEC.md b/_bmad-output/specs/spec-private-corpus-access/SPEC.md new file mode 100644 index 00000000..ac1e5bba --- /dev/null +++ b/_bmad-output/specs/spec-private-corpus-access/SPEC.md @@ -0,0 +1,67 @@ +--- +id: SPEC-private-corpus-access +companions: + - private-access-protocol.md + - verification-and-git-guardrails.md +sources: [] +--- + +> **Contrat canonique.** Cette SPEC et les fichiers de `companions:` définissent entièrement ce qui doit être construit, testé et validé. + +> **EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS** + +# Accès contrôlé au corpus privé WvW + +## Why + +Gw2Analytics doit pouvoir valider le parser, l'ingestion et la parité Elite Insights sur des combats réels sans faire du corpus privé une dépendance accessible par défaut de l'environnement de développement. Le maintien de cette frontière protège les données tout en évitant que les validations fondées sur des logs réels deviennent impraticables. + +## Capabilities + +- **CAP-1** + - **intent:** Le Lead peut demander un accès privé lié à une tâche, et le mainteneur peut autoriser un accès `subset` par une confirmation simple. + - **success:** La demande lie un identifiant de tâche, un objectif, un worktree, un profil de commande et le scope `subset`; une réponse « Oui » du mainteneur suffit sans manipulation de groupe, montage ou unité système. +- **CAP-2** + - **intent:** Une tâche normale peut consommer uniquement une sélection privée de logs en lecture seule. + - **success:** La sélection est résolue hors dépôt depuis une référence opaque, seuls ses éléments sont visibles au processus autorisé, et ni noms, ni chemins, ni contenu, ni empreintes du corpus ne figurent dans Git, les handoffs ou le journal d'audit. +- **CAP-3** + - **intent:** Le mainteneur peut autoriser exceptionnellement l'ensemble du corpus lorsqu'une analyse globale le justifie. + - **success:** Une demande `full` contient une justification concrète et exige une confirmation renforcée distincte; l'exposition reste limitée au processus éphémère, en lecture seule, et disparaît ensuite. +- **CAP-4** + - **intent:** Les outils parser, EI et ingestion peuvent recevoir des données privées sans connaître de chemin privé par défaut. + - **success:** Chaque outil compatible reçoit un chemin d'entrée explicitement injecté; ses sorties par défaut sont limitées au runtime ou à un environnement de test éphémère, et aucune donnée de corpus n'est écrite dans le worktree, Git, une base ou un stockage persistant sans une autorisation distincte. +- **CAP-5** + - **intent:** L'exécution privée reste isolée, révocable et traçable sans journaliser de données de combat. + - **success:** Une unité systemd dédiée ne donne au processus que le groupe de lecture temporaire et un bind mount read-only; fin, échec, annulation, redémarrage ou arrêt de l'unité rendent le corpus à nouveau inaccessible et ne laissent que l'audit minimal non sensible. +- **CAP-6** + - **intent:** Le mainteneur peut démontrer les garde-fous avant d'autoriser l'usage productif du mécanisme. + - **success:** Les contrôles décrits dans `verification-and-git-guardrails.md` prouvent le refus hors exécuteur, les scopes `subset` et `full`, la lecture seule, le nettoyage, le traitement des interruptions et l'absence de chemin de fuite Git. + +## Constraints + +- Le corpus maître reste hors dépôt, détenu par `roddy`, et `gw2agent` n'y possède aucun accès permanent, y compris par ACL ou groupe permanent. +- Le seul chemin d'accès est un exécuteur systemd éphémère, lancé par une autorité système administrée; un pane Herdr, une session Codex ordinaire ou un worktree ne reçoit jamais de montage persistant. +- L'exécuteur utilise un namespace de montage, un bind mount read-only et une identité `gw2agent` complétée seulement pendant l'exécution par un groupe de lecture privé non permanent. +- Le montage n'est jamais placé dans le dépôt ou dans un worktree; son chemin runtime est opaque, sous `/run/gw2analytics-private/`, et disparaît avec l'unité ou au reboot. +- `subset` est le défaut. `full` exige une justification globale et une confirmation explicite renforcée; aucun fallback implicite de `subset` vers `full` n'est permis. +- La résolution d'une sélection se fait exclusivement dans un registre privé root-owned, hors dépôt; le contrat versionné ne contient aucun nom de log, chemin, hash, extrait ou contenu de combat. +- Seuls des profils de commande approuvés peuvent être exécutés. Un shell général, l'export brut de stdout/stderr vers le journal système et la copie vers le worktree sont interdits par défaut. +- Les données privées ne sont jamais ajoutées à Git. Les protections Git complémentaires restent un chantier ultérieur, décrit mais non implémenté par cette SPEC. +- Cette Phase 8 reste au Level 1 : la SPEC autorise une future proposition d'implémentation, jamais une migration, un changement de permission, un service ou une configuration GitHub sans accord ultérieur. + +## Non-goals + +- Déplacer `WvW/`, créer l'unité systemd, modifier des permissions, créer un groupe ou monter le corpus dans cette phase de spécification. +- Donner à tous les processus de `gw2agent`, à tous les worktrees ou à Herdr un accès direct au corpus. +- Construire un gestionnaire de secrets, une plateforme de data catalog ou une orchestration de conteneurs. +- Versionner, archiver durablement ou redistribuer les logs, exports EI, sélections privées ou leurs dérivés. +- Commencer Git/GitHub Governance & Delivery Architecture, configurer GitHub ou intervenir sur un stash. + +## Success signal + +Un mainteneur peut autoriser une validation réelle par un « Oui » pour un `subset` ou une confirmation renforcée pour un `full`; seule la commande approuvée dans le worktree visé voit les données read-only, puis cet accès disparaît automatiquement sans fuite dans le dépôt ni dans l'audit. + +## Open Questions + +- Quelle autorité locale root-owned (service template, wrapper restreint ou règle polkit/sudo) est la plus adaptée à l'hôte lors de la future phase d'implémentation, après inspection de sa politique système ? +- Quels profils de commande initiaux doivent être admis pour parser, EI et ingestion, et quelle sortie agrégée non sensible chacun doit-il retourner au Lead ? diff --git a/_bmad-output/specs/spec-private-corpus-access/private-access-protocol.md b/_bmad-output/specs/spec-private-corpus-access/private-access-protocol.md new file mode 100644 index 00000000..844431ad --- /dev/null +++ b/_bmad-output/specs/spec-private-corpus-access/private-access-protocol.md @@ -0,0 +1,141 @@ +# Protocole privé d'accès au corpus + +> **EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS** + +## Invariants d'architecture + +```text +corpus maître hors dépôt (roddy, groupe de lecture privé) + │ aucune appartenance permanente de gw2agent + │ autorisation humaine liée à une tâche + ▼ +autorité système root-owned + │ résout une sélection privée ou le scope full + ▼ +unité systemd éphémère + ├─ User=gw2agent + SupplementaryGroups= + ├─ namespace de montage isolé + ├─ BindReadOnlyPaths=: + ├─ worktree autorisé, persistant et distinct du montage + └─ profil de commande autorisé + ▼ +résultat autorisé non sensible → Lead +fin/unité arrêtée/reboot → montage + runtime supprimés +``` + +Le groupe de lecture privé est une capacité du processus systemd, pas une +capacité de connexion de `gw2agent`. Le corpus maître est possédé par `roddy`; +ses permissions de groupe permettent la lecture à cette seule identité +supplémentaire. `gw2agent` ne devient jamais membre permanent de ce groupe et +ne reçoit aucune ACL durable ou temporaire sur le corpus. + +L'unité est créée par une autorité locale administrée, root-owned. Elle +applique au minimum : `User=gw2agent`, groupe supplémentaire privé, +`BindReadOnlyPaths=`, namespace de montage privé, protection des homes, +réduction des chemins inscriptibles au worktree explicitement autorisé et au +runtime, et `NoNewPrivileges=`. Les options exactes sont figées lors de la +future implémentation, contre la version de systemd effectivement installée. + +Le processus voit les données sous +`/run/gw2analytics-private//input`. `` est aléatoire ou +opaque et ne contient ni nom de corpus, ni nom de combat. Ce chemin n'existe ni +dans le worktree, ni pour les processus ordinaires de `gw2agent`. + +## Demande, autorisation et UX + +Le Lead formule une demande concise, sans données privées : + +```yaml +task_id: stable-id +purpose: validation parser|ei|ingestion|reproduction|analyse +scope: subset|full +why_private_data: justification orientée résultat +worktree_ref: identifiant ou chemin de worktree autorisé +command_profile: parser-tests|ei-parity|ingestion-ephemeral|diagnostic-approuvé +selection_ref: opaque-private-ref # obligatoire seulement pour subset +requested_output: résultat agrégé non sensible +``` + +Pour `subset`, le Lead présente : « Cette tâche nécessite l'accès à des logs +privés. Je recommande un accès subset en lecture seule pour cette validation. +Autorisez-vous cet accès ? » Le mainteneur peut répondre « Oui ». + +Pour `full`, le Lead présente le motif global, l'absence d'alternative subset, +le worktree et le profil de commande : « Cette analyse nécessite l'ensemble du +corpus privé en lecture seule. Confirmez-vous explicitement l'accès `full` pour +la tâche `` ? » Une réponse générique « Oui » ne vaut pas confirmation +`full`; la confirmation doit contenir `full` ou reprendre l'identifiant de +tâche et le scope. + +La demande autorisée est consommable une fois, avec une durée maximale bornée. +Une nouvelle commande, un nouveau worktree, un autre scope ou une reprise après +expiration créent une nouvelle demande. Le mainteneur ne manipule ni groupes, +ni montages, ni unités. + +## Sélection privée + +Une sélection `subset` est définie dans un registre privé hors dépôt, accessible +seulement à l'autorité système et au mainteneur. `selection_ref` est un jeton +opaque associé à une liste de fichiers ou de répertoires et à une durée; il ne +révèle aucune entrée par sa valeur, par le journal ni par le handoff. + +L'autorité vérifie que la sélection reste sous le corpus maître, ne suit aucun +lien sortant, et monte uniquement les éléments résolus. Le sous-ensemble peut +être un ou plusieurs fichiers ou un répertoire nécessaire à l'outil. `full` +monte la racine du corpus maître seulement après confirmation renforcée. + +La sélection n'est ni copiée ni matérialisée dans le dépôt. Le bind mount est +préféré à un staging de données : il ne crée pas de seconde copie. Un staging +temporaire sur runtime peut être autorisé uniquement lorsqu'un outil ne sait pas +consommer un montage direct; il doit être en mémoire ou dans un espace runtime +privé, supprimé avec l'unité, et faire l'objet d'une autorisation documentée. + +## Exécution, sorties et révocation + +L'autorité refuse tout argv libre. Un `command_profile` désigne une commande +connue avec paramètres bornés, workdir contrôlé et chemin d'entrée injecté par +la plateforme. Le profil reçoit le runtime input par argument ou variable +explicitement définie; il ne déduit aucun chemin privé par défaut. + +Les sorties de processus sont privées par défaut : stdout/stderr brut est +capturé dans le runtime et détruit lors du nettoyage. Le profil retourne au Lead +seulement son résultat contractuel non sensible : statut, compteurs agrégés, +identifiant de tâche et diagnostic sans nom ni contenu de log. Toute sortie +porteuse de données privées exige une décision humaine distincte, hors du flux +normal. + +Les sorties temporaires vivent sous le runtime de l'unité. Un profil +`ingestion-ephemeral` utilise en plus une base, un stockage et une file de test +éphémères. Ni upload, ni export EI, ni rapport détaillé ne peut être écrit dans +le worktree ou dans un stockage persistant par défaut. + +L'unité est `stop`pée à la fin, à l'expiration ou à l'annulation. Le runtime est +supprimé avec elle. Au reboot, les unités transient et `/run` disparaissent; un +worktree persistant n'emporte donc jamais le montage. En cas d'échec ou +d'interruption, l'autorité stoppe l'unité, vérifie l'absence de montage et de +runtime, enregistre le résultat minimal, puis refuse toute reprise automatique. + +Le rollback est l'arrêt de l'unité et l'invalidation de l'autorisation. Si une +future configuration de groupe, permissions ou service s'avère incorrecte, +elle est retirée par une procédure administrée séparée; aucune réparation ne +doit ouvrir le corpus à `gw2agent` par défaut. + +## Intégrations outils et worktrees + +Les CLI parser, EI et ingestion doivent accepter un chemin de données privé +explicite, sous forme d'option `--corpus-dir`/`--input-dir` ou d'une variable +injectée par le profil. Sans injection, elles ne trouvent aucun corpus réel et +échouent avec un message ne révélant pas de chemin privé. Les chemins implicites +privés existants doivent être retirés lors de la phase d'implémentation, avec +compatibilité documentée pour les fixtures synthétiques. + +Les profils initiaux sont : tests parser sur sélection, parité EI sur sélection, +parité EI globale, reproduction contrôlée et ingestion éphémère. Chaque profil +déclare ses entrées, sorties autorisées, services de test éphémères et règle de +nettoyage avant d'être activé. + +Herdr ne reçoit jamais le montage dans un pane normal. Lorsqu'un worker doit +valider des données privées, le Lead lie la demande à son worktree persistant +et déclenche le profil via l'exécuteur. Un autre worktree, pane ou agent sous +le même UID ne voit pas les données : l'isolation est celle du processus et de +son namespace, non une promesse fondée sur le seul chemin du worktree. diff --git a/_bmad-output/specs/spec-private-corpus-access/verification-and-git-guardrails.md b/_bmad-output/specs/spec-private-corpus-access/verification-and-git-guardrails.md new file mode 100644 index 00000000..a0685085 --- /dev/null +++ b/_bmad-output/specs/spec-private-corpus-access/verification-and-git-guardrails.md @@ -0,0 +1,63 @@ +# Vérification et garde-fous Git + +> **EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS** + +## Matrice de preuve avant activation + +| ID | Preuve | Méthode sans corpus réel | Verdict attendu | +| --- | --- | --- | --- | +| V-1 | Refus par défaut | Exécuter une sonde sous `gw2agent` hors unité contre un corpus de test administré. | Accès refusé; aucun point de montage runtime visible. | +| V-2 | Scope subset | Autoriser une sélection de test à plusieurs entrées et demander un seul élément. | L'élément autorisé est lisible dans l'unité; les autres sont absents et le corpus source reste invisible. | +| V-3 | Scope full | Autoriser explicitement `full` sur un corpus de test. | La racine est visible seulement dans l'unité et seulement pour la durée autorisée. | +| V-4 | Lecture seule | Tenter création, modification, renommage et suppression via le chemin runtime. | Toutes échouent; le corpus de test est inchangé. | +| V-5 | Isolation processus | Lancer simultanément une sonde `gw2agent` hors unité et une tâche autorisée. | Seule la tâche autorisée lit le montage. | +| V-6 | Nettoyage et expiration | Laisser expirer, annuler et tuer une unité de test. | Plus aucun montage ni runtime; le jeton ne peut être réemployé. | +| V-7 | Reboot | Créer une unité de test puis redémarrer l'hôte dans une fenêtre de maintenance. | L'unité et `/run` ont disparu; le worktree demeure sans accès. | +| V-8 | Sorties | Faire produire par le profil une sortie de test marquée sensible. | Elle reste runtime et n'apparaît ni dans journal d'audit, ni worktree, ni journal système standard. | + +Ces tests utilisent un corpus factice administré. Ils ne lisent ni n'énumèrent +le corpus WvW réel. Après réussite, une validation manuelle limitée sur le +corpus réel peut seulement vérifier le profil demandé et son résultat agrégé; +elle ne verse aucun détail dans le rapport versionné. + +## Audit minimal + +Le journal root-owned contient seulement : timestamp, `task_id`, demandeur, +scope `subset|full`, profil de commande, identité opaque du worktree, décision, +début, fin, résultat et motif d'échec générique. Il exclut noms, chemins, +compteurs de fichiers, empreintes, contenu, stdout/stderr et arguments privés. + +L'autorisation et la sélection sont stockées hors dépôt. Leur expiration et +leur consommation unique sont vérifiables dans ce stockage privé sans que les +données de corpus rejoignent Git ou les checkpoints. + +## Protections Git à implémenter ultérieurement + +1. Le runtime privé reste hors du dépôt; aucun montage ne cible le worktree. +2. Les règles d'ignorance couvrent les répertoires de données privées et les + extensions de combat pertinentes, mais ne sont jamais la seule barrière. +3. Un contrôle pré-commit inspecte les chemins et contenus indexés et refuse + les signatures ou formats de logs définis par la politique, y compris un + ajout forcé connu. +4. La CI vérifie les règles et les tests de garde-fou sans nécessiter le + corpus. Elle refuse toute fixture réelle ou référence à un chemin privé. +5. Les profils privés écrivent leurs outputs exclusivement au runtime; les + tests vérifient qu'aucun fichier de corpus ou dérivé n'apparaît dans le + worktree après exécution. + +Ces protections sont une phase séparée : elles ne donnent pas l'autorisation +de modifier Git, GitHub, le stash ni les workflows de gouvernance dans cette +SPEC. + +## Critères d'acceptation d'implémentation + +- Les V-1 à V-6 passent avant la première autorisation de corpus réel. +- V-7 est exécuté lors d'une maintenance planifiée avant de déclarer le + mécanisme opérationnel après reboot. +- Aucun outil ne conserve un chemin privé par défaut; parser, EI et ingestion + documentent leur option ou variable explicite et conservent les tests + synthétiques sans corpus. +- Un subset autorisé peut être validé par un « Oui »; un full est refusé sans + confirmation textuelle explicite de son scope. +- Une revue indépendante confirme que le journal et les artefacts de test ne + contiennent aucune donnée réelle ou identifiant sensible. diff --git a/docs/agentic/README.md b/docs/agentic/README.md index 9818c2f3..bc10f09c 100644 --- a/docs/agentic/README.md +++ b/docs/agentic/README.md @@ -40,6 +40,16 @@ Supermemory n'est pas une source opérationnelle. `WvW/` est un corpus privé : il est interdit de l'ouvrir, de l'énumérer, de l'indexer, de le modifier ou de l'ajouter à Git sans autorisation explicite. +## Prérequis de l'environnement `gw2agent` + +- `uv` fait partie des prérequis de l'environnement `gw2agent`. La version + épinglée est `0.12.5`, fournie par `mise` ; les outils Python du dépôt sont + exécutés via `uv run`. +- Le sandbox Codex peut refuser l'accès au socket Herdr. Les contrôles + read-only de disponibilité de Herdr sont alors réalisés hors sandbox sous + `gw2agent` ; ce refus ne constitue pas une anomalie Herdr ni une autorisation + d'écriture. + ## Documents opérationnels - [Architecture](architecture.md) — composants et frontières. @@ -49,3 +59,6 @@ l'ajouter à Git sans autorisation explicite. - [Worktrees et Herdr](worktrees-herdr.md) — flux macro indépendants. - [Backlog agentique](backlog.md) — initiatives à traiter ultérieurement. - [État courant](current-state.md) — reprise après interruption. +- [Checkpoint final Phase 8](phase8-final-checkpoint-2026-08-21.md) — + prérequis, confidentialité, repli humain et statut expérimental de + l'exécuteur privé. diff --git a/docs/agentic/backlog.md b/docs/agentic/backlog.md index 11a2f29c..41f2b088 100644 --- a/docs/agentic/backlog.md +++ b/docs/agentic/backlog.md @@ -29,3 +29,29 @@ pour commit, push, PR, merge et release aux Levels 1/2/3. Elle doit inventorier l'état réel, proposer les changements avant de les faire, et aligner code, CI, documentation et règles GitHub sans les modifier pendant la Phase 5. + +## Dette technique — Exécuteur privé Phase 8 + +> **EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS** + +**Statut : EXPERIMENTAL / NOT OPERATIONAL, reportée.** Ne pas autoriser le +corpus réel via l'exécuteur tant que les validations live synthétiques ne sont +pas concluantes. Reprise uniquement après accord explicite, en commençant par +l'observabilité redacted `systemd-start` / `sandbox-bind` / `tool-exec` / +`profile-exit`, puis namespace, bind UV root-owned, `.venv`, cache, lecture +seule, nettoyage et reboot. Le repli humain `roddy` demeure le mécanisme +temporaire autorisé par tâche. + +Avant toute intégration opérationnelle, traiter aussi : autorisation par tâche +matérialisée, redaction sans champ libre persistant, cause primaire préservée +au cleanup, contrôle du groupe à chaque demande, sockets locaux, worktree +mutable, sous-ensemble qui résout vers la racine, binaires système absolus et +purge autonome des diagnostics. + +### Private Corpus Executor Finalization — GPT-5.6 Sol / High + +Tâche système dédiée, explicitement non bloquante pour GW2Analytics. Le Lead +demande `GPT-5.6 Sol / High` à chaque reprise et, lors de tout `codex exec +resume`, réapplique explicitement modèle et reasoning effort avant diagnostic. +Il contrôle le runtime observé ; toute divergence impose l'arrêt avant +diagnostic et une demande d'instruction. diff --git a/docs/agentic/current-state.md b/docs/agentic/current-state.md index f5ce6f4c..15880963 100644 --- a/docs/agentic/current-state.md +++ b/docs/agentic/current-state.md @@ -1,50 +1,63 @@ # État courant agentique -## Phase 6 — Validation réelle de l'infrastructure - -- **Statut :** corrections des findings Phase 6 réalisées ; livrable candidat - à une seconde review indépendante. Phase 7 interdite sans accord. -- **Base :** `72ec29fd4eb71c41f02ff2bf1ccbff7c4468dc9c` sur `main`. -- **Objectif :** démontrer les profils et politiques dans Codex/Herdr réels, - sans développement fonctionnel. -- **Profil Lead initial :** Codex / `gpt-5.6-terra` / `medium`. -- **Autonomie :** Level 1 pour tous les domaines. -- **Fallback :** non configuré et non déclaré opérationnel. -- **Confidentialité :** `WvW/` interdit à toute lecture, énumération, - indexation, modification ou ajout Git sans autorisation explicite. -- **Prérequis utilisateur local :** l'entrée de confiance Codex du dépôt est - présente et valide dans la configuration utilisateur ; son contenu hors de - cette entrée ne fait pas partie du dépôt. - -## Dettes acceptées de la revue Phase 5 - -Ajustements réalisés, en attente de seconde review indépendante avant toute -promotion au-delà du Level 1 : tests sémantiques des garde-fous (Reviewer, -`WvW/`, fallback, handoff et reprise), critères objectivables pour Ultra, et -procédure worktree de conflit ou d'abandon. - -## Validations - -- PASS — `uv run pytest tests/scripts/test_agentic_infrastructure.py - tests/scripts/test_bmad_framework.py` : 11 tests. -- PASS — Ruff ciblé sur les deux tests d'infrastructure. -- PASS — Codex réel : Lead `gpt-5.6-terra` / `medium`, interaction read-only, - profils custom et handoff Explorer → Reviewer validés. -- PASS — Herdr 0.8.0 : socket, pane temporaire, Codex read-only, état et - résultat récupérés, puis pane supprimé. -- PASS — reprise fresh-context : une session Codex éphémère reconstruit Phase - 6, Level 1, confidentialité, fallback, dettes et prochaine action depuis les - documents versionnés. -- PASS — interfaces Codex/Herdr : CLI Codex 0.148.0 ; Herdr 0.8.0 expose les - commandes d'intégration et de worktrees documentées. -- PASS — résolveurs `resolve_config.py` et `resolve_customization.py` BMAD. -- PASS — `git diff --check`. -- BLOCKED — spawn dans `codex exec --ephemeral` : ce mode ne fournit pas de - thread parent au routeur. Le même test persistant read-only est passé. -- NOT RUN — worktree temporaire : non requis pour le flux Herdr sans écriture. - -## Reprise - -Faire la seconde review indépendante read-only des ajustements Phase 6. Ne pas -créer de worktree d'écriture, modifier les niveaux d'autonomie ou configurer un -fallback sans accord explicite. +> **EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS** + +## Phase 8 — clôture Level 1 + +- **Statut :** décision Phase 8 clôturée au Level 1 ; l'implémentation de + l'exécuteur privé reste en review expérimentale, **EXPERIMENTAL / NOT + OPERATIONAL**, et ne doit jamais être utilisée avec `WvW/` réel. +- **Autonomie :** Level 1 pour tous les domaines, inchangé. +- **Confidentialité :** `WvW/` n'a pas été ouvert, énuméré, indexé, déplacé, + modifié ou ajouté à Git. `gw2agent`, Herdr et les worktrees n'ont aucun + accès permanent au corpus. +- **Mécanisme temporaire autorisé :** repli humain `roddy`, par tâche + explicitement approuvée : `subset` par défaut, `full` après confirmation + distincte, commande précise exécutée sous `roddy`, résultat redacted seul + partagé avec les agents, puis nettoyage des temporaires. + +## Findings consolidés + +| Domaine | Statut | Décision opérationnelle | +| --- | --- | --- | +| Routing et rôles | PASS | Lead stable ; séparation intention/autorisation conservée. | +| Worktrees persistants | PASS | Worktrees séparés du corpus ; aucun montage privé persistant. | +| Confidentialité | PASS | Corpus privé absent des accès par défaut et du dépôt. | +| `uv` | PASS | `uv 0.12.5` via Mise est un prérequis `gw2agent`; Python passe par `uv run`. | +| Herdr | PASS avec limite connue | Le socket peut être refusé par le sandbox Codex ; contrôles read-only hors sandbox sous `gw2agent`. | +| Exécuteur privé avancé | EXPERIMENTAL / NOT OPERATIONAL | Sources, contrats et tests synthétiques conservés ; aucun accès réel autorisé. | +| Stash `codex-pre-phase7-main-sync` | ABSENT / NON APPLIQUÉ | Aucun ref `stash` n'est présent lors de ce checkpoint ; aucun patch n'a été appliqué ni inspecté. | + +## Validations Phase 8 + +- PASS — prérequis `uv 0.12.5`, tests d'infrastructure agentique et framework + BMAD ; voir `phase8-environment-prerequisites-2026-08-20.md`. +- PASS — tests de contrat de l'exécuteur synthétique, garde-fous documentaires, + Ruff, formatage, syntaxe Bash et `git diff --check`. +- PASS — diagnostics synthétiques root-owned : création atomique, redaction, + TTL et lecture fermée sous `roddy`. +- PASS — demandes synthétiques `subset` et `full` validées jusqu'à l'étape + service ; les jetons sont à usage unique. +- NOT OPERATIONAL — l'unité retourne encore `unit-failed/service` pour les + deux scopes. Son code retour effectif n'est pas suffisamment observé pour + justifier une nouvelle correction. + +## Dette et reprise + +Ne pas reprendre le debug systemd sans accord humain explicite. La reprise doit +d'abord ajouter une observabilité fermée distinguant `systemd-start`, +`sandbox-bind`, `tool-exec` et `profile-exit`, avec seulement +`ExecMainStatus`, code retour et profil logique redacted. Elle devra ensuite +prouver en live synthétique namespace, bind UV root-owned, `.venv`, cache, +lecture seule, nettoyage succès/échec/interruption et reboot avant tout accès +réel. + +Les artefacts expérimentaux ne sont pas recommandés pour intégration +opérationnelle tant que les tests live et les garde-fous de l'exécuteur ne sont +pas satisfaits. Leur présence non suivie est volontaire à ce stade : une future +décision d'intégration devra examiner explicitement leur inventaire et les +findings de review. + +Le checkpoint détaillé est +`phase8-final-checkpoint-2026-08-21.md`. Git/GitHub Governance & Delivery +Architecture reste non commencée. diff --git a/docs/agentic/phase8-environment-prerequisites-2026-08-20.md b/docs/agentic/phase8-environment-prerequisites-2026-08-20.md new file mode 100644 index 00000000..8dac0bfc --- /dev/null +++ b/docs/agentic/phase8-environment-prerequisites-2026-08-20.md @@ -0,0 +1,51 @@ +# Checkpoint historique — Phase 8 : prérequis d'environnement + +> Ce checkpoint couvre uniquement la reprise initiale de `uv`. Son état courant +> est consolidé dans `phase8-final-checkpoint-2026-08-21.md` : l'exécuteur +> privé est **EXPERIMENTAL / NOT OPERATIONAL** et le repli humain `roddy` est +> le mécanisme temporaire autorisé. + +> **EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS** + +## Statut + +Phase 8, Level 1. Reprise limitée aux validations précédemment bloquées par +l'absence de `uv` et à la documentation du prérequis correspondant. Aucune +étape de Git/GitHub Governance n'est commencée. + +## Décisions conservées + +- `uv 0.12.5`, fourni par `mise` sous l'environnement `gw2agent`, est un + prérequis pour exécuter les outils Python du dépôt avec `uv run`. +- Le sandbox Codex peut refuser l'accès au socket Herdr. Les contrôles read-only + de disponibilité Herdr sont effectués hors sandbox sous `gw2agent`. Ce refus + ne signale pas un échec de Herdr et ne modifie pas les limites Level 1. + +## Validations reprises + +| Validation | État | Preuve | +| --- | --- | --- | +| Disponibilité `uv` | PASS | `uv 0.12.5 (x86_64-unknown-linux-musl)` | +| Tests d'infrastructure agentique et du framework BMAD | PASS | `uv run pytest tests/scripts/test_agentic_infrastructure.py tests/scripts/test_bmad_framework.py --tb=short -q` : 11 tests | +| Ruff ciblé | PASS | `uv run ruff check tests/scripts/test_agentic_infrastructure.py tests/scripts/test_bmad_framework.py` | +| Format Ruff ciblé | PASS | `uv run ruff format --check tests/scripts/test_agentic_infrastructure.py tests/scripts/test_bmad_framework.py` : 2 fichiers déjà formatés | + +La première tentative dans le sandbox Codex a été bloquée seulement parce que +`uv` ne pouvait pas créer son cache dans `/home/gw2agent/.cache/uv`. Les mêmes +validations ont donc été exécutées hors sandbox sous `gw2agent`, où `uv` a créé +l'environnement requis et elles ont passé. + +## Périmètre explicitement non touché + +- `WvW/` n'a pas été ouvert, énuméré, indexé, déplacé, modifié ou ajouté à Git. +- Aucun stash n'a été consulté ou modifié. +- Aucune configuration GitHub ni activité Git/GitHub Governance n'a été + commencée. + +## Décision ultérieure consignée + +Le mainteneur a ensuite approuvé une implémentation synthétique de la cible +« corpus maître + exécuteur privé éphémère ». Les artefacts obtenus sont +conservés pour reprise, mais aucun accès réel, migration de `WvW/`, changement +de permission du corpus maître ni activité Git/GitHub n'est autorisé par ce +checkpoint. diff --git a/docs/agentic/phase8-final-checkpoint-2026-08-21.md b/docs/agentic/phase8-final-checkpoint-2026-08-21.md new file mode 100644 index 00000000..1e11ff6e --- /dev/null +++ b/docs/agentic/phase8-final-checkpoint-2026-08-21.md @@ -0,0 +1,122 @@ +# Checkpoint final — Phase 8 + +> **EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS** + +## Décision de clôture + +La décision Phase 8 est clôturée au **Level 1**. L'exécuteur privé systemd reste +**EXPERIMENTAL / NOT OPERATIONAL** : il est interdit avec le corpus réel, +incluant `WvW/`, jusqu'aux critères de passage à OPERATIONAL ci-dessous. + +Les artefacts `ops/private-corpus/`, l'installateur, les tests et la +documentation sont conservés comme base auditable de reprise. Ils ne sont pas +une autorisation d'accès réel, de migration de `WvW/`, de changement de +permission ou de lancement de service pour le corpus maître. + +## Mécanisme temporaire : repli humain `roddy` + +Une tâche nécessitant des logs privés suit ce protocole simple : + +1. Le Lead indique le besoin, le profil précis et recommande `subset`. +2. Le mainteneur autorise explicitement la tâche ; `full` requiert une + confirmation séparée et explicite. +3. `roddy` exécute une commande précise et limitée dans le worktree visé, avec + lecture du corpus seulement pour cette tâche. +4. Aucun log n'est copié dans le dépôt, un pane Herdr ou un environnement + `gw2agent`; aucun agent ne reçoit d'accès permanent. +5. Seul un résultat choisi et redacted par le mainteneur peut être transmis aux + agents ; les temporaires sont nettoyés après la tâche. + +Le repli est un contrôle humain temporaire, pas un équivalent technique de +l'exécuteur. Chaque autorisation consigne hors dépôt : identifiant de tâche, +scope, worktree, commande exacte, résultat attendu, décision de redaction et +confirmation de nettoyage. La commande ne reçoit ni shell interactif, ni argv +additionnel, ni destination persistante par défaut ; tout résultat à partager +est sélectionné par le mainteneur après exécution. + +Ce repli permet parser, ingestion et parité EI sans confondre accès au code et +accès aux données privées. + +## État des findings + +| Sujet | État consolidé | +| --- | --- | +| Routing, rôles et Level 1 | Clair et validé ; toute exécution reste précédée d'un accord humain. | +| Worktrees persistants | Séparés du corpus ; aucun montage ou copie privée persistante. | +| Confidentialité | `WvW/` non lu, non énuméré, non déplacé, non modifié et non ajouté à Git. | +| `uv` | Prérequis `gw2agent` : version épinglée 0.12.5 via Mise, utilisation `uv run`. | +| Herdr | Contrôles de disponibilité read-only hors sandbox si le socket est refusé dans Codex. | +| Exécuteur privé | Contrats, refus, diagnostics et supply chain UV testés ; exécution systemd synthétique non validée. | +| Stash `codex-pre-phase7-main-sync` | Absent de `git stash list` lors de la clôture ; non appliqué, non inspecté. | + +## Preuves et limite constatée + +Les validations locales de l'exécuteur et des garde-fous documentaires passent +avec `uv run pytest tests/private_corpus tests/scripts/test_agentic_infrastructure.py -q`; +Ruff, formatage, Bash et `git diff --check` passent également. Les diagnostics +synthétiques ont démontré la validation `subset`/`full`, le tombstone à usage +unique et l'écriture redacted atomique. + +Les essais hôte synthétiques retournent encore `unit-failed/service` pour les +deux scopes. La catégorie actuelle recouvre trop d'étapes et ne prouve pas à +elle seule le lancement effectif du profil. Il serait trompeur d'affirmer la +lecture read-only, le namespace ou le nettoyage live comme validés. + +La review finale relève aussi que le mécanisme expérimental ne matérialise pas +encore l'autorisation humaine par tâche, que les diagnostics peuvent conserver +un `task_id` fourni par le demandeur, et que le worktree mutable exécute du code +ayant la capacité de lecture pendant la tâche. Ces risques interdisent toute +intégration opérationnelle ou accès réel. + +## Dette technique : condition de reprise + +Avant toute nouvelle correction systemd, ajouter une observation fermée qui +différencie : + +- `systemd-start` ; +- `sandbox-bind` ; +- `tool-exec` ; +- `profile-exit`. + +Elle ne peut conserver que le profil logique, `ExecMainStatus` et un code +retour ; jamais chemin privé, token, sélection, stdout/stderr ni contenu. La +reprise doit ensuite valider avec corpus synthétique : namespace de montage, +bind UV root-owned, `.venv`, cache `UV_CACHE_DIR`, lecture seule, nettoyage sur +succès/échec/interruption et reboot. + +Elle doit aussi : ne jamais écraser la cause primaire par un échec de cleanup ; +contrôler l'absence d'appartenance permanente au groupe lecteur à chaque +demande ; borner les sockets locaux et le code exécutable ; refuser une +sélection `subset` qui résout vers la racine ; utiliser les binaires système +absolus ; purger les diagnostics par mécanisme indépendant ; et matérialiser un +jeton émis et lié à l'autorisation humaine. + +### Private Corpus Executor Finalization — GPT-5.6 Sol / High + +Cette reprise est une tâche système dédiée et ne bloque pas GW2Analytics. Elle +exige explicitement `GPT-5.6 Sol / High`. Lors de tout `codex exec resume`, le +Lead doit redemander et réappliquer explicitement le modèle et le reasoning +effort avant toute commande de diagnostic. Il vérifie le runtime effectivement +observé ; si le modèle ou l'effort ne correspond pas, il s'arrête avant tout +diagnostic et demande instruction. + +## Critères de passage à OPERATIONAL + +1. Les validations live synthétiques `subset` et `full` passent sous l'unité. +2. Le corpus synthétique est lisible seulement dans l'unité et son écriture y + est refusée. +3. Aucun runtime ou montage ne demeure après succès, erreur, interruption ou + reboot. +4. Les diagnostics restent redacted et les sorties brutes restent éphémères. +5. Une review indépendante confirme les preuves live et les garde-fous Git. +6. Le mainteneur autorise explicitement le premier accès au corpus réel. + +## Hors périmètre maintenu + +- Aucun déplacement de `WvW/`. +- Aucun accès réel par l'exécuteur expérimental. +- Aucun changement GitHub, aucune gouvernance Git/GitHub et aucun commit, + push ou PR. +- Aucune intégration opérationnelle des artefacts expérimentaux. Leur future + intégration exige un inventaire explicite des fichiers non suivis et une + review des findings ci-dessus. diff --git a/docs/agentic/phase8-integration-manifest.md b/docs/agentic/phase8-integration-manifest.md new file mode 100644 index 00000000..8ce4f3a1 --- /dev/null +++ b/docs/agentic/phase8-integration-manifest.md @@ -0,0 +1,56 @@ +# Manifeste d'intégration — Phase 8 + +> **EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS** + +Ce manifeste définit le seul périmètre autorisé d'un futur commit Phase 8. Il +préserve les sources de reprise et exclut les données, installations et runtime +locaux. Il ne rend pas l'exécuteur opérationnel et n'autorise aucun accès réel. + +## Fichiers suivis modifiés + +- `docs/agentic/README.md` +- `docs/agentic/backlog.md` +- `docs/agentic/current-state.md` +- `scripts/ei-parity/ei_diff.py` +- `tests/scripts/test_agentic_infrastructure.py` + +## Artefacts expérimentaux à versionner + +- `ops/private-corpus/README.md` +- `ops/private-corpus/contract.json` +- `ops/private-corpus/executor.py` +- `ops/private-corpus/gw2analytics-private-corpus.sudoers` +- `ops/private-corpus/gw2analytics-private-corpus@.service` +- `ops/private-corpus/private-test-registry.json` +- `tools/install-private-corpus-executor.sh` +- `tests/private_corpus/test_executor_contract.py` +- `tests/private_corpus/test_mounted_input.py` +- `docs/agentic/phase8-environment-prerequisites-2026-08-20.md` +- `docs/agentic/phase8-final-checkpoint-2026-08-21.md` +- `docs/agentic/private-corpus-executor.md` +- `_bmad-output/implementation-artifacts/spec-phase8-private-corpus-executor.md` +- `_bmad-output/specs/spec-private-corpus-access/SPEC.md` +- `_bmad-output/specs/spec-private-corpus-access/private-access-protocol.md` +- `_bmad-output/specs/spec-private-corpus-access/verification-and-git-guardrails.md` + +## À ne jamais versionner + +- `.memlog.md`, caches Python ou `uv`, outputs de test et instrumentation de + diagnostic temporaire ; +- jetons, tombstones, diagnostics, stdout/stderr, journaux systemd et runtime ; +- installations hôte sous `/etc`, `/usr/local`, `/run` ou `/var/lib` ; +- registre privé réel, sélections, autorisations et copie UV installée ; +- `WvW/`, logs privés et tout dérivé de combat ou export EI privé. + +## Éléments strictement locaux + +Le corpus maître, les montages, le repli humain `roddy`, les commandes privées +autorisées par tâche et leurs résultats non redacted demeurent locaux. Ils ne +figurent ni dans le commit, ni dans un pane Herdr, ni dans un handoff. + +## Dette de reprise + +**Private Corpus Executor Finalization — GPT-5.6 Sol / High** est une tâche +système dédiée, non bloquante pour GW2Analytics. Lors de tout `codex exec +resume`, le Lead réapplique explicitement le modèle et le reasoning effort, +vérifie le runtime observé et s'arrête avant diagnostic en cas de divergence. diff --git a/docs/agentic/private-corpus-executor.md b/docs/agentic/private-corpus-executor.md new file mode 100644 index 00000000..ad38a955 --- /dev/null +++ b/docs/agentic/private-corpus-executor.md @@ -0,0 +1,49 @@ +# Phase 8 — exécuteur privé éphémère + +> **EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS** +> +> Ne pas utiliser cet exécuteur avec `WvW/` ou tout corpus réel. Il est conservé pour reprise auditable et +> validation synthétique uniquement ; le repli humain `roddy` est le mécanisme +> temporaire autorisé pour une tâche privée explicitement approuvée. + +Les sources de l’exécuteur sont dans `ops/private-corpus/`. Elles ne donnent +aucun accès au corpus : l'installateur place le wrapper, son contrat, l'unité, +la règle sudo et la copie UV dédiée root-owned seulement après accord explicite. + +Le runtime est exclusivement `/run/gw2analytics-private//`. Les +profils admis sont `parser-validation-readonly`, `ei-parity-readonly` et +`pytest-private-fixture`; ils construisent eux-mêmes leurs arguments. Un +profil inconnu, un worktree non enregistré, un jeton réemployé, un argv libre +ou `full` sans confirmation explicite est refusé avant la création d’une unité. +Le worktree enregistré est utilisé seulement comme répertoire de travail et +reste read-only : caches Python, pytest et temporaires sont redirigés vers le +runtime de l’unité. + +L’unité conserve `ProtectHome=yes`. L’installateur copie le binaire UV 0.12.5 +statiquement lié depuis Mise seulement après contrôle de sa version et de son +empreinte, vers `/usr/local/lib/gw2analytics-private/` root-owned (`root:root`, +`0511`). Avant chaque demande, le wrapper revalide propriétaire, permissions, +empreinte et version. L’unité ne rend accessible que cette unique copie, par +bind mount read-only vers le runtime ; les profils utilisent ce chemin absolu +et ne résolvent jamais le shim Mise ni un `uv` fourni par `PATH`. +`UV_CACHE_DIR` demeure dans le runtime. + +Les diagnostics sont réservés au registre synthétique et au purpose +`validation`. Ils sont root-owned, à durée de vie bornée, sans chemin, sortie, +contenu ou justification, et accessibles à `roddy` seulement via la commande +sudo fermée de lecture. + +Les tests de contrat synthétiques automatisent les refus, le wiring subset/full +et le nettoyage demandé au wrapper. Les preuves live V-1 à V-6 (namespace, +montage et isolation effectifs) restent à exécuter sur l’hôte autorisé ; V-7 +reste un contrôle de maintenance avant toute activation réelle. Les journaux +bruts et les rapports EI détaillés restent dans le runtime +et sont supprimés avec l’unité. Seul un tombstone root-owned non sensible +(tâche, scope, profil) demeure afin de refuser le réemploi du jeton. + +Les essais hôte synthétiques actuels se terminent par `unit-failed/service`. +La catégorie ne distingue pas encore lancement systemd, bind de sandbox, +exécution de l'outil et sortie du profil : aucune nouvelle correction ne doit +être engagée sans observabilité redacted de ces quatre cas. Les critères de +passage à OPERATIONAL sont consignés dans +`phase8-final-checkpoint-2026-08-21.md`. diff --git a/ops/private-corpus/README.md b/ops/private-corpus/README.md new file mode 100644 index 00000000..88d2d3ca --- /dev/null +++ b/ops/private-corpus/README.md @@ -0,0 +1,32 @@ +# Exécuteur privé éphémère + +> **EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS** + +Ces fichiers sont des sources versionnées et ne donnent aucun accès au corpus. +L'installation, explicitement demandée à un mainteneur, place uniquement le +wrapper, le template systemd et la règle sudo root-owned. Le registre de +production et les autorisations restent hors dépôt. + +Le wrapper accepte exclusivement une requête JSON sur stdin avec +`--request-stdin` ou son invocation interne +`--run-unit `. Il refuse les arguments libres, un profil inconnu, +un worktree non enregistré, les jetons réutilisés et `full` sans confirmation +contenant `full`. Les sorties brutes restent sous `/run/gw2analytics-private`. + +Un diagnostic synthétique root-owned, identifié de façon opaque, ne contient +que tâche, scope, profil, étape, catégorie et expiration. Il expire au bout de +dix minutes; `roddy` peut le lire par le sous-programme sudo fermé +`--read-diagnostic-stdin`. Les étapes et catégories sont fixées dans le contrat. +Le registre doit pointer exactement vers la racine synthétique canonique; +l’installateur échoue si `gw2agent` possède déjà le groupe lecteur permanent. + +L'installateur crée seulement le groupe privé, le corpus synthétique et le +registre root-owned nécessaires à la validation; il n'ajoute jamais +`gw2agent` à ce groupe et ne référence aucun corpus réel. Le passage `sudo` de +`roddy` constitue l’autorisation humaine ; le wrapper crée avant le montage un +tombstone root-owned atomique ne contenant que task, scope et profil, et le +conserve pour refuser toute réutilisation du jeton. L’installateur ne crée ni +groupe, ni corpus, ni appartenance permanente pour `gw2agent`. + +`ei-parity-readonly` est un diagnostic non certifiant tant qu’une référence EI +canonique et l’outil de référence ne sont pas fournis et vérifiés séparément. diff --git a/ops/private-corpus/contract.json b/ops/private-corpus/contract.json new file mode 100644 index 00000000..bf61b8bf --- /dev/null +++ b/ops/private-corpus/contract.json @@ -0,0 +1,13 @@ +{ + "request": { + "required": ["task_id", "purpose", "scope", "why_private_data", "worktree_ref", "command_profile", "requested_output", "authorization_token"], + "purposes": ["validation", "reproduction", "analysis"], + "profiles": ["parser-validation-readonly", "ei-parity-readonly", "pytest-private-fixture"], + "text_limits": {"task_id": 80, "why_private_data": 500, "requested_output": 200}, + "diagnostics": { + "ttl_seconds": 600, + "stages": ["validated", "runtime-created", "source-mounted", "unit-started", "unit-failed"], + "categories": ["mount", "registry", "worktree", "service", "cleanup"] + } + } +} diff --git a/ops/private-corpus/executor.py b/ops/private-corpus/executor.py new file mode 100644 index 00000000..a652f2fe --- /dev/null +++ b/ops/private-corpus/executor.py @@ -0,0 +1,494 @@ +#!/usr/bin/env python3 +"""Root-owned, single-use private-corpus request gate. + +EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS. + +The installed registry is deliberately outside the repository. This source +contains no corpus path and never performs a shell invocation. +""" + +from __future__ import annotations + +import argparse +import grp +import hashlib +import json +import os +import re +import secrets +import shutil +import stat +import subprocess +import sys +import time +from pathlib import Path + +RUNTIME_ROOT = Path("/run/gw2analytics-private") +REGISTRY = Path("/etc/gw2analytics/private-corpus-registry.json") +CONSUMED_TOKENS = Path("/var/lib/gw2analytics/private-corpus-consumed") +DIAGNOSTICS = Path("/var/lib/gw2analytics/private-corpus-diagnostics") +SYNTHETIC_ROOT = Path("/var/lib/gw2analytics/private-corpus-synthetic") +CONTRACT = Path(__file__).with_name("contract.json") +if not CONTRACT.is_file(): + CONTRACT = Path("/usr/local/sbin/gw2analytics-private-corpus-contract.json") +PRIVATE_GROUP = "gw2analytics-private-readers" +WORKTREE_BASE = Path("/srv/gw2analytics").resolve() +UV_RUNTIME_BINARY = Path("toolchain/uv") +PINNED_UV = Path("/usr/local/lib/gw2analytics-private/uv/0.12.5/uv") +PINNED_UV_SHA256 = "6470fe2ab573e01f703fd76cada1952f7755dd0fc7f2f6ac0bee1d5f8ba4413e" +PINNED_UV_VERSION = "uv 0.12.5 (x86_64-unknown-linux-musl)" +SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{16,128}$") +TASK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$") + + +def refuse() -> None: + print("private corpus request refused", file=sys.stderr) + raise SystemExit(1) + + +def refuse_diagnostic(diagnostic_id: str) -> None: + print(f"private corpus request refused [diagnostic={diagnostic_id}]", file=sys.stderr) + raise SystemExit(1) + + +def load_json(path: Path) -> dict[str, object]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + refuse() + if not isinstance(value, dict): + refuse() + return value + + +def _contract() -> dict[str, object]: + contract = load_json(CONTRACT).get("request") + if not isinstance(contract, dict): + refuse() + return contract + + +PROFILES = frozenset(_contract().get("profiles", ())) + + +def validate_request(request: dict[str, object], registry: dict[str, object]) -> str: + contract = _contract() + required = set(contract.get("required", ())) + profiles = set(contract.get("profiles", ())) + purposes = set(contract.get("purposes", ())) + limits = contract.get("text_limits") + if not all(isinstance(value, str) for value in required) or not isinstance(limits, dict): + refuse() + if not required <= set(request) or set(request) - ( + required | {"selection_ref", "full_confirmation"} + ): + refuse() + scope = request.get("scope") + profile = request.get("command_profile") + token = request.get("authorization_token") + if ( + scope not in {"subset", "full"} + or profile not in profiles + or not isinstance(token, str) + or not SAFE_ID.fullmatch(token) + ): + refuse() + if request.get("purpose") not in purposes: + refuse() + allowed_purposes = registry.get("allowed_purposes") + if ( + registry.get("synthetic") is not True + or registry.get("corpus_root") != str(SYNTHETIC_ROOT) + or not isinstance(allowed_purposes, list) + or request["purpose"] not in allowed_purposes + ): + refuse() + for field in ("task_id", "why_private_data", "requested_output"): + value, limit = request.get(field), limits.get(field) + if ( + not isinstance(value, str) + or not value + or not isinstance(limit, int) + or len(value) > limit + ): + refuse() + if not TASK_ID.fullmatch(request["task_id"]): + refuse() + worktrees = registry.get("worktrees") + if not isinstance(worktrees, dict) or request.get("worktree_ref") not in worktrees: + refuse() + if scope == "subset": + selections = registry.get("selections") + if not isinstance(selections, dict) or request.get("selection_ref") not in selections: + refuse() + if scope == "full" and ( + not isinstance(request.get("full_confirmation"), str) + or "full" not in request["full_confirmation"].lower() + ): + refuse() + return token + + +def _diagnostic_contract() -> tuple[int, set[str], set[str]]: + value = _contract().get("diagnostics") + if not isinstance(value, dict): + refuse() + ttl = value.get("ttl_seconds") + stages, categories = value.get("stages"), value.get("categories") + if ( + not isinstance(ttl, int) + or ttl <= 0 + or not isinstance(stages, list) + or not isinstance(categories, list) + or not all(isinstance(item, str) for item in stages + categories) + ): + refuse() + return ttl, set(stages), set(categories) + + +def _record_diagnostic( + diagnostic_id: str, + request_data: dict[str, object], + stage: str, + category: str, +) -> None: + ttl, stages, categories = _diagnostic_contract() + if not SAFE_ID.fullmatch(diagnostic_id) or stage not in stages or category not in categories: + refuse() + now = int(time.time()) + artifact = { + "schema_version": 1, + "diagnostic_id": diagnostic_id, + "task_id": request_data["task_id"], + "scope": request_data["scope"], + "command_profile": request_data["command_profile"], + "stage": stage, + "category": category, + "created_at": now, + "expires_at": now + ttl, + } + _purge_expired_diagnostics() + DIAGNOSTICS.mkdir(mode=0o700, parents=True, exist_ok=True) + diagnostic_path = DIAGNOSTICS / diagnostic_id + temporary = DIAGNOSTICS / f".{diagnostic_id}.tmp" + with temporary.open("x", encoding="utf-8") as handle: + handle.write(json.dumps(artifact, sort_keys=True)) + handle.flush() + os.fsync(handle.fileno()) + temporary.chmod(0o600) + temporary.replace(diagnostic_path) + + +def _purge_expired_diagnostics() -> None: + if not DIAGNOSTICS.is_dir(): + return + now = int(time.time()) + for path in DIAGNOSTICS.iterdir(): + if not path.is_file() or not SAFE_ID.fullmatch(path.name): + continue + try: + expires_at = load_json(path).get("expires_at") + except SystemExit: + path.unlink(missing_ok=True) + continue + if not isinstance(expires_at, int) or expires_at <= now: + path.unlink(missing_ok=True) + + +def read_diagnostic(diagnostic_id: str) -> dict[str, object]: + if not SAFE_ID.fullmatch(diagnostic_id): + refuse() + _purge_expired_diagnostics() + artifact_path = DIAGNOSTICS / diagnostic_id + artifact = load_json(artifact_path) + expires_at = artifact.get("expires_at") + if not isinstance(expires_at, int) or expires_at <= int(time.time()): + artifact_path.unlink(missing_ok=True) + refuse() + return artifact + + +def _source_for(request_data: dict[str, object], registry: dict[str, object]) -> Path: + root = registry.get("corpus_root") + if not isinstance(root, str): + refuse() + try: + corpus_root = Path(root).resolve(strict=True) + except OSError: + refuse() + selection: object = "." + if request_data["scope"] == "subset": + selections = registry.get("selections") + if not isinstance(selections, dict): + refuse() + selection = selections.get(request_data.get("selection_ref")) + if not isinstance(selection, str) or Path(selection).is_absolute(): + refuse() + if request_data["scope"] == "subset" and selection in {"", ".", ".."}: + refuse() + try: + source = (corpus_root / selection).resolve(strict=True) + source.relative_to(corpus_root) + except (OSError, ValueError): + refuse() + if source.is_symlink(): + refuse() + return source + + +def _worktree_for(request_data: dict[str, object], registry: dict[str, object]) -> Path: + worktrees = registry.get("worktrees") + registered = ( + worktrees.get(request_data["worktree_ref"]) if isinstance(worktrees, dict) else None + ) + if not isinstance(registered, str): + refuse() + try: + worktree = Path(registered).resolve(strict=True) + except OSError: + refuse() + if worktree == WORKTREE_BASE or WORKTREE_BASE not in worktree.parents: + refuse() + return worktree + + +def _mount_source(source: Path, runtime: Path) -> None: + staged = runtime / "source" + staged.mkdir(mode=0o700) + subprocess.run( # noqa: S603 - fixed mount utility and validated absolute source + ["/usr/bin/mount", "--bind", str(source), str(staged)], check=True + ) + subprocess.run( # noqa: S603 - fixed mount utility and private staging path + ["/usr/bin/mount", "-o", "remount,bind,ro", str(staged)], check=True + ) + + +def _consume_token(token: str, request_data: dict[str, object]) -> None: + """Create an atomic root-owned replay tombstone before any mount occurs.""" + CONSUMED_TOKENS.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + descriptor = os.open( + CONSUMED_TOKENS / token, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + except FileExistsError: + refuse() + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump( + { + "task_id": request_data["task_id"], + "scope": request_data["scope"], + "command_profile": request_data["command_profile"], + }, + stream, + ) + + +def _verify_pinned_uv() -> None: + """Fail closed before token consumption if the root-owned toolchain drifts.""" + try: + metadata = PINNED_UV.stat() + with PINNED_UV.open("rb") as binary: + digest = hashlib.file_digest(binary, "sha256").hexdigest() + version = subprocess.run( # noqa: S603 - fixed root-owned binary and argument + [str(PINNED_UV), "--version"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + except OSError: + refuse() + if ( + metadata.st_uid != 0 + or metadata.st_gid != 0 + or stat.S_IMODE(metadata.st_mode) != 0o511 + or digest != PINNED_UV_SHA256 + or version.returncode != 0 + or version.stdout.strip() != PINNED_UV_VERSION + ): + refuse() + + +def _cleanup(access_id: str, runtime: Path) -> bool: + stopped = subprocess.run( # noqa: S603 - fixed unit and opaque generated identifier + ["/usr/bin/systemctl", "stop", f"gw2analytics-private-corpus@{access_id}.service"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + unmounted = subprocess.run( # noqa: S603 - fixed unmount utility and private staging path + ["/usr/bin/umount", "--lazy", str(runtime / "source")], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + shutil.rmtree(runtime) + except OSError: + return False + return stopped.returncode == 0 and unmounted.returncode == 0 + + +def request(data: dict[str, object]) -> int: + registry = load_json(REGISTRY) + token = validate_request(data, registry) + _verify_pinned_uv() + _consume_token(token, data) + access_id = secrets.token_urlsafe(24) + _record_diagnostic(access_id, data, "validated", "registry") + runtime = RUNTIME_ROOT / access_id + try: + RUNTIME_ROOT.mkdir(mode=0o710, parents=True, exist_ok=True) + os.chown(RUNTIME_ROOT, 0, grp.getgrnam(PRIVATE_GROUP).gr_gid) + RUNTIME_ROOT.chmod(0o710) + runtime.mkdir(mode=0o770, parents=True) + os.chown(runtime, 0, grp.getgrnam(PRIVATE_GROUP).gr_gid) + uv_cache = runtime / "uv-cache" + uv_cache.mkdir(mode=0o770) + os.chown(uv_cache, 0, grp.getgrnam(PRIVATE_GROUP).gr_gid) + uv_cache.chmod(0o770) + toolchain = runtime / UV_RUNTIME_BINARY.parent + toolchain.mkdir(mode=0o750) + os.chown(toolchain, 0, grp.getgrnam(PRIVATE_GROUP).gr_gid) + uv_target = runtime / UV_RUNTIME_BINARY + uv_target.touch(mode=0o550) + os.chown(uv_target, 0, grp.getgrnam(PRIVATE_GROUP).gr_gid) + _record_diagnostic(access_id, data, "runtime-created", "mount") + try: + source = _source_for(data, registry) + except SystemExit: + _record_diagnostic(access_id, data, "unit-failed", "registry") + refuse_diagnostic(access_id) + _mount_source(source, runtime) + _record_diagnostic(access_id, data, "source-mounted", "mount") + try: + worktree = _worktree_for(data, registry) + except SystemExit: + _record_diagnostic(access_id, data, "unit-failed", "worktree") + refuse_diagnostic(access_id) + runtime_data = {**data, "worktree_path": str(worktree)} + (runtime / "request.json").write_text(json.dumps(runtime_data), encoding="utf-8") + subprocess.run( # noqa: S603 - fixed system service and opaque generated identifier + [ # noqa: S607 - systemctl is resolved from the root-controlled system PATH + "systemctl", + "start", + f"gw2analytics-private-corpus@{access_id}.service", + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + _record_diagnostic(access_id, data, "unit-started", "service") + except (OSError, subprocess.CalledProcessError): + _record_diagnostic(access_id, data, "unit-failed", "service") + refuse_diagnostic(access_id) + finally: + if not _cleanup(access_id, runtime): + _record_diagnostic(access_id, data, "unit-failed", "cleanup") + return 0 + + +def run_unit(access_id: str) -> int: + if not SAFE_ID.fullmatch(access_id): + refuse() + runtime = RUNTIME_ROOT / access_id + uv_binary = runtime / UV_RUNTIME_BINARY + request_data = load_json(runtime / "request.json") + profile = request_data.get("command_profile") + if profile not in set(_contract().get("profiles", ())): + refuse() + input_dir = runtime / "input" + worktree = request_data.get("worktree_path") + if ( + not isinstance(worktree, str) + or Path(worktree).resolve() == WORKTREE_BASE + or WORKTREE_BASE not in Path(worktree).resolve().parents + ): + refuse() + (runtime / "tmp").mkdir(exist_ok=True) + commands = { + "parser-validation-readonly": [ + str(uv_binary), + "run", + "pytest", + "tests/private_corpus/test_mounted_input.py", + "-q", + ], + "ei-parity-readonly": [ + str(uv_binary), + "run", + "python", + "scripts/ei-parity/ei_diff.py", + "--private-subset", + "--corpus-dir", + str(input_dir), + "--manifest", + str(input_dir / "corpus-manifest.json"), + "--runtime-dir", + str(runtime), + ], + "pytest-private-fixture": [ + str(uv_binary), + "run", + "pytest", + "tests/private_corpus/test_mounted_input.py", + "-q", + ], + } + try: + with ( + (runtime / "stdout.log").open("wb") as output, + (runtime / "stderr.log").open("wb") as errors, + ): + completed = subprocess.run( # noqa: S603 - selected exclusively from closed commands above + commands[profile], + cwd=worktree, + env={ + **os.environ, + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONPYCACHEPREFIX": str(runtime / "pycache"), + "PYTEST_ADDOPTS": "-p no:cacheprovider", + "TMPDIR": str(runtime / "tmp"), + }, + stdout=output, + stderr=errors, + check=False, + ) + except OSError: + return 1 + return completed.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(add_help=False) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--request-stdin", action="store_true") + group.add_argument("--read-diagnostic-stdin", action="store_true") + group.add_argument("--run-unit") + args = parser.parse_args() + if (args.request_stdin or args.read_diagnostic_stdin) and os.geteuid() != 0: + refuse() + if args.request_stdin: + try: + payload = sys.stdin.buffer.read(8192) + if sys.stdin.isatty() or len(payload) == 8192: + refuse() + data = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + refuse() + if not isinstance(data, dict): + refuse() + return request(data) + if args.read_diagnostic_stdin: + diagnostic_id = sys.stdin.read(129).strip() + if len(diagnostic_id) > 128: + refuse() + print(json.dumps(read_diagnostic(diagnostic_id), sort_keys=True)) + return 0 + return run_unit(args.run_unit) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ops/private-corpus/gw2analytics-private-corpus.sudoers b/ops/private-corpus/gw2analytics-private-corpus.sudoers new file mode 100644 index 00000000..963b576f --- /dev/null +++ b/ops/private-corpus/gw2analytics-private-corpus.sudoers @@ -0,0 +1,3 @@ +# EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS. +roddy ALL=(root) NOPASSWD: /usr/local/sbin/gw2analytics-private-corpus-executor --request-stdin +roddy ALL=(root) NOPASSWD: /usr/local/sbin/gw2analytics-private-corpus-executor --read-diagnostic-stdin diff --git a/ops/private-corpus/gw2analytics-private-corpus@.service b/ops/private-corpus/gw2analytics-private-corpus@.service new file mode 100644 index 00000000..16193e6b --- /dev/null +++ b/ops/private-corpus/gw2analytics-private-corpus@.service @@ -0,0 +1,24 @@ +# EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS. +[Unit] +Description=Gw2Analytics private corpus execution %i +After=local-fs.target + +[Service] +Type=oneshot +User=gw2agent +SupplementaryGroups=gw2analytics-private-readers +PrivateMounts=yes +ProtectHome=yes +NoNewPrivileges=yes +IPAddressDeny=any +PrivateTmp=yes +ProtectSystem=strict +ReadWritePaths=/run/gw2analytics-private/%i +BindReadOnlyPaths=/run/gw2analytics-private/%i/source:/run/gw2analytics-private/%i/input +BindReadOnlyPaths=/usr/local/lib/gw2analytics-private/uv/0.12.5/uv:/run/gw2analytics-private/%i/toolchain/uv +Environment=GW2_PRIVATE_RUNTIME=/run/gw2analytics-private/%i +Environment=GW2_PRIVATE_INPUT=/run/gw2analytics-private/%i/input +Environment=UV_CACHE_DIR=/run/gw2analytics-private/%i/uv-cache +ExecStart=/usr/local/sbin/gw2analytics-private-corpus-executor --run-unit %i +TimeoutStartSec=15min +KillMode=control-group diff --git a/ops/private-corpus/private-test-registry.json b/ops/private-corpus/private-test-registry.json new file mode 100644 index 00000000..be963e68 --- /dev/null +++ b/ops/private-corpus/private-test-registry.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "note": "Synthetic test fixture only. Production registry is root-owned and outside the repository.", + "synthetic": true, + "allowed_purposes": ["validation"], + "corpus_root": "/var/lib/gw2analytics/private-corpus-synthetic", + "worktrees": {"synthetic-private-tests": "/srv/gw2analytics/repo"}, + "selections": {"synthetic-subset-token": "fixture-a"} +} diff --git a/scripts/ei-parity/ei_diff.py b/scripts/ei-parity/ei_diff.py index 0e5bd811..973cd33b 100644 --- a/scripts/ei-parity/ei_diff.py +++ b/scripts/ei-parity/ei_diff.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 +# EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS. """Run the in-house parser against EI reference JSON and categorise the deltas. Usage: uv run python .tooling/ei_diff.py [--json out.json] [log-stem ...] """ +# ruff: noqa: PLR0912, PLR0915 from __future__ import annotations @@ -176,7 +178,7 @@ def _check_artifact(stem: str, artifact: str, path: Path, expected_sha256: objec _fail(f"{stem}: altered {artifact}") -def validate_corpus() -> tuple[list[str], str, dict[str, object]]: # noqa: PLR0912 +def validate_corpus(*, private_subset: bool = False) -> tuple[list[str], str, dict[str, object]]: """Validate the complete certification corpus before any log is parsed.""" try: manifest_bytes = MANIFEST.read_bytes() @@ -195,13 +197,17 @@ def validate_corpus() -> tuple[list[str], str, dict[str, object]]: # noqa: PLR0 _fail("invalid EI version") if reference["export_type"] != EXPORT_TYPE: _fail("invalid export type") - _check_artifact("corpus", "EI CLI", EI_CLI, reference["cli_sha256"]) + if not private_subset: + _check_artifact("corpus", "EI CLI", EI_CLI, reference["cli_sha256"]) vocabulary = manifest["tag_vocabulary"] if vocabulary != TAG_VOCABULARY: _fail("invalid tag vocabulary") allowed_tags = set(vocabulary) + entries = manifest["entries"] + if not isinstance(entries, list): + _fail("manifest entries must be a list") try: corpus = [ line.strip() @@ -209,12 +215,13 @@ def validate_corpus() -> tuple[list[str], str, dict[str, object]]: # noqa: PLR0 if line.strip() ] except (OSError, UnicodeDecodeError): - _fail("invalid corpus.txt") + if not private_subset: + _fail("invalid corpus.txt") + corpus = [entry.get("stem") for entry in entries if isinstance(entry, dict)] corpus = [_validate_stem(stem) for stem in corpus] - if len(corpus) != 35 or len(corpus) != len(set(corpus)): - _fail("corpus.txt must contain 35 unique stems") - entries = manifest["entries"] - if not isinstance(entries, list) or len(entries) != len(corpus): + if len(corpus) != len(set(corpus)) or (not private_subset and len(corpus) != 35): + _fail("invalid corpus selection") + if len(entries) != len(corpus): _fail("manifest must contain exactly one entry per corpus stem") seen: set[str] = set() @@ -283,7 +290,7 @@ def _write_atomic(destination: Path, content: str) -> None: _fail("unable to write output") -def _validate_known_deltas(data: object) -> list[dict[str, object]]: # noqa: PLR0912 +def _validate_known_deltas(data: object) -> list[dict[str, object]]: if not isinstance(data, dict): _fail("known-deltas.json must be a mapping") if set(data) != KNOWN_DELTAS_KEYS: @@ -564,7 +571,8 @@ def _validate_report_destination(path: Path, corpus: list[str]) -> Path: return destination -def main() -> int: # noqa: PLR0912, PLR0915 +def main() -> int: + global CORPUS, EI_OUT, LOGS, MANIFEST # noqa: PLW0603 ap = argparse.ArgumentParser() ap.add_argument("stems", nargs="*") ap.add_argument("--json", dest="json_out") @@ -582,9 +590,29 @@ def main() -> int: # noqa: PLR0912, PLR0915 help="include rotation skills already proven noisy or regressive", ) ap.add_argument("--show", default=None, help="print raw diffs whose bucket matches this regex") + ap.add_argument("--private-subset", action="store_true", help=argparse.SUPPRESS) + ap.add_argument("--corpus-dir", type=Path, help=argparse.SUPPRESS) + ap.add_argument("--manifest", type=Path, help=argparse.SUPPRESS) + ap.add_argument("--runtime-dir", type=Path, help=argparse.SUPPRESS) args = ap.parse_args() - corpus, manifest_sha256, reference = validate_corpus() + if args.private_subset: + if args.corpus_dir is None or args.manifest is None or args.runtime_dir is None: + _fail("private subset requires injected inputs and runtime") + if not args.corpus_dir.is_dir() or not args.runtime_dir.is_dir(): + _fail("invalid private runtime or input") + MANIFEST = args.manifest + CORPUS = MANIFEST.parent / "corpus.txt" + LOGS = args.corpus_dir / "logs" + EI_OUT = args.corpus_dir / "ei-out" + if not LOGS.is_dir(): + LOGS = args.corpus_dir + if not EI_OUT.is_dir(): + EI_OUT = args.corpus_dir + elif any(value is not None for value in (args.corpus_dir, args.manifest, args.runtime_dir)): + _fail("injected inputs require private subset") + + corpus, manifest_sha256, reference = validate_corpus(private_subset=args.private_subset) if len(args.stems) != len(set(args.stems)): _fail("duplicate positional stem") for stem in args.stems: @@ -592,18 +620,26 @@ def main() -> int: # noqa: PLR0912, PLR0915 unknown_stems = set(args.stems) - set(corpus) if unknown_stems: _fail("requested stem is not in corpus.txt") - if args.json_out and args.stems: + if args.json_out and args.stems and not args.private_subset: _fail("baseline output requires the complete corpus") json_out = ( _validate_baseline_destination(Path(args.json_out), corpus) if args.json_out else None ) - if args.report_out and args.stems: + if args.report_out and args.stems and not args.private_subset: _fail("report output requires the complete corpus") report_out = ( _validate_report_destination(Path(args.report_out), corpus) if args.report_out else None ) if json_out is not None and report_out is not None and json_out == report_out: _fail("json and report destinations must differ") + if args.private_subset: + runtime = args.runtime_dir.resolve() + for destination in (json_out, report_out): + if destination is not None and runtime not in destination.parents: + _fail("private output must remain in runtime") + if report_out is None: + report_out = runtime / "ei-parity-report.json" + print("private subset diagnostic: not an EI certification run", flush=True) stems = args.stems or corpus reports = [] grand: Counter[str] = Counter() diff --git a/tests/private_corpus/test_executor_contract.py b/tests/private_corpus/test_executor_contract.py new file mode 100644 index 00000000..09e8d920 --- /dev/null +++ b/tests/private_corpus/test_executor_contract.py @@ -0,0 +1,271 @@ +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +EXECUTOR = ROOT / "ops" / "private-corpus" / "executor.py" +SPEC = importlib.util.spec_from_file_location("private_executor", EXECUTOR) +assert SPEC and SPEC.loader +private_executor = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(private_executor) + + +def request(**changes: object) -> dict[str, object]: + value: dict[str, object] = { + "task_id": "phase8-test", + "purpose": "validation", + "scope": "subset", + "why_private_data": "synthetic validation", + "worktree_ref": "synthetic-private-tests", + "command_profile": "parser-validation-readonly", + "selection_ref": "synthetic-subset-token", + "requested_output": "status only", + "authorization_token": "a" * 16, + } + value.update(changes) + return value + + +def registry() -> dict[str, object]: + return { + "synthetic": True, + "corpus_root": str(private_executor.SYNTHETIC_ROOT), + "allowed_purposes": ["validation"], + "worktrees": {"synthetic-private-tests": "/synthetic"}, + "selections": {"synthetic-subset-token": "fixture"}, + } + + +def test_subset_and_full_resolve_only_inside_the_registered_root(tmp_path: Path) -> None: + root = tmp_path / "corpus" + subset = root / "subset" + subset.mkdir(parents=True) + source_registry = { + "corpus_root": str(root), + "worktrees": {"synthetic-private-tests": "/synthetic"}, + "selections": {"synthetic-subset-token": "subset"}, + } + assert private_executor._source_for(request(), source_registry) == subset + assert ( + private_executor._source_for( + request(scope="full", full_confirmation="confirm full", authorization_token="c" * 16), + source_registry, + ) + == root + ) + source_registry["selections"] = {"synthetic-subset-token": "../outside"} + with pytest.raises(SystemExit): + private_executor._source_for(request(), source_registry) + source_registry["selections"] = {"synthetic-subset-token": "."} + with pytest.raises(SystemExit): + private_executor._source_for(request(), source_registry) + + +def test_registered_worktree_is_resolved_and_limited_to_unit_writable_root( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + worktree = tmp_path / "worktree" + worktree.mkdir() + monkeypatch.setattr(private_executor, "WORKTREE_BASE", tmp_path.resolve()) + source_registry = {"worktrees": {"synthetic-private-tests": str(worktree)}} + assert private_executor._worktree_for(request(), source_registry) == worktree + source_registry["worktrees"] = {"synthetic-private-tests": str(tmp_path.parent)} + with pytest.raises(SystemExit): + private_executor._worktree_for(request(), source_registry) + + +def test_mount_and_cleanup_use_private_runtime_only( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + calls: list[list[str]] = [] + + def run(argv: list[str], **_kwargs: object) -> object: + calls.append(argv) + return SimpleNamespace(returncode=0) + + runtime = tmp_path / "runtime" + runtime.mkdir() + source = tmp_path / "source" + source.mkdir() + monkeypatch.setattr(private_executor.subprocess, "run", run) + private_executor._mount_source(source, runtime) + private_executor._cleanup("a" * 16, runtime) + assert ["/usr/bin/mount", "--bind", str(source), str(runtime / "source")] in calls + assert any(command[0] == "/usr/bin/umount" for command in calls) + assert not runtime.exists() + + +def test_consumed_token_is_an_atomic_persistent_tombstone( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(private_executor, "CONSUMED_TOKENS", tmp_path / "consumed") + token = "d" * 16 + private_executor._consume_token(token, request(authorization_token=token)) + tombstone = private_executor.CONSUMED_TOKENS / token + assert json.loads(tombstone.read_text()) == { + "task_id": "phase8-test", + "scope": "subset", + "command_profile": "parser-validation-readonly", + } + with pytest.raises(SystemExit): + private_executor._consume_token(token, request(authorization_token=token)) + + +def test_diagnostic_is_ttl_bounded_and_redacts_request_details( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(private_executor, "DIAGNOSTICS", tmp_path / "diagnostics") + monkeypatch.setattr(private_executor.time, "time", lambda: 1000) + diagnostic_id = "z" * 16 + request_data = request(why_private_data="sensitive reason", requested_output="sensitive output") + private_executor._record_diagnostic(diagnostic_id, request_data, "validated", "registry") + artifact = private_executor.read_diagnostic(diagnostic_id) + assert artifact["stage"] == "validated" + assert artifact["expires_at"] == 1600 + serialized = json.dumps(artifact) + assert "sensitive reason" not in serialized + assert "sensitive output" not in serialized + assert "worktree" not in serialized + monkeypatch.setattr(private_executor.time, "time", lambda: 1600) + with pytest.raises(SystemExit): + private_executor.read_diagnostic(diagnostic_id) + assert not (private_executor.DIAGNOSTICS / diagnostic_id).exists() + + +def test_diagnostic_rejects_unknown_stage_or_category( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(private_executor, "DIAGNOSTICS", tmp_path / "diagnostics") + with pytest.raises(SystemExit): + private_executor._record_diagnostic("y" * 16, request(), "anything", "registry") + with pytest.raises(SystemExit): + private_executor._record_diagnostic("y" * 16, request(), "validated", "anything") + + +def test_diagnostic_request_proactively_purges_expired_artifacts( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(private_executor, "DIAGNOSTICS", tmp_path / "diagnostics") + private_executor.DIAGNOSTICS.mkdir() + expired = private_executor.DIAGNOSTICS / ("e" * 16) + expired.write_text('{"expires_at": 1}', encoding="utf-8") + monkeypatch.setattr(private_executor.time, "time", lambda: 1000) + private_executor._record_diagnostic("f" * 16, request(), "validated", "registry") + assert not expired.exists() + + +def test_synthetic_request_requires_canonical_root_anchor() -> None: + invalid_registry = registry() | {"corpus_root": "/var/lib/not-the-synthetic-root"} + with pytest.raises(SystemExit): + private_executor.validate_request(request(), invalid_registry) + + +def test_refusal_has_no_diagnostic_id_before_artifact(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit): + private_executor.refuse() + assert "diagnostic=" not in capsys.readouterr().err + + +def test_refusal_has_opaque_diagnostic_id_after_artifact( + capsys: pytest.CaptureFixture[str], +) -> None: + diagnostic_id = "q" * 16 + with pytest.raises(SystemExit): + private_executor.refuse_diagnostic(diagnostic_id) + assert ( + capsys.readouterr().err == f"private corpus request refused [diagnostic={diagnostic_id}]\n" + ) + + +def test_runtime_root_is_not_listable_by_plain_gw2agent() -> None: + source = EXECUTOR.read_text(encoding="utf-8") + assert "RUNTIME_ROOT.mkdir(mode=0o710" in source + assert "RUNTIME_ROOT.chmod(0o710)" in source + assert "diag-enter" not in source + + +def test_profiles_use_only_the_runtime_bound_pinned_uv_binary() -> None: + source = EXECUTOR.read_text(encoding="utf-8") + unit = (ROOT / "ops/private-corpus/gw2analytics-private-corpus@.service").read_text( + encoding="utf-8" + ) + assert 'UV_RUNTIME_BINARY = Path("toolchain/uv")' in source + assert 'PINNED_UV = Path("/usr/local/lib/gw2analytics-private/uv/0.12.5/uv")' in source + assert "_verify_pinned_uv()" in source + assert '"uv",' not in source + assert "ProtectHome=yes" in unit + assert ( + "BindReadOnlyPaths=/usr/local/lib/gw2analytics-private/uv/0.12.5/uv:" + "/run/gw2analytics-private/%i/toolchain/uv" in unit + ) + + +def test_pinned_uv_is_checked_before_single_use_token_consumption() -> None: + source = EXECUTOR.read_text(encoding="utf-8") + request_start = source.index("def request(") + assert source.index("_verify_pinned_uv()", request_start) < source.index( + "_consume_token(token, data)", request_start + ) + assert "/home/gw2agent" not in source + + +@pytest.mark.parametrize( + "changes", + [ + {"command_profile": "shell"}, + {"worktree_ref": "outside"}, + {"selection_ref": "outside"}, + {"authorization_token": "short"}, + {"task_id": "bad task id"}, + {"purpose": "other"}, + {"purpose": "analysis"}, + {"why_private_data": "x" * 501}, + {"requested_output": "x" * 201}, + {"unexpected": "argv"}, + ], +) +def test_refus_happens_before_a_unit_for_untrusted_request(changes: dict[str, object]) -> None: + with pytest.raises(SystemExit): + private_executor.validate_request(request(**changes), registry()) + + +def test_full_requires_a_confirmation_that_names_full() -> None: + with pytest.raises(SystemExit): + private_executor.validate_request( + request(scope="full", full_confirmation="yes"), registry() + ) + assert ( + private_executor.validate_request( + request(scope="full", full_confirmation="confirm full", authorization_token="b" * 16), + registry(), + ) + == "b" * 16 + ) + + +def test_private_sources_do_not_contain_a_real_corpus_reference() -> None: + for path in (ROOT / "ops" / "private-corpus").iterdir(): + if path.is_file(): + assert "WvW/" not in path.read_text(encoding="utf-8") + + +def test_profile_is_closed_and_raw_output_is_runtime_only() -> None: + source = EXECUTOR.read_text(encoding="utf-8") + assert "shell=True" not in source + assert "stdout.log" in source and "stderr.log" in source + assert "commands[profile]" in source + assert "test_mounted_input.py" in source + assert "--request-stdin" in source + assert "PYTHONDONTWRITEBYTECODE" in source + assert "PYTEST_ADDOPTS" in source + assert "SYNTHETIC_ROOT" in source + assert 'uv_cache = runtime / "uv-cache"' in source + assert "uv_cache.chmod(0o770)" in source + contract = json.loads((ROOT / "ops" / "private-corpus" / "contract.json").read_text())[ + "request" + ] + assert set(contract["profiles"]) == private_executor.PROFILES + assert set(contract["purposes"]) == {"validation", "reproduction", "analysis"} diff --git a/tests/private_corpus/test_mounted_input.py b/tests/private_corpus/test_mounted_input.py new file mode 100644 index 00000000..f7dd54b0 --- /dev/null +++ b/tests/private_corpus/test_mounted_input.py @@ -0,0 +1,17 @@ +"""Checks executed by closed private profiles inside the service namespace.""" + +import os +from pathlib import Path + +import pytest + + +def test_mounted_input_is_readable_and_read_only() -> None: + value = os.environ.get("GW2_PRIVATE_INPUT") + if value is None: + pytest.skip("only executed by the private service profile") + input_dir = Path(value) + assert input_dir.is_dir() + assert any(input_dir.iterdir()) + with pytest.raises(OSError): + (input_dir / "must-not-write").write_text("blocked", encoding="utf-8") diff --git a/tests/scripts/test_agentic_infrastructure.py b/tests/scripts/test_agentic_infrastructure.py index a6e9a5bd..93d30ef3 100644 --- a/tests/scripts/test_agentic_infrastructure.py +++ b/tests/scripts/test_agentic_infrastructure.py @@ -6,6 +6,7 @@ CODEX_DIR = ROOT / ".codex" AGENTS_DIR = CODEX_DIR / "agents" AGENTIC_DOCS = ROOT / "docs" / "agentic" +PRIVATE_CORPUS_OPS = ROOT / "ops" / "private-corpus" EXPECTED_AGENTS = { "gw2analytics_lead": ("gpt-5.6-terra", "medium"), @@ -180,3 +181,74 @@ def test_agentic_guardrails_cover_fallback_handoff_and_ultra() -> None: assert stage in recovery assert "référence" in recovery assert "Herdr" in recovery and "preuve suffisante" in recovery + + +def test_private_corpus_executor_is_documented_and_has_only_closed_profiles() -> None: + documentation = (AGENTIC_DOCS / "private-corpus-executor.md").read_text() + wrapper = (PRIVATE_CORPUS_OPS / "executor.py").read_text() + unit = (PRIVATE_CORPUS_OPS / "gw2analytics-private-corpus@.service").read_text() + installer = (ROOT / "tools" / "install-private-corpus-executor.sh").read_text() + + assert "/run/gw2analytics-private" in documentation + assert "WvW/" not in wrapper + assert "shell=True" not in wrapper + assert "parser-validation-readonly" in wrapper + assert "ei-parity-readonly" in wrapper + assert "pytest-private-fixture" in wrapper + assert "User=gw2agent" in unit + assert "PrivateMounts=yes" in unit + assert "BindReadOnlyPaths=/run/gw2analytics-private/%i/source:" in unit + assert "NoNewPrivileges=yes" in unit + assert "IPAddressDeny=any" in unit + assert "Environment=UV_CACHE_DIR=/run/gw2analytics-private/%i/uv-cache" in unit + assert "/home/gw2agent" not in unit + assert "/usr/local/lib/gw2analytics-private/uv/0.12.5/uv" in unit + assert "/srv/gw2analytics/repo" not in unit + assert "chown root:gw2analytics-private-readers" in installer + assert "gw2agent must not be a permanent member" in installer + assert "install -D -o root -g root -m 0511" in installer + assert "installed uv integrity verification failed" in installer + for target in ( + "/usr/local/sbin/gw2analytics-private-corpus-executor", + "/etc/systemd/system/gw2analytics-private-corpus@.service", + "/etc/sudoers.d/gw2analytics-private-corpus", + ): + assert target in installer + + +def test_phase8_checkpoint_keeps_private_executor_experimental() -> None: + current_state = (AGENTIC_DOCS / "current-state.md").read_text() + checkpoint = (AGENTIC_DOCS / "phase8-final-checkpoint-2026-08-21.md").read_text() + + for document in (current_state, checkpoint): + assert "EXPERIMENTAL / NOT OPERATIONAL" in document + assert "repli humain `roddy`" in document + assert "WvW/" in document + assert "systemd-start" in checkpoint + assert "sandbox-bind" in checkpoint + assert "tool-exec" in checkpoint + assert "profile-exit" in checkpoint + + +def test_phase8_private_corpus_documents_carry_the_operational_warning() -> None: + warning = "EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS" + documents = ( + AGENTIC_DOCS / "current-state.md", + AGENTIC_DOCS / "phase8-environment-prerequisites-2026-08-20.md", + AGENTIC_DOCS / "phase8-final-checkpoint-2026-08-21.md", + AGENTIC_DOCS / "private-corpus-executor.md", + ROOT / "ops/private-corpus/README.md", + ROOT / "_bmad-output/implementation-artifacts/spec-phase8-private-corpus-executor.md", + ROOT / "_bmad-output/specs/spec-private-corpus-access/SPEC.md", + ROOT / "_bmad-output/specs/spec-private-corpus-access/private-access-protocol.md", + ROOT / "_bmad-output/specs/spec-private-corpus-access/verification-and-git-guardrails.md", + ) + assert all(warning in document.read_text(encoding="utf-8") for document in documents) + + +def test_phase8_integration_manifest_excludes_private_and_runtime_artifacts() -> None: + manifest = (AGENTIC_DOCS / "phase8-integration-manifest.md").read_text(encoding="utf-8") + assert "WvW/" in manifest + assert ".memlog.md" in manifest + assert "Private Corpus Executor Finalization — GPT-5.6 Sol / High" in manifest + assert "codex exec\nresume" in manifest diff --git a/tools/install-private-corpus-executor.sh b/tools/install-private-corpus-executor.sh new file mode 100644 index 00000000..7f96c329 --- /dev/null +++ b/tools/install-private-corpus-executor.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# EXPERIMENTAL / NOT OPERATIONAL — DO NOT USE WITH REAL PRIVATE CORPUS. +set -euo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "run with sudo as root" >&2 + exit 1 +fi + +group_name=gw2analytics-private-readers +if ! getent group "$group_name" >/dev/null; then + groupadd --system "$group_name" +fi +if id -nG gw2agent | tr ' ' '\n' | grep -Fx "$group_name" >/dev/null; then + echo "gw2agent must not be a permanent member of $group_name" >&2 + exit 1 +fi + +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +source_dir="$root/ops/private-corpus" +uv_source=/home/gw2agent/.local/share/mise/installs/uv/0.12.5/uv-x86_64-unknown-linux-musl/uv +uv_target=/usr/local/lib/gw2analytics-private/uv/0.12.5/uv +uv_sha256=6470fe2ab573e01f703fd76cada1952f7755dd0fc7f2f6ac0bee1d5f8ba4413e +uv_version='uv 0.12.5 (x86_64-unknown-linux-musl)' +if [[ ! -x $uv_source ]] || [[ $(sha256sum "$uv_source" | awk '{print $1}') != "$uv_sha256" ]] \ + || [[ $("$uv_source" --version) != "$uv_version" ]] \ + || ! file "$uv_source" | grep -Fq 'static-pie linked'; then + echo "verified static uv 0.12.5 source is required" >&2 + exit 1 +fi +install -D -o root -g root -m 0511 "$uv_source" "$uv_target" +if [[ $(stat -c '%U:%G:%a' "$uv_target") != root:root:511 ]] \ + || [[ $(sha256sum "$uv_target" | awk '{print $1}') != "$uv_sha256" ]] \ + || [[ $("$uv_target" --version) != "$uv_version" ]]; then + echo "installed uv integrity verification failed" >&2 + exit 1 +fi +install -D -m 0750 "$source_dir/executor.py" /usr/local/sbin/gw2analytics-private-corpus-executor +chown root:gw2analytics-private-readers /usr/local/sbin/gw2analytics-private-corpus-executor +install -D -m 0640 "$source_dir/contract.json" /usr/local/sbin/gw2analytics-private-corpus-contract.json +chown root:gw2analytics-private-readers /usr/local/sbin/gw2analytics-private-corpus-contract.json +install -D -m 0644 "$source_dir/gw2analytics-private-corpus@.service" /etc/systemd/system/gw2analytics-private-corpus@.service +install -D -m 0440 "$source_dir/gw2analytics-private-corpus.sudoers" /etc/sudoers.d/gw2analytics-private-corpus +visudo -cf /etc/sudoers.d/gw2analytics-private-corpus + +# Corpus synthétique de validation seulement. gw2agent n'est jamais ajouté au +# groupe : seule l'unité reçoit ce groupe supplémentaire pendant son exécution. +synthetic_root=/var/lib/gw2analytics/private-corpus-synthetic +install -d -o root -g "$group_name" -m 0750 "$synthetic_root/fixture-a" +install -o root -g "$group_name" -m 0640 /dev/null "$synthetic_root/fixture-a/probe.txt" +printf '%s\n' 'synthetic private-corpus probe' > "$synthetic_root/fixture-a/probe.txt" +chown root:"$group_name" "$synthetic_root/fixture-a/probe.txt" +install -d -o root -g "$group_name" -m 0750 /etc/gw2analytics +install -o root -g "$group_name" -m 0640 "$source_dir/private-test-registry.json" /etc/gw2analytics/private-corpus-registry.json +systemctl daemon-reload