This is an automated email from the ASF dual-hosted git repository.

choo121600 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git


The following commit(s) were added to refs/heads/main by this push:
     new 7948591  add Privacy-LLM gate-check validator  (#215)
7948591 is described below

commit 794859175a125ed116fc08e17d201cfcf08efd71
Author: Justin Mclean <[email protected]>
AuthorDate: Mon May 25 16:50:34 2026 +1000

    add Privacy-LLM gate-check validator  (#215)
    
    * feat(privacy-llm): add Privacy-LLM gate-check validation
    
    * Update tools/skill-validator/src/skill_validator/__init__.py
    
    Co-authored-by: André Ahlert <[email protected]>
    
    ---------
    
    Co-authored-by: André Ahlert <[email protected]>
---
 .claude/skills/security-issue-deduplicate/SKILL.md |  11 +
 .claude/skills/security-issue-fix/SKILL.md         |  11 +
 .../src/skill_validator/__init__.py                | 199 +++++++++++++++++-
 tools/skill-validator/tests/test_validator.py      | 233 +++++++++++++++++++++
 4 files changed, 450 insertions(+), 4 deletions(-)

diff --git a/.claude/skills/security-issue-deduplicate/SKILL.md 
b/.claude/skills/security-issue-deduplicate/SKILL.md
index 716008a..2610a8b 100644
--- a/.claude/skills/security-issue-deduplicate/SKILL.md
+++ b/.claude/skills/security-issue-deduplicate/SKILL.md
@@ -162,6 +162,17 @@ in `docs/prerequisites.md`.
    `gh issue view <kept> --repo <tracker> --json number`
    and the same for `<dropped>` — before any write.
 3. `uv --version` returns.
+4. **Privacy-LLM gate-check** passes:
+
+   ```bash
+   uv run --project <framework>/tools/privacy-llm/checker \
+     privacy-llm-check
+   ```
+
+   This skill reads both tracker issue bodies in Step 1;
+   the redact-after-fetch protocol
+   (see [`tools/privacy-llm/wiring.md`](../../../tools/privacy-llm/wiring.md))
+   applies to those fetches.
 
 If any check fails, stop. A partial dedup (body merged but
 dropped tracker left open, or CVE JSON not regenerated) is worse
diff --git a/.claude/skills/security-issue-fix/SKILL.md 
b/.claude/skills/security-issue-fix/SKILL.md
index 2ff4b9f..bb5f8eb 100644
--- a/.claude/skills/security-issue-fix/SKILL.md
+++ b/.claude/skills/security-issue-fix/SKILL.md
@@ -201,6 +201,17 @@ continue.
    `breeze` is required for the area of the fix, also
    `breeze --version`. Any missing tool stops the skill;
    installing them mid-run is out of scope.
+6. **Privacy-LLM gate-check** passes:
+
+   ```bash
+   uv run --project <framework>/tools/privacy-llm/checker \
+     privacy-llm-check
+   ```
+
+   This skill reads the `<tracker>` issue body to update the
+   "PR with the fix" field; the redact-after-fetch protocol
+   (see [`tools/privacy-llm/wiring.md`](../../../tools/privacy-llm/wiring.md))
+   applies to that fetch.
 
 Only after **every** check is green, proceed to Step 1.
 
diff --git a/tools/skill-validator/src/skill_validator/__init__.py 
b/tools/skill-validator/src/skill_validator/__init__.py
index 23480e9..ee99270 100644
--- a/tools/skill-validator/src/skill_validator/__init__.py
+++ b/tools/skill-validator/src/skill_validator/__init__.py
@@ -68,9 +68,54 @@ PROJECTS_TEMPLATE_DIR = Path("projects/_template")
 REQUIRED_FRONTMATTER_KEYS = {"name", "description", "license"}
 OPTIONAL_FRONTMATTER_KEYS = {"when_to_use", "mode"}
 ALLOWED_LICENSES = {"Apache-2.0"}
-# MISSION mode taxonomy — see docs/modes.md.
-# "Auto-merge" deliberately excluded: it is off per MISSION sequencing.
-ALLOWED_MODES = {"Triage", "Mentoring", "Drafting", "Pairing"}
+
+
+def _read_mode_table() -> dict[str, str]:
+    """Read the canonical MISSION mode table from ``docs/modes.md``."""
+    starts = [Path.cwd().resolve(), Path(__file__).resolve().parent]
+    roots: list[Path] = []
+    for start in starts:
+        roots.extend([start, *start.parents])
+
+    rejected: list[str] = []
+    for root in roots:
+        modes_doc = root / DOCS_DIR / "modes.md"
+        if not modes_doc.is_file():
+            continue
+        text = modes_doc.read_text(encoding="utf-8")
+        if "## Modes at a glance" not in text:
+            rejected.append(f"{modes_doc}: missing '## Modes at a glance' 
section marker")
+            continue
+        modes_table = text.split("## Modes at a glance", 1)[1].split("## 
Triage", 1)[0]
+        modes: dict[str, str] = {}
+        for line in modes_table.splitlines():
+            if not line.startswith("| **"):
+                continue
+            cells = [cell.strip() for cell in line.strip("|").split("|")]
+            if len(cells) < 3:
+                continue
+            mode = cells[0].strip("*")
+            status = cells[2].strip()
+            if mode and status:
+                modes[mode] = status
+        if modes:
+            return modes
+        rejected.append(
+            f"{modes_doc}: found '## Modes at a glance' but parsed 0 modes "
+            f"(expected rows like '| **<Mode>** | … | <status> |')"
+        )
+
+    if rejected:
+        raise RuntimeError("could not parse mode taxonomy from docs/modes.md — 
" + "; ".join(rejected))
+    searched = dict.fromkeys(str(r / DOCS_DIR / "modes.md") for r in roots)
+    raise RuntimeError("could not locate docs/modes.md; searched: " + ", 
".join(searched))
+
+
+# MISSION mode taxonomy — docs/modes.md is canonical.
+_MODE_STATUS_BY_NAME = _read_mode_table()
+_MODE_TAXONOMY = set(_MODE_STATUS_BY_NAME)
+_OFF_MODES = {mode for mode, status in _MODE_STATUS_BY_NAME.items() if status 
== "off"}
+ALLOWED_MODES = _MODE_TAXONOMY - _OFF_MODES
 
 # Forbidden hardcoded project references (fixed strings, case-sensitive)
 FORBIDDEN_PATTERNS: list[str] = [
@@ -147,6 +192,7 @@ INJECTION_GUARD_TODO_CATEGORY = "injection_guard_todo"
 
 GH_LIST_CATEGORY = "gh_list_no_limit"
 SECURITY_PATTERN_CATEGORY = "security_pattern"
+PRIVACY_CATEGORY = "privacy"
 SOFT_CATEGORIES: frozenset[str] = frozenset(
     {
         PRINCIPLE_CATEGORY,
@@ -154,6 +200,7 @@ SOFT_CATEGORIES: frozenset[str] = frozenset(
         INJECTION_GUARD_TODO_CATEGORY,
         SECURITY_PATTERN_CATEGORY,
         GH_LIST_CATEGORY,
+        PRIVACY_CATEGORY,
     }
 )
 
@@ -220,6 +267,36 @@ _FIELD_PLACEHOLDER_RE = re.compile(
     r"(?:[\"'][^\"'\s]*<[^>]+>[^\"'\s]*[\"']|[^\s\"']*<[^>]+>[^\s\"']*)"
 )
 
+# ---------------------------------------------------------------------------
+# Privacy-LLM gate-check constants (write-skill/security-checklist.md § 
Pattern 6)
+# ---------------------------------------------------------------------------
+
+# Modes that can process external / attacker-controlled content and need the
+# Privacy-LLM gate when they read private tracker bodies.  Derived from
+# docs/modes.md taxonomy constants above: Pairing is intentionally excluded
+# because the human remains in the loop; Auto-merge is currently excluded only
+# because it is in _OFF_MODES.  When the first Auto-merge skill ships, remove
+# it from _OFF_MODES so body-reading Auto-merge skills are gated by default.
+_PRIVACY_EXTERNAL_CONTENT_MODES: frozenset[str] = frozenset(ALLOWED_MODES - 
{"Pairing"})
+
+_TRACKER_PLACEHOLDER = "<tracker>"
+_TRACKER_ISSUE_VIEW_RE = re.compile(r"\bgh\s+issue\s+view\b")
+_TRACKER_ISSUE_API_RE = 
re.compile(r"\bgh\s+api\s+/?repos/<tracker>/issues/[^\s`]+")
+_TRACKER_ISSUE_API_MUTATION_RE = 
re.compile(r"\s-X\s+(?:PATCH|POST|PUT|DELETE)\b")
+# TODO: detect body reads through ``gh api graphql`` and
+# ``gh issue list --json body`` once the validator has command parsing
+# rich enough to avoid broad prose false positives.
+_PRIVACY_LLM_GATE_PHRASE = "privacy-llm-check"
+_PRIVACY_GATE_SECTION_RE = re.compile(
+    r"^(?:"
+    r"prerequisites?(?:\b|$)"
+    r"|pre[- ]?flight(?:\b|$)"
+    r"|step\s*0(?:\b|$)"
+    r")",
+    re.IGNORECASE,
+)
+_ANTI_EXAMPLE_SECTION_RE = re.compile(r"\b(?:don'?t|anti[- 
]?example|bad|wrong)\b", re.IGNORECASE)
+
 ACTION_INVENTORY_COMMA_THRESHOLD = 5
 
 DISTINCT_FROM_RE = re.compile(
@@ -421,7 +498,7 @@ def extract_headings(text: str) -> set[str]:
 # or attacker-controlled content.
 _BODY_INLINE_RE = re.compile(r'--body[\s=]["\']')
 
-_FENCED_CODE_RE = re.compile(r"^```[\s\S]*?^```", re.MULTILINE)
+_FENCED_CODE_RE = re.compile(r"^ {0,3}```[\s\S]*?^ {0,3}```", re.MULTILINE)
 _DOUBLE_BACKTICK_RE = re.compile(r"``[\s\S]+?``")
 _SINGLE_BACKTICK_RE = re.compile(r"(?<!`)`(?!`)[\s\S]+?(?<!`)`(?!`)")
 
@@ -755,6 +832,118 @@ def validate_security_patterns(path: Path, text: str) -> 
Iterable[Violation]:
         )
 
 
+# ---------------------------------------------------------------------------
+# Privacy-LLM gate-check (write-skill/security-checklist.md § Pattern 6)
+# ---------------------------------------------------------------------------
+
+
+def _heading_text(raw: str) -> str:
+    text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", raw.strip())
+    text = text.strip("#").strip()
+    return text
+
+
+def _fenced_code_blocks(text: str) -> list[str]:
+    return [m.group(0) for m in _FENCED_CODE_RE.finditer(text)]
+
+
+def _fenced_code_blocks_in_privacy_gate_sections(text: str) -> list[str]:
+    """Return fenced code blocks inside Prerequisites / Preflight / Step 0 
sections."""
+    heading_re = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
+    headings = list(heading_re.finditer(text))
+    heading_index = 0
+    stack: list[tuple[int, str]] = []
+    blocks: list[str] = []
+
+    for block in _FENCED_CODE_RE.finditer(text):
+        while heading_index < len(headings) and 
headings[heading_index].start() < block.start():
+            heading = headings[heading_index]
+            level = len(heading.group(1))
+            title = _heading_text(heading.group(2))
+            stack = [(old_level, old_title) for old_level, old_title in stack 
if old_level < level]
+            stack.append((level, title))
+            heading_index += 1
+
+        titles = [title for _, title in stack]
+        if any(_ANTI_EXAMPLE_SECTION_RE.search(title) for title in titles):
+            continue
+        if any(_PRIVACY_GATE_SECTION_RE.search(title) for title in titles):
+            blocks.append(block.group(0))
+
+    return blocks
+
+
+def _shell_logical_lines(text: str) -> list[str]:
+    lines: list[str] = []
+    current: list[str] = []
+    for line in text.splitlines():
+        stripped = line.rstrip()
+        if stripped.endswith("\\"):
+            current.append(stripped[:-1].strip())
+            continue
+        if current:
+            current.append(stripped.strip())
+            lines.append(" ".join(part for part in current if part))
+            current = []
+        else:
+            lines.append(line)
+    if current:
+        lines.append(" ".join(part for part in current if part))
+    return lines
+
+
+def _has_tracker_body_read(text: str) -> bool:
+    body = _strip_html_comments(_skill_body(text))
+    if _TRACKER_ISSUE_VIEW_RE.search(body):
+        return True
+    for command in _shell_logical_lines(body):
+        if _TRACKER_ISSUE_API_RE.search(command) and not 
_TRACKER_ISSUE_API_MUTATION_RE.search(command):
+            return True
+    return False
+
+
+def _has_privacy_gate_command(text: str) -> bool:
+    body = _strip_html_comments(_skill_body(text))
+    return any(
+        _PRIVACY_LLM_GATE_PHRASE in block for block in 
_fenced_code_blocks_in_privacy_gate_sections(body)
+    )
+
+
+def validate_privacy_patterns(path: Path, text: str) -> Iterable[Violation]:
+    """Check Privacy-LLM gate-check convention from 
``write-skill/security-checklist.md``.
+
+    Pattern 6 applies to SKILL.md entry points whose mode processes external
+    content and whose workflow reads full issue bodies from the private
+    ``<tracker>`` repository. The gate is considered present only when
+    ``privacy-llm-check`` appears in a fenced command block; prose, HTML
+    comments, TODO notes, and anti-examples do not satisfy the check.
+    """
+    if path.name != "SKILL.md":
+        return
+
+    fm = parse_frontmatter(text) or {}
+    mode = fm.get("mode", "")
+    if mode not in _PRIVACY_EXTERNAL_CONTENT_MODES:
+        return
+
+    if _TRACKER_PLACEHOLDER not in text:
+        return
+    if not _has_tracker_body_read(text):
+        return
+
+    if not _has_privacy_gate_command(text):
+        yield Violation(
+            path,
+            None,
+            f"privacy-llm-gate: mode '{mode}' + '<tracker>' body read implies "
+            f"private-content access but the Privacy-LLM gate-check is missing 
— "
+            f"add 'uv run --project <framework>/tools/privacy-llm/checker "
+            f"privacy-llm-check' in the Prerequisites / Step 0 section "
+            f"(see write-skill/security-checklist.md § Pattern 6)",
+            category=PRIVACY_CATEGORY,
+        )
+
+
 # ---------------------------------------------------------------------------
 # Trigger-phrase non-regression
 # ---------------------------------------------------------------------------
@@ -1035,6 +1224,7 @@ def run_validation(root: Path | None = None) -> 
list[Violation]:
             violations.extend(validate_frontmatter(path, text))
             violations.extend(validate_injection_guard(path, text))
             violations.extend(validate_principle_compliance(path, text))
+            violations.extend(validate_privacy_patterns(path, text))
             violations.extend(validate_trigger_preservation(path, text, 
repo_root=repo_root))
 
         # All skill files get link + placeholder + security-pattern validation
@@ -1107,6 +1297,7 @@ _SOFT_RULE_PREFIXES: tuple[str, ...] = (
     "security-pattern-4",
     "security-pattern-9",
     "gh-list-no-limit",
+    "privacy-llm-gate",
 )
 
 
diff --git a/tools/skill-validator/tests/test_validator.py 
b/tools/skill-validator/tests/test_validator.py
index 3c2e098..210c9c6 100644
--- a/tools/skill-validator/tests/test_validator.py
+++ b/tools/skill-validator/tests/test_validator.py
@@ -24,6 +24,11 @@ from pathlib import Path
 import pytest
 
 from skill_validator import (
+    _MODE_STATUS_BY_NAME,
+    _MODE_TAXONOMY,
+    _OFF_MODES,
+    _PRIVACY_EXTERNAL_CONTENT_MODES,
+    ALLOWED_MODES,
     FORBIDDEN_PATTERNS,
     GH_LIST_CATEGORY,
     INJECTION_GUARD_CALLOUT_SENTINEL,
@@ -32,9 +37,11 @@ from skill_validator import (
     INJECTION_GUARD_TODO_SENTINEL,
     MAX_METADATA_CHARS,
     PRINCIPLE_CATEGORY,
+    PRIVACY_CATEGORY,
     SECURITY_PATTERN_CATEGORY,
     SOFT_CATEGORIES,
     TRIGGER_PRESERVATION_CATEGORY,
+    _read_mode_table,
     collect_doc_files,
     collect_files_to_check,
     collect_skill_dirs,
@@ -54,6 +61,7 @@ from skill_validator import (
     validate_links,
     validate_placeholders,
     validate_principle_compliance,
+    validate_privacy_patterns,
     validate_security_patterns,
     validate_trigger_preservation,
 )
@@ -178,6 +186,26 @@ class TestValidateFrontmatter:
         violations = list(validate_frontmatter(path, text))
         assert violations == []
 
+    def test_mode_taxonomy_matches_docs_modes(self) -> None:
+        docs_modes = Path(__file__).parents[3] / "docs" / "modes.md"
+        modes_table = (
+            docs_modes.read_text(encoding="utf-8")
+            .split("## Modes at a glance", 1)[1]
+            .split("## Triage", 1)[0]
+        )
+        modes: dict[str, str] = {}
+        for line in modes_table.splitlines():
+            if not line.startswith("| **"):
+                continue
+            cells = [cell.strip() for cell in line.strip("|").split("|")]
+            modes[cells[0].strip("*")] = cells[2]
+        assert _read_mode_table() == modes
+        assert _MODE_STATUS_BY_NAME == modes
+        assert _MODE_TAXONOMY == set(modes)
+        assert _OFF_MODES == {mode for mode, status in modes.items() if status 
== "off"}
+        assert ALLOWED_MODES == _MODE_TAXONOMY - _OFF_MODES
+        assert _PRIVACY_EXTERNAL_CONTENT_MODES == frozenset(ALLOWED_MODES - 
{"Pairing"})
+
     def test_metadata_under_limit(self, tmp_path: Path) -> None:
         path = tmp_path / "SKILL.md"
         desc = "a" * 800
@@ -695,6 +723,30 @@ class TestPrincipleCompliance:
 
 
 class TestTriggerPreservation:
+    @pytest.fixture(autouse=True)
+    def _isolate_git_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
+        """Insulate temp-repo git calls from inherited git environment.
+
+        When the suite runs inside a pre-commit/prek hook, git env vars
+        (GIT_DIR, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY, ...) point at the host
+        repo. Without scrubbing them, ``git add``/``commit`` in the tmp_path
+        repo below — and the validator's ``git show`` — operate against the
+        host repo's index/objects instead of the isolated one, which fails
+        with "invalid object … Error building trees".
+        """
+        for var in (
+            "GIT_DIR",
+            "GIT_WORK_TREE",
+            "GIT_INDEX_FILE",
+            "GIT_OBJECT_DIRECTORY",
+            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
+            "GIT_COMMON_DIR",
+            "GIT_NAMESPACE",
+            "GIT_PREFIX",
+            "GIT_CEILING_DIRECTORIES",
+        ):
+            monkeypatch.delenv(var, raising=False)
+
     def test_unavailable_base_ref_no_op(self, tmp_path: Path) -> None:
         """When git or the base ref isn't reachable, the check returns no 
violations."""
         skill = tmp_path / "SKILL.md"
@@ -1163,6 +1215,7 @@ class TestSoftCategories:
         assert INJECTION_GUARD_TODO_CATEGORY in SOFT_CATEGORIES
         assert SECURITY_PATTERN_CATEGORY in SOFT_CATEGORIES
         assert GH_LIST_CATEGORY in SOFT_CATEGORIES
+        assert PRIVACY_CATEGORY in SOFT_CATEGORIES
 
 
 # ---------------------------------------------------------------------------
@@ -1220,6 +1273,186 @@ class TestGhListLimit:
         assert not any("gh-list-no-limit" in v.message for v in violations)
 
 
+# ---------------------------------------------------------------------------
+# Pattern 6 — Privacy-LLM gate-check
+# ---------------------------------------------------------------------------
+
+_GATE = "privacy-llm-check"
+
+
+def _p6_skill(
+    mode: str = "Triage",
+    has_tracker: bool = True,
+    read_line: str = "gh issue view <N> --repo <tracker> --json body",
+    gate_text: str = "",
+) -> str:
+    parts = ["---", "name: test-skill", "description: bar", "license: 
Apache-2.0"]
+    if mode:
+        parts.append(f"mode: {mode}")
+    parts.append("---")
+    body_parts = ["# body"]
+    if has_tracker:
+        body_parts.append("Reads from the <tracker> repo.")
+    if read_line:
+        body_parts.append(f"Use `{read_line}`.")
+    if gate_text:
+        body_parts.append(gate_text)
+    parts.extend(body_parts)
+    return "\n".join(parts) + "\n"
+
+
+def _gate_block() -> str:
+    return "```bash\nuv run --project <framework>/tools/privacy-llm/checker 
\\\n  privacy-llm-check\n```\n"
+
+
+def _gate_section() -> str:
+    return f"## Step 0 — Pre-flight check\n\n{_gate_block()}"
+
+
+class TestPrivacyPatternP6:
+    def test_fires_triage_with_tracker_and_read_no_gate(self, tmp_path: Path) 
-> None:
+        path = tmp_path / "SKILL.md"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(mode="Triage")))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_fires_drafting_with_tracker_and_read_no_gate(self, tmp_path: 
Path) -> None:
+        path = tmp_path / "SKILL.md"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(mode="Drafting")))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_fires_mentoring_with_tracker_and_read_no_gate(self, tmp_path: 
Path) -> None:
+        path = tmp_path / "SKILL.md"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(mode="Mentoring")))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_violation_is_soft_category(self, tmp_path: Path) -> None:
+        path = tmp_path / "SKILL.md"
+        violations = list(validate_privacy_patterns(path, _p6_skill()))
+        assert all(v.category == PRIVACY_CATEGORY for v in violations)
+
+    def test_silent_when_gate_present_in_fenced_command(self, tmp_path: Path) 
-> None:
+        path = tmp_path / "SKILL.md"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(gate_text=_gate_section())))
+        assert not any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_silent_when_gate_present_in_indented_fenced_command(self, 
tmp_path: Path) -> None:
+        path = tmp_path / "SKILL.md"
+        gate = (
+            "## Prerequisites\n\n"
+            "   ```bash\n"
+            "   uv run --project <framework>/tools/privacy-llm/checker \\\n"
+            "     privacy-llm-check\n"
+            "   ```"
+        )
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(gate_text=gate)))
+        assert not any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_silent_when_gate_present_in_step_0_subsection(self, tmp_path: 
Path) -> None:
+        path = tmp_path / "SKILL.md"
+        gate = f"## Step 0 — Resolve inputs\n\n### Privacy-LLM 
gate\n\n{_gate_block()}"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(gate_text=gate)))
+        assert not any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_gate_in_html_comment_does_not_satisfy(self, tmp_path: Path) -> 
None:
+        path = tmp_path / "SKILL.md"
+        text = _p6_skill(gate_text=f"<!-- TODO: wire up {_GATE} -->")
+        violations = list(validate_privacy_patterns(path, text))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_gate_in_prose_does_not_satisfy(self, tmp_path: Path) -> None:
+        path = tmp_path / "SKILL.md"
+        text = _p6_skill(gate_text=f"Remember to run {_GATE} later.")
+        violations = list(validate_privacy_patterns(path, text))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_gate_in_inline_code_does_not_satisfy(self, tmp_path: Path) -> 
None:
+        path = tmp_path / "SKILL.md"
+        text = _p6_skill(gate_text=f"TODO: `{_GATE}`")
+        violations = list(validate_privacy_patterns(path, text))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_gate_in_fenced_bad_example_does_not_satisfy(self, tmp_path: Path) 
-> None:
+        path = tmp_path / "SKILL.md"
+        gate = f"## Don't do this\n\n{_gate_block()}"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(gate_text=gate)))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_gate_in_later_fenced_section_does_not_satisfy(self, tmp_path: 
Path) -> None:
+        path = tmp_path / "SKILL.md"
+        gate = f"## History\n\n{_gate_block()}"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(gate_text=gate)))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_gate_after_step_0_section_does_not_satisfy(self, tmp_path: Path) 
-> None:
+        path = tmp_path / "SKILL.md"
+        gate = f"## Step 0 — Pre-flight check\n\nNo gate here.\n\n## 
History\n\n{_gate_block()}"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(gate_text=gate)))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_gate_in_appendix_step_0_snippet_does_not_satisfy(self, tmp_path: 
Path) -> None:
+        path = tmp_path / "SKILL.md"
+        gate = f"## Appendix: Step 0 from an older version\n\n{_gate_block()}"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(gate_text=gate)))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_gate_in_step_0_bad_example_subsection_does_not_satisfy(self, 
tmp_path: Path) -> None:
+        path = tmp_path / "SKILL.md"
+        gate = f"## Step 0 — Pre-flight check\n\n### Bad 
example\n\n{_gate_block()}"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(gate_text=gate)))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_rest_issue_get_counts_as_tracker_body_read(self, tmp_path: Path) 
-> None:
+        path = tmp_path / "SKILL.md"
+        text = _p6_skill(read_line="gh api repos/<tracker>/issues/<N>")
+        violations = list(validate_privacy_patterns(path, text))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def 
test_rest_issue_get_with_leading_slash_counts_as_tracker_body_read(self, 
tmp_path: Path) -> None:
+        path = tmp_path / "SKILL.md"
+        text = _p6_skill(read_line="gh api /repos/<tracker>/issues/<N>")
+        violations = list(validate_privacy_patterns(path, text))
+        assert any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_rest_issue_patch_is_exempt(self, tmp_path: Path) -> None:
+        path = tmp_path / "SKILL.md"
+        text = _p6_skill(read_line="gh api repos/<tracker>/issues/<N> -X PATCH 
-f title=x")
+        violations = list(validate_privacy_patterns(path, text))
+        assert not any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_multiline_rest_issue_patch_is_exempt(self, tmp_path: Path) -> 
None:
+        path = tmp_path / "SKILL.md"
+        text = _p6_skill(
+            read_line="gh api repos/<tracker>/issues/<N> \\\n  -X PATCH \\\n  
-f title=x",
+        )
+        violations = list(validate_privacy_patterns(path, text))
+        assert not any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_silent_when_no_tracker_reference(self, tmp_path: Path) -> None:
+        path = tmp_path / "SKILL.md"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(has_tracker=False, read_line="")))
+        assert not any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_silent_when_tracker_but_no_issue_body_read(self, tmp_path: Path) 
-> None:
+        path = tmp_path / "SKILL.md"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(read_line="")))
+        assert not any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_silent_when_no_mode(self, tmp_path: Path) -> None:
+        path = tmp_path / "SKILL.md"
+        violations = list(validate_privacy_patterns(path, _p6_skill(mode="")))
+        assert not any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_silent_for_pairing_mode(self, tmp_path: Path) -> None:
+        path = tmp_path / "SKILL.md"
+        violations = list(validate_privacy_patterns(path, 
_p6_skill(mode="Pairing")))
+        assert not any("privacy-llm-gate" in v.message for v in violations)
+
+    def test_silent_on_sub_doc(self, tmp_path: Path) -> None:
+        path = tmp_path / "step-0-preflight.md"
+        violations = list(validate_privacy_patterns(path, _p6_skill()))
+        assert not any("privacy-llm-gate" in v.message for v in violations)
+
+
 # ---------------------------------------------------------------------------
 # is_placeholder_url
 # ---------------------------------------------------------------------------

Reply via email to