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.
@@ -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 the group's tests pass or collect nothing; pytest's non-zero
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
- pytest_process = subprocess.run(
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
- _resolve_gate_python_executable(repository_root),
1731
- *ALL_PYTEST_MODULE_INVOCATION,
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
- cwd=str(group_root),
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
- if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
1740
- return 0
1741
- return pytest_process.returncode
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 = ALL_ALLOWLISTED_PRIVATE_IP_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=9222, timeout_seconds=30)\n"
128
- " assert cfg.debug_port == 9222\n"
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 test_allows_product_nas_private_ip() -> None:
73
- assert scan_text_for_pii("ssh -p 9222 jon@192.168.1.100 uptime") == []
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. Add intentional LAN hosts to "
273
- "ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES in hooks_constants when tooling must "
274
- "keep a host such as a NAS."
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.93.0",
3
+ "version": "1.93.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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; product NAS `192.168.1.100`; other entries in `ALL_ALLOWLISTED_PRIVATE_IP_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 add to `ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES` only when tooling requires it |
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
- - Product NAS host `192.168.1.100` (allowlisted for the NAS SSH rule/hook)
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. `192.168.1.100` is allowlisted for the NAS SSH enforcer. Add further hosts to `ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES` only when tooling requires them.
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