@ictechgy/context-guard 0.4.16 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,8 @@ from dataclasses import dataclass
12
12
  import json
13
13
  import os
14
14
  import re
15
+ import shutil
16
+ import subprocess
15
17
  import sys
16
18
 
17
19
  ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*")
@@ -24,6 +26,13 @@ WRAPPER_BASENAMES = frozenset({
24
26
  "claude-sanitize-output",
25
27
  })
26
28
  MINISHELL_ROUTE_POLICY_VERSION = "minishell-route-v1"
29
+ MINISHELL_EXPLICIT_NOOP_ARGV = frozenset({
30
+ ("kubectl", "get", "pods"),
31
+ ("kubectl", "version"),
32
+ ("docker", "ps"),
33
+ ("docker", "images"),
34
+ ("docker", "compose", "ps"),
35
+ })
27
36
  MINISHELL_MAX_COMMAND_BYTES = 65_536
28
37
  MINISHELL_MAX_LEXICAL_ITEMS = 4_096
29
38
  MINISHELL_MAX_SEGMENTS = 8
@@ -111,9 +120,12 @@ MINISHELL_ALLOWED_ENV_PREFIX_NAMES = frozenset({
111
120
  "NODE_ENV",
112
121
  })
113
122
  CGW1_MAX_LINES = "220"
114
- CGW1_SHELL_ARGV = ("bash", "-lc")
123
+ CGW1_SHELL_ARGV = ("bash", "-c")
115
124
  CGW1_SENTINEL = "--context-guard-wrapper-v1"
116
125
  CGW1_COMMAND_SEARCH_DIFF = "command_search_diff"
126
+ BASH_REFERENCE_FLAG = "--bash-reference-v1"
127
+ BASH_REFERENCE_PUBLIC_COMMAND = "./node_modules/.bin/context-guard"
128
+ BASH_REFERENCE_HANDLE_RE = re.compile(r"^cgr1p_[A-Za-z0-9_-]{43}$", re.ASCII)
117
129
  FAIL_OPEN_ENV = "CONTEXT_GUARD_SANITIZER_FAIL_OPEN"
118
130
  LEGACY_FAIL_OPEN_ENV = "CLAUDE_TOKEN_SANITIZER_FAIL_OPEN"
119
131
  FAIL_OPEN_VALUES = {"1", "true", "yes", "on"}
@@ -124,6 +136,47 @@ UNPARSEABLE_SANITIZER_RISK_RE = re.compile(
124
136
  r"(?:$|[\s;&|()])"
125
137
  )
126
138
 
139
+
140
+ def _approved_runtime_executable(name: str) -> str:
141
+ """Resolve only from the fixed OS command path, never inherited PATH."""
142
+ found = shutil.which(name, path=os.defpath)
143
+ if not found:
144
+ raise RuntimeError(f"required runtime {name!r} is unavailable")
145
+ canonical = os.path.realpath(found)
146
+ if not os.path.isabs(canonical) or not os.path.isfile(canonical) or not os.access(canonical, os.X_OK):
147
+ raise RuntimeError(f"required runtime {name!r} is not an executable regular file")
148
+ return canonical
149
+
150
+
151
+ def _approved_python_runtime() -> str:
152
+ canonical = os.path.realpath(sys.executable)
153
+ if not canonical or not os.path.isabs(canonical) or not os.path.isfile(canonical) or not os.access(canonical, os.X_OK):
154
+ raise RuntimeError("approved Python runtime is unavailable")
155
+ return canonical
156
+
157
+
158
+ def _runtime_shell_argv() -> tuple[str, ...]:
159
+ return (
160
+ _approved_runtime_executable("env"),
161
+ "-u", "BASH_ENV",
162
+ "-u", "ENV",
163
+ "-u", "PYTHONHOME",
164
+ "-u", "PYTHONPATH",
165
+ "-u", "PYTHONSTARTUP",
166
+ "-u", "SHELLOPTS",
167
+ "-u", "BASHOPTS",
168
+ "-u", "PS4",
169
+ _approved_runtime_executable("bash"),
170
+ "--noprofile",
171
+ "--norc",
172
+ "-p",
173
+ "-c",
174
+ )
175
+
176
+
177
+ def _isolated_wrapper_prefix(wrapper: str) -> list[str]:
178
+ return [_approved_python_runtime(), "-I", os.path.realpath(wrapper)]
179
+
127
180
  # kubectl/docker/podman/oc 글로벌 옵션 중 다음 토큰을 value로 소비하는 형태.
128
181
  # `-n prod`, `--context=prod`, `-f file.yml` 같은 케이스를 hub로 흡수해
129
182
  # `kubectl -n prod logs api`, `docker --context prod logs api`,
@@ -705,7 +758,23 @@ def split_single_safe_command(command: str) -> list[str] | None:
705
758
 
706
759
 
707
760
  def command_basename(command: str) -> str:
708
- return os.path.basename(command)
761
+ """Return a trusted routing identity only for a bare ASCII command token.
762
+
763
+ Route predicates describe standard command identities, not arbitrary files
764
+ that happen to share a basename. Normalizing ``./rg`` or
765
+ ``/tmp/evil/grep`` to a trusted name would let a caller-selected executable
766
+ inherit that route. Non-ASCII tokens are also rejected here so Unicode
767
+ separator lookalikes cannot create a visually ambiguous identity.
768
+ """
769
+ if (
770
+ not command
771
+ or not command.isascii()
772
+ or not command.isprintable()
773
+ or "/" in command
774
+ or "\\" in command
775
+ ):
776
+ return ""
777
+ return command
709
778
 
710
779
 
711
780
  def strip_env_prefix(argv: list[str]) -> list[str]:
@@ -782,9 +851,9 @@ def is_noisy_command(argv: list[str]) -> bool:
782
851
  return True
783
852
  if first == "cargo" and "test" in rest:
784
853
  return True
785
- if first in {"mvn", "mvnw", "./mvnw"} and "test" in rest:
854
+ if first in {"mvn", "mvnw"} and "test" in rest:
786
855
  return True
787
- if first in {"gradle", "gradlew", "./gradlew"} and "test" in rest:
856
+ if first in {"gradle", "gradlew"} and "test" in rest:
788
857
  return True
789
858
  if first == "make" and any(arg in {"test", "build", "lint"} for arg in rest):
790
859
  return True
@@ -1041,15 +1110,25 @@ def _routing_argv(parsed: MiniShellParse) -> tuple[str, ...]:
1041
1110
  def _wrapper_invocation(argv: tuple[str, ...]) -> tuple[str, int] | None:
1042
1111
  if not argv:
1043
1112
  return None
1044
- head_basename = command_basename(argv[0])
1113
+ # Incoming wrappers are recognized before the general command-identity
1114
+ # gate. Their generated envelopes intentionally contain absolute helper
1115
+ # paths, so this narrow recursion guard must inspect the real basename.
1116
+ head_basename = os.path.basename(argv[0])
1045
1117
  if head_basename in WRAPPER_BASENAMES:
1046
1118
  return head_basename, 0
1047
1119
  if (
1048
1120
  re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", head_basename)
1049
1121
  and len(argv) > 1
1050
- and command_basename(argv[1]) in WRAPPER_BASENAMES
1122
+ and os.path.basename(argv[1]) in WRAPPER_BASENAMES
1051
1123
  ):
1052
- return command_basename(argv[1]), 1
1124
+ return os.path.basename(argv[1]), 1
1125
+ if (
1126
+ re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", head_basename)
1127
+ and len(argv) > 2
1128
+ and argv[1] == "-I"
1129
+ and os.path.basename(argv[2]) in WRAPPER_BASENAMES
1130
+ ):
1131
+ return os.path.basename(argv[2]), 2
1053
1132
  return None
1054
1133
 
1055
1134
 
@@ -1070,6 +1149,21 @@ def _expected_cgw1_prefix(kind: str) -> tuple[str, ...]:
1070
1149
  return (os.path.join(script_dir, helper),)
1071
1150
 
1072
1151
 
1152
+ def _is_expected_direct_wrapper_path(argv: tuple[str, ...]) -> bool:
1153
+ """Whether argv starts with this package's exact generated helper path.
1154
+
1155
+ Direct helper CLI use predates F-11 and remains ordinary. The exception
1156
+ is deliberately limited to the helper beside this entrypoint; an
1157
+ attacker-chosen path that merely shares its basename is not trusted.
1158
+ """
1159
+ invocation = _wrapper_invocation(argv)
1160
+ if invocation is None or invocation[1] != 0:
1161
+ return False
1162
+ basename, _wrapper_index = invocation
1163
+ expected = _expected_cgw1_prefix(_wrapper_kind(basename))
1164
+ return len(expected) == 1 and argv[0] == expected[0]
1165
+
1166
+
1073
1167
  def classify_incoming_wrapper(
1074
1168
  parsed: MiniShellParse,
1075
1169
  ) -> tuple[str, str | None, str | None] | None:
@@ -1108,13 +1202,15 @@ def classify_incoming_wrapper(
1108
1202
  (CGW1_COMMAND_SEARCH_DIFF,),
1109
1203
  ("--mode", CGW1_COMMAND_SEARCH_DIFF),
1110
1204
  )
1205
+ shell_argvs = (CGW1_SHELL_ARGV, _runtime_shell_argv())
1111
1206
  for prefix in legacy_prefixes:
1112
- fixed = (*prefix, "--", *CGW1_SHELL_ARGV)
1113
- if (
1114
- len(envelope_argv) == len(fixed) + 1
1115
- and envelope_argv[:-1] == fixed
1116
- ):
1117
- return ("incoming_wrapper_denied", kind, envelope_argv[-1])
1207
+ for shell_argv in shell_argvs:
1208
+ fixed = (*prefix, "--", *shell_argv)
1209
+ if (
1210
+ len(envelope_argv) == len(fixed) + 1
1211
+ and envelope_argv[:-1] == fixed
1212
+ ):
1213
+ return ("incoming_wrapper_denied", kind, envelope_argv[-1])
1118
1214
  return None
1119
1215
 
1120
1216
 
@@ -1338,7 +1434,12 @@ def _cut_is_safe(argv: tuple[str, ...]) -> bool:
1338
1434
  return selector is not None and (not delimiter or selector == "-f")
1339
1435
 
1340
1436
 
1341
- _SED_SCRIPT_RE = re.compile(r"(?:[1-9]\d*|[1-9]\d*,(?:[1-9]\d*|\$))p")
1437
+ _SED_SEGMENT_PATTERN = r"(?:[1-9]\d*|[1-9]\d*,(?:[1-9]\d*|\$))p"
1438
+ _SED_MAX_SEGMENTS = 8
1439
+ _SED_SCRIPT_RE = re.compile(
1440
+ rf"{_SED_SEGMENT_PATTERN}(?:;{_SED_SEGMENT_PATTERN})"
1441
+ rf"{{0,{_SED_MAX_SEGMENTS - 1}}}"
1442
+ )
1342
1443
 
1343
1444
 
1344
1445
  def _sed_route_shape(argv: tuple[str, ...]) -> tuple[bool, int]:
@@ -1356,8 +1457,9 @@ def _sed_route_shape(argv: tuple[str, ...]) -> tuple[bool, int]:
1356
1457
  전부 거부하고 정확한 토큰만 허용한다.
1357
1458
 
1358
1459
  스크립트 본문 자체의 안전 경계(`w`/`W`/`s///w`/`e`/`s///e`/`r`/`R` 배제)는
1359
- `_SED_SCRIPT_RE` 의 `re.fullmatch` 가 담당하며 이번 변경으로 절대
1360
- 느슨해지지 않는다 함수는 정규식이 실제로 검사하는 대상이 진짜
1460
+ `_SED_SCRIPT_RE` 의 `re.fullmatch` 가 담당한다. S009는 기존의 안전한
1461
+ p-only SEG를 `;`로 최대 `_SED_MAX_SEGMENTS` 조합할 SEG 자체를
1462
+ 넓히지 않는다 — 이 함수는 그 정규식이 실제로 검사하는 대상이 진짜
1361
1463
  스크립트임을 보장하는 역할만 한다.
1362
1464
  """
1363
1465
  quiet_seen = False
@@ -1505,7 +1607,7 @@ def _head_tail_is_safe(argv: tuple[str, ...], *, allow_files: bool) -> bool:
1505
1607
  `-n`/`--lines`(및 `-N`/`-nN`/`--lines=N` 축약형)는 최대 1회만 허용하며 유효한
1506
1608
  양의 정수여야 한다. **`-n` 미지정도 허용한다** — bare `head`/`tail`은 기본
1507
1609
  10줄 상한이 이미 적용되므로 무제한 출력 위험이 없다. `tail -f`/`-F`는 무제한
1508
- 스트림이므로 allow_files 여부와 무관하게 항상 거부한다(`bash -lc` 내부에서
1610
+ 스트림이므로 allow_files 여부와 무관하게 항상 거부한다(`bash -c` 내부에서
1509
1611
  프로세스가 종결되지 않는 것을 방지). `-c`(바이트 단위)는 지원하지 않는다 —
1510
1612
  trim 예산 단위는 줄(line)이라 바이트 상한과 섞일 수 없다.
1511
1613
  """
@@ -1549,18 +1651,15 @@ def _head_tail_is_safe(argv: tuple[str, ...], *, allow_files: bool) -> bool:
1549
1651
  #: 이 저장소에서 결함이 전파되는 경로다. `-s`를 허용하기로 결정한다면 짧은 옵션
1550
1652
  #: 쪽을 먼저 넓히고 그 다음 이 표에 롱 형태를 추가한다.
1551
1653
  #:
1552
- #: 값 형태를 취하는 옵션(`--include=`, `--exclude=`, `--exclude-dir=`, `--devices=`,
1654
+ #: 값 형태를 취하는 옵션(`--exclude=`, `--exclude-dir=`, `--devices=`,
1553
1655
  #: `--directories=`, `--label=`, `--binary-files=`, `-D/-U/-z/-Z/--null` 계열)은
1554
1656
  #: 의도적으로 제외한다. 이유는 값이 동작을 바꾸기 때문이다(예: `--directories=read`).
1555
1657
  #:
1556
- #: 주의 제외를 "어차피 파서 단계(`active_2a`)에서 글롭으로 거부된다"로
1557
- #: 정당화하면 **틀린다.** 비인용 `--include=*.py`는 확실히 `active_2a`로 죽지만,
1558
- #: 인용된 `--include='*.py'`는 셸이 확장하지 않아 롱플래그로 여기까지 도달하며
1559
- #: `route_policy_denied`를 받는다(리뷰 라운드 실측: 그런 명령이 코퍼스에 66건).
1560
- #: 이들은 여기서 다루면 실제로 열린다. 다루지 않는 이유는 `--flag=value` 형태를
1561
- #: 담으려면 값 문법을 갖춘 접두 규칙이 필요한데, 그 첫 접두 규칙을 "접두사 매칭
1562
- #: 금지" 규율을 세우는 바로 이 변경에 함께 넣으면 규율 자체가 무너지기 때문이다.
1563
- #: 별도 변경으로 다룬다.
1658
+ #: S010은 incidence gate를 통과한 bare recursive `grep`의 정확히 한 개
1659
+ #: `--include=<glob>`만 아래 별도 문법으로 다룬다. 이 옵션은 정확 일치 이름,
1660
+ #: 제한된 ASCII basename grammar, recursive/file-operand 조건을 모두 만족해야 하며
1661
+ #: 별칭 표에는 들어오지 않는다. 다른 옵션과 `--include*` 근접 철자는 계속
1662
+ #: exact-match fail-closed 규칙을 따른다.
1564
1663
  _GREP_LONG_ALIASES = frozenset(
1565
1664
  {
1566
1665
  "--only-matching",
@@ -1589,10 +1688,29 @@ _GREP_LONG_ALIASES = frozenset(
1589
1688
  }
1590
1689
  )
1591
1690
 
1691
+ _GREP_INCLUDE_GLOB_RE = re.compile(r"[A-Za-z0-9._*?-]+\Z", re.ASCII)
1692
+
1693
+
1694
+ def _grep_include_glob_is_safe(value: str) -> bool:
1695
+ return (
1696
+ 1 <= len(value) <= 96
1697
+ and not value.startswith("-")
1698
+ and _GREP_INCLUDE_GLOB_RE.fullmatch(value) is not None
1699
+ and re.search(r"[A-Za-z0-9._]", value, re.ASCII) is not None
1700
+ )
1592
1701
 
1593
- def _grep_is_safe(argv: tuple[str, ...], *, allow_files: bool) -> bool:
1702
+
1703
+ def _grep_is_safe(
1704
+ argv: tuple[str, ...],
1705
+ *,
1706
+ allow_files: bool,
1707
+ allow_include: bool = False,
1708
+ ) -> bool:
1594
1709
  pattern_seen = False
1595
1710
  files = 0
1711
+ include_seen = False
1712
+ recursive_seen = False
1713
+ stdin_operand_seen = False
1596
1714
  allowed_flags = set("nHhivEFGPwxcolLrRq".replace(" ", ""))
1597
1715
  index = 1
1598
1716
  while index < len(argv):
@@ -1600,6 +1718,17 @@ def _grep_is_safe(argv: tuple[str, ...], *, allow_files: bool) -> bool:
1600
1718
  if argument == "--":
1601
1719
  index += 1
1602
1720
  break
1721
+ if argument.startswith("--include"):
1722
+ if (
1723
+ not allow_include
1724
+ or include_seen
1725
+ or not argument.startswith("--include=")
1726
+ or not _grep_include_glob_is_safe(argument.split("=", 1)[1])
1727
+ ):
1728
+ return False
1729
+ include_seen = True
1730
+ index += 1
1731
+ continue
1603
1732
  if argument in {"-f", "--file"} or argument.startswith(("--file=", "--binary-files=")):
1604
1733
  return False
1605
1734
  if argument == "-e":
@@ -1624,9 +1753,12 @@ def _grep_is_safe(argv: tuple[str, ...], *, allow_files: bool) -> bool:
1624
1753
  index += 1
1625
1754
  continue
1626
1755
  if argument == "--recursive":
1756
+ recursive_seen = True
1627
1757
  index += 1
1628
1758
  continue
1629
1759
  if argument in _GREP_LONG_ALIASES:
1760
+ if argument == "--dereference-recursive":
1761
+ recursive_seen = True
1630
1762
  index += 1
1631
1763
  continue
1632
1764
  if argument.startswith("-") and argument != "-":
@@ -1636,19 +1768,31 @@ def _grep_is_safe(argv: tuple[str, ...], *, allow_files: bool) -> bool:
1636
1768
  or not set(argument[1:]).issubset(allowed_flags)
1637
1769
  ):
1638
1770
  return False
1771
+ if "r" in argument[1:] or "R" in argument[1:]:
1772
+ recursive_seen = True
1639
1773
  index += 1
1640
1774
  continue
1641
1775
  if not pattern_seen:
1642
1776
  pattern_seen = True
1643
1777
  else:
1644
1778
  files += 1
1779
+ stdin_operand_seen = stdin_operand_seen or argument == "-"
1645
1780
  index += 1
1646
1781
  while index < len(argv):
1647
1782
  if not pattern_seen:
1648
1783
  pattern_seen = True
1649
1784
  else:
1650
1785
  files += 1
1786
+ stdin_operand_seen = stdin_operand_seen or argv[index] == "-"
1651
1787
  index += 1
1788
+ if include_seen:
1789
+ return (
1790
+ allow_files
1791
+ and recursive_seen
1792
+ and pattern_seen
1793
+ and files > 0
1794
+ and not stdin_operand_seen
1795
+ )
1652
1796
  return pattern_seen and (allow_files or files == 0)
1653
1797
 
1654
1798
 
@@ -1960,6 +2104,24 @@ _GIT_DIFF_SHOW_BOOLEAN_FLAGS = frozenset({
1960
2104
  "--color=never", "--cached", "--staged", "--oneline",
1961
2105
  })
1962
2106
 
2107
+ _GIT_CONFIG_EXECUTION_GUARD = (
2108
+ "GIT_CONFIG_COUNT=1",
2109
+ "GIT_CONFIG_KEY_0=core.fsmonitor",
2110
+ "GIT_CONFIG_VALUE_0=false",
2111
+ )
2112
+ _GIT_ORIGINAL_COMMAND_ENV = "CONTEXT_GUARD_ORIGINAL_COMMAND"
2113
+ _GIT_GUARD_MODE = "--context-guard-exec-git"
2114
+ _GIT_DIFF_EXECUTION_FLAGS = ("--no-ext-diff", "--no-textconv")
2115
+ _GIT_TEXTCONV_EXECUTION_FLAGS = ("--no-textconv",)
2116
+ _GIT_FILTER_CONFIG_KEY_RE = re.compile(
2117
+ r"^filter\..+\.(?:clean|smudge|process|required)$",
2118
+ re.IGNORECASE,
2119
+ )
2120
+ _GIT_FILTER_CONFIG_QUERY = r"^filter\..*\.(clean|smudge|process|required)$"
2121
+ _GIT_FILTER_CONFIG_MAX_KEYS = 128
2122
+ _GIT_FILTER_CONFIG_MAX_BYTES = 65_536
2123
+ _GIT_FILTER_CONFIG_TIMEOUT_SECONDS = 5
2124
+
1963
2125
 
1964
2126
  def _git_diff_show_is_safe(arguments: tuple[str, ...]) -> bool:
1965
2127
  """`git diff`/`git show`: 기존 `_git_is_safe` 경로를 그대로 보존한다
@@ -2176,7 +2338,13 @@ def _npx_route(argv: tuple[str, ...]) -> str:
2176
2338
  index += 1
2177
2339
  continue
2178
2340
  return "deny"
2179
- if index < len(argv) and command_basename(argv[index]) in {"jest", "vitest"}:
2341
+ if index >= len(argv):
2342
+ return "noop"
2343
+ delegated_command = argv[index]
2344
+ delegated_basename = command_basename(delegated_command)
2345
+ if delegated_basename != delegated_command:
2346
+ return "deny"
2347
+ if delegated_basename in {"jest", "vitest"}:
2180
2348
  return "trim"
2181
2349
  return "noop"
2182
2350
 
@@ -2206,6 +2374,27 @@ def _make_route(argv: tuple[str, ...]) -> str:
2206
2374
  return "noop"
2207
2375
 
2208
2376
 
2377
+ def _is_explicit_noop_command(argv: tuple[str, ...]) -> bool:
2378
+ """Match only the pre-existing short-command controls kept by S011.
2379
+
2380
+ Tool basenames are deliberately insufficient: e.g. `kubectl get secrets`
2381
+ and `docker run` still reach the fail-closed fallback. The one variable
2382
+ shape is a read-only pod description with a static Kubernetes-style name.
2383
+ """
2384
+ if argv in MINISHELL_EXPLICIT_NOOP_ARGV:
2385
+ return True
2386
+ return (
2387
+ len(argv) == 4
2388
+ and argv[:3] == ("kubectl", "describe", "pod")
2389
+ and re.fullmatch(
2390
+ r"[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?",
2391
+ argv[3],
2392
+ re.ASCII,
2393
+ )
2394
+ is not None
2395
+ )
2396
+
2397
+
2209
2398
  def command_search_diff(
2210
2399
  argv: tuple[str, ...],
2211
2400
  *,
@@ -2285,7 +2474,11 @@ def command_search_diff(
2285
2474
  if first in {"grep", "egrep", "fgrep"}:
2286
2475
  return (
2287
2476
  "sanitize"
2288
- if _grep_is_safe(argv, allow_files=role != "filter")
2477
+ if _grep_is_safe(
2478
+ argv,
2479
+ allow_files=role != "filter",
2480
+ allow_include=first == "grep" and role != "filter",
2481
+ )
2289
2482
  else "deny"
2290
2483
  )
2291
2484
  if first == "rg":
@@ -2300,7 +2493,20 @@ def command_search_diff(
2300
2493
  if role != "filter" and _git_is_safe(argv)
2301
2494
  else "deny"
2302
2495
  )
2303
- if first in {"npm", "pnpm", "yarn", "bun"}:
2496
+ if first == "echo" or _is_explicit_noop_command(argv):
2497
+ # `echo` is the explicit side-effect-free noop used by the shell
2498
+ # contract and hook-envelope controls. The exact kubectl/docker rows
2499
+ # are the pre-existing short-command controls. Keep both distinct from
2500
+ # the unregistered-command fallback so closing F-1 does not turn a
2501
+ # broad tool basename into an allowlist.
2502
+ route = "noop"
2503
+ elif _wrapper_invocation(argv) is not None:
2504
+ # A direct ContextGuard helper CLI is not an incoming CGW1/v0
2505
+ # execution envelope. `classify_incoming_wrapper` already denied the
2506
+ # exact envelope shapes before route classification; preserve the
2507
+ # established direct-CLI compatibility contract here explicitly.
2508
+ route = "noop"
2509
+ elif first in {"npm", "pnpm", "yarn", "bun"}:
2304
2510
  route = _package_script_route(argv)
2305
2511
  elif first == "npx":
2306
2512
  route = _npx_route(argv)
@@ -2331,7 +2537,11 @@ def command_search_diff(
2331
2537
  elif is_log_streaming_command(list(argv)):
2332
2538
  route = "sanitize"
2333
2539
  else:
2334
- route = "noop"
2540
+ # F-1: an unregistered executable identity (including execution-prefix
2541
+ # wrappers such as nice/command/xargs/stdbuf/nohup) has no modeled
2542
+ # semantics. It must not inherit standalone `noop` merely because it
2543
+ # contains no pipeline.
2544
+ route = "deny"
2335
2545
  if role == "standalone":
2336
2546
  return route
2337
2547
  if role == "first":
@@ -2358,7 +2568,10 @@ def _prefix_overrides_path(
2358
2568
  def _forbidden_command_basename(argv: tuple[str, ...]) -> bool:
2359
2569
  if not argv:
2360
2570
  return False
2361
- basename = command_basename(argv[0])
2571
+ # Forbidden identities may be recognized through a path because this gate
2572
+ # can only narrow behavior. Unlike positive route predicates, a basename
2573
+ # match here never grants a wrapper or noop route.
2574
+ basename = os.path.basename(argv[0])
2362
2575
  if basename in MINISHELL_DENIED_COMMAND_BASENAMES:
2363
2576
  return True
2364
2577
  if basename not in MINISHELL_DENIED_SHELL_BASENAMES:
@@ -2369,6 +2582,43 @@ def _forbidden_command_basename(argv: tuple[str, ...]) -> bool:
2369
2582
  )
2370
2583
 
2371
2584
 
2585
+ def _reference_route_argv(parsed: MiniShellParse) -> tuple[str, ...] | None:
2586
+ """Recognize only the static standalone command emitted by the digest."""
2587
+
2588
+ if (
2589
+ parsed.heredoc_delimiter is not None
2590
+ or len(parsed.segments) != 1
2591
+ or len(parsed.segments[0]) not in {3, 5}
2592
+ ):
2593
+ return None
2594
+ words = parsed.segments[0]
2595
+ if any(
2596
+ word.source_value != word.value
2597
+ or not all(word.active)
2598
+ or word.barriers
2599
+ or word.assignment_index is not None
2600
+ for word in words
2601
+ ):
2602
+ return None
2603
+ argv = tuple(word.value for word in words)
2604
+ if (
2605
+ argv[0] != BASH_REFERENCE_PUBLIC_COMMAND
2606
+ or argv[1] != "reference"
2607
+ or BASH_REFERENCE_HANDLE_RE.fullmatch(argv[2]) is None
2608
+ ):
2609
+ return None
2610
+ if len(argv) == 3:
2611
+ return argv
2612
+ offset = argv[4]
2613
+ if (
2614
+ argv[3] != "--offset"
2615
+ or len(offset) > 20
2616
+ or re.fullmatch(r"(?:0|[1-9][0-9]*)", offset, re.ASCII) is None
2617
+ ):
2618
+ return None
2619
+ return argv
2620
+
2621
+
2372
2622
  def classify_command(command: str, *, allow_cgw1: bool = True) -> CommandDecision:
2373
2623
  """Make a side-effect-free shell-boundary and routing decision."""
2374
2624
  parsed = parse_minishell(command)
@@ -2387,6 +2637,13 @@ def classify_command(command: str, *, allow_cgw1: bool = True) -> CommandDecisio
2387
2637
  reason_code="active_shell_expansion_denied",
2388
2638
  )
2389
2639
 
2640
+ if _reference_route_argv(parsed) is not None:
2641
+ return CommandDecision(
2642
+ action="reference",
2643
+ parsed=parsed,
2644
+ route_code="reference_expand",
2645
+ )
2646
+
2390
2647
  wrapper = classify_incoming_wrapper(parsed)
2391
2648
  if wrapper is not None:
2392
2649
  wrapper_status, _wrapper_kind_name, _payload = wrapper
@@ -2443,6 +2700,19 @@ def classify_command(command: str, *, allow_cgw1: bool = True) -> CommandDecisio
2443
2700
  reason="Forbidden command denied (forbidden_command_denied).",
2444
2701
  reason_code="forbidden_command_denied",
2445
2702
  )
2703
+ if (
2704
+ command_basename(route_argv[0]) != route_argv[0]
2705
+ and not (
2706
+ len(parsed.segments) == 1
2707
+ and _is_expected_direct_wrapper_path(route_argv)
2708
+ )
2709
+ ):
2710
+ return CommandDecision(
2711
+ action="deny",
2712
+ parsed=parsed,
2713
+ reason="Non-bare command identity denied (command_identity_denied).",
2714
+ reason_code="command_identity_denied",
2715
+ )
2446
2716
  if parsed.heredoc_delimiter is not None and (
2447
2717
  len(parsed.segments) != 1
2448
2718
  or command_basename(route_argv[0])
@@ -2519,25 +2789,185 @@ def shell_join(argv: list[str] | tuple[str, ...]) -> str:
2519
2789
  return " ".join(shell_quote(value) for value in argv)
2520
2790
 
2521
2791
 
2522
- def build_wrapped_command(wrapper: str, command: str) -> str:
2523
- if wrapper.endswith(".py"):
2524
- prefix = ["python3", wrapper]
2525
- else:
2526
- prefix = [wrapper]
2527
- wrapped_argv = prefix + ["--max-lines", CGW1_MAX_LINES, "--", *CGW1_SHELL_ARGV, command]
2792
+ def _render_minishell_word(word: MiniShellWord) -> str:
2793
+ assignment_name = _env_prefix_name(word)
2794
+ if assignment_name is None:
2795
+ return shell_quote(word.value)
2796
+ assignment_value = word.value[len(assignment_name) + 1 :]
2797
+ return f"{assignment_name}={shell_quote(assignment_value)}"
2798
+
2799
+
2800
+ def _git_execution_guard_spec(git_argv: tuple[str, ...]) -> tuple[int, tuple[str, ...]]:
2801
+ if len(git_argv) < 2:
2802
+ return (len(git_argv), ())
2803
+ subcommand = git_argv[1]
2804
+ if subcommand in {"diff", "show", "log"}:
2805
+ return (2, _GIT_DIFF_EXECUTION_FLAGS)
2806
+ if subcommand in {"grep", "blame"}:
2807
+ return (2, _GIT_TEXTCONV_EXECUTION_FLAGS)
2808
+ if len(git_argv) >= 3 and git_argv[:3] == ("git", "stash", "show"):
2809
+ return (3, _GIT_DIFF_EXECUTION_FLAGS)
2810
+ return (2, ())
2811
+
2812
+
2813
+ def _validated_guarded_git_argv(argv: tuple[str, ...]) -> tuple[str, ...] | None:
2814
+ """Accept only the exact guarded form of an independently safe Git command."""
2815
+ if not argv or command_basename(argv[0]) != "git":
2816
+ return None
2817
+ flag_index, expected_flags = _git_execution_guard_spec(argv)
2818
+ if tuple(argv[flag_index : flag_index + len(expected_flags)]) != expected_flags:
2819
+ return None
2820
+ original_argv = (
2821
+ argv[:flag_index]
2822
+ + argv[flag_index + len(expected_flags) :]
2823
+ )
2824
+ if not _git_is_safe(original_argv):
2825
+ return None
2826
+ return argv
2827
+
2828
+
2829
+ def _clear_git_command_scope_config(environment: dict[str, str]) -> None:
2830
+ environment.pop("GIT_CONFIG_COUNT", None)
2831
+ environment.pop("GIT_CONFIG_PARAMETERS", None)
2832
+ for name in tuple(environment):
2833
+ if re.fullmatch(r"GIT_CONFIG_(?:KEY|VALUE)_\d+", name):
2834
+ environment.pop(name, None)
2835
+
2836
+
2837
+ def _discover_git_filter_config_keys() -> tuple[str, ...]:
2838
+ discovery_env = os.environ.copy()
2839
+ _clear_git_command_scope_config(discovery_env)
2840
+ discovery_env.update(
2841
+ {
2842
+ "GIT_CONFIG_COUNT": "1",
2843
+ "GIT_CONFIG_KEY_0": "core.fsmonitor",
2844
+ "GIT_CONFIG_VALUE_0": "false",
2845
+ }
2846
+ )
2847
+ result = subprocess.run(
2848
+ [
2849
+ "git",
2850
+ "config",
2851
+ "--null",
2852
+ "--name-only",
2853
+ "--get-regexp",
2854
+ _GIT_FILTER_CONFIG_QUERY,
2855
+ ],
2856
+ env=discovery_env,
2857
+ stdin=subprocess.DEVNULL,
2858
+ stdout=subprocess.PIPE,
2859
+ stderr=subprocess.DEVNULL,
2860
+ timeout=_GIT_FILTER_CONFIG_TIMEOUT_SECONDS,
2861
+ check=False,
2862
+ )
2863
+ if result.returncode not in {0, 1}:
2864
+ raise RuntimeError("git config discovery failed")
2865
+ if len(result.stdout) > _GIT_FILTER_CONFIG_MAX_BYTES:
2866
+ raise RuntimeError("git filter config exceeded the discovery limit")
2867
+
2868
+ keys: list[str] = []
2869
+ seen: set[str] = set()
2870
+ for raw_key in result.stdout.split(b"\0"):
2871
+ if not raw_key:
2872
+ continue
2873
+ key = os.fsdecode(raw_key)
2874
+ if not _GIT_FILTER_CONFIG_KEY_RE.fullmatch(key):
2875
+ raise RuntimeError("git config discovery returned an unexpected key")
2876
+ if key in seen:
2877
+ continue
2878
+ seen.add(key)
2879
+ keys.append(key)
2880
+ if len(keys) > _GIT_FILTER_CONFIG_MAX_KEYS:
2881
+ raise RuntimeError("too many git filter config keys")
2882
+ return tuple(keys)
2883
+
2884
+
2885
+ def _guarded_git_environment(filter_keys: tuple[str, ...]) -> dict[str, str]:
2886
+ environment = os.environ.copy()
2887
+ _clear_git_command_scope_config(environment)
2888
+ environment.pop("GIT_EXTERNAL_DIFF", None)
2889
+ config_pairs: list[tuple[str, str]] = [("core.fsmonitor", "false")]
2890
+ for key in filter_keys:
2891
+ value = "false" if key.casefold().endswith(".required") else ""
2892
+ config_pairs.append((key, value))
2893
+ environment["GIT_CONFIG_COUNT"] = str(len(config_pairs))
2894
+ for index, (key, value) in enumerate(config_pairs):
2895
+ environment[f"GIT_CONFIG_KEY_{index}"] = key
2896
+ environment[f"GIT_CONFIG_VALUE_{index}"] = value
2897
+ return environment
2898
+
2899
+
2900
+ def run_guarded_git(argv: tuple[str, ...]) -> int:
2901
+ guarded_argv = _validated_guarded_git_argv(argv)
2902
+ if guarded_argv is None:
2903
+ print("ContextGuard denied an invalid guarded Git invocation.", file=sys.stderr)
2904
+ return 126
2905
+ try:
2906
+ filter_keys = _discover_git_filter_config_keys()
2907
+ environment = _guarded_git_environment(filter_keys)
2908
+ os.execvpe(guarded_argv[0], list(guarded_argv), environment)
2909
+ except (OSError, RuntimeError, subprocess.SubprocessError):
2910
+ print("ContextGuard could not neutralize Git execution configuration.", file=sys.stderr)
2911
+ return 126
2912
+ raise AssertionError("os.execvpe returned unexpectedly")
2913
+
2914
+
2915
+ def neutralize_git_config_execution(command: str, parsed: MiniShellParse) -> str:
2916
+ """Disable config helpers while retaining the original command for inspection."""
2917
+ guarded_segments: list[str] = []
2918
+ changed = False
2919
+ for segment in parsed.segments:
2920
+ segment_argv = tuple(word.value for word in segment)
2921
+ route_start = _routing_start(segment, segment_argv)
2922
+ rendered_words = [_render_minishell_word(word) for word in segment]
2923
+ if (
2924
+ route_start >= 0
2925
+ and route_start + 1 < len(segment_argv)
2926
+ and command_basename(segment_argv[route_start]) == "git"
2927
+ ):
2928
+ git_argv = segment_argv[route_start:]
2929
+ relative_flag_index, flags = _git_execution_guard_spec(git_argv)
2930
+ flag_index = route_start + relative_flag_index
2931
+ rendered_words[flag_index:flag_index] = flags
2932
+ rendered_words[route_start : route_start + 1] = (
2933
+ shell_quote(_approved_python_runtime()),
2934
+ "-I",
2935
+ shell_quote(os.path.realpath(__file__)),
2936
+ _GIT_GUARD_MODE,
2937
+ "--",
2938
+ "git",
2939
+ )
2940
+ # Existing wrapper consumers inspect the rewritten string for the
2941
+ # admitted source command. Keep it as one quoted, namespaced
2942
+ # assignment; Git ignores the value and the shell cannot execute it.
2943
+ original_command_marker = (
2944
+ f"{_GIT_ORIGINAL_COMMAND_ENV}={shell_quote(command)}"
2945
+ )
2946
+ rendered_words[route_start:route_start] = (
2947
+ original_command_marker,
2948
+ *_GIT_CONFIG_EXECUTION_GUARD,
2949
+ )
2950
+ changed = True
2951
+ guarded_segments.append(" ".join(rendered_words))
2952
+ return " | ".join(guarded_segments) if changed else command
2953
+
2954
+
2955
+ def build_wrapped_command(wrapper: str, command: str, *, bash_reference_v1: bool = False) -> str:
2956
+ prefix = _isolated_wrapper_prefix(wrapper)
2957
+ wrapped_argv = prefix + ["--max-lines", CGW1_MAX_LINES]
2958
+ if bash_reference_v1:
2959
+ wrapped_argv += ["--digest", "json", BASH_REFERENCE_FLAG]
2960
+ wrapped_argv += ["--", *_runtime_shell_argv(), command]
2528
2961
  return shell_join(wrapped_argv)
2529
2962
 
2530
2963
 
2531
2964
  def build_sanitized_command(wrapper: str, command: str) -> str:
2532
- if wrapper.endswith(".py"):
2533
- prefix = ["python3", wrapper]
2534
- else:
2535
- prefix = [wrapper]
2965
+ prefix = _isolated_wrapper_prefix(wrapper)
2536
2966
  wrapped_argv = prefix + [
2537
2967
  CGW1_SENTINEL,
2538
2968
  CGW1_COMMAND_SEARCH_DIFF,
2539
2969
  "--",
2540
- *CGW1_SHELL_ARGV,
2970
+ *_runtime_shell_argv(),
2541
2971
  command,
2542
2972
  ]
2543
2973
  return shell_join(wrapped_argv)
@@ -2560,9 +2990,15 @@ def print_updated_command(wrapped: str, tool_input: dict[str, object]) -> None:
2560
2990
 
2561
2991
 
2562
2992
  def main() -> int:
2993
+ if sys.argv[1:3] == [_GIT_GUARD_MODE, "--"]:
2994
+ return run_guarded_git(tuple(sys.argv[3:]))
2995
+ if _GIT_GUARD_MODE in sys.argv[1:]:
2996
+ print("ContextGuard denied a malformed guarded Git invocation.", file=sys.stderr)
2997
+ return 126
2563
2998
  if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
2564
2999
  print("ContextGuard helper: context-guard-rewrite-bash")
2565
3000
  return 0
3001
+ bash_reference_v1 = BASH_REFERENCE_FLAG in sys.argv[1:]
2566
3002
  try:
2567
3003
  payload = load_hook_payload()
2568
3004
  tool_input = select_tool_input(payload)
@@ -2595,7 +3031,7 @@ def main() -> int:
2595
3031
  f"{FAIL_OPEN_ENV}=1 to run untrimmed intentionally."
2596
3032
  )
2597
3033
  return 0
2598
- wrapped = build_wrapped_command(wrapper, command)
3034
+ wrapped = build_wrapped_command(wrapper, command, bash_reference_v1=bash_reference_v1)
2599
3035
  elif decision.action == "sanitize":
2600
3036
  wrapper = find_wrapper("sanitize")
2601
3037
  if wrapper is None:
@@ -2606,7 +3042,23 @@ def main() -> int:
2606
3042
  )
2607
3043
  deny(reason)
2608
3044
  return 0
2609
- wrapped = build_sanitized_command(wrapper, command)
3045
+ guarded_command = neutralize_git_config_execution(command, decision.parsed)
3046
+ wrapped = build_sanitized_command(wrapper, guarded_command)
3047
+ elif decision.action == "reference":
3048
+ wrapper = find_wrapper("trim")
3049
+ if wrapper is None:
3050
+ deny(
3051
+ "Reference expansion blocked because the package-local trim helper "
3052
+ "is unavailable. Reinstall ContextGuard."
3053
+ )
3054
+ return 0
3055
+ reference_argv = _reference_route_argv(decision.parsed)
3056
+ if reference_argv is None:
3057
+ raise AssertionError("reference route lost its closed grammar")
3058
+ prefix = ["python3", wrapper] if wrapper.endswith(".py") else [wrapper]
3059
+ wrapped = shell_join(
3060
+ [*prefix, "--expand-bash-reference", *reference_argv[2:]]
3061
+ )
2610
3062
  else:
2611
3063
  raise AssertionError(f"unknown command action: {decision.action}")
2612
3064