@ictechgy/context-guard 0.4.14 → 0.4.16

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