@ictechgy/context-guard 0.4.15 → 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.
- package/CHANGELOG.md +80 -0
- package/README.ko.md +128 -2
- package/README.md +144 -3
- package/docs/distribution.md +100 -0
- package/package.json +4 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +43 -1
- package/plugins/context-guard/README.md +44 -1
- package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
- package/plugins/context-guard/bin/context-guard-artifact +90 -9
- package/plugins/context-guard/bin/context-guard-audit +169 -66
- package/plugins/context-guard/bin/context-guard-bench +9865 -211
- package/plugins/context-guard/bin/context-guard-compress +90 -8
- package/plugins/context-guard/bin/context-guard-diet +1 -7
- package/plugins/context-guard/bin/context-guard-experiments +5 -1
- package/plugins/context-guard/bin/context-guard-failed-nudge +777 -83
- package/plugins/context-guard/bin/context-guard-guard-read +496 -57
- package/plugins/context-guard/bin/context-guard-mcp +2 -1
- package/plugins/context-guard/bin/context-guard-pack +1570 -150
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2669 -236
- package/plugins/context-guard/bin/context-guard-sanitize-output +723 -92
- package/plugins/context-guard/bin/context-guard-setup +1944 -222
- package/plugins/context-guard/bin/context-guard-statusline +163 -55
- package/plugins/context-guard/bin/context-guard-statusline-merged +78 -23
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +795 -48
- package/plugins/context-guard/brief/README.md +19 -0
- package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
- package/plugins/context-guard/lib/context_guard_commands.py +10 -2
- package/plugins/context-guard/lib/credential_policy.py +185 -0
- package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
|
@@ -7,19 +7,16 @@ experiments so it can be versioned and reviewed.
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import copy
|
|
11
|
+
from dataclasses import dataclass
|
|
10
12
|
import json
|
|
11
13
|
import os
|
|
12
14
|
import re
|
|
13
|
-
import
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
14
17
|
import sys
|
|
15
18
|
|
|
16
|
-
# Reject actual shell control operators after shlex tokenization. Quoted search
|
|
17
|
-
# patterns such as `rg "token|password"` and `grep "^foo$"` are safe to wrap,
|
|
18
|
-
# but real pipes, redirects, command substitutions, and sequencing are not.
|
|
19
|
-
SHELL_OPERATOR_TOKENS = {";", ";;", ";&", ";;&", "&", "&&", "|", "||", "<", ">", "<<", ">>", "<>", "(", ")"}
|
|
20
|
-
SHELL_OPERATOR_CHARS = frozenset(";&|<>()")
|
|
21
19
|
ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*")
|
|
22
|
-
SAFE_PIPE_FILTER_BASENAMES = frozenset({"cat", "head", "tail", "wc", "sort", "uniq"})
|
|
23
20
|
WRAPPER_BASENAMES = frozenset({
|
|
24
21
|
"trim_command_output.py",
|
|
25
22
|
"context-guard-trim-output",
|
|
@@ -28,15 +25,158 @@ WRAPPER_BASENAMES = frozenset({
|
|
|
28
25
|
"context-guard-sanitize-output",
|
|
29
26
|
"claude-sanitize-output",
|
|
30
27
|
})
|
|
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
|
+
})
|
|
36
|
+
MINISHELL_MAX_COMMAND_BYTES = 65_536
|
|
37
|
+
MINISHELL_MAX_LEXICAL_ITEMS = 4_096
|
|
38
|
+
MINISHELL_MAX_SEGMENTS = 8
|
|
39
|
+
MINISHELL_MAX_WORDS_PER_SEGMENT = 256
|
|
40
|
+
MINISHELL_MAX_HEREDOC_DELIMITER_BYTES = 64
|
|
41
|
+
MINISHELL_DENIED_ACTIVE_CHARS = frozenset(";&>()`*?[]{}")
|
|
42
|
+
MINISHELL_DENIED_COMMAND_WORDS = frozenset({
|
|
43
|
+
"!",
|
|
44
|
+
"case",
|
|
45
|
+
"coproc",
|
|
46
|
+
"do",
|
|
47
|
+
"done",
|
|
48
|
+
"elif",
|
|
49
|
+
"else",
|
|
50
|
+
"esac",
|
|
51
|
+
"fi",
|
|
52
|
+
"for",
|
|
53
|
+
"function",
|
|
54
|
+
"if",
|
|
55
|
+
"in",
|
|
56
|
+
"select",
|
|
57
|
+
"then",
|
|
58
|
+
"time",
|
|
59
|
+
"until",
|
|
60
|
+
"while",
|
|
61
|
+
})
|
|
62
|
+
MINISHELL_DENIED_COMMAND_BASENAMES = frozenset({
|
|
63
|
+
"curl",
|
|
64
|
+
"eval",
|
|
65
|
+
"exec",
|
|
66
|
+
"fetch",
|
|
67
|
+
"ftp",
|
|
68
|
+
"nc",
|
|
69
|
+
"ncat",
|
|
70
|
+
"netcat",
|
|
71
|
+
"scp",
|
|
72
|
+
"sftp",
|
|
73
|
+
"socat",
|
|
74
|
+
"ssh",
|
|
75
|
+
"tee",
|
|
76
|
+
"telnet",
|
|
77
|
+
"wget",
|
|
78
|
+
})
|
|
79
|
+
MINISHELL_DENIED_SHELL_BASENAMES = frozenset({
|
|
80
|
+
"bash",
|
|
81
|
+
"dash",
|
|
82
|
+
"fish",
|
|
83
|
+
"ksh",
|
|
84
|
+
"sh",
|
|
85
|
+
"zsh",
|
|
86
|
+
})
|
|
87
|
+
MINISHELL_HEREDOC_STDIN_CONSUMERS = frozenset({
|
|
88
|
+
"cut",
|
|
89
|
+
"sed",
|
|
90
|
+
"sort",
|
|
91
|
+
"uniq",
|
|
92
|
+
"wc",
|
|
93
|
+
})
|
|
94
|
+
MINISHELL_HEREDOC_DELIMITER_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
|
95
|
+
# bash 가 접두사 할당으로 적용하는 `NAME+=VALUE` 형태 — MiniShell 은 이를 할당으로
|
|
96
|
+
# 표시하지 않으므로(§_is_unmodeled_assignment_prefix) 라우팅 접두사 구간에서 거부한다.
|
|
97
|
+
MINISHELL_APPEND_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\+=")
|
|
98
|
+
# 환경변수 접두사(`KEY=VALUE cmd`) 이름 화이트리스트 — FIX-5, 원칙 4의 유일한 예외.
|
|
99
|
+
# denylist 는 구조적으로 종료하지 않는다(실측: 최소 denylist가 PAGER/EDITOR/VISUAL/
|
|
100
|
+
# PERL5LIB/RUBYOPT/PYTHONPATH/PYTHONSTARTUP/NODE_OPTIONS 8종을 놓침). 이 15개는
|
|
101
|
+
# "값을 실행 가능한 코드 경로로 해석하지 않는다"는 기준을 통과한 것만 포함한다.
|
|
102
|
+
# 정확 이름 일치만 허용 — 접두사/글롭 매칭 금지(`TERM*`는 `TERMINFO`를 재승인시킨다).
|
|
103
|
+
# TERM 은 TERMINFO/TERMINFO_DIRS 가, LANG/LC_* 는 LOCPATH/NLSPATH 가 배제되었기
|
|
104
|
+
# 때문에만 안전하다 — 이 조건부 안전성을 확장 심사 시 반드시 재확인할 것.
|
|
105
|
+
MINISHELL_ALLOWED_ENV_PREFIX_NAMES = frozenset({
|
|
106
|
+
"LANG",
|
|
107
|
+
"LC_ALL",
|
|
108
|
+
"LC_CTYPE",
|
|
109
|
+
"LC_NUMERIC",
|
|
110
|
+
"LC_TIME",
|
|
111
|
+
"LC_COLLATE",
|
|
112
|
+
"LC_MESSAGES",
|
|
113
|
+
"TZ",
|
|
114
|
+
"NO_COLOR",
|
|
115
|
+
"CLICOLOR",
|
|
116
|
+
"CI",
|
|
117
|
+
"COLUMNS",
|
|
118
|
+
"LINES",
|
|
119
|
+
"TERM",
|
|
120
|
+
"NODE_ENV",
|
|
121
|
+
})
|
|
122
|
+
CGW1_MAX_LINES = "220"
|
|
123
|
+
CGW1_SHELL_ARGV = ("bash", "-c")
|
|
124
|
+
CGW1_SENTINEL = "--context-guard-wrapper-v1"
|
|
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)
|
|
31
129
|
FAIL_OPEN_ENV = "CONTEXT_GUARD_SANITIZER_FAIL_OPEN"
|
|
32
130
|
LEGACY_FAIL_OPEN_ENV = "CLAUDE_TOKEN_SANITIZER_FAIL_OPEN"
|
|
33
131
|
FAIL_OPEN_VALUES = {"1", "true", "yes", "on"}
|
|
132
|
+
MAX_HOOK_ENVELOPE_BYTES = 1_048_576
|
|
34
133
|
UNPARSEABLE_SANITIZER_RISK_RE = re.compile(
|
|
35
134
|
r"(?i)(?:^|[\s;&|()])"
|
|
36
135
|
r"(?:rg|grep|egrep|fgrep|journalctl|kubectl|oc|docker|podman|docker-compose|git|find)"
|
|
37
136
|
r"(?:$|[\s;&|()])"
|
|
38
137
|
)
|
|
39
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
|
+
|
|
40
180
|
# kubectl/docker/podman/oc 글로벌 옵션 중 다음 토큰을 value로 소비하는 형태.
|
|
41
181
|
# `-n prod`, `--context=prod`, `-f file.yml` 같은 케이스를 hub로 흡수해
|
|
42
182
|
# `kubectl -n prod logs api`, `docker --context prod logs api`,
|
|
@@ -67,6 +207,98 @@ _FIND_OUTPUT_RISK_ACTIONS = frozenset({
|
|
|
67
207
|
})
|
|
68
208
|
|
|
69
209
|
|
|
210
|
+
@dataclass(frozen=True)
|
|
211
|
+
class MiniShellWord:
|
|
212
|
+
value: str
|
|
213
|
+
source_value: str
|
|
214
|
+
active: tuple[bool, ...]
|
|
215
|
+
barriers: frozenset[int]
|
|
216
|
+
assignment_index: int | None
|
|
217
|
+
active_tilde_sites: tuple[int, ...]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass(frozen=True)
|
|
221
|
+
class MiniShellParse:
|
|
222
|
+
words: tuple[MiniShellWord, ...]
|
|
223
|
+
segments: tuple[tuple[MiniShellWord, ...], ...]
|
|
224
|
+
argv: tuple[str, ...]
|
|
225
|
+
consumed: int
|
|
226
|
+
denial_reason: str | None = None
|
|
227
|
+
lexical_items: int = 0
|
|
228
|
+
heredoc_delimiter: str | None = None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class CommandDecision:
|
|
233
|
+
action: str
|
|
234
|
+
parsed: MiniShellParse
|
|
235
|
+
reason: str | None = None
|
|
236
|
+
reason_code: str | None = None
|
|
237
|
+
route_code: str | None = None
|
|
238
|
+
policy_version: str = MINISHELL_ROUTE_POLICY_VERSION
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class HookInputError(ValueError):
|
|
242
|
+
def __init__(self, reason_code: str):
|
|
243
|
+
super().__init__(reason_code)
|
|
244
|
+
self.reason_code = reason_code
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
248
|
+
decoded: dict[str, object] = {}
|
|
249
|
+
for key, value in pairs:
|
|
250
|
+
if key in decoded:
|
|
251
|
+
raise HookInputError("duplicate_json_key")
|
|
252
|
+
decoded[key] = value
|
|
253
|
+
return decoded
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def reject_nonfinite_json_number(value: str) -> object:
|
|
257
|
+
raise HookInputError(f"non_finite_json_number_{value.lower()}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def load_hook_payload() -> dict[str, object]:
|
|
261
|
+
raw_payload = sys.stdin.buffer.read(MAX_HOOK_ENVELOPE_BYTES + 1)
|
|
262
|
+
if len(raw_payload) > MAX_HOOK_ENVELOPE_BYTES:
|
|
263
|
+
raise HookInputError("envelope_too_large")
|
|
264
|
+
try:
|
|
265
|
+
payload_text = raw_payload.decode("utf-8")
|
|
266
|
+
payload = json.loads(
|
|
267
|
+
payload_text,
|
|
268
|
+
object_pairs_hook=reject_duplicate_keys,
|
|
269
|
+
parse_constant=reject_nonfinite_json_number,
|
|
270
|
+
)
|
|
271
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
272
|
+
raise HookInputError("malformed_json") from exc
|
|
273
|
+
except RecursionError as exc:
|
|
274
|
+
raise HookInputError("json_nesting_too_deep") from exc
|
|
275
|
+
if not isinstance(payload, dict):
|
|
276
|
+
raise HookInputError("top_level_not_object")
|
|
277
|
+
return payload
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def select_tool_input(payload: dict[str, object]) -> dict[str, object]:
|
|
281
|
+
has_snake_case = "tool_input" in payload
|
|
282
|
+
has_camel_case = "toolInput" in payload
|
|
283
|
+
if has_snake_case and has_camel_case:
|
|
284
|
+
if payload["tool_input"] != payload["toolInput"]:
|
|
285
|
+
raise HookInputError("conflicting_tool_input_aliases")
|
|
286
|
+
tool_input = payload["tool_input"]
|
|
287
|
+
elif has_snake_case:
|
|
288
|
+
tool_input = payload["tool_input"]
|
|
289
|
+
elif has_camel_case:
|
|
290
|
+
tool_input = payload["toolInput"]
|
|
291
|
+
else:
|
|
292
|
+
raise HookInputError("missing_tool_input")
|
|
293
|
+
if not isinstance(tool_input, dict):
|
|
294
|
+
raise HookInputError("tool_input_not_object")
|
|
295
|
+
|
|
296
|
+
command = tool_input.get("command")
|
|
297
|
+
if not isinstance(command, str) or not command:
|
|
298
|
+
raise HookInputError("missing_or_invalid_command")
|
|
299
|
+
return tool_input
|
|
300
|
+
|
|
301
|
+
|
|
70
302
|
def find_wrapper(kind: str) -> str | None:
|
|
71
303
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
72
304
|
if kind == "sanitize":
|
|
@@ -102,6 +334,22 @@ def print_noop() -> None:
|
|
|
102
334
|
print("{}")
|
|
103
335
|
|
|
104
336
|
|
|
337
|
+
def print_deny_response(reason: str) -> None:
|
|
338
|
+
print(json.dumps({
|
|
339
|
+
"hookSpecificOutput": {
|
|
340
|
+
"hookEventName": "PreToolUse",
|
|
341
|
+
"permissionDecision": "deny",
|
|
342
|
+
"permissionDecisionReason": reason,
|
|
343
|
+
}
|
|
344
|
+
}, ensure_ascii=False))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def deny_invalid_hook_input(reason_code: str) -> None:
|
|
348
|
+
reason = f"Invalid Bash hook input ({reason_code})."
|
|
349
|
+
print(f"context-guard-rewrite-bash: {reason}", file=sys.stderr)
|
|
350
|
+
print_deny_response(reason)
|
|
351
|
+
|
|
352
|
+
|
|
105
353
|
def deny(reason: str) -> None:
|
|
106
354
|
print(f"context-guard-rewrite-bash: {reason}", file=sys.stderr)
|
|
107
355
|
fail_open_env = fail_open_source_env()
|
|
@@ -112,114 +360,421 @@ def deny(reason: str) -> None:
|
|
|
112
360
|
)
|
|
113
361
|
print_noop()
|
|
114
362
|
return
|
|
115
|
-
|
|
116
|
-
"hookSpecificOutput": {
|
|
117
|
-
"hookEventName": "PreToolUse",
|
|
118
|
-
"permissionDecision": "deny",
|
|
119
|
-
"permissionDecisionReason": reason,
|
|
120
|
-
}
|
|
121
|
-
}, ensure_ascii=False))
|
|
363
|
+
print_deny_response(reason)
|
|
122
364
|
|
|
123
365
|
|
|
124
|
-
def
|
|
125
|
-
"""
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
lowered = command.lower()
|
|
129
|
-
if re.search(r"(?:^|[\s;&|()])(?:rg|grep|egrep|fgrep)(?:$|[\s;&|()])", lowered):
|
|
130
|
-
return True
|
|
131
|
-
if re.search(r"(?:^|[\s;&|()])(?:journalctl|kubectl|oc|docker|podman|docker-compose)(?:$|[\s;&|()])", lowered):
|
|
132
|
-
return any(word in lowered for word in (" logs", " log ", "journalctl"))
|
|
133
|
-
if re.search(r"(?:^|[\s;&|()])git(?:$|[\s;&|()])", lowered):
|
|
134
|
-
return any(word in lowered for word in (" diff", " show", " grep", " log")) and (
|
|
135
|
-
" diff" in lowered or " show" in lowered or " grep" in lowered or " -p" in lowered or " --patch" in lowered
|
|
136
|
-
)
|
|
137
|
-
if re.search(r"(?:^|[\s;&|()])find(?:$|[\s;&|()])", lowered):
|
|
138
|
-
return any(action in lowered for action in (" -exec", " -execdir", " -ok", " -okdir", " -delete", " -fprint", " -fls"))
|
|
139
|
-
return False
|
|
366
|
+
def deny_boundary(reason: str) -> None:
|
|
367
|
+
"""Hard-deny invalid shell structure without consulting fail-open state."""
|
|
368
|
+
print(f"context-guard-rewrite-bash: {reason}", file=sys.stderr)
|
|
369
|
+
print_deny_response(reason)
|
|
140
370
|
|
|
141
371
|
|
|
142
|
-
def
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return None
|
|
153
|
-
if not argv:
|
|
154
|
-
return None
|
|
155
|
-
for token in argv:
|
|
156
|
-
if token in SHELL_OPERATOR_TOKENS or (
|
|
157
|
-
any(char in SHELL_OPERATOR_CHARS for char in token)
|
|
158
|
-
and all(char in SHELL_OPERATOR_CHARS for char in token)
|
|
159
|
-
):
|
|
372
|
+
def _exact_assignment_index(
|
|
373
|
+
value: str,
|
|
374
|
+
active: tuple[bool, ...],
|
|
375
|
+
barriers: frozenset[int],
|
|
376
|
+
) -> int | None:
|
|
377
|
+
for index, char in enumerate(value):
|
|
378
|
+
if char != "=" or not active[index]:
|
|
379
|
+
continue
|
|
380
|
+
name = value[:index]
|
|
381
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
|
160
382
|
return None
|
|
161
|
-
if
|
|
383
|
+
if not all(active[:index]):
|
|
162
384
|
return None
|
|
163
|
-
if
|
|
385
|
+
if any(boundary <= index for boundary in barriers):
|
|
164
386
|
return None
|
|
165
|
-
|
|
166
|
-
|
|
387
|
+
return index
|
|
388
|
+
return None
|
|
167
389
|
|
|
168
|
-
def split_safe_sanitizer_pipeline(command: str) -> list[list[str]] | None:
|
|
169
|
-
"""Return argv segments for a narrow read-only pipeline safe to sanitizer-wrap.
|
|
170
390
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
helper therefore allows only plain `|` pipelines where the first segment is
|
|
175
|
-
sanitizer-worthy and every later segment is a simple stdout filter. It
|
|
176
|
-
intentionally rejects redirection, here-doc/string, `tee`, `curl`, `&&`,
|
|
177
|
-
command substitution, and other shell syntax.
|
|
178
|
-
"""
|
|
179
|
-
if not command.strip():
|
|
180
|
-
return None
|
|
181
|
-
if any(char in command for char in "\n\r\t`"):
|
|
182
|
-
return None
|
|
183
|
-
if "$(" in command or "${" in command:
|
|
391
|
+
def _tilde_prefix_end(word: MiniShellWord, start: int, assignment_site: bool) -> int | None:
|
|
392
|
+
source = word.source_value
|
|
393
|
+
if start >= len(source) or source[start] != "~" or not word.active[start]:
|
|
184
394
|
return None
|
|
185
|
-
|
|
186
|
-
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
187
|
-
lexer.whitespace_split = True
|
|
188
|
-
tokens = list(lexer)
|
|
189
|
-
except ValueError:
|
|
395
|
+
if start in word.barriers:
|
|
190
396
|
return None
|
|
191
|
-
|
|
397
|
+
index = start + 1
|
|
398
|
+
while index < len(source):
|
|
399
|
+
char = source[index]
|
|
400
|
+
if word.active[index] and (char == "/" or (assignment_site and char == ":")):
|
|
401
|
+
break
|
|
402
|
+
if not word.active[index] or index in word.barriers:
|
|
403
|
+
return None
|
|
404
|
+
index += 1
|
|
405
|
+
if any(start < boundary <= index for boundary in word.barriers):
|
|
192
406
|
return None
|
|
407
|
+
return index
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _assignment_tilde_sites(word: MiniShellWord) -> tuple[tuple[int, int], ...]:
|
|
411
|
+
assignment_index = word.assignment_index
|
|
412
|
+
if assignment_index is None:
|
|
413
|
+
return ()
|
|
414
|
+
sites: list[tuple[int, int]] = []
|
|
415
|
+
delimiters = [assignment_index]
|
|
416
|
+
delimiters.extend(
|
|
417
|
+
index
|
|
418
|
+
for index in range(assignment_index + 1, len(word.source_value))
|
|
419
|
+
if word.source_value[index] == ":" and word.active[index]
|
|
420
|
+
)
|
|
421
|
+
for delimiter in delimiters:
|
|
422
|
+
start = delimiter + 1
|
|
423
|
+
if start in word.barriers:
|
|
424
|
+
continue
|
|
425
|
+
end = _tilde_prefix_end(word, start, assignment_site=True)
|
|
426
|
+
if end is not None:
|
|
427
|
+
sites.append((start, end))
|
|
428
|
+
return tuple(sites)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _annotate_word_tildes(word: MiniShellWord) -> MiniShellWord:
|
|
432
|
+
sites = list(_assignment_tilde_sites(word))
|
|
433
|
+
if word.source_value.startswith("~"):
|
|
434
|
+
end = _tilde_prefix_end(word, 0, assignment_site=False)
|
|
435
|
+
if end is not None:
|
|
436
|
+
sites.append((0, end))
|
|
437
|
+
return MiniShellWord(
|
|
438
|
+
value=word.source_value,
|
|
439
|
+
source_value=word.source_value,
|
|
440
|
+
active=word.active,
|
|
441
|
+
barriers=word.barriers,
|
|
442
|
+
assignment_index=word.assignment_index,
|
|
443
|
+
active_tilde_sites=tuple(start for start, _end in sorted(set(sites))),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _denied_minishell(command: str, consumed: int, reason: str) -> MiniShellParse:
|
|
448
|
+
return MiniShellParse(
|
|
449
|
+
words=(),
|
|
450
|
+
segments=(),
|
|
451
|
+
argv=(),
|
|
452
|
+
consumed=consumed,
|
|
453
|
+
denial_reason=reason,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _dollar_starts_expansion(
|
|
458
|
+
command: str,
|
|
459
|
+
index: int,
|
|
460
|
+
*,
|
|
461
|
+
allow_quoted_literal: bool = False,
|
|
462
|
+
) -> bool:
|
|
463
|
+
cursor = index + 1
|
|
464
|
+
while command.startswith("\\\n", cursor):
|
|
465
|
+
cursor += 2
|
|
466
|
+
if cursor >= len(command):
|
|
467
|
+
return False
|
|
468
|
+
following = command[cursor]
|
|
469
|
+
if allow_quoted_literal and following in {'"', "'"}:
|
|
470
|
+
return True
|
|
471
|
+
return following in "({$0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz?!#*@-"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def parse_minishell(command: str) -> MiniShellParse:
|
|
475
|
+
"""Parse the fully consumed bounded MiniShell-v1 grammar.
|
|
193
476
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
477
|
+
The parser intentionally keeps quote/escape provenance instead of
|
|
478
|
+
reconstructing it from decoded argv. Only backslash-newline is removed
|
|
479
|
+
without leaving a provenance barrier; every retained quote or escape can
|
|
480
|
+
therefore suppress a local Bash assignment-style tilde site.
|
|
481
|
+
"""
|
|
482
|
+
try:
|
|
483
|
+
command_bytes = len(command.encode("utf-8"))
|
|
484
|
+
except UnicodeEncodeError:
|
|
485
|
+
return _denied_minishell(command, 0, "invalid_utf8")
|
|
486
|
+
if command_bytes > MINISHELL_MAX_COMMAND_BYTES:
|
|
487
|
+
return _denied_minishell(
|
|
488
|
+
command,
|
|
489
|
+
min(len(command), MINISHELL_MAX_COMMAND_BYTES),
|
|
490
|
+
"command_bytes_exceeded",
|
|
199
491
|
)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
492
|
+
if "\0" in command:
|
|
493
|
+
return _denied_minishell(command, command.index("\0"), "nul_denied")
|
|
494
|
+
|
|
495
|
+
raw_segments: list[list[MiniShellWord]] = [[]]
|
|
496
|
+
chars: list[str] = []
|
|
497
|
+
active: list[bool] = []
|
|
498
|
+
barriers: set[int] = set()
|
|
499
|
+
in_word = False
|
|
500
|
+
quote: str | None = None
|
|
501
|
+
fragment_kind: str | None = None
|
|
502
|
+
lexical_items = 0
|
|
503
|
+
heredoc_delimiter: str | None = None
|
|
504
|
+
index = 0
|
|
505
|
+
|
|
506
|
+
def bump_item() -> bool:
|
|
507
|
+
nonlocal lexical_items
|
|
508
|
+
lexical_items += 1
|
|
509
|
+
return lexical_items <= MINISHELL_MAX_LEXICAL_ITEMS
|
|
510
|
+
|
|
511
|
+
def finish_word() -> str | None:
|
|
512
|
+
nonlocal chars, active, barriers, in_word, fragment_kind
|
|
513
|
+
if not in_word:
|
|
208
514
|
return None
|
|
209
|
-
if
|
|
210
|
-
return
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
515
|
+
if len(raw_segments[-1]) >= MINISHELL_MAX_WORDS_PER_SEGMENT:
|
|
516
|
+
return "segment_words_exceeded"
|
|
517
|
+
source_value = "".join(chars)
|
|
518
|
+
active_tuple = tuple(active)
|
|
519
|
+
barrier_set = frozenset(barriers)
|
|
520
|
+
raw_segments[-1].append(MiniShellWord(
|
|
521
|
+
value=source_value,
|
|
522
|
+
source_value=source_value,
|
|
523
|
+
active=active_tuple,
|
|
524
|
+
barriers=barrier_set,
|
|
525
|
+
assignment_index=_exact_assignment_index(
|
|
526
|
+
source_value,
|
|
527
|
+
active_tuple,
|
|
528
|
+
barrier_set,
|
|
529
|
+
),
|
|
530
|
+
active_tilde_sites=(),
|
|
531
|
+
))
|
|
532
|
+
chars = []
|
|
533
|
+
active = []
|
|
534
|
+
barriers = set()
|
|
535
|
+
in_word = False
|
|
536
|
+
fragment_kind = None
|
|
215
537
|
return None
|
|
216
|
-
|
|
538
|
+
|
|
539
|
+
def deny(reason: str, at: int | None = None) -> MiniShellParse:
|
|
540
|
+
return _denied_minishell(command, index if at is None else at, reason)
|
|
541
|
+
|
|
542
|
+
while index < len(command):
|
|
543
|
+
char = command[index]
|
|
544
|
+
if quote is None:
|
|
545
|
+
if char == " ":
|
|
546
|
+
error = finish_word()
|
|
547
|
+
if error is not None:
|
|
548
|
+
return deny(error)
|
|
549
|
+
index += 1
|
|
550
|
+
continue
|
|
551
|
+
if char in "\t\r\n":
|
|
552
|
+
return deny("forbidden_whitespace")
|
|
553
|
+
if char == "\\":
|
|
554
|
+
if index + 1 >= len(command):
|
|
555
|
+
return deny("trailing_escape")
|
|
556
|
+
escaped = command[index + 1]
|
|
557
|
+
if escaped == "\n":
|
|
558
|
+
index += 2
|
|
559
|
+
fragment_kind = None
|
|
560
|
+
continue
|
|
561
|
+
if escaped in "\t\r":
|
|
562
|
+
return deny("forbidden_escaped_character")
|
|
563
|
+
if not bump_item():
|
|
564
|
+
return deny("lexical_items_exceeded")
|
|
565
|
+
in_word = True
|
|
566
|
+
chars.append(escaped)
|
|
567
|
+
active.append(False)
|
|
568
|
+
fragment_kind = None
|
|
569
|
+
index += 2
|
|
570
|
+
continue
|
|
571
|
+
if char in {"'", '"'}:
|
|
572
|
+
if not bump_item():
|
|
573
|
+
return deny("lexical_items_exceeded")
|
|
574
|
+
in_word = True
|
|
575
|
+
barriers.add(len(chars))
|
|
576
|
+
quote = char
|
|
577
|
+
fragment_kind = f"quote:{char}"
|
|
578
|
+
index += 1
|
|
579
|
+
continue
|
|
580
|
+
if char == "#" and not in_word:
|
|
581
|
+
if not raw_segments[-1]:
|
|
582
|
+
return deny("comment_without_command")
|
|
583
|
+
if not bump_item():
|
|
584
|
+
return deny("lexical_items_exceeded")
|
|
585
|
+
newline = command.find("\n", index)
|
|
586
|
+
if newline < 0:
|
|
587
|
+
index = len(command)
|
|
588
|
+
break
|
|
589
|
+
if any(tail != " " for tail in command[newline + 1:]):
|
|
590
|
+
return deny("leftover_after_comment", newline + 1)
|
|
591
|
+
index = len(command)
|
|
592
|
+
break
|
|
593
|
+
if char == "|":
|
|
594
|
+
error = finish_word()
|
|
595
|
+
if error is not None:
|
|
596
|
+
return deny(error)
|
|
597
|
+
if not bump_item():
|
|
598
|
+
return deny("lexical_items_exceeded")
|
|
599
|
+
if (
|
|
600
|
+
not raw_segments[-1]
|
|
601
|
+
or len(raw_segments) >= MINISHELL_MAX_SEGMENTS
|
|
602
|
+
or command.startswith("|&", index)
|
|
603
|
+
):
|
|
604
|
+
return deny("invalid_pipeline")
|
|
605
|
+
raw_segments.append([])
|
|
606
|
+
index += 1
|
|
607
|
+
continue
|
|
608
|
+
if char == "<":
|
|
609
|
+
error = finish_word()
|
|
610
|
+
if error is not None:
|
|
611
|
+
return deny(error)
|
|
612
|
+
if (
|
|
613
|
+
heredoc_delimiter is not None
|
|
614
|
+
or len(raw_segments) != 1
|
|
615
|
+
or not raw_segments[-1]
|
|
616
|
+
or not command.startswith("<<", index)
|
|
617
|
+
or command.startswith(("<<<", "<<-"), index)
|
|
618
|
+
):
|
|
619
|
+
return deny("unsupported_redirect")
|
|
620
|
+
if not bump_item():
|
|
621
|
+
return deny("lexical_items_exceeded")
|
|
622
|
+
delimiter_quote_index = index + 2
|
|
623
|
+
if (
|
|
624
|
+
delimiter_quote_index >= len(command)
|
|
625
|
+
or command[delimiter_quote_index] not in {"'", '"'}
|
|
626
|
+
):
|
|
627
|
+
return deny("unquoted_heredoc_delimiter")
|
|
628
|
+
delimiter_quote = command[delimiter_quote_index]
|
|
629
|
+
delimiter_end = command.find(
|
|
630
|
+
delimiter_quote,
|
|
631
|
+
delimiter_quote_index + 1,
|
|
632
|
+
)
|
|
633
|
+
if delimiter_end < 0:
|
|
634
|
+
return deny("unterminated_heredoc_delimiter")
|
|
635
|
+
delimiter = command[delimiter_quote_index + 1:delimiter_end]
|
|
636
|
+
if (
|
|
637
|
+
not delimiter
|
|
638
|
+
or len(delimiter.encode("ascii", "ignore"))
|
|
639
|
+
!= len(delimiter)
|
|
640
|
+
or len(delimiter) > MINISHELL_MAX_HEREDOC_DELIMITER_BYTES
|
|
641
|
+
or MINISHELL_HEREDOC_DELIMITER_RE.fullmatch(delimiter) is None
|
|
642
|
+
):
|
|
643
|
+
return deny("invalid_heredoc_delimiter")
|
|
644
|
+
if not bump_item():
|
|
645
|
+
return deny("lexical_items_exceeded")
|
|
646
|
+
header_end = delimiter_end + 1
|
|
647
|
+
while header_end < len(command) and command[header_end] == " ":
|
|
648
|
+
header_end += 1
|
|
649
|
+
if header_end >= len(command) or command[header_end] != "\n":
|
|
650
|
+
return deny("heredoc_header_not_terminated", header_end)
|
|
651
|
+
|
|
652
|
+
body_start = header_end + 1
|
|
653
|
+
line_start = body_start
|
|
654
|
+
terminator_end: int | None = None
|
|
655
|
+
while line_start <= len(command):
|
|
656
|
+
line_end = command.find("\n", line_start)
|
|
657
|
+
if line_end < 0:
|
|
658
|
+
if command[line_start:] == delimiter:
|
|
659
|
+
terminator_end = len(command)
|
|
660
|
+
break
|
|
661
|
+
if command[line_start:line_end] == delimiter:
|
|
662
|
+
terminator_end = line_end + 1
|
|
663
|
+
break
|
|
664
|
+
line_start = line_end + 1
|
|
665
|
+
if terminator_end is None:
|
|
666
|
+
return deny("unterminated_heredoc", body_start)
|
|
667
|
+
if terminator_end != len(command):
|
|
668
|
+
return deny("leftover_after_heredoc", terminator_end)
|
|
669
|
+
if not bump_item():
|
|
670
|
+
return deny("lexical_items_exceeded")
|
|
671
|
+
heredoc_delimiter = delimiter
|
|
672
|
+
index = len(command)
|
|
673
|
+
break
|
|
674
|
+
if char in MINISHELL_DENIED_ACTIVE_CHARS:
|
|
675
|
+
return deny(f"active_{ord(char):02x}")
|
|
676
|
+
if char == "$" and _dollar_starts_expansion(
|
|
677
|
+
command,
|
|
678
|
+
index,
|
|
679
|
+
allow_quoted_literal=True,
|
|
680
|
+
):
|
|
681
|
+
return deny("active_24")
|
|
682
|
+
if fragment_kind != "unquoted":
|
|
683
|
+
if not bump_item():
|
|
684
|
+
return deny("lexical_items_exceeded")
|
|
685
|
+
fragment_kind = "unquoted"
|
|
686
|
+
in_word = True
|
|
687
|
+
chars.append(char)
|
|
688
|
+
active.append(True)
|
|
689
|
+
index += 1
|
|
690
|
+
continue
|
|
691
|
+
|
|
692
|
+
if char in "\t\r\n":
|
|
693
|
+
if quote == '"' and char == "\n" and index > 0 and command[index - 1] == "\\":
|
|
694
|
+
index += 1
|
|
695
|
+
continue
|
|
696
|
+
return deny("forbidden_quoted_whitespace")
|
|
697
|
+
if char == quote:
|
|
698
|
+
barriers.add(len(chars))
|
|
699
|
+
quote = None
|
|
700
|
+
fragment_kind = None
|
|
701
|
+
index += 1
|
|
702
|
+
continue
|
|
703
|
+
if quote == "'":
|
|
704
|
+
chars.append(char)
|
|
705
|
+
active.append(False)
|
|
706
|
+
index += 1
|
|
707
|
+
continue
|
|
708
|
+
if char == "`" or (char == "$" and _dollar_starts_expansion(command, index)):
|
|
709
|
+
return deny("active_double_quote_expansion")
|
|
710
|
+
if char == "\\":
|
|
711
|
+
if index + 1 >= len(command):
|
|
712
|
+
return deny("trailing_double_quote_escape")
|
|
713
|
+
escaped = command[index + 1]
|
|
714
|
+
if escaped == "\n":
|
|
715
|
+
index += 2
|
|
716
|
+
continue
|
|
717
|
+
if escaped in "\t\r":
|
|
718
|
+
return deny("forbidden_escaped_character")
|
|
719
|
+
if escaped in {'$', '`', '"', "\\"}:
|
|
720
|
+
chars.append(escaped)
|
|
721
|
+
active.append(False)
|
|
722
|
+
else:
|
|
723
|
+
chars.extend(("\\", escaped))
|
|
724
|
+
active.extend((False, False))
|
|
725
|
+
index += 2
|
|
726
|
+
continue
|
|
727
|
+
chars.append(char)
|
|
728
|
+
active.append(False)
|
|
729
|
+
index += 1
|
|
730
|
+
|
|
731
|
+
if quote is not None:
|
|
732
|
+
return _denied_minishell(command, len(command), "unterminated_quote")
|
|
733
|
+
error = finish_word()
|
|
734
|
+
if error is not None:
|
|
735
|
+
return _denied_minishell(command, len(command), error)
|
|
736
|
+
if not raw_segments[-1]:
|
|
737
|
+
return _denied_minishell(command, len(command), "empty_command")
|
|
738
|
+
segments = tuple(
|
|
739
|
+
tuple(_annotate_word_tildes(word) for word in segment)
|
|
740
|
+
for segment in raw_segments
|
|
741
|
+
)
|
|
742
|
+
words = tuple(word for segment in segments for word in segment)
|
|
743
|
+
return MiniShellParse(
|
|
744
|
+
words=words,
|
|
745
|
+
segments=segments,
|
|
746
|
+
argv=tuple(word.value for word in words),
|
|
747
|
+
consumed=len(command),
|
|
748
|
+
lexical_items=lexical_items,
|
|
749
|
+
heredoc_delimiter=heredoc_delimiter,
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def split_single_safe_command(command: str) -> list[str] | None:
|
|
754
|
+
parsed = parse_minishell(command)
|
|
755
|
+
if parsed.denial_reason is not None:
|
|
217
756
|
return None
|
|
218
|
-
return
|
|
757
|
+
return list(parsed.argv)
|
|
219
758
|
|
|
220
759
|
|
|
221
760
|
def command_basename(command: str) -> str:
|
|
222
|
-
|
|
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
|
|
223
778
|
|
|
224
779
|
|
|
225
780
|
def strip_env_prefix(argv: list[str]) -> list[str]:
|
|
@@ -268,70 +823,6 @@ def npm_script_args(rest: list[str]) -> list[str]:
|
|
|
268
823
|
return rest[i:]
|
|
269
824
|
|
|
270
825
|
|
|
271
|
-
def _filter_args_are_stdin_only(first: str, args: list[str]) -> bool:
|
|
272
|
-
"""Accept small, option-only filter argv forms that do not name files."""
|
|
273
|
-
if first == "cat":
|
|
274
|
-
return not args
|
|
275
|
-
long_no_value_options = {
|
|
276
|
-
"head": set(),
|
|
277
|
-
"tail": set(),
|
|
278
|
-
"wc": {"--bytes", "--chars", "--lines", "--words"},
|
|
279
|
-
"sort": {"--ignore-leading-blanks", "--dictionary-order", "--ignore-case", "--general-numeric-sort", "--human-numeric-sort", "--numeric-sort", "--reverse", "--unique"},
|
|
280
|
-
"uniq": {"--count", "--repeated", "--unique", "--ignore-case"},
|
|
281
|
-
}.get(first, set())
|
|
282
|
-
short_no_value_chars = {
|
|
283
|
-
"head": set(),
|
|
284
|
-
"tail": {"f", "F", "r"},
|
|
285
|
-
"wc": {"c", "m", "l", "w"},
|
|
286
|
-
"sort": {"b", "d", "f", "g", "h", "n", "r", "u"},
|
|
287
|
-
"uniq": {"c", "d", "u", "i"},
|
|
288
|
-
}.get(first, set())
|
|
289
|
-
value_options = {"-n", "--lines", "-c", "--bytes"} if first in {"head", "tail"} else set()
|
|
290
|
-
i = 0
|
|
291
|
-
while i < len(args):
|
|
292
|
-
arg = args[i]
|
|
293
|
-
if arg == "--":
|
|
294
|
-
return i == len(args) - 1
|
|
295
|
-
if arg.startswith("--") and "=" in arg:
|
|
296
|
-
name, value = arg.split("=", 1)
|
|
297
|
-
if name not in value_options:
|
|
298
|
-
return False
|
|
299
|
-
if not re.fullmatch(r"[+-]?\d+[KkMmGg]?", value):
|
|
300
|
-
return False
|
|
301
|
-
i += 1
|
|
302
|
-
continue
|
|
303
|
-
if arg in value_options:
|
|
304
|
-
if i + 1 >= len(args):
|
|
305
|
-
return False
|
|
306
|
-
if not re.fullmatch(r"[+-]?\d+[KkMmGg]?", args[i + 1]):
|
|
307
|
-
return False
|
|
308
|
-
i += 2
|
|
309
|
-
continue
|
|
310
|
-
if arg in long_no_value_options:
|
|
311
|
-
i += 1
|
|
312
|
-
continue
|
|
313
|
-
if arg.startswith("--"):
|
|
314
|
-
return False
|
|
315
|
-
if arg.startswith("-") and arg != "-":
|
|
316
|
-
if not set(arg[1:]).issubset(short_no_value_chars):
|
|
317
|
-
return False
|
|
318
|
-
i += 1
|
|
319
|
-
continue
|
|
320
|
-
if arg.startswith("-"):
|
|
321
|
-
return False
|
|
322
|
-
return False
|
|
323
|
-
return True
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def is_safe_pipe_filter(argv: list[str]) -> bool:
|
|
327
|
-
if not argv:
|
|
328
|
-
return False
|
|
329
|
-
first = command_basename(argv[0])
|
|
330
|
-
if first not in SAFE_PIPE_FILTER_BASENAMES:
|
|
331
|
-
return False
|
|
332
|
-
return _filter_args_are_stdin_only(first, argv[1:])
|
|
333
|
-
|
|
334
|
-
|
|
335
826
|
def is_noisy_command(argv: list[str]) -> bool:
|
|
336
827
|
argv = strip_env_prefix(argv)
|
|
337
828
|
if not argv:
|
|
@@ -360,9 +851,9 @@ def is_noisy_command(argv: list[str]) -> bool:
|
|
|
360
851
|
return True
|
|
361
852
|
if first == "cargo" and "test" in rest:
|
|
362
853
|
return True
|
|
363
|
-
if first in {"mvn", "mvnw"
|
|
854
|
+
if first in {"mvn", "mvnw"} and "test" in rest:
|
|
364
855
|
return True
|
|
365
|
-
if first in {"gradle", "gradlew"
|
|
856
|
+
if first in {"gradle", "gradlew"} and "test" in rest:
|
|
366
857
|
return True
|
|
367
858
|
if first == "make" and any(arg in {"test", "build", "lint"} for arg in rest):
|
|
368
859
|
return True
|
|
@@ -458,21 +949,279 @@ def is_log_streaming_command(argv: list[str]) -> bool:
|
|
|
458
949
|
return False
|
|
459
950
|
|
|
460
951
|
|
|
461
|
-
def
|
|
462
|
-
"""
|
|
952
|
+
def _env_prefix_name(word: MiniShellWord) -> str | None:
|
|
953
|
+
"""할당 word 의 소스 텍스트에서 `=` 앞 변수 이름만 뽑아낸다.
|
|
463
954
|
|
|
464
|
-
|
|
465
|
-
(
|
|
466
|
-
|
|
467
|
-
`
|
|
955
|
+
`word.assignment_index` 는 `_exact_assignment_index` 가 `source_value` 기준으로
|
|
956
|
+
확정한 활성(비인용) `=` 의 위치다. 그 교차 필드 불변식이 깨진 word 는 이름을
|
|
957
|
+
신뢰할 수 없으므로 `None` 을 돌려 호출자가 fail-closed 로 처리하게 한다.
|
|
958
|
+
`source_value[:None]` 이 토큰 전체를 조용히 돌려주는 파이썬 슬라이스 특성 때문에
|
|
959
|
+
불변식 위반이 무증상으로 통과하지 않도록 명시적으로 막는다.
|
|
468
960
|
"""
|
|
469
|
-
|
|
961
|
+
index = word.assignment_index
|
|
962
|
+
if index is None or not 0 <= index < len(word.source_value):
|
|
963
|
+
return None
|
|
964
|
+
if word.source_value[index] != "=":
|
|
965
|
+
return None
|
|
966
|
+
return word.source_value[:index]
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _is_unmodeled_assignment_prefix(word: MiniShellWord) -> bool:
|
|
970
|
+
"""bash 는 환경 접두사로 적용하지만 MiniShell 이 할당으로 표시하지 않는 형태인가.
|
|
971
|
+
|
|
972
|
+
`NAME+=VALUE` 는 bash 가 접두사 할당으로 실제 적용하지만(실측 확인),
|
|
973
|
+
`_exact_assignment_index` 는 `=` 앞이 `NAME+` 라서 이름 문법을 만족하지 못해
|
|
974
|
+
`assignment_index` 를 남기지 않는다. 그 결과 이 word 는 할당이 아니라 명령어로
|
|
975
|
+
취급되어 FIX-5 이름 검사를 통째로 건너뛴다. 모델링하지 못하는 할당 형태는
|
|
976
|
+
안전을 증명할 수 없으므로 fail-closed 로 거부한다.
|
|
977
|
+
|
|
978
|
+
인용된 형태(`"FOO"+=x`)는 bash 가 할당으로 보지 않으므로 대상이 아니다 —
|
|
979
|
+
`_exact_assignment_index` 와 동일한 활성/배리어 규칙을 적용한다.
|
|
980
|
+
"""
|
|
981
|
+
if word.assignment_index is not None:
|
|
982
|
+
return False
|
|
983
|
+
match = MINISHELL_APPEND_ASSIGNMENT_RE.match(word.source_value)
|
|
984
|
+
if match is None:
|
|
985
|
+
return False
|
|
986
|
+
equals_index = match.end() - 1
|
|
987
|
+
if not all(word.active[: equals_index + 1]):
|
|
988
|
+
return False
|
|
989
|
+
return not any(boundary <= equals_index for boundary in word.barriers)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _env_operand_name(word: MiniShellWord) -> str | None:
|
|
993
|
+
"""`env` 피연산자에서 환경변수 이름을 뽑는다 — 셸 인용을 무시한다.
|
|
994
|
+
|
|
995
|
+
coreutils `env` 는 셸 할당 문법을 검사하지 않는다. 인용 제거가 끝난 argv 원소가
|
|
996
|
+
`=` 를 포함하기만 하면 그대로 putenv() 한다. 따라서 셸이 할당으로 보지 않는
|
|
997
|
+
`env 'GIT_EXTERNAL_DIFF'=/tmp/evil.sh git diff` 나 `env NAME\\=v cmd` 도 실제로는
|
|
998
|
+
환경에 적용된다(실측 확인). `assignment_index` 는 인용된 문자를 비활성으로 보고
|
|
999
|
+
할당 표시를 남기지 않으므로, `env` 피연산자 구간에서는 인용이 제거된
|
|
1000
|
+
`word.value` 를 기준으로 이름을 다시 판정해야 한다.
|
|
1001
|
+
|
|
1002
|
+
`=` 가 없으면 그 word 가 곧 실행할 명령어이므로 `None` 을 돌려 소비를 멈춘다.
|
|
1003
|
+
"""
|
|
1004
|
+
equals_index = word.value.find("=")
|
|
1005
|
+
if equals_index <= 0:
|
|
1006
|
+
return None
|
|
1007
|
+
return word.value[:equals_index]
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _has_unsafe_env_prefix_name(
|
|
1011
|
+
words: tuple[MiniShellWord, ...],
|
|
1012
|
+
start: int,
|
|
1013
|
+
end: int,
|
|
1014
|
+
) -> bool:
|
|
1015
|
+
"""[start, end) 구간의 환경변수 할당 이름이 시드 화이트리스트 밖이면 True.
|
|
1016
|
+
|
|
1017
|
+
정확 이름 일치만 검사한다(접두사/글롭 금지) — `TERM*` 글롭이 `TERMINFO` 를
|
|
1018
|
+
재승인시키는 실패 형태를 피하기 위함(AC-5.6). 이름을 추출할 수 없는 word 는
|
|
1019
|
+
안전을 증명할 수 없으므로 unsafe 로 간주한다(fail-closed).
|
|
1020
|
+
"""
|
|
1021
|
+
for index in range(start, end):
|
|
1022
|
+
name = _env_prefix_name(words[index])
|
|
1023
|
+
if name is None or name not in MINISHELL_ALLOWED_ENV_PREFIX_NAMES:
|
|
1024
|
+
return True
|
|
1025
|
+
return False
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _routing_start(
|
|
1029
|
+
words: tuple[MiniShellWord, ...],
|
|
1030
|
+
argv: tuple[str, ...],
|
|
1031
|
+
) -> int:
|
|
1032
|
+
"""라우팅이 시작되는 word 인덱스를 계산한다.
|
|
1033
|
+
|
|
1034
|
+
반환값 의미: `>= 0` 은 라우팅 시작 인덱스, `-1` 은 기존 `restricted_env_denied`
|
|
1035
|
+
(`env` 뒤에 알 수 없는 플래그가 오거나, `env` 뒤에 명령어 word 자체가 없는 경우),
|
|
1036
|
+
`-2` 는 신규 `unsafe_env_name_denied`(FIX-5 — 접두사 변수 이름이 화이트리스트 밖
|
|
1037
|
+
이거나, 모델링하지 못하는 접두사 할당 형태). 두 원인은 §5.4/§5.6 측정이
|
|
1038
|
+
`reason_code` 로 필터링하므로 호출자가 구분해서 처리해야 한다(classify_command 참고).
|
|
1039
|
+
|
|
1040
|
+
음수 센티넬을 인덱스로 다시 쓰면 파이썬 음수 인덱싱 때문에 조용히 잘못된 word 를
|
|
1041
|
+
가리키므로, 모든 호출부는 인덱싱 전에 `< 0` 을 먼저 검사해야 한다.
|
|
1042
|
+
"""
|
|
1043
|
+
index = 0
|
|
1044
|
+
saw_env = False
|
|
1045
|
+
# 각 반복은 `env` 또는 `--` 를 최소 한 개 소비하므로 word 수만큼이면 충분하다.
|
|
1046
|
+
# PreToolUse 훅 안에서 도는 코드라 구조적 종료 보장을 명시한다(무한 루프 = 행).
|
|
1047
|
+
for _ in range(len(words) + 1):
|
|
1048
|
+
assignment_start = index
|
|
1049
|
+
while index < len(words) and words[index].assignment_index is not None:
|
|
1050
|
+
index += 1
|
|
1051
|
+
# 이름 검사는 어떤 조기 반환보다도 먼저 수행한다. 명령어 없는 할당 전용
|
|
1052
|
+
# 세그먼트(`PATH=/tmp/evil`)도 `assignment_only_denied` 라는 다른 백스톱에
|
|
1053
|
+
# 의존하지 않고 자신의 원인 코드로 거부되어야 §5.4/§5.6 측정이 눈을 뜬다.
|
|
1054
|
+
if _has_unsafe_env_prefix_name(words, assignment_start, index):
|
|
1055
|
+
return -2
|
|
1056
|
+
# 모델링하지 못하는 접두사 할당(`NAME+=VALUE`)이 라우팅 헤드 자리에 오면
|
|
1057
|
+
# 이름 검사를 건너뛴 채 명령어로 취급되므로 여기서 fail-closed 로 막는다.
|
|
1058
|
+
if index < len(words) and _is_unmodeled_assignment_prefix(words[index]):
|
|
1059
|
+
return -2
|
|
1060
|
+
if saw_env:
|
|
1061
|
+
# coreutils `env` 문법은 `env [옵션]... [--] [NAME=VALUE]... [명령]` 이며
|
|
1062
|
+
# `--` 는 할당 목록의 앞뒤 어느 쪽에도 올 수 있다. `--` 를 소비한 뒤에도
|
|
1063
|
+
# 할당이 이어질 수 있으므로 루프 선두로 돌아가 이름 검사를 다시 수행한다.
|
|
1064
|
+
if index < len(words) and argv[index] == "--":
|
|
1065
|
+
index += 1
|
|
1066
|
+
continue
|
|
1067
|
+
# `env` 피연산자는 셸 할당 문법이 아니라 "`=` 를 포함한 argv 원소" 규칙을
|
|
1068
|
+
# 따른다. 인용으로 셸 할당 표시를 피한 형태도 env 가 그대로 적용하므로
|
|
1069
|
+
# 인용 제거된 value 기준으로 한 번 더 검사한다(§_env_operand_name).
|
|
1070
|
+
if index < len(words):
|
|
1071
|
+
operand_name = _env_operand_name(words[index])
|
|
1072
|
+
if operand_name is not None:
|
|
1073
|
+
if operand_name not in MINISHELL_ALLOWED_ENV_PREFIX_NAMES:
|
|
1074
|
+
return -2
|
|
1075
|
+
index += 1
|
|
1076
|
+
continue
|
|
1077
|
+
# 이름 문제가 아닌 미지의 `env` 플래그는 기존 원인을 유지한다.
|
|
1078
|
+
if index >= len(words) or argv[index].startswith("-"):
|
|
1079
|
+
return -1
|
|
1080
|
+
if index >= len(words):
|
|
1081
|
+
return index
|
|
1082
|
+
if command_basename(argv[index]) != "env":
|
|
1083
|
+
return index
|
|
1084
|
+
|
|
1085
|
+
# `env env NAME=VALUE cmd` 같은 중첩 호출도 각 단계마다 할당 구간을 검사한다.
|
|
1086
|
+
index += 1
|
|
1087
|
+
saw_env = True
|
|
1088
|
+
|
|
1089
|
+
# 도달 불가(매 반복이 word 를 최소 하나 소비한다). 방어적으로 fail-closed.
|
|
1090
|
+
return -1
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _routing_start_index(parsed: MiniShellParse) -> int:
|
|
1094
|
+
return _routing_start(parsed.words, parsed.argv)
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _routing_argv(parsed: MiniShellParse) -> tuple[str, ...]:
|
|
1098
|
+
"""라우팅 대상 argv. 거부 센티넬(`-1`/`-2`)은 빈 튜플로 fail-closed 처리한다.
|
|
1099
|
+
|
|
1100
|
+
센티넬을 그대로 슬라이스하면 파이썬 음수 인덱싱 때문에 `argv[-2:]` 가 마지막 두
|
|
1101
|
+
토큰을 조용히 돌려주어, 불변식 위반이 예외가 아니라 "잘못된 word 에 대한 라우팅
|
|
1102
|
+
결정"으로 둔갑한다.
|
|
1103
|
+
"""
|
|
1104
|
+
route_start = _routing_start_index(parsed)
|
|
1105
|
+
if route_start < 0:
|
|
1106
|
+
return ()
|
|
1107
|
+
return parsed.argv[route_start:]
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _wrapper_invocation(argv: tuple[str, ...]) -> tuple[str, int] | None:
|
|
470
1111
|
if not argv:
|
|
1112
|
+
return None
|
|
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])
|
|
1117
|
+
if head_basename in WRAPPER_BASENAMES:
|
|
1118
|
+
return head_basename, 0
|
|
1119
|
+
if (
|
|
1120
|
+
re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", head_basename)
|
|
1121
|
+
and len(argv) > 1
|
|
1122
|
+
and os.path.basename(argv[1]) in WRAPPER_BASENAMES
|
|
1123
|
+
):
|
|
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
|
|
1132
|
+
return None
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _wrapper_kind(basename: str) -> str:
|
|
1136
|
+
return "sanitize" if "sanitize" in basename else "trim"
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _expected_cgw1_prefix(kind: str) -> tuple[str, ...]:
|
|
1140
|
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
1141
|
+
if os.path.basename(__file__) == "rewrite_bash_for_token_budget.py":
|
|
1142
|
+
helper = "sanitize_output.py" if kind == "sanitize" else "trim_command_output.py"
|
|
1143
|
+
return ("python3", os.path.join(script_dir, helper))
|
|
1144
|
+
helper = (
|
|
1145
|
+
"context-guard-sanitize-output"
|
|
1146
|
+
if kind == "sanitize"
|
|
1147
|
+
else "context-guard-trim-output"
|
|
1148
|
+
)
|
|
1149
|
+
return (os.path.join(script_dir, helper),)
|
|
1150
|
+
|
|
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:
|
|
471
1161
|
return False
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
+
|
|
1167
|
+
def classify_incoming_wrapper(
|
|
1168
|
+
parsed: MiniShellParse,
|
|
1169
|
+
) -> tuple[str, str | None, str | None] | None:
|
|
1170
|
+
"""Classify raw wrapper input without probing the filesystem.
|
|
1171
|
+
|
|
1172
|
+
Direct wrapper CLI use is not an execution envelope. A known wrapper
|
|
1173
|
+
combined with the reserved CGW1 sentinel or an exact v0 shell envelope is
|
|
1174
|
+
always incoming execution syntax and therefore denied at PreToolUse.
|
|
1175
|
+
"""
|
|
1176
|
+
if len(parsed.segments) != 1:
|
|
1177
|
+
return None
|
|
1178
|
+
route_start = _routing_start_index(parsed)
|
|
1179
|
+
if route_start < 0:
|
|
1180
|
+
return None
|
|
1181
|
+
route_argv = parsed.argv[route_start:]
|
|
1182
|
+
invocation = _wrapper_invocation(route_argv)
|
|
1183
|
+
if invocation is None:
|
|
1184
|
+
return None
|
|
1185
|
+
basename, wrapper_index = invocation
|
|
1186
|
+
kind = _wrapper_kind(basename)
|
|
1187
|
+
envelope_argv = route_argv[wrapper_index + 1:]
|
|
1188
|
+
sentinel_tokens = [
|
|
1189
|
+
token for token in envelope_argv if CGW1_SENTINEL in token
|
|
1190
|
+
]
|
|
1191
|
+
if sentinel_tokens:
|
|
1192
|
+
code = (
|
|
1193
|
+
"nested_wrapper_denied"
|
|
1194
|
+
if len(sentinel_tokens) > 1
|
|
1195
|
+
or any(token != CGW1_SENTINEL for token in sentinel_tokens)
|
|
1196
|
+
else "incoming_wrapper_denied"
|
|
1197
|
+
)
|
|
1198
|
+
return (code, kind, None)
|
|
1199
|
+
|
|
1200
|
+
legacy_prefixes = (
|
|
1201
|
+
("--max-lines", CGW1_MAX_LINES),
|
|
1202
|
+
(CGW1_COMMAND_SEARCH_DIFF,),
|
|
1203
|
+
("--mode", CGW1_COMMAND_SEARCH_DIFF),
|
|
1204
|
+
)
|
|
1205
|
+
shell_argvs = (CGW1_SHELL_ARGV, _runtime_shell_argv())
|
|
1206
|
+
for prefix in legacy_prefixes:
|
|
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])
|
|
1214
|
+
return None
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def is_already_wrapped(argv: list[str]) -> bool:
|
|
1218
|
+
"""Compatibility helper: only exact CGW1 argv counts as already wrapped."""
|
|
1219
|
+
command = shell_join(argv)
|
|
1220
|
+
parsed = parse_minishell(command)
|
|
1221
|
+
if parsed.denial_reason is not None:
|
|
1222
|
+
return False
|
|
1223
|
+
wrapper = classify_incoming_wrapper(parsed)
|
|
1224
|
+
return wrapper is not None and wrapper[0] == "exact"
|
|
476
1225
|
|
|
477
1226
|
|
|
478
1227
|
def is_sanitizable_output_command(argv: list[str]) -> bool:
|
|
@@ -523,91 +1272,1757 @@ def git_subcommand_args(rest: list[str]) -> list[str]:
|
|
|
523
1272
|
return rest[i:]
|
|
524
1273
|
|
|
525
1274
|
|
|
526
|
-
def
|
|
527
|
-
|
|
528
|
-
|
|
1275
|
+
def _valid_n(value: str) -> bool:
|
|
1276
|
+
return value.isascii() and value.isdigit() and 1 <= int(value) <= 1_000_000
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _valid_range(value: str) -> bool:
|
|
1280
|
+
if (
|
|
1281
|
+
not value
|
|
1282
|
+
or len(value.encode("utf-8")) > 64
|
|
1283
|
+
or not value.isascii()
|
|
1284
|
+
):
|
|
1285
|
+
return False
|
|
1286
|
+
return all(
|
|
1287
|
+
(
|
|
1288
|
+
_valid_n(item)
|
|
1289
|
+
if "-" not in item
|
|
1290
|
+
else (
|
|
1291
|
+
item.count("-") == 1
|
|
1292
|
+
and _valid_n(item.split("-", 1)[0])
|
|
1293
|
+
and (
|
|
1294
|
+
not item.split("-", 1)[1]
|
|
1295
|
+
or _valid_n(item.split("-", 1)[1])
|
|
1296
|
+
)
|
|
1297
|
+
)
|
|
1298
|
+
)
|
|
1299
|
+
for item in value.split(",")
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _valid_key(value: str) -> bool:
|
|
1304
|
+
parts = value.split(",")
|
|
1305
|
+
return (
|
|
1306
|
+
1 <= len(parts) <= 2
|
|
1307
|
+
and all(
|
|
1308
|
+
part.isascii()
|
|
1309
|
+
and part.isdigit()
|
|
1310
|
+
and 1 <= len(part) <= 6
|
|
1311
|
+
and int(part) >= 1
|
|
1312
|
+
for part in parts
|
|
1313
|
+
)
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _printf_is_safe(argv: tuple[str, ...]) -> bool:
|
|
1318
|
+
if len(argv) < 2:
|
|
1319
|
+
return False
|
|
1320
|
+
index = 1
|
|
1321
|
+
if argv[index] == "--":
|
|
1322
|
+
index += 1
|
|
1323
|
+
elif argv[index].startswith("-"):
|
|
1324
|
+
return False
|
|
1325
|
+
return index < len(argv)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
_LS_SHORT_FLAGS = set("laAhtrS1dFpRincu")
|
|
1329
|
+
_LS_LONG_FLAGS = {
|
|
1330
|
+
"--all",
|
|
1331
|
+
"--almost-all",
|
|
1332
|
+
"--human-readable",
|
|
1333
|
+
"--reverse",
|
|
1334
|
+
"--recursive",
|
|
1335
|
+
"--directory",
|
|
1336
|
+
"--classify",
|
|
1337
|
+
"--group-directories-first",
|
|
1338
|
+
"--color=never",
|
|
1339
|
+
"--color=auto",
|
|
1340
|
+
"--no-group",
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _ls_is_safe(argv: tuple[str, ...]) -> bool:
|
|
1345
|
+
"""`ls`가 producer 라우트로 허용되기에 안전한지 판단하는 순수 허용목록.
|
|
1346
|
+
|
|
1347
|
+
값(value)을 소비하는 `ls` 플래그가 존재하지 않고, 부수효과를 갖는 플래그도
|
|
1348
|
+
없다는 성질 덕분에 짧은 플래그 클러스터(`-ltrh`)까지 안전하게 분해할 수
|
|
1349
|
+
있다. 이 성질은 `sed`/`git`에는 성립하지 않으므로 이 패턴을 일반화하지
|
|
1350
|
+
말 것 (설계 문서 4.1 참고).
|
|
1351
|
+
"""
|
|
1352
|
+
options_done = False
|
|
1353
|
+
for argument in argv[1:]:
|
|
1354
|
+
if not options_done and argument == "--":
|
|
1355
|
+
options_done = True
|
|
1356
|
+
continue
|
|
1357
|
+
if options_done:
|
|
1358
|
+
continue
|
|
1359
|
+
if argument.startswith("--"):
|
|
1360
|
+
if argument not in _LS_LONG_FLAGS:
|
|
1361
|
+
return False
|
|
1362
|
+
continue
|
|
1363
|
+
if argument.startswith("-") and argument != "-":
|
|
1364
|
+
if not set(argument[1:]).issubset(_LS_SHORT_FLAGS):
|
|
1365
|
+
return False
|
|
1366
|
+
continue
|
|
1367
|
+
return True
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _cat_is_safe(argv: tuple[str, ...], *, allow_files: bool) -> bool:
|
|
1371
|
+
operands = 0
|
|
1372
|
+
options_done = False
|
|
1373
|
+
for argument in argv[1:]:
|
|
1374
|
+
if not options_done and argument == "--":
|
|
1375
|
+
options_done = True
|
|
1376
|
+
continue
|
|
1377
|
+
if (
|
|
1378
|
+
not options_done
|
|
1379
|
+
and argument.startswith("-")
|
|
1380
|
+
and argument != "-"
|
|
1381
|
+
):
|
|
1382
|
+
if (
|
|
1383
|
+
argument.startswith("--")
|
|
1384
|
+
or not argument[1:]
|
|
1385
|
+
or not set(argument[1:]).issubset(set("bnsETAvet"))
|
|
1386
|
+
):
|
|
1387
|
+
return False
|
|
1388
|
+
continue
|
|
1389
|
+
operands += 1
|
|
1390
|
+
return allow_files or operands == 0
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _cut_is_safe(argv: tuple[str, ...]) -> bool:
|
|
1394
|
+
selector: str | None = None
|
|
1395
|
+
delimiter = False
|
|
1396
|
+
index = 1
|
|
1397
|
+
while index < len(argv):
|
|
1398
|
+
argument = argv[index]
|
|
1399
|
+
if argument == "--":
|
|
1400
|
+
return selector is not None and index == len(argv) - 1
|
|
1401
|
+
if argument in {"-s", "--complement"}:
|
|
1402
|
+
index += 1
|
|
1403
|
+
continue
|
|
1404
|
+
if argument in {"-f", "-c", "-b", "-d"}:
|
|
1405
|
+
if index + 1 >= len(argv):
|
|
1406
|
+
return False
|
|
1407
|
+
value = argv[index + 1]
|
|
1408
|
+
if argument == "-d":
|
|
1409
|
+
if delimiter or len(value.encode("utf-8")) != 1:
|
|
1410
|
+
return False
|
|
1411
|
+
delimiter = True
|
|
1412
|
+
else:
|
|
1413
|
+
if selector is not None or not _valid_range(value):
|
|
1414
|
+
return False
|
|
1415
|
+
selector = argument
|
|
1416
|
+
index += 2
|
|
1417
|
+
continue
|
|
1418
|
+
if (
|
|
1419
|
+
len(argument) > 2
|
|
1420
|
+
and argument[:2] in {"-f", "-c", "-b", "-d"}
|
|
1421
|
+
):
|
|
1422
|
+
option, value = argument[:2], argument[2:]
|
|
1423
|
+
if option == "-d":
|
|
1424
|
+
if delimiter or len(value.encode("utf-8")) != 1:
|
|
1425
|
+
return False
|
|
1426
|
+
delimiter = True
|
|
1427
|
+
else:
|
|
1428
|
+
if selector is not None or not _valid_range(value):
|
|
1429
|
+
return False
|
|
1430
|
+
selector = option
|
|
1431
|
+
index += 1
|
|
1432
|
+
continue
|
|
1433
|
+
return False
|
|
1434
|
+
return selector is not None and (not delimiter or selector == "-f")
|
|
1435
|
+
|
|
1436
|
+
|
|
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
|
+
)
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def _sed_route_shape(argv: tuple[str, ...]) -> tuple[bool, int]:
|
|
1446
|
+
"""`sed`의 인자를 전체 스캔해 (안전 여부, 파일 피연산자 수)를 반환한다.
|
|
1447
|
+
|
|
1448
|
+
design route-readmission-design-20260729.md §2.3 의 정확한 재현이다. 세
|
|
1449
|
+
가지 문법 함정을 여기서 막는다:
|
|
1450
|
+
1) 스크립트 위치는 조건부다 — `-e`/`--expression=` 이 하나라도 있으면
|
|
1451
|
+
모든 피연산자가 파일이고, 없으면 첫 피연산자가 스크립트다.
|
|
1452
|
+
2) GNU sed 는 옵션을 순열(permute)한다 — `sed -n '1,5p' -i f` 처럼
|
|
1453
|
+
피연산자 뒤에도 옵션이 온다. 그래서 접두부만 훑는 스캔이 아니라
|
|
1454
|
+
`--` 전까지 argv 전체를 훑어야 `-i` 를 놓치지 않는다.
|
|
1455
|
+
3) 짧은 옵션 클러스터는 `-i` 를 밀반입할 수 있다(`-ni` == `-n -i`).
|
|
1456
|
+
`set(arg[1:]).issubset(allowed)` 패턴은 여기서 틀리다 — 클러스터는
|
|
1457
|
+
전부 거부하고 정확한 토큰만 허용한다.
|
|
1458
|
+
|
|
1459
|
+
스크립트 본문 자체의 안전 경계(`w`/`W`/`s///w`/`e`/`s///e`/`r`/`R` 배제)는
|
|
1460
|
+
`_SED_SCRIPT_RE` 의 `re.fullmatch` 가 담당한다. S009는 기존의 안전한
|
|
1461
|
+
p-only SEG를 `;`로 최대 `_SED_MAX_SEGMENTS` 개 조합할 뿐 SEG 자체를
|
|
1462
|
+
넓히지 않는다 — 이 함수는 그 정규식이 실제로 검사하는 대상이 진짜
|
|
1463
|
+
스크립트임을 보장하는 역할만 한다.
|
|
1464
|
+
"""
|
|
1465
|
+
quiet_seen = False
|
|
1466
|
+
expressions: list[str] = []
|
|
1467
|
+
operands: list[str] = []
|
|
1468
|
+
options_done = False
|
|
1469
|
+
index = 1
|
|
1470
|
+
while index < len(argv):
|
|
1471
|
+
token = argv[index]
|
|
1472
|
+
if options_done:
|
|
1473
|
+
operands.append(token)
|
|
1474
|
+
index += 1
|
|
1475
|
+
continue
|
|
1476
|
+
if token == "--":
|
|
1477
|
+
options_done = True
|
|
1478
|
+
index += 1
|
|
1479
|
+
continue
|
|
1480
|
+
if token in {"-n", "--quiet", "--silent"}:
|
|
1481
|
+
if quiet_seen:
|
|
1482
|
+
return False, 0
|
|
1483
|
+
quiet_seen = True
|
|
1484
|
+
index += 1
|
|
1485
|
+
continue
|
|
1486
|
+
if token == "-e":
|
|
1487
|
+
if index + 1 >= len(argv):
|
|
1488
|
+
return False, 0
|
|
1489
|
+
expressions.append(argv[index + 1])
|
|
1490
|
+
index += 2
|
|
1491
|
+
continue
|
|
1492
|
+
if token.startswith("--expression="):
|
|
1493
|
+
expressions.append(token.split("=", 1)[1])
|
|
1494
|
+
index += 1
|
|
1495
|
+
continue
|
|
1496
|
+
if token.startswith("-") and token != "-":
|
|
1497
|
+
return False, 0
|
|
1498
|
+
operands.append(token)
|
|
1499
|
+
index += 1
|
|
1500
|
+
|
|
1501
|
+
if not quiet_seen:
|
|
1502
|
+
return False, 0
|
|
1503
|
+
if len(expressions) > 1:
|
|
1504
|
+
return False, 0
|
|
1505
|
+
if expressions:
|
|
1506
|
+
script, files = expressions[0], operands
|
|
1507
|
+
elif operands:
|
|
1508
|
+
script, files = operands[0], operands[1:]
|
|
529
1509
|
else:
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
1510
|
+
return False, 0
|
|
1511
|
+
if _SED_SCRIPT_RE.fullmatch(script) is None:
|
|
1512
|
+
return False, 0
|
|
1513
|
+
if not all(_valid_n(number) for number in re.findall(r"\d+", script)):
|
|
1514
|
+
return False, 0
|
|
1515
|
+
return True, len(files)
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def _sort_is_safe(argv: tuple[str, ...]) -> bool:
|
|
1519
|
+
index = 1
|
|
1520
|
+
while index < len(argv):
|
|
1521
|
+
argument = argv[index]
|
|
1522
|
+
if argument == "--":
|
|
1523
|
+
return index == len(argv) - 1
|
|
1524
|
+
if argument in {"-k", "-t"}:
|
|
1525
|
+
if index + 1 >= len(argv):
|
|
1526
|
+
return False
|
|
1527
|
+
value = argv[index + 1]
|
|
1528
|
+
if (
|
|
1529
|
+
argument == "-k" and not _valid_key(value)
|
|
1530
|
+
) or (
|
|
1531
|
+
argument == "-t" and len(value.encode("utf-8")) != 1
|
|
1532
|
+
):
|
|
1533
|
+
return False
|
|
1534
|
+
index += 2
|
|
1535
|
+
continue
|
|
1536
|
+
if argument in {"-r", "-u", "-n", "-f", "-s"}:
|
|
1537
|
+
index += 1
|
|
1538
|
+
continue
|
|
1539
|
+
if argument.startswith(("-k", "-t")) and len(argument) > 2:
|
|
1540
|
+
value = argument[2:]
|
|
1541
|
+
if (
|
|
1542
|
+
argument.startswith("-k") and not _valid_key(value)
|
|
1543
|
+
) or (
|
|
1544
|
+
argument.startswith("-t") and len(value.encode("utf-8")) != 1
|
|
1545
|
+
):
|
|
1546
|
+
return False
|
|
1547
|
+
index += 1
|
|
1548
|
+
continue
|
|
1549
|
+
return False
|
|
1550
|
+
return True
|
|
533
1551
|
|
|
534
1552
|
|
|
535
|
-
def
|
|
536
|
-
|
|
537
|
-
|
|
1553
|
+
def _uniq_is_safe(argv: tuple[str, ...]) -> bool:
|
|
1554
|
+
index = 1
|
|
1555
|
+
while index < len(argv):
|
|
1556
|
+
argument = argv[index]
|
|
1557
|
+
if argument == "--":
|
|
1558
|
+
return index == len(argv) - 1
|
|
1559
|
+
if argument in {"-c", "-d", "-u", "-i"}:
|
|
1560
|
+
index += 1
|
|
1561
|
+
continue
|
|
1562
|
+
if argument in {"-f", "-s", "-w"}:
|
|
1563
|
+
if index + 1 >= len(argv) or not _valid_n(argv[index + 1]):
|
|
1564
|
+
return False
|
|
1565
|
+
index += 2
|
|
1566
|
+
continue
|
|
1567
|
+
if (
|
|
1568
|
+
len(argument) > 2
|
|
1569
|
+
and argument[:2] in {"-f", "-s", "-w"}
|
|
1570
|
+
and _valid_n(argument[2:])
|
|
1571
|
+
):
|
|
1572
|
+
index += 1
|
|
1573
|
+
continue
|
|
1574
|
+
return False
|
|
1575
|
+
return True
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def _wc_is_safe(argv: tuple[str, ...], *, allow_files: bool) -> bool:
|
|
1579
|
+
"""wc 인자가 안전한 라우팅 대상인지 판정한다.
|
|
1580
|
+
|
|
1581
|
+
플래그는 -c/-l/-m/-w 조합만 허용한다. 파일 피연산자는 `_cat_is_safe`(:1138)와
|
|
1582
|
+
대칭으로 `allow_files`가 True일 때만 허용한다 — role이 "filter"(파이프 중간)면
|
|
1583
|
+
stdin만 읽어야 하므로 파일 인자를 거부해야 한다. `--` 이후 토큰은 전부
|
|
1584
|
+
피연산자로 취급한다(pathspec 구분자와 동일한 관례).
|
|
1585
|
+
"""
|
|
1586
|
+
operands = 0
|
|
1587
|
+
options_done = False
|
|
1588
|
+
for argument in argv[1:]:
|
|
1589
|
+
if not options_done and argument == "--":
|
|
1590
|
+
options_done = True
|
|
1591
|
+
continue
|
|
1592
|
+
if not options_done and argument.startswith("-") and argument != "-":
|
|
1593
|
+
if (
|
|
1594
|
+
argument.startswith("--")
|
|
1595
|
+
or not argument[1:]
|
|
1596
|
+
or not set(argument[1:]).issubset({"c", "l", "m", "w"})
|
|
1597
|
+
):
|
|
1598
|
+
return False
|
|
1599
|
+
continue
|
|
1600
|
+
operands += 1
|
|
1601
|
+
return allow_files or operands == 0
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def _head_tail_is_safe(argv: tuple[str, ...], *, allow_files: bool) -> bool:
|
|
1605
|
+
"""head/tail 인자가 안전한 라우팅 대상인지 판정한다.
|
|
1606
|
+
|
|
1607
|
+
`-n`/`--lines`(및 `-N`/`-nN`/`--lines=N` 축약형)는 최대 1회만 허용하며 유효한
|
|
1608
|
+
양의 정수여야 한다. **`-n` 미지정도 허용한다** — bare `head`/`tail`은 기본
|
|
1609
|
+
10줄 상한이 이미 적용되므로 무제한 출력 위험이 없다. `tail -f`/`-F`는 무제한
|
|
1610
|
+
스트림이므로 allow_files 여부와 무관하게 항상 거부한다(`bash -c` 내부에서
|
|
1611
|
+
프로세스가 종결되지 않는 것을 방지). `-c`(바이트 단위)는 지원하지 않는다 —
|
|
1612
|
+
trim 예산 단위는 줄(line)이라 바이트 상한과 섞일 수 없다.
|
|
1613
|
+
"""
|
|
1614
|
+
first = command_basename(argv[0])
|
|
1615
|
+
index = 1
|
|
1616
|
+
count_seen = False
|
|
1617
|
+
while index < len(argv):
|
|
1618
|
+
argument = argv[index]
|
|
1619
|
+
if argument == "--":
|
|
1620
|
+
index += 1
|
|
1621
|
+
break
|
|
1622
|
+
if first == "tail" and argument in {"-f", "-F"}:
|
|
1623
|
+
return False
|
|
1624
|
+
if argument in {"-n", "--lines"}:
|
|
1625
|
+
if count_seen or index + 1 >= len(argv) or not _valid_n(argv[index + 1]):
|
|
1626
|
+
return False
|
|
1627
|
+
count_seen = True
|
|
1628
|
+
index += 2
|
|
1629
|
+
continue
|
|
1630
|
+
attached = re.fullmatch(r"(?:-|(?:-n)|(?:--lines=))([1-9]\d*)", argument)
|
|
1631
|
+
if attached is not None:
|
|
1632
|
+
if count_seen or not _valid_n(attached.group(1)):
|
|
1633
|
+
return False
|
|
1634
|
+
count_seen = True
|
|
1635
|
+
index += 1
|
|
1636
|
+
continue
|
|
1637
|
+
if argument.startswith("-"):
|
|
1638
|
+
return False
|
|
1639
|
+
break
|
|
1640
|
+
return allow_files or index == len(argv)
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
#: grep 긴 옵션(long flag) 중 이미 허용된 짧은 옵션과 동치인 것만 정확히 나열한
|
|
1644
|
+
#: 화이트리스트. **접두사(startswith) 매칭 금지** — `--color`로 시작 매칭을 허용하면
|
|
1645
|
+
#: `--color=always`(ANSI 이스케이프 주입)가, `--f`류 접두사 매칭을 허용하면
|
|
1646
|
+
#: `--file=`(패턴을 파일에서 읽음, 예측 불가능한 I/O)이 함께 통과해버린다.
|
|
1647
|
+
#:
|
|
1648
|
+
#: 이 표는 **짧은 옵션 동치 규칙을 예외 없이** 지킨다. `--no-messages`는 그 짧은
|
|
1649
|
+
#: 형태 `-s`가 `allowed_flags` 밖이라 표에서 뺐다 — `-s` 자체는 stderr 진단만
|
|
1650
|
+
#: 억제해 위험하지 않지만, 규칙에 예외를 하나 두면 주석이 거짓이 되고 거짓 주석은
|
|
1651
|
+
#: 이 저장소에서 결함이 전파되는 경로다. `-s`를 허용하기로 결정한다면 짧은 옵션
|
|
1652
|
+
#: 쪽을 먼저 넓히고 그 다음 이 표에 롱 형태를 추가한다.
|
|
1653
|
+
#:
|
|
1654
|
+
#: 값 형태를 취하는 옵션(`--exclude=`, `--exclude-dir=`, `--devices=`,
|
|
1655
|
+
#: `--directories=`, `--label=`, `--binary-files=`, `-D/-U/-z/-Z/--null` 계열)은
|
|
1656
|
+
#: 의도적으로 제외한다. 이유는 값이 동작을 바꾸기 때문이다(예: `--directories=read`).
|
|
1657
|
+
#:
|
|
1658
|
+
#: S010은 incidence gate를 통과한 bare recursive `grep`의 정확히 한 개
|
|
1659
|
+
#: `--include=<glob>`만 아래 별도 값 문법으로 다룬다. 이 옵션은 정확 일치 이름,
|
|
1660
|
+
#: 제한된 ASCII basename grammar, recursive/file-operand 조건을 모두 만족해야 하며
|
|
1661
|
+
#: 이 별칭 표에는 들어오지 않는다. 다른 값 옵션과 `--include*` 근접 철자는 계속
|
|
1662
|
+
#: exact-match fail-closed 규칙을 따른다.
|
|
1663
|
+
_GREP_LONG_ALIASES = frozenset(
|
|
1664
|
+
{
|
|
1665
|
+
"--only-matching",
|
|
1666
|
+
"--count",
|
|
1667
|
+
"--files-with-matches",
|
|
1668
|
+
"--files-without-match",
|
|
1669
|
+
"--line-number",
|
|
1670
|
+
"--with-filename",
|
|
1671
|
+
"--no-filename",
|
|
1672
|
+
"--ignore-case",
|
|
1673
|
+
"--invert-match",
|
|
1674
|
+
"--word-regexp",
|
|
1675
|
+
"--line-regexp",
|
|
1676
|
+
"--extended-regexp",
|
|
1677
|
+
"--fixed-strings",
|
|
1678
|
+
"--basic-regexp",
|
|
1679
|
+
"--perl-regexp",
|
|
1680
|
+
"--quiet",
|
|
1681
|
+
"--silent",
|
|
1682
|
+
# `--recursive`는 표에 두지 않는다 — 조회보다 앞선 독립 분기가 이미
|
|
1683
|
+
# 처리하므로 여기 넣으면 도달 불가능한 중복 항목이 되고, 모든 항목이
|
|
1684
|
+
# 하중을 받아야 한다는 성질이 깨진다.
|
|
1685
|
+
"--dereference-recursive",
|
|
1686
|
+
"--color=never",
|
|
1687
|
+
"--color=auto",
|
|
1688
|
+
}
|
|
1689
|
+
)
|
|
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
|
+
)
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def _grep_is_safe(
|
|
1704
|
+
argv: tuple[str, ...],
|
|
1705
|
+
*,
|
|
1706
|
+
allow_files: bool,
|
|
1707
|
+
allow_include: bool = False,
|
|
1708
|
+
) -> bool:
|
|
1709
|
+
pattern_seen = False
|
|
1710
|
+
files = 0
|
|
1711
|
+
include_seen = False
|
|
1712
|
+
recursive_seen = False
|
|
1713
|
+
stdin_operand_seen = False
|
|
1714
|
+
allowed_flags = set("nHhivEFGPwxcolLrRq".replace(" ", ""))
|
|
1715
|
+
index = 1
|
|
1716
|
+
while index < len(argv):
|
|
1717
|
+
argument = argv[index]
|
|
1718
|
+
if argument == "--":
|
|
1719
|
+
index += 1
|
|
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
|
|
1732
|
+
if argument in {"-f", "--file"} or argument.startswith(("--file=", "--binary-files=")):
|
|
1733
|
+
return False
|
|
1734
|
+
if argument == "-e":
|
|
1735
|
+
if index + 1 >= len(argv):
|
|
1736
|
+
return False
|
|
1737
|
+
pattern_seen = True
|
|
1738
|
+
index += 2
|
|
1739
|
+
continue
|
|
1740
|
+
if argument in {"-m", "--max-count", "-A", "-B", "-C"}:
|
|
1741
|
+
if index + 1 >= len(argv) or not _valid_n(argv[index + 1]):
|
|
1742
|
+
return False
|
|
1743
|
+
index += 2
|
|
1744
|
+
continue
|
|
1745
|
+
if argument.startswith("--max-count="):
|
|
1746
|
+
if not _valid_n(argument.split("=", 1)[1]):
|
|
1747
|
+
return False
|
|
1748
|
+
index += 1
|
|
1749
|
+
continue
|
|
1750
|
+
if re.fullmatch(r"-(?:m|A|B|C)([1-9]\d*)", argument):
|
|
1751
|
+
if not _valid_n(argument[2:]):
|
|
1752
|
+
return False
|
|
1753
|
+
index += 1
|
|
1754
|
+
continue
|
|
1755
|
+
if argument == "--recursive":
|
|
1756
|
+
recursive_seen = True
|
|
1757
|
+
index += 1
|
|
1758
|
+
continue
|
|
1759
|
+
if argument in _GREP_LONG_ALIASES:
|
|
1760
|
+
if argument == "--dereference-recursive":
|
|
1761
|
+
recursive_seen = True
|
|
1762
|
+
index += 1
|
|
1763
|
+
continue
|
|
1764
|
+
if argument.startswith("-") and argument != "-":
|
|
1765
|
+
if (
|
|
1766
|
+
argument.startswith("--")
|
|
1767
|
+
or not argument[1:]
|
|
1768
|
+
or not set(argument[1:]).issubset(allowed_flags)
|
|
1769
|
+
):
|
|
1770
|
+
return False
|
|
1771
|
+
if "r" in argument[1:] or "R" in argument[1:]:
|
|
1772
|
+
recursive_seen = True
|
|
1773
|
+
index += 1
|
|
1774
|
+
continue
|
|
1775
|
+
if not pattern_seen:
|
|
1776
|
+
pattern_seen = True
|
|
1777
|
+
else:
|
|
1778
|
+
files += 1
|
|
1779
|
+
stdin_operand_seen = stdin_operand_seen or argument == "-"
|
|
1780
|
+
index += 1
|
|
1781
|
+
while index < len(argv):
|
|
1782
|
+
if not pattern_seen:
|
|
1783
|
+
pattern_seen = True
|
|
1784
|
+
else:
|
|
1785
|
+
files += 1
|
|
1786
|
+
stdin_operand_seen = stdin_operand_seen or argv[index] == "-"
|
|
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
|
+
)
|
|
1796
|
+
return pattern_seen and (allow_files or files == 0)
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def _rg_is_safe(argv: tuple[str, ...]) -> bool:
|
|
1800
|
+
pattern_seen = False
|
|
1801
|
+
options_done = False
|
|
1802
|
+
index = 1
|
|
1803
|
+
allowed_short = {
|
|
1804
|
+
"-n", "-H", "-h", "-i", "-S", "-F", "-w", "-x", "-l", "-c",
|
|
1805
|
+
}
|
|
1806
|
+
allowed_long = {
|
|
1807
|
+
"--line-number", "--with-filename", "--no-filename", "--ignore-case",
|
|
1808
|
+
"--smart-case", "--fixed-strings", "--word-regexp", "--line-regexp",
|
|
1809
|
+
"--files-with-matches", "--count", "--hidden", "--no-ignore",
|
|
1810
|
+
}
|
|
1811
|
+
while index < len(argv):
|
|
1812
|
+
argument = argv[index]
|
|
1813
|
+
if not options_done and argument == "--":
|
|
1814
|
+
options_done = True
|
|
1815
|
+
index += 1
|
|
1816
|
+
continue
|
|
1817
|
+
if not options_done and argument in allowed_short | allowed_long:
|
|
1818
|
+
index += 1
|
|
1819
|
+
continue
|
|
1820
|
+
if not options_done and argument in {"-g", "--glob"}:
|
|
1821
|
+
if index + 1 >= len(argv):
|
|
1822
|
+
return False
|
|
1823
|
+
index += 2
|
|
1824
|
+
continue
|
|
1825
|
+
if not options_done and (
|
|
1826
|
+
(argument.startswith("-g") and len(argument) > 2)
|
|
1827
|
+
or argument.startswith("--glob=")
|
|
1828
|
+
):
|
|
1829
|
+
index += 1
|
|
1830
|
+
continue
|
|
1831
|
+
if not options_done and argument.startswith("-"):
|
|
1832
|
+
return False
|
|
1833
|
+
pattern_seen = True
|
|
1834
|
+
index += 1
|
|
1835
|
+
return pattern_seen
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
GIT_TABLE_SUBCOMMANDS = frozenset({
|
|
1839
|
+
"status", "log", "branch", "tag", "remote", "rev-parse", "describe",
|
|
1840
|
+
"ls-files", "shortlog", "blame", "stash", "diff", "show", "grep",
|
|
1841
|
+
})
|
|
1842
|
+
"""§6.1b 12행 쌍 화이트리스트가 다루는 git 서브커맨드 집합 — `diff`/`show`/`grep`은
|
|
1843
|
+
한 표 행을 공유하므로 14개 서브커맨드가 12행이 된다(FIX-6이 `remote`행을
|
|
1844
|
+
재도입해 11행 -> 12행). 오라클 `git-*` family 집합과의 동치 검증(AC-1b.3, R-11)이
|
|
1845
|
+
이 상수를 그대로 참조한다 — 행을 늘리고 family를 빠뜨리면 그 테스트가 실패한다."""
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def _git_flags_and_positionals(
|
|
1849
|
+
arguments: tuple[str, ...],
|
|
1850
|
+
*,
|
|
1851
|
+
long_flags: frozenset[str],
|
|
1852
|
+
short_flags: frozenset[str],
|
|
1853
|
+
) -> int | None:
|
|
1854
|
+
"""옵션을 소비하며 위치 인자 개수를 반환한다. 미지 플래그면 `None`.
|
|
1855
|
+
|
|
1856
|
+
`--` 토큰 자체는 위치 인자로 계수하지 않되, 그 이후 토큰은 옵션 파싱을 끄고
|
|
1857
|
+
전부 위치 인자로 계수한다(AC-1.10 — `git log a..b -- p1 p2 p3`는 `--`를
|
|
1858
|
+
빼면 정확히 4개다. 과거 결함은 오버플로가 아니라 이 규칙의 부재였다).
|
|
1859
|
+
묶음 단축 플래그(`-ad` 등)는 `-`로 시작하는 각 글자가 모두 `short_flags`에
|
|
1860
|
+
속해야 허용된다(분해 없이 집합 매칭 — AC-1.9). `git branch -ad`는 `{a,d}`로
|
|
1861
|
+
분해되고 `d`가 branch의 허용 집합에 없어 거부된다(D1 완화가 다시 쓰기를
|
|
1862
|
+
재승인하지 않는지 확인하는 회귀 핀).
|
|
1863
|
+
"""
|
|
1864
|
+
positionals = 0
|
|
1865
|
+
options_done = False
|
|
1866
|
+
for argument in arguments:
|
|
1867
|
+
if not options_done and argument == "--":
|
|
1868
|
+
options_done = True
|
|
1869
|
+
continue
|
|
1870
|
+
if options_done:
|
|
1871
|
+
positionals += 1
|
|
1872
|
+
continue
|
|
1873
|
+
if argument in long_flags:
|
|
1874
|
+
continue
|
|
1875
|
+
if (
|
|
1876
|
+
argument.startswith("-")
|
|
1877
|
+
and not argument.startswith("--")
|
|
1878
|
+
and argument != "-"
|
|
1879
|
+
and set(argument[1:]).issubset(short_flags)
|
|
1880
|
+
):
|
|
1881
|
+
continue
|
|
1882
|
+
if argument.startswith("-"):
|
|
1883
|
+
return None
|
|
1884
|
+
positionals += 1
|
|
1885
|
+
return positionals
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
_GIT_STATUS_LONG_FLAGS = frozenset({
|
|
1889
|
+
"--short", "--branch", "--porcelain", "--long", "--no-color",
|
|
1890
|
+
"--untracked-files",
|
|
1891
|
+
})
|
|
1892
|
+
_GIT_STATUS_SHORT_FLAGS = frozenset("sb")
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def _git_status_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
1896
|
+
"""`git status`: 위치 인자 0개(§6.1b 표). `.git/index` stat-cache 갱신은
|
|
1897
|
+
허용된 부작용이다(AC-1.4 각주) — 이 함수의 쓰기 판정 대상이 아니다."""
|
|
1898
|
+
positionals = _git_flags_and_positionals(
|
|
1899
|
+
arguments,
|
|
1900
|
+
long_flags=_GIT_STATUS_LONG_FLAGS,
|
|
1901
|
+
short_flags=_GIT_STATUS_SHORT_FLAGS,
|
|
1902
|
+
)
|
|
1903
|
+
return positionals == 0
|
|
1904
|
+
|
|
1905
|
+
|
|
1906
|
+
_GIT_BRANCH_LONG_FLAGS = frozenset({
|
|
1907
|
+
"--all", "--remotes", "--verbose", "--list", "--show-current",
|
|
1908
|
+
"--no-color", "--sort",
|
|
1909
|
+
})
|
|
1910
|
+
_GIT_BRANCH_SHORT_FLAGS = frozenset("arv")
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def _git_branch_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
1914
|
+
"""`git branch`: 위치 인자 0개 엄격 — arity가 조회(0개)를 생성(1개+)으로
|
|
1915
|
+
뒤집는 서브커맨드다(D2 반증 사례, plan §6.1b). `--edit-description` 등
|
|
1916
|
+
쓰기 플래그는 표에 없어 미지 플래그로 거부된다."""
|
|
1917
|
+
positionals = _git_flags_and_positionals(
|
|
1918
|
+
arguments,
|
|
1919
|
+
long_flags=_GIT_BRANCH_LONG_FLAGS,
|
|
1920
|
+
short_flags=_GIT_BRANCH_SHORT_FLAGS,
|
|
1921
|
+
)
|
|
1922
|
+
return positionals == 0
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
_GIT_TAG_LONG_FLAGS = frozenset({"--list", "--sort", "--no-color"})
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def _git_tag_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
1929
|
+
"""`git tag`: 위치 인자 0개 엄격 — branch와 동일하게 arity가 조회↔생성을
|
|
1930
|
+
뒤집는다(§6.1b 표). `-n`은 부착형 주석 줄 수만 허용한다 — 분리형 `-n 5`는
|
|
1931
|
+
다음 토큰 `5`가 미지 위치 인자로 남아 이미 안전하게 거부된다(subcommand별
|
|
1932
|
+
`-n` 의미 차이, AC-1.9 — log는 분리형 값, tag는 부착형, shortlog는 순수
|
|
1933
|
+
불리언)."""
|
|
1934
|
+
positionals = 0
|
|
1935
|
+
for argument in arguments:
|
|
1936
|
+
if argument == "--":
|
|
1937
|
+
continue
|
|
1938
|
+
if argument in _GIT_TAG_LONG_FLAGS or argument in {"-l", "-n"}:
|
|
1939
|
+
continue
|
|
1940
|
+
if re.fullmatch(r"-n[1-9]\d*", argument):
|
|
1941
|
+
continue
|
|
1942
|
+
if argument.startswith("-"):
|
|
1943
|
+
return False
|
|
1944
|
+
positionals += 1
|
|
1945
|
+
return positionals == 0
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
_GIT_REMOTE_LONG_FLAGS = frozenset({"--verbose"})
|
|
1949
|
+
_GIT_REMOTE_SHORT_FLAGS = frozenset("v")
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
def _git_remote_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
1953
|
+
"""`git remote`: 위치 인자 0개 엄격 — branch/tag와 동일하게 arity가
|
|
1954
|
+
조회(0개)를 쓰기(`add`/`remove`/`rename`/`set-url`, 1개+)로 뒤집는다
|
|
1955
|
+
(§6.1b 표, FIX-6 재도입). `add`/`remove`/`rename`/`set-url`/`get-url` 등
|
|
1956
|
+
서브서브커맨드는 별도 목록 없이도 위치 인자로 잡혀 자동 거부된다(AC-1.4에
|
|
1957
|
+
`remote add origin url` deny가 고정돼 있고, 이번 재도입 후에도 그대로다).
|
|
1958
|
+
`-v`/`--verbose`만 허용해 URL을 노출하는 유일한 조회 형태를 표에 올린다
|
|
1959
|
+
— 이 URL이 자격증명을 담고 있어도 안전한 이유는 FIX-6에서 확장한
|
|
1960
|
+
`credential_policy.py`의 토큰 전용(콜론 없는) userinfo 리댁션이 담보한다."""
|
|
1961
|
+
positionals = _git_flags_and_positionals(
|
|
1962
|
+
arguments,
|
|
1963
|
+
long_flags=_GIT_REMOTE_LONG_FLAGS,
|
|
1964
|
+
short_flags=_GIT_REMOTE_SHORT_FLAGS,
|
|
1965
|
+
)
|
|
1966
|
+
return positionals == 0
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
_GIT_REV_PARSE_LONG_FLAGS = frozenset({
|
|
1970
|
+
"--abbrev-ref", "--short", "--verify", "--show-toplevel", "--git-dir",
|
|
1971
|
+
"--is-inside-work-tree", "--quiet",
|
|
1972
|
+
})
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
def _git_rev_parse_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
1976
|
+
"""`git rev-parse`: 위치 인자 무제한(revision 문자열, §6.1b 표) — 쓰기가
|
|
1977
|
+
되지 않는다."""
|
|
1978
|
+
positionals = _git_flags_and_positionals(
|
|
1979
|
+
arguments,
|
|
1980
|
+
long_flags=_GIT_REV_PARSE_LONG_FLAGS,
|
|
1981
|
+
short_flags=frozenset(),
|
|
1982
|
+
)
|
|
1983
|
+
return positionals is not None
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
_GIT_DESCRIBE_LONG_FLAGS = frozenset({
|
|
1987
|
+
"--tags", "--always", "--dirty", "--long", "--abbrev",
|
|
1988
|
+
})
|
|
1989
|
+
|
|
1990
|
+
|
|
1991
|
+
def _git_describe_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
1992
|
+
"""`git describe`: 위치 인자 무제한(§6.1b 표) — 쓰기가 되지 않는다."""
|
|
1993
|
+
positionals = _git_flags_and_positionals(
|
|
1994
|
+
arguments,
|
|
1995
|
+
long_flags=_GIT_DESCRIBE_LONG_FLAGS,
|
|
1996
|
+
short_flags=frozenset(),
|
|
1997
|
+
)
|
|
1998
|
+
return positionals is not None
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
_GIT_LS_FILES_LONG_FLAGS = frozenset({
|
|
2002
|
+
"--cached", "--modified", "--others", "--exclude-standard", "--stage",
|
|
2003
|
+
"--deleted",
|
|
2004
|
+
})
|
|
2005
|
+
_GIT_LS_FILES_SHORT_FLAGS = frozenset("cmos")
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
def _git_ls_files_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
2009
|
+
"""`git ls-files`: 위치 인자 무제한(pathspec 필터, §6.1b 표) — 쓰기가
|
|
2010
|
+
되지 않는다."""
|
|
2011
|
+
positionals = _git_flags_and_positionals(
|
|
2012
|
+
arguments,
|
|
2013
|
+
long_flags=_GIT_LS_FILES_LONG_FLAGS,
|
|
2014
|
+
short_flags=_GIT_LS_FILES_SHORT_FLAGS,
|
|
2015
|
+
)
|
|
2016
|
+
return positionals is not None
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
_GIT_SHORTLOG_LONG_FLAGS = frozenset({
|
|
2020
|
+
"--summary", "--numbered", "--email", "--no-color",
|
|
2021
|
+
})
|
|
2022
|
+
_GIT_SHORTLOG_SHORT_FLAGS = frozenset("sne")
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
def _git_shortlog_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
2026
|
+
"""`git shortlog`: 위치 인자 무제한이나 리비전 1개 이상 필수(§6.1b 표).
|
|
2027
|
+
`-n`은 여기서 `--numbered`(값을 취하지 않는 순수 불리언)다 — log의
|
|
2028
|
+
max-count `-n`과 의미가 다르다(subcommand별 `-n` 의미 차이, AC-1.9).
|
|
2029
|
+
|
|
2030
|
+
**리비전 1개 이상을 요구하는 이유(비종료 방지)**: git shortlog 는 리비전
|
|
2031
|
+
피연산자가 없으면 커밋 로그를 stdin 에서 읽는다. 재작성 래퍼
|
|
2032
|
+
(`sanitize_output.py:1052`)는 자식 프로세스에 `stdin=` 을 지정하지 않아
|
|
2033
|
+
훅의 stdin 을 그대로 상속시키므로, 닫히지 않은 stdin 아래에서
|
|
2034
|
+
`git shortlog -sn` 은 `DEFAULT_TIMEOUT_SECONDS`(600초) 워치독이 프로세스
|
|
2035
|
+
그룹을 죽일 때까지 아무 것도 출력하지 않고 블록한다(실측). 이는
|
|
2036
|
+
`_head_tail_is_safe` 가 `tail -f`/`-F` 를 거부하는 것과 동일한 불변식이며,
|
|
2037
|
+
승인 범위를 좁히는 방향이므로 표의 보안 태세를 약화하지 않는다.
|
|
2038
|
+
`git shortlog -sn HEAD` 처럼 리비전을 주면 stdin 을 읽지 않고 즉시 끝난다.
|
|
2039
|
+
|
|
2040
|
+
`--` 이후 토큰은 리비전이 아니라 pathspec 이므로 세지 않는다 —
|
|
2041
|
+
`git shortlog -sn -- README.md` 는 위치 인자가 1개로 보이지만 리비전이
|
|
2042
|
+
없어 여전히 stdin 을 읽고 블록한다(실측). blame 의 `>=1 path` 규칙과 달리
|
|
2043
|
+
여기서는 `--` 앞의 리비전만 요건을 충족시킨다.
|
|
2044
|
+
"""
|
|
2045
|
+
if _git_flags_and_positionals(
|
|
2046
|
+
arguments,
|
|
2047
|
+
long_flags=_GIT_SHORTLOG_LONG_FLAGS,
|
|
2048
|
+
short_flags=_GIT_SHORTLOG_SHORT_FLAGS,
|
|
2049
|
+
) is None:
|
|
2050
|
+
return False
|
|
2051
|
+
revision_arguments = (
|
|
2052
|
+
arguments[: arguments.index("--")] if "--" in arguments else arguments
|
|
2053
|
+
)
|
|
2054
|
+
revisions = _git_flags_and_positionals(
|
|
2055
|
+
revision_arguments,
|
|
2056
|
+
long_flags=_GIT_SHORTLOG_LONG_FLAGS,
|
|
2057
|
+
short_flags=_GIT_SHORTLOG_SHORT_FLAGS,
|
|
2058
|
+
)
|
|
2059
|
+
return revisions is not None and revisions >= 1
|
|
2060
|
+
|
|
2061
|
+
|
|
2062
|
+
def _git_blame_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
2063
|
+
"""`git blame`: 위치 인자 무제한이나 경로 1개 이상 필수(§6.1b 표).
|
|
2064
|
+
`-L`은 값을 취한다(부착 `-L10,20` 또는 분리 `-L 10,20` 모두 허용 — 범위
|
|
2065
|
+
문자열 자체를 검증하지 않아도 안전하다, sanitize 240줄 상한이 출력을
|
|
2066
|
+
이미 유계화한다)."""
|
|
2067
|
+
positionals = 0
|
|
2068
|
+
options_done = False
|
|
2069
|
+
index = 0
|
|
2070
|
+
while index < len(arguments):
|
|
2071
|
+
argument = arguments[index]
|
|
2072
|
+
if not options_done and argument == "--":
|
|
2073
|
+
options_done = True
|
|
2074
|
+
index += 1
|
|
2075
|
+
continue
|
|
2076
|
+
if not options_done and argument in {"--porcelain", "--line-porcelain", "-w"}:
|
|
2077
|
+
index += 1
|
|
2078
|
+
continue
|
|
2079
|
+
if not options_done and argument == "-L":
|
|
2080
|
+
if index + 1 >= len(arguments):
|
|
2081
|
+
return False
|
|
2082
|
+
index += 2
|
|
2083
|
+
continue
|
|
2084
|
+
if not options_done and argument.startswith("-L") and len(argument) > 2:
|
|
2085
|
+
index += 1
|
|
2086
|
+
continue
|
|
2087
|
+
if not options_done and argument.startswith("-"):
|
|
2088
|
+
return False
|
|
2089
|
+
positionals += 1
|
|
2090
|
+
index += 1
|
|
2091
|
+
return positionals >= 1
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
def _git_stash_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
2095
|
+
"""`git stash`: `list`/`show`만 허용, 부가 인자 없는 정확히 그 형태만
|
|
2096
|
+
— 맨 `git stash`(0-arity writer, D2 반증 사례)와 그 밖의 서브커맨드
|
|
2097
|
+
(`push`/`pop`/`apply`/`drop`/`clear`/`branch`/`save`)는 표에 없어
|
|
2098
|
+
거부된다(§6.1b 표)."""
|
|
2099
|
+
return len(arguments) == 1 and arguments[0] in {"list", "show"}
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
_GIT_DIFF_SHOW_BOOLEAN_FLAGS = frozenset({
|
|
2103
|
+
"-p", "--patch", "--stat", "--name-only", "--name-status", "--no-color",
|
|
2104
|
+
"--color=never", "--cached", "--staged", "--oneline",
|
|
2105
|
+
})
|
|
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
|
+
|
|
2125
|
+
|
|
2126
|
+
def _git_diff_show_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
2127
|
+
"""`git diff`/`git show`: 기존 `_git_is_safe` 경로를 그대로 보존한다
|
|
2128
|
+
(§6.1b 표 — "기존대로"). 개조 전 `patch_output`은 diff/show에서
|
|
2129
|
+
항상 `True`로 시작해 끝까지 `False`로 바뀌는 경로가 없었으므로(오직
|
|
2130
|
+
log에서만 `-p` 요구가 의미 있었다) 여기서는 제거했다 — 동작은 동일하다."""
|
|
2131
|
+
index = 0
|
|
2132
|
+
options_done = False
|
|
2133
|
+
while index < len(arguments):
|
|
2134
|
+
argument = arguments[index]
|
|
2135
|
+
if not options_done and argument == "--":
|
|
2136
|
+
options_done = True
|
|
2137
|
+
index += 1
|
|
2138
|
+
continue
|
|
2139
|
+
if options_done:
|
|
2140
|
+
index += 1
|
|
2141
|
+
continue
|
|
2142
|
+
if argument in _GIT_DIFF_SHOW_BOOLEAN_FLAGS:
|
|
2143
|
+
index += 1
|
|
2144
|
+
continue
|
|
2145
|
+
if argument in {"-U", "--unified"}:
|
|
2146
|
+
if index + 1 >= len(arguments) or not _valid_n(arguments[index + 1]):
|
|
2147
|
+
return False
|
|
2148
|
+
index += 2
|
|
2149
|
+
continue
|
|
2150
|
+
if re.fullmatch(r"-U[1-9]\d*", argument) or (
|
|
2151
|
+
argument.startswith("--unified=")
|
|
2152
|
+
and _valid_n(argument.split("=", 1)[1])
|
|
2153
|
+
):
|
|
2154
|
+
index += 1
|
|
2155
|
+
continue
|
|
2156
|
+
if argument.startswith("-"):
|
|
2157
|
+
return False
|
|
2158
|
+
index += 1
|
|
2159
|
+
return True
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
_GIT_LOG_BOOLEAN_FLAGS = frozenset({
|
|
2163
|
+
"--oneline", "--stat", "--name-only", "--name-status", "--graph",
|
|
2164
|
+
"--decorate", "--no-color", "-p", "--patch", "--reverse",
|
|
2165
|
+
})
|
|
2166
|
+
_GIT_LOG_VALUE_FLAGS = frozenset({
|
|
2167
|
+
"--pretty", "--format", "--author", "--since", "--until",
|
|
2168
|
+
})
|
|
2169
|
+
|
|
2170
|
+
|
|
2171
|
+
def _git_log_attached_value_ok(argument: str) -> bool:
|
|
2172
|
+
"""`-<N>`/`-U<N>`/`--unified=<N>`/`--max-count=<N>`/`--<value-flag>=…`
|
|
2173
|
+
부착형이 안전한지 판정한다(AC-1.9 — `git log --oneline -20` 같은 부착형이
|
|
2174
|
+
거짓 거부되지 않도록 분해 전에 먼저 인식한다)."""
|
|
2175
|
+
if re.fullmatch(r"-[1-9]\d*", argument):
|
|
2176
|
+
return True
|
|
2177
|
+
if re.fullmatch(r"-U[1-9]\d*", argument):
|
|
2178
|
+
return True
|
|
2179
|
+
if argument.startswith("--unified=") and _valid_n(argument.split("=", 1)[1]):
|
|
2180
|
+
return True
|
|
2181
|
+
if argument.startswith("--max-count=") and _valid_n(argument.split("=", 1)[1]):
|
|
2182
|
+
return True
|
|
2183
|
+
return any(argument.startswith(f"{flag}=") for flag in _GIT_LOG_VALUE_FLAGS)
|
|
2184
|
+
|
|
2185
|
+
|
|
2186
|
+
def _git_log_is_safe(arguments: tuple[str, ...]) -> bool:
|
|
2187
|
+
"""`git log`: 위치 인자 무제한(revision/pathspec, §6.1b 표) — arity가
|
|
2188
|
+
쓰기로 뒤집히지 않으므로 상한이 불필요하다. 출력 증폭은 sanitize 240줄
|
|
2189
|
+
상한(`sanitize_output.py:295`)으로 이미 유계다. 개조 전에는 `-p` 없이
|
|
2190
|
+
`git log`/`git log --oneline`이 거부됐다(§0 정정 1) — 이 요구를 제거한
|
|
2191
|
+
것이 이 함수의 핵심 완화다."""
|
|
2192
|
+
index = 0
|
|
2193
|
+
while index < len(arguments):
|
|
2194
|
+
argument = arguments[index]
|
|
2195
|
+
if argument == "--":
|
|
2196
|
+
return True
|
|
2197
|
+
if argument in _GIT_LOG_BOOLEAN_FLAGS:
|
|
2198
|
+
index += 1
|
|
2199
|
+
continue
|
|
2200
|
+
if argument in {"-n", "--max-count", "-U", "--unified"}:
|
|
2201
|
+
if index + 1 >= len(arguments) or not _valid_n(arguments[index + 1]):
|
|
2202
|
+
return False
|
|
2203
|
+
index += 2
|
|
2204
|
+
continue
|
|
2205
|
+
if argument in _GIT_LOG_VALUE_FLAGS:
|
|
2206
|
+
if index + 1 >= len(arguments):
|
|
2207
|
+
return False
|
|
2208
|
+
index += 2
|
|
2209
|
+
continue
|
|
2210
|
+
if _git_log_attached_value_ok(argument):
|
|
2211
|
+
index += 1
|
|
2212
|
+
continue
|
|
2213
|
+
if argument.startswith("-"):
|
|
2214
|
+
return False
|
|
2215
|
+
index += 1
|
|
2216
|
+
return True
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
def _git_is_safe(argv: tuple[str, ...]) -> bool:
|
|
2220
|
+
"""git (서브커맨드, 인자 형태) 쌍 화이트리스트(D1, plan §6.1b, 12행).
|
|
2221
|
+
|
|
2222
|
+
R-5 불변식(표 전체를 지탱하는 단일 지점) — `argv[1]`을 리터럴로만
|
|
2223
|
+
서브커맨드로 인정한다. `-`로 시작하면 무조건 거부하고, 서브커맨드를
|
|
2224
|
+
찾기 위해 선행 전역 옵션(`-c`/`-C`/`-p`/`--paginate`/`--no-pager`/
|
|
2225
|
+
`--exec-path`/`--git-dir` 등)을 절대 건너뛰지 않는다.
|
|
2226
|
+
**경고**: `_package_script_route:1436`의
|
|
2227
|
+
`while index < len(argv) and argv[index].startswith("-")` 패턴을 이
|
|
2228
|
+
함수에 재사용하지 말 것 — 그 패턴을 쓰면 `git -c alias.zz='!echo pwned' zz`
|
|
2229
|
+
가 임의 셸을 실행한다(3라운드 레드팀 실증, plan §4 시나리오 1). 현재
|
|
2230
|
+
9개 전역 옵션 우회(AC-1b.2)가 전부 막히는 이유는 오직 이 리터럴 비교
|
|
2231
|
+
하나다.
|
|
2232
|
+
|
|
2233
|
+
R-1 불변식 — 서브커맨드 이름만으로도, "위치 인자 0개면 거부"만으로도
|
|
2234
|
+
승인하지 않는다. 전자는 쓰기 6/6 누수, 후자는 0-arity 쓰기 8건 누수를
|
|
2235
|
+
실증했다(`git stash`/`gc`/`prune`/`repack`/`clean -fd`/`reset --hard`/
|
|
2236
|
+
`commit --amend --no-edit`/`branch --edit-description`; 뒤 둘은 데이터
|
|
2237
|
+
손실이다). 반드시 (서브커맨드, 허용 플래그, 위치 인자 상한) 삼중으로
|
|
2238
|
+
판정한다. 표에 없는 서브커맨드(`config`/`gc`/`prune`/`repack`/
|
|
2239
|
+
`clean`/`reset`/`commit`/`push`/`pull`/`fetch`/`merge`/`rebase`/
|
|
2240
|
+
`checkout`/`switch`/`restore` 등)는 아래 분기에 없어 자동으로 폴스루
|
|
2241
|
+
거부된다 — never-list는 두지 않는다(이미 deny인 폴스루에 목록을 얹으면
|
|
2242
|
+
"목록에 없으면 안전"이라는 오독만 유발할 뿐 방어를 강화하지 않는다,
|
|
2243
|
+
plan 결정 D1). `config`는 키 없이 값만 출력해 구조적으로 리댁션이
|
|
2244
|
+
불가능하므로(원칙 6, R-13) 표에서 영구 삭제되었다 — `config`는 FIX-6의
|
|
2245
|
+
범위 밖이다(FIX-6은 `remote`만 재도입 심사 대상이었다).
|
|
2246
|
+
|
|
2247
|
+
`remote`는 FIX-6에서 재도입됐다. `git remote -v`가 자격증명이 임베드된
|
|
2248
|
+
URL(`https://TOKEN@host/...`)을 출력해 구조적으로 위험했던 원인은
|
|
2249
|
+
`credential_policy.py`의 URL 리댁션 정규식이 `user:pass@` 두 파트를 모두
|
|
2250
|
+
요구해 콜론 없는 토큰 전용 URL(가장 흔한 PAT 임베딩 형태)을 통과시켰기
|
|
2251
|
+
때문이다 — 그 정규식 자체의 결함이지, `remote` 행이 원천적으로 리댁션
|
|
2252
|
+
불가능한 것은 아니었다(`config`와 다른 점). FIX-6이 그 정규식을
|
|
2253
|
+
`scheme://TOKEN@` 형태까지 커버하도록 넓혔으므로(비밀번호 파트를
|
|
2254
|
+
선택적으로 만듦) 지금은 안전하다 — `_git_remote_is_safe`가 `-v`/
|
|
2255
|
+
`--verbose` 조회 형태만 허용하고 `add`/`remove`/`rename`/`set-url` 등
|
|
2256
|
+
위치 인자가 있는 쓰기 형태는 branch/tag와 동일한 0-arity 규칙으로
|
|
2257
|
+
거부한다(AC-1.4에 `remote add origin url` deny가 고정돼 있다).
|
|
2258
|
+
"""
|
|
2259
|
+
if len(argv) < 2 or argv[1].startswith("-"):
|
|
2260
|
+
return False
|
|
2261
|
+
subcommand = argv[1]
|
|
2262
|
+
arguments = argv[2:]
|
|
2263
|
+
if subcommand == "status":
|
|
2264
|
+
return _git_status_is_safe(arguments)
|
|
2265
|
+
if subcommand == "log":
|
|
2266
|
+
return _git_log_is_safe(arguments)
|
|
2267
|
+
if subcommand == "branch":
|
|
2268
|
+
return _git_branch_is_safe(arguments)
|
|
2269
|
+
if subcommand == "tag":
|
|
2270
|
+
return _git_tag_is_safe(arguments)
|
|
2271
|
+
if subcommand == "remote":
|
|
2272
|
+
return _git_remote_is_safe(arguments)
|
|
2273
|
+
if subcommand == "rev-parse":
|
|
2274
|
+
return _git_rev_parse_is_safe(arguments)
|
|
2275
|
+
if subcommand == "describe":
|
|
2276
|
+
return _git_describe_is_safe(arguments)
|
|
2277
|
+
if subcommand == "ls-files":
|
|
2278
|
+
return _git_ls_files_is_safe(arguments)
|
|
2279
|
+
if subcommand == "shortlog":
|
|
2280
|
+
return _git_shortlog_is_safe(arguments)
|
|
2281
|
+
if subcommand == "blame":
|
|
2282
|
+
return _git_blame_is_safe(arguments)
|
|
2283
|
+
if subcommand == "stash":
|
|
2284
|
+
return _git_stash_is_safe(arguments)
|
|
2285
|
+
if subcommand == "grep":
|
|
2286
|
+
return _grep_is_safe(("grep", *arguments), allow_files=True)
|
|
2287
|
+
if subcommand in {"diff", "show"}:
|
|
2288
|
+
return _git_diff_show_is_safe(arguments)
|
|
2289
|
+
return False
|
|
2290
|
+
|
|
2291
|
+
|
|
2292
|
+
def _package_script_route(argv: tuple[str, ...]) -> str:
|
|
2293
|
+
value_options = {"--prefix", "--workspace", "-w", "--filter", "--cwd", "-C"}
|
|
2294
|
+
long_value_options = {"--prefix", "--workspace", "--filter", "--cwd"}
|
|
2295
|
+
index = 1
|
|
2296
|
+
while index < len(argv) and argv[index].startswith("-"):
|
|
2297
|
+
option = argv[index]
|
|
2298
|
+
if option in value_options and index + 1 < len(argv):
|
|
2299
|
+
index += 2
|
|
2300
|
+
continue
|
|
2301
|
+
if any(option.startswith(name + "=") for name in long_value_options):
|
|
2302
|
+
index += 1
|
|
2303
|
+
continue
|
|
2304
|
+
return "deny"
|
|
2305
|
+
if index >= len(argv):
|
|
2306
|
+
return "noop"
|
|
2307
|
+
command = argv[index]
|
|
2308
|
+
if command in {"test", "build", "lint"}:
|
|
2309
|
+
return (
|
|
2310
|
+
"trim"
|
|
2311
|
+
if index + 1 == len(argv)
|
|
2312
|
+
or argv[index + 1] == "--"
|
|
2313
|
+
else "deny"
|
|
2314
|
+
)
|
|
2315
|
+
if command in {"run", "run-script"} and index + 1 < len(argv):
|
|
2316
|
+
script = argv[index + 1]
|
|
2317
|
+
if script == "build" or script == "lint" or script.startswith("test"):
|
|
2318
|
+
return (
|
|
2319
|
+
"trim"
|
|
2320
|
+
if index + 2 == len(argv)
|
|
2321
|
+
or argv[index + 2] == "--"
|
|
2322
|
+
else "deny"
|
|
2323
|
+
)
|
|
2324
|
+
return "noop"
|
|
2325
|
+
|
|
2326
|
+
|
|
2327
|
+
def _npx_route(argv: tuple[str, ...]) -> str:
|
|
2328
|
+
index = 1
|
|
2329
|
+
while index < len(argv) and argv[index].startswith("-"):
|
|
2330
|
+
option = argv[index]
|
|
2331
|
+
if option in {"--no-install", "--yes", "-y"}:
|
|
2332
|
+
index += 1
|
|
2333
|
+
continue
|
|
2334
|
+
if option in {"-p", "--package"} and index + 1 < len(argv):
|
|
2335
|
+
index += 2
|
|
2336
|
+
continue
|
|
2337
|
+
if option.startswith("--package="):
|
|
2338
|
+
index += 1
|
|
2339
|
+
continue
|
|
2340
|
+
return "deny"
|
|
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"}:
|
|
2348
|
+
return "trim"
|
|
2349
|
+
return "noop"
|
|
2350
|
+
|
|
2351
|
+
|
|
2352
|
+
def _make_route(argv: tuple[str, ...]) -> str:
|
|
2353
|
+
index = 1
|
|
2354
|
+
while index < len(argv) and argv[index].startswith("-"):
|
|
2355
|
+
option = argv[index]
|
|
2356
|
+
if option == "-C" and index + 1 < len(argv):
|
|
2357
|
+
index += 2
|
|
2358
|
+
continue
|
|
2359
|
+
if option.startswith("-C") and len(option) > 2:
|
|
2360
|
+
index += 1
|
|
2361
|
+
continue
|
|
2362
|
+
if option == "--directory" and index + 1 < len(argv):
|
|
2363
|
+
index += 2
|
|
2364
|
+
continue
|
|
2365
|
+
if option.startswith("--directory="):
|
|
2366
|
+
index += 1
|
|
2367
|
+
continue
|
|
2368
|
+
if option in {"-s", "--silent", "--no-print-directory"}:
|
|
2369
|
+
index += 1
|
|
2370
|
+
continue
|
|
2371
|
+
return "deny"
|
|
2372
|
+
if index < len(argv) and argv[index] in {"test", "build", "lint"}:
|
|
2373
|
+
return "trim"
|
|
2374
|
+
return "noop"
|
|
2375
|
+
|
|
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
|
+
|
|
2398
|
+
def command_search_diff(
|
|
2399
|
+
argv: tuple[str, ...],
|
|
2400
|
+
*,
|
|
2401
|
+
role: str = "standalone",
|
|
2402
|
+
) -> str:
|
|
2403
|
+
"""Classify one boundary-checked simple command for the A1 route table.
|
|
2404
|
+
|
|
2405
|
+
FIX-2: standalone `cat`도 `trim`으로 라우팅한다(과거에는 `noop`, 즉 무변형
|
|
2406
|
+
통과였다). 48KB 초과 파일을 `cat <bigfile>`로 그대로 읽으면 Read 가드
|
|
2407
|
+
(`guard_large_read.py`)가 `tool_name == "Read"`에서만 발동하므로 이 구멍을
|
|
2408
|
+
그대로 우회했다 — standalone `cat`이 first/filter 역할과 동일하게 항상
|
|
2409
|
+
`trim`을 받도록 통일해 막는다. `_cat_is_safe`의 안전성 판정 자체(허용 플래그,
|
|
2410
|
+
`allow_files`)는 바뀌지 않는다.
|
|
2411
|
+
"""
|
|
2412
|
+
if not argv:
|
|
2413
|
+
return "deny"
|
|
2414
|
+
first = command_basename(argv[0])
|
|
2415
|
+
if _forbidden_command_basename(argv):
|
|
2416
|
+
return "deny"
|
|
2417
|
+
if first == "printf":
|
|
2418
|
+
if role == "filter" or not _printf_is_safe(argv):
|
|
2419
|
+
return "deny"
|
|
2420
|
+
return "trim" if role == "first" else ("noop" if role == "standalone" else "deny")
|
|
2421
|
+
if first == "ls":
|
|
2422
|
+
# standalone 은 오늘의 동작(`noop`)을 그대로 보존한다. `_ls_is_safe` 는
|
|
2423
|
+
# producer(role == "first") 재승인의 게이트일 뿐이며, standalone 판정에
|
|
2424
|
+
# 개입해서는 안 된다 — 개입하면 `ls -G`, `ls -x`, `ls --color=always`
|
|
2425
|
+
# 처럼 지금 통과하는 standalone 형태가 새로 거부되어 "이미 동작하는 것을
|
|
2426
|
+
# 움직이지 않는다"는 설계 불변식을 깨뜨린다.
|
|
2427
|
+
if role == "standalone":
|
|
2428
|
+
return "noop"
|
|
2429
|
+
if role == "filter" or not _ls_is_safe(argv):
|
|
2430
|
+
return "deny"
|
|
2431
|
+
return "trim"
|
|
2432
|
+
if first == "cat":
|
|
2433
|
+
if not _cat_is_safe(argv, allow_files=role != "filter"):
|
|
2434
|
+
return "deny"
|
|
2435
|
+
return "trim"
|
|
2436
|
+
if first == "cut":
|
|
2437
|
+
if role == "first" or not _cut_is_safe(argv):
|
|
2438
|
+
return "deny"
|
|
2439
|
+
return "trim" if role == "filter" else "noop"
|
|
2440
|
+
if first == "sed":
|
|
2441
|
+
# design route-readmission-design-20260729.md §2.3 라우트 배선 —
|
|
2442
|
+
# 기존 filter/standalone 판정을 전혀 움직이지 않는다(둘 다 파일
|
|
2443
|
+
# 피연산자가 없는 stdin 형태만 오늘 존재했으므로 files == 0 으로
|
|
2444
|
+
# 수렴). role == "first" 만 새로 열린다 — 단, 파일 피연산자가 있을
|
|
2445
|
+
# 때만이다. 파일 없는 producer sed 는 훅이 물려준 stdin 을 읽어
|
|
2446
|
+
# 600초 워치독까지 블록한다(`_git_shortlog_is_safe` 와 동일한
|
|
2447
|
+
# non-termination 불변식).
|
|
2448
|
+
safe, files = _sed_route_shape(argv)
|
|
2449
|
+
if not safe:
|
|
2450
|
+
return "deny"
|
|
2451
|
+
if role == "filter":
|
|
2452
|
+
return "deny" if files else "trim"
|
|
2453
|
+
if role == "first":
|
|
2454
|
+
return "trim" if files else "deny"
|
|
2455
|
+
return "trim" if files else "noop"
|
|
2456
|
+
if first == "sort":
|
|
2457
|
+
if role == "first" or not _sort_is_safe(argv):
|
|
2458
|
+
return "deny"
|
|
2459
|
+
return "trim" if role == "filter" else "noop"
|
|
2460
|
+
if first == "uniq":
|
|
2461
|
+
if role == "first" or not _uniq_is_safe(argv):
|
|
2462
|
+
return "deny"
|
|
2463
|
+
return "trim" if role == "filter" else "noop"
|
|
2464
|
+
if first == "wc":
|
|
2465
|
+
if role == "first" or not _wc_is_safe(argv, allow_files=role != "filter"):
|
|
2466
|
+
return "deny"
|
|
2467
|
+
return "trim" if role == "filter" else "noop"
|
|
2468
|
+
if first in {"head", "tail"}:
|
|
2469
|
+
return (
|
|
2470
|
+
"trim"
|
|
2471
|
+
if _head_tail_is_safe(argv, allow_files=role != "filter")
|
|
2472
|
+
else "deny"
|
|
2473
|
+
)
|
|
2474
|
+
if first in {"grep", "egrep", "fgrep"}:
|
|
2475
|
+
return (
|
|
2476
|
+
"sanitize"
|
|
2477
|
+
if _grep_is_safe(
|
|
2478
|
+
argv,
|
|
2479
|
+
allow_files=role != "filter",
|
|
2480
|
+
allow_include=first == "grep" and role != "filter",
|
|
2481
|
+
)
|
|
2482
|
+
else "deny"
|
|
2483
|
+
)
|
|
2484
|
+
if first == "rg":
|
|
2485
|
+
return (
|
|
2486
|
+
"sanitize"
|
|
2487
|
+
if role != "filter" and _rg_is_safe(argv)
|
|
2488
|
+
else "deny"
|
|
2489
|
+
)
|
|
2490
|
+
if first == "git":
|
|
2491
|
+
return (
|
|
2492
|
+
"sanitize"
|
|
2493
|
+
if role != "filter" and _git_is_safe(argv)
|
|
2494
|
+
else "deny"
|
|
2495
|
+
)
|
|
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"}:
|
|
2510
|
+
route = _package_script_route(argv)
|
|
2511
|
+
elif first == "npx":
|
|
2512
|
+
route = _npx_route(argv)
|
|
2513
|
+
elif first == "make":
|
|
2514
|
+
route = _make_route(argv)
|
|
2515
|
+
elif re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", first):
|
|
2516
|
+
route = (
|
|
2517
|
+
"trim"
|
|
2518
|
+
if len(argv) > 2 and argv[1] == "-m" and argv[2] in {"pytest", "unittest"}
|
|
2519
|
+
else "noop"
|
|
2520
|
+
)
|
|
2521
|
+
elif first == "go":
|
|
2522
|
+
route = "trim" if len(argv) > 1 and argv[1] == "test" else "noop"
|
|
2523
|
+
elif first == "cargo":
|
|
2524
|
+
route = "trim" if len(argv) > 1 and argv[1] == "test" else "noop"
|
|
2525
|
+
elif first in {"mvn", "mvnw", "gradle", "gradlew"}:
|
|
2526
|
+
index = 1
|
|
2527
|
+
if index < len(argv) and argv[index] in {"-q", "--quiet"}:
|
|
2528
|
+
index += 1
|
|
2529
|
+
if index < len(argv) and argv[index] == "test":
|
|
2530
|
+
route = "trim"
|
|
2531
|
+
else:
|
|
2532
|
+
route = "noop"
|
|
2533
|
+
elif first in {"pytest", "tox", "jest", "vitest"}:
|
|
2534
|
+
route = "trim"
|
|
2535
|
+
elif first in {"find", "tree", "fd"}:
|
|
2536
|
+
route = "trim"
|
|
2537
|
+
elif is_log_streaming_command(list(argv)):
|
|
2538
|
+
route = "sanitize"
|
|
538
2539
|
else:
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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"
|
|
2545
|
+
if role == "standalone":
|
|
2546
|
+
return route
|
|
2547
|
+
if role == "first":
|
|
2548
|
+
return route if route in {"trim", "sanitize"} else "deny"
|
|
2549
|
+
return "deny"
|
|
2550
|
+
|
|
2551
|
+
|
|
2552
|
+
def _find_command_is_side_effecting(argv: tuple[str, ...]) -> bool:
|
|
2553
|
+
if not argv or argv[0].rsplit("/", 1)[-1] != "find":
|
|
2554
|
+
return False
|
|
2555
|
+
return any(argument in _FIND_OUTPUT_RISK_ACTIONS for argument in argv[1:])
|
|
542
2556
|
|
|
543
2557
|
|
|
544
|
-
def
|
|
2558
|
+
def _prefix_overrides_path(
|
|
2559
|
+
segment: tuple[MiniShellWord, ...],
|
|
2560
|
+
route_start: int,
|
|
2561
|
+
) -> bool:
|
|
2562
|
+
return any(
|
|
2563
|
+
word.assignment_index == 4 and word.source_value.startswith("PATH=")
|
|
2564
|
+
for word in segment[:route_start]
|
|
2565
|
+
)
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
def _forbidden_command_basename(argv: tuple[str, ...]) -> bool:
|
|
2569
|
+
if not argv:
|
|
2570
|
+
return False
|
|
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])
|
|
2575
|
+
if basename in MINISHELL_DENIED_COMMAND_BASENAMES:
|
|
2576
|
+
return True
|
|
2577
|
+
if basename not in MINISHELL_DENIED_SHELL_BASENAMES:
|
|
2578
|
+
return False
|
|
2579
|
+
return any(
|
|
2580
|
+
re.fullmatch(r"-[^-]*c[^-]*", argument) is not None
|
|
2581
|
+
for argument in argv[1:]
|
|
2582
|
+
)
|
|
2583
|
+
|
|
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
|
+
|
|
2622
|
+
def classify_command(command: str, *, allow_cgw1: bool = True) -> CommandDecision:
|
|
2623
|
+
"""Make a side-effect-free shell-boundary and routing decision."""
|
|
2624
|
+
parsed = parse_minishell(command)
|
|
2625
|
+
if parsed.denial_reason is not None:
|
|
2626
|
+
return CommandDecision(
|
|
2627
|
+
action="deny",
|
|
2628
|
+
parsed=parsed,
|
|
2629
|
+
reason=f"MiniShell-v1 rejected command ({parsed.denial_reason}).",
|
|
2630
|
+
reason_code=parsed.denial_reason,
|
|
2631
|
+
)
|
|
2632
|
+
if any(word.active_tilde_sites for word in parsed.words):
|
|
2633
|
+
return CommandDecision(
|
|
2634
|
+
action="deny",
|
|
2635
|
+
parsed=parsed,
|
|
2636
|
+
reason="MiniShell-v1 denied active shell expansion (active_shell_expansion_denied).",
|
|
2637
|
+
reason_code="active_shell_expansion_denied",
|
|
2638
|
+
)
|
|
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
|
+
|
|
2647
|
+
wrapper = classify_incoming_wrapper(parsed)
|
|
2648
|
+
if wrapper is not None:
|
|
2649
|
+
wrapper_status, _wrapper_kind_name, _payload = wrapper
|
|
2650
|
+
return CommandDecision(
|
|
2651
|
+
action="deny",
|
|
2652
|
+
parsed=parsed,
|
|
2653
|
+
reason=f"Incoming ContextGuard execution wrapper denied ({wrapper_status}).",
|
|
2654
|
+
reason_code=wrapper_status,
|
|
2655
|
+
)
|
|
2656
|
+
|
|
2657
|
+
segment_routes: list[str] = []
|
|
2658
|
+
for segment_index, segment in enumerate(parsed.segments):
|
|
2659
|
+
segment_argv = tuple(word.value for word in segment)
|
|
2660
|
+
route_start = _routing_start(segment, segment_argv)
|
|
2661
|
+
if route_start == -2:
|
|
2662
|
+
return CommandDecision(
|
|
2663
|
+
action="deny",
|
|
2664
|
+
parsed=parsed,
|
|
2665
|
+
reason="MiniShell-v1 denied an unsafe environment prefix name (unsafe_env_name_denied).",
|
|
2666
|
+
reason_code="unsafe_env_name_denied",
|
|
2667
|
+
)
|
|
2668
|
+
if route_start < 0:
|
|
2669
|
+
return CommandDecision(
|
|
2670
|
+
action="deny",
|
|
2671
|
+
parsed=parsed,
|
|
2672
|
+
reason="Restricted env prefix denied (restricted_env_denied).",
|
|
2673
|
+
reason_code="restricted_env_denied",
|
|
2674
|
+
)
|
|
2675
|
+
if route_start < len(segment):
|
|
2676
|
+
command_word = segment[route_start]
|
|
2677
|
+
if (
|
|
2678
|
+
command_word.source_value in MINISHELL_DENIED_COMMAND_WORDS
|
|
2679
|
+
and all(command_word.active)
|
|
2680
|
+
and not command_word.barriers
|
|
2681
|
+
):
|
|
2682
|
+
return CommandDecision(
|
|
2683
|
+
action="deny",
|
|
2684
|
+
parsed=parsed,
|
|
2685
|
+
reason="MiniShell-v1 rejected an active shell reserved word.",
|
|
2686
|
+
reason_code="reserved_word_denied",
|
|
2687
|
+
)
|
|
2688
|
+
route_argv = segment_argv[route_start:]
|
|
2689
|
+
if not route_argv:
|
|
2690
|
+
return CommandDecision(
|
|
2691
|
+
action="deny",
|
|
2692
|
+
parsed=parsed,
|
|
2693
|
+
reason="Assignment-only input denied (assignment_only_denied).",
|
|
2694
|
+
reason_code="assignment_only_denied",
|
|
2695
|
+
)
|
|
2696
|
+
if _forbidden_command_basename(route_argv):
|
|
2697
|
+
return CommandDecision(
|
|
2698
|
+
action="deny",
|
|
2699
|
+
parsed=parsed,
|
|
2700
|
+
reason="Forbidden command denied (forbidden_command_denied).",
|
|
2701
|
+
reason_code="forbidden_command_denied",
|
|
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
|
+
)
|
|
2716
|
+
if parsed.heredoc_delimiter is not None and (
|
|
2717
|
+
len(parsed.segments) != 1
|
|
2718
|
+
or command_basename(route_argv[0])
|
|
2719
|
+
not in MINISHELL_HEREDOC_STDIN_CONSUMERS
|
|
2720
|
+
):
|
|
2721
|
+
return CommandDecision(
|
|
2722
|
+
action="deny",
|
|
2723
|
+
parsed=parsed,
|
|
2724
|
+
reason="Quoted heredoc consumer denied (heredoc_consumer_denied).",
|
|
2725
|
+
reason_code="heredoc_consumer_denied",
|
|
2726
|
+
)
|
|
2727
|
+
if (
|
|
2728
|
+
len(parsed.segments) > 1
|
|
2729
|
+
and _prefix_overrides_path(segment, route_start)
|
|
2730
|
+
):
|
|
2731
|
+
return CommandDecision(
|
|
2732
|
+
action="deny",
|
|
2733
|
+
parsed=parsed,
|
|
2734
|
+
reason="Pipeline PATH overrides are outside the immutable MiniShell-v1 route allowlist.",
|
|
2735
|
+
reason_code="route_operand_denied",
|
|
2736
|
+
)
|
|
2737
|
+
if _find_command_is_side_effecting(route_argv):
|
|
2738
|
+
return CommandDecision(
|
|
2739
|
+
action="deny",
|
|
2740
|
+
parsed=parsed,
|
|
2741
|
+
reason="Side-effecting find actions are outside the MiniShell-v1 read-only boundary.",
|
|
2742
|
+
reason_code="route_operand_denied",
|
|
2743
|
+
)
|
|
2744
|
+
role = (
|
|
2745
|
+
"standalone"
|
|
2746
|
+
if len(parsed.segments) == 1
|
|
2747
|
+
else ("first" if segment_index == 0 else "filter")
|
|
2748
|
+
)
|
|
2749
|
+
route = command_search_diff(route_argv, role=role)
|
|
2750
|
+
if route == "deny":
|
|
2751
|
+
return CommandDecision(
|
|
2752
|
+
action="deny",
|
|
2753
|
+
parsed=parsed,
|
|
2754
|
+
reason="Command is outside the immutable MiniShell-v1 route allowlist.",
|
|
2755
|
+
reason_code="route_policy_denied",
|
|
2756
|
+
)
|
|
2757
|
+
segment_routes.append(route)
|
|
2758
|
+
|
|
2759
|
+
if len(parsed.segments) == 1:
|
|
2760
|
+
action = segment_routes[0]
|
|
2761
|
+
route_code = {
|
|
2762
|
+
"noop": "noop",
|
|
2763
|
+
"trim": "rewrite_trim",
|
|
2764
|
+
"sanitize": "rewrite_sanitize",
|
|
2765
|
+
}[action]
|
|
2766
|
+
return CommandDecision(action=action, parsed=parsed, route_code=route_code)
|
|
2767
|
+
route = "sanitize" if "sanitize" in segment_routes else "trim"
|
|
2768
|
+
return CommandDecision(
|
|
2769
|
+
action=route,
|
|
2770
|
+
parsed=parsed,
|
|
2771
|
+
route_code=(
|
|
2772
|
+
"rewrite_sanitize" if route == "sanitize" else "rewrite_trim"
|
|
2773
|
+
),
|
|
2774
|
+
)
|
|
2775
|
+
|
|
2776
|
+
|
|
2777
|
+
_SHELL_SAFE_WORD_RE = re.compile(r"^[A-Za-z0-9_@%+=:,./-]+$")
|
|
2778
|
+
|
|
2779
|
+
|
|
2780
|
+
def shell_quote(value: str) -> str:
|
|
2781
|
+
if not value:
|
|
2782
|
+
return "''"
|
|
2783
|
+
if _SHELL_SAFE_WORD_RE.fullmatch(value):
|
|
2784
|
+
return value
|
|
2785
|
+
return "'" + value.replace("'", "'\"'\"'") + "'"
|
|
2786
|
+
|
|
2787
|
+
|
|
2788
|
+
def shell_join(argv: list[str] | tuple[str, ...]) -> str:
|
|
2789
|
+
return " ".join(shell_quote(value) for value in argv)
|
|
2790
|
+
|
|
2791
|
+
|
|
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]
|
|
2961
|
+
return shell_join(wrapped_argv)
|
|
2962
|
+
|
|
2963
|
+
|
|
2964
|
+
def build_sanitized_command(wrapper: str, command: str) -> str:
|
|
2965
|
+
prefix = _isolated_wrapper_prefix(wrapper)
|
|
2966
|
+
wrapped_argv = prefix + [
|
|
2967
|
+
CGW1_SENTINEL,
|
|
2968
|
+
CGW1_COMMAND_SEARCH_DIFF,
|
|
2969
|
+
"--",
|
|
2970
|
+
*_runtime_shell_argv(),
|
|
2971
|
+
command,
|
|
2972
|
+
]
|
|
2973
|
+
return shell_join(wrapped_argv)
|
|
2974
|
+
|
|
2975
|
+
|
|
2976
|
+
def build_updated_input(tool_input: dict[str, object], wrapped: str) -> dict[str, object]:
|
|
2977
|
+
updated_input = copy.deepcopy(tool_input)
|
|
2978
|
+
updated_input["command"] = wrapped
|
|
2979
|
+
return updated_input
|
|
2980
|
+
|
|
2981
|
+
|
|
2982
|
+
def print_updated_command(wrapped: str, tool_input: dict[str, object]) -> None:
|
|
545
2983
|
response = {
|
|
546
2984
|
"hookSpecificOutput": {
|
|
547
2985
|
"hookEventName": "PreToolUse",
|
|
548
|
-
"updatedInput":
|
|
2986
|
+
"updatedInput": build_updated_input(tool_input, wrapped),
|
|
549
2987
|
}
|
|
550
2988
|
}
|
|
551
2989
|
print(json.dumps(response, ensure_ascii=False))
|
|
552
2990
|
|
|
553
2991
|
|
|
554
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
|
|
555
2998
|
if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
|
|
556
2999
|
print("ContextGuard helper: context-guard-rewrite-bash")
|
|
557
3000
|
return 0
|
|
3001
|
+
bash_reference_v1 = BASH_REFERENCE_FLAG in sys.argv[1:]
|
|
558
3002
|
try:
|
|
559
|
-
payload =
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
3003
|
+
payload = load_hook_payload()
|
|
3004
|
+
tool_input = select_tool_input(payload)
|
|
3005
|
+
except HookInputError as exc:
|
|
3006
|
+
deny_invalid_hook_input(exc.reason_code)
|
|
563
3007
|
return 0
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
print("{}")
|
|
3008
|
+
except RecursionError:
|
|
3009
|
+
deny_invalid_hook_input("payload_nesting_too_deep")
|
|
567
3010
|
return 0
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
print("{}")
|
|
3011
|
+
except OSError:
|
|
3012
|
+
deny_invalid_hook_input("input_read_failed")
|
|
571
3013
|
return 0
|
|
572
|
-
command = tool_input
|
|
3014
|
+
command = tool_input["command"]
|
|
3015
|
+
assert isinstance(command, str)
|
|
573
3016
|
|
|
574
|
-
|
|
575
|
-
|
|
3017
|
+
decision = classify_command(command)
|
|
3018
|
+
if decision.action == "deny":
|
|
3019
|
+
deny_boundary(decision.reason or "MiniShell-v1 rejected command.")
|
|
576
3020
|
return 0
|
|
577
|
-
|
|
578
|
-
argv = split_single_safe_command(command)
|
|
579
|
-
if not argv:
|
|
580
|
-
if unparseable_command_needs_sanitizer(command):
|
|
581
|
-
safe_pipeline = split_safe_sanitizer_pipeline(command)
|
|
582
|
-
if safe_pipeline is None:
|
|
583
|
-
deny(
|
|
584
|
-
"Search/diff/log command contains shell operators that are not in ContextGuard's "
|
|
585
|
-
"read-only pipe allowlist. Simplify to a plain pipeline ending in cat/head/tail/wc/sort/uniq, "
|
|
586
|
-
"run context-guard-sanitize-output explicitly after review, or set "
|
|
587
|
-
f"{FAIL_OPEN_ENV}=1 to run unsanitized intentionally."
|
|
588
|
-
)
|
|
589
|
-
return 0
|
|
590
|
-
wrapper = find_wrapper("sanitize")
|
|
591
|
-
if wrapper is None:
|
|
592
|
-
deny(
|
|
593
|
-
"Search/diff/log command blocked because it contains shell operators and "
|
|
594
|
-
"context-guard-sanitize-output is not installed next to context-guard-rewrite-bash. "
|
|
595
|
-
"Install the sanitizer or set "
|
|
596
|
-
f"{FAIL_OPEN_ENV}=1 to run unsanitized intentionally."
|
|
597
|
-
)
|
|
598
|
-
return 0
|
|
599
|
-
print_updated_command(build_sanitized_command(wrapper, command))
|
|
600
|
-
return 0
|
|
3021
|
+
if decision.action == "noop":
|
|
601
3022
|
print_noop()
|
|
602
3023
|
return 0
|
|
603
3024
|
|
|
604
|
-
|
|
605
|
-
# 우연히 wrapper 이름과 일치할 때 false-bypass 를 일으킬 수 있다.
|
|
606
|
-
if is_already_wrapped(argv):
|
|
607
|
-
print("{}")
|
|
608
|
-
return 0
|
|
609
|
-
|
|
610
|
-
if is_noisy_command(argv) or is_dir_traversal_command(argv):
|
|
3025
|
+
if decision.action == "trim":
|
|
611
3026
|
wrapper = find_wrapper("trim")
|
|
612
3027
|
if wrapper is None:
|
|
613
3028
|
deny(
|
|
@@ -616,8 +3031,8 @@ def main() -> int:
|
|
|
616
3031
|
f"{FAIL_OPEN_ENV}=1 to run untrimmed intentionally."
|
|
617
3032
|
)
|
|
618
3033
|
return 0
|
|
619
|
-
wrapped = build_wrapped_command(wrapper, command)
|
|
620
|
-
elif
|
|
3034
|
+
wrapped = build_wrapped_command(wrapper, command, bash_reference_v1=bash_reference_v1)
|
|
3035
|
+
elif decision.action == "sanitize":
|
|
621
3036
|
wrapper = find_wrapper("sanitize")
|
|
622
3037
|
if wrapper is None:
|
|
623
3038
|
reason = (
|
|
@@ -627,12 +3042,30 @@ def main() -> int:
|
|
|
627
3042
|
)
|
|
628
3043
|
deny(reason)
|
|
629
3044
|
return 0
|
|
630
|
-
|
|
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
|
+
)
|
|
631
3062
|
else:
|
|
632
|
-
|
|
633
|
-
return 0
|
|
3063
|
+
raise AssertionError(f"unknown command action: {decision.action}")
|
|
634
3064
|
|
|
635
|
-
|
|
3065
|
+
try:
|
|
3066
|
+
print_updated_command(wrapped, tool_input)
|
|
3067
|
+
except RecursionError:
|
|
3068
|
+
deny_invalid_hook_input("payload_copy_too_deep")
|
|
636
3069
|
return 0
|
|
637
3070
|
|
|
638
3071
|
|