@softspark/ai-toolkit 4.15.1 → 4.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +21 -12
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +4 -3
- package/app/hooks/_hook-io.sh +18 -3
- package/app/hooks/ai-toolkit-statusline.sh +30 -5
- package/app/hooks/filter-tool-output.sh +76 -0
- package/app/hooks/governance-capture.sh +1 -1
- package/app/hooks/guard-path.sh +2 -2
- package/app/hooks/post-tool-use.sh +5 -3
- package/app/hooks/pre-compact-save.sh +4 -3
- package/app/hooks/quality-gate.sh +12 -1
- package/app/hooks/revert-guard.sh +5 -2
- package/app/hooks/save-session.sh +4 -2
- package/app/hooks/session-end.sh +36 -4
- package/app/hooks/session-start.sh +11 -5
- package/app/hooks.json +10 -0
- package/app/output-filter-policy.json +15 -0
- package/app/skills/brand-voice/scripts/measure.py +7 -5
- package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
- package/benchmarks/output-filter/README.md +11 -0
- package/benchmarks/output-filter/scenarios.json +25 -0
- package/bin/ai-toolkit.js +2 -0
- package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
- package/kb/procedures/release-preparation-sop.md +6 -5
- package/kb/reference/architecture-overview.md +6 -5
- package/kb/reference/cli-reference.md +19 -2
- package/kb/reference/codex-cli-compatibility.md +1 -0
- package/kb/reference/copilot-compatibility.md +173 -0
- package/kb/reference/enterprise-config-guide.md +28 -2
- package/kb/reference/global-install-model.md +1 -0
- package/kb/reference/hooks-catalog.md +105 -16
- package/kb/reference/opencode-compatibility.md +1 -0
- package/kb/reference/supported-tools-registry.md +10 -5
- package/kb/reference/tool-output-filter.md +288 -0
- package/llms-full.txt +1173 -35
- package/llms.txt +3 -0
- package/manifest.json +9 -6
- package/package.json +3 -2
- package/scripts/benchmark_output_filter.py +343 -0
- package/scripts/check_deps.py +16 -0
- package/scripts/claude_app.py +30 -2
- package/scripts/config_cli.py +4 -4
- package/scripts/config_lock.py +120 -14
- package/scripts/config_merger.py +103 -20
- package/scripts/config_resolver.py +22 -2
- package/scripts/config_validator.py +268 -16
- package/scripts/doctor.py +1 -0
- package/scripts/generate_codex_hooks.py +2 -0
- package/scripts/generate_copilot.py +35 -4
- package/scripts/generate_gemini_hooks.py +33 -10
- package/scripts/generate_opencode_plugin.py +28 -12
- package/scripts/install.py +5 -1
- package/scripts/install_steps/ai_tools.py +101 -2
- package/scripts/install_steps/hooks.py +25 -1
- package/scripts/output_filter_cli.py +347 -0
- package/scripts/output_filter_hook.py +23 -0
- package/scripts/plugin_schema.py +27 -1
- package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
- package/scripts/session_state.py +156 -42
- package/scripts/tool_output_filter/__init__.py +33 -0
- package/scripts/tool_output_filter/contracts.py +173 -0
- package/scripts/tool_output_filter/engine.py +260 -0
- package/scripts/tool_output_filter/hook_runtime.py +369 -0
- package/scripts/tool_output_filter/input.py +56 -0
- package/scripts/tool_output_filter/invariants.py +40 -0
- package/scripts/tool_output_filter/policy.py +153 -0
- package/scripts/tool_output_filter/profiles/__init__.py +68 -0
- package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
- package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
- package/scripts/tool_output_filter/recovery.py +846 -0
- package/scripts/tool_output_filter/telemetry.py +13 -0
- package/scripts/uninstall.py +96 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Install global and project-local AI tool configs."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import json
|
|
4
5
|
import os
|
|
5
6
|
import shutil
|
|
6
7
|
import subprocess
|
|
@@ -14,6 +15,7 @@ from codex_skill_adapter import (
|
|
|
14
15
|
unmanaged_codex_skill_names,
|
|
15
16
|
)
|
|
16
17
|
from mcp_editors import sync_project_mcp_to_editors
|
|
18
|
+
from secure_fs import SecureDestination, run_secure_transaction
|
|
17
19
|
from injection import (
|
|
18
20
|
collapse_blank_runs as _collapse_blank_runs,
|
|
19
21
|
strip_all_sections as _strip_all_sections,
|
|
@@ -21,6 +23,11 @@ from injection import (
|
|
|
21
23
|
)
|
|
22
24
|
|
|
23
25
|
|
|
26
|
+
OUTPUT_FILTER_POLICY_NAME = "ai-toolkit-output-filter.json"
|
|
27
|
+
OUTPUT_FILTER_OWNER_NAME = ".ai-toolkit-output-filter.owner"
|
|
28
|
+
OUTPUT_FILTER_OWNER_MARKER = b"ai-toolkit-output-filter-policy-v1\n"
|
|
29
|
+
|
|
30
|
+
|
|
24
31
|
def install_ai_tools(target_dir: Path, rules_dir: Path,
|
|
25
32
|
dry_run: bool,
|
|
26
33
|
editors: list[str] | None = None,
|
|
@@ -750,6 +757,10 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
|
|
|
750
757
|
print(f" Would inject language rules: {', '.join(language_modules)}")
|
|
751
758
|
if merged_config:
|
|
752
759
|
print(" Would apply merged config from extends")
|
|
760
|
+
if merged_config and merged_config.get("toolOutputFilter") is not None:
|
|
761
|
+
print(" Would write: .claude/ai-toolkit-output-filter.json")
|
|
762
|
+
else:
|
|
763
|
+
print(" Would remove: managed project output-filter policy (if present)")
|
|
753
764
|
return
|
|
754
765
|
|
|
755
766
|
(cwd / ".claude").mkdir(parents=True, exist_ok=True)
|
|
@@ -759,6 +770,12 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
|
|
|
759
770
|
|
|
760
771
|
_create_local_claude_md(cwd, reset)
|
|
761
772
|
_create_local_settings(cwd, reset)
|
|
773
|
+
configured_policy = (
|
|
774
|
+
merged_config.get("toolOutputFilter")
|
|
775
|
+
if merged_config is not None
|
|
776
|
+
else None
|
|
777
|
+
)
|
|
778
|
+
_sync_local_output_filter_policy(cwd, configured_policy)
|
|
762
779
|
|
|
763
780
|
legacy_local_hooks = cwd / ".claude" / "hooks.json"
|
|
764
781
|
if legacy_local_hooks.is_file():
|
|
@@ -832,8 +849,8 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
|
|
|
832
849
|
|
|
833
850
|
# Inject constitution amendments
|
|
834
851
|
amendments = merged.get("constitution", {}).get("amendments", [])
|
|
835
|
-
#
|
|
836
|
-
custom_amendments = [a for a in amendments if a.get("article", 0) >=
|
|
852
|
+
# Articles I-VII are toolkit-owned; inherited custom amendments start at VIII.
|
|
853
|
+
custom_amendments = [a for a in amendments if a.get("article", 0) >= 8]
|
|
837
854
|
if custom_amendments:
|
|
838
855
|
constitution_file = cwd / ".claude" / "constitution.md"
|
|
839
856
|
if constitution_file.is_file():
|
|
@@ -876,6 +893,88 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
|
|
|
876
893
|
print(" Saved: .softspark-toolkit-extends.json (resolution metadata)")
|
|
877
894
|
|
|
878
895
|
|
|
896
|
+
def _sync_local_output_filter_policy(
|
|
897
|
+
cwd: Path,
|
|
898
|
+
configured: dict | None,
|
|
899
|
+
) -> None:
|
|
900
|
+
"""Atomically synchronize the toolkit-owned per-project output policy."""
|
|
901
|
+
policy_path = cwd / ".claude" / OUTPUT_FILTER_POLICY_NAME
|
|
902
|
+
owner_path = cwd / ".claude" / OUTPUT_FILTER_OWNER_NAME
|
|
903
|
+
policy_destination = SecureDestination(
|
|
904
|
+
policy_path, cwd, "project output-filter policy",
|
|
905
|
+
)
|
|
906
|
+
owner_destination = SecureDestination(
|
|
907
|
+
owner_path, cwd, "project output-filter owner marker",
|
|
908
|
+
)
|
|
909
|
+
destinations = [policy_destination, owner_destination]
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
policy_content = (
|
|
913
|
+
_materialize_output_filter_policy(configured)
|
|
914
|
+
if configured is not None
|
|
915
|
+
else None
|
|
916
|
+
)
|
|
917
|
+
except (
|
|
918
|
+
AttributeError,
|
|
919
|
+
json.JSONDecodeError,
|
|
920
|
+
OSError,
|
|
921
|
+
RuntimeError,
|
|
922
|
+
TypeError,
|
|
923
|
+
ValueError,
|
|
924
|
+
) as error:
|
|
925
|
+
print(f" Warning: project output-filter policy not changed: {error}")
|
|
926
|
+
return
|
|
927
|
+
|
|
928
|
+
def mutate(transaction) -> None:
|
|
929
|
+
owner = transaction.initial_content(owner_destination)
|
|
930
|
+
existing_policy = transaction.initial_content(policy_destination)
|
|
931
|
+
if owner is None and existing_policy is not None:
|
|
932
|
+
print(" Kept: user-owned .claude/ai-toolkit-output-filter.json")
|
|
933
|
+
return
|
|
934
|
+
if owner not in (None, OUTPUT_FILTER_OWNER_MARKER):
|
|
935
|
+
print(" Kept: untrusted .claude output-filter ownership marker")
|
|
936
|
+
return
|
|
937
|
+
|
|
938
|
+
if policy_content is None:
|
|
939
|
+
if owner == OUTPUT_FILTER_OWNER_MARKER:
|
|
940
|
+
transaction.unlink(policy_destination)
|
|
941
|
+
transaction.unlink(owner_destination)
|
|
942
|
+
print(" Removed: managed project output-filter policy")
|
|
943
|
+
return
|
|
944
|
+
|
|
945
|
+
transaction.atomic_write(policy_destination, policy_content, 0o600)
|
|
946
|
+
if owner is None:
|
|
947
|
+
transaction.atomic_write(
|
|
948
|
+
owner_destination,
|
|
949
|
+
OUTPUT_FILTER_OWNER_MARKER,
|
|
950
|
+
0o600,
|
|
951
|
+
)
|
|
952
|
+
print(" Wrote: .claude/ai-toolkit-output-filter.json")
|
|
953
|
+
|
|
954
|
+
try:
|
|
955
|
+
run_secure_transaction(destinations, mutate)
|
|
956
|
+
except RuntimeError as error:
|
|
957
|
+
print(f" Warning: project output-filter policy not changed: {error}")
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _materialize_output_filter_policy(configured: dict) -> bytes:
|
|
961
|
+
"""Merge a partial project policy over canonical safe defaults."""
|
|
962
|
+
default_path = app_dir / "output-filter-policy.json"
|
|
963
|
+
with open(default_path, encoding="utf-8") as handle:
|
|
964
|
+
defaults = json.load(handle)
|
|
965
|
+
policy = dict(defaults)
|
|
966
|
+
policy.update(configured)
|
|
967
|
+
recovery = dict(defaults.get("recovery", {}))
|
|
968
|
+
recovery.update(configured.get("recovery", {}))
|
|
969
|
+
policy["recovery"] = recovery
|
|
970
|
+
|
|
971
|
+
from config_validator import validate_project_config
|
|
972
|
+
errors = validate_project_config({"toolOutputFilter": policy})
|
|
973
|
+
if errors:
|
|
974
|
+
raise RuntimeError("invalid output-filter policy: " + "; ".join(errors))
|
|
975
|
+
return (json.dumps(policy, indent=2, sort_keys=True) + "\n").encode()
|
|
976
|
+
|
|
977
|
+
|
|
879
978
|
def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> None:
|
|
880
979
|
"""Install Claude language-rule entrypoints for a project.
|
|
881
980
|
|
|
@@ -61,6 +61,13 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
|
|
|
61
61
|
for runtime_file in sorted(hooks_src.glob("*.json")):
|
|
62
62
|
shutil.copy2(runtime_file, hooks_scripts_dir / runtime_file.name)
|
|
63
63
|
copied += 1
|
|
64
|
+
output_filter_policy = app_dir / "output-filter-policy.json"
|
|
65
|
+
policy_destination = hooks_scripts_dir / output_filter_policy.name
|
|
66
|
+
# The global policy is user configuration: seed it once, never overwrite,
|
|
67
|
+
# so `ai-toolkit update` cannot silently reset an enabled mode to off.
|
|
68
|
+
if output_filter_policy.is_file() and not policy_destination.exists():
|
|
69
|
+
shutil.copy2(output_filter_policy, policy_destination)
|
|
70
|
+
copied += 1
|
|
64
71
|
print(f" Copied: {copied} hook scripts to ~/.softspark/ai-toolkit/hooks/")
|
|
65
72
|
legacy_hooks = claude_dir / "hooks"
|
|
66
73
|
if legacy_hooks.is_symlink():
|
|
@@ -71,6 +78,8 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
|
|
|
71
78
|
# Python helpers that hooks invoke at runtime. Kept narrow on purpose — only
|
|
72
79
|
# scripts that a deployed hook actually executes belong here.
|
|
73
80
|
HOOK_RUNTIME_SCRIPTS: tuple[str, ...] = (
|
|
81
|
+
"output_filter_cli.py",
|
|
82
|
+
"output_filter_hook.py",
|
|
74
83
|
"session_state.py",
|
|
75
84
|
"session_token_stats.py",
|
|
76
85
|
"test_cohesion.py",
|
|
@@ -98,8 +107,23 @@ def _copy_hook_runtime_scripts(scripts_dst: Path) -> None:
|
|
|
98
107
|
shutil.copy2(src, dst)
|
|
99
108
|
dst.chmod(dst.stat().st_mode | 0o111)
|
|
100
109
|
copied += 1
|
|
110
|
+
output_filter_src = scripts_src / "tool_output_filter"
|
|
111
|
+
if output_filter_src.is_dir():
|
|
112
|
+
output_filter_dst = scripts_dst / output_filter_src.name
|
|
113
|
+
# Prune first: dirs_exist_ok alone never removes modules deleted in a
|
|
114
|
+
# newer release, and a stale .py at sys.path[0] would shadow the
|
|
115
|
+
# shipped implementation under `python3 -S`.
|
|
116
|
+
if output_filter_dst.is_dir() and not output_filter_dst.is_symlink():
|
|
117
|
+
shutil.rmtree(output_filter_dst)
|
|
118
|
+
shutil.copytree(
|
|
119
|
+
output_filter_src,
|
|
120
|
+
output_filter_dst,
|
|
121
|
+
dirs_exist_ok=True,
|
|
122
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
|
|
123
|
+
)
|
|
124
|
+
copied += 1
|
|
101
125
|
if copied:
|
|
102
|
-
print(f" Copied: {copied} hook runtime
|
|
126
|
+
print(f" Copied: {copied} hook runtime assets to ~/.softspark/ai-toolkit/scripts/")
|
|
103
127
|
|
|
104
128
|
|
|
105
129
|
def _run_merge_hooks(action: str, *args: str) -> None:
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Manual and hook entry points for native tool-output filtering."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from tool_output_filter.contracts import FilterMode, FilterRequest
|
|
14
|
+
from tool_output_filter.contracts import (
|
|
15
|
+
DEFAULT_MAX_INPUT_BYTES,
|
|
16
|
+
DEFAULT_MIN_SAVINGS_BYTES,
|
|
17
|
+
DEFAULT_MIN_SAVINGS_RATIO,
|
|
18
|
+
)
|
|
19
|
+
from tool_output_filter.engine import filter_output
|
|
20
|
+
from tool_output_filter.hook_runtime import run_hook
|
|
21
|
+
from tool_output_filter.input import read_bounded_text
|
|
22
|
+
from tool_output_filter.policy import OutputFilterPolicy, load_policy
|
|
23
|
+
from tool_output_filter.recovery import (
|
|
24
|
+
EphemeralRecoveryStore,
|
|
25
|
+
clean_owned_repo_recovery,
|
|
26
|
+
clean_session,
|
|
27
|
+
recover_by_handle,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
import argparse
|
|
32
|
+
|
|
33
|
+
_OWNER_MARKER = "ai-toolkit-output-filter-policy-v1"
|
|
34
|
+
_INSPECT_PROFILES = frozenset({"repeat-lines", "tap-success"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _infer_repo_root(start: Path) -> Path | None:
|
|
38
|
+
# realpath keeps the repo key aligned with the bash hooks, which resolve
|
|
39
|
+
# symlinks via `git rev-parse --show-toplevel`.
|
|
40
|
+
candidate_path = Path(
|
|
41
|
+
os.path.realpath(os.path.expanduser(str(start)))
|
|
42
|
+
)
|
|
43
|
+
if not candidate_path.is_dir():
|
|
44
|
+
return None
|
|
45
|
+
for candidate in (candidate_path, *candidate_path.parents):
|
|
46
|
+
git_marker = candidate / ".git"
|
|
47
|
+
if git_marker.is_dir() or git_marker.is_file():
|
|
48
|
+
return candidate
|
|
49
|
+
return candidate_path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _session_base_for_repo(repo_root: Path) -> Path:
|
|
53
|
+
repo_key = "-" + str(repo_root).replace("/", "-").lstrip("-")
|
|
54
|
+
return (
|
|
55
|
+
Path.home()
|
|
56
|
+
/ ".softspark"
|
|
57
|
+
/ "ai-toolkit"
|
|
58
|
+
/ "sessions"
|
|
59
|
+
/ repo_key
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _manual_session_base() -> Path | None:
|
|
64
|
+
project_directory = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
65
|
+
repo_root = _infer_repo_root(
|
|
66
|
+
Path(project_directory) if project_directory else Path.cwd()
|
|
67
|
+
)
|
|
68
|
+
return _session_base_for_repo(repo_root) if repo_root is not None else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_regular_file(path: Path) -> bool:
|
|
72
|
+
return path.is_file() and not path.is_symlink()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_registered_project(project_root: Path) -> bool:
|
|
76
|
+
"""Trust only projects registered via `ai-toolkit install --local`.
|
|
77
|
+
|
|
78
|
+
The owner marker is a public constant, so a cloned repo must never be
|
|
79
|
+
able to self-enable filtering with it. Mirrors filter-tool-output.sh.
|
|
80
|
+
"""
|
|
81
|
+
registry = (
|
|
82
|
+
Path.home() / ".softspark" / "ai-toolkit" / "projects.json"
|
|
83
|
+
)
|
|
84
|
+
if not _is_regular_file(registry):
|
|
85
|
+
return False
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(registry.read_text(encoding="utf-8"))
|
|
88
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
89
|
+
return False
|
|
90
|
+
projects = data.get("projects") if isinstance(data, dict) else None
|
|
91
|
+
if not isinstance(projects, list):
|
|
92
|
+
return False
|
|
93
|
+
root_text = str(project_root)
|
|
94
|
+
return any(
|
|
95
|
+
isinstance(entry, dict) and entry.get("path") == root_text
|
|
96
|
+
for entry in projects
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _trusted_project_policy(project_root: Path) -> Path | None:
|
|
101
|
+
managed_directory = project_root / ".claude"
|
|
102
|
+
if project_root.is_symlink() or managed_directory.is_symlink():
|
|
103
|
+
return None
|
|
104
|
+
if not _is_registered_project(project_root):
|
|
105
|
+
return None
|
|
106
|
+
policy_path = managed_directory / "ai-toolkit-output-filter.json"
|
|
107
|
+
owner_path = managed_directory / ".ai-toolkit-output-filter.owner"
|
|
108
|
+
if not _is_regular_file(policy_path) or not _is_regular_file(owner_path):
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
owner_text = owner_path.read_text(encoding="utf-8")
|
|
112
|
+
except OSError:
|
|
113
|
+
return None
|
|
114
|
+
# The bash hook's $(<file) strips trailing newlines; accept the same.
|
|
115
|
+
if owner_text.rstrip("\n") != _OWNER_MARKER:
|
|
116
|
+
return None
|
|
117
|
+
return policy_path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _resolve_policy_path(explicit_path: Path | None) -> Path | None:
|
|
121
|
+
if explicit_path is not None:
|
|
122
|
+
return explicit_path
|
|
123
|
+
# Same resolution as filter-tool-output.sh: the project directory only,
|
|
124
|
+
# never its parents, so `status` reports what the hook will actually do.
|
|
125
|
+
project_directory = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
126
|
+
project_policy = _trusted_project_policy(Path(project_directory))
|
|
127
|
+
if project_policy is not None:
|
|
128
|
+
return project_policy
|
|
129
|
+
global_policy = (
|
|
130
|
+
Path.home()
|
|
131
|
+
/ ".softspark"
|
|
132
|
+
/ "ai-toolkit"
|
|
133
|
+
/ "hooks"
|
|
134
|
+
/ "output-filter-policy.json"
|
|
135
|
+
)
|
|
136
|
+
return global_policy if _is_regular_file(global_policy) else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _default_policy() -> OutputFilterPolicy:
|
|
140
|
+
return OutputFilterPolicy(
|
|
141
|
+
mode=FilterMode.OFF,
|
|
142
|
+
profiles=("repeat-lines", "tap-success"),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _run_status(policy_path: Path | None) -> int:
|
|
147
|
+
resolved_path = _resolve_policy_path(policy_path)
|
|
148
|
+
if resolved_path is None:
|
|
149
|
+
policy = _default_policy()
|
|
150
|
+
else:
|
|
151
|
+
try:
|
|
152
|
+
policy = load_policy(resolved_path)
|
|
153
|
+
except (OSError, TypeError, ValueError, KeyError):
|
|
154
|
+
return 2
|
|
155
|
+
output = {
|
|
156
|
+
"mode": policy.mode.value,
|
|
157
|
+
"profiles": list(policy.profiles),
|
|
158
|
+
"maxInputBytes": policy.max_input_bytes,
|
|
159
|
+
"minSavingsBytes": policy.min_savings_bytes,
|
|
160
|
+
"minSavingsRatio": policy.min_savings_ratio,
|
|
161
|
+
"recovery": {
|
|
162
|
+
"mode": "ephemeral",
|
|
163
|
+
"ttlMinutes": policy.ttl_minutes,
|
|
164
|
+
"maxSessionBytes": policy.max_session_bytes,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
print(json.dumps(output, ensure_ascii=False, separators=(",", ":")))
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _run_inspect(args: argparse.Namespace) -> int:
|
|
172
|
+
if not _valid_inspect_arguments(args):
|
|
173
|
+
return 2
|
|
174
|
+
try:
|
|
175
|
+
raw_output = read_bounded_text(
|
|
176
|
+
sys.stdin.buffer,
|
|
177
|
+
max_bytes=args.max_input_bytes,
|
|
178
|
+
)
|
|
179
|
+
except (TypeError, UnicodeDecodeError, ValueError):
|
|
180
|
+
return 2
|
|
181
|
+
if raw_output is None:
|
|
182
|
+
report = {
|
|
183
|
+
"profile": args.profile,
|
|
184
|
+
"eligible": False,
|
|
185
|
+
"outcome": "passthrough",
|
|
186
|
+
"fallbackReason": "input-too-large",
|
|
187
|
+
"inputBytesAtLeast": args.max_input_bytes + 1,
|
|
188
|
+
"maxInputBytes": args.max_input_bytes,
|
|
189
|
+
}
|
|
190
|
+
print(json.dumps(report, separators=(",", ":")))
|
|
191
|
+
return 0
|
|
192
|
+
request = FilterRequest(
|
|
193
|
+
output=raw_output,
|
|
194
|
+
mode=FilterMode.OBSERVE,
|
|
195
|
+
profile_id=args.profile,
|
|
196
|
+
max_input_bytes=args.max_input_bytes,
|
|
197
|
+
min_savings_bytes=args.min_savings_bytes,
|
|
198
|
+
min_savings_ratio=args.min_savings_ratio,
|
|
199
|
+
)
|
|
200
|
+
result = filter_output(request)
|
|
201
|
+
telemetry = result.telemetry
|
|
202
|
+
input_bytes = len(raw_output.encode("utf-8"))
|
|
203
|
+
report = {
|
|
204
|
+
"profile": args.profile,
|
|
205
|
+
"eligible": result.outcome == "observed",
|
|
206
|
+
"outcome": result.outcome,
|
|
207
|
+
"fallbackReason": result.fallback_reason,
|
|
208
|
+
"inputBytes": input_bytes,
|
|
209
|
+
"candidateBytes": (
|
|
210
|
+
telemetry.output_bytes if telemetry is not None else input_bytes
|
|
211
|
+
),
|
|
212
|
+
"inputLines": len(raw_output.splitlines()),
|
|
213
|
+
"candidateLines": (
|
|
214
|
+
telemetry.output_lines
|
|
215
|
+
if telemetry is not None
|
|
216
|
+
else len(raw_output.splitlines())
|
|
217
|
+
),
|
|
218
|
+
}
|
|
219
|
+
print(json.dumps(report, ensure_ascii=False, separators=(",", ":")))
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _valid_inspect_arguments(args: argparse.Namespace) -> bool:
|
|
224
|
+
"""Reject unsafe manual limits before consuming stdin."""
|
|
225
|
+
return (
|
|
226
|
+
args.profile in _INSPECT_PROFILES
|
|
227
|
+
and 1 <= args.max_input_bytes <= DEFAULT_MAX_INPUT_BYTES
|
|
228
|
+
and 0 <= args.min_savings_bytes <= DEFAULT_MAX_INPUT_BYTES
|
|
229
|
+
and math.isfinite(args.min_savings_ratio)
|
|
230
|
+
and 0.0 <= args.min_savings_ratio <= 1.0
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _run_recover(args: argparse.Namespace) -> int:
|
|
235
|
+
base_directory = args.base_directory or _manual_session_base()
|
|
236
|
+
if base_directory is None:
|
|
237
|
+
return 2
|
|
238
|
+
try:
|
|
239
|
+
if args.session_id:
|
|
240
|
+
with EphemeralRecoveryStore(
|
|
241
|
+
base_directory,
|
|
242
|
+
session_identifier=args.session_id,
|
|
243
|
+
) as recovery:
|
|
244
|
+
response = recovery.load(args.handle)
|
|
245
|
+
else:
|
|
246
|
+
response = recover_by_handle(base_directory, args.handle)
|
|
247
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
248
|
+
return 2
|
|
249
|
+
if response is None:
|
|
250
|
+
return 1
|
|
251
|
+
print(json.dumps(response, ensure_ascii=False, separators=(",", ":")))
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _run_clean(args: argparse.Namespace) -> int:
|
|
256
|
+
base_directory = args.base_directory or _manual_session_base()
|
|
257
|
+
if base_directory is None:
|
|
258
|
+
return 2
|
|
259
|
+
try:
|
|
260
|
+
if args.session_id:
|
|
261
|
+
if args.expired:
|
|
262
|
+
with EphemeralRecoveryStore(
|
|
263
|
+
base_directory,
|
|
264
|
+
session_identifier=args.session_id,
|
|
265
|
+
) as recovery:
|
|
266
|
+
removed = recovery.clean_expired()
|
|
267
|
+
scope = "expired"
|
|
268
|
+
else:
|
|
269
|
+
removed = clean_session(
|
|
270
|
+
base_directory,
|
|
271
|
+
args.session_id,
|
|
272
|
+
)
|
|
273
|
+
scope = "all"
|
|
274
|
+
elif args.expired:
|
|
275
|
+
return 2
|
|
276
|
+
else:
|
|
277
|
+
removed = clean_owned_repo_recovery(base_directory)
|
|
278
|
+
scope = "all"
|
|
279
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
280
|
+
return 2
|
|
281
|
+
print(json.dumps({"removed": removed, "scope": scope}, separators=(",", ":")))
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
286
|
+
import argparse
|
|
287
|
+
|
|
288
|
+
parser = argparse.ArgumentParser(
|
|
289
|
+
description="Inspect and safely filter supported tool output"
|
|
290
|
+
)
|
|
291
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
292
|
+
hook = subparsers.add_parser("hook")
|
|
293
|
+
hook.add_argument("--policy", type=Path, required=True)
|
|
294
|
+
status = subparsers.add_parser("status")
|
|
295
|
+
status.add_argument("--policy", type=Path)
|
|
296
|
+
inspect = subparsers.add_parser("inspect")
|
|
297
|
+
inspect.add_argument("--profile", required=True)
|
|
298
|
+
inspect.add_argument(
|
|
299
|
+
"--max-input-bytes",
|
|
300
|
+
type=int,
|
|
301
|
+
default=DEFAULT_MAX_INPUT_BYTES,
|
|
302
|
+
)
|
|
303
|
+
inspect.add_argument(
|
|
304
|
+
"--min-savings-bytes",
|
|
305
|
+
type=int,
|
|
306
|
+
default=DEFAULT_MIN_SAVINGS_BYTES,
|
|
307
|
+
)
|
|
308
|
+
inspect.add_argument(
|
|
309
|
+
"--min-savings-ratio",
|
|
310
|
+
type=float,
|
|
311
|
+
default=DEFAULT_MIN_SAVINGS_RATIO,
|
|
312
|
+
)
|
|
313
|
+
recover = subparsers.add_parser("recover")
|
|
314
|
+
recover.add_argument("handle")
|
|
315
|
+
recover.add_argument("--base-directory", type=Path)
|
|
316
|
+
recover.add_argument("--session-id")
|
|
317
|
+
clean = subparsers.add_parser("clean")
|
|
318
|
+
clean.add_argument("--base-directory", type=Path)
|
|
319
|
+
clean.add_argument("--session-id")
|
|
320
|
+
clean.add_argument("--expired", action="store_true")
|
|
321
|
+
return parser
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def main(argv: list[str] | None = None) -> int:
|
|
325
|
+
arguments = sys.argv[1:] if argv is None else argv
|
|
326
|
+
if (
|
|
327
|
+
len(arguments) == 3
|
|
328
|
+
and arguments[0] == "hook"
|
|
329
|
+
and arguments[1] == "--policy"
|
|
330
|
+
):
|
|
331
|
+
return run_hook(Path(arguments[2]))
|
|
332
|
+
args = _build_parser().parse_args(arguments)
|
|
333
|
+
if args.command == "hook":
|
|
334
|
+
return run_hook(args.policy)
|
|
335
|
+
if args.command == "status":
|
|
336
|
+
return _run_status(args.policy)
|
|
337
|
+
if args.command == "inspect":
|
|
338
|
+
return _run_inspect(args)
|
|
339
|
+
if args.command == "recover":
|
|
340
|
+
return _run_recover(args)
|
|
341
|
+
if args.command == "clean":
|
|
342
|
+
return _run_clean(args)
|
|
343
|
+
return 2
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
if __name__ == "__main__":
|
|
347
|
+
sys.exit(main())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lean process entry point for the Claude output-filter hook."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from tool_output_filter.hook_runtime import run_hook
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> int:
|
|
12
|
+
arguments = sys.argv[1:] if argv is None else argv
|
|
13
|
+
if (
|
|
14
|
+
len(arguments) != 3
|
|
15
|
+
or arguments[0] != "hook"
|
|
16
|
+
or arguments[1] != "--policy"
|
|
17
|
+
):
|
|
18
|
+
return 2
|
|
19
|
+
return run_hook(arguments[2])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
sys.exit(main())
|
package/scripts/plugin_schema.py
CHANGED
|
@@ -11,7 +11,14 @@ from pathlib import Path
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
# Required top-level fields
|
|
14
|
-
REQUIRED_FIELDS = (
|
|
14
|
+
REQUIRED_FIELDS = (
|
|
15
|
+
"name",
|
|
16
|
+
"description",
|
|
17
|
+
"version",
|
|
18
|
+
"domain",
|
|
19
|
+
"type",
|
|
20
|
+
"status",
|
|
21
|
+
)
|
|
15
22
|
|
|
16
23
|
# Valid status values
|
|
17
24
|
VALID_STATUSES = frozenset({"stable", "experimental", "deprecated"})
|
|
@@ -39,6 +46,24 @@ VALID_HOOK_EVENTS = frozenset({
|
|
|
39
46
|
})
|
|
40
47
|
|
|
41
48
|
|
|
49
|
+
def _validate_requires(data: dict) -> list[str]:
|
|
50
|
+
if "requires" not in data:
|
|
51
|
+
return ["Missing required field: requires"]
|
|
52
|
+
|
|
53
|
+
requires = data["requires"]
|
|
54
|
+
if not isinstance(requires, dict) or not requires:
|
|
55
|
+
return ["'requires' must be a non-empty dictionary"]
|
|
56
|
+
|
|
57
|
+
errors: list[str] = []
|
|
58
|
+
for dependency, constraint in requires.items():
|
|
59
|
+
if not isinstance(dependency, str) or not dependency.strip():
|
|
60
|
+
errors.append("requires keys must be non-empty strings")
|
|
61
|
+
continue
|
|
62
|
+
if not isinstance(constraint, str) or not constraint.strip():
|
|
63
|
+
errors.append(f"requires.{dependency} must be a non-empty string")
|
|
64
|
+
return errors
|
|
65
|
+
|
|
66
|
+
|
|
42
67
|
def validate_manifest(data: dict, pack_dir: Path | None = None) -> list[str]:
|
|
43
68
|
"""Validate a plugin manifest dict.
|
|
44
69
|
|
|
@@ -50,6 +75,7 @@ def validate_manifest(data: dict, pack_dir: Path | None = None) -> list[str]:
|
|
|
50
75
|
for field in REQUIRED_FIELDS:
|
|
51
76
|
if field not in data or not data[field]:
|
|
52
77
|
errors.append(f"Missing required field: {field}")
|
|
78
|
+
errors.extend(_validate_requires(data))
|
|
53
79
|
|
|
54
80
|
# Validate status
|
|
55
81
|
status = data.get("status", "")
|