@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.
@@ -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["PATH"] = str(path.parent) + (os.pathsep + system_path if system_path else "")
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
- return {"type": "command", "command": helper_command(HELPER_STATUSLINE, "statusline_merged.sh", shell="bash", allow_path_fallback=allow_path_fallback)}
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": helper_command(HELPER_REWRITE_BASH, "rewrite_bash_for_token_budget.py", allow_path_fallback=allow_path_fallback)}],
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": helper_command(HELPER_GUARD_READ, "guard_large_read.py", allow_path_fallback=allow_path_fallback)}],
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": helper_command(HELPER_FAILED_NUDGE, "failed_attempt_nudge.py", allow_path_fallback=allow_path_fallback)}],
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 command_matches_existing_or_equivalent(existing: str, desired: str) -> bool:
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
- desired_helpers = equivalent_helper_basenames(desired)
2057
- if not desired_helpers:
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
- return bool(command_helper_basenames(existing) & desired_helpers)
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
- actions = apply_choices(settings, choices, allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False))) if claude_targeted else []
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 = statusline_setting(allow_path_fallback=allow_path_fallback)
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
- actions.append("kept existing statusLine; add context-guard-statusline-merged manually if desired")
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(allow_path_fallback=allow_path_fallback)
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
- actions = apply_choices(settings, choices, allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False))) if claude_targeted else []
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")