claude-dev-env 8.4.1 → 8.4.3
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/package.json +1 -1
- package/scripts/windows/pstack/pstack-command-shim.ps1 +9 -2
- package/hooks/blocking/stop_dispatcher.py +0 -81
- package/hooks/blocking/test_stop_dispatcher.py +0 -165
- package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -20
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -7
- package/scripts/audit-hooks-cli.mjs +0 -59
- package/scripts/audit-hooks.mjs +0 -453
- package/scripts/tests/test-audit-hooks-cli.mjs +0 -150
- package/scripts/tests/test-audit-hooks.mjs +0 -471
package/package.json
CHANGED
|
@@ -32,8 +32,15 @@ $scriptsRoot = Split-Path -Parent (Split-Path -Parent $entrypoint)
|
|
|
32
32
|
$exitCode = 1
|
|
33
33
|
Push-Location $scriptsRoot
|
|
34
34
|
try {
|
|
35
|
-
& bun
|
|
36
|
-
$
|
|
35
|
+
& bun -e 'import { ensureDependenciesInstalled } from "./bootstrap.ts"; ensureDependenciesInstalled();'
|
|
36
|
+
$bootstrapExitCode = $LASTEXITCODE
|
|
37
|
+
if ($bootstrapExitCode -ne 0) {
|
|
38
|
+
Write-Error "The pstack dependencies could not be installed"
|
|
39
|
+
$exitCode = $bootstrapExitCode
|
|
40
|
+
} else {
|
|
41
|
+
& bun $entrypoint @commandArguments
|
|
42
|
+
$exitCode = $LASTEXITCODE
|
|
43
|
+
}
|
|
37
44
|
}
|
|
38
45
|
finally {
|
|
39
46
|
Pop-Location
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Stop-hook dispatcher that hosts the Stop-chain blockers in one process.
|
|
3
|
-
|
|
4
|
-
Reads the Stop payload from stdin once, runs each hosted hook in registration
|
|
5
|
-
order via the shared hosted-hook runner, and emits the first block decision the
|
|
6
|
-
chain produced. Later hooks still run after an earlier block so every hosted
|
|
7
|
-
hook gets a chance to run. A single hosted hook crash fails open and does not
|
|
8
|
-
suppress the remaining hooks.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
import json
|
|
14
|
-
import sys
|
|
15
|
-
|
|
16
|
-
import _path_setup # noqa: F401
|
|
17
|
-
|
|
18
|
-
from hooks_constants.hosted_hook_runner import (
|
|
19
|
-
run_hook_capturing_output,
|
|
20
|
-
HostedHookRun,
|
|
21
|
-
resolved_hook_script_path,
|
|
22
|
-
)
|
|
23
|
-
from hooks_constants.stop_dispatcher_constants import (
|
|
24
|
-
ALL_STOP_HOSTED_HOOK_PATHS,
|
|
25
|
-
BLOCK_DECISION,
|
|
26
|
-
DECISION_KEY,
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _is_block_decision(stdout_text: str) -> bool:
|
|
31
|
-
"""Return True when stdout carries a Stop decision of block."""
|
|
32
|
-
stripped_text = stdout_text.strip()
|
|
33
|
-
if not stripped_text:
|
|
34
|
-
return False
|
|
35
|
-
try:
|
|
36
|
-
parsed_output = json.loads(stripped_text)
|
|
37
|
-
except json.JSONDecodeError:
|
|
38
|
-
return False
|
|
39
|
-
if not isinstance(parsed_output, dict):
|
|
40
|
-
return False
|
|
41
|
-
return parsed_output.get(DECISION_KEY) == BLOCK_DECISION
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def select_first_block_stdout(all_runs: list[HostedHookRun]) -> str:
|
|
45
|
-
"""Return the first non-crashed block stdout, or empty when none blocked."""
|
|
46
|
-
for each_run in all_runs:
|
|
47
|
-
if each_run.did_crash:
|
|
48
|
-
continue
|
|
49
|
-
if _is_block_decision(each_run.captured_stdout):
|
|
50
|
-
return each_run.captured_stdout.strip()
|
|
51
|
-
return ""
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def dispatch(payload_text: str) -> None:
|
|
55
|
-
"""Run every Stop hosted hook and emit the first block decision if any."""
|
|
56
|
-
all_runs: list[HostedHookRun] = []
|
|
57
|
-
for each_relative_path in ALL_STOP_HOSTED_HOOK_PATHS:
|
|
58
|
-
script_path = resolved_hook_script_path(each_relative_path)
|
|
59
|
-
hook_run = run_hook_capturing_output(script_path, payload_text)
|
|
60
|
-
all_runs.append(hook_run)
|
|
61
|
-
block_stdout = select_first_block_stdout(all_runs)
|
|
62
|
-
if block_stdout:
|
|
63
|
-
sys.stdout.write(block_stdout + "\n")
|
|
64
|
-
sys.stdout.flush()
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def main() -> None:
|
|
68
|
-
"""Read stdin once and dispatch the Stop hosted-hook chain."""
|
|
69
|
-
payload_text = sys.stdin.read()
|
|
70
|
-
if not payload_text.strip():
|
|
71
|
-
sys.exit(0)
|
|
72
|
-
try:
|
|
73
|
-
json.loads(payload_text)
|
|
74
|
-
except json.JSONDecodeError:
|
|
75
|
-
sys.exit(0)
|
|
76
|
-
dispatch(payload_text)
|
|
77
|
-
sys.exit(0)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if __name__ == "__main__":
|
|
81
|
-
main()
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
"""Behavior tests for the Stop-hook dispatcher."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
import pytest
|
|
12
|
-
|
|
13
|
-
_HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
|
|
14
|
-
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
15
|
-
if _HOOKS_DIR not in sys.path:
|
|
16
|
-
sys.path.insert(0, _HOOKS_DIR)
|
|
17
|
-
_blocking_dir_text = str(_BLOCKING_DIR)
|
|
18
|
-
if _blocking_dir_text not in sys.path:
|
|
19
|
-
sys.path.insert(0, _blocking_dir_text)
|
|
20
|
-
|
|
21
|
-
from hooks_constants.hosted_hook_runner import HostedHookRun # noqa: E402
|
|
22
|
-
from stop_dispatcher import dispatch, select_first_block_stdout # noqa: E402
|
|
23
|
-
|
|
24
|
-
_DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "stop_dispatcher.py")
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _block_json(
|
|
28
|
-
reason: str, *, system_message: str = "", suppress_output: bool = False
|
|
29
|
-
) -> str:
|
|
30
|
-
"""Build a Stop block stdout string with optional supplementary fields."""
|
|
31
|
-
payload: dict[str, object] = {"decision": "block", "reason": reason}
|
|
32
|
-
if system_message:
|
|
33
|
-
payload["systemMessage"] = system_message
|
|
34
|
-
if suppress_output:
|
|
35
|
-
payload["suppressOutput"] = True
|
|
36
|
-
return json.dumps(payload)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def test_first_block_wins_among_non_crashed_runs() -> None:
|
|
40
|
-
"""The first non-crashed block decision is the one the dispatcher emits."""
|
|
41
|
-
all_runs = [
|
|
42
|
-
HostedHookRun(captured_stdout="", did_crash=False),
|
|
43
|
-
HostedHookRun(captured_stdout=_block_json("first"), did_crash=False),
|
|
44
|
-
HostedHookRun(captured_stdout=_block_json("second"), did_crash=False),
|
|
45
|
-
]
|
|
46
|
-
selected = select_first_block_stdout(all_runs)
|
|
47
|
-
assert json.loads(selected)["reason"] == "first"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_crashed_hook_before_block_is_skipped() -> None:
|
|
51
|
-
"""A crash before a block fails open so a later block still surfaces."""
|
|
52
|
-
all_runs = [
|
|
53
|
-
HostedHookRun(captured_stdout="", did_crash=True),
|
|
54
|
-
HostedHookRun(captured_stdout=_block_json("later"), did_crash=False),
|
|
55
|
-
]
|
|
56
|
-
selected = select_first_block_stdout(all_runs)
|
|
57
|
-
assert json.loads(selected)["reason"] == "later"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def test_no_block_when_every_hook_is_silent() -> None:
|
|
61
|
-
"""A silent chain produces no block stdout."""
|
|
62
|
-
assert select_first_block_stdout([HostedHookRun("", False), HostedHookRun("", False)]) == ""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def test_select_preserves_system_message_and_suppress_output() -> None:
|
|
66
|
-
"""select_first_block_stdout keeps supplementary fields on the winning block."""
|
|
67
|
-
all_runs = [
|
|
68
|
-
HostedHookRun(captured_stdout="", did_crash=True),
|
|
69
|
-
HostedHookRun(
|
|
70
|
-
captured_stdout=_block_json(
|
|
71
|
-
"keep fields",
|
|
72
|
-
system_message="rewrite the ending",
|
|
73
|
-
suppress_output=True,
|
|
74
|
-
),
|
|
75
|
-
did_crash=False,
|
|
76
|
-
),
|
|
77
|
-
]
|
|
78
|
-
selected = json.loads(select_first_block_stdout(all_runs))
|
|
79
|
-
assert selected["reason"] == "keep fields"
|
|
80
|
-
assert selected["systemMessage"] == "rewrite the ending"
|
|
81
|
-
assert selected["suppressOutput"] is True
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def test_dispatch_re_emits_first_block_with_system_message_and_suppress(
|
|
85
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
86
|
-
capsys: pytest.CaptureFixture[str],
|
|
87
|
-
) -> None:
|
|
88
|
-
"""dispatch re-emits the first block's full JSON including supplementary fields."""
|
|
89
|
-
all_responses = [
|
|
90
|
-
HostedHookRun(captured_stdout="", did_crash=False),
|
|
91
|
-
HostedHookRun(
|
|
92
|
-
captured_stdout=_block_json(
|
|
93
|
-
"keep fields",
|
|
94
|
-
system_message="rewrite the ending",
|
|
95
|
-
suppress_output=True,
|
|
96
|
-
),
|
|
97
|
-
did_crash=False,
|
|
98
|
-
),
|
|
99
|
-
HostedHookRun(captured_stdout=_block_json("later block"), did_crash=False),
|
|
100
|
-
]
|
|
101
|
-
response_iterator = iter(all_responses)
|
|
102
|
-
|
|
103
|
-
def _fake_run_hook(script_path: str, payload_text: str) -> HostedHookRun:
|
|
104
|
-
del script_path, payload_text
|
|
105
|
-
return next(response_iterator)
|
|
106
|
-
|
|
107
|
-
monkeypatch.setattr("stop_dispatcher.run_hook_capturing_output", _fake_run_hook)
|
|
108
|
-
monkeypatch.setattr(
|
|
109
|
-
"stop_dispatcher.ALL_STOP_HOSTED_HOOK_PATHS",
|
|
110
|
-
(
|
|
111
|
-
"blocking/first_stub_hook.py",
|
|
112
|
-
"blocking/question_to_user_enforcer.py",
|
|
113
|
-
"blocking/second_stub_hook.py",
|
|
114
|
-
),
|
|
115
|
-
)
|
|
116
|
-
dispatch('{"session_id": "test"}')
|
|
117
|
-
emitted_payload = json.loads(capsys.readouterr().out.strip())
|
|
118
|
-
assert emitted_payload["decision"] == "block"
|
|
119
|
-
assert emitted_payload["reason"] == "keep fields"
|
|
120
|
-
assert emitted_payload["systemMessage"] == "rewrite the ending"
|
|
121
|
-
assert emitted_payload["suppressOutput"] is True
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def test_dispatch_writes_nothing_when_no_hook_blocks(
|
|
125
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
126
|
-
capsys: pytest.CaptureFixture[str],
|
|
127
|
-
) -> None:
|
|
128
|
-
"""dispatch leaves stdout empty when every hosted hook is silent or allows."""
|
|
129
|
-
monkeypatch.setattr(
|
|
130
|
-
"stop_dispatcher.run_hook_capturing_output",
|
|
131
|
-
lambda script_path, payload_text: HostedHookRun(captured_stdout="", did_crash=False),
|
|
132
|
-
)
|
|
133
|
-
monkeypatch.setattr(
|
|
134
|
-
"stop_dispatcher.ALL_STOP_HOSTED_HOOK_PATHS",
|
|
135
|
-
("blocking/first_stub_hook.py",),
|
|
136
|
-
)
|
|
137
|
-
dispatch('{"session_id": "test"}')
|
|
138
|
-
assert capsys.readouterr().out == ""
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def test_dispatcher_exits_zero_on_empty_payload() -> None:
|
|
142
|
-
"""The dispatcher exits zero when stdin is empty."""
|
|
143
|
-
completed = subprocess.run(
|
|
144
|
-
[sys.executable, _DISPATCHER_SCRIPT],
|
|
145
|
-
check=False,
|
|
146
|
-
input="",
|
|
147
|
-
capture_output=True,
|
|
148
|
-
text=True,
|
|
149
|
-
)
|
|
150
|
-
assert completed.returncode == 0
|
|
151
|
-
assert completed.stdout.strip() == ""
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def test_dispatcher_imports_standalone_with_only_blocking_on_the_path() -> None:
|
|
155
|
-
"""The dispatcher's bootstrap resolves hooks_constants without hooks/ on PYTHONPATH."""
|
|
156
|
-
subprocess_environment = {**os.environ, "PYTHONPATH": str(_BLOCKING_DIR)}
|
|
157
|
-
completed = subprocess.run(
|
|
158
|
-
[sys.executable, "-c", "import stop_dispatcher; print('ok')"],
|
|
159
|
-
check=False,
|
|
160
|
-
capture_output=True,
|
|
161
|
-
text=True,
|
|
162
|
-
env=subprocess_environment,
|
|
163
|
-
)
|
|
164
|
-
assert completed.returncode == 0, completed.stderr
|
|
165
|
-
assert completed.stdout.strip() == "ok"
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
"""Constants for the Stop-hook dispatcher.
|
|
2
|
-
|
|
3
|
-
Holds the ordered hosted-hook roster for the Stop chain. The dispatcher imports
|
|
4
|
-
this roster to run every Stop hook in registration order inside one process.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
__all__ = [
|
|
10
|
-
"ALL_STOP_HOSTED_HOOK_PATHS",
|
|
11
|
-
"BLOCK_DECISION",
|
|
12
|
-
"DECISION_KEY",
|
|
13
|
-
"REASON_KEY",
|
|
14
|
-
]
|
|
15
|
-
|
|
16
|
-
BLOCK_DECISION = "block"
|
|
17
|
-
DECISION_KEY = "decision"
|
|
18
|
-
REASON_KEY = "reason"
|
|
19
|
-
|
|
20
|
-
ALL_STOP_HOSTED_HOOK_PATHS: tuple[str, ...] = ()
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { writeFileSync } from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
|
|
5
|
-
import { auditHooks } from "./audit-hooks.mjs";
|
|
6
|
-
|
|
7
|
-
function parseArguments(argv) {
|
|
8
|
-
const options = {
|
|
9
|
-
catalogPath: null,
|
|
10
|
-
format: "text",
|
|
11
|
-
homeDirectory: null,
|
|
12
|
-
includeInstalled: false,
|
|
13
|
-
outputPath: null,
|
|
14
|
-
requireNonblocking: false,
|
|
15
|
-
repositoryRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."),
|
|
16
|
-
};
|
|
17
|
-
for (let offset = 0; offset < argv.length; offset += 1) {
|
|
18
|
-
const argument = argv[offset];
|
|
19
|
-
if (argument === "--installed") options.includeInstalled = true;
|
|
20
|
-
else if (argument === "--catalog") options.catalogPath = argv[++offset];
|
|
21
|
-
else if (argument === "--format") options.format = argv[++offset];
|
|
22
|
-
else if (argument === "--home") options.homeDirectory = argv[++offset];
|
|
23
|
-
else if (argument === "--output") options.outputPath = argv[++offset];
|
|
24
|
-
else if (argument === "--require-nonblocking") options.requireNonblocking = true;
|
|
25
|
-
else if (argument === "--repository-root") options.repositoryRoot = argv[++offset];
|
|
26
|
-
else throw new Error(`unknown argument ${argument}`);
|
|
27
|
-
}
|
|
28
|
-
return options;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function renderText(report) {
|
|
32
|
-
const summary = report.summary;
|
|
33
|
-
return [
|
|
34
|
-
`canonical direct: ${summary.directCount}`,
|
|
35
|
-
`dispatchers: ${summary.dispatcherCount}`,
|
|
36
|
-
`hosted entries: ${summary.effectiveCount}`,
|
|
37
|
-
`logical associations: ${summary.logicalAssociationCount}`,
|
|
38
|
-
`unclassified targets: ${summary.unclassifiedCount}`,
|
|
39
|
-
`installed Claude direct: ${summary.claudeDirectCount}`,
|
|
40
|
-
`installed Codex direct: ${summary.codexDirectCount}`,
|
|
41
|
-
`active native Git hooks: ${summary.gitHookCount}`,
|
|
42
|
-
`known native Git hooks: ${summary.knownGitHookCount}`,
|
|
43
|
-
`registration eligibility: ${report.registrationEligibility.status}`,
|
|
44
|
-
"runtime proof: separate verification required",
|
|
45
|
-
`findings: ${summary.findingCount}`,
|
|
46
|
-
].join("\n");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function main() {
|
|
50
|
-
const options = parseArguments(process.argv.slice(2));
|
|
51
|
-
const report = auditHooks(options);
|
|
52
|
-
const rendered =
|
|
53
|
-
options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : `${renderText(report)}\n`;
|
|
54
|
-
if (options.outputPath) writeFileSync(options.outputPath, rendered, "utf8");
|
|
55
|
-
else process.stdout.write(rendered);
|
|
56
|
-
process.exitCode = report.findings.some(({ severity }) => severity === "error") ? 1 : 0;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
main();
|