@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
|
@@ -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", "-
|
|
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
|
-
|
|
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"
|
|
854
|
+
if first in {"mvn", "mvnw"} and "test" in rest:
|
|
786
855
|
return True
|
|
787
|
-
if first in {"gradle", "gradlew"
|
|
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
|
-
|
|
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
|
|
1122
|
+
and os.path.basename(argv[1]) in WRAPPER_BASENAMES
|
|
1051
1123
|
):
|
|
1052
|
-
return
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
-
#: 값 형태를 취하는 옵션(`--
|
|
1654
|
+
#: 값 형태를 취하는 옵션(`--exclude=`, `--exclude-dir=`, `--devices=`,
|
|
1553
1655
|
#: `--directories=`, `--label=`, `--binary-files=`, `-D/-U/-z/-Z/--null` 계열)은
|
|
1554
1656
|
#: 의도적으로 제외한다. 이유는 값이 동작을 바꾸기 때문이다(예: `--directories=read`).
|
|
1555
1657
|
#:
|
|
1556
|
-
#:
|
|
1557
|
-
#:
|
|
1558
|
-
#:
|
|
1559
|
-
#:
|
|
1560
|
-
#:
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|