@ictechgy/context-guard 0.4.16 → 0.5.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 +72 -0
- package/README.ko.md +82 -1
- package/README.md +86 -1
- package/docs/distribution.md +100 -0
- package/package.json +4 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +25 -1
- package/plugins/context-guard/README.md +26 -1
- package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
- package/plugins/context-guard/bin/context-guard-bench +4216 -103
- package/plugins/context-guard/bin/context-guard-failed-nudge +95 -23
- package/plugins/context-guard/bin/context-guard-guard-read +6 -2
- package/plugins/context-guard/bin/context-guard-mcp +2 -1
- package/plugins/context-guard/bin/context-guard-pack +1463 -142
- package/plugins/context-guard/bin/context-guard-rewrite-bash +497 -45
- package/plugins/context-guard/bin/context-guard-sanitize-output +178 -22
- package/plugins/context-guard/bin/context-guard-setup +812 -16
- package/plugins/context-guard/bin/context-guard-statusline +33 -2
- package/plugins/context-guard/bin/context-guard-statusline-merged +71 -20
- package/plugins/context-guard/bin/context-guard-trim-output +706 -35
- package/plugins/context-guard/lib/context_guard_commands.py +4 -0
- package/plugins/context-guard/lib/credential_policy.py +10 -2
|
@@ -13,6 +13,7 @@ import datetime as _dt
|
|
|
13
13
|
import hashlib
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
|
+
import pwd
|
|
16
17
|
import re
|
|
17
18
|
import selectors
|
|
18
19
|
import shlex
|
|
@@ -22,6 +23,7 @@ import stat
|
|
|
22
23
|
import subprocess
|
|
23
24
|
import sys
|
|
24
25
|
import time
|
|
26
|
+
import types
|
|
25
27
|
import uuid
|
|
26
28
|
from dataclasses import dataclass
|
|
27
29
|
from pathlib import Path
|
|
@@ -61,10 +63,21 @@ PRODUCT_OWNED_ENV_READ_DENIES = frozenset({
|
|
|
61
63
|
"Read(./.env.*)",
|
|
62
64
|
})
|
|
63
65
|
HELPER_STATUSLINE = "context-guard-statusline-merged"
|
|
66
|
+
HELPER_STATUSLINE_PLAIN = "context-guard-statusline"
|
|
64
67
|
HELPER_REWRITE_BASH = "context-guard-rewrite-bash"
|
|
65
68
|
HELPER_GUARD_READ = "context-guard-guard-read"
|
|
66
69
|
HELPER_FAILED_NUDGE = "context-guard-failed-nudge"
|
|
67
70
|
HELPER_DIET = "context-guard-diet"
|
|
71
|
+
ROOT_PACKAGE_NAME = "@ictechgy/context-guard"
|
|
72
|
+
RECEIPT_PACKAGE_NAME = "@ictechgy/context-guard-receipt"
|
|
73
|
+
_BASH_REFERENCE_UNAVAILABLE = (
|
|
74
|
+
"bash_reference_v1 reference unavailable"
|
|
75
|
+
)
|
|
76
|
+
_BASH_REFERENCE_RECOVERY = (
|
|
77
|
+
"repair or reinstall the exact paired npm packages in the target project, "
|
|
78
|
+
"ensure a trusted system Node interpreter is available, then rerun setup"
|
|
79
|
+
)
|
|
80
|
+
BASH_REFERENCE_POLICY_MAX_BYTES = 512 * 1024
|
|
68
81
|
HELPER_EQUIVALENT_BASENAMES = {
|
|
69
82
|
"context-guard-rewrite-bash": {
|
|
70
83
|
"context-guard-rewrite-bash",
|
|
@@ -99,6 +112,33 @@ DEFAULT_POST_SETUP_SCAN_TOP = 5
|
|
|
99
112
|
POST_SETUP_SCAN_TIMEOUT_SECONDS = 20
|
|
100
113
|
PATH_HELPER_PROBE_TIMEOUT_SECONDS = 5
|
|
101
114
|
PATH_HELPER_PROBE_MAX_OUTPUT_BYTES = 4096
|
|
115
|
+
ISOLATED_RUNTIME_PATH = os.defpath
|
|
116
|
+
READ_GUARD_BEHAVIOR_ENV = (
|
|
117
|
+
"CONTEXT_GUARD_READ_GUARD",
|
|
118
|
+
"CLAUDE_TOKEN_READ_GUARD",
|
|
119
|
+
"CONTEXT_GUARD_READ_GUARD_MAX_BYTES",
|
|
120
|
+
"CLAUDE_TOKEN_READ_GUARD_MAX_BYTES",
|
|
121
|
+
"CONTEXT_GUARD_READ_GUARD_MAX_LINES",
|
|
122
|
+
"CLAUDE_TOKEN_READ_GUARD_MAX_LINES",
|
|
123
|
+
"CONTEXT_GUARD_READ_GUARD_PROOF_BYTES",
|
|
124
|
+
"CLAUDE_TOKEN_READ_GUARD_PROOF_BYTES",
|
|
125
|
+
)
|
|
126
|
+
REWRITE_BEHAVIOR_ENV = (
|
|
127
|
+
"CONTEXT_GUARD_SANITIZER_FAIL_OPEN",
|
|
128
|
+
"CLAUDE_TOKEN_SANITIZER_FAIL_OPEN",
|
|
129
|
+
)
|
|
130
|
+
STATUSLINE_BEHAVIOR_ENV = (
|
|
131
|
+
"CONTEXT_GUARD_STATUSLINE_INPUT_MAX_BYTES",
|
|
132
|
+
"CLAUDE_TOKEN_STATUSLINE_INPUT_MAX_BYTES",
|
|
133
|
+
"CONTEXT_GUARD_STATUSLINE_CTX_WARN",
|
|
134
|
+
"CLAUDE_TOKEN_STATUSLINE_CTX_WARN",
|
|
135
|
+
"CONTEXT_GUARD_STATUSLINE_CACHE_TTL_SECONDS",
|
|
136
|
+
)
|
|
137
|
+
HOMEBREW_NODE_CANDIDATES = (
|
|
138
|
+
Path("/opt/homebrew/bin/node"),
|
|
139
|
+
Path("/usr/local/bin/node"),
|
|
140
|
+
)
|
|
141
|
+
BEHAVIOR_ENV_VALUE_RE = re.compile(r"^[A-Za-z0-9.+_-]{1,64}$")
|
|
102
142
|
PRIVATE_DIR_MODE = stat.S_IRWXU
|
|
103
143
|
ALLOWED_FIRST_ABSOLUTE_SYMLINKS = {
|
|
104
144
|
"tmp": Path("/private/tmp"),
|
|
@@ -111,6 +151,8 @@ class Choices:
|
|
|
111
151
|
denies: bool = True
|
|
112
152
|
statusline: bool = True
|
|
113
153
|
bash_hook: bool = True
|
|
154
|
+
# Provider-visible receipt handles are an explicit, default-off choice.
|
|
155
|
+
bash_reference_v1: bool = False
|
|
114
156
|
read_guard: bool = True
|
|
115
157
|
model_defaults: bool = True
|
|
116
158
|
# 동일 Bash 명령이 두 번 연속 실패하면 /clear 권유 — recommended setup 기본 ON.
|
|
@@ -1835,9 +1877,11 @@ def _path_has_symlink_component(path: Path) -> bool:
|
|
|
1835
1877
|
|
|
1836
1878
|
|
|
1837
1879
|
def _probe_path_helper_identity(path: Path, helper_name: str) -> None:
|
|
1838
|
-
env = os.environ.copy()
|
|
1839
1880
|
system_path = os.pathsep.join(part for part in ("/usr/bin", "/bin", "/usr/sbin", "/sbin") if Path(part).is_dir())
|
|
1840
|
-
env
|
|
1881
|
+
env = {
|
|
1882
|
+
"LC_ALL": "C",
|
|
1883
|
+
"PATH": str(path.parent) + (os.pathsep + system_path if system_path else ""),
|
|
1884
|
+
}
|
|
1841
1885
|
try:
|
|
1842
1886
|
proc = subprocess.Popen(
|
|
1843
1887
|
[str(path), "--help"],
|
|
@@ -1974,28 +2018,440 @@ def helper_command(helper_name: str, kit_script: str, *, shell: str | None = Non
|
|
|
1974
2018
|
return shlex.join(argv)
|
|
1975
2019
|
|
|
1976
2020
|
|
|
2021
|
+
def _validated_runtime_executable(raw: str | Path, *, label: str) -> Path:
|
|
2022
|
+
"""Bind a runtime/helper to the canonical executable seen during setup."""
|
|
2023
|
+
candidate = Path(raw)
|
|
2024
|
+
if not candidate.is_absolute():
|
|
2025
|
+
raise SystemExit(f"{label} did not resolve to an absolute path")
|
|
2026
|
+
try:
|
|
2027
|
+
canonical = candidate.resolve(strict=True)
|
|
2028
|
+
metadata = canonical.stat()
|
|
2029
|
+
except OSError as exc:
|
|
2030
|
+
raise SystemExit(
|
|
2031
|
+
f"{label} could not be canonicalized: {exc.strerror or exc.__class__.__name__}"
|
|
2032
|
+
) from exc
|
|
2033
|
+
if not stat.S_ISREG(metadata.st_mode) or not os.access(canonical, os.X_OK):
|
|
2034
|
+
raise SystemExit(f"{label} must be an executable regular file")
|
|
2035
|
+
return canonical
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
def _approved_python_runtime() -> Path:
|
|
2039
|
+
if not sys.executable:
|
|
2040
|
+
raise SystemExit("Python runtime identity is unavailable")
|
|
2041
|
+
return _validated_runtime_executable(sys.executable, label="Python runtime")
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
def _approved_system_runtime(name: str) -> Path:
|
|
2045
|
+
found = shutil.which(name, path=ISOLATED_RUNTIME_PATH)
|
|
2046
|
+
if not found:
|
|
2047
|
+
raise SystemExit(f"Required {name!r} runtime was not found in the fixed system path")
|
|
2048
|
+
return _validated_runtime_executable(found, label=f"{name} runtime")
|
|
2049
|
+
|
|
2050
|
+
|
|
2051
|
+
def _isolated_runtime_prefix(
|
|
2052
|
+
preserve_env_names: tuple[str, ...] = (),
|
|
2053
|
+
*,
|
|
2054
|
+
fixed_env: dict[str, str] | None = None,
|
|
2055
|
+
) -> list[str]:
|
|
2056
|
+
prefix = [
|
|
2057
|
+
str(_approved_system_runtime("env")),
|
|
2058
|
+
"-i",
|
|
2059
|
+
f"PATH={ISOLATED_RUNTIME_PATH}",
|
|
2060
|
+
"LC_ALL=C",
|
|
2061
|
+
]
|
|
2062
|
+
for name in preserve_env_names:
|
|
2063
|
+
value = os.environ.get(name)
|
|
2064
|
+
if value is not None and BEHAVIOR_ENV_VALUE_RE.fullmatch(value):
|
|
2065
|
+
prefix.append(f"{name}={value}")
|
|
2066
|
+
for name, value in (fixed_env or {}).items():
|
|
2067
|
+
if name in {"HOME"} and value and "\x00" not in value:
|
|
2068
|
+
prefix.append(f"{name}={value}")
|
|
2069
|
+
return prefix
|
|
2070
|
+
|
|
2071
|
+
|
|
2072
|
+
def _helper_path_from_argv(argv: list[str], *, label: str) -> Path:
|
|
2073
|
+
if not argv:
|
|
2074
|
+
raise SystemExit(f"{label} helper argv is empty")
|
|
2075
|
+
return _validated_runtime_executable(argv[-1], label=label)
|
|
2076
|
+
|
|
2077
|
+
|
|
2078
|
+
def _bundled_helper_candidates(helper_name: str, kit_script: str) -> set[Path]:
|
|
2079
|
+
script_dir = Path(__file__).resolve().parent
|
|
2080
|
+
raw_candidates = (
|
|
2081
|
+
script_dir / helper_name,
|
|
2082
|
+
script_dir.parent / "plugins" / "context-guard" / "bin" / helper_name,
|
|
2083
|
+
script_dir / kit_script,
|
|
2084
|
+
)
|
|
2085
|
+
candidates: set[Path] = set()
|
|
2086
|
+
for candidate in raw_candidates:
|
|
2087
|
+
try:
|
|
2088
|
+
candidates.add(candidate.resolve(strict=True))
|
|
2089
|
+
except OSError:
|
|
2090
|
+
continue
|
|
2091
|
+
return candidates
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
def automatic_helper_argv(
|
|
2095
|
+
helper_name: str,
|
|
2096
|
+
kit_script: str,
|
|
2097
|
+
*,
|
|
2098
|
+
shell: str | None = None,
|
|
2099
|
+
allow_path_fallback: bool = False,
|
|
2100
|
+
preserve_env_names: tuple[str, ...] = (),
|
|
2101
|
+
fixed_env: dict[str, str] | None = None,
|
|
2102
|
+
) -> list[str]:
|
|
2103
|
+
"""Build installed hook argv with isolated, setup-pinned runtimes."""
|
|
2104
|
+
resolved = helper_argv(
|
|
2105
|
+
helper_name,
|
|
2106
|
+
kit_script,
|
|
2107
|
+
shell=shell,
|
|
2108
|
+
allow_path_fallback=allow_path_fallback,
|
|
2109
|
+
)
|
|
2110
|
+
helper_path = _helper_path_from_argv(resolved, label=helper_name)
|
|
2111
|
+
prefix = _isolated_runtime_prefix(
|
|
2112
|
+
preserve_env_names,
|
|
2113
|
+
fixed_env=fixed_env,
|
|
2114
|
+
)
|
|
2115
|
+
if helper_path not in _bundled_helper_candidates(helper_name, kit_script):
|
|
2116
|
+
# An explicit PATH fallback may be a native executable. Its absolute
|
|
2117
|
+
# identity was already validated by validate_path_helper_fallback().
|
|
2118
|
+
return [*prefix, str(helper_path)]
|
|
2119
|
+
if shell:
|
|
2120
|
+
shell_runtime = _approved_system_runtime(shell)
|
|
2121
|
+
shell_flags = ["--noprofile", "--norc"] if shell_runtime.name == "bash" else []
|
|
2122
|
+
return [*prefix, str(shell_runtime), *shell_flags, str(helper_path)]
|
|
2123
|
+
return [*prefix, str(_approved_python_runtime()), "-I", str(helper_path)]
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
def automatic_helper_command(
|
|
2127
|
+
helper_name: str,
|
|
2128
|
+
kit_script: str,
|
|
2129
|
+
*,
|
|
2130
|
+
shell: str | None = None,
|
|
2131
|
+
allow_path_fallback: bool = False,
|
|
2132
|
+
preserve_env_names: tuple[str, ...] = (),
|
|
2133
|
+
fixed_env: dict[str, str] | None = None,
|
|
2134
|
+
) -> str:
|
|
2135
|
+
return shlex.join(
|
|
2136
|
+
automatic_helper_argv(
|
|
2137
|
+
helper_name,
|
|
2138
|
+
kit_script,
|
|
2139
|
+
shell=shell,
|
|
2140
|
+
allow_path_fallback=allow_path_fallback,
|
|
2141
|
+
preserve_env_names=preserve_env_names,
|
|
2142
|
+
fixed_env=fixed_env,
|
|
2143
|
+
)
|
|
2144
|
+
)
|
|
2145
|
+
|
|
2146
|
+
|
|
2147
|
+
def _secure_owned_regular_path(path: Path, *, executable: bool) -> Path | None:
|
|
2148
|
+
"""Validate an approval path and every parent without following symlinks."""
|
|
2149
|
+
try:
|
|
2150
|
+
canonical = path.resolve(strict=True)
|
|
2151
|
+
except OSError:
|
|
2152
|
+
return None
|
|
2153
|
+
if not path.is_absolute() or canonical != path:
|
|
2154
|
+
return None
|
|
2155
|
+
allowed_owners = {0, os.geteuid()}
|
|
2156
|
+
current = Path(path.anchor)
|
|
2157
|
+
components = path.parts[1:]
|
|
2158
|
+
if not components:
|
|
2159
|
+
return None
|
|
2160
|
+
try:
|
|
2161
|
+
root_metadata = os.lstat(current)
|
|
2162
|
+
if (
|
|
2163
|
+
stat.S_ISLNK(root_metadata.st_mode)
|
|
2164
|
+
or not stat.S_ISDIR(root_metadata.st_mode)
|
|
2165
|
+
or root_metadata.st_uid not in allowed_owners
|
|
2166
|
+
or stat.S_IMODE(root_metadata.st_mode) & 0o022
|
|
2167
|
+
):
|
|
2168
|
+
return None
|
|
2169
|
+
for index, component in enumerate(components):
|
|
2170
|
+
current = current / component
|
|
2171
|
+
metadata = os.lstat(current)
|
|
2172
|
+
is_leaf = index == len(components) - 1
|
|
2173
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
2174
|
+
return None
|
|
2175
|
+
if metadata.st_uid not in allowed_owners or stat.S_IMODE(metadata.st_mode) & 0o022:
|
|
2176
|
+
return None
|
|
2177
|
+
if is_leaf:
|
|
2178
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
2179
|
+
return None
|
|
2180
|
+
if executable and not os.access(current, os.X_OK):
|
|
2181
|
+
return None
|
|
2182
|
+
elif not stat.S_ISDIR(metadata.st_mode):
|
|
2183
|
+
return None
|
|
2184
|
+
except OSError:
|
|
2185
|
+
return None
|
|
2186
|
+
return canonical
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
def _secure_owned_directory_path(path: Path) -> Path | None:
|
|
2190
|
+
try:
|
|
2191
|
+
canonical = path.resolve(strict=True)
|
|
2192
|
+
except OSError:
|
|
2193
|
+
return None
|
|
2194
|
+
if not path.is_absolute() or canonical != path:
|
|
2195
|
+
return None
|
|
2196
|
+
allowed_owners = {0, os.geteuid()}
|
|
2197
|
+
current = Path(path.anchor)
|
|
2198
|
+
try:
|
|
2199
|
+
root_metadata = os.lstat(current)
|
|
2200
|
+
if (
|
|
2201
|
+
stat.S_ISLNK(root_metadata.st_mode)
|
|
2202
|
+
or not stat.S_ISDIR(root_metadata.st_mode)
|
|
2203
|
+
or root_metadata.st_uid not in allowed_owners
|
|
2204
|
+
or stat.S_IMODE(root_metadata.st_mode) & 0o022
|
|
2205
|
+
):
|
|
2206
|
+
return None
|
|
2207
|
+
for component in path.parts[1:]:
|
|
2208
|
+
current = current / component
|
|
2209
|
+
metadata = os.lstat(current)
|
|
2210
|
+
if (
|
|
2211
|
+
stat.S_ISLNK(metadata.st_mode)
|
|
2212
|
+
or not stat.S_ISDIR(metadata.st_mode)
|
|
2213
|
+
or metadata.st_uid not in allowed_owners
|
|
2214
|
+
or stat.S_IMODE(metadata.st_mode) & 0o022
|
|
2215
|
+
):
|
|
2216
|
+
return None
|
|
2217
|
+
except OSError:
|
|
2218
|
+
return None
|
|
2219
|
+
return canonical
|
|
2220
|
+
|
|
2221
|
+
|
|
2222
|
+
def _approved_node_runtime() -> Path | None:
|
|
2223
|
+
system_node = shutil.which("node", path=ISOLATED_RUNTIME_PATH)
|
|
2224
|
+
if system_node:
|
|
2225
|
+
approved = _secure_owned_regular_path(Path(system_node), executable=True)
|
|
2226
|
+
if approved is not None:
|
|
2227
|
+
return approved
|
|
2228
|
+
|
|
2229
|
+
allowed_owners = {0, os.geteuid()}
|
|
2230
|
+
for candidate in HOMEBREW_NODE_CANDIDATES:
|
|
2231
|
+
if len(candidate.parents) < 2:
|
|
2232
|
+
continue
|
|
2233
|
+
allowed_root = candidate.parents[1]
|
|
2234
|
+
if _secure_owned_directory_path(allowed_root) is None:
|
|
2235
|
+
continue
|
|
2236
|
+
if _secure_owned_directory_path(candidate.parent) is None:
|
|
2237
|
+
continue
|
|
2238
|
+
try:
|
|
2239
|
+
link_metadata = os.lstat(candidate)
|
|
2240
|
+
physical_target = candidate.resolve(strict=True)
|
|
2241
|
+
physical_target.relative_to(allowed_root)
|
|
2242
|
+
except (OSError, ValueError):
|
|
2243
|
+
continue
|
|
2244
|
+
if link_metadata.st_uid not in allowed_owners:
|
|
2245
|
+
continue
|
|
2246
|
+
approved = _secure_owned_regular_path(physical_target, executable=True)
|
|
2247
|
+
if approved is not None:
|
|
2248
|
+
return approved
|
|
2249
|
+
return None
|
|
2250
|
+
|
|
2251
|
+
|
|
2252
|
+
def _approved_default_omc_hud() -> tuple[Path, Path] | None:
|
|
2253
|
+
"""Approve only the effective user's default OMC HUD and fixed-path Node."""
|
|
2254
|
+
try:
|
|
2255
|
+
passwd_home = Path(pwd.getpwuid(os.geteuid()).pw_dir)
|
|
2256
|
+
canonical_home = passwd_home.resolve(strict=True)
|
|
2257
|
+
except (KeyError, OSError, RuntimeError):
|
|
2258
|
+
return None
|
|
2259
|
+
if not passwd_home.is_absolute() or canonical_home != passwd_home:
|
|
2260
|
+
return None
|
|
2261
|
+
omc_script = _secure_owned_regular_path(
|
|
2262
|
+
canonical_home / ".claude" / "hud" / "omc-hud.mjs",
|
|
2263
|
+
executable=False,
|
|
2264
|
+
)
|
|
2265
|
+
if omc_script is None:
|
|
2266
|
+
return None
|
|
2267
|
+
node_runtime = _approved_node_runtime()
|
|
2268
|
+
if node_runtime is None:
|
|
2269
|
+
return None
|
|
2270
|
+
return node_runtime, omc_script
|
|
2271
|
+
|
|
2272
|
+
|
|
2273
|
+
def _statusline_setting(*, allow_path_fallback: bool = False) -> tuple[dict[str, str], bool]:
|
|
2274
|
+
approved_omc = _approved_default_omc_hud()
|
|
2275
|
+
fixed_env = (
|
|
2276
|
+
{"HOME": str(approved_omc[1].parents[2])}
|
|
2277
|
+
if approved_omc is not None
|
|
2278
|
+
else None
|
|
2279
|
+
)
|
|
2280
|
+
argv = automatic_helper_argv(
|
|
2281
|
+
HELPER_STATUSLINE,
|
|
2282
|
+
"statusline_merged.sh",
|
|
2283
|
+
shell="bash",
|
|
2284
|
+
allow_path_fallback=allow_path_fallback,
|
|
2285
|
+
preserve_env_names=STATUSLINE_BEHAVIOR_ENV,
|
|
2286
|
+
fixed_env=fixed_env,
|
|
2287
|
+
)
|
|
2288
|
+
token_path = _helper_path_from_argv(
|
|
2289
|
+
helper_argv(
|
|
2290
|
+
HELPER_STATUSLINE_PLAIN,
|
|
2291
|
+
"statusline.sh",
|
|
2292
|
+
shell="bash",
|
|
2293
|
+
allow_path_fallback=allow_path_fallback,
|
|
2294
|
+
),
|
|
2295
|
+
label=HELPER_STATUSLINE_PLAIN,
|
|
2296
|
+
)
|
|
2297
|
+
argv.extend(
|
|
2298
|
+
[
|
|
2299
|
+
"--approved-bash",
|
|
2300
|
+
str(_approved_system_runtime("bash")),
|
|
2301
|
+
"--approved-python",
|
|
2302
|
+
str(_approved_python_runtime()),
|
|
2303
|
+
"--approved-token-statusline",
|
|
2304
|
+
str(token_path),
|
|
2305
|
+
]
|
|
2306
|
+
)
|
|
2307
|
+
if approved_omc is not None:
|
|
2308
|
+
node_runtime, omc_script = approved_omc
|
|
2309
|
+
argv.extend(
|
|
2310
|
+
[
|
|
2311
|
+
"--approved-node",
|
|
2312
|
+
str(node_runtime),
|
|
2313
|
+
"--approved-omc-script",
|
|
2314
|
+
str(omc_script),
|
|
2315
|
+
]
|
|
2316
|
+
)
|
|
2317
|
+
return {"type": "command", "command": shlex.join(argv)}, approved_omc is not None
|
|
2318
|
+
|
|
2319
|
+
|
|
1977
2320
|
def statusline_setting(*, allow_path_fallback: bool = False) -> dict[str, str]:
|
|
1978
|
-
|
|
2321
|
+
setting, _omc_included = _statusline_setting(allow_path_fallback=allow_path_fallback)
|
|
2322
|
+
return setting
|
|
1979
2323
|
|
|
1980
2324
|
|
|
1981
|
-
def bash_hook_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
|
|
2325
|
+
def bash_hook_setting(*, allow_path_fallback: bool = False, bash_reference_v1: bool = False) -> dict[str, Any]:
|
|
2326
|
+
command = automatic_helper_command(
|
|
2327
|
+
HELPER_REWRITE_BASH,
|
|
2328
|
+
"rewrite_bash_for_token_budget.py",
|
|
2329
|
+
allow_path_fallback=allow_path_fallback,
|
|
2330
|
+
preserve_env_names=REWRITE_BEHAVIOR_ENV,
|
|
2331
|
+
)
|
|
2332
|
+
if bash_reference_v1:
|
|
2333
|
+
command = f"{command} --bash-reference-v1"
|
|
1982
2334
|
return {
|
|
1983
2335
|
"matcher": "Bash",
|
|
1984
|
-
"hooks": [{"type": "command", "command":
|
|
2336
|
+
"hooks": [{"type": "command", "command": command}],
|
|
1985
2337
|
}
|
|
1986
2338
|
|
|
1987
2339
|
|
|
2340
|
+
def load_bash_reference_policy() -> object | None:
|
|
2341
|
+
"""Load only the package-local runtime policy, never an import from PATH."""
|
|
2342
|
+
path = Path(__file__).resolve().parent / "bash_reference_policy.py"
|
|
2343
|
+
flags = (
|
|
2344
|
+
os.O_RDONLY
|
|
2345
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
2346
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
2347
|
+
| getattr(os, "O_NOCTTY", 0)
|
|
2348
|
+
)
|
|
2349
|
+
if not hasattr(os, "O_NOFOLLOW"):
|
|
2350
|
+
return None
|
|
2351
|
+
flags |= os.O_NOFOLLOW
|
|
2352
|
+
fd = -1
|
|
2353
|
+
try:
|
|
2354
|
+
fd = os.open(path, flags)
|
|
2355
|
+
metadata = os.fstat(fd)
|
|
2356
|
+
if (
|
|
2357
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
2358
|
+
or metadata.st_nlink != 1
|
|
2359
|
+
or metadata.st_size > BASH_REFERENCE_POLICY_MAX_BYTES
|
|
2360
|
+
):
|
|
2361
|
+
return None
|
|
2362
|
+
source = os.read(fd, BASH_REFERENCE_POLICY_MAX_BYTES + 1)
|
|
2363
|
+
if len(source) > BASH_REFERENCE_POLICY_MAX_BYTES:
|
|
2364
|
+
return None
|
|
2365
|
+
source_text = source.decode("utf-8", errors="strict")
|
|
2366
|
+
except (OSError, UnicodeDecodeError):
|
|
2367
|
+
return None
|
|
2368
|
+
finally:
|
|
2369
|
+
if fd >= 0:
|
|
2370
|
+
os.close(fd)
|
|
2371
|
+
module_name = f"_context_guard_setup_reference_policy_{os.getpid()}"
|
|
2372
|
+
module = types.ModuleType(module_name)
|
|
2373
|
+
module.__file__ = str(path)
|
|
2374
|
+
module.__package__ = ""
|
|
2375
|
+
sys.modules[module_name] = module
|
|
2376
|
+
try:
|
|
2377
|
+
exec(compile(source_text, str(path), "exec"), module.__dict__)
|
|
2378
|
+
except Exception:
|
|
2379
|
+
sys.modules.pop(module_name, None)
|
|
2380
|
+
return None
|
|
2381
|
+
return module
|
|
2382
|
+
|
|
2383
|
+
|
|
2384
|
+
def bash_reference_adapter_readiness(root: Path) -> tuple[bool, str]:
|
|
2385
|
+
"""Return the runtime adapter verdict for the effective setup project."""
|
|
2386
|
+
policy = load_bash_reference_policy()
|
|
2387
|
+
discover = getattr(policy, "discover_adapter", None)
|
|
2388
|
+
if policy is None:
|
|
2389
|
+
return False, "receipt_policy_unavailable"
|
|
2390
|
+
if not callable(discover):
|
|
2391
|
+
return False, "receipt_policy_invalid"
|
|
2392
|
+
try:
|
|
2393
|
+
discovered = discover(root)
|
|
2394
|
+
except Exception:
|
|
2395
|
+
return False, "receipt_policy_load_failed"
|
|
2396
|
+
if not isinstance(discovered, tuple) or len(discovered) != 2:
|
|
2397
|
+
return False, "receipt_policy_invalid"
|
|
2398
|
+
adapter, reason = discovered
|
|
2399
|
+
adapter_methods = ("start_broker", "query_reference")
|
|
2400
|
+
if (
|
|
2401
|
+
adapter is not None
|
|
2402
|
+
and reason == "receipt_adapter_available"
|
|
2403
|
+
and all(callable(getattr(adapter, name, None)) for name in adapter_methods)
|
|
2404
|
+
):
|
|
2405
|
+
return True, reason
|
|
2406
|
+
if adapter is not None and reason == "receipt_adapter_available":
|
|
2407
|
+
return False, "receipt_adapter_invalid"
|
|
2408
|
+
if not isinstance(reason, str) or re.fullmatch(r"[a-z0-9_]{1,96}", reason) is None:
|
|
2409
|
+
return False, "receipt_policy_invalid"
|
|
2410
|
+
return False, reason
|
|
2411
|
+
|
|
2412
|
+
|
|
2413
|
+
def bash_reference_unavailable_message(reason: str) -> str:
|
|
2414
|
+
return (
|
|
2415
|
+
f"{_BASH_REFERENCE_UNAVAILABLE}: reason={reason}; requires an exact paired npm install "
|
|
2416
|
+
f"of {ROOT_PACKAGE_NAME} and {RECEIPT_PACKAGE_NAME}; "
|
|
2417
|
+
f"recovery={_BASH_REFERENCE_RECOVERY}; ordinary Bash trimming remains enabled"
|
|
2418
|
+
)
|
|
2419
|
+
|
|
2420
|
+
|
|
2421
|
+
def disable_unavailable_bash_reference(
|
|
2422
|
+
choices: Choices,
|
|
2423
|
+
warnings: list[str],
|
|
2424
|
+
*,
|
|
2425
|
+
root: Path,
|
|
2426
|
+
) -> list[str]:
|
|
2427
|
+
"""Fail closed without removing the ordinary Bash trimming choice."""
|
|
2428
|
+
if not choices.bash_reference_v1:
|
|
2429
|
+
return []
|
|
2430
|
+
available, reason = bash_reference_adapter_readiness(root)
|
|
2431
|
+
if available:
|
|
2432
|
+
return []
|
|
2433
|
+
choices.bash_reference_v1 = False
|
|
2434
|
+
warning = bash_reference_unavailable_message(reason)
|
|
2435
|
+
warnings.append(warning)
|
|
2436
|
+
return [warning]
|
|
2437
|
+
|
|
2438
|
+
|
|
1988
2439
|
def read_hook_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
|
|
1989
2440
|
return {
|
|
1990
2441
|
"matcher": "Read",
|
|
1991
|
-
"hooks": [{"type": "command", "command":
|
|
2442
|
+
"hooks": [{"type": "command", "command": automatic_helper_command(
|
|
2443
|
+
HELPER_GUARD_READ,
|
|
2444
|
+
"guard_large_read.py",
|
|
2445
|
+
allow_path_fallback=allow_path_fallback,
|
|
2446
|
+
preserve_env_names=READ_GUARD_BEHAVIOR_ENV,
|
|
2447
|
+
)}],
|
|
1992
2448
|
}
|
|
1993
2449
|
|
|
1994
2450
|
|
|
1995
2451
|
def failed_nudge_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
|
|
1996
2452
|
return {
|
|
1997
2453
|
"matcher": "Bash",
|
|
1998
|
-
"hooks": [{"type": "command", "command":
|
|
2454
|
+
"hooks": [{"type": "command", "command": automatic_helper_command(HELPER_FAILED_NUDGE, "failed_attempt_nudge.py", allow_path_fallback=allow_path_fallback)}],
|
|
1999
2455
|
}
|
|
2000
2456
|
|
|
2001
2457
|
|
|
@@ -2020,6 +2476,18 @@ def command_helper_basenames(command: str) -> set[str]:
|
|
|
2020
2476
|
index = 0
|
|
2021
2477
|
if os.path.basename(parts[index]) == "env":
|
|
2022
2478
|
index += 1
|
|
2479
|
+
while index < len(parts):
|
|
2480
|
+
token = parts[index]
|
|
2481
|
+
if token in {"-i", "--ignore-environment"}:
|
|
2482
|
+
index += 1
|
|
2483
|
+
continue
|
|
2484
|
+
if token in {"-u", "--unset"} and index + 1 < len(parts):
|
|
2485
|
+
index += 2
|
|
2486
|
+
continue
|
|
2487
|
+
if token.startswith("--unset="):
|
|
2488
|
+
index += 1
|
|
2489
|
+
continue
|
|
2490
|
+
break
|
|
2023
2491
|
while index < len(parts) and "=" in parts[index] and not parts[index].startswith("-"):
|
|
2024
2492
|
index += 1
|
|
2025
2493
|
if index >= len(parts):
|
|
@@ -2042,6 +2510,139 @@ def command_helper_basenames(command: str) -> set[str]:
|
|
|
2042
2510
|
return {head}
|
|
2043
2511
|
|
|
2044
2512
|
|
|
2513
|
+
def _statusline_candidate_paths(*, merged: bool) -> set[Path]:
|
|
2514
|
+
script_dir = Path(__file__).resolve().parent
|
|
2515
|
+
helper_key = HELPER_STATUSLINE if merged else HELPER_STATUSLINE_PLAIN
|
|
2516
|
+
kit_script = "statusline_merged.sh" if merged else "statusline.sh"
|
|
2517
|
+
names = HELPER_EQUIVALENT_BASENAMES[helper_key]
|
|
2518
|
+
raw_candidates = {script_dir / kit_script}
|
|
2519
|
+
for name in names:
|
|
2520
|
+
raw_candidates.add(script_dir / name)
|
|
2521
|
+
raw_candidates.add(
|
|
2522
|
+
script_dir.parent / "plugins" / "context-guard" / "bin" / name
|
|
2523
|
+
)
|
|
2524
|
+
candidates: set[Path] = set()
|
|
2525
|
+
for candidate in raw_candidates:
|
|
2526
|
+
try:
|
|
2527
|
+
candidates.add(candidate.resolve(strict=True))
|
|
2528
|
+
except OSError:
|
|
2529
|
+
continue
|
|
2530
|
+
return candidates
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
def _authenticated_statusline_path(raw: str, *, merged: bool) -> Path | None:
|
|
2534
|
+
candidate = Path(raw)
|
|
2535
|
+
if not candidate.is_absolute():
|
|
2536
|
+
candidate = Path.cwd() / candidate
|
|
2537
|
+
try:
|
|
2538
|
+
canonical = candidate.resolve(strict=True)
|
|
2539
|
+
except OSError:
|
|
2540
|
+
return None
|
|
2541
|
+
return canonical if canonical in _statusline_candidate_paths(merged=merged) else None
|
|
2542
|
+
|
|
2543
|
+
|
|
2544
|
+
def exact_known_statusline_command(command: str) -> bool:
|
|
2545
|
+
"""Match only authenticated complete historical merged-statusline commands."""
|
|
2546
|
+
try:
|
|
2547
|
+
parts = shlex.split(command) if command else []
|
|
2548
|
+
except ValueError:
|
|
2549
|
+
return False
|
|
2550
|
+
if not parts:
|
|
2551
|
+
return False
|
|
2552
|
+
known_helpers = HELPER_EQUIVALENT_BASENAMES[HELPER_STATUSLINE]
|
|
2553
|
+
direct_head = parts[0]
|
|
2554
|
+
if len(parts) == 1:
|
|
2555
|
+
if direct_head in known_helpers:
|
|
2556
|
+
return True
|
|
2557
|
+
return _authenticated_statusline_path(direct_head, merged=True) is not None
|
|
2558
|
+
|
|
2559
|
+
index = 0
|
|
2560
|
+
generated_shape = False
|
|
2561
|
+
assignments: dict[str, str] = {}
|
|
2562
|
+
approved_env = str(_approved_system_runtime("env"))
|
|
2563
|
+
if direct_head == approved_env:
|
|
2564
|
+
generated_shape = True
|
|
2565
|
+
index += 1
|
|
2566
|
+
if index >= len(parts) or parts[index] != "-i":
|
|
2567
|
+
return False
|
|
2568
|
+
index += 1
|
|
2569
|
+
allowed_assignments = {
|
|
2570
|
+
"PATH",
|
|
2571
|
+
"LC_ALL",
|
|
2572
|
+
"HOME",
|
|
2573
|
+
*STATUSLINE_BEHAVIOR_ENV,
|
|
2574
|
+
}
|
|
2575
|
+
while index < len(parts) and "=" in parts[index] and not parts[index].startswith("-"):
|
|
2576
|
+
name, _separator, value = parts[index].partition("=")
|
|
2577
|
+
if name not in allowed_assignments or name in assignments:
|
|
2578
|
+
return False
|
|
2579
|
+
assignments[name] = value
|
|
2580
|
+
index += 1
|
|
2581
|
+
if assignments.get("PATH") != ISOLATED_RUNTIME_PATH or assignments.get("LC_ALL") != "C":
|
|
2582
|
+
return False
|
|
2583
|
+
for name, value in assignments.items():
|
|
2584
|
+
if name in STATUSLINE_BEHAVIOR_ENV and not BEHAVIOR_ENV_VALUE_RE.fullmatch(value):
|
|
2585
|
+
return False
|
|
2586
|
+
|
|
2587
|
+
approved_bash = str(_approved_system_runtime("bash"))
|
|
2588
|
+
if index >= len(parts):
|
|
2589
|
+
return False
|
|
2590
|
+
shell = parts[index]
|
|
2591
|
+
if generated_shape:
|
|
2592
|
+
if shell != approved_bash:
|
|
2593
|
+
return False
|
|
2594
|
+
elif shell not in {"bash", "sh", approved_bash}:
|
|
2595
|
+
return False
|
|
2596
|
+
index += 1
|
|
2597
|
+
if generated_shape:
|
|
2598
|
+
if parts[index : index + 2] != ["--noprofile", "--norc"]:
|
|
2599
|
+
return False
|
|
2600
|
+
index += 2
|
|
2601
|
+
else:
|
|
2602
|
+
while index < len(parts) and parts[index] in {"--noprofile", "--norc"}:
|
|
2603
|
+
index += 1
|
|
2604
|
+
if index >= len(parts):
|
|
2605
|
+
return False
|
|
2606
|
+
if _authenticated_statusline_path(parts[index], merged=True) is None:
|
|
2607
|
+
return False
|
|
2608
|
+
index += 1
|
|
2609
|
+
if not generated_shape:
|
|
2610
|
+
return index == len(parts)
|
|
2611
|
+
|
|
2612
|
+
required_prefix = [
|
|
2613
|
+
"--approved-bash",
|
|
2614
|
+
approved_bash,
|
|
2615
|
+
"--approved-python",
|
|
2616
|
+
str(_approved_python_runtime()),
|
|
2617
|
+
"--approved-token-statusline",
|
|
2618
|
+
]
|
|
2619
|
+
if parts[index : index + len(required_prefix)] != required_prefix:
|
|
2620
|
+
return False
|
|
2621
|
+
index += len(required_prefix)
|
|
2622
|
+
if index >= len(parts):
|
|
2623
|
+
return False
|
|
2624
|
+
if _authenticated_statusline_path(parts[index], merged=False) is None:
|
|
2625
|
+
return False
|
|
2626
|
+
index += 1
|
|
2627
|
+
|
|
2628
|
+
approved_omc = _approved_default_omc_hud()
|
|
2629
|
+
if index == len(parts):
|
|
2630
|
+
return "HOME" not in assignments
|
|
2631
|
+
if approved_omc is None or len(parts) - index != 4:
|
|
2632
|
+
return False
|
|
2633
|
+
node_runtime, omc_script = approved_omc
|
|
2634
|
+
expected_omc = [
|
|
2635
|
+
"--approved-node",
|
|
2636
|
+
str(node_runtime),
|
|
2637
|
+
"--approved-omc-script",
|
|
2638
|
+
str(omc_script),
|
|
2639
|
+
]
|
|
2640
|
+
return (
|
|
2641
|
+
parts[index:] == expected_omc
|
|
2642
|
+
and assignments.get("HOME") == str(omc_script.parents[2])
|
|
2643
|
+
)
|
|
2644
|
+
|
|
2645
|
+
|
|
2045
2646
|
def equivalent_helper_basenames(command: str) -> set[str]:
|
|
2046
2647
|
bases = command_helper_basenames(command)
|
|
2047
2648
|
equivalents = set(bases)
|
|
@@ -2050,13 +2651,132 @@ def equivalent_helper_basenames(command: str) -> set[str]:
|
|
|
2050
2651
|
return equivalents
|
|
2051
2652
|
|
|
2052
2653
|
|
|
2053
|
-
def
|
|
2654
|
+
def _generic_hook_spec(desired: str) -> tuple[str, str, tuple[str, ...]] | None:
|
|
2655
|
+
desired_bases = command_helper_basenames(desired)
|
|
2656
|
+
specs = (
|
|
2657
|
+
(HELPER_REWRITE_BASH, "rewrite_bash_for_token_budget.py", REWRITE_BEHAVIOR_ENV),
|
|
2658
|
+
(HELPER_GUARD_READ, "guard_large_read.py", READ_GUARD_BEHAVIOR_ENV),
|
|
2659
|
+
(HELPER_FAILED_NUDGE, "failed_attempt_nudge.py", ()),
|
|
2660
|
+
)
|
|
2661
|
+
for helper_name, kit_script, behavior_env in specs:
|
|
2662
|
+
if desired_bases & HELPER_EQUIVALENT_BASENAMES[helper_name]:
|
|
2663
|
+
return helper_name, kit_script, behavior_env
|
|
2664
|
+
return None
|
|
2665
|
+
|
|
2666
|
+
|
|
2667
|
+
def _generic_hook_candidate_paths(helper_name: str, kit_script: str) -> set[Path]:
|
|
2668
|
+
script_dir = Path(__file__).resolve().parent
|
|
2669
|
+
raw_candidates = {script_dir / kit_script}
|
|
2670
|
+
for name in HELPER_EQUIVALENT_BASENAMES[helper_name]:
|
|
2671
|
+
raw_candidates.add(script_dir / name)
|
|
2672
|
+
raw_candidates.add(
|
|
2673
|
+
script_dir.parent / "plugins" / "context-guard" / "bin" / name
|
|
2674
|
+
)
|
|
2675
|
+
candidates: set[Path] = set()
|
|
2676
|
+
for candidate in raw_candidates:
|
|
2677
|
+
try:
|
|
2678
|
+
candidates.add(candidate.resolve(strict=True))
|
|
2679
|
+
except OSError:
|
|
2680
|
+
continue
|
|
2681
|
+
return candidates
|
|
2682
|
+
|
|
2683
|
+
|
|
2684
|
+
def _authenticated_generic_hook_path(
|
|
2685
|
+
raw: str,
|
|
2686
|
+
*,
|
|
2687
|
+
helper_name: str,
|
|
2688
|
+
kit_script: str,
|
|
2689
|
+
) -> Path | None:
|
|
2690
|
+
candidate = Path(raw)
|
|
2691
|
+
if not candidate.is_absolute():
|
|
2692
|
+
candidate = Path.cwd() / candidate
|
|
2693
|
+
try:
|
|
2694
|
+
canonical = candidate.resolve(strict=True)
|
|
2695
|
+
except OSError:
|
|
2696
|
+
return None
|
|
2697
|
+
candidates = _generic_hook_candidate_paths(helper_name, kit_script)
|
|
2698
|
+
return canonical if canonical in candidates else None
|
|
2699
|
+
|
|
2700
|
+
|
|
2701
|
+
def exact_known_hook_command(existing: str, desired: str) -> bool:
|
|
2054
2702
|
if command_matches(existing, desired):
|
|
2055
2703
|
return True
|
|
2056
|
-
|
|
2057
|
-
if
|
|
2704
|
+
spec = _generic_hook_spec(desired)
|
|
2705
|
+
if spec is None:
|
|
2706
|
+
return False
|
|
2707
|
+
helper_name, kit_script, behavior_env = spec
|
|
2708
|
+
known_names = HELPER_EQUIVALENT_BASENAMES[helper_name]
|
|
2709
|
+
try:
|
|
2710
|
+
parts = shlex.split(existing) if existing else []
|
|
2711
|
+
except ValueError:
|
|
2712
|
+
return False
|
|
2713
|
+
if not parts:
|
|
2058
2714
|
return False
|
|
2059
|
-
|
|
2715
|
+
if len(parts) == 1:
|
|
2716
|
+
if parts[0] in known_names:
|
|
2717
|
+
return True
|
|
2718
|
+
return _authenticated_generic_hook_path(
|
|
2719
|
+
parts[0],
|
|
2720
|
+
helper_name=helper_name,
|
|
2721
|
+
kit_script=kit_script,
|
|
2722
|
+
) is not None
|
|
2723
|
+
|
|
2724
|
+
index = 0
|
|
2725
|
+
generated_shape = parts[0] == str(_approved_system_runtime("env"))
|
|
2726
|
+
if generated_shape:
|
|
2727
|
+
index = 1
|
|
2728
|
+
if index >= len(parts) or parts[index] != "-i":
|
|
2729
|
+
return False
|
|
2730
|
+
index += 1
|
|
2731
|
+
assignments: dict[str, str] = {}
|
|
2732
|
+
allowed_assignments = {"PATH", "LC_ALL", *behavior_env}
|
|
2733
|
+
while index < len(parts) and "=" in parts[index] and not parts[index].startswith("-"):
|
|
2734
|
+
name, _separator, value = parts[index].partition("=")
|
|
2735
|
+
if name not in allowed_assignments or name in assignments:
|
|
2736
|
+
return False
|
|
2737
|
+
assignments[name] = value
|
|
2738
|
+
index += 1
|
|
2739
|
+
if assignments.get("PATH") != ISOLATED_RUNTIME_PATH or assignments.get("LC_ALL") != "C":
|
|
2740
|
+
return False
|
|
2741
|
+
for name, value in assignments.items():
|
|
2742
|
+
if name in behavior_env and not BEHAVIOR_ENV_VALUE_RE.fullmatch(value):
|
|
2743
|
+
return False
|
|
2744
|
+
|
|
2745
|
+
if index >= len(parts):
|
|
2746
|
+
return False
|
|
2747
|
+
python_runtime = parts[index]
|
|
2748
|
+
if generated_shape:
|
|
2749
|
+
if python_runtime != str(_approved_python_runtime()):
|
|
2750
|
+
return False
|
|
2751
|
+
elif (
|
|
2752
|
+
"/" in python_runtime
|
|
2753
|
+
or re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", python_runtime) is None
|
|
2754
|
+
):
|
|
2755
|
+
return False
|
|
2756
|
+
index += 1
|
|
2757
|
+
if index < len(parts) and parts[index] == "-I":
|
|
2758
|
+
index += 1
|
|
2759
|
+
elif generated_shape:
|
|
2760
|
+
return False
|
|
2761
|
+
if index >= len(parts):
|
|
2762
|
+
return False
|
|
2763
|
+
if _authenticated_generic_hook_path(
|
|
2764
|
+
parts[index],
|
|
2765
|
+
helper_name=helper_name,
|
|
2766
|
+
kit_script=kit_script,
|
|
2767
|
+
) is None:
|
|
2768
|
+
return False
|
|
2769
|
+
index += 1
|
|
2770
|
+
if index == len(parts):
|
|
2771
|
+
return True
|
|
2772
|
+
return (
|
|
2773
|
+
helper_name == HELPER_REWRITE_BASH
|
|
2774
|
+
and parts[index:] == ["--bash-reference-v1"]
|
|
2775
|
+
)
|
|
2776
|
+
|
|
2777
|
+
|
|
2778
|
+
def command_matches_existing_or_equivalent(existing: str, desired: str) -> bool:
|
|
2779
|
+
return exact_known_hook_command(existing, desired)
|
|
2060
2780
|
|
|
2061
2781
|
|
|
2062
2782
|
def canonicalize_equivalent_command(value: Any, desired: str) -> tuple[bool, bool]:
|
|
@@ -2440,7 +3160,28 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
2440
3160
|
}
|
|
2441
3161
|
|
|
2442
3162
|
choices = choices_from_args(args)
|
|
2443
|
-
|
|
3163
|
+
reference_actions = disable_unavailable_bash_reference(
|
|
3164
|
+
choices,
|
|
3165
|
+
warnings,
|
|
3166
|
+
root=root,
|
|
3167
|
+
)
|
|
3168
|
+
if reference_actions:
|
|
3169
|
+
checks.append(doctor_check(
|
|
3170
|
+
"bash-reference-distribution",
|
|
3171
|
+
"warning",
|
|
3172
|
+
"medium",
|
|
3173
|
+
reference_actions[0],
|
|
3174
|
+
next_action=_BASH_REFERENCE_RECOVERY + ".",
|
|
3175
|
+
))
|
|
3176
|
+
actions = reference_actions + (
|
|
3177
|
+
apply_choices(
|
|
3178
|
+
settings,
|
|
3179
|
+
choices,
|
|
3180
|
+
allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False)),
|
|
3181
|
+
)
|
|
3182
|
+
if claude_targeted
|
|
3183
|
+
else []
|
|
3184
|
+
)
|
|
2444
3185
|
changed = (settings != original) if claude_targeted else False
|
|
2445
3186
|
if changed:
|
|
2446
3187
|
checks.append(doctor_check(
|
|
@@ -2596,12 +3337,29 @@ def apply_choices(settings: dict[str, Any], choices: Choices, *, allow_path_fall
|
|
|
2596
3337
|
settings["effortLevel"] = DEFAULT_EFFORT
|
|
2597
3338
|
actions.append(f"set default effortLevel to {DEFAULT_EFFORT}")
|
|
2598
3339
|
if choices.statusline:
|
|
2599
|
-
statusline =
|
|
3340
|
+
statusline, omc_included = _statusline_setting(allow_path_fallback=allow_path_fallback)
|
|
2600
3341
|
if "statusLine" not in settings:
|
|
2601
3342
|
settings["statusLine"] = statusline
|
|
2602
3343
|
actions.append("enabled token statusline")
|
|
3344
|
+
if omc_included:
|
|
3345
|
+
actions.append("included setup-approved OMC HUD")
|
|
2603
3346
|
elif settings.get("statusLine") != statusline:
|
|
2604
|
-
|
|
3347
|
+
existing_statusline = settings.get("statusLine")
|
|
3348
|
+
existing_command = (
|
|
3349
|
+
existing_statusline.get("command")
|
|
3350
|
+
if isinstance(existing_statusline, dict)
|
|
3351
|
+
else None
|
|
3352
|
+
)
|
|
3353
|
+
if (
|
|
3354
|
+
isinstance(existing_command, str)
|
|
3355
|
+
and exact_known_statusline_command(existing_command)
|
|
3356
|
+
):
|
|
3357
|
+
settings["statusLine"] = statusline
|
|
3358
|
+
actions.append("migrated token statusline")
|
|
3359
|
+
if omc_included:
|
|
3360
|
+
actions.append("included setup-approved OMC HUD")
|
|
3361
|
+
else:
|
|
3362
|
+
actions.append("kept existing statusLine; add context-guard-statusline-merged manually if desired")
|
|
2605
3363
|
if choices.denies:
|
|
2606
3364
|
ensure_permissions(
|
|
2607
3365
|
settings,
|
|
@@ -2609,9 +3367,16 @@ def apply_choices(settings: dict[str, Any], choices: Choices, *, allow_path_fall
|
|
|
2609
3367
|
migrate_env_read_denies=choices.read_guard,
|
|
2610
3368
|
)
|
|
2611
3369
|
if choices.bash_hook:
|
|
2612
|
-
bash_hook = bash_hook_setting(
|
|
3370
|
+
bash_hook = bash_hook_setting(
|
|
3371
|
+
allow_path_fallback=allow_path_fallback,
|
|
3372
|
+
bash_reference_v1=choices.bash_reference_v1,
|
|
3373
|
+
)
|
|
2613
3374
|
bash_command = bash_hook["hooks"][0]["command"]
|
|
2614
3375
|
ensure_pre_tool_hook(settings, bash_hook, bash_command, "Bash trim/sanitize", actions)
|
|
3376
|
+
if choices.bash_reference_v1:
|
|
3377
|
+
actions.append(
|
|
3378
|
+
"enabled bash_reference_v1: a scoped 7-day bearer handle may appear in Claude/provider-visible transcripts"
|
|
3379
|
+
)
|
|
2615
3380
|
if choices.read_guard:
|
|
2616
3381
|
read_hook = read_hook_setting(allow_path_fallback=allow_path_fallback)
|
|
2617
3382
|
read_command = read_hook["hooks"][0]["command"]
|
|
@@ -3004,6 +3769,10 @@ def interactive_choices(defaults: Choices) -> Choices:
|
|
|
3004
3769
|
denies=prompt_bool("Add deny rules for bulky/sensitive paths?", defaults.denies),
|
|
3005
3770
|
statusline=prompt_bool("Enable token/cost statusline?", defaults.statusline),
|
|
3006
3771
|
bash_hook=prompt_bool("Enable Bash output trim + grep/diff sanitizer hook?", defaults.bash_hook),
|
|
3772
|
+
bash_reference_v1=prompt_bool(
|
|
3773
|
+
"Enable optional Bash receipt references? 7-day scoped bearer handles are visible to Claude/provider transcripts",
|
|
3774
|
+
defaults.bash_reference_v1,
|
|
3775
|
+
),
|
|
3007
3776
|
read_guard=prompt_bool("Enable large Read guard?", defaults.read_guard),
|
|
3008
3777
|
model_defaults=prompt_bool("Set missing defaults to model=sonnet and effortLevel=medium?", defaults.model_defaults),
|
|
3009
3778
|
failed_attempt_nudge=prompt_bool(
|
|
@@ -3019,6 +3788,7 @@ def choices_from_args(args: argparse.Namespace) -> Choices:
|
|
|
3019
3788
|
denies=not args.no_denies,
|
|
3020
3789
|
statusline=not args.no_statusline,
|
|
3021
3790
|
bash_hook=not args.no_bash_hook,
|
|
3791
|
+
bash_reference_v1=getattr(args, "bash_reference_v1", False),
|
|
3022
3792
|
read_guard=not args.no_read_guard,
|
|
3023
3793
|
model_defaults=not args.no_model_defaults,
|
|
3024
3794
|
failed_attempt_nudge=(
|
|
@@ -3327,7 +4097,20 @@ def run(args: argparse.Namespace) -> SetupResult:
|
|
|
3327
4097
|
if interactive:
|
|
3328
4098
|
choices = interactive_choices(choices)
|
|
3329
4099
|
|
|
3330
|
-
|
|
4100
|
+
reference_actions = disable_unavailable_bash_reference(
|
|
4101
|
+
choices,
|
|
4102
|
+
warnings,
|
|
4103
|
+
root=root,
|
|
4104
|
+
)
|
|
4105
|
+
actions = reference_actions + (
|
|
4106
|
+
apply_choices(
|
|
4107
|
+
settings,
|
|
4108
|
+
choices,
|
|
4109
|
+
allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False)),
|
|
4110
|
+
)
|
|
4111
|
+
if claude_targeted
|
|
4112
|
+
else []
|
|
4113
|
+
)
|
|
3331
4114
|
changed = (settings != original) if claude_targeted else False
|
|
3332
4115
|
|
|
3333
4116
|
apply_requested = bool(args.yes and not args.dry_run and not args.plan)
|
|
@@ -3481,6 +4264,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
3481
4264
|
parser.add_argument("--no-denies", action="store_true", help="skip recommended permissions.deny rules")
|
|
3482
4265
|
parser.add_argument("--no-statusline", action="store_true", help="skip token statusline")
|
|
3483
4266
|
parser.add_argument("--no-bash-hook", action="store_true", help="skip Bash trim/sanitize hook")
|
|
4267
|
+
reference_group = parser.add_mutually_exclusive_group()
|
|
4268
|
+
reference_group.add_argument(
|
|
4269
|
+
"--bash-reference-v1",
|
|
4270
|
+
action="store_true",
|
|
4271
|
+
help="opt in to 7-day scoped receipt references in the Bash hook; handles are provider-visible",
|
|
4272
|
+
)
|
|
4273
|
+
reference_group.add_argument(
|
|
4274
|
+
"--no-bash-reference-v1",
|
|
4275
|
+
dest="bash_reference_v1",
|
|
4276
|
+
action="store_false",
|
|
4277
|
+
help="disable/remove the optional Bash receipt-reference hook flag (default)",
|
|
4278
|
+
)
|
|
4279
|
+
parser.set_defaults(bash_reference_v1=False)
|
|
3484
4280
|
parser.add_argument("--no-read-guard", action="store_true", help="skip large Read guard hook")
|
|
3485
4281
|
parser.add_argument("--no-model-defaults", action="store_true", help="skip model/effort defaults")
|
|
3486
4282
|
parser.add_argument("--no-diet-scan", action="store_true", help="skip the read-only diet scan summary after applying setup")
|