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