@ictechgy/context-guard 0.4.9 → 0.4.11

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 (64) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.ko.md +59 -31
  3. package/README.md +85 -36
  4. package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
  5. package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
  6. package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
  7. package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
  8. package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
  9. package/docs/benchmark-workflow-examples.md +3 -0
  10. package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
  11. package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
  12. package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
  13. package/docs/distribution.md +10 -7
  14. package/docs/experimental-benchmark-fixtures.md +30 -6
  15. package/package.json +4 -6
  16. package/packaging/homebrew/context-guard.rb.template +1 -1
  17. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  18. package/plugins/context-guard/README.ko.md +20 -14
  19. package/plugins/context-guard/README.md +26 -17
  20. package/plugins/context-guard/bin/context-guard +147 -25
  21. package/plugins/context-guard/bin/context-guard-artifact +884 -79
  22. package/plugins/context-guard/bin/context-guard-audit +33 -2
  23. package/plugins/context-guard/bin/context-guard-bench +1542 -31
  24. package/plugins/context-guard/bin/context-guard-cache-score +665 -0
  25. package/plugins/context-guard/bin/context-guard-compress +146 -1
  26. package/plugins/context-guard/bin/context-guard-cost +790 -6
  27. package/plugins/context-guard/bin/context-guard-experiments +463 -26
  28. package/plugins/context-guard/bin/context-guard-failed-nudge +9 -2
  29. package/plugins/context-guard/bin/context-guard-filter +163 -7
  30. package/plugins/context-guard/bin/context-guard-guard-read +3 -0
  31. package/plugins/context-guard/bin/context-guard-pack +892 -49
  32. package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
  33. package/plugins/context-guard/bin/context-guard-sanitize-output +76 -12
  34. package/plugins/context-guard/bin/context-guard-setup +165 -31
  35. package/plugins/context-guard/bin/context-guard-statusline +490 -283
  36. package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
  37. package/plugins/context-guard/bin/context-guard-tool-prune +480 -53
  38. package/plugins/context-guard/bin/context-guard-trim-output +288 -41
  39. package/plugins/context-guard/brief/README.md +5 -5
  40. package/plugins/context-guard/lib/context_guard_commands.py +230 -0
  41. package/plugins/context-guard/skills/setup/SKILL.md +1 -0
  42. package/context-guard-kit/README.md +0 -91
  43. package/context-guard-kit/benchmark_runner.py +0 -2401
  44. package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
  45. package/context-guard-kit/context_compress.py +0 -695
  46. package/context-guard-kit/context_escrow.py +0 -935
  47. package/context-guard-kit/context_filter.py +0 -637
  48. package/context-guard-kit/context_guard_cli.py +0 -325
  49. package/context-guard-kit/context_guard_diet.py +0 -1711
  50. package/context-guard-kit/context_pack.py +0 -2713
  51. package/context-guard-kit/cost_guard.py +0 -2349
  52. package/context-guard-kit/experimental_registry.py +0 -4348
  53. package/context-guard-kit/failed_attempt_nudge.py +0 -567
  54. package/context-guard-kit/guard_large_read.py +0 -690
  55. package/context-guard-kit/hook_secret_patterns.py +0 -43
  56. package/context-guard-kit/read_symbol.py +0 -483
  57. package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
  58. package/context-guard-kit/sanitize_output.py +0 -725
  59. package/context-guard-kit/settings.example.json +0 -67
  60. package/context-guard-kit/setup_wizard.py +0 -2515
  61. package/context-guard-kit/statusline.sh +0 -362
  62. package/context-guard-kit/statusline_merged.sh +0 -157
  63. package/context-guard-kit/tool_schema_pruner.py +0 -837
  64. package/context-guard-kit/trim_command_output.py +0 -1449
@@ -424,6 +424,9 @@ def build_sanitized_command(wrapper: str, command: str) -> str:
424
424
 
425
425
 
426
426
  def main() -> int:
427
+ if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
428
+ print("ContextGuard helper: context-guard-rewrite-bash")
429
+ return 0
427
430
  try:
428
431
  payload = json.load(sys.stdin)
429
432
  except json.JSONDecodeError as exc:
@@ -8,6 +8,7 @@ keeps only bounded head/anchor/tail context when output is too large.
8
8
  from __future__ import annotations
9
9
 
10
10
  import argparse
11
+ import codecs
11
12
  import collections
12
13
  import hashlib
13
14
  import os
@@ -19,7 +20,7 @@ import subprocess
19
20
  import sys
20
21
  import threading
21
22
  import time
22
- from typing import Iterable, Iterator, TextIO
23
+ from typing import BinaryIO, Iterable, Iterator, TextIO
23
24
 
24
25
  TERMINAL_CONTROL_RE = re.compile(
25
26
  r"(?:"
@@ -112,6 +113,9 @@ MAX_SECTION_LINES_LIMIT = 2_000
112
113
  DEFAULT_TIMEOUT_SECONDS = 600
113
114
  MAX_TIMEOUT_SECONDS = 86_400
114
115
  TIMEOUT_EXIT_CODE = 124
116
+ COMMAND_READ_CHUNK_BYTES = 64 * 1024
117
+ COMMAND_MAX_UNTERMINATED_LINE_CHARS = 4_096
118
+ RAW_TRUNCATION_REDACTION_HOLDBACK_CHARS = 1_024
115
119
 
116
120
 
117
121
  def bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
@@ -520,14 +524,16 @@ def terminate_process_tree(
520
524
  class TimedCommandStream:
521
525
  def __init__(
522
526
  self,
523
- proc: subprocess.Popen[str],
524
- stdout: TextIO,
527
+ proc: subprocess.Popen[bytes],
528
+ stdout: BinaryIO,
525
529
  *,
526
530
  timeout_seconds: int,
531
+ max_line_chars: int = MAX_LINE_CHARS_LIMIT,
527
532
  process_group_id: int | None = None,
528
533
  ) -> None:
529
534
  self.proc = proc
530
535
  self.timeout_seconds = timeout_seconds
536
+ self.max_unterminated_line_chars = max(1, max_line_chars)
531
537
  self.process_group_id = process_group_id
532
538
  self.deadline = time.monotonic() + timeout_seconds
533
539
  self.timed_out = False
@@ -537,10 +543,62 @@ class TimedCommandStream:
537
543
  self._thread = threading.Thread(target=self._read_stdout, args=(stdout,), daemon=True)
538
544
  self._thread.start()
539
545
 
540
- def _read_stdout(self, stdout: TextIO) -> None:
546
+ def _truncated_raw_line(self, text: str) -> str:
547
+ holdback = min(RAW_TRUNCATION_REDACTION_HOLDBACK_CHARS, self.max_unterminated_line_chars)
548
+ safe_keep = max(0, self.max_unterminated_line_chars - holdback)
549
+ return (
550
+ text[:safe_keep]
551
+ + (
552
+ "...[context-guard-kit: raw line truncated before newline "
553
+ f"after {self.max_unterminated_line_chars} chars; "
554
+ f"withheld {holdback} boundary chars for redaction safety]\n"
555
+ )
556
+ )
557
+
558
+ def _read_stdout(self, stdout: BinaryIO) -> None:
559
+ decoder = codecs.getincrementaldecoder("utf-8")("replace")
560
+ pending = ""
561
+ discarding_oversized_line = False
562
+
563
+ def feed(text: str) -> None:
564
+ nonlocal pending, discarding_oversized_line
565
+ if not text:
566
+ return
567
+ pending += text
568
+ while pending:
569
+ if discarding_oversized_line:
570
+ newline_index = pending.find("\n")
571
+ if newline_index == -1:
572
+ pending = ""
573
+ return
574
+ pending = pending[newline_index + 1 :]
575
+ discarding_oversized_line = False
576
+ continue
577
+
578
+ newline_index = pending.find("\n")
579
+ if newline_index != -1:
580
+ if newline_index > self.max_unterminated_line_chars:
581
+ self._queue.put(self._truncated_raw_line(pending))
582
+ else:
583
+ self._queue.put(pending[: newline_index + 1])
584
+ pending = pending[newline_index + 1 :]
585
+ continue
586
+
587
+ if len(pending) > self.max_unterminated_line_chars:
588
+ self._queue.put(self._truncated_raw_line(pending))
589
+ pending = ""
590
+ discarding_oversized_line = True
591
+ return
592
+
541
593
  try:
542
- for line in stdout:
543
- self._queue.put(line)
594
+ while True:
595
+ chunk = stdout.read(COMMAND_READ_CHUNK_BYTES)
596
+ if not chunk:
597
+ break
598
+ feed(decoder.decode(chunk, final=False))
599
+ feed(decoder.decode(b"", final=True))
600
+ if pending and not discarding_oversized_line:
601
+ self._queue.put(pending)
544
602
  finally:
545
603
  self._stream_closed = True
546
604
  self._queue.put(_STREAM_END)
@@ -613,7 +671,9 @@ def process_group_id_for(proc: subprocess.Popen[str]) -> int | None:
613
671
  def run_command(
614
672
  command: list[str],
615
673
  timeout_seconds: int,
616
- ) -> tuple[Iterable[str], subprocess.Popen[str] | None, int | None]:
674
+ *,
675
+ max_line_chars: int = MAX_LINE_CHARS_LIMIT,
676
+ ) -> tuple[Iterable[str], subprocess.Popen[bytes] | None, int | None]:
617
677
  popen_kwargs: dict[str, object] = {}
618
678
  if os.name != "nt":
619
679
  popen_kwargs["start_new_session"] = True
@@ -622,9 +682,8 @@ def run_command(
622
682
  command,
623
683
  stdout=subprocess.PIPE,
624
684
  stderr=subprocess.STDOUT,
625
- text=True,
626
- bufsize=1,
627
- errors="replace",
685
+ text=False,
686
+ bufsize=0,
628
687
  **popen_kwargs,
629
688
  )
630
689
  except OSError as exc:
@@ -638,6 +697,7 @@ def run_command(
638
697
  proc,
639
698
  proc.stdout,
640
699
  timeout_seconds=timeout_seconds,
700
+ max_line_chars=max_line_chars,
641
701
  process_group_id=process_group_id_for(proc),
642
702
  ),
643
703
  proc,
@@ -685,11 +745,15 @@ def main() -> int:
685
745
  if command and command[0] == "--":
686
746
  command = command[1:]
687
747
 
688
- proc: subprocess.Popen[str] | None = None
748
+ proc: subprocess.Popen[bytes] | None = None
689
749
  command_stream: TimedCommandStream | None = None
690
750
  early_rc: int | None = None
691
751
  if command:
692
- stream, proc, early_rc = run_command(command, args.timeout_seconds)
752
+ stream, proc, early_rc = run_command(
753
+ command,
754
+ args.timeout_seconds,
755
+ max_line_chars=COMMAND_MAX_UNTERMINATED_LINE_CHARS,
756
+ )
693
757
  if isinstance(stream, TimedCommandStream):
694
758
  command_stream = stream
695
759
  if early_rc is not None and proc is None:
@@ -13,11 +13,14 @@ import datetime as _dt
13
13
  import json
14
14
  import os
15
15
  import re
16
+ import selectors
16
17
  import shlex
17
18
  import shutil
19
+ import signal
18
20
  import stat
19
21
  import subprocess
20
22
  import sys
23
+ import time
21
24
  import uuid
22
25
  from dataclasses import dataclass
23
26
  from pathlib import Path
@@ -91,6 +94,8 @@ DEFAULT_EFFORT = "medium"
91
94
  DEFAULT_FAILED_ATTEMPT_NUDGE = True
92
95
  DEFAULT_POST_SETUP_SCAN_TOP = 5
93
96
  POST_SETUP_SCAN_TIMEOUT_SECONDS = 20
97
+ PATH_HELPER_PROBE_TIMEOUT_SECONDS = 5
98
+ PATH_HELPER_PROBE_MAX_OUTPUT_BYTES = 4096
94
99
  PRIVATE_DIR_MODE = stat.S_IRWXU
95
100
  ALLOWED_FIRST_ABSOLUTE_SYMLINKS = {
96
101
  "tmp": Path("/private/tmp"),
@@ -1355,7 +1360,130 @@ def matcher_covers(existing: Any, desired: str) -> bool:
1355
1360
  return not parts or "*" in parts or desired.lower() in parts
1356
1361
 
1357
1362
 
1358
- def helper_argv(helper_name: str, kit_script: str, *, shell: str | None = None) -> list[str]:
1363
+ def _path_has_symlink_component(path: Path) -> bool:
1364
+ current = Path(path.anchor) if path.is_absolute() else Path.cwd()
1365
+ parts = path.parts[1:] if path.is_absolute() else path.parts
1366
+ for part in parts:
1367
+ if part in {"", "."}:
1368
+ continue
1369
+ current = current / part
1370
+ try:
1371
+ if stat.S_ISLNK(os.lstat(current).st_mode):
1372
+ return True
1373
+ except FileNotFoundError:
1374
+ return False
1375
+ except OSError:
1376
+ return True
1377
+ return False
1378
+
1379
+
1380
+ def _probe_path_helper_identity(path: Path, helper_name: str) -> None:
1381
+ env = os.environ.copy()
1382
+ system_path = os.pathsep.join(part for part in ("/usr/bin", "/bin", "/usr/sbin", "/sbin") if Path(part).is_dir())
1383
+ env["PATH"] = str(path.parent) + (os.pathsep + system_path if system_path else "")
1384
+ try:
1385
+ proc = subprocess.Popen(
1386
+ [str(path), "--help"],
1387
+ stdin=subprocess.DEVNULL,
1388
+ stdout=subprocess.PIPE,
1389
+ stderr=subprocess.PIPE,
1390
+ env=env,
1391
+ start_new_session=True,
1392
+ )
1393
+ except OSError as exc:
1394
+ raise SystemExit(f"PATH helper {helper_name!r} identity probe failed: {exc.strerror or exc.__class__.__name__}") from exc
1395
+
1396
+ output = bytearray()
1397
+ selector = selectors.DefaultSelector()
1398
+ streams = [stream for stream in (proc.stdout, proc.stderr) if stream is not None]
1399
+ for stream in streams:
1400
+ os.set_blocking(stream.fileno(), False)
1401
+ selector.register(stream, selectors.EVENT_READ)
1402
+ deadline = time.monotonic() + PATH_HELPER_PROBE_TIMEOUT_SECONDS
1403
+
1404
+ def stop_probe() -> None:
1405
+ if proc.poll() is None:
1406
+ try:
1407
+ os.killpg(proc.pid, signal.SIGKILL)
1408
+ except OSError:
1409
+ try:
1410
+ proc.kill()
1411
+ except OSError:
1412
+ pass
1413
+ try:
1414
+ proc.wait(timeout=1)
1415
+ except subprocess.TimeoutExpired:
1416
+ pass
1417
+
1418
+ try:
1419
+ while selector.get_map():
1420
+ remaining = deadline - time.monotonic()
1421
+ if remaining <= 0:
1422
+ stop_probe()
1423
+ raise SystemExit(f"PATH helper {helper_name!r} identity probe timed out; refusing fallback")
1424
+ for key, _mask in selector.select(timeout=min(0.1, remaining)):
1425
+ stream = key.fileobj
1426
+ try:
1427
+ chunk = os.read(stream.fileno(), 4096)
1428
+ except BlockingIOError:
1429
+ continue
1430
+ if not chunk:
1431
+ selector.unregister(stream)
1432
+ stream.close()
1433
+ continue
1434
+ output.extend(chunk)
1435
+ if len(output) > PATH_HELPER_PROBE_MAX_OUTPUT_BYTES:
1436
+ stop_probe()
1437
+ raise SystemExit(f"PATH helper {helper_name!r} identity probe output exceeded {PATH_HELPER_PROBE_MAX_OUTPUT_BYTES} bytes")
1438
+ remaining = max(0.0, deadline - time.monotonic())
1439
+ try:
1440
+ returncode = proc.wait(timeout=remaining)
1441
+ except subprocess.TimeoutExpired:
1442
+ stop_probe()
1443
+ raise SystemExit(f"PATH helper {helper_name!r} identity probe timed out; refusing fallback")
1444
+ finally:
1445
+ selector.close()
1446
+ for stream in streams:
1447
+ try:
1448
+ stream.close()
1449
+ except OSError:
1450
+ pass
1451
+
1452
+ if returncode != 0:
1453
+ raise SystemExit(f"PATH helper {helper_name!r} identity probe exited {returncode}; refusing fallback")
1454
+ decoded = output.decode("utf-8", errors="replace")
1455
+ lowered = decoded.lower()
1456
+ if "contextguard" not in lowered and "context-guard" not in lowered and helper_name.lower() not in lowered:
1457
+ raise SystemExit(f"PATH helper {helper_name!r} identity probe did not identify ContextGuard; refusing fallback")
1458
+
1459
+
1460
+ def validate_path_helper_fallback(helper_name: str, found: str) -> Path:
1461
+ raw = Path(found)
1462
+ if not raw.is_absolute():
1463
+ raise SystemExit(f"PATH helper {helper_name!r} did not resolve to an absolute path; refusing fallback")
1464
+ try:
1465
+ canonical = raw.resolve(strict=True)
1466
+ except OSError as exc:
1467
+ raise SystemExit(f"PATH helper {helper_name!r} could not be canonicalized: {exc.strerror or exc.__class__.__name__}") from exc
1468
+ normalized_raw = _normalize_allowed_first_absolute_symlink(Path(os.path.normpath(str(raw))))
1469
+ if normalized_raw != canonical:
1470
+ raise SystemExit(f"PATH helper {helper_name!r} traverses a symlink or alias; refusing fallback")
1471
+ if _path_has_symlink_component(canonical):
1472
+ raise SystemExit(f"PATH helper {helper_name!r} has a symlink parent or leaf; refusing fallback")
1473
+ if canonical.name != helper_name:
1474
+ raise SystemExit(f"PATH helper {helper_name!r} resolved to unexpected basename {canonical.name!r}; refusing fallback")
1475
+ fd = _open_regular_no_symlink(canonical)
1476
+ try:
1477
+ st = os.fstat(fd)
1478
+ if not stat.S_ISREG(st.st_mode) or not os.access(canonical, os.X_OK):
1479
+ raise SystemExit(f"PATH helper {helper_name!r} must be an executable regular file; refusing fallback")
1480
+ finally:
1481
+ os.close(fd)
1482
+ _probe_path_helper_identity(canonical, helper_name)
1483
+ return canonical
1484
+
1485
+
1486
+ def helper_argv(helper_name: str, kit_script: str, *, shell: str | None = None, allow_path_fallback: bool = False) -> list[str]:
1359
1487
  """Return argv for a bundled helper without invoking a shell."""
1360
1488
  script_dir = Path(__file__).resolve().parent
1361
1489
  colocated = script_dir / helper_name
@@ -1368,46 +1496,49 @@ def helper_argv(helper_name: str, kit_script: str, *, shell: str | None = None)
1368
1496
  if kit_path.exists():
1369
1497
  prefix = [shell] if shell else [sys.executable]
1370
1498
  return [*prefix, str(kit_path)]
1371
- found = shutil.which(helper_name)
1372
- if found:
1373
- return [str(Path(found).resolve())]
1499
+ if allow_path_fallback:
1500
+ found = shutil.which(helper_name)
1501
+ if found:
1502
+ return [str(validate_path_helper_fallback(helper_name, found))]
1503
+ raise SystemExit(f"Could not resolve required helper {helper_name!r} from PATH even though --allow-path-helper-fallback was supplied.")
1374
1504
  raise SystemExit(
1375
- f"Could not resolve required helper {helper_name!r}; install the plugin or run from a checked-out repository."
1505
+ f"Could not resolve required helper {helper_name!r}; install the plugin or run from a complete checkout. "
1506
+ "PATH helper fallback is disabled by default; pass --allow-path-helper-fallback only for trusted helpers."
1376
1507
  )
1377
1508
 
1378
1509
 
1379
- def helper_command(helper_name: str, kit_script: str, *, shell: str | None = None) -> str:
1510
+ def helper_command(helper_name: str, kit_script: str, *, shell: str | None = None, allow_path_fallback: bool = False) -> str:
1380
1511
  """hook 에 기록할 단일 셸 명령 문자열을 반환한다.
1381
1512
 
1382
1513
  경로에 공백이나 셸 메타문자가 들어와도 안전하도록 모든 분기에서 `shlex.join` 으로
1383
1514
  quote 한다. PATH 에서 찾은 helper 도 절대 경로로 고정해 hook hijacking 을 막는다.
1384
1515
  """
1385
- argv = helper_argv(helper_name, kit_script, shell=shell)
1516
+ argv = helper_argv(helper_name, kit_script, shell=shell, allow_path_fallback=allow_path_fallback)
1386
1517
  return shlex.join(argv)
1387
1518
 
1388
1519
 
1389
- def statusline_setting() -> dict[str, str]:
1390
- return {"type": "command", "command": helper_command(HELPER_STATUSLINE, "statusline_merged.sh", shell="bash")}
1520
+ def statusline_setting(*, allow_path_fallback: bool = False) -> dict[str, str]:
1521
+ return {"type": "command", "command": helper_command(HELPER_STATUSLINE, "statusline_merged.sh", shell="bash", allow_path_fallback=allow_path_fallback)}
1391
1522
 
1392
1523
 
1393
- def bash_hook_setting() -> dict[str, Any]:
1524
+ def bash_hook_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
1394
1525
  return {
1395
1526
  "matcher": "Bash",
1396
- "hooks": [{"type": "command", "command": helper_command(HELPER_REWRITE_BASH, "rewrite_bash_for_token_budget.py")}],
1527
+ "hooks": [{"type": "command", "command": helper_command(HELPER_REWRITE_BASH, "rewrite_bash_for_token_budget.py", allow_path_fallback=allow_path_fallback)}],
1397
1528
  }
1398
1529
 
1399
1530
 
1400
- def read_hook_setting() -> dict[str, Any]:
1531
+ def read_hook_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
1401
1532
  return {
1402
1533
  "matcher": "Read",
1403
- "hooks": [{"type": "command", "command": helper_command(HELPER_GUARD_READ, "guard_large_read.py")}],
1534
+ "hooks": [{"type": "command", "command": helper_command(HELPER_GUARD_READ, "guard_large_read.py", allow_path_fallback=allow_path_fallback)}],
1404
1535
  }
1405
1536
 
1406
1537
 
1407
- def failed_nudge_setting() -> dict[str, Any]:
1538
+ def failed_nudge_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
1408
1539
  return {
1409
1540
  "matcher": "Bash",
1410
- "hooks": [{"type": "command", "command": helper_command(HELPER_FAILED_NUDGE, "failed_attempt_nudge.py")}],
1541
+ "hooks": [{"type": "command", "command": helper_command(HELPER_FAILED_NUDGE, "failed_attempt_nudge.py", allow_path_fallback=allow_path_fallback)}],
1411
1542
  }
1412
1543
 
1413
1544
 
@@ -1595,9 +1726,9 @@ def summarize_diet_report(report: dict[str, Any]) -> dict[str, Any]:
1595
1726
  }
1596
1727
 
1597
1728
 
1598
- def run_post_setup_diet_scan(root: Path) -> dict[str, Any]:
1729
+ def run_post_setup_diet_scan(root: Path, *, allow_path_fallback: bool = False) -> dict[str, Any]:
1599
1730
  argv = [
1600
- *helper_argv(HELPER_DIET, "context_guard_diet.py"),
1731
+ *helper_argv(HELPER_DIET, "context_guard_diet.py", allow_path_fallback=allow_path_fallback),
1601
1732
  "scan",
1602
1733
  str(root),
1603
1734
  "--json",
@@ -1661,6 +1792,8 @@ def _setup_command(args: argparse.Namespace, *, apply: bool, root: Path | None =
1661
1792
  parts.extend(["--agent", ",".join(selected)])
1662
1793
  elif normalize_scope(getattr(args, "scope", "project")) == "user":
1663
1794
  parts.extend(["--agent", "claude"])
1795
+ if getattr(args, "allow_path_helper_fallback", False):
1796
+ parts.append("--allow-path-helper-fallback")
1664
1797
  if getattr(args, "with_init", False):
1665
1798
  parts.append("--with-init")
1666
1799
  if getattr(args, "with_skill", False):
@@ -1694,7 +1827,7 @@ def _doctor_status(checks: list[dict[str, Any]]) -> str:
1694
1827
  return "ok"
1695
1828
 
1696
1829
 
1697
- def _helper_availability_check(*, include_diet: bool = True) -> dict[str, Any]:
1830
+ def _helper_availability_check(*, include_diet: bool = True, allow_path_fallback: bool = False) -> dict[str, Any]:
1698
1831
  helpers = {
1699
1832
  HELPER_STATUSLINE: "statusline_merged.sh",
1700
1833
  HELPER_REWRITE_BASH: "rewrite_bash_for_token_budget.py",
@@ -1707,7 +1840,7 @@ def _helper_availability_check(*, include_diet: bool = True) -> dict[str, Any]:
1707
1840
  missing: list[str] = []
1708
1841
  for helper, kit_script in helpers.items():
1709
1842
  try:
1710
- resolved[helper] = shlex.join(helper_argv(helper, kit_script, shell=("bash" if kit_script.endswith(".sh") else None)))
1843
+ resolved[helper] = shlex.join(helper_argv(helper, kit_script, shell=("bash" if kit_script.endswith(".sh") else None), allow_path_fallback=allow_path_fallback))
1711
1844
  except SystemExit:
1712
1845
  missing.append(helper)
1713
1846
  if missing:
@@ -1716,7 +1849,7 @@ def _helper_availability_check(*, include_diet: bool = True) -> dict[str, Any]:
1716
1849
  "error",
1717
1850
  "error",
1718
1851
  "Some ContextGuard helper commands could not be resolved.",
1719
- detail={"missing": missing, "resolved": resolved},
1852
+ detail={"missing": missing, "resolved": resolved, "allow_path_helper_fallback": allow_path_fallback},
1720
1853
  next_action="Reinstall ContextGuard or run from a complete checkout.",
1721
1854
  )
1722
1855
  return doctor_check(
@@ -1724,7 +1857,7 @@ def _helper_availability_check(*, include_diet: bool = True) -> dict[str, Any]:
1724
1857
  "ok",
1725
1858
  "low",
1726
1859
  "Required ContextGuard helper commands are resolvable.",
1727
- detail={"resolved": resolved},
1860
+ detail={"resolved": resolved, "allow_path_helper_fallback": allow_path_fallback},
1728
1861
  )
1729
1862
 
1730
1863
 
@@ -1751,7 +1884,7 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
1751
1884
  scope = normalize_scope(getattr(args, "scope", "project"))
1752
1885
  root = resolve_scope_root(args.root, scope)
1753
1886
  settings_path = root / SETTINGS_REL
1754
- helper_check = _helper_availability_check(include_diet=not getattr(args, "no_diet_scan", False))
1887
+ 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)))
1755
1888
  checks: list[dict[str, Any]] = [helper_check]
1756
1889
  warnings: list[str] = []
1757
1890
  if scope == "user":
@@ -1840,7 +1973,7 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
1840
1973
  }
1841
1974
 
1842
1975
  choices = choices_from_args(args)
1843
- actions = apply_choices(settings, choices) if claude_targeted else []
1976
+ actions = apply_choices(settings, choices, allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False))) if claude_targeted else []
1844
1977
  changed = (settings != original) if claude_targeted else False
1845
1978
  if changed:
1846
1979
  checks.append(doctor_check(
@@ -1907,7 +2040,7 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
1907
2040
  ))
1908
2041
  else:
1909
2042
  diet_next_action = shlex.join(["context-guard", "diet", "scan", str(root), "--json"])
1910
- diet_scan = run_post_setup_diet_scan(root)
2043
+ diet_scan = run_post_setup_diet_scan(root, allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False)))
1911
2044
  if diet_scan.get("status") != "completed":
1912
2045
  checks.append(doctor_check(
1913
2046
  "diet-scan",
@@ -1986,7 +2119,7 @@ def render_doctor_text(report: dict[str, Any]) -> str:
1986
2119
  return "\n".join(lines) + "\n"
1987
2120
 
1988
2121
 
1989
- def apply_choices(settings: dict[str, Any], choices: Choices) -> list[str]:
2122
+ def apply_choices(settings: dict[str, Any], choices: Choices, *, allow_path_fallback: bool = False) -> list[str]:
1990
2123
  actions: list[str] = []
1991
2124
  if choices.model_defaults:
1992
2125
  if not settings.get("model"):
@@ -1996,7 +2129,7 @@ def apply_choices(settings: dict[str, Any], choices: Choices) -> list[str]:
1996
2129
  settings["effortLevel"] = DEFAULT_EFFORT
1997
2130
  actions.append(f"set default effortLevel to {DEFAULT_EFFORT}")
1998
2131
  if choices.statusline:
1999
- statusline = statusline_setting()
2132
+ statusline = statusline_setting(allow_path_fallback=allow_path_fallback)
2000
2133
  if "statusLine" not in settings:
2001
2134
  settings["statusLine"] = statusline
2002
2135
  actions.append("enabled token statusline")
@@ -2005,15 +2138,15 @@ def apply_choices(settings: dict[str, Any], choices: Choices) -> list[str]:
2005
2138
  if choices.denies:
2006
2139
  ensure_permissions(settings, actions)
2007
2140
  if choices.bash_hook:
2008
- bash_hook = bash_hook_setting()
2141
+ bash_hook = bash_hook_setting(allow_path_fallback=allow_path_fallback)
2009
2142
  bash_command = bash_hook["hooks"][0]["command"]
2010
2143
  ensure_pre_tool_hook(settings, bash_hook, bash_command, "Bash trim/sanitize", actions)
2011
2144
  if choices.read_guard:
2012
- read_hook = read_hook_setting()
2145
+ read_hook = read_hook_setting(allow_path_fallback=allow_path_fallback)
2013
2146
  read_command = read_hook["hooks"][0]["command"]
2014
2147
  ensure_pre_tool_hook(settings, read_hook, read_command, "large Read guard", actions)
2015
2148
  if choices.failed_attempt_nudge:
2016
- nudge_hook = failed_nudge_setting()
2149
+ nudge_hook = failed_nudge_setting(allow_path_fallback=allow_path_fallback)
2017
2150
  nudge_command = nudge_hook["hooks"][0]["command"]
2018
2151
  ensure_post_tool_hook(settings, nudge_hook, nudge_command, "failed-attempt /clear nudge", actions)
2019
2152
  return actions
@@ -2285,7 +2418,7 @@ def run(args: argparse.Namespace) -> SetupResult:
2285
2418
  if interactive:
2286
2419
  choices = interactive_choices(choices)
2287
2420
 
2288
- actions = apply_choices(settings, choices) if claude_targeted else []
2421
+ actions = apply_choices(settings, choices, allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False))) if claude_targeted else []
2289
2422
  changed = (settings != original) if claude_targeted else False
2290
2423
 
2291
2424
  apply_requested = bool(args.yes and not args.dry_run and not args.plan)
@@ -2371,7 +2504,7 @@ def run(args: argparse.Namespace) -> SetupResult:
2371
2504
 
2372
2505
  diet_scan = None
2373
2506
  if (applied or (apply_requested and claude_targeted)) and not getattr(args, "no_diet_scan", False):
2374
- diet_scan = run_post_setup_diet_scan(root)
2507
+ diet_scan = run_post_setup_diet_scan(root, allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False)))
2375
2508
 
2376
2509
  return SetupResult(
2377
2510
  root=root,
@@ -2417,6 +2550,7 @@ def build_parser() -> argparse.ArgumentParser:
2417
2550
  parser.add_argument("--no-read-guard", action="store_true", help="skip large Read guard hook")
2418
2551
  parser.add_argument("--no-model-defaults", action="store_true", help="skip model/effort defaults")
2419
2552
  parser.add_argument("--no-diet-scan", action="store_true", help="skip the read-only diet scan summary after applying setup")
2553
+ parser.add_argument("--allow-path-helper-fallback", action="store_true", help="allow trusted PATH helper resolution only after bundled/repo helpers are missing and identity validation passes")
2420
2554
  parser.add_argument(
2421
2555
  "--agent",
2422
2556
  action="append",