diff --git a/Makefile b/Makefile index 5d75a8d..3f03976 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,56 @@ # Rapport de couverture des tests Python coverage: - coverage run -m pytest - coverage html -d docs/coverage_report -.PHONY: fw hw s0 docs + python3 -m coverage run -m pytest + python3 -m coverage html -d docs/coverage_report + +CAD_STACK ?= ./tools/hw/cad_stack.sh +CAD_ARGS ?= + +.PHONY: coverage fw hw s0 docs compliance cad-up cad-down cad-ps cad-build cad-doctor cad-mcp cad-kicad cad-freecad cad-pio s0: - python tools/cockpit/cockpit.py gate_s0 + python3 tools/cockpit/cockpit.py gate_s0 fw: - python tools/cockpit/cockpit.py fw + python3 tools/cockpit/cockpit.py fw hw: @echo "usage: make hw SCHEM=hardware/kicad/

/

.kicad_sch" bash tools/hw/hw_check.sh $(SCHEM) docs: - python -m pip install -U mkdocs + python3 -m pip install -U mkdocs mkdocs build --strict compliance: - python tools/compliance/validate.py --strict + python3 tools/compliance/validate.py --strict + +cad-up: + $(CAD_STACK) up $(CAD_ARGS) + +cad-down: + $(CAD_STACK) down + +cad-ps: + $(CAD_STACK) ps + +cad-build: + $(CAD_STACK) build $(CAD_ARGS) + +cad-doctor: + $(CAD_STACK) doctor + +cad-mcp: + $(CAD_STACK) mcp $(CAD_ARGS) + +cad-kicad: + @if [ -z "$(CAD_ARGS)" ]; then echo "usage: make cad-kicad CAD_ARGS='version'"; exit 1; fi + $(CAD_STACK) kicad-cli $(CAD_ARGS) + +cad-freecad: + @if [ -z "$(CAD_ARGS)" ]; then echo "usage: make cad-freecad CAD_ARGS='-c \"import FreeCAD; print(FreeCAD.Version())\"'"; exit 1; fi + $(CAD_STACK) freecad-cmd $(CAD_ARGS) + +cad-pio: + @if [ -z "$(CAD_ARGS)" ]; then echo "usage: make cad-pio CAD_ARGS='system info'"; exit 1; fi + $(CAD_STACK) pio $(CAD_ARGS) diff --git a/gh_setup_and_patches/setup_repo.sh b/gh_setup_and_patches/setup_repo.sh index f9dfcb9..14b19d0 100755 --- a/gh_setup_and_patches/setup_repo.sh +++ b/gh_setup_and_patches/setup_repo.sh @@ -28,6 +28,7 @@ BRANCH="${BRANCH:-main}" ENABLE_DISCUSSIONS="${ENABLE_DISCUSSIONS:-0}" REQUIRE_BUILD_CHECKS="${REQUIRE_BUILD_CHECKS:-1}" DRY_RUN="${DRY_RUN:-0}" +REQUIRED_CONTEXTS_JSON="${REQUIRED_CONTEXTS_JSON:-}" if [[ -z "${REPO_FULL}" ]]; then echo "Usage: $0 " >&2 @@ -37,11 +38,32 @@ fi OWNER="${REPO_FULL%/*}" REPO="${REPO_FULL#*/}" -run() { +shell_join() { + local quoted=() + local arg + for arg in "$@"; do + quoted+=("$(printf '%q' "$arg")") + done + printf '%s' "${quoted[*]}" +} + +run_cmd() { if [[ "${DRY_RUN}" == "1" ]]; then - echo "[dry-run] $*" + printf '[dry-run] ' + shell_join "$@" + printf '\n' else - eval "$@" + "$@" + fi +} + +run_cmd_quiet() { + if [[ "${DRY_RUN}" == "1" ]]; then + printf '[dry-run] ' + shell_join "$@" + printf '\n' + else + "$@" >/dev/null fi } @@ -52,7 +74,7 @@ need_gh() { create_label() { local name="$1"; local color="$2"; local desc="$3" - run "gh label create \"${name}\" -R \"${REPO_FULL}\" --color \"${color}\" --description \"${desc}\" --force >/dev/null" + run_cmd_quiet gh label create "${name}" -R "${REPO_FULL}" --color "${color}" --description "${desc}" --force } setup_labels() { @@ -120,39 +142,55 @@ enable_discussions() { repository(owner:$owner, name:$name) { id hasDiscussionsEnabled } }' --jq '.data.repository.id')" - run "gh api graphql -f repositoryId=\"${repo_id}\" -F enabled=true -f query='\ - mutation($repositoryId:ID!, $enabled:Boolean!) {\ - updateRepository(input:{repositoryId:$repositoryId, hasDiscussionsEnabled:$enabled}) {\ - repository { name hasDiscussionsEnabled }\ - }\ - }' >/dev/null" + local mutation='mutation($repositoryId:ID!, $enabled:Boolean!) { + updateRepository(input:{repositoryId:$repositoryId, hasDiscussionsEnabled:$enabled}) { + repository { name hasDiscussionsEnabled } + } + }' + run_cmd_quiet gh api graphql -f repositoryId="${repo_id}" -F enabled=true -f query="${mutation}" echo "==> Discussions enabled." } +load_required_contexts() { + if [[ -n "${REQUIRED_CONTEXTS_JSON}" ]]; then + python3 - "${REQUIRED_CONTEXTS_JSON}" <<'PY' +import json +import sys + +payload = json.loads(sys.argv[1]) +if not isinstance(payload, list) or not all(isinstance(item, str) and item.strip() for item in payload): + raise SystemExit("REQUIRED_CONTEXTS_JSON must be a JSON array of non-empty strings") +for item in payload: + print(item) +PY + return 0 + fi + + printf '%s\n' \ + "Badges & Coverage / badges" \ + "Secret Scanning / secret_scan" \ + "Repo State / repo-state" + + if [[ "${REQUIRE_BUILD_CHECKS}" == "1" ]]; then + printf '%s\n' \ + "API Contract & Integration Testing / api_contract" \ + "Evidence Pack Validation / evidence_pack" + fi +} + setup_branch_protection() { echo "==> Configuring branch protection for ${REPO_FULL}:${BRANCH} ..." # Required check contexts (GitHub Actions job names): # These MUST match the check names you see in PR -> Checks. # If you change workflow/job names, update this list. - local -a contexts - contexts+=("PR Label Enforcement / label-enforcement") - contexts+=("Scope Guard / guard") - - if [[ "${REQUIRE_BUILD_CHECKS}" == "1" ]]; then - contexts+=("Firmware CI / pio") - contexts+=("Hardware CI (KiCad) / hw") - contexts+=("Compliance Gate / validate") - fi + local -a contexts=() + mapfile -t contexts < <(load_required_contexts) # JSON array for contexts local contexts_json - contexts_json="$(printf '%s\n' "${contexts[@]}" | python3 - <<'PY' -import sys, json -print(json.dumps([l.rstrip('\n') for l in sys.stdin if l.strip()])) -PY - )" + contexts_json="$(printf '%s\n' "${contexts[@]}" | python3 -c 'import json, sys; print(json.dumps([line.rstrip("\n") for line in sys.stdin if line.strip()]))')" local body body="$(cat < str: s = re.sub(r"`[^`]*`", "", s) return s +def remove_html_blocks(s: str) -> str: + """ + Remove paired HTML-like blocks with their content. + + This intentionally fails closed: if a user wraps sensitive content in a tag + such as `token`, the content is discarded with the tag. + """ + return re.sub(r"<([A-Za-z][A-Za-z0-9:_-]*)\b[^>]*>.*?", "", s, flags=re.DOTALL) + def strip_html_tags(s: str) -> str: """Remove all HTML tags from the input.""" return re.sub(r"<[^>]+>", "", s) @@ -83,7 +92,7 @@ def sanitize_text(s: str) -> str: 1. Remove HTML comments (already done by `strip_html_comments`). 2. Remove fenced/indented/inline code blocks. - 3. Strip remaining HTML tags. + 3. Remove HTML-like blocks with their content, then strip remaining tags. 4. Neutralize mentions, issue references and email addresses. 5. Remove lines with suspicious shell patterns. 6. Strip URLs. @@ -93,6 +102,7 @@ def sanitize_text(s: str) -> str: """ s = strip_html_comments(s) s = remove_code_blocks(s) + s = remove_html_blocks(s) s = strip_html_tags(s) s = neutralize_mentions_and_refs(s) s = remove_suspicious_patterns(s) diff --git a/tools/auto_check_ci_cd.py b/tools/auto_check_ci_cd.py index e9c05c4..921734a 100644 --- a/tools/auto_check_ci_cd.py +++ b/tools/auto_check_ci_cd.py @@ -1,22 +1,74 @@ -import subprocess +#!/usr/bin/env python3 +from __future__ import annotations -def check_all_targets(targets): - results = {} - for target in targets: +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +REPORT_PATH = ROOT / "docs" / "evidence" / "ci_cd_audit_summary.json" + + +def run_step(args: list[str]) -> dict: + proc = subprocess.run( + [sys.executable, *args], + cwd=str(ROOT), + capture_output=True, + text=True, + ) + return { + "command": [sys.executable, *args], + "returncode": proc.returncode, + "stdout": proc.stdout.strip(), + "stderr": proc.stderr.strip(), + } + + +def check_all_targets() -> tuple[dict, bool]: + report = { + "targets": {}, + "compliance": run_step(["tools/compliance/validate.py", "--strict"]), + } + failed = report["compliance"]["returncode"] != 0 + + target_steps = { + "esp": [ + ["tools/build_firmware.py", "esp"], + ["tools/collect_evidence.py", "esp"], + ["tools/verify_evidence.py", "esp"], + ], + "linux": [ + ["tools/test_firmware.py", "linux"], + ["tools/collect_evidence.py", "linux"], + ["tools/verify_evidence.py", "linux"], + ], + } + + for target, steps in target_steps.items(): print(f"\n--- Vérification {target} ---") - try: - subprocess.run(["python", "tools/build_firmware.py", target], check=True) - subprocess.run(["python", "tools/test_firmware.py", target], check=True) - subprocess.run(["python", "tools/collect_evidence.py", target], check=True) - evidence = subprocess.run(["python", "tools/verify_evidence.py", target], capture_output=True, text=True) - results[target] = evidence.stdout.strip() - except subprocess.CalledProcessError as e: - results[target] = f"Erreur: {e}" - return results + results = [] + for step in steps: + result = run_step(step) + results.append(result) + if result["stdout"]: + print(result["stdout"]) + if result["stderr"]: + print(result["stderr"], file=sys.stderr) + report["targets"][target] = results + failed = failed or any(item["returncode"] != 0 for item in results) + + REPORT_PATH.parent.mkdir(parents=True, exist_ok=True) + REPORT_PATH.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return report, failed + if __name__ == '__main__': - targets = ["esp", "stm", "linux"] - report = check_all_targets(targets) + report, failed = check_all_targets() print("\n=== Rapport de vérification ===") - for tgt, res in report.items(): - print(f"{tgt}: {res}") + print(f"compliance: rc={report['compliance']['returncode']}") + for target, steps in report["targets"].items(): + rc = max(item["returncode"] for item in steps) + print(f"{target}: rc={rc}") + print(f"rapport: {REPORT_PATH}") + raise SystemExit(1 if failed else 0) diff --git a/tools/build_firmware.py b/tools/build_firmware.py index f9b919c..5786aa4 100644 --- a/tools/build_firmware.py +++ b/tools/build_firmware.py @@ -1,20 +1,29 @@ +#!/usr/bin/env python3 +from __future__ import annotations + import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tools.ci_runtime import pio_mode, resolve_target, resolved_pio_runner, run_platformio_step + + +def build_firmware(target: str) -> int: + spec = resolve_target(target, expected_mode="build") + print( + f"Build target '{spec.requested}' via PlatformIO env '{spec.env}' " + f"(mode={pio_mode()}, runner={resolved_pio_runner(spec, 'build')})" + ) + rc = run_platformio_step(spec, "build") + if rc == 0: + print(f"Build terminé pour {spec.requested}") + return rc -def build_firmware(target): - # Exemple minimal : build pour chaque cible - if target == 'esp': - print('Build ESP...') - # Appel PlatformIO ou script ESP - elif target == 'stm': - print('Build STM...') - # Appel PlatformIO ou script STM - elif target == 'linux': - print('Build Linux...') - # Appel QEMU ou make Linux - else: - print('Cible inconnue') - sys.exit(1) - print(f'Build terminé pour {target}') if __name__ == '__main__': - build_firmware(sys.argv[1]) + if len(sys.argv) != 2: + raise SystemExit("usage: build_firmware.py ") + raise SystemExit(build_firmware(sys.argv[1])) diff --git a/tools/ci/ci_audit.py b/tools/ci/ci_audit.py index 26da6a4..934acc2 100644 --- a/tools/ci/ci_audit.py +++ b/tools/ci/ci_audit.py @@ -4,15 +4,17 @@ import glob import yaml import json -from datetime import datetime +import sys +from datetime import datetime, timezone workflows = glob.glob('.github/workflows/*.yml') report = { 'schemaVersion': 1, 'label': 'Audit CI/CD', - 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'timestamp': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'), 'workflows': {}, + 'invalid_workflows': {}, 'summary': { 'tests': 0, 'coverage': 0, @@ -20,13 +22,23 @@ report = { 'compliance': 0, 'docs': 0, 'hardware': 0, - 'ai': 0 + 'ai': 0, + 'invalid': 0 } } for wf in workflows: - with open(wf, 'r') as f: - data = yaml.safe_load(f) + with open(wf, 'r', encoding='utf-8') as f: + try: + data = yaml.safe_load(f) + except yaml.YAMLError as exc: + report['invalid_workflows'][wf] = str(exc) + report['summary']['invalid'] += 1 + continue + if not isinstance(data, dict): + report['invalid_workflows'][wf] = 'workflow did not parse to a mapping' + report['summary']['invalid'] += 1 + continue wf_name = data.get('name', wf) jobs = data.get('jobs', {}) wf_info = {'tests': False, 'coverage': False, 'badges': False, 'compliance': False, 'docs': False, 'hardware': False, 'ai': False} @@ -57,3 +69,7 @@ with open('docs/ci-audit-summary.json', 'w') as f: json.dump(report, f, indent=2) print(f"Rapport d’audit CI/CD généré : docs/ci-audit-summary.json") +if report['invalid_workflows']: + for wf, err in report['invalid_workflows'].items(): + print(f"Workflow invalide: {wf}: {err}", file=sys.stderr) + raise SystemExit(1) diff --git a/tools/ci_runtime.py b/tools/ci_runtime.py new file mode 100644 index 0000000..46132da --- /dev/null +++ b/tools/ci_runtime.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FIRMWARE_DIR = ROOT / "firmware" +EVIDENCE_ROOT = ROOT / "docs" / "evidence" +CAD_STACK = ROOT / "tools" / "hw" / "cad_stack.sh" +PIO_MODE_ENV = "KILL_LIFE_PIO_MODE" + +BUILD_ENV_BY_TARGET = { + "esp": "esp32s3_arduino", + "esp32s3_arduino": "esp32s3_arduino", + "esp32_arduino": "esp32_arduino", +} + +TEST_ENV_BY_TARGET = { + "linux": "native", + "native": "native", +} + + +@dataclass(frozen=True) +class TargetSpec: + requested: str + env: str + mode: str + + +def now_utc() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def relative_to_root(path: Path) -> str: + try: + return str(path.resolve().relative_to(ROOT)).replace("\\", "/") + except ValueError: + return str(path.resolve()) + + +def ensure_evidence_dir(target: str) -> Path: + path = EVIDENCE_ROOT / target + path.mkdir(parents=True, exist_ok=True) + return path + + +def pio_mode() -> str: + value = os.environ.get(PIO_MODE_ENV, "auto").strip().lower() or "auto" + if value not in {"auto", "native", "container"}: + raise SystemExit( + f"Unsupported {PIO_MODE_ENV}='{value}'. Supported values: auto, native, container" + ) + return value + + +def native_pio_available() -> bool: + return shutil.which("pio") is not None + + +def container_pio_available() -> bool: + return CAD_STACK.exists() and shutil.which("bash") is not None and shutil.which("docker") is not None + + +def firmware_project_dir() -> str: + return relative_to_root(FIRMWARE_DIR) + + +def resolve_target(target: str, expected_mode: str | None = None) -> TargetSpec: + if target in BUILD_ENV_BY_TARGET: + spec = TargetSpec(requested=target, env=BUILD_ENV_BY_TARGET[target], mode="build") + elif target in TEST_ENV_BY_TARGET: + spec = TargetSpec(requested=target, env=TEST_ENV_BY_TARGET[target], mode="test") + else: + supported = sorted({*BUILD_ENV_BY_TARGET.keys(), *TEST_ENV_BY_TARGET.keys()}) + raise SystemExit(f"Unsupported target '{target}'. Supported targets: {', '.join(supported)}") + + if expected_mode and spec.mode != expected_mode: + raise SystemExit(f"Target '{target}' only supports mode '{spec.mode}', not '{expected_mode}'") + return spec + + +def platformio_command(spec: TargetSpec, step: str) -> tuple[list[str], Path, str]: + mode = pio_mode() + native_available = native_pio_available() + use_container = mode == "container" or (mode == "auto" and not native_available) + + pio_subcommand = "run" if step == "build" else "test" + project_dir = firmware_project_dir() + args = [pio_subcommand, "-d", project_dir, "-e", spec.env] + + if use_container: + return ["bash", str(CAD_STACK), "pio", *args], ROOT, "cad-stack-container" + + return ["pio", *args], ROOT, "native-pio" + + +def resolved_pio_runner(spec: TargetSpec, step: str) -> str: + return platformio_command(spec, step)[2] + + +def run_logged_command(spec: TargetSpec, step: str, cmd: list[str], cwd: Path = FIRMWARE_DIR) -> int: + evidence_dir = ensure_evidence_dir(spec.requested) + runner = "subprocess" + try: + result = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True) + stdout = result.stdout + stderr = result.stderr + returncode = result.returncode + except FileNotFoundError as exc: + stdout = "" + stderr = str(exc) + returncode = 127 + (evidence_dir / f"{step}.stdout.txt").write_text(stdout, encoding="utf-8") + (evidence_dir / f"{step}.stderr.txt").write_text(stderr, encoding="utf-8") + write_json( + evidence_dir / f"{step}.result.json", + { + "target": spec.requested, + "env": spec.env, + "mode": spec.mode, + "step": step, + "cwd": relative_to_root(cwd), + "command": cmd, + "runner": runner, + "returncode": returncode, + "generated_at_utc": now_utc(), + }, + ) + return returncode + + +def run_platformio_step(spec: TargetSpec, step: str) -> int: + cmd, cwd, runner = platformio_command(spec, step) + evidence_dir = ensure_evidence_dir(spec.requested) + try: + result = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True) + stdout = result.stdout + stderr = result.stderr + returncode = result.returncode + except FileNotFoundError as exc: + stdout = "" + stderr = str(exc) + returncode = 127 + + (evidence_dir / f"{step}.stdout.txt").write_text(stdout, encoding="utf-8") + (evidence_dir / f"{step}.stderr.txt").write_text(stderr, encoding="utf-8") + write_json( + evidence_dir / f"{step}.result.json", + { + "target": spec.requested, + "env": spec.env, + "mode": spec.mode, + "step": step, + "cwd": relative_to_root(cwd), + "project_dir": firmware_project_dir(), + "command": cmd, + "runner": runner, + "returncode": returncode, + "generated_at_utc": now_utc(), + }, + ) + return returncode + + +def collect_artifacts(spec: TargetSpec) -> list[str]: + base = FIRMWARE_DIR / ".pio" / "build" / spec.env + if spec.mode == "build": + candidates = [ + base / "firmware.bin", + base / "firmware.elf", + base / "firmware.map", + base, + ] + else: + candidates = [base] + return [relative_to_root(path) for path in candidates if path.exists()] diff --git a/tools/collect_evidence.py b/tools/collect_evidence.py index 4c453ad..3c59807 100644 --- a/tools/collect_evidence.py +++ b/tools/collect_evidence.py @@ -1,11 +1,51 @@ +#!/usr/bin/env python3 +from __future__ import annotations + import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tools.ci_runtime import collect_artifacts, ensure_evidence_dir, now_utc, resolve_target, write_json + + +def collect_evidence(target: str) -> bool: + spec = resolve_target(target) + evidence_dir = ensure_evidence_dir(spec.requested) + prefix = "build" if spec.mode == "build" else "test" + required = [ + evidence_dir / f"{prefix}.result.json", + evidence_dir / f"{prefix}.stdout.txt", + evidence_dir / f"{prefix}.stderr.txt", + ] + artifacts = collect_artifacts(spec) + missing = [path.name for path in required if not path.exists()] + if not artifacts: + missing.append("artifacts") + + summary = { + "target": spec.requested, + "env": spec.env, + "mode": spec.mode, + "generated_at_utc": now_utc(), + "required_files": [path.name for path in required], + "artifacts": artifacts, + "status": "ok" if not missing else "incomplete", + "missing": missing, + } + write_json(evidence_dir / "summary.json", summary) + + if missing: + print(f"Evidence pack incomplet pour {spec.requested}: {', '.join(missing)}") + return False + + print(f"Evidence pack généré pour {spec.requested}: {evidence_dir}") + return True -def collect_evidence(target): - # Exemple minimal : collecte logs, binaries, rapports - print(f'Collecte evidence pack pour {target}...') - # Simuler la collecte - # À compléter avec la vraie logique - print(f'Evidence pack généré pour {target}') if __name__ == '__main__': - collect_evidence(sys.argv[1]) + if len(sys.argv) != 2: + raise SystemExit("usage: collect_evidence.py ") + raise SystemExit(0 if collect_evidence(sys.argv[1]) else 1) diff --git a/tools/compliance/scan_rfc2119.py b/tools/compliance/scan_rfc2119.py index 0492354..28fc4af 100644 --- a/tools/compliance/scan_rfc2119.py +++ b/tools/compliance/scan_rfc2119.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -# Scan RFC2119 conformité dans docs/specs/*.md et génère docs/rfc2119-summary.json +# Scan RFC2119 conformité dans specs/*.md et génère docs/rfc2119-summary.json -import glob import json import re -from datetime import datetime +from datetime import datetime, timezone +from pathlib import Path -files = glob.glob('docs/specs/*.md') +files = sorted(Path("specs").rglob("*.md")) rfc_terms = ['MUST', 'SHOULD', 'MAY'] forbidden = [r'must', r'should', r'may', r'Must', r'Should', r'May'] @@ -16,24 +16,25 @@ summary = { 'label': 'Conformité RFC2119', 'counts': {t: 0 for t in rfc_terms}, 'forbidden': [], - 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'timestamp': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'), 'files': {} } for f in files: - with open(f, 'r') as fd: + with open(f, 'r', encoding='utf-8') as fd: content = fd.read() - summary['files'][f] = {'MUST': 0, 'SHOULD': 0, 'MAY': 0, 'forbidden': []} + key = str(f).replace("\\", "/") + summary['files'][key] = {'MUST': 0, 'SHOULD': 0, 'MAY': 0, 'forbidden': []} for t in rfc_terms: summary['counts'][t] += len(re.findall(rf'\b{t}\b', content)) - summary['files'][f][t] = len(re.findall(rf'\b{t}\b', content)) + summary['files'][key][t] = len(re.findall(rf'\b{t}\b', content)) for forb in forbidden: matches = re.findall(rf'\b{forb}\b', content) if matches: summary['forbidden'] += matches - summary['files'][f]['forbidden'] += matches + summary['files'][key]['forbidden'] += matches -with open('docs/rfc2119-summary.json', 'w') as fd: +with open('docs/rfc2119-summary.json', 'w', encoding='utf-8') as fd: json.dump(summary, fd, indent=2) print(f"Conformité RFC2119 : {summary['counts']} | Interdits : {summary['forbidden']}") diff --git a/tools/compliance/use_profile.py b/tools/compliance/use_profile.py index 06c672d..3c8063f 100644 --- a/tools/compliance/use_profile.py +++ b/tools/compliance/use_profile.py @@ -1,20 +1,30 @@ #!/usr/bin/env python3 """Switch active compliance profile.""" -from pathlib import Path import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + from tools.compliance.common import repo_path, save_yaml def main(): ap = argparse.ArgumentParser() - ap.add_argument("profile", help="Profile name (e.g., prototype, iot_wifi_eu)") + ap.add_argument("profile", nargs="?", help="Profile name (e.g., prototype, iot_wifi_eu)") + ap.add_argument("--profile", dest="profile_flag", help="Profile name (alias for the positional argument)") args = ap.parse_args() + profile_name = args.profile_flag or args.profile + if not profile_name: + ap.error("a profile name is required") - p = repo_path(f"compliance/profiles/{args.profile}.yaml") + p = repo_path(f"compliance/profiles/{profile_name}.yaml") if not p.exists(): - raise SystemExit(f"ERROR: unknown profile: {args.profile} (missing {p})") + raise SystemExit(f"ERROR: unknown profile: {profile_name} (missing {p})") - save_yaml(repo_path("compliance/active_profile.yaml"), {"profile": args.profile}) - print(f"Active compliance profile = {args.profile}") + save_yaml(repo_path("compliance/active_profile.yaml"), {"profile": profile_name}) + print(f"Active compliance profile = {profile_name}") if __name__ == "__main__": main() diff --git a/tools/compliance/validate.py b/tools/compliance/validate.py index a8c3df3..e4d4a9a 100644 --- a/tools/compliance/validate.py +++ b/tools/compliance/validate.py @@ -9,7 +9,11 @@ from pathlib import Path import argparse import glob -import os +import sys + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) from tools.compliance.common import ( repo_path, load_active_profile_name, load_profile, load_catalog, load_yaml diff --git a/tools/mistral/apply_safe_patch.py b/tools/mistral/apply_safe_patch.py index 5162fd6..a7d6f75 100644 --- a/tools/mistral/apply_safe_patch.py +++ b/tools/mistral/apply_safe_patch.py @@ -11,13 +11,20 @@ from __future__ import annotations import argparse import json +import re +import sys from pathlib import Path +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + from jsonschema import Draft202012Validator from tools.mistral.scope_allowlists import is_path_allowed, explain_scope SCHEMA_PATH = Path(__file__).parent / "schemas" / "safe_patch.schema.json" +WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[/\\]") def safe_write(path: Path, content: str) -> None: @@ -25,6 +32,27 @@ def safe_write(path: Path, content: str) -> None: path.write_text(content, encoding="utf-8") +def normalize_relative_path(raw_path: str) -> str: + rel = raw_path.replace("\\", "/").strip() + while rel.startswith("./"): + rel = rel[2:] + if not rel or rel.startswith("/") or WINDOWS_DRIVE_RE.match(rel): + raise SystemExit(f"Refusing absolute patch path: {raw_path}") + parts = rel.split("/") + if any(part in ("", ".", "..") for part in parts): + raise SystemExit(f"Refusing unsafe patch path: {raw_path}") + return "/".join(parts) + + +def resolve_destination(root: Path, rel: str) -> Path: + dst = (root / rel).resolve() + try: + dst.relative_to(root) + except ValueError as exc: + raise SystemExit(f"Refusing out-of-repo path: {rel}") from exc + return dst + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--scope", required=True, help="ai:spec|ai:plan|ai:tasks|ai:impl|ai:qa|ai:docs") @@ -48,7 +76,7 @@ def main() -> int: edits = patch.get("edits", []) blocked = [] for e in edits: - p = e["path"].replace("\\", "/").lstrip("/") + p = normalize_relative_path(e["path"]) if not is_path_allowed(args.scope, p): blocked.append(p) @@ -64,9 +92,9 @@ def main() -> int: applied = [] for e in edits: - rel = e["path"].replace("\\", "/").lstrip("/") + rel = normalize_relative_path(e["path"]) action = e["action"] - dst = root / rel + dst = resolve_destination(root, rel) if action in ("create", "update"): safe_write(dst, e.get("content", "")) @@ -74,6 +102,8 @@ def main() -> int: elif action == "delete": if not args.allow_delete: raise SystemExit(f"Refusing to delete {rel} (pass --allow-delete)") + if dst.is_dir(): + raise SystemExit(f"Refusing to delete directory {rel}") if dst.exists(): dst.unlink() applied.append(f"delete: {rel}") diff --git a/tools/mistral/schemas/safe_patch.schema.json b/tools/mistral/schemas/safe_patch.schema.json index 4d8b89b..c7e51cd 100644 --- a/tools/mistral/schemas/safe_patch.schema.json +++ b/tools/mistral/schemas/safe_patch.schema.json @@ -25,7 +25,8 @@ "properties": { "path": { "type": "string", - "minLength": 1 + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$)).+$" }, "action": { "type": "string", @@ -90,4 +91,4 @@ "type": "string" } } -} \ No newline at end of file +} diff --git a/tools/mistral/scope_allowlists.py b/tools/mistral/scope_allowlists.py index 6c2521c..7cb968c 100644 --- a/tools/mistral/scope_allowlists.py +++ b/tools/mistral/scope_allowlists.py @@ -5,71 +5,4 @@ This is NOT a replacement for CI scope guards; it is a local "pre-flight" safety """ from __future__ import annotations -import fnmatch -from typing import List - - -DENY_GLOBS = [ - ".git/**", - ".github/workflows/**", - ".github/actions/**", - "**/.env", - "**/*.key", - "**/*secret*", - "**/*token*", -] - -ALLOWED_BY_SCOPE = { - "ai:spec": [ - "specs/**", - "docs/**", - "README.md", - ".github/copilot-instructions.md", - ".github/copilot/**", - ".github/prompts/**", - ], - "ai:plan": [ - "specs/**", - "docs/**", - "README.md", - ], - "ai:tasks": [ - "specs/**", - "docs/**", - ], - "ai:docs": [ - "docs/**", - "README.md", - ".github/copilot-instructions.md", - ".github/copilot/**", - ".github/prompts/**", - ], - "ai:impl": [ - "firmware/**", - "docs/**", - "specs/**", - "tools/**", - ], - "ai:qa": [ - "firmware/**", - "tools/**", - "docs/**", - "specs/**", - ], -} - -def _matches_any(path: str, globs: List[str]) -> bool: - return any(fnmatch.fnmatch(path, g) for g in globs) - -def is_path_allowed(scope: str, path: str) -> bool: - path = path.replace("\\", "/").lstrip("/") - if _matches_any(path, DENY_GLOBS): - return False - allow = ALLOWED_BY_SCOPE.get(scope) - if not allow: - return False - return _matches_any(path, allow) - -def explain_scope(scope: str) -> str: - allow = ALLOWED_BY_SCOPE.get(scope, []) - return f"{scope} allows: " + ", ".join(allow) +from tools.scope_policy import explain_scope, is_path_allowed diff --git a/tools/scope_guard.py b/tools/scope_guard.py index c888dad..f798246 100644 --- a/tools/scope_guard.py +++ b/tools/scope_guard.py @@ -1,5 +1,3 @@ -# Easter Egg musique expérimentale -# _« Le scope guard veille comme Éliane Radigue : lent, précis, et toujours prêt à vibrer en silence. »_ #!/usr/bin/env python3 """ Scope guard for AI‑driven pull requests. @@ -28,30 +26,15 @@ import json import os import subprocess import sys +from pathlib import Path from typing import List +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) -# Mapping of ai:* labels to allowed directory prefixes. The keys should -# correspond exactly to the labels used in your workflow. These prefixes are -# relative to the repository root. If a file's path starts with any of -# these prefixes or matches exactly, it is considered allowed. -ALLOWLIST = { - "ai:spec": ["specs/", "docs/", "README.md"], - "ai:plan": ["specs/", "docs/", "README.md"], - "ai:tasks": ["specs/features/", "docs/", "README.md"], - "ai:impl": ["firmware/", "tools/hw/", "tools/ai/", "tools/compliance/", "docs/auto_generated/", "README.md"], - "ai:qa": ["firmware/test/", "docs/", "README.md"], - "ai:docs": ["docs/", "README.md"], -} +from tools.scope_policy import explain_scope, is_path_allowed -# Files or directories that must never be modified by AI automations. If a -# modified file starts with any of these patterns, the guard fails. -DENYLIST = [ - ".github/workflows/", # workflows are controlled manually - "openclaw/", # openclaw configuration is managed separately - "tools/ai/sanitize_issue.py", # sanitation logic is security‑sensitive - "tools/scope_guard.py", # this script itself -] def get_labels_from_event() -> List[str]: """Return a list of label names from the GitHub event JSON, if present.""" @@ -111,18 +94,7 @@ def get_changed_files() -> List[str]: def is_allowed(file_path: str, label: str) -> bool: """Check if a file_path is allowed for the given label.""" - # Denylist check first - for deny in DENYLIST: - if file_path.startswith(deny): - return False - allowed_prefixes = ALLOWLIST.get(label) - if not allowed_prefixes: - # Unknown label uses the default 'ai:impl' - allowed_prefixes = ALLOWLIST.get("ai:impl", []) - for prefix in allowed_prefixes: - if file_path == prefix or file_path.startswith(prefix): - return True - return False + return is_path_allowed(label, file_path) def main() -> int: @@ -141,9 +113,9 @@ def main() -> int: print(f" - {f}") print("See tools/scope_guard.py for the policy details.") return 1 - print(f"Scope guard passed. Label '{label}' allows changes to: {', '.join(ALLOWLIST.get(label, []))}") + print(f"Scope guard passed. {explain_scope(label)}") return 0 if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/tools/scope_policy.py b/tools/scope_policy.py new file mode 100644 index 0000000..2c97bb5 --- /dev/null +++ b/tools/scope_policy.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import fnmatch +import re + +DENY_GLOBS = [ + ".git/**", + ".github/workflows/**", + ".github/actions/**", + "**/.env", + "**/*.key", + "**/*secret*", + "**/*token*", +] + +ALLOWED_BY_SCOPE = { + "ai:spec": [ + "specs/**", + "docs/**", + "README.md", + ".github/copilot-instructions.md", + ".github/copilot/**", + ".github/prompts/**", + ], + "ai:plan": [ + "specs/**", + "docs/**", + "README.md", + ], + "ai:tasks": [ + "specs/**", + "docs/**", + ], + "ai:docs": [ + "docs/**", + "README.md", + ".github/copilot-instructions.md", + ".github/copilot/**", + ".github/prompts/**", + ], + "ai:impl": [ + "firmware/**", + "docs/**", + "specs/**", + "tools/**", + ], + "ai:qa": [ + "firmware/**", + "tools/**", + "docs/**", + "specs/**", + ], +} + +WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:/") + + +def normalize_path(path: str) -> str: + path = path.replace("\\", "/").strip() + while path.startswith("./"): + path = path[2:] + return path + + +def has_safe_segments(path: str) -> bool: + if not path or path.startswith("/") or WINDOWS_DRIVE_RE.match(path): + return False + return all(part not in ("", ".", "..") for part in path.split("/")) + + +def matches_any(path: str, patterns: list[str]) -> bool: + return any(fnmatch.fnmatch(path, pattern) for pattern in patterns) + + +def is_path_allowed(scope: str, path: str) -> bool: + path = normalize_path(path) + if not has_safe_segments(path) or matches_any(path, DENY_GLOBS): + return False + allow = ALLOWED_BY_SCOPE.get(scope) + if not allow: + return False + return matches_any(path, allow) + + +def explain_scope(scope: str) -> str: + allow = ALLOWED_BY_SCOPE.get(scope, []) + return f"{scope} allows: " + ", ".join(allow) diff --git a/tools/test_firmware.py b/tools/test_firmware.py index 9bcb78f..df29abc 100644 --- a/tools/test_firmware.py +++ b/tools/test_firmware.py @@ -1,20 +1,29 @@ +#!/usr/bin/env python3 +from __future__ import annotations + import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tools.ci_runtime import pio_mode, resolve_target, resolved_pio_runner, run_platformio_step + + +def test_firmware(target: str) -> int: + spec = resolve_target(target, expected_mode="test") + print( + f"Tests target '{spec.requested}' via PlatformIO env '{spec.env}' " + f"(mode={pio_mode()}, runner={resolved_pio_runner(spec, 'test')})" + ) + rc = run_platformio_step(spec, "test") + if rc == 0: + print(f"Tests terminés pour {spec.requested}") + return rc -def test_firmware(target): - # Exemple minimal : tests pour chaque cible - if target == 'esp': - print('Tests ESP...') - # Appel tests unitaires ESP - elif target == 'stm': - print('Tests STM...') - # Appel tests unitaires STM - elif target == 'linux': - print('Tests Linux...') - # Appel tests unitaires Linux - else: - print('Cible inconnue') - sys.exit(1) - print(f'Tests terminés pour {target}') if __name__ == '__main__': - test_firmware(sys.argv[1]) + if len(sys.argv) != 2: + raise SystemExit("usage: test_firmware.py ") + raise SystemExit(test_firmware(sys.argv[1])) diff --git a/tools/verify_evidence.py b/tools/verify_evidence.py index a234efe..b8ebef1 100644 --- a/tools/verify_evidence.py +++ b/tools/verify_evidence.py @@ -1,19 +1,44 @@ -import os -import sys +#!/usr/bin/env python3 +from __future__ import annotations -def verify_evidence(target): - evidence_dir = f"docs/evidence/{target}" - if os.path.exists(evidence_dir): - files = os.listdir(evidence_dir) - if files: - print(f"Evidence pack trouvé pour {target} : {files}") - return True - else: - print(f"Evidence pack vide pour {target}") - return False - else: - print(f"Evidence pack absent pour {target}") +import json +import sys +from pathlib import Path + +BOOTSTRAP_ROOT = Path(__file__).resolve().parents[1] +if str(BOOTSTRAP_ROOT) not in sys.path: + sys.path.insert(0, str(BOOTSTRAP_ROOT)) + +from tools.ci_runtime import ROOT, ensure_evidence_dir + + +def verify_evidence(target: str) -> bool: + evidence_dir = ensure_evidence_dir(target) + summary_path = evidence_dir / "summary.json" + if not summary_path.exists(): + print(f"Evidence pack absent pour {target}: {summary_path}") return False + summary = json.loads(summary_path.read_text(encoding="utf-8")) + if summary.get("status") != "ok": + print(f"Evidence pack invalide pour {target}: {summary.get('missing', [])}") + return False + + missing = [] + for rel in summary.get("artifacts", []): + path = ROOT / rel + if not path.exists(): + missing.append(rel) + + if missing: + print(f"Artifacts manquants pour {target}: {missing}") + return False + + print(f"Evidence pack trouvé pour {target}: {summary.get('artifacts', [])}") + return True + + if __name__ == '__main__': - verify_evidence(sys.argv[1]) + if len(sys.argv) != 2: + raise SystemExit("usage: verify_evidence.py ") + raise SystemExit(0 if verify_evidence(sys.argv[1]) else 1)