Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .claude/rules/gh-posting-hygiene.md
Original file line number Diff line number Diff line change
@@ -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/<id> --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/<id> -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)
126 changes: 126 additions & 0 deletions scripts/ci/check_gh_comment_traps.py
Original file line number Diff line number Diff line change
@@ -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 `@<path>` 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 `@<path>` 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/<id> -X PATCH "
"-F body=@<real-file.md>".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 @<path> body in the window")

return 1 if trapped else 0


if __name__ == "__main__":
sys.exit(main())
74 changes: 74 additions & 0 deletions scripts/tests/test_check_gh_comment_traps.py
Original file line number Diff line number Diff line change
@@ -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"
Loading