@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.
- package/CHANGELOG.md +74 -0
- package/README.ko.md +91 -1
- package/README.md +95 -1
- package/docs/distribution.md +100 -0
- package/package.json +5 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +34 -1
- package/plugins/context-guard/README.md +36 -1
- package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
- package/plugins/context-guard/bin/context-guard-artifact +1 -1
- package/plugins/context-guard/bin/context-guard-bench +4216 -103
- package/plugins/context-guard/bin/context-guard-failed-nudge +95 -23
- package/plugins/context-guard/bin/context-guard-guard-read +6 -2
- package/plugins/context-guard/bin/context-guard-mcp +2 -1
- package/plugins/context-guard/bin/context-guard-pack +2086 -142
- package/plugins/context-guard/bin/context-guard-rewrite-bash +497 -45
- package/plugins/context-guard/bin/context-guard-sanitize-output +178 -22
- package/plugins/context-guard/bin/context-guard-setup +901 -28
- package/plugins/context-guard/bin/context-guard-statusline +33 -2
- package/plugins/context-guard/bin/context-guard-statusline-merged +71 -20
- package/plugins/context-guard/bin/context-guard-task-memory +635 -0
- package/plugins/context-guard/bin/context-guard-trim-output +706 -35
- package/plugins/context-guard/lib/context_guard_commands.py +25 -1
- package/plugins/context-guard/lib/context_pack_git_boundary.py +19 -0
- package/plugins/context-guard/lib/context_pack_identity.py +115 -0
- package/plugins/context-guard/lib/context_pack_receipts.py +9 -0
- package/plugins/context-guard/lib/context_pack_rendering.py +12 -0
- package/plugins/context-guard/lib/context_pack_scanning.py +10 -0
- package/plugins/context-guard/lib/context_pack_selection.py +6 -0
- package/plugins/context-guard/lib/credential_policy.py +10 -2
|
@@ -13,6 +13,7 @@ import datetime as _dt
|
|
|
13
13
|
import hashlib
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
|
+
import pwd
|
|
16
17
|
import re
|
|
17
18
|
import selectors
|
|
18
19
|
import shlex
|
|
@@ -22,6 +23,7 @@ import stat
|
|
|
22
23
|
import subprocess
|
|
23
24
|
import sys
|
|
24
25
|
import time
|
|
26
|
+
import types
|
|
25
27
|
import uuid
|
|
26
28
|
from dataclasses import dataclass
|
|
27
29
|
from pathlib import Path
|
|
@@ -61,10 +63,21 @@ PRODUCT_OWNED_ENV_READ_DENIES = frozenset({
|
|
|
61
63
|
"Read(./.env.*)",
|
|
62
64
|
})
|
|
63
65
|
HELPER_STATUSLINE = "context-guard-statusline-merged"
|
|
66
|
+
HELPER_STATUSLINE_PLAIN = "context-guard-statusline"
|
|
64
67
|
HELPER_REWRITE_BASH = "context-guard-rewrite-bash"
|
|
65
68
|
HELPER_GUARD_READ = "context-guard-guard-read"
|
|
66
69
|
HELPER_FAILED_NUDGE = "context-guard-failed-nudge"
|
|
67
70
|
HELPER_DIET = "context-guard-diet"
|
|
71
|
+
ROOT_PACKAGE_NAME = "@ictechgy/context-guard"
|
|
72
|
+
RECEIPT_PACKAGE_NAME = "@ictechgy/context-guard-receipt"
|
|
73
|
+
_BASH_REFERENCE_UNAVAILABLE = (
|
|
74
|
+
"bash_reference_v1 reference unavailable"
|
|
75
|
+
)
|
|
76
|
+
_BASH_REFERENCE_RECOVERY = (
|
|
77
|
+
"repair or reinstall the exact paired npm packages in the target project, "
|
|
78
|
+
"ensure a trusted system Node interpreter is available, then rerun setup"
|
|
79
|
+
)
|
|
80
|
+
BASH_REFERENCE_POLICY_MAX_BYTES = 512 * 1024
|
|
68
81
|
HELPER_EQUIVALENT_BASENAMES = {
|
|
69
82
|
"context-guard-rewrite-bash": {
|
|
70
83
|
"context-guard-rewrite-bash",
|
|
@@ -99,6 +112,33 @@ DEFAULT_POST_SETUP_SCAN_TOP = 5
|
|
|
99
112
|
POST_SETUP_SCAN_TIMEOUT_SECONDS = 20
|
|
100
113
|
PATH_HELPER_PROBE_TIMEOUT_SECONDS = 5
|
|
101
114
|
PATH_HELPER_PROBE_MAX_OUTPUT_BYTES = 4096
|
|
115
|
+
ISOLATED_RUNTIME_PATH = os.defpath
|
|
116
|
+
READ_GUARD_BEHAVIOR_ENV = (
|
|
117
|
+
"CONTEXT_GUARD_READ_GUARD",
|
|
118
|
+
"CLAUDE_TOKEN_READ_GUARD",
|
|
119
|
+
"CONTEXT_GUARD_READ_GUARD_MAX_BYTES",
|
|
120
|
+
"CLAUDE_TOKEN_READ_GUARD_MAX_BYTES",
|
|
121
|
+
"CONTEXT_GUARD_READ_GUARD_MAX_LINES",
|
|
122
|
+
"CLAUDE_TOKEN_READ_GUARD_MAX_LINES",
|
|
123
|
+
"CONTEXT_GUARD_READ_GUARD_PROOF_BYTES",
|
|
124
|
+
"CLAUDE_TOKEN_READ_GUARD_PROOF_BYTES",
|
|
125
|
+
)
|
|
126
|
+
REWRITE_BEHAVIOR_ENV = (
|
|
127
|
+
"CONTEXT_GUARD_SANITIZER_FAIL_OPEN",
|
|
128
|
+
"CLAUDE_TOKEN_SANITIZER_FAIL_OPEN",
|
|
129
|
+
)
|
|
130
|
+
STATUSLINE_BEHAVIOR_ENV = (
|
|
131
|
+
"CONTEXT_GUARD_STATUSLINE_INPUT_MAX_BYTES",
|
|
132
|
+
"CLAUDE_TOKEN_STATUSLINE_INPUT_MAX_BYTES",
|
|
133
|
+
"CONTEXT_GUARD_STATUSLINE_CTX_WARN",
|
|
134
|
+
"CLAUDE_TOKEN_STATUSLINE_CTX_WARN",
|
|
135
|
+
"CONTEXT_GUARD_STATUSLINE_CACHE_TTL_SECONDS",
|
|
136
|
+
)
|
|
137
|
+
HOMEBREW_NODE_CANDIDATES = (
|
|
138
|
+
Path("/opt/homebrew/bin/node"),
|
|
139
|
+
Path("/usr/local/bin/node"),
|
|
140
|
+
)
|
|
141
|
+
BEHAVIOR_ENV_VALUE_RE = re.compile(r"^[A-Za-z0-9.+_-]{1,64}$")
|
|
102
142
|
PRIVATE_DIR_MODE = stat.S_IRWXU
|
|
103
143
|
ALLOWED_FIRST_ABSOLUTE_SYMLINKS = {
|
|
104
144
|
"tmp": Path("/private/tmp"),
|
|
@@ -111,6 +151,8 @@ class Choices:
|
|
|
111
151
|
denies: bool = True
|
|
112
152
|
statusline: bool = True
|
|
113
153
|
bash_hook: bool = True
|
|
154
|
+
# Provider-visible receipt handles are an explicit, default-off choice.
|
|
155
|
+
bash_reference_v1: bool = False
|
|
114
156
|
read_guard: bool = True
|
|
115
157
|
model_defaults: bool = True
|
|
116
158
|
# 동일 Bash 명령이 두 번 연속 실패하면 /clear 권유 — recommended setup 기본 ON.
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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":
|
|
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
|
|
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
|
-
|
|
2057
|
-
if
|
|
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
|
-
|
|
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(
|
|
2254
|
-
|
|
2255
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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")
|