claude-dev-env 1.93.0 → 1.93.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/pr-loop/scripts/code_rules_gate.py +127 -13
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -0
- package/hooks/blocking/pii_scanner.py +26 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
- package/hooks/blocking/test_pii_scanner.py +27 -2
- package/hooks/hooks_constants/pii_prevention_constants.py +4 -8
- package/package.json +1 -1
- package/skills/privacy-hygiene/SKILL.md +4 -4
|
@@ -28,6 +28,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
28
28
|
BANNED_NOUN_VIOLATION_PATTERN,
|
|
29
29
|
CODE_RULES_GATE_PYTHON_ENV_VAR,
|
|
30
30
|
CODE_RULES_GATE_PYTHONPATH_ENV_VAR,
|
|
31
|
+
COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH,
|
|
31
32
|
DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX,
|
|
32
33
|
DUPLICATE_BODY_SPAN_GROUP_INDEX,
|
|
33
34
|
DUPLICATE_BODY_VIOLATION_PATTERN,
|
|
@@ -41,6 +42,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
41
42
|
ISOLATION_DEFINITION_LINE_GROUP_INDEX,
|
|
42
43
|
ISOLATION_SPAN_GROUP_INDEX,
|
|
43
44
|
ISOLATION_VIOLATION_PATTERN,
|
|
45
|
+
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
|
|
44
46
|
MAX_VIOLATIONS_PER_CHECK,
|
|
45
47
|
MINIMUM_STAGED_PYTEST_PYTHON_MAJOR,
|
|
46
48
|
MINIMUM_STAGED_PYTEST_PYTHON_MINOR,
|
|
@@ -1710,11 +1712,114 @@ def _staged_pytest_environment() -> dict[str, str]:
|
|
|
1710
1712
|
return environment
|
|
1711
1713
|
|
|
1712
1714
|
|
|
1715
|
+
def _relative_pytest_argument(test_path: Path, group_root: Path) -> str:
|
|
1716
|
+
"""Express *test_path* as a pytest argument relative to *group_root*.
|
|
1717
|
+
|
|
1718
|
+
*group_root* is already the pytest working directory, so the relative form
|
|
1719
|
+
names the same file in far fewer characters.
|
|
1720
|
+
|
|
1721
|
+
Example::
|
|
1722
|
+
|
|
1723
|
+
group_root = C:/repo/package
|
|
1724
|
+
ok: C:/repo/package/tests/test_a.py -> "tests/test_a.py"
|
|
1725
|
+
flag: C:/elsewhere/test_b.py -> no relative form; passed absolute
|
|
1726
|
+
|
|
1727
|
+
Args:
|
|
1728
|
+
test_path: The staged test file to express as a pytest argument.
|
|
1729
|
+
group_root: The pytest working directory for the group.
|
|
1730
|
+
|
|
1731
|
+
Returns:
|
|
1732
|
+
The path relative to *group_root* when *test_path* sits under it,
|
|
1733
|
+
otherwise the absolute path unchanged.
|
|
1734
|
+
"""
|
|
1735
|
+
try:
|
|
1736
|
+
return str(test_path.relative_to(group_root))
|
|
1737
|
+
except ValueError:
|
|
1738
|
+
return str(test_path)
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def _batched_pytest_arguments(
|
|
1742
|
+
all_pytest_arguments: list[str], character_budget: int
|
|
1743
|
+
) -> list[list[str]]:
|
|
1744
|
+
"""Split pytest path arguments into command-line-length-safe batches.
|
|
1745
|
+
|
|
1746
|
+
Windows rejects a command line past 32767 characters with WinError 206, so a
|
|
1747
|
+
large staged set passed in one invocation crashes the gate and denies the
|
|
1748
|
+
commit. Each batch takes arguments until the next one would push its joined
|
|
1749
|
+
length past *character_budget*.
|
|
1750
|
+
|
|
1751
|
+
Example::
|
|
1752
|
+
|
|
1753
|
+
character_budget = 10
|
|
1754
|
+
arguments = ["aaaa", "bbbb", "cccc"]
|
|
1755
|
+
ok: [["aaaa", "bbbb"], ["cccc"]] - every batch within budget
|
|
1756
|
+
flag: [["aaaa", "bbbb", "cccc"]] - 14 characters, over budget
|
|
1757
|
+
|
|
1758
|
+
Args:
|
|
1759
|
+
all_pytest_arguments: The path arguments to distribute, in order.
|
|
1760
|
+
character_budget: The joined length each batch must stay within. An
|
|
1761
|
+
argument wider than the budget on its own still lands in a batch
|
|
1762
|
+
by itself, so no argument is ever dropped.
|
|
1763
|
+
|
|
1764
|
+
Returns:
|
|
1765
|
+
One argument list per pytest invocation, preserving input order.
|
|
1766
|
+
"""
|
|
1767
|
+
all_batches: list[list[str]] = []
|
|
1768
|
+
current_batch: list[str] = []
|
|
1769
|
+
current_length = 0
|
|
1770
|
+
for each_argument in all_pytest_arguments:
|
|
1771
|
+
argument_length = len(each_argument) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
|
|
1772
|
+
if current_batch and current_length + argument_length > character_budget:
|
|
1773
|
+
all_batches.append(current_batch)
|
|
1774
|
+
current_batch = []
|
|
1775
|
+
current_length = 0
|
|
1776
|
+
current_batch.append(each_argument)
|
|
1777
|
+
current_length += argument_length
|
|
1778
|
+
if current_batch:
|
|
1779
|
+
all_batches.append(current_batch)
|
|
1780
|
+
return all_batches
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _pytest_batch_exit_code(all_batch_command: list[str], group_root: Path) -> int:
|
|
1784
|
+
"""Run one pytest invocation and normalize its exit code.
|
|
1785
|
+
|
|
1786
|
+
Args:
|
|
1787
|
+
all_batch_command: The full argv for this pytest invocation.
|
|
1788
|
+
group_root: The owning test root used as the working directory.
|
|
1789
|
+
|
|
1790
|
+
Returns:
|
|
1791
|
+
0 when the batch passes or collects no tests; pytest's non-zero exit
|
|
1792
|
+
code otherwise.
|
|
1793
|
+
"""
|
|
1794
|
+
pytest_process = subprocess.run(
|
|
1795
|
+
all_batch_command,
|
|
1796
|
+
cwd=str(group_root),
|
|
1797
|
+
timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
1798
|
+
check=False,
|
|
1799
|
+
env=_staged_pytest_environment(),
|
|
1800
|
+
)
|
|
1801
|
+
if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
|
|
1802
|
+
return 0
|
|
1803
|
+
return pytest_process.returncode
|
|
1804
|
+
|
|
1805
|
+
|
|
1713
1806
|
def _run_pytest_for_group(
|
|
1714
1807
|
group_root: Path, all_group_test_paths: list[Path], repository_root: Path
|
|
1715
1808
|
) -> int:
|
|
1716
1809
|
"""Run pytest over one group's test files with the working directory at its root.
|
|
1717
1810
|
|
|
1811
|
+
Paths are passed relative to *group_root* (the subprocess working directory)
|
|
1812
|
+
and split into batches whose joined command line stays within
|
|
1813
|
+
``MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS``, so a large staged set never
|
|
1814
|
+
trips the Windows 32767-character limit. A staged set that fits the budget runs
|
|
1815
|
+
as one invocation, exactly as an unbatched run did.
|
|
1816
|
+
|
|
1817
|
+
Example::
|
|
1818
|
+
|
|
1819
|
+
ok: 3 staged tests -> 1 pytest invocation, argv well under the budget
|
|
1820
|
+
flag: 800 staged tests -> 1 pytest invocation, argv over the Windows limit
|
|
1821
|
+
(WinError 206); batching splits it across several
|
|
1822
|
+
|
|
1718
1823
|
Args:
|
|
1719
1824
|
group_root: The owning test root used as the pytest working directory.
|
|
1720
1825
|
all_group_test_paths: The staged test files that share *group_root*.
|
|
@@ -1722,23 +1827,32 @@ def _run_pytest_for_group(
|
|
|
1722
1827
|
interpreter when ``CODE_RULES_GATE_PYTHON`` is not set.
|
|
1723
1828
|
|
|
1724
1829
|
Returns:
|
|
1725
|
-
0 when
|
|
1726
|
-
exit code otherwise.
|
|
1830
|
+
0 when every batch passes or collects nothing; the first failing batch's
|
|
1831
|
+
non-zero pytest exit code otherwise.
|
|
1727
1832
|
"""
|
|
1728
|
-
|
|
1833
|
+
all_fixed_command = [
|
|
1834
|
+
_resolve_gate_python_executable(repository_root),
|
|
1835
|
+
*ALL_PYTEST_MODULE_INVOCATION,
|
|
1836
|
+
]
|
|
1837
|
+
fixed_command_length = sum(
|
|
1838
|
+
len(each_part) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
|
|
1839
|
+
for each_part in all_fixed_command
|
|
1840
|
+
)
|
|
1841
|
+
all_batches = _batched_pytest_arguments(
|
|
1729
1842
|
[
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
*[str(each_path) for each_path in all_group_test_paths],
|
|
1843
|
+
_relative_pytest_argument(each_path, group_root)
|
|
1844
|
+
for each_path in all_group_test_paths
|
|
1733
1845
|
],
|
|
1734
|
-
|
|
1735
|
-
timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
1736
|
-
check=False,
|
|
1737
|
-
env=_staged_pytest_environment(),
|
|
1846
|
+
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS - fixed_command_length,
|
|
1738
1847
|
)
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1848
|
+
first_failing_exit_code = 0
|
|
1849
|
+
for each_batch in all_batches:
|
|
1850
|
+
batch_exit_code = _pytest_batch_exit_code(
|
|
1851
|
+
[*all_fixed_command, *each_batch], group_root
|
|
1852
|
+
)
|
|
1853
|
+
if batch_exit_code != 0 and first_failing_exit_code == 0:
|
|
1854
|
+
first_failing_exit_code = batch_exit_code
|
|
1855
|
+
return first_failing_exit_code
|
|
1742
1856
|
|
|
1743
1857
|
|
|
1744
1858
|
def run_staged_test_files(repository_root: Path) -> int:
|
|
@@ -102,6 +102,10 @@ ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS: tuple[str, ...] = ("bin", "python"
|
|
|
102
102
|
|
|
103
103
|
STAGED_PYTEST_TIMEOUT_SECONDS: int = 600
|
|
104
104
|
|
|
105
|
+
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS: int = 24000
|
|
106
|
+
|
|
107
|
+
COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH: int = 1
|
|
108
|
+
|
|
105
109
|
STAGED_TEST_FAILURE_HEADER: str = (
|
|
106
110
|
"code_rules_gate: staged test file(s) failed under pytest; commit blocked."
|
|
107
111
|
)
|
|
@@ -2722,3 +2722,149 @@ def test_staged_pytest_environment_prepends_configured_pythonpath(
|
|
|
2722
2722
|
assert environment["PYTHONPATH"] == os.pathsep.join(
|
|
2723
2723
|
["/configured/site-packages", "/already/on/path"]
|
|
2724
2724
|
)
|
|
2725
|
+
|
|
2726
|
+
|
|
2727
|
+
def _record_pytest_invocations(
|
|
2728
|
+
monkeypatch: pytest.MonkeyPatch, *, all_exit_codes: list[int]
|
|
2729
|
+
) -> list[list[str]]:
|
|
2730
|
+
"""Patch subprocess.run to record each pytest argv and return staged exit codes."""
|
|
2731
|
+
all_recorded_commands: list[list[str]] = []
|
|
2732
|
+
all_remaining_exit_codes = list(all_exit_codes)
|
|
2733
|
+
|
|
2734
|
+
def _fake_subprocess_run(
|
|
2735
|
+
all_command: list[str], **_keyword_arguments: object
|
|
2736
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
2737
|
+
all_recorded_commands.append(list(all_command))
|
|
2738
|
+
exit_code = all_remaining_exit_codes.pop(0) if all_remaining_exit_codes else 0
|
|
2739
|
+
return subprocess.CompletedProcess(all_command, exit_code, b"", b"")
|
|
2740
|
+
|
|
2741
|
+
monkeypatch.setattr(gate_module.subprocess, "run", _fake_subprocess_run)
|
|
2742
|
+
return all_recorded_commands
|
|
2743
|
+
|
|
2744
|
+
|
|
2745
|
+
def _staged_test_paths_under(group_root: Path, path_count: int) -> list[Path]:
|
|
2746
|
+
return [
|
|
2747
|
+
group_root
|
|
2748
|
+
/ "tests"
|
|
2749
|
+
/ "subpackage_with_a_realistically_long_directory_name"
|
|
2750
|
+
/ f"test_module_with_a_realistically_long_name_{each_index:04d}.py"
|
|
2751
|
+
for each_index in range(path_count)
|
|
2752
|
+
]
|
|
2753
|
+
|
|
2754
|
+
|
|
2755
|
+
def _path_arguments_of(all_pytest_command: list[str]) -> list[str]:
|
|
2756
|
+
fixed_prefix_length = len(gate_module.ALL_PYTEST_MODULE_INVOCATION) + 1
|
|
2757
|
+
return all_pytest_command[fixed_prefix_length:]
|
|
2758
|
+
|
|
2759
|
+
|
|
2760
|
+
def test_run_pytest_for_group_batches_a_large_staged_set_under_the_budget(
|
|
2761
|
+
tmp_path: Path,
|
|
2762
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2763
|
+
) -> None:
|
|
2764
|
+
"""WinError 206: a staged set whose joined argv passes Windows' 32767-character
|
|
2765
|
+
command-line limit crashed the gate and denied the commit. The paths must be
|
|
2766
|
+
split across several pytest invocations, none of them over the budget.
|
|
2767
|
+
"""
|
|
2768
|
+
all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
|
|
2769
|
+
all_test_paths = _staged_test_paths_under(tmp_path, 800)
|
|
2770
|
+
|
|
2771
|
+
exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
|
|
2772
|
+
|
|
2773
|
+
assert exit_code == 0
|
|
2774
|
+
assert len(all_recorded_commands) > 1, (
|
|
2775
|
+
"a staged set wider than the command-line budget must be split across "
|
|
2776
|
+
"several pytest invocations rather than passed in one oversized argv"
|
|
2777
|
+
)
|
|
2778
|
+
for each_command in all_recorded_commands:
|
|
2779
|
+
assert (
|
|
2780
|
+
len(" ".join(each_command))
|
|
2781
|
+
<= gate_module.MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS
|
|
2782
|
+
)
|
|
2783
|
+
all_batched_arguments = [
|
|
2784
|
+
each_argument
|
|
2785
|
+
for each_command in all_recorded_commands
|
|
2786
|
+
for each_argument in _path_arguments_of(each_command)
|
|
2787
|
+
]
|
|
2788
|
+
assert len(all_batched_arguments) == len(all_test_paths)
|
|
2789
|
+
|
|
2790
|
+
|
|
2791
|
+
def test_run_pytest_for_group_passes_paths_relative_to_the_group_root(
|
|
2792
|
+
tmp_path: Path,
|
|
2793
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2794
|
+
) -> None:
|
|
2795
|
+
"""group_root is already the subprocess cwd, so each path argument is passed
|
|
2796
|
+
relative to it - the shortening that keeps a large staged set under the limit.
|
|
2797
|
+
"""
|
|
2798
|
+
all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
|
|
2799
|
+
test_path = tmp_path / "tests" / "test_relative.py"
|
|
2800
|
+
|
|
2801
|
+
gate_module._run_pytest_for_group(tmp_path, [test_path], tmp_path)
|
|
2802
|
+
|
|
2803
|
+
assert _path_arguments_of(all_recorded_commands[0]) == [
|
|
2804
|
+
str(Path("tests") / "test_relative.py")
|
|
2805
|
+
]
|
|
2806
|
+
|
|
2807
|
+
|
|
2808
|
+
def test_run_pytest_for_group_passes_an_outside_path_absolute(
|
|
2809
|
+
tmp_path: Path,
|
|
2810
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2811
|
+
) -> None:
|
|
2812
|
+
all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
|
|
2813
|
+
group_root = tmp_path / "group"
|
|
2814
|
+
outside_path = tmp_path / "elsewhere" / "test_outside.py"
|
|
2815
|
+
|
|
2816
|
+
gate_module._run_pytest_for_group(group_root, [outside_path], tmp_path)
|
|
2817
|
+
|
|
2818
|
+
assert _path_arguments_of(all_recorded_commands[0]) == [str(outside_path)]
|
|
2819
|
+
|
|
2820
|
+
|
|
2821
|
+
def test_run_pytest_for_group_runs_one_invocation_for_a_small_staged_set(
|
|
2822
|
+
tmp_path: Path,
|
|
2823
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2824
|
+
) -> None:
|
|
2825
|
+
all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
|
|
2826
|
+
all_test_paths = _staged_test_paths_under(tmp_path, 3)
|
|
2827
|
+
|
|
2828
|
+
exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
|
|
2829
|
+
|
|
2830
|
+
assert exit_code == 0
|
|
2831
|
+
assert len(all_recorded_commands) == 1, (
|
|
2832
|
+
"a staged set that fits the budget must behave exactly as before: "
|
|
2833
|
+
"one pytest invocation carrying every path"
|
|
2834
|
+
)
|
|
2835
|
+
|
|
2836
|
+
|
|
2837
|
+
def test_run_pytest_for_group_surfaces_the_first_failing_batch_exit_code(
|
|
2838
|
+
tmp_path: Path,
|
|
2839
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2840
|
+
) -> None:
|
|
2841
|
+
"""A failure in any batch must surface, and every batch must still run so the
|
|
2842
|
+
operator sees the whole staged-test picture rather than the first failure alone.
|
|
2843
|
+
"""
|
|
2844
|
+
all_recorded_commands = _record_pytest_invocations(
|
|
2845
|
+
monkeypatch, all_exit_codes=[0, 1, 2]
|
|
2846
|
+
)
|
|
2847
|
+
all_test_paths = _staged_test_paths_under(tmp_path, 800)
|
|
2848
|
+
|
|
2849
|
+
exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
|
|
2850
|
+
|
|
2851
|
+
assert exit_code == 1
|
|
2852
|
+
assert len(all_recorded_commands) >= 3
|
|
2853
|
+
|
|
2854
|
+
|
|
2855
|
+
def test_run_pytest_for_group_treats_no_tests_collected_as_a_pass_per_batch(
|
|
2856
|
+
tmp_path: Path,
|
|
2857
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2858
|
+
) -> None:
|
|
2859
|
+
_record_pytest_invocations(
|
|
2860
|
+
monkeypatch,
|
|
2861
|
+
all_exit_codes=[gate_module.PYTEST_NO_TESTS_COLLECTED_EXIT_CODE, 0],
|
|
2862
|
+
)
|
|
2863
|
+
all_test_paths = _staged_test_paths_under(tmp_path, 800)
|
|
2864
|
+
|
|
2865
|
+
exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
|
|
2866
|
+
|
|
2867
|
+
assert exit_code == 0, (
|
|
2868
|
+
"a batch that collects no tests is not a failure; only a real pytest "
|
|
2869
|
+
"failure exit code may block the commit"
|
|
2870
|
+
)
|
|
@@ -17,6 +17,7 @@ _hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
|
17
17
|
if _hooks_directory not in sys.path:
|
|
18
18
|
sys.path.insert(0, _hooks_directory)
|
|
19
19
|
|
|
20
|
+
from hooks_constants.local_identity import nas_host # noqa: E402
|
|
20
21
|
from hooks_constants.pii_prevention_constants import ( # noqa: E402
|
|
21
22
|
ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES,
|
|
22
23
|
ALL_EXACT_LEGAL_NOTICE_BASENAMES,
|
|
@@ -273,9 +274,33 @@ def _find_home_paths(text: str) -> list[PiiFinding]:
|
|
|
273
274
|
return all_findings
|
|
274
275
|
|
|
275
276
|
|
|
277
|
+
def _all_allowlisted_private_ip_addresses() -> frozenset[str]:
|
|
278
|
+
"""Return every private IPv4 address the scanner must leave unflagged.
|
|
279
|
+
|
|
280
|
+
::
|
|
281
|
+
|
|
282
|
+
CLAUDE_NAS_HOST=10.20.30.40 -> static set + {"10.20.30.40"}
|
|
283
|
+
CLAUDE_NAS_HOST=nas.local -> static set only (a name, not an address)
|
|
284
|
+
(env unset, no file) -> static set only
|
|
285
|
+
|
|
286
|
+
The committed set carries no real address. The maintainer's own NAS host
|
|
287
|
+
resolves at scan time from ``CLAUDE_NAS_HOST`` or
|
|
288
|
+
``~/.claude/local-identity.json``, so their ssh traffic and git-ignored
|
|
289
|
+
local config stay unflagged while the repository stays publishable.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
The static allowlist, joined with the locally configured NAS host when
|
|
293
|
+
that host is itself a private IPv4 address.
|
|
294
|
+
"""
|
|
295
|
+
configured_nas_host = nas_host()
|
|
296
|
+
if not _is_private_ipv4_address(configured_nas_host):
|
|
297
|
+
return ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES
|
|
298
|
+
return ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES | {configured_nas_host}
|
|
299
|
+
|
|
300
|
+
|
|
276
301
|
def _find_private_ips(text: str) -> list[PiiFinding]:
|
|
277
302
|
all_findings: list[PiiFinding] = []
|
|
278
|
-
allowlisted_addresses =
|
|
303
|
+
allowlisted_addresses = _all_allowlisted_private_ip_addresses()
|
|
279
304
|
for each_match in IPV4_PATTERN.finditer(text):
|
|
280
305
|
address_text = each_match.group(1)
|
|
281
306
|
if address_text in allowlisted_addresses:
|
|
@@ -124,8 +124,8 @@ def test_flags_field_read_only_by_test_module(neutral_root: Path) -> None:
|
|
|
124
124
|
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
125
125
|
"\n"
|
|
126
126
|
"def test_debug_port_default() -> None:\n"
|
|
127
|
-
" cfg = ThemeUpdateConfig(portal_url='x', debug_port=
|
|
128
|
-
" assert cfg.debug_port ==
|
|
127
|
+
" cfg = ThemeUpdateConfig(portal_url='x', debug_port=8471, timeout_seconds=30)\n"
|
|
128
|
+
" assert cfg.debug_port == 8471\n"
|
|
129
129
|
" assert cfg.portal_url == 'x'\n"
|
|
130
130
|
" assert cfg.timeout_seconds == 30\n"
|
|
131
131
|
)
|
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
8
10
|
_HOOK_DIR = Path(__file__).parent
|
|
9
11
|
_HOOKS_DIR = _HOOK_DIR.parent
|
|
10
12
|
if str(_HOOK_DIR) not in sys.path:
|
|
@@ -26,6 +28,10 @@ SYNTHETIC_HOME_PATH = r"C:\Users\fixture_real_user\notes.txt"
|
|
|
26
28
|
SYNTHETIC_PLACEHOLDER_HOME = r"C:\Users\<you>\notes.txt"
|
|
27
29
|
SYNTHETIC_PRIVATE_IP = "192.168.42.17"
|
|
28
30
|
SYNTHETIC_PUBLIC_IP = "8.8.8.8"
|
|
31
|
+
NAS_HOST_ENVIRONMENT_VARIABLE = "CLAUDE_NAS_HOST"
|
|
32
|
+
SYNTHETIC_NAS_PRIVATE_IP = "10.20.30.40"
|
|
33
|
+
SYNTHETIC_NAS_HOSTNAME = "nas.example.local"
|
|
34
|
+
SYNTHETIC_NAS_SSH_COMMAND = f"ssh -p 2200 operator@{SYNTHETIC_NAS_PRIVATE_IP} uptime"
|
|
29
35
|
|
|
30
36
|
|
|
31
37
|
def test_flags_real_email_and_allows_example_domain() -> None:
|
|
@@ -69,8 +75,27 @@ def test_allows_loopback_unspecified_and_link_local_addresses() -> None:
|
|
|
69
75
|
assert scan_text_for_pii("peer 169.254.10.20") == []
|
|
70
76
|
|
|
71
77
|
|
|
72
|
-
def
|
|
73
|
-
|
|
78
|
+
def test_allows_the_nas_host_resolved_from_local_configuration(
|
|
79
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
80
|
+
) -> None:
|
|
81
|
+
monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_PRIVATE_IP)
|
|
82
|
+
assert scan_text_for_pii(SYNTHETIC_NAS_SSH_COMMAND) == []
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_flags_private_ip_that_is_not_the_configured_nas_host(
|
|
86
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
87
|
+
) -> None:
|
|
88
|
+
monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_PRIVATE_IP)
|
|
89
|
+
all_lan_hits = scan_text_for_pii(f"peer {SYNTHETIC_PRIVATE_IP}")
|
|
90
|
+
assert [each.matched_text for each in all_lan_hits] == [SYNTHETIC_PRIVATE_IP]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_flags_private_ip_when_the_configured_nas_host_is_a_hostname(
|
|
94
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
95
|
+
) -> None:
|
|
96
|
+
monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_HOSTNAME)
|
|
97
|
+
all_lan_hits = scan_text_for_pii(f"host {SYNTHETIC_PRIVATE_IP}")
|
|
98
|
+
assert any(each.category == "private-ip" for each in all_lan_hits)
|
|
74
99
|
|
|
75
100
|
|
|
76
101
|
def test_flags_github_token_aws_key_and_pem_header() -> None:
|
|
@@ -169,11 +169,7 @@ ALL_PLACEHOLDER_HOME_USERNAMES: frozenset[str] = frozenset(
|
|
|
169
169
|
}
|
|
170
170
|
)
|
|
171
171
|
|
|
172
|
-
ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES: frozenset[str] = frozenset(
|
|
173
|
-
{
|
|
174
|
-
"192.168.1.100",
|
|
175
|
-
}
|
|
176
|
-
)
|
|
172
|
+
ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES: frozenset[str] = frozenset()
|
|
177
173
|
|
|
178
174
|
MAXIMUM_FINDINGS_PER_SCAN: int = 12
|
|
179
175
|
MAXIMUM_STAGED_FILE_BYTES: int = 1_000_000
|
|
@@ -269,9 +265,9 @@ CORRECTIVE_MESSAGE_HEADER: str = (
|
|
|
269
265
|
CORRECTIVE_MESSAGE_FOOTER: str = (
|
|
270
266
|
"Remediate: replace with placeholders (user@example.com, C:/Users/example/), "
|
|
271
267
|
"move secrets to an env or secret store, and run the privacy-hygiene skill "
|
|
272
|
-
"for a full sweep.
|
|
273
|
-
"
|
|
274
|
-
"
|
|
268
|
+
"for a full sweep. Your own NAS host is allowlisted at scan time from "
|
|
269
|
+
"CLAUDE_NAS_HOST or ~/.claude/local-identity.json, so set it there rather "
|
|
270
|
+
"than committing the address."
|
|
275
271
|
)
|
|
276
272
|
|
|
277
273
|
FINDING_LINE_TEMPLATE: str = " [{category}] {preview}"
|
package/package.json
CHANGED
|
@@ -24,7 +24,7 @@ Find and remove personal data and high-confidence secrets before they land in gi
|
|
|
24
24
|
|---|---|---|
|
|
25
25
|
| Email | `person@company.io` | `user@example.com`, `user@example.org`, `user@example.net` |
|
|
26
26
|
| Home path | `C:/Users/realname/...`, `/Users/realname/...`, `/home/realname/...` | `C:/Users/example/...`, `C:/Users/<you>/...`, `/Users/alice/...` |
|
|
27
|
-
| LAN address | Unlisted `10.x` / `172.16–31.x` / `192.168.x` | Public addresses;
|
|
27
|
+
| LAN address | Unlisted `10.x` / `172.16–31.x` / `192.168.x` | Public addresses; your NAS host from `CLAUDE_NAS_HOST` or `~/.claude/local-identity.json`; entries in `ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES` |
|
|
28
28
|
| Secret | `ghp_…`, `github_pat_…`, `AKIA…`, PEM private-key headers | Public keys, redacted `***`, env var names without values |
|
|
29
29
|
|
|
30
30
|
Surfaces:
|
|
@@ -71,7 +71,7 @@ Review each hit. Ignore:
|
|
|
71
71
|
|---|---|
|
|
72
72
|
| Real email | Replace with `user@example.com` or remove |
|
|
73
73
|
| Home path | Use `Path.home()`, `~`, or `C:/Users/<you>/` |
|
|
74
|
-
| LAN address | Remove, use a hostname, or
|
|
74
|
+
| LAN address | Remove, use a hostname, or — for your own NAS — set the host in `CLAUDE_NAS_HOST` or `~/.claude/local-identity.json`, both of which stay out of git |
|
|
75
75
|
| Credential material | Remove from the tree; rotate the credential; load from env/secret store |
|
|
76
76
|
| Already committed | Rewrite is not enough if already pushed — rotate secrets; scrub history only with explicit user approval |
|
|
77
77
|
|
|
@@ -87,7 +87,7 @@ Review each hit. Ignore:
|
|
|
87
87
|
- Intentional public maintainer identity published on purpose
|
|
88
88
|
- Example domains reserved for documentation (`example.com` and siblings)
|
|
89
89
|
- Placeholder home users (`example`, `user`, `alice`, `<you>`, `YOUR_USER`)
|
|
90
|
-
-
|
|
90
|
+
- The NAS host you configure locally (allowlisted at scan time, never committed)
|
|
91
91
|
- Public addresses and docs that name private ranges without a live host (still prefer hostnames)
|
|
92
92
|
|
|
93
93
|
## Enable on any machine / public repository
|
|
@@ -103,7 +103,7 @@ Hooks register via `hooks/hooks.json` into `~/.claude/settings.json`. Once insta
|
|
|
103
103
|
|
|
104
104
|
## Open knobs
|
|
105
105
|
|
|
106
|
-
- **NAS / LAN allowlist:** Unlisted private IPs are blocked. `
|
|
106
|
+
- **NAS / LAN allowlist:** Unlisted private IPs are blocked. The scanner resolves your NAS host from `CLAUDE_NAS_HOST`, then `~/.claude/local-identity.json` (`nas.host`), and allowlists it when it is a private address, so the committed tree holds no real host. `ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES` in `hooks_constants` holds the static allowlist for any host every machine must share.
|
|
107
107
|
- **Public maintainer identity:** when a real email or name is intentional product surface, keep it and note that in the PR body so reviewers do not treat it as a leak.
|
|
108
108
|
|
|
109
109
|
## What this skill does not do
|