claude-dev-env 2.18.0 → 2.19.0
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/AGENTS.md +4 -4
- package/.agents/skills/_shared/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +89 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
- package/.agents/skills/pr-cleanup/SKILL.md +17 -85
- package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
- package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
- package/.agents/skills/pr-refinement/SKILL.md +31 -0
- package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
- package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
- package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
- package/.agents/skills/skill-builder/SKILL.md +1 -0
- package/.agents/skills-archived/bugteam/SKILL.md +1 -1
- package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
- package/.agents/skills-archived/comments/SKILL.md +1 -1
- package/.agents/skills-archived/descriptions/SKILL.md +1 -1
- package/.agents/skills-archived/reviews/SKILL.md +1 -1
- package/AGENTS.md +1 -1
- package/_shared/pr-loop/AGENTS.md +2 -1
- package/_shared/pr-loop/preflight-proposal.md +56 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
- package/bin/ever-shipped-skills.mjs +14 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.agents-home.test.mjs +23 -0
- package/bin/install.codex-rules.test.mjs +61 -6
- package/bin/install.mjs +57 -9
- package/bin/install.prune.test.mjs +65 -3
- package/bin/install.test.mjs +202 -2
- package/docs/CODE_RULES.md +4 -0
- package/hooks/advisory/migration_safety_advisor.py +14 -14
- package/hooks/advisory/test_migration_safety_advisor.py +120 -0
- package/hooks/atomic_file_writer.py +81 -0
- package/hooks/blocking/AGENTS.md +2 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
- package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
- package/hooks/blocking/destructive_command_blocker.py +8 -7
- package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
- package/hooks/blocking/precommit_code_rules_gate.py +14 -139
- package/hooks/blocking/sensitive_file_protector.py +8 -6
- package/hooks/blocking/session_edit_stage_gate.py +31 -1
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +308 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
- package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +42 -25
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
- package/hooks/blocking/test_hook_subprocess_support.py +296 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
- package/hooks/blocking/test_pii_scanner.py +81 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
- package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +168 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
- package/hooks/blocking/test_sensitive_file_protector.py +145 -5
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
- package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
- package/hooks/blocking/test_test_preflight_check.py +80 -0
- package/hooks/git-hooks/pre_commit.py +6 -2
- package/hooks/git-hooks/test_pre_push.py +57 -0
- package/hooks/hooks_constants/AGENTS.md +3 -1
- package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
- package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
- package/hooks/json_file_reader.py +22 -0
- package/hooks/pending_sidecars.py +33 -0
- package/hooks/test_atomic_file_writer.py +73 -0
- package/hooks/test_json_file_reader.py +24 -0
- package/hooks/test_pending_sidecars.py +25 -0
- package/package.json +1 -1
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
package/bin/install.test.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import { strict as assert } from 'node:assert';
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
4
4
|
import {
|
|
5
5
|
mkdtempSync,
|
|
6
6
|
rmSync,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
pythonCandidatesForPlatform,
|
|
28
28
|
isWindowsStorePythonStub,
|
|
29
29
|
interpreterCommandFromPath,
|
|
30
|
+
commandArgumentFromPath,
|
|
30
31
|
invokedAsEntryPoint,
|
|
31
32
|
managedHookScriptRelativePaths,
|
|
32
33
|
managedHookScriptRelativePathsFromSourceRoots,
|
|
@@ -504,7 +505,9 @@ test('mergeHooksIntoSettings inserts dollar characters in plugin root and interp
|
|
|
504
505
|
const rewrittenCommand = settings.hooks.SessionStart[0].hooks[0].command;
|
|
505
506
|
assert.equal(
|
|
506
507
|
rewrittenCommand,
|
|
507
|
-
|
|
508
|
+
process.platform === 'win32'
|
|
509
|
+
? 'C:/Users/$&evil$1/Python/python.exe "C:/Users/$&evil$1/.claude/hooks/session/session_env_cleanup.py"'
|
|
510
|
+
: "C:/Users/$&evil$1/Python/python.exe 'C:/Users/$&evil$1/.claude/hooks/session/session_env_cleanup.py'",
|
|
508
511
|
);
|
|
509
512
|
assert.equal(rewrittenCommand.includes('${CLAUDE_PLUGIN_ROOT}'), false);
|
|
510
513
|
});
|
|
@@ -530,6 +533,191 @@ test('mergeHooksIntoSettings substitutes a quoted absolute interpreter path for
|
|
|
530
533
|
});
|
|
531
534
|
|
|
532
535
|
|
|
536
|
+
test('mergeHooksIntoSettings quotes standalone plugin-root hook paths and preserves existing quotes', () => {
|
|
537
|
+
const hooksConfig = {
|
|
538
|
+
hooks: {
|
|
539
|
+
PreToolUse: [{
|
|
540
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
541
|
+
hooks: [
|
|
542
|
+
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py' },
|
|
543
|
+
{ type: 'command', command: 'python3 "${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py"' },
|
|
544
|
+
{ type: 'command', command: 'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\')"' },
|
|
545
|
+
{ type: 'command', command: 'python3 -c "import sys; sys.path.insert(0, r\"${CLAUDE_PLUGIN_ROOT}/hooks\")"' },
|
|
546
|
+
{ type: 'command', command: "python3 -c \"print('${CLAUDE_PLUGIN_ROOT}/hooks/foo.py')\"" },
|
|
547
|
+
],
|
|
548
|
+
}],
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
const settings = {};
|
|
552
|
+
|
|
553
|
+
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Program Files/Claude/.claude', 'python3');
|
|
554
|
+
|
|
555
|
+
assert.deepEqual(
|
|
556
|
+
settings.hooks.PreToolUse[0].hooks.map(eachHook => eachHook.command),
|
|
557
|
+
[
|
|
558
|
+
process.platform === 'win32'
|
|
559
|
+
? 'python3 "C:/Program Files/Claude/.claude/hooks/blocking/pre_tool_use_dispatcher.py"'
|
|
560
|
+
: "python3 'C:/Program Files/Claude/.claude/hooks/blocking/pre_tool_use_dispatcher.py'",
|
|
561
|
+
process.platform === 'win32'
|
|
562
|
+
? 'python3 "C:/Program Files/Claude/.claude/hooks/blocking/pre_tool_use_dispatcher.py"'
|
|
563
|
+
: "python3 'C:/Program Files/Claude/.claude/hooks/blocking/pre_tool_use_dispatcher.py'",
|
|
564
|
+
"python3 -c \"import sys; sys.path.insert(0, (__import__('base64').b64decode('QzovUHJvZ3JhbSBGaWxlcy9DbGF1ZGUvLmNsYXVkZS9ob29rcw==').decode(), '__claude_dev_env_managed_hooks__')[0])\"",
|
|
565
|
+
"python3 -c \"import sys; sys.path.insert(0, (__import__('base64').b64decode('QzovUHJvZ3JhbSBGaWxlcy9DbGF1ZGUvLmNsYXVkZS9ob29rcw==').decode(), '__claude_dev_env_managed_hooks__')[0])\"",
|
|
566
|
+
"python3 -c \"print('C:/Program Files/Claude/.claude/hooks/foo.py')\"",
|
|
567
|
+
],
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
test('mergeHooksIntoSettings embeds a decodable inline plugin-root path', () => {
|
|
573
|
+
const hooksConfig = {
|
|
574
|
+
hooks: {
|
|
575
|
+
SessionStart: [{
|
|
576
|
+
matcher: '',
|
|
577
|
+
hooks: [{
|
|
578
|
+
type: 'command',
|
|
579
|
+
command: 'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\'); print(sys.path[0])"',
|
|
580
|
+
}],
|
|
581
|
+
}],
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
const settings = {};
|
|
585
|
+
const pluginRootDirectory = 'C:/Program Files/Claude/.claude';
|
|
586
|
+
|
|
587
|
+
mergeHooksIntoSettings(settings, hooksConfig, pluginRootDirectory, 'python3');
|
|
588
|
+
|
|
589
|
+
const rewrittenCommand = settings.hooks.SessionStart[0].hooks[0].command;
|
|
590
|
+
const pythonCommand = process.platform === 'win32' ? 'python' : 'python3';
|
|
591
|
+
const inlineRun = spawnSync(rewrittenCommand.replace(/^python3\b/, pythonCommand), {
|
|
592
|
+
encoding: 'utf8',
|
|
593
|
+
shell: process.platform === 'win32' ? true : '/bin/sh',
|
|
594
|
+
timeout: 30000,
|
|
595
|
+
});
|
|
596
|
+
assert.equal(inlineRun.status, 0, inlineRun.stderr);
|
|
597
|
+
assert.equal(inlineRun.stdout.trim(), `${pluginRootDirectory}/hooks`);
|
|
598
|
+
assert.equal(rewrittenCommand.includes("r'${CLAUDE_PLUGIN_ROOT}/hooks'"), false);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
test('commandArgumentFromPath returns raw shell-safe paths', () => {
|
|
603
|
+
assert.equal(
|
|
604
|
+
commandArgumentFromPath('/opt/claude/hooks/pre_tool_use_dispatcher.py', 'linux'),
|
|
605
|
+
'/opt/claude/hooks/pre_tool_use_dispatcher.py',
|
|
606
|
+
);
|
|
607
|
+
assert.equal(
|
|
608
|
+
commandArgumentFromPath('C:\\Python313\\python.exe', 'win32'),
|
|
609
|
+
'C:/Python313/python.exe',
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
test('commandArgumentFromPath quotes POSIX shell metacharacters', () => {
|
|
615
|
+
assert.equal(
|
|
616
|
+
commandArgumentFromPath("/opt/Claude's $hooks `pre_tool_use_dispatcher`.py", 'linux'),
|
|
617
|
+
"'/opt/Claude'\\''s $hooks `pre_tool_use_dispatcher`.py'",
|
|
618
|
+
);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
test('commandArgumentFromPath quotes Windows percent and shell metacharacters', () => {
|
|
623
|
+
assert.equal(
|
|
624
|
+
commandArgumentFromPath('C:\\Program Files\\%PATH% & (x) ! ^.py', 'win32'),
|
|
625
|
+
'"C:/Program Files/"^%"PATH"^%" & (x) ! ^.py"',
|
|
626
|
+
);
|
|
627
|
+
assert.equal(
|
|
628
|
+
commandArgumentFromPath('C:\\safe\\%PATH%.py', 'win32'),
|
|
629
|
+
'"C:/safe/"^%"PATH"^%".py"',
|
|
630
|
+
);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
test('commandArgumentFromPath launches a script from a metacharacter path', () => {
|
|
635
|
+
const temporaryDirectory = mkdtempSync(join(tmpdir(), 'cdev command & (path)!-'));
|
|
636
|
+
const isWindows = process.platform === 'win32';
|
|
637
|
+
const scriptPath = join(
|
|
638
|
+
temporaryDirectory,
|
|
639
|
+
isWindows ? 'launcher script %PATH% & (x) !.py' : "launcher script & (x) '$'.py",
|
|
640
|
+
);
|
|
641
|
+
try {
|
|
642
|
+
writeFileSync(scriptPath, 'print("launched")\n');
|
|
643
|
+
const pythonCommand = isWindows ? 'python' : 'python3';
|
|
644
|
+
const shellCommand = `${pythonCommand} ${commandArgumentFromPath(scriptPath, process.platform)}`;
|
|
645
|
+
const launcherRun = spawnSync(shellCommand, {
|
|
646
|
+
encoding: 'utf8',
|
|
647
|
+
shell: isWindows ? true : '/bin/sh',
|
|
648
|
+
timeout: 30000,
|
|
649
|
+
});
|
|
650
|
+
assert.equal(launcherRun.error, undefined);
|
|
651
|
+
assert.equal(launcherRun.signal, null);
|
|
652
|
+
assert.equal(launcherRun.status, 0, launcherRun.stderr);
|
|
653
|
+
assert.equal(launcherRun.stdout.trim(), 'launched');
|
|
654
|
+
} finally {
|
|
655
|
+
rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
test('commandArgumentFromPath launches a no-space percent path', () => {
|
|
661
|
+
const temporaryDirectory = mkdtempSync(join(tmpdir(), 'cdev-command-launch-'));
|
|
662
|
+
const isWindows = process.platform === 'win32';
|
|
663
|
+
const scriptName = isWindows ? 'launcher%PATH%.py' : 'launcher.py';
|
|
664
|
+
const scriptPath = join(temporaryDirectory, scriptName);
|
|
665
|
+
try {
|
|
666
|
+
writeFileSync(scriptPath, 'print("launched")\n');
|
|
667
|
+
const pythonCommand = isWindows ? 'python' : 'python3';
|
|
668
|
+
const shellCommand = `${pythonCommand} ${commandArgumentFromPath(scriptPath, process.platform)}`;
|
|
669
|
+
const launcherRun = spawnSync(shellCommand, {
|
|
670
|
+
encoding: 'utf8',
|
|
671
|
+
shell: isWindows ? true : '/bin/sh',
|
|
672
|
+
timeout: 30000,
|
|
673
|
+
});
|
|
674
|
+
assert.equal(launcherRun.error, undefined);
|
|
675
|
+
assert.equal(launcherRun.signal, null);
|
|
676
|
+
assert.equal(launcherRun.status, 0, launcherRun.stderr);
|
|
677
|
+
assert.equal(launcherRun.stdout.trim(), 'launched');
|
|
678
|
+
} finally {
|
|
679
|
+
rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
test('mergeHooksIntoSettings matches full standalone arguments at boundaries', () => {
|
|
685
|
+
const hooksConfig = {
|
|
686
|
+
hooks: {
|
|
687
|
+
PreToolUse: [{
|
|
688
|
+
matcher: 'Write',
|
|
689
|
+
hooks: [
|
|
690
|
+
{ type: 'command', command: 'python3 prefix${CLAUDE_PLUGIN_ROOT}/hooks/a.py' },
|
|
691
|
+
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/a.py.bak' },
|
|
692
|
+
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/a.py' },
|
|
693
|
+
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/a.py; next' },
|
|
694
|
+
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/a.py& next' },
|
|
695
|
+
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/a.py| next' },
|
|
696
|
+
],
|
|
697
|
+
}],
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
const settings = {};
|
|
701
|
+
|
|
702
|
+
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Program Files/Claude/.claude', 'python3');
|
|
703
|
+
|
|
704
|
+
const expectedQuotedHookPath = process.platform === 'win32'
|
|
705
|
+
? '"C:/Program Files/Claude/.claude/hooks/a.py"'
|
|
706
|
+
: "'C:/Program Files/Claude/.claude/hooks/a.py'";
|
|
707
|
+
assert.deepEqual(
|
|
708
|
+
settings.hooks.PreToolUse[0].hooks.map(eachHook => eachHook.command),
|
|
709
|
+
[
|
|
710
|
+
'python3 prefixC:/Program Files/Claude/.claude/hooks/a.py',
|
|
711
|
+
'python3 C:/Program Files/Claude/.claude/hooks/a.py.bak',
|
|
712
|
+
`python3 ${expectedQuotedHookPath}`,
|
|
713
|
+
`python3 ${expectedQuotedHookPath}; next`,
|
|
714
|
+
`python3 ${expectedQuotedHookPath}& next`,
|
|
715
|
+
`python3 ${expectedQuotedHookPath}| next`,
|
|
716
|
+
],
|
|
717
|
+
);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
|
|
533
721
|
test('mergeHooksIntoSettings prunes a prior py -3 managed hook when reinstalling with an absolute interpreter path', () => {
|
|
534
722
|
const hooksConfig = {
|
|
535
723
|
hooks: {
|
|
@@ -687,6 +875,12 @@ test('commandReferencesManagedHook matches the rewritten inline validators-runne
|
|
|
687
875
|
const rewrittenInlineCommand =
|
|
688
876
|
"py -3 -c \"import sys; sys.path.insert(0, r'C:/Users/example/.claude/hooks'); from validators.run_all_validators import main; sys.exit(main())\"";
|
|
689
877
|
assert.ok(commandReferencesManagedHook(rewrittenInlineCommand, managedPaths));
|
|
878
|
+
const base64InlineCommand =
|
|
879
|
+
"py -3 -c \"import sys; sys.path.insert(0, (__import__('base64').b64decode('QzovVXNlcnMvZXhhbXBsZS8uY2xhdWRlL2hvb2tz').decode(), '__claude_dev_env_managed_hooks__')[0]); from validators.run_all_validators import main; sys.exit(main())\"";
|
|
880
|
+
assert.ok(commandReferencesManagedHook(base64InlineCommand, managedPaths));
|
|
881
|
+
const unmarkedBase64InlineCommand =
|
|
882
|
+
"py -3 -c \"import sys; sys.path.insert(0, __import__('base64').b64decode('QzovVXNlcnMvZXhhbXBsZS8uY2xhdWRlL2hvb2tz').decode()); from validators.run_all_validators import main; sys.exit(main())\"";
|
|
883
|
+
assert.equal(commandReferencesManagedHook(unmarkedBase64InlineCommand, managedPaths), false);
|
|
690
884
|
});
|
|
691
885
|
|
|
692
886
|
|
|
@@ -695,6 +889,12 @@ test('commandReferencesManagedHook leaves an unmanaged inline -c command that im
|
|
|
695
889
|
const userInlineCommand =
|
|
696
890
|
"python -c \"import sys; sys.path.insert(0, r'/home/me/tools'); from my_tools.runner import main; sys.exit(main())\"";
|
|
697
891
|
assert.equal(commandReferencesManagedHook(userInlineCommand, managedPaths), false);
|
|
892
|
+
const similarlyNamedUserInlineCommand =
|
|
893
|
+
"python -c \"import sys; sys.path.insert(0, r'/home/me/tools'); from mypkg.run_all_validators import main; sys.exit(main())\"";
|
|
894
|
+
assert.equal(commandReferencesManagedHook(similarlyNamedUserInlineCommand, managedPaths), false);
|
|
895
|
+
const userPathWithManagedImport =
|
|
896
|
+
"python -c \"import sys; sys.path.insert(0, r'/home/me/tools'); from validators.run_all_validators import main; sys.exit(main())\"";
|
|
897
|
+
assert.equal(commandReferencesManagedHook(userPathWithManagedImport, managedPaths), false);
|
|
698
898
|
});
|
|
699
899
|
|
|
700
900
|
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -44,6 +44,10 @@ Before writing ANY constant: search `config/` for the exact value → semantic m
|
|
|
44
44
|
|
|
45
45
|
Full words only (`context`, not `ctx`). Exceptions: `i`/`j`/`k` in loops, `e` for exception. Naming patterns: loop vars `each_*`; booleans `is_/has_/should_/can_/was_/did_`; collections `all_*`; maps `X_by_Y`; preposition params (`from_path=`, `to=`, `into=`). Banned names: `result`, `data`, `output`, `response`, `value`, `item`, `temp`. Banned prefixes: `handle`, `process`, `manage`, `do`. Name a component for what it IS — `Overlay`, `Validator`, `InvoicePreview`.
|
|
46
46
|
|
|
47
|
+
### Public compatibility definitions
|
|
48
|
+
|
|
49
|
+
A public function may use the exact inline marker `# pragma: no-banned-noun` on its `def` line. The banned-noun check permits the function name and parameters on that definition. Body local bindings, other definitions, and similar names remain checked.
|
|
50
|
+
|
|
47
51
|
---
|
|
48
52
|
|
|
49
53
|
## 6. COMPLETE TYPE HINTS
|
|
@@ -26,21 +26,21 @@ def main() -> None:
|
|
|
26
26
|
|
|
27
27
|
if found_unsafe:
|
|
28
28
|
operations = ", ".join(found_unsafe)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"hookEventName": "PreToolUse",
|
|
34
|
-
"permissionDecision": "ask",
|
|
35
|
-
"permissionDecisionReason": (
|
|
36
|
-
f"MIGRATION SAFETY: Contains {operations}. "
|
|
37
|
-
"Post-launch, model changes MUST be backwards-compatible. "
|
|
38
|
-
"Verify this won't break running instances during deployment."
|
|
39
|
-
),
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
)
|
|
29
|
+
advisory_message = (
|
|
30
|
+
f"MIGRATION SAFETY: Contains {operations}. "
|
|
31
|
+
"Post-launch, model changes MUST be backwards-compatible. "
|
|
32
|
+
"Verify this won't break running instances during deployment."
|
|
43
33
|
)
|
|
34
|
+
advisory_payload = {
|
|
35
|
+
"systemMessage": advisory_message,
|
|
36
|
+
"hookSpecificOutput": {
|
|
37
|
+
"hookEventName": "PreToolUse",
|
|
38
|
+
"permissionDecision": "allow",
|
|
39
|
+
"permissionDecisionReason": advisory_message,
|
|
40
|
+
"additionalContext": advisory_message,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
print(json.dumps(advisory_payload))
|
|
44
44
|
|
|
45
45
|
sys.exit(0)
|
|
46
46
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Behavior tests for migration_safety_advisor through its production entry paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_ADVISORY_DIRECTORY = Path(__file__).resolve().parent
|
|
14
|
+
_HOOKS_DIRECTORY = _ADVISORY_DIRECTORY.parent
|
|
15
|
+
_ADVISOR_SCRIPT = _ADVISORY_DIRECTORY / "migration_safety_advisor.py"
|
|
16
|
+
_DISPATCHER_SCRIPT = _HOOKS_DIRECTORY / "blocking" / "pre_tool_use_dispatcher.py"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _run_hook(script_path: Path, payload: dict[str, object]) -> dict[str, object] | None:
|
|
20
|
+
completed_process = subprocess.run(
|
|
21
|
+
[sys.executable, str(script_path)],
|
|
22
|
+
check=False,
|
|
23
|
+
input=json.dumps(payload),
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
)
|
|
28
|
+
assert completed_process.returncode == 0
|
|
29
|
+
if not completed_process.stdout.strip():
|
|
30
|
+
return None
|
|
31
|
+
parsed_payload = json.loads(completed_process.stdout)
|
|
32
|
+
assert isinstance(parsed_payload, dict)
|
|
33
|
+
return parsed_payload
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_edit_payload(file_path: Path, new_string: str) -> dict[str, object]:
|
|
37
|
+
return {
|
|
38
|
+
"tool_name": "Edit",
|
|
39
|
+
"tool_input": {
|
|
40
|
+
"file_path": str(file_path),
|
|
41
|
+
"old_string": "operations = []",
|
|
42
|
+
"new_string": new_string,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_control_payload(tool_name: str, file_path: Path, content: str) -> dict[str, object]:
|
|
48
|
+
if tool_name == "Write":
|
|
49
|
+
tool_input: dict[str, object] = {
|
|
50
|
+
"file_path": str(file_path),
|
|
51
|
+
"content": content,
|
|
52
|
+
}
|
|
53
|
+
else:
|
|
54
|
+
tool_input = {
|
|
55
|
+
"file_path": str(file_path),
|
|
56
|
+
"edits": [{"old_string": "operations = []", "new_string": content}],
|
|
57
|
+
}
|
|
58
|
+
return {"tool_name": tool_name, "tool_input": tool_input}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _prepare_fresh_test_pair(migration_path: Path) -> None:
|
|
62
|
+
migration_path.parent.mkdir(parents=True)
|
|
63
|
+
migration_path.write_text("operations = []\n", encoding="utf-8")
|
|
64
|
+
test_path = migration_path.with_name(f"test_{migration_path.stem}.py")
|
|
65
|
+
test_path.write_text("def test_migration_contract():\n assert True\n", encoding="utf-8")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _permission_fields(parsed_payload: dict[str, object]) -> dict[str, object]:
|
|
69
|
+
hook_specific = parsed_payload.get("hookSpecificOutput", {})
|
|
70
|
+
assert isinstance(hook_specific, dict)
|
|
71
|
+
return hook_specific
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _text_field(field_by_key: dict[str, object], field_name: str) -> str:
|
|
75
|
+
field_value = field_by_key.get(field_name)
|
|
76
|
+
assert isinstance(field_value, str)
|
|
77
|
+
return field_value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_standalone_advisor_warns_for_unsafe_migration_edit(tmp_path: Path) -> None:
|
|
81
|
+
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
82
|
+
payload = _build_edit_payload(migration_path, 'operations = [migrations.RemoveField("name")]')
|
|
83
|
+
|
|
84
|
+
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
85
|
+
assert advisory_payload is not None
|
|
86
|
+
permission_fields = _permission_fields(advisory_payload)
|
|
87
|
+
|
|
88
|
+
assert permission_fields["permissionDecision"] == "allow"
|
|
89
|
+
assert "RemoveField" in _text_field(permission_fields, "additionalContext")
|
|
90
|
+
assert "MIGRATION SAFETY" in _text_field(advisory_payload, "systemMessage")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_dispatcher_surfaces_migration_warning_for_edit(tmp_path: Path) -> None:
|
|
94
|
+
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
95
|
+
_prepare_fresh_test_pair(migration_path)
|
|
96
|
+
payload = _build_edit_payload(migration_path, 'operations = [migrations.RemoveField("name")]')
|
|
97
|
+
|
|
98
|
+
dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
|
|
99
|
+
assert dispatched_payload is not None
|
|
100
|
+
permission_fields = _permission_fields(dispatched_payload)
|
|
101
|
+
|
|
102
|
+
assert permission_fields["permissionDecision"] == "allow"
|
|
103
|
+
assert "RemoveField" in _text_field(permission_fields, "additionalContext")
|
|
104
|
+
assert "MIGRATION SAFETY" in _text_field(dispatched_payload, "systemMessage")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize("tool_name", ("Write", "MultiEdit"))
|
|
108
|
+
def test_dispatcher_keeps_migration_advisor_edit_only(tmp_path: Path, tool_name: str) -> None:
|
|
109
|
+
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
110
|
+
_prepare_fresh_test_pair(migration_path)
|
|
111
|
+
payload = _build_control_payload(
|
|
112
|
+
tool_name,
|
|
113
|
+
migration_path,
|
|
114
|
+
'operations = [migrations.RemoveField("name")]',
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
|
|
118
|
+
serialized_payload = json.dumps(dispatched_payload or {})
|
|
119
|
+
|
|
120
|
+
assert "MIGRATION SAFETY" not in serialized_payload
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Atomic text replacement for hook-state files."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from hooks_constants.atomic_file_writer_constants import TEXT_WRITE_MODE
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _remove_orphaned_temporary_files(
|
|
11
|
+
parent_directory: Path, temporary_prefix: str, temporary_suffix: str
|
|
12
|
+
) -> None:
|
|
13
|
+
for each_temporary_path in parent_directory.glob(f"{temporary_prefix}*{temporary_suffix}"):
|
|
14
|
+
try:
|
|
15
|
+
each_temporary_path.unlink(missing_ok=True)
|
|
16
|
+
except OSError:
|
|
17
|
+
continue
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write_temporary_text(
|
|
21
|
+
parent_directory: Path,
|
|
22
|
+
serialized_text: str,
|
|
23
|
+
encoding: str,
|
|
24
|
+
temporary_prefix: str,
|
|
25
|
+
temporary_suffix: str,
|
|
26
|
+
) -> Path:
|
|
27
|
+
temporary_path: Path | None = None
|
|
28
|
+
try:
|
|
29
|
+
with tempfile.NamedTemporaryFile(
|
|
30
|
+
mode=TEXT_WRITE_MODE,
|
|
31
|
+
encoding=encoding,
|
|
32
|
+
dir=parent_directory,
|
|
33
|
+
prefix=temporary_prefix,
|
|
34
|
+
suffix=temporary_suffix,
|
|
35
|
+
delete=False,
|
|
36
|
+
) as writable_handle:
|
|
37
|
+
temporary_path = Path(writable_handle.name)
|
|
38
|
+
writable_handle.write(serialized_text)
|
|
39
|
+
return temporary_path
|
|
40
|
+
except (OSError, UnicodeError):
|
|
41
|
+
if temporary_path is not None:
|
|
42
|
+
temporary_path.unlink(missing_ok=True)
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_text_atomically(
|
|
47
|
+
target_path: Path,
|
|
48
|
+
serialized_text: str,
|
|
49
|
+
encoding: str,
|
|
50
|
+
temporary_prefix: str,
|
|
51
|
+
temporary_suffix: str,
|
|
52
|
+
should_reap_orphans: bool,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Replace one text file through a unique sibling temporary file.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
target_path: The destination file path.
|
|
58
|
+
serialized_text: The complete replacement text.
|
|
59
|
+
encoding: The text encoding.
|
|
60
|
+
temporary_prefix: Prefix for sibling temporary files.
|
|
61
|
+
temporary_suffix: Suffix for sibling temporary files.
|
|
62
|
+
should_reap_orphans: Whether to remove same-pattern temporary files first.
|
|
63
|
+
"""
|
|
64
|
+
parent_directory = target_path.parent
|
|
65
|
+
parent_directory.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
if should_reap_orphans:
|
|
67
|
+
_remove_orphaned_temporary_files(parent_directory, temporary_prefix, temporary_suffix)
|
|
68
|
+
temporary_path = _write_temporary_text(
|
|
69
|
+
parent_directory,
|
|
70
|
+
serialized_text,
|
|
71
|
+
encoding,
|
|
72
|
+
temporary_prefix,
|
|
73
|
+
temporary_suffix,
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
os.replace(temporary_path, target_path)
|
|
77
|
+
finally:
|
|
78
|
+
try:
|
|
79
|
+
temporary_path.unlink(missing_ok=True)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
package/hooks/blocking/AGENTS.md
CHANGED
|
@@ -95,7 +95,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
95
95
|
| `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
|
|
96
96
|
| `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
|
|
97
97
|
| `send_user_file_open_locally_blocker.py` | PreToolUse (SendUserFile) | A desk-side file attach (`SendUserFile` with `status` not `proactive`); points to opening the file with its native Windows app |
|
|
98
|
-
| `sensitive_file_protector.py` | PreToolUse (Write/Edit) | Writes to sensitive credential or config files |
|
|
98
|
+
| `sensitive_file_protector.py` | PreToolUse (Write/Edit/MultiEdit) | Writes to sensitive credential or config files |
|
|
99
99
|
| `session_edit_stage_gate.py` | PreToolUse (Bash) | A `git commit` that would drop files edited this session because they are tracked but left unstaged |
|
|
100
100
|
| `session_handoff_blocker.py` | Stop | Responses suggesting a new session mid-task |
|
|
101
101
|
| `shell_substitution_blocker.py` | PreToolUse (Bash) | A command carrying `$(...)`, a live backtick, or `<(...)`/`>(...)` process substitution, which the allowlist matcher cannot descend into |
|
|
@@ -114,6 +114,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
114
114
|
| File | Role |
|
|
115
115
|
|---|---|
|
|
116
116
|
| `_gh_body_arg_utils.py` | Parsing helpers for `gh_body_arg_blocker.py` |
|
|
117
|
+
| `test_hook_subprocess_support.py` | Shared subprocess runner for blocking-hook behavior tests |
|
|
117
118
|
|
|
118
119
|
## Conventions
|
|
119
120
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Banned identifier, banned noun-word, and banned function-prefix naming checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
|
+
import io
|
|
4
5
|
import sys
|
|
6
|
+
import tokenize
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
7
9
|
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
@@ -30,6 +32,7 @@ from hooks_constants.banned_identifiers_constants import ( # noqa: E402
|
|
|
30
32
|
ALL_BANNED_NOUN_WORDS,
|
|
31
33
|
BANNED_IDENTIFIER_MESSAGE_SUFFIX,
|
|
32
34
|
BANNED_IDENTIFIER_SKIP_ADVISORY,
|
|
35
|
+
BANNED_NOUN_COMPATIBILITY_COMMENT,
|
|
33
36
|
BANNED_NOUN_WORD_MESSAGE_SUFFIX,
|
|
34
37
|
CAMEL_CASE_WORD_PATTERN,
|
|
35
38
|
MAX_BANNED_IDENTIFIER_ISSUES,
|
|
@@ -235,8 +238,31 @@ def _is_dunder_name(identifier: str) -> bool:
|
|
|
235
238
|
return identifier.startswith("__") and identifier.endswith("__")
|
|
236
239
|
|
|
237
240
|
|
|
241
|
+
def _collect_annotated_public_definition_lines(
|
|
242
|
+
content: str, parsed_tree: ast.AST
|
|
243
|
+
) -> frozenset[int]:
|
|
244
|
+
all_annotation_lines: set[int] = set()
|
|
245
|
+
try:
|
|
246
|
+
for each_token in tokenize.generate_tokens(io.StringIO(content).readline):
|
|
247
|
+
if each_token.type != tokenize.COMMENT:
|
|
248
|
+
continue
|
|
249
|
+
if each_token.string.strip() == BANNED_NOUN_COMPATIBILITY_COMMENT:
|
|
250
|
+
all_annotation_lines.add(each_token.start[0])
|
|
251
|
+
except (IndentationError, SyntaxError, tokenize.TokenError):
|
|
252
|
+
return frozenset()
|
|
253
|
+
|
|
254
|
+
return frozenset(
|
|
255
|
+
each_node.lineno
|
|
256
|
+
for each_node in ast.walk(parsed_tree)
|
|
257
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
258
|
+
and not each_node.name.startswith("_")
|
|
259
|
+
and each_node.lineno in all_annotation_lines
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
238
263
|
def _collect_banned_noun_word_bindings(
|
|
239
264
|
parsed_tree: ast.AST,
|
|
265
|
+
all_annotated_public_definition_lines: frozenset[int],
|
|
240
266
|
) -> list[tuple[str, int, int, str]]:
|
|
241
267
|
"""Yield ``(identifier, lineno, col_offset, banned_word)`` for each binding.
|
|
242
268
|
|
|
@@ -248,7 +274,14 @@ def _collect_banned_noun_word_bindings(
|
|
|
248
274
|
flagged_bindings: list[tuple[str, int, int, str]] = []
|
|
249
275
|
seen_keys: set[tuple[str, int, int]] = set()
|
|
250
276
|
|
|
251
|
-
def record(
|
|
277
|
+
def record(
|
|
278
|
+
name: str,
|
|
279
|
+
lineno: int,
|
|
280
|
+
col_offset: int,
|
|
281
|
+
is_annotated_public_definition: bool = False,
|
|
282
|
+
) -> None:
|
|
283
|
+
if is_annotated_public_definition:
|
|
284
|
+
return
|
|
252
285
|
if name in ALL_BANNED_IDENTIFIERS:
|
|
253
286
|
return
|
|
254
287
|
if _is_dunder_name(name):
|
|
@@ -282,11 +315,24 @@ def _collect_banned_noun_word_bindings(
|
|
|
282
315
|
for each_name_node in _collect_target_names(each_node.optional_vars):
|
|
283
316
|
record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
|
|
284
317
|
elif isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
285
|
-
|
|
318
|
+
is_annotated_public_definition = (
|
|
319
|
+
each_node.lineno in all_annotated_public_definition_lines
|
|
320
|
+
)
|
|
321
|
+
record(
|
|
322
|
+
each_node.name,
|
|
323
|
+
each_node.lineno,
|
|
324
|
+
each_node.col_offset,
|
|
325
|
+
is_annotated_public_definition,
|
|
326
|
+
)
|
|
286
327
|
for each_arg in _collect_annotated_arguments(each_node):
|
|
287
328
|
if each_arg.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
|
|
288
329
|
continue
|
|
289
|
-
record(
|
|
330
|
+
record(
|
|
331
|
+
each_arg.arg,
|
|
332
|
+
each_arg.lineno,
|
|
333
|
+
each_arg.col_offset,
|
|
334
|
+
is_annotated_public_definition,
|
|
335
|
+
)
|
|
290
336
|
elif isinstance(each_node, ast.ClassDef):
|
|
291
337
|
record(each_node.name, each_node.lineno, each_node.col_offset)
|
|
292
338
|
elif isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
@@ -368,9 +414,14 @@ def check_banned_noun_word_boundary(
|
|
|
368
414
|
except SyntaxError:
|
|
369
415
|
return []
|
|
370
416
|
|
|
417
|
+
all_annotated_public_definition_lines = _collect_annotated_public_definition_lines(
|
|
418
|
+
content, parsed_tree
|
|
419
|
+
)
|
|
371
420
|
single_line_span = 1
|
|
372
421
|
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
373
|
-
for each_name, each_lineno, _, each_word in _collect_banned_noun_word_bindings(
|
|
422
|
+
for each_name, each_lineno, _, each_word in _collect_banned_noun_word_bindings(
|
|
423
|
+
parsed_tree, all_annotated_public_definition_lines
|
|
424
|
+
):
|
|
374
425
|
span_range = range(each_lineno, each_lineno + single_line_span)
|
|
375
426
|
span_fragment = BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE.format(
|
|
376
427
|
definition_line=each_lineno, line_span=single_line_span
|