@ictechgy/context-guard 0.4.8 → 0.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.ko.md +92 -37
- package/README.md +111 -37
- package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
- package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
- package/docs/distribution.md +10 -7
- package/docs/experimental-benchmark-fixtures.md +8 -1
- package/package.json +3 -6
- package/packaging/homebrew/context-guard.rb.template +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +9 -6
- package/plugins/context-guard/README.md +27 -12
- package/plugins/context-guard/bin/context-guard +113 -26
- package/plugins/context-guard/bin/context-guard-artifact +542 -46
- package/plugins/context-guard/bin/context-guard-cache-score +380 -0
- package/plugins/context-guard/bin/context-guard-compress +146 -1
- package/plugins/context-guard/bin/context-guard-cost +783 -4
- package/plugins/context-guard/bin/context-guard-experiments +2211 -121
- package/plugins/context-guard/bin/context-guard-failed-nudge +3 -0
- package/plugins/context-guard/bin/context-guard-filter +163 -7
- package/plugins/context-guard/bin/context-guard-guard-read +3 -0
- package/plugins/context-guard/bin/context-guard-pack +602 -43
- package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
- package/plugins/context-guard/bin/context-guard-setup +165 -31
- package/plugins/context-guard/bin/context-guard-statusline +490 -283
- package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
- package/plugins/context-guard/bin/context-guard-tool-prune +241 -1
- package/plugins/context-guard/lib/context_guard_commands.py +206 -0
- package/plugins/context-guard/skills/setup/SKILL.md +1 -0
- package/context-guard-kit/README.md +0 -91
- package/context-guard-kit/benchmark_runner.py +0 -2401
- package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
- package/context-guard-kit/context_compress.py +0 -695
- package/context-guard-kit/context_escrow.py +0 -935
- package/context-guard-kit/context_filter.py +0 -637
- package/context-guard-kit/context_guard_cli.py +0 -325
- package/context-guard-kit/context_guard_diet.py +0 -1711
- package/context-guard-kit/context_pack.py +0 -2713
- package/context-guard-kit/cost_guard.py +0 -2349
- package/context-guard-kit/experimental_registry.py +0 -2339
- package/context-guard-kit/failed_attempt_nudge.py +0 -567
- package/context-guard-kit/guard_large_read.py +0 -690
- package/context-guard-kit/hook_secret_patterns.py +0 -43
- package/context-guard-kit/read_symbol.py +0 -483
- package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
- package/context-guard-kit/sanitize_output.py +0 -725
- package/context-guard-kit/settings.example.json +0 -67
- package/context-guard-kit/setup_wizard.py +0 -2515
- package/context-guard-kit/statusline.sh +0 -362
- package/context-guard-kit/statusline_merged.sh +0 -157
- package/context-guard-kit/tool_schema_pruner.py +0 -837
- 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:
|
|
@@ -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
|
|
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
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
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
|
|
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",
|