From 63783601f9ae4181849363404fe20dd0f51356ce Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 19 Sep 2026 17:55:46 +0200 Subject: [PATCH] fix(harness,#16866): regle gh-posting-hygiene + detecteur des corps pieges -f body=@file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parade portee dans le harnais : formes sures (--input/--body-file/-F typé), garde longueur post-POST (<100 chars = piege tire), remediation PATCH. Detecteur scripts/ci/check_gh_comment_traps.py : scan fenetre N h des commentaires, verdict TRAPPED/CLEAN/UNKNOWN (jamais de rouge forge). Run inaugural 48h : 12 corps pieges mesures (issue = 3) — batch po-2025 du 18/09 22:17 (9 posts) + 16685/16718 replies + i16422_evidence. Tests offline 9/9 (fixtures = les 3 corps reels de l'issue). Co-Authored-By: Claude Sonnet 5 --- .claude/rules/gh-posting-hygiene.md | 56 +++++++++ scripts/ci/check_gh_comment_traps.py | 126 +++++++++++++++++++ scripts/tests/test_check_gh_comment_traps.py | 74 +++++++++++ 3 files changed, 256 insertions(+) create mode 100644 .claude/rules/gh-posting-hygiene.md create mode 100644 scripts/ci/check_gh_comment_traps.py create mode 100644 scripts/tests/test_check_gh_comment_traps.py diff --git a/.claude/rules/gh-posting-hygiene.md b/.claude/rules/gh-posting-hygiene.md new file mode 100644 index 0000000000..cf4d760597 --- /dev/null +++ b/.claude/rules/gh-posting-hygiene.md @@ -0,0 +1,56 @@ +# Posting gh : corps de fichier via `-f` = chaîne littérale — formes sûres + garde post-POST + +S'applique à **toute lane qui poste un corps depuis un fichier** (commentaire d'issue/PR, review, edition de body) via `gh api` ou `gh pr comment`. Source : incident #16866 (3 occurrences mesurées, 2 sièges, 2 OS — Linux conteneur NanoClaw ET Windows po-2025). + +## Règle HARD 1 — jamais `-f body=@fichier` + +Le préfixe `@` n'est expansé **que par les champs typés `-F`**. Avec `-f` (chaîne brute), GitHub reçoit le texte littéral `@/tmp/f.md` — **échec 100 % silencieux** : 0 erreur, le commentaire atterrit avec un corps inutile de ~30-50 caractères. + +| Intention | Correct | Piégé | +|---|---|---| +| Poster un corps de fichier via API | `gh api …/comments --input payload.json` (JSON `{"body": …}` construit par `json.dumps`) | ~~`gh api …/comments -f body=@f.md`~~ | +| Champ typé (rare) | `gh api …/comments -F body=@f.md` | ~~`-f body=@f.md`~~ | +| Expansion shell | `gh api … -f body="$(cat f.md)"` (fragile : backticks/quotes, préférer `--input`) | ~~`-f body=@f.md`~~ | +| PR body | `gh pr create --body-file f.md` / `gh pr edit --body-file f.md` | ~~`gh pr create -b @f.md`~~ | +| PR commentaire | `gh pr comment N --body-file f.md` | ~~`gh pr comment N --body @f.md`~~ | + +Voir aussi le piège jumeau : **backticks dans un `-f body` / `--body` inline** (le shell les interprète) → là aussi, toujours `--input`/`--body-file`. + +## Règle HARD 2 — garde longueur post-POST + +Après tout POST d'un corps de fichier, **relire le corps publié et vérifier sa longueur** : un corps < 100 caractères après un POST de fichier = le piège a tiré. + +```bash +gh api repos/jsboige/CoursIA/issues/comments/ --jq '.body | length' +``` + +## Règle 3 — remédiation PATCH + +Corps piégé détecté : corriger par PATCH (pas de suppression si le contenu d'origine est traçable) — + +```bash +gh api repos/jsboige/CoursIA/issues/comments/ -X PATCH --input payload.json +``` + +## Détection mesurable + +```bash +python scripts/ci/check_gh_comment_traps.py # fenêtre 48 h, exit 1 si corps piégé +python scripts/ci/check_gh_comment_traps.py --json # verdict machine + ids + commandes de fix +``` + +Verdicts : `TRAPPED` (exit 1, liste + commande PATCH) · `CLEAN` · `UNKNOWN` (réseau — jamais un rouge forge, #14849). Le critère d'escalade NanoClaw (« 3ᵉ occurrence ») se mesure avec cet organe. + +## Incidents de référence (2026-09-18/19) + +- #16855 c.5740907673 — `@/tmp/…` — siège NanoClaw (conteneur Linux) — corrigé par PATCH au constat. +- #16766 — `@C:\Users\jsboi\AppData\Local\Temp/a16766.md` — siège po-2025 (Windows). +- #16723 c.5736850630 — idem, même batch 14 s plus tôt (Windows). + +La classe n'est ni spécifique à un siège ni à un OS : elle frappe toute lane qui poste un corps de fichier via `-f`. + +## Voir aussi + +- [secrets-hygiene.md](secrets-hygiene.md) — jamais de valeur de secret dans les corps, même Piégés +- [lane-claim-protocol.md](lane-claim-protocol.md) — les commentaires `[CLAIMED]`/`[DELIVERED]` empruntent les mêmes formes sûres +- #16866 — issue fondateure (mesure, sièges, parade) diff --git a/scripts/ci/check_gh_comment_traps.py b/scripts/ci/check_gh_comment_traps.py new file mode 100644 index 0000000000..67b4b708c6 --- /dev/null +++ b/scripts/ci/check_gh_comment_traps.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +r"""Detect comments whose body is a literal file path -- the `gh -f body=@file` trap. + +Context (#16866): `gh api -f body=@f.md` posts the LITERAL string `@f.md`. The +`@` expansion only works on typed fields (`-F body=@f.md`), so the comment +lands with a ~30-50 char body that is a path, with zero error on the way. Three +occurrences were measured on two seats and two OS (#16855 c.5740907673 on a +Linux container, #16766 and #16723 on Windows, 14 s apart) -- the class hits +any lane that posts a file body through `-f`. + +The behavioral rule that prevents the trap lives in +`.claude/rules/gh-posting-hygiene.md`. This organ is the measurable half: it +scans recent issue comments (the endpoint covers issue AND PR comments, since a +PR is an issue) and flags bodies that carry the trap signature, so the +escalation criterion from #16866 ("3rd occurrence") is measured, not felt. + +Verdicts: + TRAPPED at least one comment body is a literal `@` token (exit 1) + CLEAN no trapped body in the window (exit 0) + UNKNOWN network/gh failure -- infrastructure never forges a red (#14849) + (exit 0, ::warning) + +Usage: + python scripts/ci/check_gh_comment_traps.py + python scripts/ci/check_gh_comment_traps.py --hours 96 --json + python scripts/ci/check_gh_comment_traps.py --repo jsboige/CoursIA +""" +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from datetime import datetime, timedelta, timezone + +REPO = "jsboige/CoursIA" + +# A trapped body is a single token: `@` + a path containing a separator, with +# no whitespace (a user @mention never carries a path separator, and a real +# comment body has spaces). Length floor mirrors the #16866 guard: a file body +# is never < 100 chars when it actually posted. +TRAP_RE = re.compile(r"^@[^\s]*[\\/][^\s]*$") +TRAP_MAX_LEN = 100 + + +def classify_body(body: str) -> bool: + """True if the body carries the `@` literal-expansion trap signature.""" + stripped = (body or "").strip() + return len(stripped) < TRAP_MAX_LEN and bool(TRAP_RE.fullmatch(stripped)) + + +def fetch_comments(repo: str, since_iso: str) -> list[dict] | None: + """Return issue comments since a timestamp, or None on infrastructure failure.""" + from urllib.parse import quote + path = f"repos/{repo}/issues/comments?since={quote(since_iso)}&per_page=100" + cmd = ["gh", "api", "--paginate", path] + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + except (subprocess.TimeoutExpired, OSError): + return None + if out.returncode != 0: + return None + try: + return json.loads(out.stdout) + except json.JSONDecodeError: + return None + + +def scan(comments: list[dict]) -> list[dict]: + trapped = [] + for c in comments: + body = c.get("body") or "" + if classify_body(body): + trapped.append({ + "id": c.get("id"), + "url": c.get("html_url"), + "created_at": c.get("created_at"), + "user": (c.get("user") or {}).get("login"), + "body": body, + }) + return trapped + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--hours", type=int, default=48, + help="scan window in hours (default 48)") + ap.add_argument("--repo", default=REPO) + ap.add_argument("--json", action="store_true", help="machine-readable output") + args = ap.parse_args() + + since = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat() + comments = fetch_comments(args.repo, since) + if comments is None: + print("::warning ::gh api unreachable -- verdict UNKNOWN (never a red)") + if args.json: + print(json.dumps({"verdict": "UNKNOWN", "trapped": []})) + return 0 + + trapped = scan(comments) + remediation = ( + " fix: gh api repos/{repo}/issues/comments/ -X PATCH " + "-F body=@".format(repo=args.repo) + ) + if args.json: + print(json.dumps({ + "verdict": "TRAPPED" if trapped else "CLEAN", + "scanned": len(comments), + "window_hours": args.hours, + "trapped": trapped, + }, indent=2)) + else: + print(f"scanned {len(comments)} comments over the last {args.hours}h") + for t in trapped: + print(f"TRAPPED c.{t['id']} by {t['user']} at {t['created_at']}: {t['body']!r}") + print(f" {t['url']}") + print(remediation) + if not trapped: + print("CLEAN -- no literal @ body in the window") + + return 1 if trapped else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_check_gh_comment_traps.py b/scripts/tests/test_check_gh_comment_traps.py new file mode 100644 index 0000000000..8c32415c2c --- /dev/null +++ b/scripts/tests/test_check_gh_comment_traps.py @@ -0,0 +1,74 @@ +"""Offline tests for the `gh -f body=@file` trap detector (#16866). + +The three positive fixtures are the REAL trapped bodies measured in #16866 +(redacted to their path shapes); negatives cover @mentions, real bodies with +paths inside prose, and long path-like bodies above the length floor. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "ci" / "check_gh_comment_traps.py" + +spec = importlib.util.spec_from_file_location("check_gh_comment_traps", SCRIPT) +mod = importlib.util.module_from_spec(spec) +sys.modules["check_gh_comment_traps"] = mod +spec.loader.exec_module(mod) + + +# --- positives: the three measured occurrences (#16866) --- +def test_trap_linux_tmp_path(): + assert mod.classify_body("@/tmp/a16766.md") + + +def test_trap_windows_backslash_path(): + assert mod.classify_body("@C:\\Users\\jsboi\\AppData\\Local\\Temp/a16766.md") + + +def test_trap_windows_path_newline_padded(): + assert mod.classify_body("@C:\\Users\\jsboi\\AppData\\Local\\Temp/a16723.md\n") + + +# --- negatives: look-alikes that must NOT flag --- +def test_mention_is_not_a_trap(): + assert not mod.classify_body("@clusterManager-Myia peux-tu relire la PR ?") + + +def test_real_body_mentioning_a_path_in_prose(): + assert not mod.classify_body( + "Verifie dans scripts/ci/check_umbrella_freshness.py avant de conclure." + ) + + +def test_at_path_inside_longer_body(): + assert not mod.classify_body( + "Le fichier @/tmp/report.md contient le detail complet de la mesure, " + "voir la section 3 pour le verdict par cellule." + ) + + +def test_long_path_body_above_floor(): + # > 100 chars: not the silent-failure shape (a real file body that posted) + long_path = "@" + "/".join(f"d{i}" for i in range(40)) + "/body.md" + assert len(long_path) >= 100 + assert not mod.classify_body(long_path) + + +def test_empty_body(): + assert not mod.classify_body("") + assert not mod.classify_body(None) + + +# --- scan wiring --- +def test_scan_reports_id_user_and_url(): + comments = [ + {"id": 1, "body": "[CLAIMED] legit", "user": {"login": "a"}, "html_url": "u1"}, + {"id": 2, "body": "@/tmp/x.md", "user": {"login": "b"}, "html_url": "u2"}, + ] + trapped = mod.scan(comments) + assert len(trapped) == 1 + assert trapped[0]["id"] == 2 + assert trapped[0]["user"] == "b" + assert trapped[0]["url"] == "u2"