claude-dev-env 7.3.0 → 7.4.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/.agents/skills/session-continuity/continuity.mjs +1 -3
- package/.agents/skills/session-continuity/continuity.test.mjs +12 -4
- package/AGENTS.md +12 -2
- package/bin/install.agents-home.test.mjs +1 -0
- package/bin/install.codex-rules.test.mjs +2 -0
- package/bin/install.cursor-rules.test.mjs +1 -0
- package/bin/install.mjs +57 -10
- package/bin/install.prune.test.mjs +1 -0
- package/bin/install.pstack-plugin.test.mjs +1 -0
- package/bin/install.settings-defaults.test.mjs +1 -0
- package/bin/install.test.mjs +118 -4
- package/bin/install.transaction.test.mjs +6 -0
- package/bin/install.uninstall-transaction.test.mjs +2 -0
- package/bin/pstack.mjs +24 -4
- package/bin/pstack.test.mjs +20 -10
- package/hooks/blocking/env_var_table_code_drift_blocker.py +11 -99
- package/hooks/blocking/env_var_table_rows.py +130 -0
- package/hooks/blocking/pii_scanner.py +19 -29
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +81 -3
- package/hooks/blocking/test_env_var_table_rows.py +100 -0
- package/hooks/blocking/test_pii_scanner.py +34 -24
- package/hooks/hooks_constants/env_var_table_code_drift_constants.py +12 -4
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +12 -10
- package/hooks/hooks_constants/pii_prevention_constants.py +10 -25
- package/package.json +1 -1
- package/scripts/pstack-adapters/common.md +1 -1
- package/scripts/repository_checks/config/constants.py +7 -17
- package/scripts/repository_checks/config/email_exemptions.py +10 -0
- package/scripts/repository_checks/email_exemptions.py +96 -0
- package/scripts/repository_checks/env_var_documentation.py +1 -9
- package/scripts/repository_checks/tracked_secrets.py +10 -4
- package/scripts/tests/test_email_exemptions.py +121 -0
- package/scripts/tests/test_env_var_documentation.py +26 -0
- package/scripts/tests/test_rules.py +25 -0
- package/scripts/tests/test_tracked_secrets.py +13 -0
|
@@ -25,22 +25,23 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
25
25
|
if _hooks_dir not in sys.path:
|
|
26
26
|
sys.path.insert(0, _hooks_dir)
|
|
27
27
|
|
|
28
|
-
from
|
|
29
|
-
|
|
28
|
+
from env_var_table_rows import (
|
|
29
|
+
_code_file_reference_in_cell,
|
|
30
|
+
_env_var_name_in_cell,
|
|
31
|
+
_is_separator_row,
|
|
32
|
+
_row_cells,
|
|
33
|
+
iter_env_var_table_rows,
|
|
34
|
+
)
|
|
35
|
+
from hooks_constants.env_var_table_code_drift_constants import (
|
|
30
36
|
ALL_NOISE_DIRECTORY_NAMES,
|
|
31
|
-
BACKTICK_TOKEN_PATTERN,
|
|
32
|
-
CODE_FENCE_PATTERN,
|
|
33
37
|
DRIFT_ADDITIONAL_CONTEXT,
|
|
34
38
|
DRIFT_MESSAGE_TEMPLATE,
|
|
35
39
|
DRIFT_SYSTEM_MESSAGE,
|
|
36
|
-
ENV_VAR_NAME_PATTERN,
|
|
37
40
|
GIT_DIRECTORY_NAME,
|
|
38
41
|
MARKDOWN_FILE_EXTENSION,
|
|
39
42
|
MAX_DRIFT_ISSUES,
|
|
40
43
|
MAX_SUBTREE_FILES_SCANNED,
|
|
41
44
|
MINIMUM_ENV_VAR_ROW_CELL_COUNT,
|
|
42
|
-
SEPARATOR_CELL_PATTERN,
|
|
43
|
-
TABLE_ROW_PATTERN,
|
|
44
45
|
)
|
|
45
46
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
46
47
|
from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
|
|
@@ -65,87 +66,6 @@ def is_markdown_file(file_path: str) -> bool:
|
|
|
65
66
|
return extension.lower() == MARKDOWN_FILE_EXTENSION
|
|
66
67
|
|
|
67
68
|
|
|
68
|
-
def _row_cells(table_line: str) -> list[str]:
|
|
69
|
-
"""Return the trimmed cells of one markdown table row.
|
|
70
|
-
|
|
71
|
-
Args:
|
|
72
|
-
table_line: A single line that begins with a pipe character.
|
|
73
|
-
|
|
74
|
-
Returns:
|
|
75
|
-
The text of each pipe-delimited cell, stripped, with the empty leading
|
|
76
|
-
and trailing segments a bounding pipe produces removed.
|
|
77
|
-
"""
|
|
78
|
-
stripped_line = table_line.strip()
|
|
79
|
-
inner = stripped_line.strip("|")
|
|
80
|
-
return [each_cell.strip() for each_cell in inner.split("|")]
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _first_backtick_token(cell_text: str) -> str | None:
|
|
84
|
-
"""Return the first backtick-wrapped token in a cell, when it has one.
|
|
85
|
-
|
|
86
|
-
Args:
|
|
87
|
-
cell_text: The trimmed text of a table cell.
|
|
88
|
-
|
|
89
|
-
Returns:
|
|
90
|
-
The text inside the first pair of backticks, or None when the cell
|
|
91
|
-
carries no backtick-wrapped token.
|
|
92
|
-
"""
|
|
93
|
-
token_match = BACKTICK_TOKEN_PATTERN.search(cell_text)
|
|
94
|
-
if token_match is None:
|
|
95
|
-
return None
|
|
96
|
-
inner_text = token_match.group(1).strip()
|
|
97
|
-
return inner_text or None
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _env_var_name_in_cell(cell_text: str) -> str | None:
|
|
101
|
-
"""Return the environment-variable name a cell names, when it names one.
|
|
102
|
-
|
|
103
|
-
Args:
|
|
104
|
-
cell_text: The trimmed text of a table cell.
|
|
105
|
-
|
|
106
|
-
Returns:
|
|
107
|
-
The UPPER_SNAKE variable name inside the first backticks, or None when
|
|
108
|
-
the cell names no variable-shaped token.
|
|
109
|
-
"""
|
|
110
|
-
token = _first_backtick_token(cell_text)
|
|
111
|
-
if token is None:
|
|
112
|
-
return None
|
|
113
|
-
if ENV_VAR_NAME_PATTERN.match(token) is None:
|
|
114
|
-
return None
|
|
115
|
-
return token
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _code_file_reference_in_cell(cell_text: str) -> str | None:
|
|
119
|
-
"""Return the code-file path a cell names, when it names one.
|
|
120
|
-
|
|
121
|
-
Args:
|
|
122
|
-
cell_text: The trimmed text of a table cell.
|
|
123
|
-
|
|
124
|
-
Returns:
|
|
125
|
-
The relative code-file path inside the first backticks, or None when the
|
|
126
|
-
token carries no recognized code-file extension.
|
|
127
|
-
"""
|
|
128
|
-
token = _first_backtick_token(cell_text)
|
|
129
|
-
if token is None:
|
|
130
|
-
return None
|
|
131
|
-
_, extension = os.path.splitext(token)
|
|
132
|
-
if extension.lower() not in ALL_CODE_FILE_EXTENSIONS:
|
|
133
|
-
return None
|
|
134
|
-
return token
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _is_separator_row(all_cells: list[str]) -> bool:
|
|
138
|
-
"""Return whether every cell is a markdown table header-separator cell.
|
|
139
|
-
|
|
140
|
-
Args:
|
|
141
|
-
all_cells: The trimmed cells of one table row.
|
|
142
|
-
|
|
143
|
-
Returns:
|
|
144
|
-
True when each cell holds only dashes, colons, and whitespace.
|
|
145
|
-
"""
|
|
146
|
-
return all(SEPARATOR_CELL_PATTERN.match(each_cell) is not None for each_cell in all_cells)
|
|
147
|
-
|
|
148
|
-
|
|
149
69
|
def _resolve_scan_root(markdown_directory: Path) -> Path:
|
|
150
70
|
"""Return the repository directory whose subtree bounds the code-file search.
|
|
151
71
|
|
|
@@ -248,8 +168,8 @@ def _code_file_reads_variable(code_file: Path, variable_name: str) -> bool | Non
|
|
|
248
168
|
def find_drift_rows(content: str, markdown_directory: Path) -> list[str]:
|
|
249
169
|
"""Return each env-var table row whose code file does not read the variable.
|
|
250
170
|
|
|
251
|
-
|
|
252
|
-
|
|
171
|
+
Reads environment-variable tables and headerless edit fragments outside code
|
|
172
|
+
fences. A row counts when its first cell names an UPPER_SNAKE variable and
|
|
253
173
|
a later cell names a code file with a recognized extension. The row drifts when
|
|
254
174
|
that code file resolves under the scan root yet its source never references the
|
|
255
175
|
variable name; a row whose code file does not resolve, or cannot be read, is
|
|
@@ -266,15 +186,7 @@ def find_drift_rows(content: str, markdown_directory: Path) -> list[str]:
|
|
|
266
186
|
scan_root = _resolve_scan_root(markdown_directory)
|
|
267
187
|
drift_rows: list[str] = []
|
|
268
188
|
already_reported: set[str] = set()
|
|
269
|
-
|
|
270
|
-
for each_line in content.splitlines():
|
|
271
|
-
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
272
|
-
is_inside_code_fence = not is_inside_code_fence
|
|
273
|
-
continue
|
|
274
|
-
if is_inside_code_fence:
|
|
275
|
-
continue
|
|
276
|
-
if TABLE_ROW_PATTERN.match(each_line) is None:
|
|
277
|
-
continue
|
|
189
|
+
for each_line in iter_env_var_table_rows(content):
|
|
278
190
|
each_finding = _drift_finding_for_row(each_line, scan_root)
|
|
279
191
|
if each_finding is None or each_finding in already_reported:
|
|
280
192
|
continue
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Recognize Markdown environment-variable tables and parse their cells."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from hooks_constants.env_var_table_code_drift_constants import (
|
|
9
|
+
ALL_CODE_FILE_EXTENSIONS,
|
|
10
|
+
ALL_GENERIC_ENV_VAR_HEADERS,
|
|
11
|
+
BACKTICK_TOKEN_PATTERN,
|
|
12
|
+
CODE_FENCE_PATTERN,
|
|
13
|
+
ENV_VAR_HEADER_PATTERN,
|
|
14
|
+
ENV_VAR_HEADING_PATTERN,
|
|
15
|
+
ENV_VAR_NAME_PATTERN,
|
|
16
|
+
SEPARATOR_CELL_PATTERN,
|
|
17
|
+
TABLE_ROW_PATTERN,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _row_cells(table_line: str) -> list[str]:
|
|
22
|
+
"""Return the trimmed cells of one markdown table row.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
table_line: A single line that begins with a pipe character.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The text of each pipe-delimited cell, stripped, with the empty leading
|
|
29
|
+
and trailing segments a bounding pipe produces removed.
|
|
30
|
+
"""
|
|
31
|
+
stripped_line = table_line.strip()
|
|
32
|
+
inner = stripped_line.strip("|")
|
|
33
|
+
return [each_cell.strip() for each_cell in inner.split("|")]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _first_backtick_token(cell_text: str) -> str | None:
|
|
37
|
+
"""Return the first backtick-wrapped token in a cell, when it has one.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
cell_text: The trimmed text of a table cell.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The text inside the first pair of backticks, or None when the cell
|
|
44
|
+
carries no backtick-wrapped token.
|
|
45
|
+
"""
|
|
46
|
+
token_match = BACKTICK_TOKEN_PATTERN.search(cell_text)
|
|
47
|
+
if token_match is None:
|
|
48
|
+
return None
|
|
49
|
+
inner_text = token_match.group(1).strip()
|
|
50
|
+
return inner_text or None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _env_var_name_in_cell(cell_text: str) -> str | None:
|
|
54
|
+
"""Return the environment-variable name a cell names, when it names one.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
cell_text: The trimmed text of a table cell.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The UPPER_SNAKE variable name inside the first backticks, or None when
|
|
61
|
+
the cell names no variable-shaped token.
|
|
62
|
+
"""
|
|
63
|
+
token = _first_backtick_token(cell_text)
|
|
64
|
+
if token is None:
|
|
65
|
+
return None
|
|
66
|
+
if ENV_VAR_NAME_PATTERN.match(token) is None:
|
|
67
|
+
return None
|
|
68
|
+
return token
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _code_file_reference_in_cell(cell_text: str) -> str | None:
|
|
72
|
+
"""Return the code-file path a cell names, when it names one.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
cell_text: The trimmed text of a table cell.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The relative code-file path inside the first backticks, or None when the
|
|
79
|
+
token carries no recognized code-file extension.
|
|
80
|
+
"""
|
|
81
|
+
token = _first_backtick_token(cell_text)
|
|
82
|
+
if token is None:
|
|
83
|
+
return None
|
|
84
|
+
_, extension = os.path.splitext(token)
|
|
85
|
+
if extension.lower() not in ALL_CODE_FILE_EXTENSIONS:
|
|
86
|
+
return None
|
|
87
|
+
return token
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_separator_row(all_cells: list[str]) -> bool:
|
|
91
|
+
"""Return whether every cell is a markdown table header-separator cell.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
all_cells: The trimmed cells of one table row.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True when each cell holds only dashes, colons, and whitespace.
|
|
98
|
+
"""
|
|
99
|
+
return all(SEPARATOR_CELL_PATTERN.match(each_cell) is not None for each_cell in all_cells)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def iter_env_var_table_rows(content: str) -> Iterator[str]:
|
|
103
|
+
"""Yield environment-table rows and headerless edit fragments outside code fences."""
|
|
104
|
+
is_inside_code_fence = False
|
|
105
|
+
is_environment_table: bool | None = None
|
|
106
|
+
is_environment_section = False
|
|
107
|
+
for each_line in content.splitlines():
|
|
108
|
+
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
109
|
+
is_inside_code_fence = not is_inside_code_fence
|
|
110
|
+
is_environment_table = None
|
|
111
|
+
continue
|
|
112
|
+
if is_inside_code_fence:
|
|
113
|
+
continue
|
|
114
|
+
if each_line.lstrip().startswith("#"):
|
|
115
|
+
is_environment_section = ENV_VAR_HEADING_PATTERN.search(each_line) is not None
|
|
116
|
+
if TABLE_ROW_PATTERN.match(each_line) is None:
|
|
117
|
+
is_environment_table = None
|
|
118
|
+
continue
|
|
119
|
+
all_cells = _row_cells(each_line)
|
|
120
|
+
if not all_cells or _is_separator_row(all_cells):
|
|
121
|
+
continue
|
|
122
|
+
if is_environment_table is None:
|
|
123
|
+
header = all_cells[0].strip("`* ")
|
|
124
|
+
is_environment_table = (
|
|
125
|
+
ENV_VAR_HEADER_PATTERN.fullmatch(header) is not None
|
|
126
|
+
or _env_var_name_in_cell(all_cells[0]) is not None
|
|
127
|
+
or (is_environment_section and header.casefold() in ALL_GENERIC_ENV_VAR_HEADERS)
|
|
128
|
+
)
|
|
129
|
+
if is_environment_table:
|
|
130
|
+
yield each_line
|
|
@@ -18,10 +18,9 @@ if _hooks_directory not in sys.path:
|
|
|
18
18
|
sys.path.insert(0, _hooks_directory)
|
|
19
19
|
|
|
20
20
|
from hooks_constants.local_identity import nas_host # noqa: E402
|
|
21
|
-
from hooks_constants.pii_prevention_constants import (
|
|
21
|
+
from hooks_constants.pii_prevention_constants import (
|
|
22
22
|
ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES,
|
|
23
23
|
ALL_EXACT_LEGAL_NOTICE_BASENAMES,
|
|
24
|
-
ALL_HOME_DIRECTORY_PATH_MARKERS,
|
|
25
24
|
ALL_PLACEHOLDER_HOME_USERNAMES,
|
|
26
25
|
ALL_REDACTED_PREVIEW_CATEGORIES,
|
|
27
26
|
ALL_RFC1918_NETWORK_CIDRS,
|
|
@@ -37,6 +36,8 @@ from hooks_constants.pii_prevention_constants import ( # noqa: E402
|
|
|
37
36
|
CONFTEST_BASENAME,
|
|
38
37
|
EMAIL_PATTERN,
|
|
39
38
|
HOME_PATH_PATTERN,
|
|
39
|
+
HOME_USERNAME_MARKER_PATTERN,
|
|
40
|
+
INTERPOLATED_HOME_USERNAME_PATTERN,
|
|
40
41
|
IPV4_PATTERN,
|
|
41
42
|
IPV4_VERSION_NUMBER,
|
|
42
43
|
MAXIMUM_FINDINGS_PER_SCAN,
|
|
@@ -90,9 +91,7 @@ def is_path_exempt_from_pii_scan(file_path: str) -> bool:
|
|
|
90
91
|
return False
|
|
91
92
|
normalized_path = file_path.replace("\\", "/").lower()
|
|
92
93
|
path_for_suffix_match = (
|
|
93
|
-
normalized_path
|
|
94
|
-
if normalized_path.startswith("/")
|
|
95
|
-
else f"/{normalized_path}"
|
|
94
|
+
normalized_path if normalized_path.startswith("/") else f"/{normalized_path}"
|
|
96
95
|
)
|
|
97
96
|
basename = os.path.basename(file_path)
|
|
98
97
|
basename_lower = basename.lower()
|
|
@@ -106,9 +105,7 @@ def is_path_exempt_from_pii_scan(file_path: str) -> bool:
|
|
|
106
105
|
or basename_lower.endswith(TEST_MODULE_BASENAME_SUFFIX)
|
|
107
106
|
):
|
|
108
107
|
return True
|
|
109
|
-
if TESTS_PATH_SEGMENT in normalized_path or normalized_path.startswith(
|
|
110
|
-
TESTS_PATH_PREFIX
|
|
111
|
-
):
|
|
108
|
+
if TESTS_PATH_SEGMENT in normalized_path or normalized_path.startswith(TESTS_PATH_PREFIX):
|
|
112
109
|
return True
|
|
113
110
|
if basename_lower.endswith(ALL_SOURCE_TEST_FILE_SUFFIXES) and (
|
|
114
111
|
SPEC_BASENAME_MARKER in basename_lower or TEST_BASENAME_MARKER in basename_lower
|
|
@@ -130,11 +127,7 @@ def _redact_sensitive_preview(matched_text: str) -> str:
|
|
|
130
127
|
return REDACTED_SHORT_PREVIEW
|
|
131
128
|
prefix_length = REDACTED_PREVIEW_PREFIX_LENGTH
|
|
132
129
|
suffix_length = REDACTED_PREVIEW_SUFFIX_LENGTH
|
|
133
|
-
return
|
|
134
|
-
matched_text[:prefix_length]
|
|
135
|
-
+ REDACTED_PREVIEW_ELLIPSIS
|
|
136
|
-
+ matched_text[-suffix_length:]
|
|
137
|
-
)
|
|
130
|
+
return matched_text[:prefix_length] + REDACTED_PREVIEW_ELLIPSIS + matched_text[-suffix_length:]
|
|
138
131
|
|
|
139
132
|
|
|
140
133
|
def _build_preview(matched_text: str, category: str) -> str:
|
|
@@ -161,8 +154,7 @@ def _is_safe_email_domain(domain_name: str) -> bool:
|
|
|
161
154
|
if domain_name in ALL_SAFE_EMAIL_DOMAINS:
|
|
162
155
|
return True
|
|
163
156
|
return any(
|
|
164
|
-
domain_name.endswith("." + each_safe_domain)
|
|
165
|
-
for each_safe_domain in ALL_SAFE_EMAIL_DOMAINS
|
|
157
|
+
domain_name.endswith("." + each_safe_domain) for each_safe_domain in ALL_SAFE_EMAIL_DOMAINS
|
|
166
158
|
)
|
|
167
159
|
|
|
168
160
|
|
|
@@ -180,8 +172,8 @@ def _home_username_offset(matched_path: str) -> tuple[int, str] | None:
|
|
|
180
172
|
|
|
181
173
|
C:\\Users\\Users\\file.txt -> (9, "Users") the second Users, not the first
|
|
182
174
|
|
|
183
|
-
|
|
184
|
-
|
|
175
|
+
The marker match consumes repeated separators, so the username starts after
|
|
176
|
+
both ordinary and escaped Windows path separators.
|
|
185
177
|
|
|
186
178
|
Args:
|
|
187
179
|
matched_path: The matched home-path substring.
|
|
@@ -189,18 +181,14 @@ def _home_username_offset(matched_path: str) -> tuple[int, str] | None:
|
|
|
189
181
|
Returns:
|
|
190
182
|
The username's start index and value, or None when no marker is found.
|
|
191
183
|
"""
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if not username:
|
|
201
|
-
return None
|
|
202
|
-
return username_start, username
|
|
203
|
-
return None
|
|
184
|
+
marker = HOME_USERNAME_MARKER_PATTERN.search(matched_path)
|
|
185
|
+
if marker is None:
|
|
186
|
+
return None
|
|
187
|
+
username_start = marker.end()
|
|
188
|
+
username = matched_path[username_start:].replace("\\", "/").split("/", 1)[0]
|
|
189
|
+
if not username:
|
|
190
|
+
return None
|
|
191
|
+
return username_start, username
|
|
204
192
|
|
|
205
193
|
|
|
206
194
|
def _username_from_home_path(matched_path: str) -> str | None:
|
|
@@ -226,6 +214,8 @@ def _redact_home_path_username(matched_text: str) -> str:
|
|
|
226
214
|
|
|
227
215
|
|
|
228
216
|
def _is_placeholder_home_username(username: str) -> bool:
|
|
217
|
+
if INTERPOLATED_HOME_USERNAME_PATTERN.fullmatch(username) is not None:
|
|
218
|
+
return True
|
|
229
219
|
if ANGLE_BRACKET_PLACEHOLDER_PATTERN.match(username) is not None:
|
|
230
220
|
return True
|
|
231
221
|
lowered_username = username.lower()
|
|
@@ -125,9 +125,10 @@ def test_production_entrypoint_denies_drifted_table(tmp_path: Path) -> None:
|
|
|
125
125
|
hook_specific_output = parsed_payload["hookSpecificOutput"]
|
|
126
126
|
assert completed.returncode == 0, completed.stderr
|
|
127
127
|
assert hook_specific_output["permissionDecision"] == "deny"
|
|
128
|
-
assert
|
|
129
|
-
"
|
|
130
|
-
|
|
128
|
+
assert (
|
|
129
|
+
"GOOGLE_APPLICATION_CREDENTIALS -> auth/google_auth.py"
|
|
130
|
+
in hook_specific_output["permissionDecisionReason"]
|
|
131
|
+
)
|
|
131
132
|
assert_hook_deny_log_contains(tmp_path, "env_var_table_code_drift_blocker.py")
|
|
132
133
|
|
|
133
134
|
|
|
@@ -159,3 +160,80 @@ def test_ignores_row_whose_second_cell_is_not_a_code_file(tmp_path: Path) -> Non
|
|
|
159
160
|
"| `SOME_FLAG` | `enables the thing` | off |\n"
|
|
160
161
|
)
|
|
161
162
|
assert find_drift_rows(content, doc_path.parent) == []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_constant_tables_do_not_claim_environment_consumers(tmp_path: Path) -> None:
|
|
166
|
+
_anchor_repo_root(tmp_path)
|
|
167
|
+
_write(tmp_path / "workflow.py", "def run():\n return\n")
|
|
168
|
+
content = (
|
|
169
|
+
"| Symbol | Imported by |\n"
|
|
170
|
+
"| --- | --- |\n"
|
|
171
|
+
"| `PROCESSING_VISIBLE` | `workflow.py` |\n\n"
|
|
172
|
+
"| Component | Location | Purpose |\n"
|
|
173
|
+
"| --- | --- | --- |\n"
|
|
174
|
+
"| `ACCOUNT_LINK_PRIMARY` | `workflow.py` | Selector |\n\n"
|
|
175
|
+
"| Constant | Value | Source |\n"
|
|
176
|
+
"| --- | --- | --- |\n"
|
|
177
|
+
"| `UPLOAD_DELAY_SECONDS` | 5 | `workflow.py` |\n"
|
|
178
|
+
)
|
|
179
|
+
assert find_drift_rows(content, tmp_path) == []
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_environment_table_after_constants_still_reports_drift(tmp_path: Path) -> None:
|
|
183
|
+
_anchor_repo_root(tmp_path)
|
|
184
|
+
_write(tmp_path / "workflow.py", "def run():\n return\n")
|
|
185
|
+
content = (
|
|
186
|
+
"| Constant | Source |\n"
|
|
187
|
+
"| --- | --- |\n"
|
|
188
|
+
"| `UPLOAD_DELAY_SECONDS` | `workflow.py` |\n\n"
|
|
189
|
+
"| Environment variable | Consumer |\n"
|
|
190
|
+
"| --- | --- |\n"
|
|
191
|
+
"| `UPLOAD_TOKEN` | `workflow.py` |\n"
|
|
192
|
+
)
|
|
193
|
+
assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_environment_section_supports_name_header(tmp_path: Path) -> None:
|
|
197
|
+
_anchor_repo_root(tmp_path)
|
|
198
|
+
_write(tmp_path / "workflow.py", "def run():\n return\n")
|
|
199
|
+
content = (
|
|
200
|
+
"## Environment variables\n\n"
|
|
201
|
+
"| Name | Consumer |\n"
|
|
202
|
+
"| --- | --- |\n"
|
|
203
|
+
"| `UPLOAD_TOKEN` | `workflow.py` |\n"
|
|
204
|
+
)
|
|
205
|
+
assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_headerless_edit_fragment_still_reports_drift(tmp_path: Path) -> None:
|
|
209
|
+
_anchor_repo_root(tmp_path)
|
|
210
|
+
_write(tmp_path / "workflow.py", "def run():\n return\n")
|
|
211
|
+
content = "| `UPLOAD_TOKEN` | `workflow.py` |\n"
|
|
212
|
+
assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_descriptive_environment_header_reports_drift(tmp_path: Path) -> None:
|
|
216
|
+
_anchor_repo_root(tmp_path)
|
|
217
|
+
_write(tmp_path / "workflow.py", "def run():\n return\n")
|
|
218
|
+
content = (
|
|
219
|
+
"| Environment Variable Name | Consumer |\n"
|
|
220
|
+
"| --- | --- |\n"
|
|
221
|
+
"| `UPLOAD_TOKEN` | `workflow.py` |\n"
|
|
222
|
+
)
|
|
223
|
+
assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_explicit_source_header_overrides_environment_section(tmp_path: Path) -> None:
|
|
227
|
+
_anchor_repo_root(tmp_path)
|
|
228
|
+
_write(tmp_path / "workflow.py", "def run():\n return\n")
|
|
229
|
+
content = (
|
|
230
|
+
"## Environment variables\n\n"
|
|
231
|
+
"| Name | Consumer |\n"
|
|
232
|
+
"| --- | --- |\n"
|
|
233
|
+
"| `UPLOAD_TOKEN` | `workflow.py` |\n\n"
|
|
234
|
+
"The source also defines a delay constant.\n\n"
|
|
235
|
+
"| Constant | Source |\n"
|
|
236
|
+
"| --- | --- |\n"
|
|
237
|
+
"| `UPLOAD_DELAY_SECONDS` | `workflow.py` |\n"
|
|
238
|
+
)
|
|
239
|
+
assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Tests for environment-variable table row recognition.
|
|
2
|
+
|
|
3
|
+
Each test drives ``iter_env_var_table_rows`` against markdown text and asserts
|
|
4
|
+
the exact rows it yields. The behavior under test is table context: which
|
|
5
|
+
markdown tables read as environment-variable summaries, and which read as
|
|
6
|
+
source, constant, or component tables that name no environment consumer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from env_var_table_rows import iter_env_var_table_rows
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_environment_variable_header_yields_data_rows() -> None:
|
|
15
|
+
content = (
|
|
16
|
+
"| Environment variable | Consumer |\n| --- | --- |\n| `UPLOAD_TOKEN` | `workflow.py` |\n"
|
|
17
|
+
)
|
|
18
|
+
assert list(iter_env_var_table_rows(content)) == [
|
|
19
|
+
"| Environment variable | Consumer |",
|
|
20
|
+
"| `UPLOAD_TOKEN` | `workflow.py` |",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_constant_table_yields_nothing() -> None:
|
|
25
|
+
content = (
|
|
26
|
+
"| Constant | Value | Source |\n"
|
|
27
|
+
"| --- | --- | --- |\n"
|
|
28
|
+
"| `UPLOAD_DELAY_SECONDS` | 5 | `workflow.py` |\n"
|
|
29
|
+
)
|
|
30
|
+
assert list(iter_env_var_table_rows(content)) == []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_component_table_yields_nothing() -> None:
|
|
34
|
+
content = (
|
|
35
|
+
"| Component | Location | Purpose |\n"
|
|
36
|
+
"| --- | --- | --- |\n"
|
|
37
|
+
"| `ACCOUNT_LINK_PRIMARY` | `workflow.py` | Selector |\n"
|
|
38
|
+
)
|
|
39
|
+
assert list(iter_env_var_table_rows(content)) == []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_generic_name_header_under_environment_heading_yields_rows() -> None:
|
|
43
|
+
content = (
|
|
44
|
+
"## Environment variables\n\n"
|
|
45
|
+
"| Name | Consumer |\n"
|
|
46
|
+
"| --- | --- |\n"
|
|
47
|
+
"| `UPLOAD_TOKEN` | `workflow.py` |\n"
|
|
48
|
+
)
|
|
49
|
+
assert list(iter_env_var_table_rows(content)) == [
|
|
50
|
+
"| Name | Consumer |",
|
|
51
|
+
"| `UPLOAD_TOKEN` | `workflow.py` |",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_generic_name_header_without_environment_heading_yields_nothing() -> None:
|
|
56
|
+
content = (
|
|
57
|
+
"## Selectors\n\n"
|
|
58
|
+
"| Name | Consumer |\n"
|
|
59
|
+
"| --- | --- |\n"
|
|
60
|
+
"| `ACCOUNT_LINK_PRIMARY` | `workflow.py` |\n"
|
|
61
|
+
)
|
|
62
|
+
assert list(iter_env_var_table_rows(content)) == []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_headerless_fragment_naming_a_variable_yields_the_row() -> None:
|
|
66
|
+
content = "| `UPLOAD_TOKEN` | `workflow.py` |\n"
|
|
67
|
+
assert list(iter_env_var_table_rows(content)) == ["| `UPLOAD_TOKEN` | `workflow.py` |"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_rows_inside_a_code_fence_yield_nothing() -> None:
|
|
71
|
+
content = (
|
|
72
|
+
"```markdown\n"
|
|
73
|
+
"| Environment variable | Consumer |\n"
|
|
74
|
+
"| --- | --- |\n"
|
|
75
|
+
"| `UPLOAD_TOKEN` | `workflow.py` |\n"
|
|
76
|
+
"```\n"
|
|
77
|
+
)
|
|
78
|
+
assert list(iter_env_var_table_rows(content)) == []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_constant_table_after_an_environment_table_yields_nothing_extra() -> None:
|
|
82
|
+
content = (
|
|
83
|
+
"| Environment variable | Consumer |\n"
|
|
84
|
+
"| --- | --- |\n"
|
|
85
|
+
"| `UPLOAD_TOKEN` | `workflow.py` |\n\n"
|
|
86
|
+
"| Constant | Source |\n"
|
|
87
|
+
"| --- | --- |\n"
|
|
88
|
+
"| `UPLOAD_DELAY_SECONDS` | `workflow.py` |\n"
|
|
89
|
+
)
|
|
90
|
+
assert list(iter_env_var_table_rows(content)) == [
|
|
91
|
+
"| Environment variable | Consumer |",
|
|
92
|
+
"| `UPLOAD_TOKEN` | `workflow.py` |",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_separator_row_is_never_yielded() -> None:
|
|
97
|
+
content = (
|
|
98
|
+
"| Environment variable | Consumer |\n| :--- | ---: |\n| `UPLOAD_TOKEN` | `workflow.py` |\n"
|
|
99
|
+
)
|
|
100
|
+
assert "| :--- | ---: |" not in list(iter_env_var_table_rows(content))
|