@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
@@ -10,17 +10,63 @@ from __future__ import annotations
10
10
  import argparse
11
11
  import codecs
12
12
  import collections
13
+ from dataclasses import dataclass
13
14
  import hashlib
15
+ import importlib.util
14
16
  import os
15
- from pathlib import PurePosixPath
17
+ from pathlib import Path, PurePosixPath
16
18
  import queue
17
19
  import re
20
+ import shutil
18
21
  import signal
19
22
  import subprocess
20
23
  import sys
21
24
  import threading
22
25
  import time
23
- from typing import BinaryIO, Iterable, Iterator, TextIO
26
+ from types import ModuleType
27
+ from typing import BinaryIO, Iterable, Iterator, NamedTuple, TextIO
28
+
29
+
30
+ WRAPPER_ENV_DENY = frozenset({
31
+ "BASHOPTS",
32
+ "BASH_ENV",
33
+ "BASH_XTRACEFD",
34
+ "ENV",
35
+ "PS4",
36
+ "PYTHONHOME",
37
+ "PYTHONPATH",
38
+ "PYTHONSTARTUP",
39
+ "SHELLOPTS",
40
+ })
41
+ BASH_FUNCTION_ENV_RE = re.compile(r"^BASH_FUNC_.*%%$")
42
+
43
+
44
+ def load_credential_policy() -> ModuleType:
45
+ script_dir = Path(__file__).resolve().parent
46
+ candidate = (
47
+ script_dir.parent / "lib" / "credential_policy.py"
48
+ if script_dir.name == "bin"
49
+ else script_dir / "credential_policy.py"
50
+ )
51
+ spec = importlib.util.spec_from_file_location(
52
+ "_context_guard_sanitize_credential_policy",
53
+ candidate,
54
+ )
55
+ if spec is None or spec.loader is None:
56
+ raise RuntimeError(f"could not load credential policy: {candidate}")
57
+ module = importlib.util.module_from_spec(spec)
58
+ spec.loader.exec_module(module)
59
+ return module
60
+
61
+
62
+ _CREDENTIAL_POLICY = load_credential_policy()
63
+ SECRET_KEY = _CREDENTIAL_POLICY.SECRET_KEY
64
+ CAMEL_ACRONYM_BOUNDARY_RE = _CREDENTIAL_POLICY.CAMEL_ACRONYM_BOUNDARY_RE
65
+ CAMEL_WORD_BOUNDARY_RE = _CREDENTIAL_POLICY.CAMEL_WORD_BOUNDARY_RE
66
+ normalize_sensitive_key = _CREDENTIAL_POLICY.normalize_sensitive_key
67
+ is_sensitive_key = _CREDENTIAL_POLICY.is_sensitive_key
68
+ redact_url_like_secret_params = _CREDENTIAL_POLICY.redact_url_like_secret_params
69
+ redact_high_confidence_credentials = _CREDENTIAL_POLICY.redact_high_confidence_credentials
24
70
 
25
71
  TERMINAL_CONTROL_RE = re.compile(
26
72
  r"(?:"
@@ -40,6 +86,22 @@ ABSOLUTE_PATH_RE = re.compile(
40
86
  WINDOWS_PATH_RE = re.compile(
41
87
  rf"(?P<prefix>^|[\s('\"=])(?P<path>[A-Za-z]:\\(?:{PATH_SEGMENT}\\)+{PATH_SEGMENT})"
42
88
  )
89
+ TRACEBACK_PATH_RE = re.compile(
90
+ rf"(?P<prefix>\bFile\s+[\"'])(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
91
+ r"(?P<suffix>[\"'],\s+line\s+\d+)"
92
+ )
93
+ LOCATION_PATH_RE = re.compile(
94
+ rf"(?P<prefix>^(?:\s*[+-]\s*)?)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
95
+ r"(?P<suffix>:\d+(?::\d+)?(?=[:\s]|$))"
96
+ )
97
+ DIFF_PATH_RE = re.compile(
98
+ rf"(?P<prefix>^(?:---|\+\+\+)\s+)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
99
+ r"(?P<suffix>(?:\t.*)?$)"
100
+ )
101
+ LISTING_PATH_RE = re.compile(
102
+ rf"(?P<prefix>^\s*)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
103
+ r"(?P<suffix>/?(?:\s+->\s+\S+)?\s*$)"
104
+ )
43
105
  PRIVATE_KEY_BEGIN_RE = re.compile(
44
106
  r"-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----"
45
107
  )
@@ -52,29 +114,30 @@ AUTH_HEADER_RE = re.compile(
52
114
  COOKIE_HEADER_RE = re.compile(
53
115
  r"(?i)^(?P<prefix>\s*(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:Set-)?Cookie\s*:\s*).+$"
54
116
  )
55
- SESSION_SECRET_KEY = (
56
- r"(?:session(?:[_-]?(?:id|token))?|sessionid|sid|jsessionid|"
57
- r"csrf(?:[_-]?token)?|xsrf(?:[_-]?token)?)"
58
- )
59
- SECRET_KEY = (
60
- r"[A-Za-z0-9_.-]*(?:api[_-]?key|apikey|token|secret|password|passwd|pwd|"
61
- r"private[_-]?key|access[_-]?key|client[_-]?secret)[A-Za-z0-9_.-]*"
62
- rf"|{SESSION_SECRET_KEY}"
63
- r"|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|"
64
- r"GOOGLE_APPLICATION_CREDENTIALS|AZURE_CLIENT_SECRET"
65
- )
117
+ # QUOTED_VALUE_BODY의 두 대안은 첫 문자가 서로 겹치지 않아야 한다.
118
+ # 예전 형태 `(?:\\.|(?!(?P=quote)).)*`에서는 역슬래시 하나가 `\\.`의 시작이면서
119
+ # 동시에 두 번째 대안의 `.`이기도 했다. 닫는 따옴표가 없는 줄에서는 역슬래시
120
+ # 연속 구간을 나누는 방법이 피보나치 수만큼 생겨 지수 시간 백트래킹(ReDoS)
121
+ # 발생했다 — 47자짜리 줄이 7.6초를 소모했다.
122
+ # 두 번째 대안에서 역슬래시를 제외해 각 위치에서 적용 가능한 대안이 하나뿐이도록
123
+ # 만든다. 뒤에 붙은 `\\?`는 이 배제로 잃게 되는 유일한 문자열 부류, 즉 값이
124
+ # 짝지어지지 않은 역슬래시 하나로 끝나는 경우(`token = "abc\"` → value `abc\`)를
125
+ # 복원한다. 이 형태가 예전 형태와 언어·스팬·그룹 모두에서 동치임은
126
+ # tests/test_sanitize_output_redos.py의 차분 배터리로 고정한다.
127
+ QUOTED_VALUE_BODY = r"(?:\\.|(?!(?P=quote))[^\\])*\\?"
128
+ SECRET_ASSIGNMENT_SEPARATOR = r"[ \t]*[:=][ \t]*"
66
129
  INLINE_QUOTED_SECRET_ASSIGNMENT_RE = re.compile(
67
130
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
68
131
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
69
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
70
- rf"(?P<quote>[\"'])(?P<value>(?:\\.|(?!(?P=quote)).)*)(?P=quote)(?P<tail>[^\s,;}}\]]*)"
132
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
133
+ rf"(?P<quote>[\"'])(?P<value>{QUOTED_VALUE_BODY})(?P=quote)(?P<tail>[^\s,;}}\]]*)"
71
134
  )
72
135
  CODE_IDENTIFIER = r"[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*"
73
- CALL_ARGUMENT_CHUNK = r"(?:[^()\"'\n;]+|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|\([^()]*\))*"
136
+ CALL_ARGUMENT_CHUNK = r"(?:[^()\"'\n;]|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|\([^()]*\))*"
74
137
  INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE = re.compile(
75
138
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
76
139
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
77
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
140
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
78
141
  rf"(?P<value>(?![\"']){CODE_IDENTIFIER}\({CALL_ARGUMENT_CHUNK}\))"
79
142
  )
80
143
  SECRET_IDENTIFIER_PART = (
@@ -86,7 +149,7 @@ FALLBACK_SECRET_OPERAND = rf"(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*{SECRET_IDENTIFIER_P
86
149
  INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE = re.compile(
87
150
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
88
151
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
89
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
152
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
90
153
  rf"(?P<value>(?![\"']|\[REDACTED\])"
91
154
  rf"[^;\n]*?(?:\bor\b|\|\||\?\?|\belse\b|\?[^:\n;]*:)\s*"
92
155
  rf"(?:[\"'](?:\\.|[^\"'\\])*[\"']|{FALLBACK_SECRET_OPERAND})[^;\n]*)"
@@ -94,43 +157,165 @@ INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE = re.compile(
94
157
  INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE = re.compile(
95
158
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
96
159
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
97
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
160
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
98
161
  rf"(?P<value>(?![\"']|\[REDACTED\])"
99
162
  rf"[^\s,;}}\]]*(?:\([^;\n]*?\)|\{{[^;\n]*?\}}|\[[^;\n]*?\])[^\s,;}}\]]*)"
100
163
  )
101
164
  INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE = re.compile(
102
165
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
103
166
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
104
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
167
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
105
168
  rf"(?P<value>(?![\"']|\[REDACTED\])[^\s,;}}\]]+)"
106
169
  )
170
+ WHITESPACE_SECRET_ASSIGNMENT_PREFIX = (
171
+ rf"(?P<lead>\A)(?P<prefix>[ \t]*(?:[+-][ \t]*)?(?:export[ \t]+)?"
172
+ rf"[\"']?(?:{SECRET_KEY})[\"']?(?![ \t]*[:=])[ \t]+)"
173
+ )
174
+ WHITESPACE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = re.compile(
175
+ rf"(?i){WHITESPACE_SECRET_ASSIGNMENT_PREFIX}"
176
+ rf"(?P<quote>[\"'])(?P<value>{QUOTED_VALUE_BODY})(?P=quote)(?P<tail>[^\s,;}}\]]*)"
177
+ )
178
+ WHITESPACE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = re.compile(
179
+ rf"(?i){WHITESPACE_SECRET_ASSIGNMENT_PREFIX}"
180
+ rf"(?P<value>(?![\"']|\[REDACTED\])\S[^\n]*)"
181
+ )
107
182
  UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE = re.compile(
108
183
  rf"(?i)(?:^|[\s;{{\[,])"
109
184
  rf"(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
110
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*(?P<value>(?![\"']).*)$"
185
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR}(?P<value>(?![\"']).*)$"
186
+ )
187
+ # F-14 one-pass scanner.
188
+ #
189
+ # 아홉 개 consumer 는 모두 아래 fragment 를 각자 품고 있었다. unanchored 패턴에서는
190
+ # offset 마다 `[^:\n]+` 가 다음 콜론까지 삼키고 되돌아오므로, 콜론이 드문 긴 줄에서
191
+ # offset 당 O(n) 작업이 발생해 전체가 이차가 된다(측정: 82KB 한 줄 2.62초,
192
+ # 배가 비율 3.6~3.96). 대신 줄마다 앞머리 location prefix 를 단조 좌→우로 한 번만
193
+ # 확정하고, consumer 에는 fragment 를 제거한 쌍둥이 패턴을 먹인다.
194
+ LOCATION_PREFIX_FRAGMENT = r"(?:(?:[^:\n]+):\d+(?::\d+)?:)?"
195
+ # path 성분에서 '=' 를 제외한다. 허용하면 'api_key=abc:123:456def' 처럼 비밀 값 안에서
196
+ # 줄이 쪼개져 앞부분만 가려지고 뒷부분이 남는 절단 누출이 발생한다. grep/diff 경로에
197
+ # '=' 가 들어가는 경우는 드물고, 그런 줄은 그냥 fast path 를 쓰지 않을 뿐이다.
198
+ # 공백과 유니코드는 계약대로 계속 허용한다.
199
+ LOCATION_PREFIX_SCAN_RE = re.compile(
200
+ r"\A(?P<lead>[ \t]*(?:[+-][ \t]*)?)(?P<location>[^:\n=]+:\d+(?::\d+)?:)"
201
+ )
202
+ # 후보 스팬의 신호는 두 종류이고 처리도 달라야 한다.
203
+ #
204
+ # ASSIGNMENT: 비밀 키나 헤더 이름 뒤에 ':' 또는 '=' 가 오면 그 스팬은 경로가 아니라
205
+ # 값의 일부다. 예: 'token:123456789:AAH-...' 는 앞부분만 가리면 뒷부분이 남는다.
206
+ # 이 경우 분리 자체를 하지 않고 전체 줄을 consumer 에 넘긴다.
207
+ #
208
+ # PATH_ONLY: 따옴표나 private key 표지처럼 경로 자체에 들어갈 수 있는 문자는 후보를
209
+ # 경로로 인정하되, 그 스팬도 함께 검사한다. 예: "src/it's.py:5:api_key='x'".
210
+ LOCATION_PREFIX_ASSIGNMENT_SIGNAL_RE = re.compile(
211
+ rf"(?i)(?:-----BEGIN|(?:Proxy-)?Authorization\s*:|(?:Set-)?Cookie\s*:"
212
+ rf"|[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
213
+ )
214
+ LOCATION_PREFIX_WHITESPACE_ASSIGNMENT_SIGNAL_RE = re.compile(
215
+ rf"(?i)\A[ \t]*(?:[+-][ \t]*)?(?:export[ \t]+)?"
216
+ rf"[\"']?(?:{SECRET_KEY})[\"']?(?![ \t]*[:=])[ \t]+"
217
+ )
218
+ LOCATION_PREFIX_PATH_ONLY_SIGNAL_RE = re.compile(r"[\"']")
219
+ NINE_LOCATION_CONSUMERS = (
220
+ "AUTH_HEADER_RE",
221
+ "COOKIE_HEADER_RE",
222
+ "INLINE_QUOTED_SECRET_ASSIGNMENT_RE",
223
+ "INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE",
224
+ "INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE",
225
+ "INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE",
226
+ "INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE",
227
+ "UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE",
228
+ "MULTILINE_SECRET_ASSIGNMENT_RE",
111
229
  )
230
+
231
+
232
+ def without_location_prefix(compiled: re.Pattern[str]) -> re.Pattern[str]:
233
+ """Return the same consumer with its embedded location prefix removed.
234
+
235
+ Deriving the twin from the compiled source keeps the two spellings from
236
+ drifting: if the fragment ever moves or changes, this fails loudly instead of
237
+ silently leaving a per-offset location scan in place.
238
+ """
239
+ source = compiled.pattern
240
+ if source.count(LOCATION_PREFIX_FRAGMENT) != 1:
241
+ raise RuntimeError(
242
+ "consumer no longer embeds exactly one location prefix fragment"
243
+ )
244
+ return re.compile(source.replace(LOCATION_PREFIX_FRAGMENT, "", 1), compiled.flags)
245
+
246
+
247
+ class ScannedLine(NamedTuple):
248
+ """One monotonic left-to-right split of a line into prefix and remainder.
249
+
250
+ An assignment signal inside the candidate means the span is part of a value
251
+ rather than a path, so no split is reported at all; splitting there would
252
+ redact only the leading part and leave the tail behind. A path-only signal
253
+ such as a quote in a filename keeps the split but sets ``declined``, and
254
+ unanchored consumers then also scan the bounded candidate span.
255
+ """
256
+
257
+ prefix: str
258
+ remainder: str
259
+ scan_index: int
260
+ declined: bool = False
261
+
262
+
263
+ def scan_location_prefix(line: str) -> ScannedLine:
264
+ """Identify a leading grep/diff location prefix in exactly one anchored scan.
265
+
266
+ The scan index only ever moves forward: it is the end of the anchored match,
267
+ or zero when there is no prefix.
268
+
269
+ The path component must keep accepting spaces and Unicode, because real grep
270
+ and diff output carries such filenames. That permissiveness means the
271
+ anchored run can reach past a secret that happens to sit before a later
272
+ ``:<digits>:`` sequence, so the candidate span is checked once for consumer
273
+ signal and flagged when any is present. The check is a single bounded pass, so
274
+ the scan stays linear.
275
+ """
276
+ match = LOCATION_PREFIX_SCAN_RE.match(line)
277
+ if match is None:
278
+ return ScannedLine("", line, 0)
279
+ end = match.end()
280
+ candidate = line[:end]
281
+ if (
282
+ LOCATION_PREFIX_ASSIGNMENT_SIGNAL_RE.search(candidate)
283
+ or LOCATION_PREFIX_WHITESPACE_ASSIGNMENT_SIGNAL_RE.match(candidate)
284
+ ):
285
+ # 값의 일부를 경로로 오인한 경우다. 분리하면 앞부분만 가려져 뒤가 남는다.
286
+ return ScannedLine("", line, 0, False)
287
+ declined = bool(LOCATION_PREFIX_PATH_ONLY_SIGNAL_RE.search(candidate))
288
+ return ScannedLine(candidate, line[end:], end, declined)
289
+
290
+
112
291
  CONTINUATION_OPERATOR_RE = re.compile(
113
292
  r"(?i)(?:\\|\|\||&&|\?\?|[+*/%&|^?,]|\?|:|\bor\b|\band\b|\belse\b)\s*(?://.*|#.*)?$"
114
293
  )
115
- URL_LIKE_RE = re.compile(r"\b[A-Za-z][A-Za-z0-9+.-]*://[^\s]+")
116
- URL_SECRET_PARAM_RE = re.compile(rf"(?i)([?&#;](?:{SECRET_KEY})=)[^\s?&#;]+")
117
294
  SAFE_UNQUOTED_VALUES = {
118
295
  "[redacted]",
296
+ "bool",
297
+ "boolean",
298
+ "bytes",
119
299
  "false",
300
+ "float",
301
+ "int",
302
+ "integer",
120
303
  "none",
121
304
  "null",
305
+ "object",
122
306
  "os.getenv",
123
307
  "process.env",
308
+ "str",
309
+ "string",
124
310
  "true",
125
311
  "undefined",
312
+ "unknown",
126
313
  }
127
314
  IDENTIFIER_CHAIN_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$")
128
315
  SAFE_ENV_LOOKUP_CALL_RE = re.compile(r"^(?:os\.getenv|os\.environ\.get)\(\s*[\"'][A-Za-z0-9_.-]{1,80}[\"']\s*\)$")
129
316
  SAFE_RE_COMPILE_CALL_RE = re.compile(r"^re\.compile\([^;\n]*\)$")
130
317
  SAFE_CODE_EXPRESSION_CALL_RE = re.compile(rf"^{CODE_IDENTIFIER}\(\s*(?:{CODE_IDENTIFIER}(?:\s*,\s*{CODE_IDENTIFIER})*)?\s*\)$")
131
318
  GETTER_CALL_RE = re.compile(rf"^{CODE_IDENTIFIER}\.get\(\s*[\"'](?P<key>[A-Za-z0-9_.-]{{1,80}})[\"']\s*\)$")
132
- CAMEL_ACRONYM_BOUNDARY_RE = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])")
133
- CAMEL_WORD_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
134
319
  SAFE_GETTER_KEY_NAMES = {
135
320
  "access_key",
136
321
  "access_token",
@@ -162,26 +347,43 @@ SAFE_GETTER_KEY_NAMES = {
162
347
  "sid",
163
348
  "token",
164
349
  }
165
- INLINE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
166
- (re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+"), "[REDACTED]"),
167
- (re.compile(r"(?i)\bBasic\s+[A-Za-z0-9._~+/=-]+"), "[REDACTED]"),
168
- (re.compile(rf"(?i)([?&#](?:{SECRET_KEY})=)[^\s&#;]+"), r"\1[REDACTED]"),
169
- (re.compile(r"(?i)(--(?:api[_-]?key|token|secret|password|client[_-]?secret)\s+)\S+"), r"\1[REDACTED]"),
170
- (re.compile(r"(?i)(--(?:api[_-]?key|token|secret|password|client[_-]?secret)=)\S+"), r"\1[REDACTED]"),
171
- (re.compile(r"(?i)((?:-p|-u|--user)\s+)\S+:\S+"), r"\1[REDACTED]"),
172
- (re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"), "[REDACTED]"),
173
- (re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "[REDACTED]"),
174
- (re.compile(r"glpat-[A-Za-z0-9_-]{12,}"), "[REDACTED]"),
175
- (re.compile(r"xox[abprs]-[A-Za-z0-9-]{10,}"), "[REDACTED]"),
176
- (re.compile(r"(?:AKIA|ASIA)[0-9A-Z]{16}"), "[REDACTED]"),
177
- (re.compile(r"(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}"), "[REDACTED]"),
178
- (re.compile(r"sk-(?:ant|proj)-[A-Za-z0-9_-]{12,}"), "[REDACTED]"),
179
- (re.compile(r"sk-[A-Za-z0-9][A-Za-z0-9_-]{20,}"), "[REDACTED]"),
180
- (re.compile(r"npm_[A-Za-z0-9]{20,}"), "[REDACTED]"),
181
- (re.compile(r"AIza[0-9A-Za-z_\-]{20,}"), "[REDACTED]"),
182
- (re.compile(r"SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"), "[REDACTED]"),
183
- (re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), "[REDACTED]"),
184
- (re.compile(r"([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@", re.IGNORECASE), r"\1[REDACTED]@"),
350
+ AMBIGUOUS_BARE_WHITESPACE_KEYS = frozenset(
351
+ {
352
+ "auth",
353
+ "authorization",
354
+ "cookie",
355
+ "credential",
356
+ "credentials",
357
+ "csrf",
358
+ "jwt",
359
+ "pass",
360
+ "password",
361
+ "passwd",
362
+ "pwd",
363
+ "secret",
364
+ "session",
365
+ "sid",
366
+ "sig",
367
+ "signature",
368
+ "token",
369
+ "xsrf",
370
+ }
371
+ )
372
+ ASSIGNMENT_KEY_RE = re.compile(
373
+ rf"(?P<key>[A-Za-z_$][A-Za-z0-9_$.-]*)[\"']?{SECRET_ASSIGNMENT_SEPARATOR}$"
374
+ )
375
+ WHITESPACE_ASSIGNMENT_KEY_RE = re.compile(
376
+ r"(?P<key>[A-Za-z_$][A-Za-z0-9_$.-]*)[\"']?[ \t]+$"
377
+ )
378
+ SOURCE_SAFE_VALUE_RE = re.compile(
379
+ r"^(?:"
380
+ r"[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*|"
381
+ r"[A-Za-z_$][A-Za-z0-9_$]*(?:\[[A-Za-z0-9_$., |]+\])+|"
382
+ r"\d+(?:\.\d+)?|"
383
+ r"\$\{[A-Za-z0-9_.-]+\}|"
384
+ r"<[A-Za-z0-9_.-]+>|"
385
+ r"(?:YOUR|REPLACE|EXAMPLE|PLACEHOLDER)_[A-Z0-9_]+"
386
+ r")$"
185
387
  )
186
388
  ANCHOR_RE = re.compile(
187
389
  r"^(?:diff --git |index [0-9a-f]|--- |\+\+\+ |@@ |Binary files |(?:[^:\n]+):\d+(?::\d+)?:)",
@@ -200,6 +402,55 @@ COMMAND_MAX_UNTERMINATED_LINE_CHARS = 4_096
200
402
  RAW_TRUNCATION_REDACTION_HOLDBACK_CHARS = 1_024
201
403
 
202
404
 
405
+ SANITIZATION_MODES = (
406
+ "unknown_text",
407
+ "command_search_diff",
408
+ "filesystem_listing",
409
+ "source_code",
410
+ )
411
+
412
+
413
+ @dataclass(frozen=True)
414
+ class SanitizationContext:
415
+ mode: str = "unknown_text"
416
+ private_roots: tuple[str, ...] = ()
417
+
418
+ def __post_init__(self) -> None:
419
+ if self.mode not in SANITIZATION_MODES:
420
+ raise ValueError(f"unsupported sanitization context: {self.mode}")
421
+ object.__setattr__(
422
+ self,
423
+ "private_roots",
424
+ tuple(str(root) for root in self.private_roots),
425
+ )
426
+
427
+
428
+ def coerce_sanitization_context(
429
+ value: SanitizationContext | str,
430
+ *,
431
+ private_roots: Iterable[str] = (),
432
+ ) -> SanitizationContext:
433
+ if isinstance(value, SanitizationContext):
434
+ supplied_roots = tuple(str(root) for root in private_roots)
435
+ normalized_context_roots = _normalized_private_roots(value.private_roots)
436
+ if supplied_roots and _normalized_private_roots(
437
+ supplied_roots
438
+ ) != normalized_context_roots:
439
+ raise ValueError(
440
+ "private_roots must be declared inside SanitizationContext "
441
+ "when a context object is supplied"
442
+ )
443
+ roots = normalized_context_roots
444
+ mode = value.mode
445
+ else:
446
+ roots = tuple(str(root) for root in private_roots)
447
+ mode = str(value)
448
+ return SanitizationContext(
449
+ mode=mode,
450
+ private_roots=_normalized_private_roots(roots),
451
+ )
452
+
453
+
203
454
  def bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
204
455
  try:
205
456
  number = int(value)
@@ -231,16 +482,98 @@ def stable_hash(value: str, length: int = 12) -> str:
231
482
  return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()[:length]
232
483
 
233
484
 
234
- def anonymize_absolute_paths(text: str) -> str:
485
+ def anonymized_path(path: str) -> str:
486
+ normalized = path.replace("\\", "/")
487
+ name = PurePosixPath(normalized).name or "path"
488
+ return f"{name}#path:{stable_hash(path)}"
489
+
490
+
491
+ def anonymize_absolute_paths_with_count(text: str) -> tuple[str, int]:
492
+ count = 0
493
+
235
494
  def repl(match: re.Match[str]) -> str:
495
+ nonlocal count
236
496
  prefix = match.group("prefix")
237
497
  path = match.group("path")
238
- normalized = path.replace("\\", "/")
239
- name = PurePosixPath(normalized).name or "path"
240
- return f"{prefix}{name}#path:{stable_hash(path)}"
498
+ count += 1
499
+ return f"{prefix}{anonymized_path(path)}"
241
500
 
242
501
  text = ABSOLUTE_PATH_RE.sub(repl, text)
243
- return WINDOWS_PATH_RE.sub(repl, text)
502
+ return WINDOWS_PATH_RE.sub(repl, text), count
503
+
504
+
505
+ def anonymize_absolute_paths(text: str) -> str:
506
+ return anonymize_absolute_paths_with_count(text)[0]
507
+
508
+
509
+ def _replace_named_paths(text: str, pattern: re.Pattern[str]) -> tuple[str, int]:
510
+ count = 0
511
+
512
+ def repl(match: re.Match[str]) -> str:
513
+ nonlocal count
514
+ count += 1
515
+ return (
516
+ match.group("prefix")
517
+ + anonymized_path(match.group("path"))
518
+ + match.group("suffix")
519
+ )
520
+
521
+ return pattern.sub(repl, text), count
522
+
523
+
524
+ def _normalized_private_roots(private_roots: Iterable[str]) -> tuple[str, ...]:
525
+ normalized: list[str] = []
526
+ for root in private_roots:
527
+ value = os.path.normpath(os.path.abspath(os.path.expanduser(str(root))))
528
+ if value not in normalized:
529
+ normalized.append(value)
530
+ return tuple(normalized)
531
+
532
+
533
+ def _is_under_private_root(path: str, private_roots: tuple[str, ...]) -> bool:
534
+ normalized = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
535
+ return any(
536
+ normalized == root or normalized.startswith(root.rstrip(os.sep) + os.sep)
537
+ for root in private_roots
538
+ )
539
+
540
+
541
+ def anonymize_private_root_paths(
542
+ text: str,
543
+ private_roots: tuple[str, ...],
544
+ ) -> tuple[str, int]:
545
+ if not private_roots:
546
+ return text, 0
547
+ count = 0
548
+
549
+ def repl(match: re.Match[str]) -> str:
550
+ nonlocal count
551
+ path = match.group("path")
552
+ if not _is_under_private_root(path, private_roots):
553
+ return match.group(0)
554
+ count += 1
555
+ return match.group("prefix") + anonymized_path(path)
556
+
557
+ return ABSOLUTE_PATH_RE.sub(repl, text), count
558
+
559
+
560
+ def anonymize_paths_for_context(
561
+ text: str,
562
+ *,
563
+ context: SanitizationContext,
564
+ ) -> tuple[str, int]:
565
+ if context.mode in {"unknown_text", "source_code"}:
566
+ return text, 0
567
+
568
+ total = 0
569
+ if context.mode == "filesystem_listing":
570
+ text, count = anonymize_private_root_paths(text, context.private_roots)
571
+ total += count
572
+
573
+ for pattern in (TRACEBACK_PATH_RE, LOCATION_PATH_RE, DIFF_PATH_RE):
574
+ text, count = _replace_named_paths(text, pattern)
575
+ total += count
576
+ return text, total
244
577
 
245
578
 
246
579
  def cap_line(line: str, max_line_chars: int) -> tuple[str, bool]:
@@ -260,21 +593,56 @@ def normalize_getter_key(key: str) -> str:
260
593
  return re.sub(r"_+", "_", key).strip("_").lower()
261
594
 
262
595
 
596
+ def assignment_key(prefix: str) -> str | None:
597
+ for pattern in (ASSIGNMENT_KEY_RE, WHITESPACE_ASSIGNMENT_KEY_RE):
598
+ match = pattern.search(prefix)
599
+ if match is not None:
600
+ return match.group("key")
601
+ return None
602
+
603
+
263
604
  def is_safe_getter_key(key: str) -> bool:
264
605
  return normalize_getter_key(key) in SAFE_GETTER_KEY_NAMES
265
606
 
266
607
 
267
- def should_redact_unquoted_secret_value(line: str, match: re.Match[str]) -> bool:
608
+ def is_ambiguous_bare_whitespace_key(key: str) -> bool:
609
+ return normalize_sensitive_key(key.strip().strip("\"'")) in AMBIGUOUS_BARE_WHITESPACE_KEYS
610
+
611
+
612
+ def should_redact_whitespace_secret_value(key: str, value: str) -> bool:
613
+ stripped_key = key.strip().strip("\"'")
614
+ normalized_key = normalize_sensitive_key(stripped_key)
615
+ if normalized_key == "pass" and stripped_key != "pass":
616
+ return False
617
+ return not (
618
+ normalized_key in AMBIGUOUS_BARE_WHITESPACE_KEYS
619
+ and re.search(r"[ \t]", value.strip()) is not None
620
+ )
621
+
622
+
623
+ def should_redact_unquoted_secret_value(
624
+ line: str,
625
+ match: re.Match[str],
626
+ *,
627
+ context: SanitizationContext,
628
+ ) -> bool:
268
629
  value = match.group("value").strip()
269
630
  prefix = match.group("prefix")
270
631
  if not value:
271
632
  return False
272
633
  if value.lower() in SAFE_UNQUOTED_VALUES:
273
634
  return False
635
+ if re.search(r":\s*$", prefix):
636
+ return not (
637
+ context.mode == "source_code"
638
+ and SOURCE_SAFE_VALUE_RE.match(value) is not None
639
+ )
274
640
  if IDENTIFIER_CHAIN_RE.match(value):
275
641
  return False
276
642
  if SAFE_ENV_LOOKUP_CALL_RE.match(value) or SAFE_RE_COMPILE_CALL_RE.match(value):
277
643
  return False
644
+ if context.mode == "source_code" and SOURCE_SAFE_VALUE_RE.match(value):
645
+ return False
278
646
  getter_match = GETTER_CALL_RE.match(value)
279
647
  if re.search(r"\s[:=]\s*$", prefix) and (
280
648
  SAFE_CODE_EXPRESSION_CALL_RE.match(value)
@@ -284,45 +652,121 @@ def should_redact_unquoted_secret_value(line: str, match: re.Match[str]) -> bool
284
652
  return True
285
653
 
286
654
 
287
- def redact_url_like_secret_params(line: str) -> tuple[str, bool]:
288
- redacted = False
289
-
290
- def url_repl(match: re.Match[str]) -> str:
291
- nonlocal redacted
292
- url, count = URL_SECRET_PARAM_RE.subn(r"\1[REDACTED]", match.group(0))
293
- if count:
294
- redacted = True
295
- return url
296
-
297
- return URL_LIKE_RE.sub(url_repl, line), redacted
298
-
299
-
300
- def redact_secret_assignments(line: str) -> tuple[str, bool]:
655
+ def redact_secret_assignments(
656
+ line: str,
657
+ *,
658
+ context: SanitizationContext,
659
+ ) -> tuple[str, bool]:
660
+ """Redact inline secret assignments using the no-location consumer twins.
661
+
662
+ The leading grep/diff location prefix is identified once by the scanner and
663
+ reattached untouched, so no consumer re-parses it at every offset.
664
+ """
665
+ # URL 형태 비밀 파라미터는 분리 전 전체 줄에서 처리한다. prefix 안에 들어 있어도
666
+ # 가려지지 않고 다시 붙는 일이 없어야 한다.
301
667
  line, redacted = redact_url_like_secret_params(line)
668
+ # 반드시 URL 치환 이후의 문자열에서 다시 스캔한다. 호출자가 미리 계산한 split 을
669
+ # 받아 쓰면 치환으로 길이가 바뀐 문자열에 낡은 오프셋을 적용해 내용이 유실된다.
670
+ scan = scan_location_prefix(line)
671
+ location_prefix = scan.prefix
672
+ line = scan.remainder
302
673
 
303
674
  def quoted_repl(match: re.Match[str]) -> str:
304
675
  nonlocal redacted
676
+ key = assignment_key(match.group("prefix"))
677
+ if key is None or not is_sensitive_key(key):
678
+ return match.group(0)
305
679
  redacted = True
306
680
  return f"{match.group('lead')}{match.group('prefix')}{match.group('quote')}[REDACTED]{match.group('quote')}"
307
681
 
308
- def unquoted_repl(match: re.Match[str]) -> str:
682
+ def unquoted_repl(
683
+ match: re.Match[str],
684
+ *,
685
+ whitespace_separator: bool = False,
686
+ ) -> str:
309
687
  nonlocal redacted
310
- if not should_redact_unquoted_secret_value(line, match):
688
+ key = assignment_key(match.group("prefix"))
689
+ if key is None or not is_sensitive_key(key):
690
+ return match.group(0)
691
+ if whitespace_separator and not should_redact_whitespace_secret_value(
692
+ key,
693
+ match.group("value"),
694
+ ):
695
+ return match.group(0)
696
+ if not should_redact_unquoted_secret_value(line, match, context=context):
311
697
  return match.group(0)
312
698
  redacted = True
313
699
  return f"{match.group('lead')}{match.group('prefix')}[REDACTED]"
314
700
 
315
- line = INLINE_QUOTED_SECRET_ASSIGNMENT_RE.sub(quoted_repl, line)
316
- line = INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE.sub(unquoted_repl, line)
317
- line = INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE.sub(unquoted_repl, line)
318
- line = INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE.sub(unquoted_repl, line)
319
- line = INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE.sub(unquoted_repl, line)
320
- return line, redacted
701
+ def apply(span: str) -> str:
702
+ # 언제나 fragment 를 제거한 쌍둥이만 쓴다. 원본으로 되돌아가면 offset 마다
703
+ # location 다시 파싱해 이차 비용이 되살아난다.
704
+ span = INLINE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(quoted_repl, span)
705
+ span = INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
706
+ span = INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
707
+ span = INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
708
+ span = WHITESPACE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(quoted_repl, span)
709
+ span = WHITESPACE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(
710
+ lambda match: unquoted_repl(match, whitespace_separator=True),
711
+ span,
712
+ )
713
+ span = INLINE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
714
+ return span
715
+
716
+ # 후보 스팬이 consumer 신호를 품었을 때만 그 스팬도 검사한다. 두 스팬 모두 길이가
717
+ # 유계이므로 전체는 선형으로 남는다.
718
+ if scan.declined:
719
+ location_prefix = apply(location_prefix)
720
+ return location_prefix + apply(line), redacted
321
721
 
322
722
 
323
723
  MULTILINE_SECRET_ASSIGNMENT_RE = re.compile(
324
724
  rf"(?i)(?:^|[\s;{{\[,])(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
325
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*(?P<quote>[\"'])"
725
+ rf"[\"']?(?P<key>{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR}(?P<quote>[\"'])"
726
+ )
727
+ WHITESPACE_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = re.compile(
728
+ rf"(?i)\A[ \t]*(?:[+-][ \t]*)?(?:export[ \t]+)?"
729
+ rf"[\"']?(?P<key>{SECRET_KEY})[\"']?(?![ \t]*[:=])[ \t]+(?P<quote>[\"'])"
730
+ )
731
+ WHITESPACE_UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = re.compile(
732
+ rf"(?i)\A[ \t]*(?:[+-][ \t]*)?(?:export[ \t]+)?"
733
+ rf"[\"']?(?:{SECRET_KEY})[\"']?(?![ \t]*[:=])[ \t]+(?P<value>(?![\"']).*)$"
734
+ )
735
+
736
+
737
+ # fragment 를 제거한 일곱 쌍둥이. 이차 비용은 unanchored consumer 에서만 발생하므로
738
+ # 그 일곱 개만 쌍둥이로 돌린다. ^ 로 고정된 헤더 두 개는 fragment 를 한 위치에서만
739
+ # 시도하니 원본을 그대로 쓰고, 스캐너 없이 스스로 prefix 를 건너뛴다.
740
+ UNANCHORED_LOCATION_CONSUMERS = (
741
+ "INLINE_QUOTED_SECRET_ASSIGNMENT_RE",
742
+ "INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE",
743
+ "INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE",
744
+ "INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE",
745
+ "INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE",
746
+ "UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE",
747
+ "MULTILINE_SECRET_ASSIGNMENT_RE",
748
+ )
749
+ ANCHORED_LOCATION_CONSUMERS = ("AUTH_HEADER_RE", "COOKIE_HEADER_RE")
750
+ INLINE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
751
+ INLINE_QUOTED_SECRET_ASSIGNMENT_RE
752
+ )
753
+ INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
754
+ INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE
755
+ )
756
+ INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
757
+ INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE
758
+ )
759
+ INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
760
+ INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE
761
+ )
762
+ INLINE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
763
+ INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE
764
+ )
765
+ UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
766
+ UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE
767
+ )
768
+ MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
769
+ MULTILINE_SECRET_ASSIGNMENT_RE
326
770
  )
327
771
 
328
772
 
@@ -347,11 +791,30 @@ def has_unescaped_quote(text: str, quote: str, start: int = 0) -> bool:
347
791
 
348
792
 
349
793
  def detect_multiline_secret_assignment(line: str) -> str | None:
350
- """Return the quote delimiter when any secret assignment starts a multiline value."""
351
- for marker in MULTILINE_SECRET_ASSIGNMENT_RE.finditer(line):
352
- quote = marker.group("quote")
353
- if not has_unescaped_quote(line, quote, marker.end("quote")):
354
- return quote
794
+ """Return the quote delimiter when any secret assignment starts a multiline value.
795
+
796
+ The leading location prefix is removed by the one-pass scanner first, so the
797
+ detector never re-parses it. Quote balancing still runs against the same
798
+ remainder the detector matched in, which keeps offsets consistent.
799
+ """
800
+ scan = scan_location_prefix(line)
801
+ # 전체 줄을 먼저 본다: $ 와 줄 끝 의미가 기준선과 같아야 한다. 그 다음 remainder 를
802
+ # 보아 location prefix 바로 뒤에 붙은 키(콜론 직후)까지 잡는다. 둘 다 한 번씩이라 선형.
803
+ spans = (line, scan.remainder) if scan.prefix else (line,)
804
+ for span in spans:
805
+ for pattern, whitespace_separator in (
806
+ (MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE, False),
807
+ (WHITESPACE_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE, True),
808
+ ):
809
+ for marker in pattern.finditer(span):
810
+ key = marker.group("key")
811
+ if not is_sensitive_key(key):
812
+ continue
813
+ if whitespace_separator and is_ambiguous_bare_whitespace_key(key):
814
+ continue
815
+ quote = marker.group("quote")
816
+ if not has_unescaped_quote(span, quote, marker.end("quote")):
817
+ return quote
355
818
  return None
356
819
 
357
820
 
@@ -382,9 +845,30 @@ def ends_with_continuation_operator(text: str) -> bool:
382
845
 
383
846
 
384
847
  def detect_multiline_secret_expression(line: str) -> int | None:
385
- marker = UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE.search(line)
848
+ # 스캐너가 앞머리 location prefix 를 먼저 떼어내므로 detector 는 그것을 재파싱하지 않는다.
849
+ # prefix 슬라이싱과 offset 이 어긋나지 않도록 이후 계산도 같은 remainder 위에서 한다.
850
+ scan = scan_location_prefix(line)
851
+ spans = (line, scan.remainder) if scan.prefix else (line,)
852
+ marker = None
853
+ for span in spans:
854
+ for pattern, whitespace_separator in (
855
+ (UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE, False),
856
+ (WHITESPACE_UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE, True),
857
+ ):
858
+ marker = pattern.search(span)
859
+ if marker is not None:
860
+ line = span
861
+ break
862
+ if marker is not None:
863
+ break
386
864
  if marker is None:
387
865
  return None
866
+ prefix = line[: marker.start("value")]
867
+ key = assignment_key(prefix)
868
+ if key is None or not is_sensitive_key(key):
869
+ return None
870
+ if whitespace_separator and is_ambiguous_bare_whitespace_key(key):
871
+ return None
388
872
  value = marker.group("value").strip()
389
873
  if not value:
390
874
  return 0
@@ -419,12 +903,23 @@ def secret_or_private_key_redaction_label(line: str) -> str:
419
903
 
420
904
 
421
905
  class LineSanitizer:
422
- def __init__(self, *, show_paths: bool = False) -> None:
906
+ def __init__(
907
+ self,
908
+ *,
909
+ show_paths: bool = False,
910
+ context: SanitizationContext | str = "unknown_text",
911
+ private_roots: Iterable[str] = (),
912
+ ) -> None:
423
913
  self.show_paths = show_paths
914
+ self.context = coerce_sanitization_context(
915
+ context,
916
+ private_roots=private_roots,
917
+ )
424
918
  self.in_private_key_block = False
425
919
  self.multiline_secret_quote: str | None = None
426
920
  self.multiline_secret_expression_depth: int | None = None
427
921
  self.redactions = 0
922
+ self.path_redactions = 0
428
923
 
429
924
  def sanitize(self, raw_line: str) -> tuple[str, bool]:
430
925
  line = strip_ansi(raw_line)
@@ -481,6 +976,11 @@ class LineSanitizer:
481
976
  self.multiline_secret_expression_depth = expression_depth
482
977
  return self._finish(diff_prefix + "[REDACTED MULTILINE SECRET]\n", True)
483
978
 
979
+ # 헤더 두 consumer 는 ^ 로 고정되어 있으므로 스캐너가 떼어낸 remainder 에 적용하고
980
+ # 앞머리 prefix 를 손대지 않은 채 다시 붙인다. 바이트 재구성은 정확해야 한다.
981
+ # 헤더 두 consumer 는 ^ 로 고정되어 있어 location fragment 를 단 한 위치에서만
982
+ # 시도한다. 즉 이차 비용의 원인이 아니므로 원본 패턴을 그대로 쓰고, 자신이
983
+ # 앞머리 prefix 를 건너뛰게 둔다. 스캐너는 unanchored consumer 만 돕는다.
484
984
  new_line, count = AUTH_HEADER_RE.subn(r"\g<prefix>[REDACTED]", line)
485
985
  if count:
486
986
  redacted = True
@@ -491,22 +991,30 @@ class LineSanitizer:
491
991
  redacted = True
492
992
  line = new_line
493
993
 
494
- line, assignment_redacted = redact_secret_assignments(line)
994
+ line, assignment_redacted = redact_secret_assignments(
995
+ line,
996
+ context=self.context,
997
+ )
495
998
  if assignment_redacted:
496
999
  redacted = True
497
1000
 
498
- for pattern, replacement in INLINE_PATTERNS:
499
- line, count = pattern.subn(replacement, line)
500
- if count:
501
- redacted = True
1001
+ line, credential_redactions = redact_high_confidence_credentials(line)
1002
+ if credential_redactions:
1003
+ redacted = True
502
1004
 
503
1005
  return self._finish(line, redacted)
504
1006
 
505
1007
  def _finish(self, line: str, redacted: bool) -> tuple[str, bool]:
1008
+ if not self.show_paths:
1009
+ line, path_redactions = anonymize_paths_for_context(
1010
+ line,
1011
+ context=self.context,
1012
+ )
1013
+ if path_redactions:
1014
+ self.path_redactions += path_redactions
1015
+ redacted = True
506
1016
  if redacted:
507
1017
  self.redactions += 1
508
- if not self.show_paths:
509
- line = anonymize_absolute_paths(line)
510
1018
  return line, redacted
511
1019
 
512
1020
 
@@ -600,7 +1108,11 @@ class BoundedOutput:
600
1108
 
601
1109
 
602
1110
  def sanitize_stream(stream: Iterable[str], args: argparse.Namespace) -> tuple[str, int, int]:
603
- sanitizer = LineSanitizer(show_paths=args.show_paths)
1111
+ sanitizer = LineSanitizer(
1112
+ show_paths=args.show_paths,
1113
+ context=getattr(args, "context", "unknown_text"),
1114
+ private_roots=getattr(args, "private_root", ()),
1115
+ )
604
1116
  bounded = BoundedOutput(
605
1117
  max_lines=args.max_lines,
606
1118
  max_chars=args.max_chars,
@@ -836,6 +1348,7 @@ def run_command(
836
1348
  timeout_seconds: int,
837
1349
  *,
838
1350
  max_line_chars: int = MAX_LINE_CHARS_LIMIT,
1351
+ environment: dict[str, str] | None = None,
839
1352
  ) -> tuple[Iterable[str], subprocess.Popen[bytes] | None, int | None]:
840
1353
  popen_kwargs: dict[str, object] = {}
841
1354
  if os.name != "nt":
@@ -843,6 +1356,7 @@ def run_command(
843
1356
  try:
844
1357
  proc = subprocess.Popen(
845
1358
  command,
1359
+ env=environment,
846
1360
  stdout=subprocess.PIPE,
847
1361
  stderr=subprocess.STDOUT,
848
1362
  text=False,
@@ -868,10 +1382,45 @@ def run_command(
868
1382
  )
869
1383
 
870
1384
 
1385
+ def sanitized_wrapper_environment() -> dict[str, str]:
1386
+ environment = os.environ.copy()
1387
+ for name in tuple(environment):
1388
+ if name in WRAPPER_ENV_DENY or BASH_FUNCTION_ENV_RE.fullmatch(name):
1389
+ environment.pop(name, None)
1390
+ return environment
1391
+
1392
+
871
1393
  def stdin_has_data(stdin: TextIO) -> bool:
872
1394
  return not stdin.isatty()
873
1395
 
874
1396
 
1397
+ def command_uses_search_diff_output(command: list[str]) -> bool:
1398
+ if not command:
1399
+ return False
1400
+ executable = os.path.basename(command[0])
1401
+ if executable in {"grep", "rg"}:
1402
+ return True
1403
+ if executable != "git":
1404
+ return False
1405
+ value_options = {"-C", "-c", "--git-dir", "--work-tree", "--namespace"}
1406
+ skip_next = False
1407
+ for arg in command[1:]:
1408
+ if skip_next:
1409
+ skip_next = False
1410
+ continue
1411
+ if arg == "--":
1412
+ break
1413
+ if arg in value_options:
1414
+ skip_next = True
1415
+ continue
1416
+ if any(arg.startswith(option + "=") for option in value_options if option.startswith("--")):
1417
+ continue
1418
+ if arg.startswith("-"):
1419
+ continue
1420
+ return arg in {"diff", "grep", "log", "show"}
1421
+ return False
1422
+
1423
+
875
1424
  def build_parser() -> argparse.ArgumentParser:
876
1425
  parser = argparse.ArgumentParser(
877
1426
  description="Redact secrets and budget grep/diff/log output before sending it to Claude."
@@ -896,6 +1445,30 @@ def build_parser() -> argparse.ArgumentParser:
896
1445
  action="store_true",
897
1446
  help="show raw absolute paths instead of basename#path:<hash>; local debugging only because private paths may be exposed",
898
1447
  )
1448
+ parser.add_argument(
1449
+ "--context",
1450
+ choices=SANITIZATION_MODES,
1451
+ default="unknown_text",
1452
+ help=(
1453
+ "sanitization origin policy (default: unknown_text); path-aware modes "
1454
+ "only anonymize structurally proven locations"
1455
+ ),
1456
+ )
1457
+ parser.add_argument(
1458
+ "--private-root",
1459
+ action="append",
1460
+ default=[],
1461
+ help=(
1462
+ "private root eligible for path anonymization in filesystem_listing "
1463
+ "mode; may be repeated"
1464
+ ),
1465
+ )
1466
+ parser.add_argument(
1467
+ "--context-guard-wrapper-v1",
1468
+ choices=("command_search_diff",),
1469
+ dest="wrapper_context",
1470
+ help=argparse.SUPPRESS,
1471
+ )
899
1472
  parser.add_argument("command", nargs=argparse.REMAINDER)
900
1473
  return parser
901
1474
 
@@ -907,6 +1480,60 @@ def main() -> int:
907
1480
  command = args.command
908
1481
  if command and command[0] == "--":
909
1482
  command = command[1:]
1483
+ command_environment: dict[str, str] | None = None
1484
+ if args.wrapper_context is not None:
1485
+ env_runtime = shutil.which("env", path=os.defpath)
1486
+ bash_runtime = shutil.which("bash", path=os.defpath)
1487
+ trusted_prefix = [
1488
+ os.path.realpath(env_runtime) if env_runtime else "",
1489
+ "-u", "BASH_ENV",
1490
+ "-u", "ENV",
1491
+ "-u", "PYTHONHOME",
1492
+ "-u", "PYTHONPATH",
1493
+ "-u", "PYTHONSTARTUP",
1494
+ "-u", "SHELLOPTS",
1495
+ "-u", "BASHOPTS",
1496
+ "-u", "PS4",
1497
+ os.path.realpath(bash_runtime) if bash_runtime else "",
1498
+ "--noprofile",
1499
+ "--norc",
1500
+ "-p",
1501
+ "-c",
1502
+ ]
1503
+ legacy_shape = len(command) == 3 and command[0:2] == ["bash", "-c"] and bool(command[2])
1504
+ trusted_shape = (
1505
+ bool(env_runtime)
1506
+ and bool(bash_runtime)
1507
+ and len(command) == len(trusted_prefix) + 1
1508
+ and command[:-1] == trusted_prefix
1509
+ and bool(command[-1])
1510
+ )
1511
+ if not legacy_shape and not trusted_shape:
1512
+ print(
1513
+ "context-guard-sanitize-output: invalid context-guard wrapper v1 shape",
1514
+ file=sys.stderr,
1515
+ )
1516
+ return 2
1517
+ if legacy_shape:
1518
+ command = [*trusted_prefix, command[2]]
1519
+ command_environment = sanitized_wrapper_environment()
1520
+ context = coerce_sanitization_context(
1521
+ args.wrapper_context,
1522
+ private_roots=args.private_root,
1523
+ )
1524
+ else:
1525
+ context = coerce_sanitization_context(
1526
+ args.context,
1527
+ private_roots=args.private_root,
1528
+ )
1529
+ if (
1530
+ context.mode == "unknown_text"
1531
+ and command_uses_search_diff_output(command)
1532
+ ):
1533
+ context = SanitizationContext(
1534
+ mode="command_search_diff",
1535
+ private_roots=context.private_roots,
1536
+ )
910
1537
 
911
1538
  proc: subprocess.Popen[bytes] | None = None
912
1539
  command_stream: TimedCommandStream | None = None
@@ -916,6 +1543,7 @@ def main() -> int:
916
1543
  command,
917
1544
  args.timeout_seconds,
918
1545
  max_line_chars=COMMAND_MAX_UNTERMINATED_LINE_CHARS,
1546
+ environment=command_environment,
919
1547
  )
920
1548
  if isinstance(stream, TimedCommandStream):
921
1549
  command_stream = stream
@@ -927,14 +1555,17 @@ def main() -> int:
927
1555
  print("context-guard-sanitize-output: missing command or stdin", file=sys.stderr)
928
1556
  return 2
929
1557
 
1558
+ args.context = context
930
1559
  output, _redactions, _line_count = sanitize_stream(stream, args)
931
1560
  rc: int | None = None
932
1561
  if proc is not None:
933
1562
  rc = command_stream.returncode() if command_stream is not None else proc.wait()
934
1563
  if command_stream is not None and command_stream.timed_out and not command_stream.timeout_reported:
935
- timeout_line, _redacted = LineSanitizer(show_paths=args.show_paths).sanitize(
936
- command_stream.timeout_message()
937
- )
1564
+ timeout_line, _redacted = LineSanitizer(
1565
+ show_paths=args.show_paths,
1566
+ context=context,
1567
+ private_roots=args.private_root,
1568
+ ).sanitize(command_stream.timeout_message())
938
1569
  command_stream.timeout_reported = True
939
1570
  output = output + timeout_line
940
1571