From d2b622f28e55f71745bebb4eabab73a5dc5839f4 Mon Sep 17 00:00:00 2001 From: Chaim Date: Wed, 10 Jun 2026 09:40:42 +0000 Subject: [PATCH] =?UTF-8?q?feat(ci):=20G12=20leak-guard=20=E2=80=94=20enfo?= =?UTF-8?q?rce=20the=20Agent=20Platform=20Port=20seam=20(R4,=20#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit המאכף האוטומטי של INV-G12 (docs/spec/X15 §4). שני כללים קשיחים: 1. mcp-server/src (שכבת-האינטליגנציה) ללא סמלי-Paperclip — allowlist מנומק לפי substring ל-6 ההפניות הלגיטימיות (pm2-bridge + הערות-מקור company_id). 2. import seam — רק web/agent_platform_port.py (+ קבצי-המעטפת) מייבאים paperclip_*. מימוש קנוני אחד (scripts/leak_guard.py, stdlib-בלבד), משותף לשלושה אכיפנים (G2): • CI hard gate: .gitea/workflows/leak-guard.yaml (pull_request + push→main) • pytest: mcp-server/tests/test_platform_port_leak_guard.py (כולל self-test שמוודא שה-guard תופס הזרקה — לא ירקב) • hook בזמן-אמת: spec-guard.sh בודק את התוכן-הנכתב (new_string/content) על כתיבה ל-mcp-server/src ומזהיר על הזרקת-Paperclip (לא-deduped); תזכורת-הספ עודכנה ל-G1–G12. מחריג קבצים-נוצרים (web-ui types.ts) ומעטפת מוצהרת; הפרונט מחוץ להיקף-האינטליגנציה (ממצא R3). עודכן scripts/SCRIPTS.md. אימות: סריקה נקייה exit 0; הזרקת pc.sh ל-mcp-server → exit 1; seam-violation ב-web → exit 1; hook מזהיר על mcp-server ומזכיר-ספ על web; pytest 3 passed; bash -n + YAML תקינים. Invariants: G12 (אכיפה), G2 (מאכף יחיד לשלושה צרכנים). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/leak-guard.yaml | 22 +++ .../tests/test_platform_port_leak_guard.py | 54 ++++++ scripts/SCRIPTS.md | 3 +- scripts/leak_guard.py | 172 ++++++++++++++++++ scripts/spec-guard.sh | 34 +++- 5 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 .gitea/workflows/leak-guard.yaml create mode 100644 mcp-server/tests/test_platform_port_leak_guard.py create mode 100644 scripts/leak_guard.py diff --git a/.gitea/workflows/leak-guard.yaml b/.gitea/workflows/leak-guard.yaml new file mode 100644 index 0000000..1ce37a0 --- /dev/null +++ b/.gitea/workflows/leak-guard.yaml @@ -0,0 +1,22 @@ +name: G12 Leak-Guard + +# Hard gate for INV-G12 (docs/spec/X15 §4 / R4): the intelligence layer +# (mcp-server/src) must stay free of Paperclip-specific symbols, and only +# web/agent_platform_port.py may import the Paperclip client. Pure-stdlib check +# (no venv) — fast, runs on every PR and on push to main. + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + leak-guard: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: G12 — Agent Platform Port leak-guard + run: python3 scripts/leak_guard.py diff --git a/mcp-server/tests/test_platform_port_leak_guard.py b/mcp-server/tests/test_platform_port_leak_guard.py new file mode 100644 index 0000000..bac67e6 --- /dev/null +++ b/mcp-server/tests/test_platform_port_leak_guard.py @@ -0,0 +1,54 @@ +"""CI fitness-test for INV-G12 (docs/spec/X15 §4 / R4) — the Agent Platform Port. + +Hard gate: the intelligence layer (``mcp-server/src``) must contain ZERO +Paperclip-specific symbols, and only ``web/agent_platform_port.py`` (+ the shell +itself) may import the Paperclip client. The check lives in +``scripts/leak_guard.py`` (one canonical implementation, shared with the +interactive ``spec-guard.sh`` hook); this test runs it and fails the build on any +violation. + +Runs OFFLINE — pure source scan, no DB / no imports of the app. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +_GUARD = REPO / "scripts" / "leak_guard.py" + + +def _load_guard(): + spec = importlib.util.spec_from_file_location("leak_guard", _GUARD) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[union-attr] + return mod + + +def test_leak_guard_script_exists() -> None: + assert _GUARD.is_file(), "scripts/leak_guard.py is missing (R4)" + + +def test_intelligence_layer_is_platform_clean() -> None: + """No Paperclip symbols in mcp-server/src; import seam intact (INV-G12).""" + guard = _load_guard() + violations = guard.scan() + assert not violations, ( + "INV-G12 leak-guard found Platform Port violations:\n" + + "\n".join(f" • {v}" for v in violations) + ) + + +def test_guard_detects_an_injected_intelligence_leak(tmp_path: Path) -> None: + """The guard must FAIL on a planted Paperclip symbol (so it can't rot).""" + guard = _load_guard() + probe = REPO / "mcp-server" / "src" / "legal_mcp" / "_leakguard_selftest.py" + probe.write_text('BAD = "use pc.sh wakeup directly"\n', encoding="utf-8") + try: + violations = guard.scan() + assert any("_leakguard_selftest.py" in v for v in violations), ( + "leak-guard failed to detect a planted intelligence-layer leak" + ) + finally: + probe.unlink(missing_ok=True) diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index cf3559f..0b45b1b 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -9,7 +9,8 @@ | Script | Type | Purpose | Scheduled | |--------|------|---------|-----------| | `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים | -| `spec-guard.sh` | bash | **PreToolUse hook לאכיפת "פרוטוקול כתיבת-קוד"** (CLAUDE.md §פרוטוקול כתיבת-קוד) — בכל Edit/Write/MultiEdit על נתיב-קוד (`web/`, `mcp-server/`, `web-ui/src/`, `scripts/`, `adapters/`) מזריק תזכורת ל-Claude לקרוא את `docs/spec/00-constitution.md`+ספ-התחום ולוודא קיום G1–G11 — לפני שכותבים. המקבילה האינטראקטיבית ל-INV-AG1 (שאוכף על סוכני Paperclip ב-HEARTBEAT.md §"קריאת-ספ"). קלט JSON ב-stdin (`.tool_input.file_path`), פלט `hookSpecificOutput.additionalContext` (non-blocking, exit 0). מחריג `.md`/`docs/`/`tests/`/artifacts. Dedup פעם-בסשן (`$TMPDIR/.spec-guard-`). רשום ב-`.claude/settings.json`. | נקרא אוטומטית ע"י Claude Code (hook) | +| `spec-guard.sh` | bash | **PreToolUse hook לאכיפת "פרוטוקול כתיבת-קוד"** (CLAUDE.md §פרוטוקול כתיבת-קוד) — בכל Edit/Write/MultiEdit על נתיב-קוד (`web/`, `mcp-server/`, `web-ui/src/`, `scripts/`, `adapters/`) מזריק תזכורת ל-Claude לקרוא את `docs/spec/00-constitution.md`+ספ-התחום ולוודא קיום G1–G12 — לפני שכותבים. **+ leak-guard בזמן-אמת (G12):** על כתיבה ל-`mcp-server/src/*` בודק את התוכן-הנכתב (`new_string`/`content`) ומזהיר אם מוזרק מונח-Paperclip לשכבת-האינטליגנציה (לא-deduped). המקבילה האינטראקטיבית ל-INV-AG1. קלט JSON ב-stdin, פלט `hookSpecificOutput.additionalContext` (non-blocking, exit 0). Dedup פעם-בסשן לתזכורת-הספ. רשום ב-`.claude/settings.json`. | נקרא אוטומטית ע"י Claude Code (hook) | +| `leak_guard.py` | python | **המאכף הקנוני של INV-G12 (שער-הפלטפורמה / docs/spec/X15 §4 / R4).** שני כללים קשיחים: (1) `mcp-server/src` ללא סמלי-Paperclip (allowlist מנומק לפי substring); (2) רק `web/agent_platform_port.py` (+ קבצי-המעטפת) מייבאים את לקוח-Paperclip. stdlib-בלבד (אין venv). `leak_guard.py` = סריקת-repo (exit 1 על הפרה); `leak_guard.py ...` = קבצים נתונים (ל-hook). משותף ל-spec-guard.sh (hook), ל-CI (`.gitea/workflows/leak-guard.yaml`) ול-`mcp-server/tests/test_platform_port_leak_guard.py`. | CI + hook + pytest | | `migrate_gap51_outcomes.py` | python | **GAP-51 (FU-14)** — נרמול ערכי `outcome` לאוצר הקנוני (rejected→rejection, accepted→full_acceptance, partial→partial_acceptance) ב-`decisions.outcome` + `cases.expected_outcome`. `betterment_levy` לא ממופה (practice_area, לא outcome). `--dry-run` (ברירת-מחדל) / `--apply` (גיבוי ל-`data/audit/gap51-outcome-backup-*.csv` + UPDATE טרנזקציוני). דורש POSTGRES_URL. בוצע 2026-06-06 (9 שורות). נוגע רק ב-cases/decisions — בטוח במקביל לחילוץ. | חד-פעמי (בוצע) | | `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס | | `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — `--apply` מדלג על הסוכן; `--verify` מדווח אותו רם כ-DRIFT.** בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות. **`--verify` יוצא exit≠0 על כל drift** (needs-sync / adapter-mismatch / missing-in-mirror) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a). | ידני אחרי כל שינוי | diff --git a/scripts/leak_guard.py b/scripts/leak_guard.py new file mode 100644 index 0000000..6377840 --- /dev/null +++ b/scripts/leak_guard.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""G12 leak-guard — enforce the Agent Platform Port seam (docs/spec/X15 §4 / R4). + +The single, canonical checker for INV-G12. Used by BOTH the interactive +PreToolUse hook (``scripts/spec-guard.sh``, warn-only) and the CI fitness-test +(``mcp-server/tests/test_platform_port_leak_guard.py``, hard fail) — one +implementation, no parallel rule (G2). + +Two HARD rules: + + 1. **Intelligence layer is platform-clean.** ``mcp-server/src`` (the MCP tools + + decision/RAG/extraction logic) contains ZERO Paperclip-specific symbols. + A short, explicit baseline allowlist (``_ALLOW``) covers pre-existing benign + prose mentions (the origin of ``company_id``) and the host pm2 bridge that + legitimately names the ``paperclip`` service — keyed by substring so it + survives line-number shifts. + + 2. **Import seam.** Only ``web/agent_platform_port.py`` (the Port) and the + declared shell itself (``web/paperclip_client.py`` / ``web/paperclip_api.py``) + may import ``web.paperclip_client`` / ``web.paperclip_api``. Any other file + in ``web/`` that imports them is a violation (R2 established the seam). + +OUT OF SCOPE (not intelligence): the declared shell (paperclip_client/api, +plugin-legal-ai, adapters, web-ui settings paperclip-tab / paperclip-agents, +skills/new-company-setup), and AUTO-GENERATED files (web-ui/src/lib/api/types.ts +mirrors the backend OpenAPI — governed by the backend, not hand-fixable). + +Usage: + leak_guard.py # scan the whole repo; exit 1 on any violation + leak_guard.py ... # scan only the given files (the spec-guard hook) +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# Paperclip-specific symbols that must never appear in the intelligence layer. +HARD = re.compile( + r"paperclip|Paperclip|PAPERCLIP|wakeup|heartbeat|HEARTBEAT|pc_request|" + r"pc\.sh|X-Paperclip|agent_wakeup|heartbeat_run|ctx\.agents|issueId" +) + +# Intelligence layer — rule 1 applies here (zero hard terms, save the allowlist). +PROTECTED_DIRS = ["mcp-server/src"] + +# Baseline allowlist: (path-suffix, substring-in-line). A hard-term hit is allowed +# only if its file ends with AND the line contains . +# Keep this list SHORT and justified — every entry is a documented exception. +_ALLOW: list[tuple[str, str]] = [ + # Host pm2 bridge legitimately lists the 'paperclip' service (ops, not intel). + ("court_fetch_service/server.py", "pm2 status of legal-* / paperclip services"), + ("court_fetch_service/server.py", '("legal-", "paperclip")'), + ("court_fetch_service/server.py", "never paperclip or arbitrary processes"), + # Prose comments naming the ORIGIN of a stored field — not code coupling. + ("services/db.py", "Paperclip company UUID"), + ("services/db.py", "from a Paperclip issue"), + ("services/db.py", "The Paperclip project"), +] + +# Import-seam — rule 2. Only these web/ files may import the Paperclip client. +SEAM_ALLOWED = { + "web/agent_platform_port.py", # the Port + "web/paperclip_client.py", # the shell itself + "web/paperclip_api.py", # the shell itself +} +SEAM_IMPORT = re.compile(r"^\s*(from\s+web\.paperclip_(client|api)\s+import|" + r"import\s+web\.paperclip_(client|api)\b)") + +_SKIP_PARTS = {".venv", "node_modules", "__pycache__", ".git", ".next"} + + +def _is_test(p: Path) -> bool: + return "tests" in p.parts or "test" in p.parts or p.name.startswith("test_") + + +def _skip(p: Path) -> bool: + return any(part in _SKIP_PARTS for part in p.parts) + + +def _allowed(rel: str, line: str) -> bool: + return any(rel.endswith(suf) and sub in line for suf, sub in _ALLOW) + + +def _iter_py(base: Path): + for p in base.rglob("*.py"): + if not _skip(p) and not _is_test(p): + yield p + + +def scan(files: list[Path] | None = None) -> list[str]: + """Return a list of violation strings (empty == clean).""" + violations: list[str] = [] + + # Rule 1 — intelligence layer is platform-clean. + if files is None: + targets = [p for d in PROTECTED_DIRS for p in _iter_py(REPO / d)] + else: + prot = [REPO / d for d in PROTECTED_DIRS] + targets = [ + p for p in files + if any(prot_d in p.resolve().parents or p.resolve() == prot_d + for prot_d in prot) + and p.suffix == ".py" and not _is_test(p) and not _skip(p) + ] + for p in targets: + rel = p.resolve().relative_to(REPO).as_posix() + try: + lines = p.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + continue + for i, line in enumerate(lines, 1): + if HARD.search(line) and not _allowed(rel, line): + violations.append( + f"{rel}:{i}: Paperclip symbol in the intelligence layer " + f"(INV-G12). Route platform access through " + f"web/agent_platform_port.py, or add a justified baseline " + f"entry in scripts/leak_guard.py if genuinely benign.\n" + f" {line.strip()[:120]}" + ) + + # Rule 2 — import seam (web/ only). + web = REPO / "web" + seam_targets = ( + [p for p in _iter_py(web)] + if files is None + else [p for p in files + if p.suffix == ".py" and (web in p.resolve().parents) + and not _is_test(p)] + ) + for p in seam_targets: + rel = p.resolve().relative_to(REPO).as_posix() + if rel in SEAM_ALLOWED: + continue + try: + lines = p.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + continue + for i, line in enumerate(lines, 1): + if SEAM_IMPORT.search(line): + violations.append( + f"{rel}:{i}: imports the Paperclip client directly " + f"(INV-G12 seam). Import from web.agent_platform_port instead.\n" + f" {line.strip()[:120]}" + ) + return violations + + +def main(argv: list[str]) -> int: + files = [Path(a) for a in argv] or None + violations = scan(files) + if violations: + sys.stderr.write( + "✗ G12 leak-guard — Agent Platform Port violated " + f"({len(violations)} finding(s)):\n\n" + ) + for v in violations: + sys.stderr.write(f" • {v}\n") + sys.stderr.write( + "\nSee docs/spec/X15-agent-platform-port.md (G12).\n" + ) + return 1 + if files is None: + print("✓ G12 leak-guard: intelligence layer is platform-clean; " + "import seam intact.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/spec-guard.sh b/scripts/spec-guard.sh index 0092ace..dbc3281 100755 --- a/scripts/spec-guard.sh +++ b/scripts/spec-guard.sh @@ -35,19 +35,39 @@ case "$file_path" in *) exit 0 ;; esac -# Dedup לכל session — מזכיר פעם אחת בלבד +# ── G12 leak-guard (INV-G12 / docs/spec/X15) — warn at write-time ────────────── +# PreToolUse fires BEFORE the edit, so we inspect the CONTENT being written +# (new_string / content), not the file on disk — this catches a Paperclip symbol +# being introduced into the intelligence layer right now. NOT session-deduped: +# every such write should warn. The hard gate is the CI fitness-test +# (mcp-server/tests/test_platform_port_leak_guard.py via scripts/leak_guard.py). +leak_warn="" +case "$file_path" in + */mcp-server/src/*) + new_content="$(printf '%s' "$input" | jq -r '.tool_input.new_string // .tool_input.content // empty' 2>/dev/null || true)" + if printf '%s' "$new_content" | grep -qE 'paperclip|Paperclip|PAPERCLIP|wakeup|heartbeat|HEARTBEAT|pc_request|pc\.sh|X-Paperclip|agent_wakeup|heartbeat_run|ctx\.agents|issueId'; then + leak_warn="⚠️ G12 (שער-הפלטפורמה) — התוכן שאתה כותב ל-${file_path} (שכבת-האינטליגנציה) מכיל מונח ספציפי-Paperclip. אסור (INV-G12): נתב מגע-פלטפורמה דרך web/agent_platform_port.py. אם זו הערת-מקור בלבד — הוסף רשומה מנומקת ל-allowlist ב-scripts/leak_guard.py, אחרת ה-CI (test_platform_port_leak_guard) ייכשל. ראה docs/spec/X15-agent-platform-port.md. +" + fi + ;; +esac + +# Dedup לכל session — תזכורת-הספ מופיעה פעם אחת בלבד (אזהרת-leak אינה deduped) session_id="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null || echo nosession)" marker="${TMPDIR:-/tmp}/.spec-guard-${session_id}" -[ -f "$marker" ] && exit 0 -: > "$marker" 2>/dev/null || true - -ctx="פרוטוקול כתיבת-קוד (CLAUDE.md §פרוטוקול כתיבת-קוד) — נוגעים בקובץ-קוד: ${file_path} +spec_ctx="" +if [ ! -f "$marker" ]; then + : > "$marker" 2>/dev/null || true + spec_ctx="פרוטוקול כתיבת-קוד (CLAUDE.md §פרוטוקול כתיבת-קוד) — נוגעים בקובץ-קוד: ${file_path} לפני השינוי ודא: -• קראת את docs/spec/00-constitution.md (ייעוד, G1–G11, אינדקס §7) + ספ-התחום הרלוונטי. -• השינוי מקיים את ה-invariants: אין מסלול מקביל ליכולת קיימת (G2), נרמול-במקור ולא תיקון-בקריאה (G1), אין בליעה שקטה של שגיאות (§6). +• קראת את docs/spec/00-constitution.md (ייעוד, G1–G12, אינדקס §7) + ספ-התחום הרלוונטי. +• השינוי מקיים את ה-invariants: אין מסלול מקביל ליכולת קיימת (G2), נרמול-במקור ולא תיקון-בקריאה (G1), שער-הפלטפורמה (G12 — Paperclip רק דרך agent_platform_port.py), אין בליעה שקטה של שגיאות (§6). • בדקת מול docs/spec/gap-audit.md אם נוגעים ב-GAP/FU שכבר ממופה — להתאים, לא לפתור מחדש. • ה-PR יצהיר אילו invariants (G*/INV-*) נגעת בהם / מקיים (ראה .gitea/PULL_REQUEST_TEMPLATE.md). (תזכורת זו מופיעה פעם אחת בסשן.)" +fi +ctx="${leak_warn}${spec_ctx}" +[ -z "$ctx" ] && exit 0 jq -n --arg ctx "$ctx" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}' exit 0 -- 2.49.1