@softspark/ai-toolkit 4.16.1 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +9 -15
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/hooks/session-end.sh +1 -13
  5. package/app/hooks.json +0 -10
  6. package/benchmarks/ecosystem-doctor-snapshot.json +14 -15
  7. package/bin/ai-toolkit.js +0 -2
  8. package/kb/history/completed/output-filter-retirement-20260726.md +128 -0
  9. package/kb/reference/architecture-overview.md +2 -3
  10. package/kb/reference/cli-reference.md +3 -13
  11. package/kb/reference/enterprise-config-guide.md +1 -21
  12. package/kb/reference/hooks-catalog.md +3 -60
  13. package/kb/reference/supported-tools-registry.md +0 -4
  14. package/llms-full.txt +143 -395
  15. package/llms.txt +1 -1
  16. package/manifest.json +147 -36
  17. package/package.json +1 -2
  18. package/scripts/claude_app.py +2 -21
  19. package/scripts/config_cli.py +4 -0
  20. package/scripts/config_merger.py +0 -17
  21. package/scripts/config_validator.py +11 -138
  22. package/scripts/doctor.py +3 -20
  23. package/scripts/install.py +2 -1
  24. package/scripts/install_steps/ai_tools.py +28 -99
  25. package/scripts/install_steps/hooks.py +26 -24
  26. package/scripts/merge-hooks.py +33 -2
  27. package/scripts/output_filter_retirement.py +395 -0
  28. package/scripts/schemas/ai-toolkit-config.schema.json +0 -60
  29. package/scripts/uninstall.py +13 -27
  30. package/app/hooks/filter-tool-output.sh +0 -76
  31. package/app/output-filter-policy.json +0 -15
  32. package/benchmarks/output-filter/README.md +0 -11
  33. package/benchmarks/output-filter/scenarios.json +0 -25
  34. package/kb/reference/tool-output-filter.md +0 -288
  35. package/scripts/benchmark_output_filter.py +0 -343
  36. package/scripts/output_filter_cli.py +0 -347
  37. package/scripts/output_filter_hook.py +0 -23
  38. package/scripts/tool_output_filter/__init__.py +0 -33
  39. package/scripts/tool_output_filter/contracts.py +0 -173
  40. package/scripts/tool_output_filter/engine.py +0 -260
  41. package/scripts/tool_output_filter/hook_runtime.py +0 -369
  42. package/scripts/tool_output_filter/input.py +0 -56
  43. package/scripts/tool_output_filter/invariants.py +0 -40
  44. package/scripts/tool_output_filter/policy.py +0 -153
  45. package/scripts/tool_output_filter/profiles/__init__.py +0 -68
  46. package/scripts/tool_output_filter/profiles/repeat_lines.py +0 -71
  47. package/scripts/tool_output_filter/profiles/tap_success.py +0 -154
  48. package/scripts/tool_output_filter/recovery.py +0 -846
  49. 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
- ]