@softspark/ai-toolkit 4.16.0 → 4.17.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/CHANGELOG.md +41 -0
- package/README.md +11 -16
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/hooks/session-end.sh +1 -13
- package/app/hooks.json +0 -10
- package/benchmarks/ecosystem-doctor-snapshot.json +14 -15
- package/bin/ai-toolkit.js +0 -2
- package/kb/history/completed/output-filter-retirement-20260726.md +128 -0
- package/kb/reference/architecture-overview.md +2 -3
- package/kb/reference/cli-reference.md +3 -13
- package/kb/reference/enterprise-config-guide.md +1 -21
- package/kb/reference/hooks-catalog.md +3 -60
- package/kb/reference/supported-tools-registry.md +0 -4
- package/llms-full.txt +143 -395
- package/llms.txt +1 -1
- package/manifest.json +147 -36
- package/package.json +1 -2
- package/scripts/claude_app.py +2 -21
- package/scripts/config_cli.py +4 -0
- package/scripts/config_merger.py +0 -17
- package/scripts/config_validator.py +11 -138
- package/scripts/doctor.py +3 -20
- package/scripts/generate_copilot.py +35 -4
- package/scripts/install.py +7 -2
- package/scripts/install_steps/ai_tools.py +28 -99
- package/scripts/install_steps/hooks.py +26 -24
- package/scripts/merge-hooks.py +33 -2
- package/scripts/output_filter_retirement.py +395 -0
- package/scripts/schemas/ai-toolkit-config.schema.json +0 -60
- package/scripts/uninstall.py +13 -27
- package/app/hooks/filter-tool-output.sh +0 -76
- package/app/output-filter-policy.json +0 -15
- package/benchmarks/output-filter/README.md +0 -11
- package/benchmarks/output-filter/scenarios.json +0 -25
- package/kb/reference/tool-output-filter.md +0 -288
- package/scripts/benchmark_output_filter.py +0 -343
- package/scripts/output_filter_cli.py +0 -347
- package/scripts/output_filter_hook.py +0 -23
- package/scripts/tool_output_filter/__init__.py +0 -33
- package/scripts/tool_output_filter/contracts.py +0 -173
- package/scripts/tool_output_filter/engine.py +0 -260
- package/scripts/tool_output_filter/hook_runtime.py +0 -369
- package/scripts/tool_output_filter/input.py +0 -56
- package/scripts/tool_output_filter/invariants.py +0 -40
- package/scripts/tool_output_filter/policy.py +0 -153
- package/scripts/tool_output_filter/profiles/__init__.py +0 -68
- package/scripts/tool_output_filter/profiles/repeat_lines.py +0 -71
- package/scripts/tool_output_filter/profiles/tap_success.py +0 -154
- package/scripts/tool_output_filter/recovery.py +0 -846
- package/scripts/tool_output_filter/telemetry.py +0 -13
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
"""Pure orchestration for post-execution output filtering."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import re
|
|
6
|
-
import time
|
|
7
|
-
from collections.abc import Callable
|
|
8
|
-
|
|
9
|
-
from .contracts import FilterMode, FilterRequest, FilterResult, FilterTelemetry
|
|
10
|
-
from .invariants import SessionCircuitBreaker
|
|
11
|
-
from .profiles import ProfileTransform, apply_profile
|
|
12
|
-
from .recovery import RecoveryStore
|
|
13
|
-
from .telemetry import TelemetrySink
|
|
14
|
-
|
|
15
|
-
_UNSAFE_CONTROL_PATTERN = re.compile(
|
|
16
|
-
r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\u2028\u2029]|\r(?!\n)"
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _meets_savings_gate(
|
|
21
|
-
raw_output: str,
|
|
22
|
-
candidate: str,
|
|
23
|
-
request: FilterRequest,
|
|
24
|
-
) -> bool:
|
|
25
|
-
input_bytes = len(raw_output.encode("utf-8"))
|
|
26
|
-
output_bytes = len(candidate.encode("utf-8"))
|
|
27
|
-
bytes_saved = input_bytes - output_bytes
|
|
28
|
-
savings_ratio = bytes_saved / input_bytes if input_bytes else 0.0
|
|
29
|
-
return (
|
|
30
|
-
bytes_saved >= request.min_savings_bytes
|
|
31
|
-
and savings_ratio >= request.min_savings_ratio
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _telemetry(
|
|
36
|
-
request: FilterRequest,
|
|
37
|
-
output: str,
|
|
38
|
-
profile_id: str,
|
|
39
|
-
profile_version: int,
|
|
40
|
-
) -> FilterTelemetry:
|
|
41
|
-
return FilterTelemetry(
|
|
42
|
-
profile_id=profile_id,
|
|
43
|
-
profile_version=profile_version,
|
|
44
|
-
input_bytes=len(request.output.encode("utf-8")),
|
|
45
|
-
output_bytes=len(output.encode("utf-8")),
|
|
46
|
-
input_lines=len(request.output.splitlines()),
|
|
47
|
-
output_lines=len(output.splitlines()),
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _with_recovery_marker(
|
|
52
|
-
candidate: str,
|
|
53
|
-
request: FilterRequest,
|
|
54
|
-
handle: str,
|
|
55
|
-
profile_id: str,
|
|
56
|
-
profile_version: int,
|
|
57
|
-
) -> str:
|
|
58
|
-
separator = "" if candidate.endswith("\n") else "\n"
|
|
59
|
-
emitted_lines = len(candidate.splitlines()) + 1
|
|
60
|
-
return (
|
|
61
|
-
f"{candidate}{separator}"
|
|
62
|
-
f"[ai-toolkit-output-filter {profile_id}/v{profile_version}; "
|
|
63
|
-
f"original_lines={len(request.output.splitlines())}; "
|
|
64
|
-
f"emitted_lines={emitted_lines}; recovery={handle}]\n"
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _discard_recovery(recovery: RecoveryStore, handle: str) -> None:
|
|
69
|
-
try:
|
|
70
|
-
recovery.delete(handle)
|
|
71
|
-
except Exception:
|
|
72
|
-
pass
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _passthrough(
|
|
76
|
-
request: FilterRequest,
|
|
77
|
-
reason: str,
|
|
78
|
-
telemetry: FilterTelemetry | None = None,
|
|
79
|
-
) -> FilterResult:
|
|
80
|
-
return FilterResult(
|
|
81
|
-
output=request.output,
|
|
82
|
-
changed=False,
|
|
83
|
-
outcome="passthrough",
|
|
84
|
-
telemetry=telemetry,
|
|
85
|
-
fallback_reason=reason,
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _eligibility_failure(request: FilterRequest) -> str | None:
|
|
90
|
-
if not request.successful:
|
|
91
|
-
return "execution-failed"
|
|
92
|
-
if request.stderr:
|
|
93
|
-
return "stderr-present"
|
|
94
|
-
if request.interrupted:
|
|
95
|
-
return "execution-interrupted"
|
|
96
|
-
if request.is_image:
|
|
97
|
-
return "unsupported-media"
|
|
98
|
-
if request.is_streaming:
|
|
99
|
-
return "unsupported-stream"
|
|
100
|
-
if _UNSAFE_CONTROL_PATTERN.search(request.output) is not None:
|
|
101
|
-
return "unsupported-control-sequence"
|
|
102
|
-
try:
|
|
103
|
-
input_bytes = len(request.output.encode("utf-8"))
|
|
104
|
-
except UnicodeEncodeError:
|
|
105
|
-
return "invalid-text"
|
|
106
|
-
if input_bytes > request.max_input_bytes:
|
|
107
|
-
return "input-too-large"
|
|
108
|
-
return None
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _apply_profile_safely(
|
|
112
|
-
request: FilterRequest,
|
|
113
|
-
) -> tuple[ProfileTransform | None, str | None]:
|
|
114
|
-
try:
|
|
115
|
-
profile = apply_profile(request.profile_id, request.output)
|
|
116
|
-
except Exception:
|
|
117
|
-
return None, "profile-failed"
|
|
118
|
-
if profile is None:
|
|
119
|
-
return None, "unknown-profile"
|
|
120
|
-
if not profile.accepted:
|
|
121
|
-
return None, "profile-rejected"
|
|
122
|
-
return profile, None
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def _save_verified_recovery(
|
|
126
|
-
response: object,
|
|
127
|
-
recovery: RecoveryStore,
|
|
128
|
-
) -> tuple[str | None, str | None]:
|
|
129
|
-
handle: str | None = None
|
|
130
|
-
try:
|
|
131
|
-
handle = recovery.save(response)
|
|
132
|
-
recovered = recovery.load(handle)
|
|
133
|
-
except Exception:
|
|
134
|
-
if handle is not None:
|
|
135
|
-
_discard_recovery(recovery, handle)
|
|
136
|
-
return None, "recovery-failed"
|
|
137
|
-
if recovered != response:
|
|
138
|
-
_discard_recovery(recovery, handle)
|
|
139
|
-
return None, "recovery-verification-failed"
|
|
140
|
-
return handle, None
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _safe_result(
|
|
144
|
-
request: FilterRequest,
|
|
145
|
-
profile: ProfileTransform,
|
|
146
|
-
telemetry: FilterTelemetry,
|
|
147
|
-
recovery: RecoveryStore | None,
|
|
148
|
-
) -> FilterResult:
|
|
149
|
-
if request.raw_response is None:
|
|
150
|
-
return _passthrough(request, "raw-response-unavailable", telemetry)
|
|
151
|
-
if recovery is None:
|
|
152
|
-
return _passthrough(request, "recovery-unavailable", telemetry)
|
|
153
|
-
handle, failure = _save_verified_recovery(
|
|
154
|
-
request.raw_response,
|
|
155
|
-
recovery,
|
|
156
|
-
)
|
|
157
|
-
if failure is not None or handle is None:
|
|
158
|
-
return _passthrough(request, failure or "recovery-failed", telemetry)
|
|
159
|
-
replacement = _with_recovery_marker(
|
|
160
|
-
profile.output,
|
|
161
|
-
request,
|
|
162
|
-
handle,
|
|
163
|
-
request.profile_id,
|
|
164
|
-
profile.profile_version,
|
|
165
|
-
)
|
|
166
|
-
if not _meets_savings_gate(request.output, replacement, request):
|
|
167
|
-
_discard_recovery(recovery, handle)
|
|
168
|
-
return _passthrough(request, "never-worse", telemetry)
|
|
169
|
-
return FilterResult(
|
|
170
|
-
output=replacement,
|
|
171
|
-
changed=True,
|
|
172
|
-
outcome="replaced",
|
|
173
|
-
telemetry=_telemetry(
|
|
174
|
-
request,
|
|
175
|
-
replacement,
|
|
176
|
-
request.profile_id,
|
|
177
|
-
profile.profile_version,
|
|
178
|
-
),
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def _filter_output(
|
|
183
|
-
request: FilterRequest,
|
|
184
|
-
*,
|
|
185
|
-
recovery: RecoveryStore | None = None,
|
|
186
|
-
) -> FilterResult:
|
|
187
|
-
"""Return an exact passthrough unless every safety gate succeeds."""
|
|
188
|
-
|
|
189
|
-
if request.mode is FilterMode.OFF:
|
|
190
|
-
return FilterResult(request.output, False, "disabled")
|
|
191
|
-
eligibility_failure = _eligibility_failure(request)
|
|
192
|
-
if eligibility_failure is not None:
|
|
193
|
-
return _passthrough(request, eligibility_failure)
|
|
194
|
-
profile, profile_failure = _apply_profile_safely(request)
|
|
195
|
-
if profile_failure is not None or profile is None:
|
|
196
|
-
return _passthrough(
|
|
197
|
-
request,
|
|
198
|
-
profile_failure or "profile-failed",
|
|
199
|
-
)
|
|
200
|
-
if not _meets_savings_gate(request.output, profile.output, request):
|
|
201
|
-
return _passthrough(request, "insufficient-savings")
|
|
202
|
-
candidate_telemetry = _telemetry(
|
|
203
|
-
request,
|
|
204
|
-
profile.output,
|
|
205
|
-
request.profile_id,
|
|
206
|
-
profile.profile_version,
|
|
207
|
-
)
|
|
208
|
-
if request.mode is FilterMode.OBSERVE:
|
|
209
|
-
return FilterResult(
|
|
210
|
-
request.output,
|
|
211
|
-
False,
|
|
212
|
-
"observed",
|
|
213
|
-
candidate_telemetry,
|
|
214
|
-
)
|
|
215
|
-
return _safe_result(
|
|
216
|
-
request,
|
|
217
|
-
profile,
|
|
218
|
-
candidate_telemetry,
|
|
219
|
-
recovery,
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def filter_output(
|
|
224
|
-
request: FilterRequest,
|
|
225
|
-
*,
|
|
226
|
-
recovery: RecoveryStore | None = None,
|
|
227
|
-
telemetry: TelemetrySink | None = None,
|
|
228
|
-
circuit_breaker: SessionCircuitBreaker | None = None,
|
|
229
|
-
clock_ns: Callable[[], int] = time.perf_counter_ns,
|
|
230
|
-
) -> FilterResult:
|
|
231
|
-
"""Apply one profile without mutating execution state or raw input."""
|
|
232
|
-
|
|
233
|
-
if circuit_breaker is not None and circuit_breaker.is_open:
|
|
234
|
-
return FilterResult(
|
|
235
|
-
output=request.output,
|
|
236
|
-
changed=False,
|
|
237
|
-
outcome="passthrough",
|
|
238
|
-
fallback_reason="circuit-open",
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
started_ns = clock_ns()
|
|
242
|
-
result = _filter_output(request, recovery=recovery)
|
|
243
|
-
finished_ns = clock_ns()
|
|
244
|
-
if circuit_breaker is not None:
|
|
245
|
-
circuit_breaker.record(result.fallback_reason)
|
|
246
|
-
if result.telemetry is None:
|
|
247
|
-
return result
|
|
248
|
-
|
|
249
|
-
event = result.telemetry.with_runtime(
|
|
250
|
-
duration_ms=max(0, finished_ns - started_ns) / 1_000_000,
|
|
251
|
-
outcome=result.outcome,
|
|
252
|
-
fallback_reason=result.fallback_reason,
|
|
253
|
-
)
|
|
254
|
-
result = result.with_telemetry(event)
|
|
255
|
-
if telemetry is not None:
|
|
256
|
-
try:
|
|
257
|
-
telemetry.record(event)
|
|
258
|
-
except Exception:
|
|
259
|
-
pass
|
|
260
|
-
return result
|
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
"""Lean Claude PostToolUse runtime for native tool-output filtering."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
import re
|
|
8
|
-
import shlex
|
|
9
|
-
import sys
|
|
10
|
-
|
|
11
|
-
from .contracts import FilterMode, FilterRequest
|
|
12
|
-
from .engine import filter_output
|
|
13
|
-
from .input import JSON_ENVELOPE_ALLOWANCE_BYTES, load_bounded_json
|
|
14
|
-
from .invariants import SessionCircuitBreaker
|
|
15
|
-
from .policy import OutputFilterPolicy, load_policy
|
|
16
|
-
from .recovery import EphemeralRecoveryStore
|
|
17
|
-
|
|
18
|
-
_UNSAFE_SHELL_FRAGMENTS = ("|", ">", "<", ";", "&&", "||", "`", "$(", "\n")
|
|
19
|
-
# Claude Code's Bash tool_response has no guaranteed exit-status field; when a
|
|
20
|
-
# runtime does provide one, a non-zero value must veto filtering outright.
|
|
21
|
-
_EXIT_STATUS_KEYS = ("exitCode", "exit_code", "returncode")
|
|
22
|
-
_FAILURE_LINE_PREFIXES = (
|
|
23
|
-
"not ok",
|
|
24
|
-
"npm err!",
|
|
25
|
-
"fail",
|
|
26
|
-
"--- fail",
|
|
27
|
-
"error",
|
|
28
|
-
"fatal",
|
|
29
|
-
"panic",
|
|
30
|
-
"traceback",
|
|
31
|
-
"critical",
|
|
32
|
-
"segmentation fault",
|
|
33
|
-
"assert",
|
|
34
|
-
"exception",
|
|
35
|
-
"✕",
|
|
36
|
-
"✗",
|
|
37
|
-
"e ",
|
|
38
|
-
)
|
|
39
|
-
_FAILURE_SUMMARY_PATTERN = re.compile(
|
|
40
|
-
r"\b[1-9][0-9]* (?:failed|failures|errors)\b"
|
|
41
|
-
)
|
|
42
|
-
_DANGEROUS_FRAGMENTS = (
|
|
43
|
-
"audit",
|
|
44
|
-
"deploy",
|
|
45
|
-
"destroy",
|
|
46
|
-
"migrat",
|
|
47
|
-
"publish",
|
|
48
|
-
"release",
|
|
49
|
-
"semgrep",
|
|
50
|
-
"snyk",
|
|
51
|
-
"terraform",
|
|
52
|
-
"trivy",
|
|
53
|
-
)
|
|
54
|
-
_SAFE_TASK_TOKENS = frozenset(
|
|
55
|
-
{
|
|
56
|
-
"analyze",
|
|
57
|
-
"analysis",
|
|
58
|
-
"check",
|
|
59
|
-
"checks",
|
|
60
|
-
"clippy",
|
|
61
|
-
"lint",
|
|
62
|
-
"qa",
|
|
63
|
-
"test",
|
|
64
|
-
"tests",
|
|
65
|
-
"typecheck",
|
|
66
|
-
"validate",
|
|
67
|
-
"validation",
|
|
68
|
-
"vet",
|
|
69
|
-
}
|
|
70
|
-
)
|
|
71
|
-
_TASK_TOKEN_SEPARATORS = "-_:./"
|
|
72
|
-
_SAFE_SESSION_ID_CHARACTERS = frozenset(
|
|
73
|
-
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
|
|
74
|
-
)
|
|
75
|
-
_MAX_SESSION_ID_LENGTH = 160
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _is_safe_task(value: str) -> bool:
|
|
79
|
-
normalized = value.lower()
|
|
80
|
-
for separator in _TASK_TOKEN_SEPARATORS:
|
|
81
|
-
normalized = normalized.replace(separator, " ")
|
|
82
|
-
return any(token in _SAFE_TASK_TOKENS for token in normalized.split())
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _matches_supported_shape(arguments: list[str]) -> bool:
|
|
86
|
-
executable = os.path.basename(arguments[0]).lower()
|
|
87
|
-
remaining = arguments[1:]
|
|
88
|
-
if executable in {"bats", "jest", "pytest", "vitest"}:
|
|
89
|
-
return True
|
|
90
|
-
if executable in {"python", "python3"}:
|
|
91
|
-
return len(remaining) >= 2 and remaining[:2] in (
|
|
92
|
-
["-m", "pytest"],
|
|
93
|
-
["-m", "unittest"],
|
|
94
|
-
)
|
|
95
|
-
if executable in {"cargo", "dart", "flutter", "go"}:
|
|
96
|
-
return bool(remaining) and _is_safe_task(remaining[0])
|
|
97
|
-
if executable == "npm":
|
|
98
|
-
if not remaining:
|
|
99
|
-
return False
|
|
100
|
-
if remaining[0] == "test":
|
|
101
|
-
return True
|
|
102
|
-
return len(remaining) >= 2 and remaining[0] == "run" and _is_safe_task(
|
|
103
|
-
remaining[1]
|
|
104
|
-
)
|
|
105
|
-
if executable == "npx":
|
|
106
|
-
return bool(remaining) and os.path.basename(remaining[0]).lower() in {
|
|
107
|
-
"eslint",
|
|
108
|
-
"jest",
|
|
109
|
-
"tsc",
|
|
110
|
-
"vitest",
|
|
111
|
-
}
|
|
112
|
-
if executable == "make":
|
|
113
|
-
return bool(remaining) and all(_is_safe_task(target) for target in remaining)
|
|
114
|
-
if executable == "composer":
|
|
115
|
-
if len(remaining) == 1:
|
|
116
|
-
return _is_safe_task(remaining[0])
|
|
117
|
-
return (
|
|
118
|
-
len(remaining) == 2
|
|
119
|
-
and remaining[0] in {"run", "run-script"}
|
|
120
|
-
and _is_safe_task(remaining[1])
|
|
121
|
-
)
|
|
122
|
-
return False
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def _is_supported_command(command: str) -> bool:
|
|
126
|
-
if any(fragment in command for fragment in _UNSAFE_SHELL_FRAGMENTS):
|
|
127
|
-
return False
|
|
128
|
-
try:
|
|
129
|
-
arguments = shlex.split(command, posix=True)
|
|
130
|
-
except ValueError:
|
|
131
|
-
return False
|
|
132
|
-
if not arguments:
|
|
133
|
-
return False
|
|
134
|
-
lowered_arguments = [argument.lower() for argument in arguments]
|
|
135
|
-
if any(
|
|
136
|
-
fragment in argument
|
|
137
|
-
for argument in lowered_arguments
|
|
138
|
-
for fragment in _DANGEROUS_FRAGMENTS
|
|
139
|
-
):
|
|
140
|
-
return False
|
|
141
|
-
return _matches_supported_shape(arguments)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _reports_failed_execution(response: dict[str, object]) -> bool:
|
|
145
|
-
for key in _EXIT_STATUS_KEYS:
|
|
146
|
-
value = response.get(key)
|
|
147
|
-
if value is None:
|
|
148
|
-
continue
|
|
149
|
-
if isinstance(value, bool) or not isinstance(value, int) or value != 0:
|
|
150
|
-
return True
|
|
151
|
-
return False
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _looks_like_failure_output(stdout: str) -> bool:
|
|
155
|
-
for line in stdout.splitlines():
|
|
156
|
-
content = line.lstrip().lower()
|
|
157
|
-
if not content:
|
|
158
|
-
continue
|
|
159
|
-
if content.startswith(_FAILURE_LINE_PREFIXES):
|
|
160
|
-
return True
|
|
161
|
-
if _FAILURE_SUMMARY_PATTERN.search(content) is not None:
|
|
162
|
-
return True
|
|
163
|
-
return False
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def _infer_repo_root(start: str) -> str | None:
|
|
167
|
-
# realpath keeps the repo key aligned with the bash hooks, which resolve
|
|
168
|
-
# symlinks via `git rev-parse --show-toplevel`.
|
|
169
|
-
candidate_path = os.path.realpath(os.path.expanduser(start))
|
|
170
|
-
if not os.path.isdir(candidate_path):
|
|
171
|
-
return None
|
|
172
|
-
candidate = candidate_path
|
|
173
|
-
while True:
|
|
174
|
-
git_marker = os.path.join(candidate, ".git")
|
|
175
|
-
if os.path.isdir(git_marker) or os.path.isfile(git_marker):
|
|
176
|
-
return candidate
|
|
177
|
-
parent = os.path.dirname(candidate)
|
|
178
|
-
if parent == candidate:
|
|
179
|
-
break
|
|
180
|
-
candidate = parent
|
|
181
|
-
return candidate_path
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def _session_base_for_repo(repo_root: str) -> str:
|
|
185
|
-
repo_key = "-" + str(repo_root).replace("/", "-").lstrip("-")
|
|
186
|
-
return os.path.join(
|
|
187
|
-
os.path.expanduser("~"),
|
|
188
|
-
".softspark",
|
|
189
|
-
"ai-toolkit",
|
|
190
|
-
"sessions",
|
|
191
|
-
repo_key,
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _normalize_payload(payload: object) -> tuple[dict[str, object], str] | None:
|
|
196
|
-
if not isinstance(payload, dict):
|
|
197
|
-
return None
|
|
198
|
-
if payload.get("hook_event_name") != "PostToolUse":
|
|
199
|
-
return None
|
|
200
|
-
if payload.get("tool_name") != "Bash":
|
|
201
|
-
return None
|
|
202
|
-
response = payload.get("tool_response")
|
|
203
|
-
tool_input = payload.get("tool_input")
|
|
204
|
-
if not isinstance(response, dict) or not isinstance(tool_input, dict):
|
|
205
|
-
return None
|
|
206
|
-
stdout = response.get("stdout")
|
|
207
|
-
command = tool_input.get("command")
|
|
208
|
-
if not isinstance(stdout, str) or not isinstance(command, str):
|
|
209
|
-
return None
|
|
210
|
-
if response.get("stderr") != "" or response.get("interrupted") is not False:
|
|
211
|
-
return None
|
|
212
|
-
if response.get("isImage") is not False:
|
|
213
|
-
return None
|
|
214
|
-
if _reports_failed_execution(response):
|
|
215
|
-
return None
|
|
216
|
-
if _looks_like_failure_output(stdout):
|
|
217
|
-
return None
|
|
218
|
-
if not _is_supported_command(command):
|
|
219
|
-
return None
|
|
220
|
-
return response, stdout
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def _repo_session_root(payload: dict[str, object]) -> tuple[str, str] | None:
|
|
224
|
-
cwd = payload.get("cwd")
|
|
225
|
-
session_identifier = payload.get("session_id")
|
|
226
|
-
if not isinstance(cwd, str) or not isinstance(session_identifier, str):
|
|
227
|
-
return None
|
|
228
|
-
if not cwd or not session_identifier:
|
|
229
|
-
return None
|
|
230
|
-
if (
|
|
231
|
-
len(session_identifier) > _MAX_SESSION_ID_LENGTH
|
|
232
|
-
or any(
|
|
233
|
-
character not in _SAFE_SESSION_ID_CHARACTERS
|
|
234
|
-
for character in session_identifier
|
|
235
|
-
)
|
|
236
|
-
):
|
|
237
|
-
return None
|
|
238
|
-
repo_root = _infer_repo_root(cwd)
|
|
239
|
-
if repo_root is None:
|
|
240
|
-
return None
|
|
241
|
-
return _session_base_for_repo(repo_root), session_identifier
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def _request(
|
|
245
|
-
policy: OutputFilterPolicy,
|
|
246
|
-
response: dict[str, object],
|
|
247
|
-
stdout: str,
|
|
248
|
-
profile_id: str,
|
|
249
|
-
) -> FilterRequest:
|
|
250
|
-
return FilterRequest(
|
|
251
|
-
output=stdout,
|
|
252
|
-
raw_response=response,
|
|
253
|
-
mode=policy.mode,
|
|
254
|
-
profile_id=profile_id,
|
|
255
|
-
max_input_bytes=policy.max_input_bytes,
|
|
256
|
-
min_savings_bytes=policy.min_savings_bytes,
|
|
257
|
-
min_savings_ratio=policy.min_savings_ratio,
|
|
258
|
-
)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
def _emit_replacement(
|
|
262
|
-
response: dict[str, object],
|
|
263
|
-
replacement: str,
|
|
264
|
-
) -> None:
|
|
265
|
-
updated_response = dict(response)
|
|
266
|
-
updated_response["stdout"] = replacement
|
|
267
|
-
output = {
|
|
268
|
-
"hookSpecificOutput": {
|
|
269
|
-
"hookEventName": "PostToolUse",
|
|
270
|
-
"updatedToolOutput": updated_response,
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
sys.stdout.write(json.dumps(output, ensure_ascii=False, separators=(",", ":")))
|
|
274
|
-
sys.stdout.write("\n")
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def _emit_system_message(message: str) -> None:
|
|
278
|
-
sys.stdout.write(
|
|
279
|
-
json.dumps(
|
|
280
|
-
{"systemMessage": message},
|
|
281
|
-
ensure_ascii=False,
|
|
282
|
-
separators=(",", ":"),
|
|
283
|
-
)
|
|
284
|
-
)
|
|
285
|
-
sys.stdout.write("\n")
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def _filter_profiles(
|
|
289
|
-
policy: OutputFilterPolicy,
|
|
290
|
-
response: dict[str, object],
|
|
291
|
-
stdout: str,
|
|
292
|
-
recovery: EphemeralRecoveryStore,
|
|
293
|
-
circuit_breaker: SessionCircuitBreaker,
|
|
294
|
-
) -> None:
|
|
295
|
-
for profile_id in policy.profiles:
|
|
296
|
-
result = filter_output(
|
|
297
|
-
_request(policy, response, stdout, profile_id),
|
|
298
|
-
recovery=recovery,
|
|
299
|
-
telemetry=recovery,
|
|
300
|
-
circuit_breaker=circuit_breaker,
|
|
301
|
-
)
|
|
302
|
-
recovery.save_failure_count(
|
|
303
|
-
min(
|
|
304
|
-
circuit_breaker.consecutive_failures,
|
|
305
|
-
circuit_breaker.failure_limit,
|
|
306
|
-
)
|
|
307
|
-
)
|
|
308
|
-
if result.changed:
|
|
309
|
-
_emit_replacement(response, result.output)
|
|
310
|
-
return
|
|
311
|
-
if result.outcome == "observed":
|
|
312
|
-
return
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
def run_hook(policy_path: str | os.PathLike[str]) -> int:
|
|
316
|
-
"""Run one bounded, fail-open Claude PostToolUse decision."""
|
|
317
|
-
|
|
318
|
-
if os.environ.get("AI_TOOLKIT_OUTPUT_FILTER_DISABLE") == "1":
|
|
319
|
-
return 0
|
|
320
|
-
try:
|
|
321
|
-
policy = load_policy(policy_path)
|
|
322
|
-
except (OSError, TypeError, ValueError, KeyError):
|
|
323
|
-
return 0
|
|
324
|
-
if policy.mode is FilterMode.OFF:
|
|
325
|
-
return 0
|
|
326
|
-
payload_limit = policy.max_input_bytes + JSON_ENVELOPE_ALLOWANCE_BYTES
|
|
327
|
-
try:
|
|
328
|
-
payload = load_bounded_json(sys.stdin.buffer, max_bytes=payload_limit)
|
|
329
|
-
except (json.JSONDecodeError, TypeError, UnicodeDecodeError, ValueError):
|
|
330
|
-
return 0
|
|
331
|
-
if not isinstance(payload, dict):
|
|
332
|
-
return 0
|
|
333
|
-
normalized = _normalize_payload(payload)
|
|
334
|
-
if normalized is None:
|
|
335
|
-
return 0
|
|
336
|
-
response, stdout = normalized
|
|
337
|
-
recovery_location = _repo_session_root(payload)
|
|
338
|
-
if recovery_location is None:
|
|
339
|
-
return 0
|
|
340
|
-
base_directory, session_identifier = recovery_location
|
|
341
|
-
try:
|
|
342
|
-
with EphemeralRecoveryStore(
|
|
343
|
-
base_directory,
|
|
344
|
-
session_identifier=session_identifier,
|
|
345
|
-
max_session_bytes=policy.max_session_bytes,
|
|
346
|
-
ttl_minutes=policy.ttl_minutes,
|
|
347
|
-
) as recovery:
|
|
348
|
-
circuit_breaker = SessionCircuitBreaker(
|
|
349
|
-
consecutive_failures=recovery.load_failure_count()
|
|
350
|
-
)
|
|
351
|
-
if circuit_breaker.is_open and not recovery.load_warning_emitted():
|
|
352
|
-
_emit_system_message(
|
|
353
|
-
"ai-toolkit: output filtering disabled for this session "
|
|
354
|
-
"after 3 safety failures"
|
|
355
|
-
)
|
|
356
|
-
recovery.mark_warning_emitted()
|
|
357
|
-
_filter_profiles(
|
|
358
|
-
policy,
|
|
359
|
-
response,
|
|
360
|
-
stdout,
|
|
361
|
-
recovery,
|
|
362
|
-
circuit_breaker,
|
|
363
|
-
)
|
|
364
|
-
except (OSError, RuntimeError, TypeError, ValueError):
|
|
365
|
-
return 0
|
|
366
|
-
return 0
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
__all__ = ["run_hook"]
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
"""Bounded input helpers for fail-open runtime adapters."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from collections.abc import Callable
|
|
7
|
-
|
|
8
|
-
JSON_ENVELOPE_ALLOWANCE_BYTES = 64 * 1024
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def read_bounded_text(
|
|
12
|
-
stream: object,
|
|
13
|
-
*,
|
|
14
|
-
max_bytes: int,
|
|
15
|
-
) -> str | None:
|
|
16
|
-
"""Read UTF-8 text without retaining data beyond the byte limit."""
|
|
17
|
-
|
|
18
|
-
if max_bytes < 0:
|
|
19
|
-
raise ValueError("maximum input bytes must be non-negative")
|
|
20
|
-
read = getattr(stream, "read", None)
|
|
21
|
-
if not callable(read):
|
|
22
|
-
raise TypeError("bounded text input must be readable")
|
|
23
|
-
chunks: list[bytes] = []
|
|
24
|
-
received = 0
|
|
25
|
-
while received <= max_bytes:
|
|
26
|
-
chunk = read(max_bytes + 1 - received)
|
|
27
|
-
if not isinstance(chunk, bytes):
|
|
28
|
-
raise TypeError("bounded text input must be binary")
|
|
29
|
-
if not chunk:
|
|
30
|
-
break
|
|
31
|
-
chunks.append(chunk)
|
|
32
|
-
received += len(chunk)
|
|
33
|
-
if received > max_bytes:
|
|
34
|
-
return None
|
|
35
|
-
return b"".join(chunks).decode("utf-8")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def load_bounded_json(
|
|
39
|
-
stream: object,
|
|
40
|
-
*,
|
|
41
|
-
max_bytes: int,
|
|
42
|
-
decoder: Callable[[str], object] = json.loads,
|
|
43
|
-
) -> object | None:
|
|
44
|
-
"""Decode one JSON value only when its encoded form fits the hard cap."""
|
|
45
|
-
|
|
46
|
-
text = read_bounded_text(stream, max_bytes=max_bytes)
|
|
47
|
-
if text is None:
|
|
48
|
-
return None
|
|
49
|
-
return decoder(text)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
__all__ = [
|
|
53
|
-
"JSON_ENVELOPE_ALLOWANCE_BYTES",
|
|
54
|
-
"load_bounded_json",
|
|
55
|
-
"read_bounded_text",
|
|
56
|
-
]
|