@ictechgy/context-guard 0.4.15 → 0.5.1

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