Merge pull request 'feat(ci): G12 leak-guard — אכיפת שער-הפלטפורמה (R4, #113)' (#177) from worktree-leak-guard-g12 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m31s
G12 Leak-Guard / leak-guard (push) Successful in 6s

This commit was merged in pull request #177.
This commit is contained in:
2026-06-10 09:41:16 +00:00
5 changed files with 277 additions and 8 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -9,7 +9,8 @@
| Script | Type | Purpose | Scheduled | | Script | Type | Purpose | Scheduled |
|--------|------|---------|-----------| |--------|------|---------|-----------|
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים | | `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [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`+ספ-התחום ולוודא קיום G1G11 — לפני שכותבים. המקבילה האינטראקטיבית ל-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-<session_id>`). רשום ב-`.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`+ספ-התחום ולוודא קיום G1G12 — לפני שכותבים. **+ 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 <file>...` = קבצים נתונים (ל-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 — בטוח במקביל לחילוץ. | חד-פעמי (בוצע) | | `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_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). | ידני אחרי כל שינוי | | `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). | ידני אחרי כל שינוי |

172
scripts/leak_guard.py Normal file
View File

@@ -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 <file>... # 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 <path-suffix> AND the line contains <substring>.
# 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:]))

View File

@@ -35,19 +35,39 @@ case "$file_path" in
*) exit 0 ;; *) exit 0 ;;
esac 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)" session_id="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null || echo nosession)"
marker="${TMPDIR:-/tmp}/.spec-guard-${session_id}" marker="${TMPDIR:-/tmp}/.spec-guard-${session_id}"
[ -f "$marker" ] && exit 0 spec_ctx=""
: > "$marker" 2>/dev/null || true if [ ! -f "$marker" ]; then
: > "$marker" 2>/dev/null || true
ctx="פרוטוקול כתיבת-קוד (CLAUDE.md §פרוטוקול כתיבת-קוד) — נוגעים בקובץ-קוד: ${file_path} spec_ctx="פרוטוקול כתיבת-קוד (CLAUDE.md §פרוטוקול כתיבת-קוד) — נוגעים בקובץ-קוד: ${file_path}
לפני השינוי ודא: לפני השינוי ודא:
• קראת את docs/spec/00-constitution.md (ייעוד, G1G11, אינדקס §7) + ספ-התחום הרלוונטי. • קראת את docs/spec/00-constitution.md (ייעוד, G1G12, אינדקס §7) + ספ-התחום הרלוונטי.
• השינוי מקיים את ה-invariants: אין מסלול מקביל ליכולת קיימת (G2), נרמול-במקור ולא תיקון-בקריאה (G1), אין בליעה שקטה של שגיאות (§6). • השינוי מקיים את ה-invariants: אין מסלול מקביל ליכולת קיימת (G2), נרמול-במקור ולא תיקון-בקריאה (G1), שער-הפלטפורמה (G12 — Paperclip רק דרך agent_platform_port.py), אין בליעה שקטה של שגיאות (§6).
• בדקת מול docs/spec/gap-audit.md אם נוגעים ב-GAP/FU שכבר ממופה — להתאים, לא לפתור מחדש. • בדקת מול docs/spec/gap-audit.md אם נוגעים ב-GAP/FU שכבר ממופה — להתאים, לא לפתור מחדש.
• ה-PR יצהיר אילו invariants (G*/INV-*) נגעת בהם / מקיים (ראה .gitea/PULL_REQUEST_TEMPLATE.md). • ה-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}}' jq -n --arg ctx "$ctx" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
exit 0 exit 0