claude-dev-env 7.4.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/bin/install.test.mjs +4 -3
- 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_tracked_secrets.py +13 -0
package/bin/install.test.mjs
CHANGED
|
@@ -3047,9 +3047,10 @@ test('the base install publishes the pstack poteto-mode entry into the managed s
|
|
|
3047
3047
|
|
|
3048
3048
|
assert.equal(outcome.status, 'installed');
|
|
3049
3049
|
assert.equal(outcome.warning, null);
|
|
3050
|
-
const
|
|
3051
|
-
|
|
3052
|
-
assert.equal(
|
|
3050
|
+
const pstackRoot = join(fixture.managedRoot, 'skills', 'pstack');
|
|
3051
|
+
const entryPath = join(pstackRoot, 'poteto-mode');
|
|
3052
|
+
assert.equal(lstatSync(pstackRoot).isSymbolicLink(), true, 'the pstack root is a pointer into the release');
|
|
3053
|
+
assert.equal(existsSync(join(entryPath, 'SKILL.md')), true, 'the nested entry resolves to a skill');
|
|
3053
3054
|
});
|
|
3054
3055
|
|
|
3055
3056
|
test('an unreachable pstack upstream reports a warning and keeps the base install going', t => {
|
package/bin/pstack.mjs
CHANGED
|
@@ -84,6 +84,17 @@ function adaptSkill(text, name, releaseRoot, skillRoot) {
|
|
|
84
84
|
+ fork + '\n' + match[2].trimStart();
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function writePstackPluginManifest(stage, skills) {
|
|
88
|
+
const manifest = {
|
|
89
|
+
$schema: 'https://anthropic.com/claude-code/plugin.schema.json',
|
|
90
|
+
name: 'pstack',
|
|
91
|
+
skills: skills.filter(skill => skill.component === 'pstack').map(skill => `./${skill.slug}`),
|
|
92
|
+
};
|
|
93
|
+
const pluginRoot = join(stage, 'runtime', 'pstack', 'skills');
|
|
94
|
+
mkdirSync(join(pluginRoot, '.claude-plugin'), { recursive: true });
|
|
95
|
+
writeFileSync(join(pluginRoot, '.claude-plugin', 'plugin.json'), json(manifest));
|
|
96
|
+
}
|
|
97
|
+
|
|
87
98
|
export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
|
|
88
99
|
const skills = [];
|
|
89
100
|
const agents = [];
|
|
@@ -102,10 +113,11 @@ export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
|
|
|
102
113
|
}
|
|
103
114
|
for (const slug of names) {
|
|
104
115
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw new Error(`Invalid skill directory: ${slug}`);
|
|
105
|
-
const name = `${component}-${slug}`;
|
|
116
|
+
const name = component === 'pstack' ? `pstack:${slug}` : `${component}-${slug}`;
|
|
117
|
+
const frontmatterName = component === 'pstack' ? slug : name;
|
|
106
118
|
const path = join('runtime', component, 'skills', slug);
|
|
107
119
|
const text = readFileSync(join(source, 'skills', slug, 'SKILL.md'), 'utf8');
|
|
108
|
-
writeFileSync(join(stage, path, 'SKILL.md'), adaptSkill(text,
|
|
120
|
+
writeFileSync(join(stage, path, 'SKILL.md'), adaptSkill(text, frontmatterName, finalRoot, join(finalRoot, path)));
|
|
109
121
|
skills.push({ name, component, slug, path });
|
|
110
122
|
}
|
|
111
123
|
const agentHome = join(source, 'agents');
|
|
@@ -118,6 +130,7 @@ export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
|
|
|
118
130
|
mkdirSync(join(stage, creatorPath), { recursive: true });
|
|
119
131
|
writeFileSync(join(stage, creatorPath, 'SKILL.md'), adapters['create-skill.md']);
|
|
120
132
|
skills.push({ name: 'cde-create-skill', component: 'cde', slug: 'create-skill', path: creatorPath });
|
|
133
|
+
writePstackPluginManifest(stage, skills);
|
|
121
134
|
const knownNames = new Set(skills.map(skill => `${skill.component}:${skill.slug}`));
|
|
122
135
|
for (const path of filesUnder(join(stage, 'upstream')).filter(path => path.endsWith('.md') && (path.includes(sep + 'skills' + sep) || path.includes(sep + 'agents' + sep)))) {
|
|
123
136
|
const references = readFileSync(path, 'utf8').matchAll(/\b(pstack|cursor-team-kit):([a-z][a-z0-9-]*)/g);
|
|
@@ -146,7 +159,9 @@ export function verifyRelease(root) {
|
|
|
146
159
|
if (digest(readFileSync(assertContained(root, path))) !== hash) throw new Error(`Changed installed file: ${path}`);
|
|
147
160
|
}
|
|
148
161
|
for (const skill of release.skills) {
|
|
149
|
-
|
|
162
|
+
const validPart = value => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
163
|
+
const expectedName = skill.component === 'pstack' ? `pstack:${skill.slug}` : `${skill.component}-${skill.slug}`;
|
|
164
|
+
if (!validPart(skill.component) || !validPart(skill.slug) || skill.name !== expectedName) throw new Error('Invalid installed skill name');
|
|
150
165
|
const path = join(assertContained(root, skill.path), 'SKILL.md');
|
|
151
166
|
if (!Object.hasOwn(release.fileDigests, relative(root, path)) || !existsSync(path)) throw new Error(`Missing entry: ${skill.name}`);
|
|
152
167
|
}
|
|
@@ -177,7 +192,12 @@ export function verifyInstallation(options = {}) {
|
|
|
177
192
|
|
|
178
193
|
function publish(releaseRoot, release, homes, prior) {
|
|
179
194
|
const uniqueHomes = [...new Set(homes.map(home => { mkdirSync(home, { recursive: true }); return realpathSync(home); }))];
|
|
180
|
-
const
|
|
195
|
+
const pstackSkill = release.skills.find(skill => skill.component === 'pstack');
|
|
196
|
+
const directSkills = release.skills.filter(skill => skill.component !== 'pstack');
|
|
197
|
+
const links = Object.fromEntries(uniqueHomes.flatMap(home => [
|
|
198
|
+
...(pstackSkill ? [[join(home, 'pstack'), join(releaseRoot, dirname(pstackSkill.path))]] : []),
|
|
199
|
+
...directSkills.map(skill => [join(home, skill.name), join(releaseRoot, skill.path)]),
|
|
200
|
+
]));
|
|
181
201
|
const paths = new Set([...Object.keys(prior?.links ?? {}), ...Object.keys(links)]);
|
|
182
202
|
const before = new Map();
|
|
183
203
|
for (const path of paths) {
|
package/bin/pstack.test.mjs
CHANGED
|
@@ -44,15 +44,23 @@ for (const host of ['claude', 'codex', 'cursor']) {
|
|
|
44
44
|
assert.equal(result.commit, '27e2a62ff94f9af4b5e68435e41cdceacadb840c');
|
|
45
45
|
assert.equal(result.verification, 'filesystem-only');
|
|
46
46
|
const release = join(f.store, 'releases', result.release);
|
|
47
|
-
const
|
|
47
|
+
const releaseManifest = JSON.parse(readFileSync(join(release, 'release.json'), 'utf8'));
|
|
48
|
+
assert.equal(releaseManifest.skills.find(skill => skill.component === 'pstack').name, 'pstack:poteto-mode');
|
|
49
|
+
const expected = ['cde-create-skill', 'cursor-team-kit-control-cli', 'cursor-team-kit-control-ui', 'cursor-team-kit-deslop', 'pstack'];
|
|
48
50
|
for (const home of ['.claude', '.agents']) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
const skillsHome = join(f.options.project, home, 'skills');
|
|
52
|
+
assert.deepEqual(readdirSync(skillsHome).sort(), expected);
|
|
53
|
+
const pstackRoot = join(skillsHome, 'pstack');
|
|
54
|
+
assert.equal(lstatSync(pstackRoot).isSymbolicLink(), true);
|
|
55
|
+
const entry = join(pstackRoot, 'poteto-mode');
|
|
51
56
|
assert.equal(realpathSync(entry), join(release, 'runtime', 'pstack', 'skills', 'poteto-mode'));
|
|
52
57
|
const text = readFileSync(join(entry, 'SKILL.md'), 'utf8');
|
|
53
|
-
assert.match(text, /name:
|
|
58
|
+
assert.match(text, /name: poteto-mode/);
|
|
54
59
|
assert.ok(text.includes(JSON.stringify(join(release, 'compat', 'common.md'))));
|
|
55
60
|
assert.equal(readFileSync(join(entry, 'playbooks', 'feature.md'), 'utf8'), 'Build a small task, delegate, and verify it.\n');
|
|
61
|
+
const manifest = JSON.parse(readFileSync(join(pstackRoot, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
62
|
+
assert.equal(manifest.name, 'pstack');
|
|
63
|
+
assert.deepEqual(manifest.skills, ['./poteto-mode']);
|
|
56
64
|
}
|
|
57
65
|
assert.equal(existsSync(join(f.options.project, '.cursor')), false);
|
|
58
66
|
for (const component of baseLock.components) {
|
|
@@ -89,10 +97,12 @@ test('changed, added, and removed skills converge while old releases remain read
|
|
|
89
97
|
const after = installPstack({ ...f.options, lock: { ...baseLock, commit: 'b'.repeat(40) } }, f.dependencies);
|
|
90
98
|
assert.equal(after.skillCount, 6);
|
|
91
99
|
for (const home of ['.claude', '.agents']) {
|
|
92
|
-
const
|
|
93
|
-
assert.equal(existsSync(join(
|
|
94
|
-
assert.equal(readFileSync(join(
|
|
95
|
-
assert.equal(readFileSync(join(
|
|
100
|
+
const pstackRoot = join(f.options.project, home, 'skills', 'pstack');
|
|
101
|
+
assert.equal(existsSync(join(pstackRoot, 'old')), false);
|
|
102
|
+
assert.equal(readFileSync(join(pstackRoot, 'new', 'SKILL.md'), 'utf8').endsWith('New body.\n'), true);
|
|
103
|
+
assert.equal(readFileSync(join(pstackRoot, 'poteto-mode', 'playbooks', 'feature.md'), 'utf8'), 'Changed playbook.\n');
|
|
104
|
+
const manifest = JSON.parse(readFileSync(join(pstackRoot, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
105
|
+
assert.deepEqual(manifest.skills, ['./new', './poteto-mode']);
|
|
96
106
|
}
|
|
97
107
|
assert.equal(readFileSync(join(oldRelease, 'runtime', 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'utf8'), firstBody);
|
|
98
108
|
});
|
|
@@ -117,9 +127,9 @@ test('a failed first install returns failure and publishes no skills', t => {
|
|
|
117
127
|
assert.equal(existsSync(join(f.store, '.install-lock')), false);
|
|
118
128
|
});
|
|
119
129
|
|
|
120
|
-
test('unmanaged
|
|
130
|
+
test('unmanaged pstack root collision preserves user files', t => {
|
|
121
131
|
const f = fixture(t);
|
|
122
|
-
const userFile = join(f.options.project, '.claude', 'skills', 'pstack
|
|
132
|
+
const userFile = join(f.options.project, '.claude', 'skills', 'pstack', 'poteto-mode', 'SKILL.md');
|
|
123
133
|
put(userFile, 'Personal workflow\n');
|
|
124
134
|
assert.throws(() => installPstack(f.options, f.dependencies), /Keep existing unmanaged path/);
|
|
125
135
|
assert.equal(readFileSync(userFile, 'utf8'), 'Personal workflow\n');
|
|
@@ -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"]
|