@ictechgy/context-guard 0.4.16 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.ko.md +91 -1
  3. package/README.md +95 -1
  4. package/docs/distribution.md +100 -0
  5. package/package.json +5 -1
  6. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  7. package/plugins/context-guard/README.ko.md +34 -1
  8. package/plugins/context-guard/README.md +36 -1
  9. package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
  10. package/plugins/context-guard/bin/context-guard-artifact +1 -1
  11. package/plugins/context-guard/bin/context-guard-bench +4216 -103
  12. package/plugins/context-guard/bin/context-guard-failed-nudge +95 -23
  13. package/plugins/context-guard/bin/context-guard-guard-read +6 -2
  14. package/plugins/context-guard/bin/context-guard-mcp +2 -1
  15. package/plugins/context-guard/bin/context-guard-pack +2086 -142
  16. package/plugins/context-guard/bin/context-guard-rewrite-bash +497 -45
  17. package/plugins/context-guard/bin/context-guard-sanitize-output +178 -22
  18. package/plugins/context-guard/bin/context-guard-setup +901 -28
  19. package/plugins/context-guard/bin/context-guard-statusline +33 -2
  20. package/plugins/context-guard/bin/context-guard-statusline-merged +71 -20
  21. package/plugins/context-guard/bin/context-guard-task-memory +635 -0
  22. package/plugins/context-guard/bin/context-guard-trim-output +706 -35
  23. package/plugins/context-guard/lib/context_guard_commands.py +25 -1
  24. package/plugins/context-guard/lib/context_pack_git_boundary.py +19 -0
  25. package/plugins/context-guard/lib/context_pack_identity.py +115 -0
  26. package/plugins/context-guard/lib/context_pack_receipts.py +9 -0
  27. package/plugins/context-guard/lib/context_pack_rendering.py +12 -0
  28. package/plugins/context-guard/lib/context_pack_scanning.py +10 -0
  29. package/plugins/context-guard/lib/context_pack_selection.py +6 -0
  30. 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.
@@ -1464,17 +1506,65 @@ def normalize_scope(raw_scope: str | None) -> str:
1464
1506
  return scope
1465
1507
 
1466
1508
 
1509
+ def _user_scope_path_issue(
1510
+ path: Path,
1511
+ *,
1512
+ label: str,
1513
+ expected_directory: bool,
1514
+ allow_missing: bool,
1515
+ ) -> str | None:
1516
+ """Return a fail-closed reason for an existing user-scope path."""
1517
+ try:
1518
+ metadata = os.lstat(path)
1519
+ except FileNotFoundError:
1520
+ return None if allow_missing else f"{label} does not exist: {path}"
1521
+ except OSError as error:
1522
+ return f"could not inspect {label}: {error.__class__.__name__}"
1523
+
1524
+ if metadata.st_uid not in {0, os.geteuid()}:
1525
+ return f"refusing unsafe {label}: it is not owned by root or the effective user"
1526
+ if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
1527
+ return f"refusing unsafe {label}: group/world write bits are set"
1528
+ if expected_directory:
1529
+ if not stat.S_ISDIR(metadata.st_mode):
1530
+ return f"refusing unsafe {label}: it is not a directory"
1531
+ elif not stat.S_ISREG(metadata.st_mode):
1532
+ return f"refusing unsafe {label}: it is not a regular file"
1533
+ return None
1534
+
1535
+
1536
+ def _validate_user_scope_home(home: Path) -> None:
1537
+ issue = _user_scope_path_issue(
1538
+ home,
1539
+ label="resolved HOME",
1540
+ expected_directory=True,
1541
+ allow_missing=False,
1542
+ )
1543
+ if issue:
1544
+ raise SystemExit(issue)
1545
+
1546
+
1467
1547
  def resolve_scope_root(raw_root: str | None, scope: str) -> Path:
1468
1548
  if scope == "project":
1469
1549
  return resolve_setup_root(raw_root)
1470
1550
  home = Path.home().expanduser().resolve()
1471
1551
  if home == Path(home.anchor or "/"):
1472
1552
  raise SystemExit("Refusing user-scope setup because HOME resolves to a filesystem root.")
1473
- if not home.exists() or not home.is_dir():
1474
- raise SystemExit(f"Refusing user-scope setup because HOME is not a directory: {home}")
1553
+ _validate_user_scope_home(home)
1475
1554
  return home
1476
1555
 
1477
1556
 
1557
+ def effective_scope(raw_root: str | None, raw_scope: str | None, *, allow_home_settings: bool) -> str:
1558
+ scope = normalize_scope(raw_scope)
1559
+ if scope != "project" or not allow_home_settings:
1560
+ return scope
1561
+ project_root = resolve_setup_root(raw_root)
1562
+ home_settings = Path.home().expanduser().resolve() / SETTINGS_REL
1563
+ if (project_root / SETTINGS_REL).expanduser().resolve() == home_settings:
1564
+ return "user"
1565
+ return scope
1566
+
1567
+
1478
1568
  def explicit_agent_selection(args: argparse.Namespace) -> list[str] | None:
1479
1569
  values: list[str] = []
1480
1570
  for attr in ("agent", "only"):
@@ -1507,6 +1597,20 @@ def validate_settings_target(root: Path, settings_path: Path, *, allow_home_sett
1507
1597
  claude_dir.resolve().relative_to(root)
1508
1598
  except ValueError as exc:
1509
1599
  raise SystemExit(f"Claude settings directory resolves outside project root: {claude_dir}") from exc
1600
+ if root == home_settings.parent.parent:
1601
+ for path, label, expected_directory, allow_missing in (
1602
+ (root, "resolved HOME", True, False),
1603
+ (claude_dir, "existing .claude directory", True, True),
1604
+ (settings_path, "existing settings.json", False, True),
1605
+ ):
1606
+ issue = _user_scope_path_issue(
1607
+ path,
1608
+ label=label,
1609
+ expected_directory=expected_directory,
1610
+ allow_missing=allow_missing,
1611
+ )
1612
+ if issue:
1613
+ raise SystemExit(issue)
1510
1614
 
1511
1615
 
1512
1616
  def _base_open_flags() -> int:
@@ -1835,9 +1939,11 @@ def _path_has_symlink_component(path: Path) -> bool:
1835
1939
 
1836
1940
 
1837
1941
  def _probe_path_helper_identity(path: Path, helper_name: str) -> None:
1838
- env = os.environ.copy()
1839
1942
  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 "")
1943
+ env = {
1944
+ "LC_ALL": "C",
1945
+ "PATH": str(path.parent) + (os.pathsep + system_path if system_path else ""),
1946
+ }
1841
1947
  try:
1842
1948
  proc = subprocess.Popen(
1843
1949
  [str(path), "--help"],
@@ -1974,28 +2080,440 @@ def helper_command(helper_name: str, kit_script: str, *, shell: str | None = Non
1974
2080
  return shlex.join(argv)
1975
2081
 
1976
2082
 
2083
+ def _validated_runtime_executable(raw: str | Path, *, label: str) -> Path:
2084
+ """Bind a runtime/helper to the canonical executable seen during setup."""
2085
+ candidate = Path(raw)
2086
+ if not candidate.is_absolute():
2087
+ raise SystemExit(f"{label} did not resolve to an absolute path")
2088
+ try:
2089
+ canonical = candidate.resolve(strict=True)
2090
+ metadata = canonical.stat()
2091
+ except OSError as exc:
2092
+ raise SystemExit(
2093
+ f"{label} could not be canonicalized: {exc.strerror or exc.__class__.__name__}"
2094
+ ) from exc
2095
+ if not stat.S_ISREG(metadata.st_mode) or not os.access(canonical, os.X_OK):
2096
+ raise SystemExit(f"{label} must be an executable regular file")
2097
+ return canonical
2098
+
2099
+
2100
+ def _approved_python_runtime() -> Path:
2101
+ if not sys.executable:
2102
+ raise SystemExit("Python runtime identity is unavailable")
2103
+ return _validated_runtime_executable(sys.executable, label="Python runtime")
2104
+
2105
+
2106
+ def _approved_system_runtime(name: str) -> Path:
2107
+ found = shutil.which(name, path=ISOLATED_RUNTIME_PATH)
2108
+ if not found:
2109
+ raise SystemExit(f"Required {name!r} runtime was not found in the fixed system path")
2110
+ return _validated_runtime_executable(found, label=f"{name} runtime")
2111
+
2112
+
2113
+ def _isolated_runtime_prefix(
2114
+ preserve_env_names: tuple[str, ...] = (),
2115
+ *,
2116
+ fixed_env: dict[str, str] | None = None,
2117
+ ) -> list[str]:
2118
+ prefix = [
2119
+ str(_approved_system_runtime("env")),
2120
+ "-i",
2121
+ f"PATH={ISOLATED_RUNTIME_PATH}",
2122
+ "LC_ALL=C",
2123
+ ]
2124
+ for name in preserve_env_names:
2125
+ value = os.environ.get(name)
2126
+ if value is not None and BEHAVIOR_ENV_VALUE_RE.fullmatch(value):
2127
+ prefix.append(f"{name}={value}")
2128
+ for name, value in (fixed_env or {}).items():
2129
+ if name in {"HOME"} and value and "\x00" not in value:
2130
+ prefix.append(f"{name}={value}")
2131
+ return prefix
2132
+
2133
+
2134
+ def _helper_path_from_argv(argv: list[str], *, label: str) -> Path:
2135
+ if not argv:
2136
+ raise SystemExit(f"{label} helper argv is empty")
2137
+ return _validated_runtime_executable(argv[-1], label=label)
2138
+
2139
+
2140
+ def _bundled_helper_candidates(helper_name: str, kit_script: str) -> set[Path]:
2141
+ script_dir = Path(__file__).resolve().parent
2142
+ raw_candidates = (
2143
+ script_dir / helper_name,
2144
+ script_dir.parent / "plugins" / "context-guard" / "bin" / helper_name,
2145
+ script_dir / kit_script,
2146
+ )
2147
+ candidates: set[Path] = set()
2148
+ for candidate in raw_candidates:
2149
+ try:
2150
+ candidates.add(candidate.resolve(strict=True))
2151
+ except OSError:
2152
+ continue
2153
+ return candidates
2154
+
2155
+
2156
+ def automatic_helper_argv(
2157
+ helper_name: str,
2158
+ kit_script: str,
2159
+ *,
2160
+ shell: str | None = None,
2161
+ allow_path_fallback: bool = False,
2162
+ preserve_env_names: tuple[str, ...] = (),
2163
+ fixed_env: dict[str, str] | None = None,
2164
+ ) -> list[str]:
2165
+ """Build installed hook argv with isolated, setup-pinned runtimes."""
2166
+ resolved = helper_argv(
2167
+ helper_name,
2168
+ kit_script,
2169
+ shell=shell,
2170
+ allow_path_fallback=allow_path_fallback,
2171
+ )
2172
+ helper_path = _helper_path_from_argv(resolved, label=helper_name)
2173
+ prefix = _isolated_runtime_prefix(
2174
+ preserve_env_names,
2175
+ fixed_env=fixed_env,
2176
+ )
2177
+ if helper_path not in _bundled_helper_candidates(helper_name, kit_script):
2178
+ # An explicit PATH fallback may be a native executable. Its absolute
2179
+ # identity was already validated by validate_path_helper_fallback().
2180
+ return [*prefix, str(helper_path)]
2181
+ if shell:
2182
+ shell_runtime = _approved_system_runtime(shell)
2183
+ shell_flags = ["--noprofile", "--norc"] if shell_runtime.name == "bash" else []
2184
+ return [*prefix, str(shell_runtime), *shell_flags, str(helper_path)]
2185
+ return [*prefix, str(_approved_python_runtime()), "-I", str(helper_path)]
2186
+
2187
+
2188
+ def automatic_helper_command(
2189
+ helper_name: str,
2190
+ kit_script: str,
2191
+ *,
2192
+ shell: str | None = None,
2193
+ allow_path_fallback: bool = False,
2194
+ preserve_env_names: tuple[str, ...] = (),
2195
+ fixed_env: dict[str, str] | None = None,
2196
+ ) -> str:
2197
+ return shlex.join(
2198
+ automatic_helper_argv(
2199
+ helper_name,
2200
+ kit_script,
2201
+ shell=shell,
2202
+ allow_path_fallback=allow_path_fallback,
2203
+ preserve_env_names=preserve_env_names,
2204
+ fixed_env=fixed_env,
2205
+ )
2206
+ )
2207
+
2208
+
2209
+ def _secure_owned_regular_path(path: Path, *, executable: bool) -> Path | None:
2210
+ """Validate an approval path and every parent without following symlinks."""
2211
+ try:
2212
+ canonical = path.resolve(strict=True)
2213
+ except OSError:
2214
+ return None
2215
+ if not path.is_absolute() or canonical != path:
2216
+ return None
2217
+ allowed_owners = {0, os.geteuid()}
2218
+ current = Path(path.anchor)
2219
+ components = path.parts[1:]
2220
+ if not components:
2221
+ return None
2222
+ try:
2223
+ root_metadata = os.lstat(current)
2224
+ if (
2225
+ stat.S_ISLNK(root_metadata.st_mode)
2226
+ or not stat.S_ISDIR(root_metadata.st_mode)
2227
+ or root_metadata.st_uid not in allowed_owners
2228
+ or stat.S_IMODE(root_metadata.st_mode) & 0o022
2229
+ ):
2230
+ return None
2231
+ for index, component in enumerate(components):
2232
+ current = current / component
2233
+ metadata = os.lstat(current)
2234
+ is_leaf = index == len(components) - 1
2235
+ if stat.S_ISLNK(metadata.st_mode):
2236
+ return None
2237
+ if metadata.st_uid not in allowed_owners or stat.S_IMODE(metadata.st_mode) & 0o022:
2238
+ return None
2239
+ if is_leaf:
2240
+ if not stat.S_ISREG(metadata.st_mode):
2241
+ return None
2242
+ if executable and not os.access(current, os.X_OK):
2243
+ return None
2244
+ elif not stat.S_ISDIR(metadata.st_mode):
2245
+ return None
2246
+ except OSError:
2247
+ return None
2248
+ return canonical
2249
+
2250
+
2251
+ def _secure_owned_directory_path(path: Path) -> Path | None:
2252
+ try:
2253
+ canonical = path.resolve(strict=True)
2254
+ except OSError:
2255
+ return None
2256
+ if not path.is_absolute() or canonical != path:
2257
+ return None
2258
+ allowed_owners = {0, os.geteuid()}
2259
+ current = Path(path.anchor)
2260
+ try:
2261
+ root_metadata = os.lstat(current)
2262
+ if (
2263
+ stat.S_ISLNK(root_metadata.st_mode)
2264
+ or not stat.S_ISDIR(root_metadata.st_mode)
2265
+ or root_metadata.st_uid not in allowed_owners
2266
+ or stat.S_IMODE(root_metadata.st_mode) & 0o022
2267
+ ):
2268
+ return None
2269
+ for component in path.parts[1:]:
2270
+ current = current / component
2271
+ metadata = os.lstat(current)
2272
+ if (
2273
+ stat.S_ISLNK(metadata.st_mode)
2274
+ or not stat.S_ISDIR(metadata.st_mode)
2275
+ or metadata.st_uid not in allowed_owners
2276
+ or stat.S_IMODE(metadata.st_mode) & 0o022
2277
+ ):
2278
+ return None
2279
+ except OSError:
2280
+ return None
2281
+ return canonical
2282
+
2283
+
2284
+ def _approved_node_runtime() -> Path | None:
2285
+ system_node = shutil.which("node", path=ISOLATED_RUNTIME_PATH)
2286
+ if system_node:
2287
+ approved = _secure_owned_regular_path(Path(system_node), executable=True)
2288
+ if approved is not None:
2289
+ return approved
2290
+
2291
+ allowed_owners = {0, os.geteuid()}
2292
+ for candidate in HOMEBREW_NODE_CANDIDATES:
2293
+ if len(candidate.parents) < 2:
2294
+ continue
2295
+ allowed_root = candidate.parents[1]
2296
+ if _secure_owned_directory_path(allowed_root) is None:
2297
+ continue
2298
+ if _secure_owned_directory_path(candidate.parent) is None:
2299
+ continue
2300
+ try:
2301
+ link_metadata = os.lstat(candidate)
2302
+ physical_target = candidate.resolve(strict=True)
2303
+ physical_target.relative_to(allowed_root)
2304
+ except (OSError, ValueError):
2305
+ continue
2306
+ if link_metadata.st_uid not in allowed_owners:
2307
+ continue
2308
+ approved = _secure_owned_regular_path(physical_target, executable=True)
2309
+ if approved is not None:
2310
+ return approved
2311
+ return None
2312
+
2313
+
2314
+ def _approved_default_omc_hud() -> tuple[Path, Path] | None:
2315
+ """Approve only the effective user's default OMC HUD and fixed-path Node."""
2316
+ try:
2317
+ passwd_home = Path(pwd.getpwuid(os.geteuid()).pw_dir)
2318
+ canonical_home = passwd_home.resolve(strict=True)
2319
+ except (KeyError, OSError, RuntimeError):
2320
+ return None
2321
+ if not passwd_home.is_absolute() or canonical_home != passwd_home:
2322
+ return None
2323
+ omc_script = _secure_owned_regular_path(
2324
+ canonical_home / ".claude" / "hud" / "omc-hud.mjs",
2325
+ executable=False,
2326
+ )
2327
+ if omc_script is None:
2328
+ return None
2329
+ node_runtime = _approved_node_runtime()
2330
+ if node_runtime is None:
2331
+ return None
2332
+ return node_runtime, omc_script
2333
+
2334
+
2335
+ def _statusline_setting(*, allow_path_fallback: bool = False) -> tuple[dict[str, str], bool]:
2336
+ approved_omc = _approved_default_omc_hud()
2337
+ fixed_env = (
2338
+ {"HOME": str(approved_omc[1].parents[2])}
2339
+ if approved_omc is not None
2340
+ else None
2341
+ )
2342
+ argv = automatic_helper_argv(
2343
+ HELPER_STATUSLINE,
2344
+ "statusline_merged.sh",
2345
+ shell="bash",
2346
+ allow_path_fallback=allow_path_fallback,
2347
+ preserve_env_names=STATUSLINE_BEHAVIOR_ENV,
2348
+ fixed_env=fixed_env,
2349
+ )
2350
+ token_path = _helper_path_from_argv(
2351
+ helper_argv(
2352
+ HELPER_STATUSLINE_PLAIN,
2353
+ "statusline.sh",
2354
+ shell="bash",
2355
+ allow_path_fallback=allow_path_fallback,
2356
+ ),
2357
+ label=HELPER_STATUSLINE_PLAIN,
2358
+ )
2359
+ argv.extend(
2360
+ [
2361
+ "--approved-bash",
2362
+ str(_approved_system_runtime("bash")),
2363
+ "--approved-python",
2364
+ str(_approved_python_runtime()),
2365
+ "--approved-token-statusline",
2366
+ str(token_path),
2367
+ ]
2368
+ )
2369
+ if approved_omc is not None:
2370
+ node_runtime, omc_script = approved_omc
2371
+ argv.extend(
2372
+ [
2373
+ "--approved-node",
2374
+ str(node_runtime),
2375
+ "--approved-omc-script",
2376
+ str(omc_script),
2377
+ ]
2378
+ )
2379
+ return {"type": "command", "command": shlex.join(argv)}, approved_omc is not None
2380
+
2381
+
1977
2382
  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)}
2383
+ setting, _omc_included = _statusline_setting(allow_path_fallback=allow_path_fallback)
2384
+ return setting
1979
2385
 
1980
2386
 
1981
- def bash_hook_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
2387
+ def bash_hook_setting(*, allow_path_fallback: bool = False, bash_reference_v1: bool = False) -> dict[str, Any]:
2388
+ command = automatic_helper_command(
2389
+ HELPER_REWRITE_BASH,
2390
+ "rewrite_bash_for_token_budget.py",
2391
+ allow_path_fallback=allow_path_fallback,
2392
+ preserve_env_names=REWRITE_BEHAVIOR_ENV,
2393
+ )
2394
+ if bash_reference_v1:
2395
+ command = f"{command} --bash-reference-v1"
1982
2396
  return {
1983
2397
  "matcher": "Bash",
1984
- "hooks": [{"type": "command", "command": helper_command(HELPER_REWRITE_BASH, "rewrite_bash_for_token_budget.py", allow_path_fallback=allow_path_fallback)}],
2398
+ "hooks": [{"type": "command", "command": command}],
1985
2399
  }
1986
2400
 
1987
2401
 
2402
+ def load_bash_reference_policy() -> object | None:
2403
+ """Load only the package-local runtime policy, never an import from PATH."""
2404
+ path = Path(__file__).resolve().parent / "bash_reference_policy.py"
2405
+ flags = (
2406
+ os.O_RDONLY
2407
+ | getattr(os, "O_CLOEXEC", 0)
2408
+ | getattr(os, "O_NONBLOCK", 0)
2409
+ | getattr(os, "O_NOCTTY", 0)
2410
+ )
2411
+ if not hasattr(os, "O_NOFOLLOW"):
2412
+ return None
2413
+ flags |= os.O_NOFOLLOW
2414
+ fd = -1
2415
+ try:
2416
+ fd = os.open(path, flags)
2417
+ metadata = os.fstat(fd)
2418
+ if (
2419
+ not stat.S_ISREG(metadata.st_mode)
2420
+ or metadata.st_nlink != 1
2421
+ or metadata.st_size > BASH_REFERENCE_POLICY_MAX_BYTES
2422
+ ):
2423
+ return None
2424
+ source = os.read(fd, BASH_REFERENCE_POLICY_MAX_BYTES + 1)
2425
+ if len(source) > BASH_REFERENCE_POLICY_MAX_BYTES:
2426
+ return None
2427
+ source_text = source.decode("utf-8", errors="strict")
2428
+ except (OSError, UnicodeDecodeError):
2429
+ return None
2430
+ finally:
2431
+ if fd >= 0:
2432
+ os.close(fd)
2433
+ module_name = f"_context_guard_setup_reference_policy_{os.getpid()}"
2434
+ module = types.ModuleType(module_name)
2435
+ module.__file__ = str(path)
2436
+ module.__package__ = ""
2437
+ sys.modules[module_name] = module
2438
+ try:
2439
+ exec(compile(source_text, str(path), "exec"), module.__dict__)
2440
+ except Exception:
2441
+ sys.modules.pop(module_name, None)
2442
+ return None
2443
+ return module
2444
+
2445
+
2446
+ def bash_reference_adapter_readiness(root: Path) -> tuple[bool, str]:
2447
+ """Return the runtime adapter verdict for the effective setup project."""
2448
+ policy = load_bash_reference_policy()
2449
+ discover = getattr(policy, "discover_adapter", None)
2450
+ if policy is None:
2451
+ return False, "receipt_policy_unavailable"
2452
+ if not callable(discover):
2453
+ return False, "receipt_policy_invalid"
2454
+ try:
2455
+ discovered = discover(root)
2456
+ except Exception:
2457
+ return False, "receipt_policy_load_failed"
2458
+ if not isinstance(discovered, tuple) or len(discovered) != 2:
2459
+ return False, "receipt_policy_invalid"
2460
+ adapter, reason = discovered
2461
+ adapter_methods = ("start_broker", "query_reference")
2462
+ if (
2463
+ adapter is not None
2464
+ and reason == "receipt_adapter_available"
2465
+ and all(callable(getattr(adapter, name, None)) for name in adapter_methods)
2466
+ ):
2467
+ return True, reason
2468
+ if adapter is not None and reason == "receipt_adapter_available":
2469
+ return False, "receipt_adapter_invalid"
2470
+ if not isinstance(reason, str) or re.fullmatch(r"[a-z0-9_]{1,96}", reason) is None:
2471
+ return False, "receipt_policy_invalid"
2472
+ return False, reason
2473
+
2474
+
2475
+ def bash_reference_unavailable_message(reason: str) -> str:
2476
+ return (
2477
+ f"{_BASH_REFERENCE_UNAVAILABLE}: reason={reason}; requires an exact paired npm install "
2478
+ f"of {ROOT_PACKAGE_NAME} and {RECEIPT_PACKAGE_NAME}; "
2479
+ f"recovery={_BASH_REFERENCE_RECOVERY}; ordinary Bash trimming remains enabled"
2480
+ )
2481
+
2482
+
2483
+ def disable_unavailable_bash_reference(
2484
+ choices: Choices,
2485
+ warnings: list[str],
2486
+ *,
2487
+ root: Path,
2488
+ ) -> list[str]:
2489
+ """Fail closed without removing the ordinary Bash trimming choice."""
2490
+ if not choices.bash_reference_v1:
2491
+ return []
2492
+ available, reason = bash_reference_adapter_readiness(root)
2493
+ if available:
2494
+ return []
2495
+ choices.bash_reference_v1 = False
2496
+ warning = bash_reference_unavailable_message(reason)
2497
+ warnings.append(warning)
2498
+ return [warning]
2499
+
2500
+
1988
2501
  def read_hook_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
1989
2502
  return {
1990
2503
  "matcher": "Read",
1991
- "hooks": [{"type": "command", "command": helper_command(HELPER_GUARD_READ, "guard_large_read.py", allow_path_fallback=allow_path_fallback)}],
2504
+ "hooks": [{"type": "command", "command": automatic_helper_command(
2505
+ HELPER_GUARD_READ,
2506
+ "guard_large_read.py",
2507
+ allow_path_fallback=allow_path_fallback,
2508
+ preserve_env_names=READ_GUARD_BEHAVIOR_ENV,
2509
+ )}],
1992
2510
  }
1993
2511
 
1994
2512
 
1995
2513
  def failed_nudge_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
1996
2514
  return {
1997
2515
  "matcher": "Bash",
1998
- "hooks": [{"type": "command", "command": helper_command(HELPER_FAILED_NUDGE, "failed_attempt_nudge.py", allow_path_fallback=allow_path_fallback)}],
2516
+ "hooks": [{"type": "command", "command": automatic_helper_command(HELPER_FAILED_NUDGE, "failed_attempt_nudge.py", allow_path_fallback=allow_path_fallback)}],
1999
2517
  }
2000
2518
 
2001
2519
 
@@ -2020,6 +2538,18 @@ def command_helper_basenames(command: str) -> set[str]:
2020
2538
  index = 0
2021
2539
  if os.path.basename(parts[index]) == "env":
2022
2540
  index += 1
2541
+ while index < len(parts):
2542
+ token = parts[index]
2543
+ if token in {"-i", "--ignore-environment"}:
2544
+ index += 1
2545
+ continue
2546
+ if token in {"-u", "--unset"} and index + 1 < len(parts):
2547
+ index += 2
2548
+ continue
2549
+ if token.startswith("--unset="):
2550
+ index += 1
2551
+ continue
2552
+ break
2023
2553
  while index < len(parts) and "=" in parts[index] and not parts[index].startswith("-"):
2024
2554
  index += 1
2025
2555
  if index >= len(parts):
@@ -2042,6 +2572,139 @@ def command_helper_basenames(command: str) -> set[str]:
2042
2572
  return {head}
2043
2573
 
2044
2574
 
2575
+ def _statusline_candidate_paths(*, merged: bool) -> set[Path]:
2576
+ script_dir = Path(__file__).resolve().parent
2577
+ helper_key = HELPER_STATUSLINE if merged else HELPER_STATUSLINE_PLAIN
2578
+ kit_script = "statusline_merged.sh" if merged else "statusline.sh"
2579
+ names = HELPER_EQUIVALENT_BASENAMES[helper_key]
2580
+ raw_candidates = {script_dir / kit_script}
2581
+ for name in names:
2582
+ raw_candidates.add(script_dir / name)
2583
+ raw_candidates.add(
2584
+ script_dir.parent / "plugins" / "context-guard" / "bin" / name
2585
+ )
2586
+ candidates: set[Path] = set()
2587
+ for candidate in raw_candidates:
2588
+ try:
2589
+ candidates.add(candidate.resolve(strict=True))
2590
+ except OSError:
2591
+ continue
2592
+ return candidates
2593
+
2594
+
2595
+ def _authenticated_statusline_path(raw: str, *, merged: bool) -> Path | None:
2596
+ candidate = Path(raw)
2597
+ if not candidate.is_absolute():
2598
+ candidate = Path.cwd() / candidate
2599
+ try:
2600
+ canonical = candidate.resolve(strict=True)
2601
+ except OSError:
2602
+ return None
2603
+ return canonical if canonical in _statusline_candidate_paths(merged=merged) else None
2604
+
2605
+
2606
+ def exact_known_statusline_command(command: str) -> bool:
2607
+ """Match only authenticated complete historical merged-statusline commands."""
2608
+ try:
2609
+ parts = shlex.split(command) if command else []
2610
+ except ValueError:
2611
+ return False
2612
+ if not parts:
2613
+ return False
2614
+ known_helpers = HELPER_EQUIVALENT_BASENAMES[HELPER_STATUSLINE]
2615
+ direct_head = parts[0]
2616
+ if len(parts) == 1:
2617
+ if direct_head in known_helpers:
2618
+ return True
2619
+ return _authenticated_statusline_path(direct_head, merged=True) is not None
2620
+
2621
+ index = 0
2622
+ generated_shape = False
2623
+ assignments: dict[str, str] = {}
2624
+ approved_env = str(_approved_system_runtime("env"))
2625
+ if direct_head == approved_env:
2626
+ generated_shape = True
2627
+ index += 1
2628
+ if index >= len(parts) or parts[index] != "-i":
2629
+ return False
2630
+ index += 1
2631
+ allowed_assignments = {
2632
+ "PATH",
2633
+ "LC_ALL",
2634
+ "HOME",
2635
+ *STATUSLINE_BEHAVIOR_ENV,
2636
+ }
2637
+ while index < len(parts) and "=" in parts[index] and not parts[index].startswith("-"):
2638
+ name, _separator, value = parts[index].partition("=")
2639
+ if name not in allowed_assignments or name in assignments:
2640
+ return False
2641
+ assignments[name] = value
2642
+ index += 1
2643
+ if assignments.get("PATH") != ISOLATED_RUNTIME_PATH or assignments.get("LC_ALL") != "C":
2644
+ return False
2645
+ for name, value in assignments.items():
2646
+ if name in STATUSLINE_BEHAVIOR_ENV and not BEHAVIOR_ENV_VALUE_RE.fullmatch(value):
2647
+ return False
2648
+
2649
+ approved_bash = str(_approved_system_runtime("bash"))
2650
+ if index >= len(parts):
2651
+ return False
2652
+ shell = parts[index]
2653
+ if generated_shape:
2654
+ if shell != approved_bash:
2655
+ return False
2656
+ elif shell not in {"bash", "sh", approved_bash}:
2657
+ return False
2658
+ index += 1
2659
+ if generated_shape:
2660
+ if parts[index : index + 2] != ["--noprofile", "--norc"]:
2661
+ return False
2662
+ index += 2
2663
+ else:
2664
+ while index < len(parts) and parts[index] in {"--noprofile", "--norc"}:
2665
+ index += 1
2666
+ if index >= len(parts):
2667
+ return False
2668
+ if _authenticated_statusline_path(parts[index], merged=True) is None:
2669
+ return False
2670
+ index += 1
2671
+ if not generated_shape:
2672
+ return index == len(parts)
2673
+
2674
+ required_prefix = [
2675
+ "--approved-bash",
2676
+ approved_bash,
2677
+ "--approved-python",
2678
+ str(_approved_python_runtime()),
2679
+ "--approved-token-statusline",
2680
+ ]
2681
+ if parts[index : index + len(required_prefix)] != required_prefix:
2682
+ return False
2683
+ index += len(required_prefix)
2684
+ if index >= len(parts):
2685
+ return False
2686
+ if _authenticated_statusline_path(parts[index], merged=False) is None:
2687
+ return False
2688
+ index += 1
2689
+
2690
+ approved_omc = _approved_default_omc_hud()
2691
+ if index == len(parts):
2692
+ return "HOME" not in assignments
2693
+ if approved_omc is None or len(parts) - index != 4:
2694
+ return False
2695
+ node_runtime, omc_script = approved_omc
2696
+ expected_omc = [
2697
+ "--approved-node",
2698
+ str(node_runtime),
2699
+ "--approved-omc-script",
2700
+ str(omc_script),
2701
+ ]
2702
+ return (
2703
+ parts[index:] == expected_omc
2704
+ and assignments.get("HOME") == str(omc_script.parents[2])
2705
+ )
2706
+
2707
+
2045
2708
  def equivalent_helper_basenames(command: str) -> set[str]:
2046
2709
  bases = command_helper_basenames(command)
2047
2710
  equivalents = set(bases)
@@ -2050,13 +2713,132 @@ def equivalent_helper_basenames(command: str) -> set[str]:
2050
2713
  return equivalents
2051
2714
 
2052
2715
 
2053
- def command_matches_existing_or_equivalent(existing: str, desired: str) -> bool:
2716
+ def _generic_hook_spec(desired: str) -> tuple[str, str, tuple[str, ...]] | None:
2717
+ desired_bases = command_helper_basenames(desired)
2718
+ specs = (
2719
+ (HELPER_REWRITE_BASH, "rewrite_bash_for_token_budget.py", REWRITE_BEHAVIOR_ENV),
2720
+ (HELPER_GUARD_READ, "guard_large_read.py", READ_GUARD_BEHAVIOR_ENV),
2721
+ (HELPER_FAILED_NUDGE, "failed_attempt_nudge.py", ()),
2722
+ )
2723
+ for helper_name, kit_script, behavior_env in specs:
2724
+ if desired_bases & HELPER_EQUIVALENT_BASENAMES[helper_name]:
2725
+ return helper_name, kit_script, behavior_env
2726
+ return None
2727
+
2728
+
2729
+ def _generic_hook_candidate_paths(helper_name: str, kit_script: str) -> set[Path]:
2730
+ script_dir = Path(__file__).resolve().parent
2731
+ raw_candidates = {script_dir / kit_script}
2732
+ for name in HELPER_EQUIVALENT_BASENAMES[helper_name]:
2733
+ raw_candidates.add(script_dir / name)
2734
+ raw_candidates.add(
2735
+ script_dir.parent / "plugins" / "context-guard" / "bin" / name
2736
+ )
2737
+ candidates: set[Path] = set()
2738
+ for candidate in raw_candidates:
2739
+ try:
2740
+ candidates.add(candidate.resolve(strict=True))
2741
+ except OSError:
2742
+ continue
2743
+ return candidates
2744
+
2745
+
2746
+ def _authenticated_generic_hook_path(
2747
+ raw: str,
2748
+ *,
2749
+ helper_name: str,
2750
+ kit_script: str,
2751
+ ) -> Path | None:
2752
+ candidate = Path(raw)
2753
+ if not candidate.is_absolute():
2754
+ candidate = Path.cwd() / candidate
2755
+ try:
2756
+ canonical = candidate.resolve(strict=True)
2757
+ except OSError:
2758
+ return None
2759
+ candidates = _generic_hook_candidate_paths(helper_name, kit_script)
2760
+ return canonical if canonical in candidates else None
2761
+
2762
+
2763
+ def exact_known_hook_command(existing: str, desired: str) -> bool:
2054
2764
  if command_matches(existing, desired):
2055
2765
  return True
2056
- desired_helpers = equivalent_helper_basenames(desired)
2057
- if not desired_helpers:
2766
+ spec = _generic_hook_spec(desired)
2767
+ if spec is None:
2768
+ return False
2769
+ helper_name, kit_script, behavior_env = spec
2770
+ known_names = HELPER_EQUIVALENT_BASENAMES[helper_name]
2771
+ try:
2772
+ parts = shlex.split(existing) if existing else []
2773
+ except ValueError:
2774
+ return False
2775
+ if not parts:
2776
+ return False
2777
+ if len(parts) == 1:
2778
+ if parts[0] in known_names:
2779
+ return True
2780
+ return _authenticated_generic_hook_path(
2781
+ parts[0],
2782
+ helper_name=helper_name,
2783
+ kit_script=kit_script,
2784
+ ) is not None
2785
+
2786
+ index = 0
2787
+ generated_shape = parts[0] == str(_approved_system_runtime("env"))
2788
+ if generated_shape:
2789
+ index = 1
2790
+ if index >= len(parts) or parts[index] != "-i":
2791
+ return False
2792
+ index += 1
2793
+ assignments: dict[str, str] = {}
2794
+ allowed_assignments = {"PATH", "LC_ALL", *behavior_env}
2795
+ while index < len(parts) and "=" in parts[index] and not parts[index].startswith("-"):
2796
+ name, _separator, value = parts[index].partition("=")
2797
+ if name not in allowed_assignments or name in assignments:
2798
+ return False
2799
+ assignments[name] = value
2800
+ index += 1
2801
+ if assignments.get("PATH") != ISOLATED_RUNTIME_PATH or assignments.get("LC_ALL") != "C":
2802
+ return False
2803
+ for name, value in assignments.items():
2804
+ if name in behavior_env and not BEHAVIOR_ENV_VALUE_RE.fullmatch(value):
2805
+ return False
2806
+
2807
+ if index >= len(parts):
2808
+ return False
2809
+ python_runtime = parts[index]
2810
+ if generated_shape:
2811
+ if python_runtime != str(_approved_python_runtime()):
2812
+ return False
2813
+ elif (
2814
+ "/" in python_runtime
2815
+ or re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", python_runtime) is None
2816
+ ):
2817
+ return False
2818
+ index += 1
2819
+ if index < len(parts) and parts[index] == "-I":
2820
+ index += 1
2821
+ elif generated_shape:
2822
+ return False
2823
+ if index >= len(parts):
2824
+ return False
2825
+ if _authenticated_generic_hook_path(
2826
+ parts[index],
2827
+ helper_name=helper_name,
2828
+ kit_script=kit_script,
2829
+ ) is None:
2058
2830
  return False
2059
- return bool(command_helper_basenames(existing) & desired_helpers)
2831
+ index += 1
2832
+ if index == len(parts):
2833
+ return True
2834
+ return (
2835
+ helper_name == HELPER_REWRITE_BASH
2836
+ and parts[index:] == ["--bash-reference-v1"]
2837
+ )
2838
+
2839
+
2840
+ def command_matches_existing_or_equivalent(existing: str, desired: str) -> bool:
2841
+ return exact_known_hook_command(existing, desired)
2060
2842
 
2061
2843
 
2062
2844
  def canonicalize_equivalent_command(value: Any, desired: str) -> tuple[bool, bool]:
@@ -2250,14 +3032,21 @@ def doctor_check(
2250
3032
  return check
2251
3033
 
2252
3034
 
2253
- def _setup_command(args: argparse.Namespace, *, apply: bool, root: Path | None = None) -> str:
2254
- parts = ["context-guard", "setup", "--scope", normalize_scope(getattr(args, "scope", "project"))]
2255
- if root is not None and normalize_scope(getattr(args, "scope", "project")) == "project":
3035
+ def _setup_command(
3036
+ args: argparse.Namespace,
3037
+ *,
3038
+ apply: bool,
3039
+ root: Path | None = None,
3040
+ scope: str | None = None,
3041
+ ) -> str:
3042
+ scope = scope or normalize_scope(getattr(args, "scope", "project"))
3043
+ parts = ["context-guard", "setup", "--scope", scope]
3044
+ if root is not None and scope == "project":
2256
3045
  parts.extend(["--root", str(root)])
2257
3046
  selected = explicit_agent_selection(args)
2258
3047
  if selected:
2259
3048
  parts.extend(["--agent", ",".join(selected)])
2260
- elif normalize_scope(getattr(args, "scope", "project")) == "user":
3049
+ elif scope == "user":
2261
3050
  parts.extend(["--agent", "claude"])
2262
3051
  if getattr(args, "allow_path_helper_fallback", False):
2263
3052
  parts.append("--allow-path-helper-fallback")
@@ -2348,7 +3137,11 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
2348
3137
  writing settings, writing rule files, or creating rollback records.
2349
3138
  """
2350
3139
  require_no_follow_file_ops_supported()
2351
- scope = normalize_scope(getattr(args, "scope", "project"))
3140
+ scope = effective_scope(
3141
+ args.root,
3142
+ getattr(args, "scope", "project"),
3143
+ allow_home_settings=bool(getattr(args, "allow_home_settings", False)),
3144
+ )
2352
3145
  root = resolve_scope_root(args.root, scope)
2353
3146
  settings_path = root / SETTINGS_REL
2354
3147
  helper_check = _helper_availability_check(include_diet=not getattr(args, "no_diet_scan", False), allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False)))
@@ -2440,7 +3233,28 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
2440
3233
  }
2441
3234
 
2442
3235
  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 []
3236
+ reference_actions = disable_unavailable_bash_reference(
3237
+ choices,
3238
+ warnings,
3239
+ root=root,
3240
+ )
3241
+ if reference_actions:
3242
+ checks.append(doctor_check(
3243
+ "bash-reference-distribution",
3244
+ "warning",
3245
+ "medium",
3246
+ reference_actions[0],
3247
+ next_action=_BASH_REFERENCE_RECOVERY + ".",
3248
+ ))
3249
+ actions = reference_actions + (
3250
+ apply_choices(
3251
+ settings,
3252
+ choices,
3253
+ allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False)),
3254
+ )
3255
+ if claude_targeted
3256
+ else []
3257
+ )
2444
3258
  changed = (settings != original) if claude_targeted else False
2445
3259
  if changed:
2446
3260
  checks.append(doctor_check(
@@ -2449,7 +3263,7 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
2449
3263
  "medium",
2450
3264
  "ContextGuard setup is not fully applied for the requested selections.",
2451
3265
  detail={"planned_action_count": len(actions), "planned_actions": actions},
2452
- next_action=_setup_command(args, apply=False, root=root),
3266
+ next_action=_setup_command(args, apply=False, root=root, scope=scope),
2453
3267
  ))
2454
3268
  else:
2455
3269
  checks.append(doctor_check(
@@ -2484,7 +3298,7 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
2484
3298
  "medium",
2485
3299
  "Some requested adapters still have planned or unsupported setup actions.",
2486
3300
  detail={"adapters": adapter_warnings},
2487
- next_action=_setup_command(args, apply=False, root=root),
3301
+ next_action=_setup_command(args, apply=False, root=root, scope=scope),
2488
3302
  ))
2489
3303
  else:
2490
3304
  checks.append(doctor_check(
@@ -2538,9 +3352,9 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
2538
3352
  detail=diet_scan,
2539
3353
  ))
2540
3354
 
2541
- recommended = [_setup_command(args, apply=False, root=root)]
3355
+ recommended = [_setup_command(args, apply=False, root=root, scope=scope)]
2542
3356
  if changed or adapter_warnings:
2543
- recommended.append(_setup_command(args, apply=True, root=root))
3357
+ recommended.append(_setup_command(args, apply=True, root=root, scope=scope))
2544
3358
  return {
2545
3359
  "schema_version": "contextguard.doctor.v1",
2546
3360
  "status": _doctor_status(checks),
@@ -2596,12 +3410,29 @@ def apply_choices(settings: dict[str, Any], choices: Choices, *, allow_path_fall
2596
3410
  settings["effortLevel"] = DEFAULT_EFFORT
2597
3411
  actions.append(f"set default effortLevel to {DEFAULT_EFFORT}")
2598
3412
  if choices.statusline:
2599
- statusline = statusline_setting(allow_path_fallback=allow_path_fallback)
3413
+ statusline, omc_included = _statusline_setting(allow_path_fallback=allow_path_fallback)
2600
3414
  if "statusLine" not in settings:
2601
3415
  settings["statusLine"] = statusline
2602
3416
  actions.append("enabled token statusline")
3417
+ if omc_included:
3418
+ actions.append("included setup-approved OMC HUD")
2603
3419
  elif settings.get("statusLine") != statusline:
2604
- actions.append("kept existing statusLine; add context-guard-statusline-merged manually if desired")
3420
+ existing_statusline = settings.get("statusLine")
3421
+ existing_command = (
3422
+ existing_statusline.get("command")
3423
+ if isinstance(existing_statusline, dict)
3424
+ else None
3425
+ )
3426
+ if (
3427
+ isinstance(existing_command, str)
3428
+ and exact_known_statusline_command(existing_command)
3429
+ ):
3430
+ settings["statusLine"] = statusline
3431
+ actions.append("migrated token statusline")
3432
+ if omc_included:
3433
+ actions.append("included setup-approved OMC HUD")
3434
+ else:
3435
+ actions.append("kept existing statusLine; add context-guard-statusline-merged manually if desired")
2605
3436
  if choices.denies:
2606
3437
  ensure_permissions(
2607
3438
  settings,
@@ -2609,9 +3440,16 @@ def apply_choices(settings: dict[str, Any], choices: Choices, *, allow_path_fall
2609
3440
  migrate_env_read_denies=choices.read_guard,
2610
3441
  )
2611
3442
  if choices.bash_hook:
2612
- bash_hook = bash_hook_setting(allow_path_fallback=allow_path_fallback)
3443
+ bash_hook = bash_hook_setting(
3444
+ allow_path_fallback=allow_path_fallback,
3445
+ bash_reference_v1=choices.bash_reference_v1,
3446
+ )
2613
3447
  bash_command = bash_hook["hooks"][0]["command"]
2614
3448
  ensure_pre_tool_hook(settings, bash_hook, bash_command, "Bash trim/sanitize", actions)
3449
+ if choices.bash_reference_v1:
3450
+ actions.append(
3451
+ "enabled bash_reference_v1: a scoped 7-day bearer handle may appear in Claude/provider-visible transcripts"
3452
+ )
2615
3453
  if choices.read_guard:
2616
3454
  read_hook = read_hook_setting(allow_path_fallback=allow_path_fallback)
2617
3455
  read_command = read_hook["hooks"][0]["command"]
@@ -3004,6 +3842,10 @@ def interactive_choices(defaults: Choices) -> Choices:
3004
3842
  denies=prompt_bool("Add deny rules for bulky/sensitive paths?", defaults.denies),
3005
3843
  statusline=prompt_bool("Enable token/cost statusline?", defaults.statusline),
3006
3844
  bash_hook=prompt_bool("Enable Bash output trim + grep/diff sanitizer hook?", defaults.bash_hook),
3845
+ bash_reference_v1=prompt_bool(
3846
+ "Enable optional Bash receipt references? 7-day scoped bearer handles are visible to Claude/provider transcripts",
3847
+ defaults.bash_reference_v1,
3848
+ ),
3007
3849
  read_guard=prompt_bool("Enable large Read guard?", defaults.read_guard),
3008
3850
  model_defaults=prompt_bool("Set missing defaults to model=sonnet and effortLevel=medium?", defaults.model_defaults),
3009
3851
  failed_attempt_nudge=prompt_bool(
@@ -3019,6 +3861,7 @@ def choices_from_args(args: argparse.Namespace) -> Choices:
3019
3861
  denies=not args.no_denies,
3020
3862
  statusline=not args.no_statusline,
3021
3863
  bash_hook=not args.no_bash_hook,
3864
+ bash_reference_v1=getattr(args, "bash_reference_v1", False),
3022
3865
  read_guard=not args.no_read_guard,
3023
3866
  model_defaults=not args.no_model_defaults,
3024
3867
  failed_attempt_nudge=(
@@ -3280,7 +4123,11 @@ def render_quiet_narration_text(result: dict[str, Any]) -> str:
3280
4123
 
3281
4124
  def run(args: argparse.Namespace) -> SetupResult:
3282
4125
  require_no_follow_file_ops_supported()
3283
- scope = normalize_scope(getattr(args, "scope", "project"))
4126
+ scope = effective_scope(
4127
+ args.root,
4128
+ getattr(args, "scope", "project"),
4129
+ allow_home_settings=bool(getattr(args, "allow_home_settings", False)),
4130
+ )
3284
4131
  root = resolve_scope_root(args.root, scope)
3285
4132
  settings_path = root / SETTINGS_REL
3286
4133
  warnings: list[str] = []
@@ -3327,7 +4174,20 @@ def run(args: argparse.Namespace) -> SetupResult:
3327
4174
  if interactive:
3328
4175
  choices = interactive_choices(choices)
3329
4176
 
3330
- actions = apply_choices(settings, choices, allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False))) if claude_targeted else []
4177
+ reference_actions = disable_unavailable_bash_reference(
4178
+ choices,
4179
+ warnings,
4180
+ root=root,
4181
+ )
4182
+ actions = reference_actions + (
4183
+ apply_choices(
4184
+ settings,
4185
+ choices,
4186
+ allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False)),
4187
+ )
4188
+ if claude_targeted
4189
+ else []
4190
+ )
3331
4191
  changed = (settings != original) if claude_targeted else False
3332
4192
 
3333
4193
  apply_requested = bool(args.yes and not args.dry_run and not args.plan)
@@ -3481,6 +4341,19 @@ def build_parser() -> argparse.ArgumentParser:
3481
4341
  parser.add_argument("--no-denies", action="store_true", help="skip recommended permissions.deny rules")
3482
4342
  parser.add_argument("--no-statusline", action="store_true", help="skip token statusline")
3483
4343
  parser.add_argument("--no-bash-hook", action="store_true", help="skip Bash trim/sanitize hook")
4344
+ reference_group = parser.add_mutually_exclusive_group()
4345
+ reference_group.add_argument(
4346
+ "--bash-reference-v1",
4347
+ action="store_true",
4348
+ help="opt in to 7-day scoped receipt references in the Bash hook; handles are provider-visible",
4349
+ )
4350
+ reference_group.add_argument(
4351
+ "--no-bash-reference-v1",
4352
+ dest="bash_reference_v1",
4353
+ action="store_false",
4354
+ help="disable/remove the optional Bash receipt-reference hook flag (default)",
4355
+ )
4356
+ parser.set_defaults(bash_reference_v1=False)
3484
4357
  parser.add_argument("--no-read-guard", action="store_true", help="skip large Read guard hook")
3485
4358
  parser.add_argument("--no-model-defaults", action="store_true", help="skip model/effort defaults")
3486
4359
  parser.add_argument("--no-diet-scan", action="store_true", help="skip the read-only diet scan summary after applying setup")