@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
@@ -10,9 +10,11 @@ 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
18
20
  import signal
@@ -20,7 +22,36 @@ import subprocess
20
22
  import sys
21
23
  import threading
22
24
  import time
23
- from typing import BinaryIO, Iterable, Iterator, TextIO
25
+ from types import ModuleType
26
+ from typing import BinaryIO, Iterable, Iterator, NamedTuple, TextIO
27
+
28
+
29
+ def load_credential_policy() -> ModuleType:
30
+ script_dir = Path(__file__).resolve().parent
31
+ candidate = (
32
+ script_dir.parent / "lib" / "credential_policy.py"
33
+ if script_dir.name == "bin"
34
+ else script_dir / "credential_policy.py"
35
+ )
36
+ spec = importlib.util.spec_from_file_location(
37
+ "_context_guard_sanitize_credential_policy",
38
+ candidate,
39
+ )
40
+ if spec is None or spec.loader is None:
41
+ raise RuntimeError(f"could not load credential policy: {candidate}")
42
+ module = importlib.util.module_from_spec(spec)
43
+ spec.loader.exec_module(module)
44
+ return module
45
+
46
+
47
+ _CREDENTIAL_POLICY = load_credential_policy()
48
+ SECRET_KEY = _CREDENTIAL_POLICY.SECRET_KEY
49
+ CAMEL_ACRONYM_BOUNDARY_RE = _CREDENTIAL_POLICY.CAMEL_ACRONYM_BOUNDARY_RE
50
+ CAMEL_WORD_BOUNDARY_RE = _CREDENTIAL_POLICY.CAMEL_WORD_BOUNDARY_RE
51
+ normalize_sensitive_key = _CREDENTIAL_POLICY.normalize_sensitive_key
52
+ is_sensitive_key = _CREDENTIAL_POLICY.is_sensitive_key
53
+ redact_url_like_secret_params = _CREDENTIAL_POLICY.redact_url_like_secret_params
54
+ redact_high_confidence_credentials = _CREDENTIAL_POLICY.redact_high_confidence_credentials
24
55
 
25
56
  TERMINAL_CONTROL_RE = re.compile(
26
57
  r"(?:"
@@ -40,6 +71,22 @@ ABSOLUTE_PATH_RE = re.compile(
40
71
  WINDOWS_PATH_RE = re.compile(
41
72
  rf"(?P<prefix>^|[\s('\"=])(?P<path>[A-Za-z]:\\(?:{PATH_SEGMENT}\\)+{PATH_SEGMENT})"
42
73
  )
74
+ TRACEBACK_PATH_RE = re.compile(
75
+ rf"(?P<prefix>\bFile\s+[\"'])(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
76
+ r"(?P<suffix>[\"'],\s+line\s+\d+)"
77
+ )
78
+ LOCATION_PATH_RE = re.compile(
79
+ rf"(?P<prefix>^(?:\s*[+-]\s*)?)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
80
+ r"(?P<suffix>:\d+(?::\d+)?(?=[:\s]|$))"
81
+ )
82
+ DIFF_PATH_RE = re.compile(
83
+ rf"(?P<prefix>^(?:---|\+\+\+)\s+)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
84
+ r"(?P<suffix>(?:\t.*)?$)"
85
+ )
86
+ LISTING_PATH_RE = re.compile(
87
+ rf"(?P<prefix>^\s*)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
88
+ r"(?P<suffix>/?(?:\s+->\s+\S+)?\s*$)"
89
+ )
43
90
  PRIVATE_KEY_BEGIN_RE = re.compile(
44
91
  r"-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----"
45
92
  )
@@ -52,25 +99,25 @@ AUTH_HEADER_RE = re.compile(
52
99
  COOKIE_HEADER_RE = re.compile(
53
100
  r"(?i)^(?P<prefix>\s*(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:Set-)?Cookie\s*:\s*).+$"
54
101
  )
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
- )
102
+ # QUOTED_VALUE_BODY의 두 대안은 첫 문자가 서로 겹치지 않아야 한다.
103
+ # 예전 형태 `(?:\\.|(?!(?P=quote)).)*`에서는 역슬래시 하나가 `\\.`의 시작이면서
104
+ # 동시에 두 번째 대안의 `.`이기도 했다. 닫는 따옴표가 없는 줄에서는 역슬래시
105
+ # 연속 구간을 나누는 방법이 피보나치 수만큼 생겨 지수 시간 백트래킹(ReDoS)
106
+ # 발생했다 — 47자짜리 줄이 7.6초를 소모했다.
107
+ # 두 번째 대안에서 역슬래시를 제외해 각 위치에서 적용 가능한 대안이 하나뿐이도록
108
+ # 만든다. 뒤에 붙은 `\\?`는 이 배제로 잃게 되는 유일한 문자열 부류, 즉 값이
109
+ # 짝지어지지 않은 역슬래시 하나로 끝나는 경우(`token = "abc\"` → value `abc\`)를
110
+ # 복원한다. 이 형태가 예전 형태와 언어·스팬·그룹 모두에서 동치임은
111
+ # tests/test_sanitize_output_redos.py의 차분 배터리로 고정한다.
112
+ QUOTED_VALUE_BODY = r"(?:\\.|(?!(?P=quote))[^\\])*\\?"
66
113
  INLINE_QUOTED_SECRET_ASSIGNMENT_RE = re.compile(
67
114
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
68
115
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
69
116
  rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
70
- rf"(?P<quote>[\"'])(?P<value>(?:\\.|(?!(?P=quote)).)*)(?P=quote)(?P<tail>[^\s,;}}\]]*)"
117
+ rf"(?P<quote>[\"'])(?P<value>{QUOTED_VALUE_BODY})(?P=quote)(?P<tail>[^\s,;}}\]]*)"
71
118
  )
72
119
  CODE_IDENTIFIER = r"[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*"
73
- CALL_ARGUMENT_CHUNK = r"(?:[^()\"'\n;]+|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|\([^()]*\))*"
120
+ CALL_ARGUMENT_CHUNK = r"(?:[^()\"'\n;]|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|\([^()]*\))*"
74
121
  INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE = re.compile(
75
122
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
76
123
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
@@ -109,28 +156,131 @@ UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE = re.compile(
109
156
  rf"(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
110
157
  rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*(?P<value>(?![\"']).*)$"
111
158
  )
159
+ # F-14 one-pass scanner.
160
+ #
161
+ # 아홉 개 consumer 는 모두 아래 fragment 를 각자 품고 있었다. unanchored 패턴에서는
162
+ # offset 마다 `[^:\n]+` 가 다음 콜론까지 삼키고 되돌아오므로, 콜론이 드문 긴 줄에서
163
+ # offset 당 O(n) 작업이 발생해 전체가 이차가 된다(측정: 82KB 한 줄 2.62초,
164
+ # 배가 비율 3.6~3.96). 대신 줄마다 앞머리 location prefix 를 단조 좌→우로 한 번만
165
+ # 확정하고, consumer 에는 fragment 를 제거한 쌍둥이 패턴을 먹인다.
166
+ LOCATION_PREFIX_FRAGMENT = r"(?:(?:[^:\n]+):\d+(?::\d+)?:)?"
167
+ # path 성분에서 '=' 를 제외한다. 허용하면 'api_key=abc:123:456def' 처럼 비밀 값 안에서
168
+ # 줄이 쪼개져 앞부분만 가려지고 뒷부분이 남는 절단 누출이 발생한다. grep/diff 경로에
169
+ # '=' 가 들어가는 경우는 드물고, 그런 줄은 그냥 fast path 를 쓰지 않을 뿐이다.
170
+ # 공백과 유니코드는 계약대로 계속 허용한다.
171
+ LOCATION_PREFIX_SCAN_RE = re.compile(
172
+ r"\A(?P<lead>[ \t]*(?:[+-][ \t]*)?)(?P<location>[^:\n=]+:\d+(?::\d+)?:)"
173
+ )
174
+ # 후보 스팬의 신호는 두 종류이고 처리도 달라야 한다.
175
+ #
176
+ # ASSIGNMENT: 비밀 키나 헤더 이름 뒤에 ':' 또는 '=' 가 오면 그 스팬은 경로가 아니라
177
+ # 값의 일부다. 예: 'token:123456789:AAH-...' 는 앞부분만 가리면 뒷부분이 남는다.
178
+ # 이 경우 분리 자체를 하지 않고 전체 줄을 consumer 에 넘긴다.
179
+ #
180
+ # PATH_ONLY: 따옴표나 private key 표지처럼 경로 자체에 들어갈 수 있는 문자는 후보를
181
+ # 경로로 인정하되, 그 스팬도 함께 검사한다. 예: "src/it's.py:5:api_key='x'".
182
+ LOCATION_PREFIX_ASSIGNMENT_SIGNAL_RE = re.compile(
183
+ rf"(?i)(?:-----BEGIN|(?:Proxy-)?Authorization\s*:|(?:Set-)?Cookie\s*:"
184
+ rf"|[\"']?(?:{SECRET_KEY})[\"']?\s*[:=])"
185
+ )
186
+ LOCATION_PREFIX_PATH_ONLY_SIGNAL_RE = re.compile(r"[\"']")
187
+ NINE_LOCATION_CONSUMERS = (
188
+ "AUTH_HEADER_RE",
189
+ "COOKIE_HEADER_RE",
190
+ "INLINE_QUOTED_SECRET_ASSIGNMENT_RE",
191
+ "INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE",
192
+ "INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE",
193
+ "INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE",
194
+ "INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE",
195
+ "UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE",
196
+ "MULTILINE_SECRET_ASSIGNMENT_RE",
197
+ )
198
+
199
+
200
+ def without_location_prefix(compiled: re.Pattern[str]) -> re.Pattern[str]:
201
+ """Return the same consumer with its embedded location prefix removed.
202
+
203
+ Deriving the twin from the compiled source keeps the two spellings from
204
+ drifting: if the fragment ever moves or changes, this fails loudly instead of
205
+ silently leaving a per-offset location scan in place.
206
+ """
207
+ source = compiled.pattern
208
+ if source.count(LOCATION_PREFIX_FRAGMENT) != 1:
209
+ raise RuntimeError(
210
+ "consumer no longer embeds exactly one location prefix fragment"
211
+ )
212
+ return re.compile(source.replace(LOCATION_PREFIX_FRAGMENT, "", 1), compiled.flags)
213
+
214
+
215
+ class ScannedLine(NamedTuple):
216
+ """One monotonic left-to-right split of a line into prefix and remainder.
217
+
218
+ An assignment signal inside the candidate means the span is part of a value
219
+ rather than a path, so no split is reported at all; splitting there would
220
+ redact only the leading part and leave the tail behind. A path-only signal
221
+ such as a quote in a filename keeps the split but sets ``declined``, and
222
+ unanchored consumers then also scan the bounded candidate span.
223
+ """
224
+
225
+ prefix: str
226
+ remainder: str
227
+ scan_index: int
228
+ declined: bool = False
229
+
230
+
231
+ def scan_location_prefix(line: str) -> ScannedLine:
232
+ """Identify a leading grep/diff location prefix in exactly one anchored scan.
233
+
234
+ The scan index only ever moves forward: it is the end of the anchored match,
235
+ or zero when there is no prefix.
236
+
237
+ The path component must keep accepting spaces and Unicode, because real grep
238
+ and diff output carries such filenames. That permissiveness means the
239
+ anchored run can reach past a secret that happens to sit before a later
240
+ ``:<digits>:`` sequence, so the candidate span is checked once for consumer
241
+ signal and flagged when any is present. The check is a single bounded pass, so
242
+ the scan stays linear.
243
+ """
244
+ match = LOCATION_PREFIX_SCAN_RE.match(line)
245
+ if match is None:
246
+ return ScannedLine("", line, 0)
247
+ end = match.end()
248
+ candidate = line[:end]
249
+ if LOCATION_PREFIX_ASSIGNMENT_SIGNAL_RE.search(candidate):
250
+ # 값의 일부를 경로로 오인한 경우다. 분리하면 앞부분만 가려져 뒤가 남는다.
251
+ return ScannedLine("", line, 0, False)
252
+ declined = bool(LOCATION_PREFIX_PATH_ONLY_SIGNAL_RE.search(candidate))
253
+ return ScannedLine(candidate, line[end:], end, declined)
254
+
255
+
112
256
  CONTINUATION_OPERATOR_RE = re.compile(
113
257
  r"(?i)(?:\\|\|\||&&|\?\?|[+*/%&|^?,]|\?|:|\bor\b|\band\b|\belse\b)\s*(?://.*|#.*)?$"
114
258
  )
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
259
  SAFE_UNQUOTED_VALUES = {
118
260
  "[redacted]",
261
+ "bool",
262
+ "boolean",
263
+ "bytes",
119
264
  "false",
265
+ "float",
266
+ "int",
267
+ "integer",
120
268
  "none",
121
269
  "null",
270
+ "object",
122
271
  "os.getenv",
123
272
  "process.env",
273
+ "str",
274
+ "string",
124
275
  "true",
125
276
  "undefined",
277
+ "unknown",
126
278
  }
127
279
  IDENTIFIER_CHAIN_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$")
128
280
  SAFE_ENV_LOOKUP_CALL_RE = re.compile(r"^(?:os\.getenv|os\.environ\.get)\(\s*[\"'][A-Za-z0-9_.-]{1,80}[\"']\s*\)$")
129
281
  SAFE_RE_COMPILE_CALL_RE = re.compile(r"^re\.compile\([^;\n]*\)$")
130
282
  SAFE_CODE_EXPRESSION_CALL_RE = re.compile(rf"^{CODE_IDENTIFIER}\(\s*(?:{CODE_IDENTIFIER}(?:\s*,\s*{CODE_IDENTIFIER})*)?\s*\)$")
131
283
  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
284
  SAFE_GETTER_KEY_NAMES = {
135
285
  "access_key",
136
286
  "access_token",
@@ -162,26 +312,18 @@ SAFE_GETTER_KEY_NAMES = {
162
312
  "sid",
163
313
  "token",
164
314
  }
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]@"),
315
+ ASSIGNMENT_KEY_RE = re.compile(
316
+ r"(?P<key>[A-Za-z_$][A-Za-z0-9_$.-]*)[\"']?\s*[:=]\s*$"
317
+ )
318
+ SOURCE_SAFE_VALUE_RE = re.compile(
319
+ r"^(?:"
320
+ r"[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*|"
321
+ r"[A-Za-z_$][A-Za-z0-9_$]*(?:\[[A-Za-z0-9_$., |]+\])+|"
322
+ r"\d+(?:\.\d+)?|"
323
+ r"\$\{[A-Za-z0-9_.-]+\}|"
324
+ r"<[A-Za-z0-9_.-]+>|"
325
+ r"(?:YOUR|REPLACE|EXAMPLE|PLACEHOLDER)_[A-Z0-9_]+"
326
+ r")$"
185
327
  )
186
328
  ANCHOR_RE = re.compile(
187
329
  r"^(?:diff --git |index [0-9a-f]|--- |\+\+\+ |@@ |Binary files |(?:[^:\n]+):\d+(?::\d+)?:)",
@@ -200,6 +342,55 @@ COMMAND_MAX_UNTERMINATED_LINE_CHARS = 4_096
200
342
  RAW_TRUNCATION_REDACTION_HOLDBACK_CHARS = 1_024
201
343
 
202
344
 
345
+ SANITIZATION_MODES = (
346
+ "unknown_text",
347
+ "command_search_diff",
348
+ "filesystem_listing",
349
+ "source_code",
350
+ )
351
+
352
+
353
+ @dataclass(frozen=True)
354
+ class SanitizationContext:
355
+ mode: str = "unknown_text"
356
+ private_roots: tuple[str, ...] = ()
357
+
358
+ def __post_init__(self) -> None:
359
+ if self.mode not in SANITIZATION_MODES:
360
+ raise ValueError(f"unsupported sanitization context: {self.mode}")
361
+ object.__setattr__(
362
+ self,
363
+ "private_roots",
364
+ tuple(str(root) for root in self.private_roots),
365
+ )
366
+
367
+
368
+ def coerce_sanitization_context(
369
+ value: SanitizationContext | str,
370
+ *,
371
+ private_roots: Iterable[str] = (),
372
+ ) -> SanitizationContext:
373
+ if isinstance(value, SanitizationContext):
374
+ supplied_roots = tuple(str(root) for root in private_roots)
375
+ normalized_context_roots = _normalized_private_roots(value.private_roots)
376
+ if supplied_roots and _normalized_private_roots(
377
+ supplied_roots
378
+ ) != normalized_context_roots:
379
+ raise ValueError(
380
+ "private_roots must be declared inside SanitizationContext "
381
+ "when a context object is supplied"
382
+ )
383
+ roots = normalized_context_roots
384
+ mode = value.mode
385
+ else:
386
+ roots = tuple(str(root) for root in private_roots)
387
+ mode = str(value)
388
+ return SanitizationContext(
389
+ mode=mode,
390
+ private_roots=_normalized_private_roots(roots),
391
+ )
392
+
393
+
203
394
  def bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
204
395
  try:
205
396
  number = int(value)
@@ -231,16 +422,98 @@ def stable_hash(value: str, length: int = 12) -> str:
231
422
  return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()[:length]
232
423
 
233
424
 
234
- def anonymize_absolute_paths(text: str) -> str:
425
+ def anonymized_path(path: str) -> str:
426
+ normalized = path.replace("\\", "/")
427
+ name = PurePosixPath(normalized).name or "path"
428
+ return f"{name}#path:{stable_hash(path)}"
429
+
430
+
431
+ def anonymize_absolute_paths_with_count(text: str) -> tuple[str, int]:
432
+ count = 0
433
+
235
434
  def repl(match: re.Match[str]) -> str:
435
+ nonlocal count
236
436
  prefix = match.group("prefix")
237
437
  path = match.group("path")
238
- normalized = path.replace("\\", "/")
239
- name = PurePosixPath(normalized).name or "path"
240
- return f"{prefix}{name}#path:{stable_hash(path)}"
438
+ count += 1
439
+ return f"{prefix}{anonymized_path(path)}"
241
440
 
242
441
  text = ABSOLUTE_PATH_RE.sub(repl, text)
243
- return WINDOWS_PATH_RE.sub(repl, text)
442
+ return WINDOWS_PATH_RE.sub(repl, text), count
443
+
444
+
445
+ def anonymize_absolute_paths(text: str) -> str:
446
+ return anonymize_absolute_paths_with_count(text)[0]
447
+
448
+
449
+ def _replace_named_paths(text: str, pattern: re.Pattern[str]) -> tuple[str, int]:
450
+ count = 0
451
+
452
+ def repl(match: re.Match[str]) -> str:
453
+ nonlocal count
454
+ count += 1
455
+ return (
456
+ match.group("prefix")
457
+ + anonymized_path(match.group("path"))
458
+ + match.group("suffix")
459
+ )
460
+
461
+ return pattern.sub(repl, text), count
462
+
463
+
464
+ def _normalized_private_roots(private_roots: Iterable[str]) -> tuple[str, ...]:
465
+ normalized: list[str] = []
466
+ for root in private_roots:
467
+ value = os.path.normpath(os.path.abspath(os.path.expanduser(str(root))))
468
+ if value not in normalized:
469
+ normalized.append(value)
470
+ return tuple(normalized)
471
+
472
+
473
+ def _is_under_private_root(path: str, private_roots: tuple[str, ...]) -> bool:
474
+ normalized = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
475
+ return any(
476
+ normalized == root or normalized.startswith(root.rstrip(os.sep) + os.sep)
477
+ for root in private_roots
478
+ )
479
+
480
+
481
+ def anonymize_private_root_paths(
482
+ text: str,
483
+ private_roots: tuple[str, ...],
484
+ ) -> tuple[str, int]:
485
+ if not private_roots:
486
+ return text, 0
487
+ count = 0
488
+
489
+ def repl(match: re.Match[str]) -> str:
490
+ nonlocal count
491
+ path = match.group("path")
492
+ if not _is_under_private_root(path, private_roots):
493
+ return match.group(0)
494
+ count += 1
495
+ return match.group("prefix") + anonymized_path(path)
496
+
497
+ return ABSOLUTE_PATH_RE.sub(repl, text), count
498
+
499
+
500
+ def anonymize_paths_for_context(
501
+ text: str,
502
+ *,
503
+ context: SanitizationContext,
504
+ ) -> tuple[str, int]:
505
+ if context.mode in {"unknown_text", "source_code"}:
506
+ return text, 0
507
+
508
+ total = 0
509
+ if context.mode == "filesystem_listing":
510
+ text, count = anonymize_private_root_paths(text, context.private_roots)
511
+ total += count
512
+
513
+ for pattern in (TRACEBACK_PATH_RE, LOCATION_PATH_RE, DIFF_PATH_RE):
514
+ text, count = _replace_named_paths(text, pattern)
515
+ total += count
516
+ return text, total
244
517
 
245
518
 
246
519
  def cap_line(line: str, max_line_chars: int) -> tuple[str, bool]:
@@ -260,21 +533,38 @@ def normalize_getter_key(key: str) -> str:
260
533
  return re.sub(r"_+", "_", key).strip("_").lower()
261
534
 
262
535
 
536
+ def assignment_key(prefix: str) -> str | None:
537
+ match = ASSIGNMENT_KEY_RE.search(prefix)
538
+ return match.group("key") if match is not None else None
539
+
540
+
263
541
  def is_safe_getter_key(key: str) -> bool:
264
542
  return normalize_getter_key(key) in SAFE_GETTER_KEY_NAMES
265
543
 
266
544
 
267
- def should_redact_unquoted_secret_value(line: str, match: re.Match[str]) -> bool:
545
+ def should_redact_unquoted_secret_value(
546
+ line: str,
547
+ match: re.Match[str],
548
+ *,
549
+ context: SanitizationContext,
550
+ ) -> bool:
268
551
  value = match.group("value").strip()
269
552
  prefix = match.group("prefix")
270
553
  if not value:
271
554
  return False
272
555
  if value.lower() in SAFE_UNQUOTED_VALUES:
273
556
  return False
557
+ if re.search(r":\s*$", prefix):
558
+ return not (
559
+ context.mode == "source_code"
560
+ and SOURCE_SAFE_VALUE_RE.match(value) is not None
561
+ )
274
562
  if IDENTIFIER_CHAIN_RE.match(value):
275
563
  return False
276
564
  if SAFE_ENV_LOOKUP_CALL_RE.match(value) or SAFE_RE_COMPILE_CALL_RE.match(value):
277
565
  return False
566
+ if context.mode == "source_code" and SOURCE_SAFE_VALUE_RE.match(value):
567
+ return False
278
568
  getter_match = GETTER_CALL_RE.match(value)
279
569
  if re.search(r"\s[:=]\s*$", prefix) and (
280
570
  SAFE_CODE_EXPRESSION_CALL_RE.match(value)
@@ -284,45 +574,99 @@ def should_redact_unquoted_secret_value(line: str, match: re.Match[str]) -> bool
284
574
  return True
285
575
 
286
576
 
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]:
577
+ def redact_secret_assignments(
578
+ line: str,
579
+ *,
580
+ context: SanitizationContext,
581
+ ) -> tuple[str, bool]:
582
+ """Redact inline secret assignments using the no-location consumer twins.
583
+
584
+ The leading grep/diff location prefix is identified once by the scanner and
585
+ reattached untouched, so no consumer re-parses it at every offset.
586
+ """
587
+ # URL 형태 비밀 파라미터는 분리 전 전체 줄에서 처리한다. prefix 안에 들어 있어도
588
+ # 가려지지 않고 다시 붙는 일이 없어야 한다.
301
589
  line, redacted = redact_url_like_secret_params(line)
590
+ # 반드시 URL 치환 이후의 문자열에서 다시 스캔한다. 호출자가 미리 계산한 split 을
591
+ # 받아 쓰면 치환으로 길이가 바뀐 문자열에 낡은 오프셋을 적용해 내용이 유실된다.
592
+ scan = scan_location_prefix(line)
593
+ location_prefix = scan.prefix
594
+ line = scan.remainder
302
595
 
303
596
  def quoted_repl(match: re.Match[str]) -> str:
304
597
  nonlocal redacted
598
+ key = assignment_key(match.group("prefix"))
599
+ if key is None or not is_sensitive_key(key):
600
+ return match.group(0)
305
601
  redacted = True
306
602
  return f"{match.group('lead')}{match.group('prefix')}{match.group('quote')}[REDACTED]{match.group('quote')}"
307
603
 
308
604
  def unquoted_repl(match: re.Match[str]) -> str:
309
605
  nonlocal redacted
310
- if not should_redact_unquoted_secret_value(line, match):
606
+ key = assignment_key(match.group("prefix"))
607
+ if key is None or not is_sensitive_key(key):
608
+ return match.group(0)
609
+ if not should_redact_unquoted_secret_value(line, match, context=context):
311
610
  return match.group(0)
312
611
  redacted = True
313
612
  return f"{match.group('lead')}{match.group('prefix')}[REDACTED]"
314
613
 
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
614
+ def apply(span: str) -> str:
615
+ # 언제나 fragment 를 제거한 쌍둥이만 쓴다. 원본으로 되돌아가면 offset 마다
616
+ # location 다시 파싱해 이차 비용이 되살아난다.
617
+ span = INLINE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(quoted_repl, span)
618
+ span = INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
619
+ span = INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
620
+ span = INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
621
+ span = INLINE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
622
+ return span
623
+
624
+ # 후보 스팬이 consumer 신호를 품었을 때만 그 스팬도 검사한다. 두 스팬 모두 길이가
625
+ # 유계이므로 전체는 선형으로 남는다.
626
+ if scan.declined:
627
+ location_prefix = apply(location_prefix)
628
+ return location_prefix + apply(line), redacted
321
629
 
322
630
 
323
631
  MULTILINE_SECRET_ASSIGNMENT_RE = re.compile(
324
632
  rf"(?i)(?:^|[\s;{{\[,])(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
325
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*(?P<quote>[\"'])"
633
+ rf"[\"']?(?P<key>{SECRET_KEY})[\"']?\s*[:=]\s*(?P<quote>[\"'])"
634
+ )
635
+
636
+
637
+ # fragment 를 제거한 일곱 쌍둥이. 이차 비용은 unanchored consumer 에서만 발생하므로
638
+ # 그 일곱 개만 쌍둥이로 돌린다. ^ 로 고정된 헤더 두 개는 fragment 를 한 위치에서만
639
+ # 시도하니 원본을 그대로 쓰고, 스캐너 없이 스스로 prefix 를 건너뛴다.
640
+ UNANCHORED_LOCATION_CONSUMERS = (
641
+ "INLINE_QUOTED_SECRET_ASSIGNMENT_RE",
642
+ "INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE",
643
+ "INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE",
644
+ "INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE",
645
+ "INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE",
646
+ "UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE",
647
+ "MULTILINE_SECRET_ASSIGNMENT_RE",
648
+ )
649
+ ANCHORED_LOCATION_CONSUMERS = ("AUTH_HEADER_RE", "COOKIE_HEADER_RE")
650
+ INLINE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
651
+ INLINE_QUOTED_SECRET_ASSIGNMENT_RE
652
+ )
653
+ INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
654
+ INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE
655
+ )
656
+ INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
657
+ INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE
658
+ )
659
+ INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
660
+ INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE
661
+ )
662
+ INLINE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
663
+ INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE
664
+ )
665
+ UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
666
+ UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE
667
+ )
668
+ MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
669
+ MULTILINE_SECRET_ASSIGNMENT_RE
326
670
  )
327
671
 
328
672
 
@@ -347,11 +691,23 @@ def has_unescaped_quote(text: str, quote: str, start: int = 0) -> bool:
347
691
 
348
692
 
349
693
  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
694
+ """Return the quote delimiter when any secret assignment starts a multiline value.
695
+
696
+ The leading location prefix is removed by the one-pass scanner first, so the
697
+ detector never re-parses it. Quote balancing still runs against the same
698
+ remainder the detector matched in, which keeps offsets consistent.
699
+ """
700
+ scan = scan_location_prefix(line)
701
+ # 전체 줄을 먼저 본다: $ 와 줄 끝 의미가 기준선과 같아야 한다. 그 다음 remainder 를
702
+ # 보아 location prefix 바로 뒤에 붙은 키(콜론 직후)까지 잡는다. 둘 다 한 번씩이라 선형.
703
+ spans = (line, scan.remainder) if scan.prefix else (line,)
704
+ for span in spans:
705
+ for marker in MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE.finditer(span):
706
+ if not is_sensitive_key(marker.group("key")):
707
+ continue
708
+ quote = marker.group("quote")
709
+ if not has_unescaped_quote(span, quote, marker.end("quote")):
710
+ return quote
355
711
  return None
356
712
 
357
713
 
@@ -382,9 +738,22 @@ def ends_with_continuation_operator(text: str) -> bool:
382
738
 
383
739
 
384
740
  def detect_multiline_secret_expression(line: str) -> int | None:
385
- marker = UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE.search(line)
741
+ # 스캐너가 앞머리 location prefix 를 먼저 떼어내므로 detector 는 그것을 재파싱하지 않는다.
742
+ # prefix 슬라이싱과 offset 이 어긋나지 않도록 이후 계산도 같은 remainder 위에서 한다.
743
+ scan = scan_location_prefix(line)
744
+ spans = (line, scan.remainder) if scan.prefix else (line,)
745
+ marker = None
746
+ for span in spans:
747
+ marker = UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE.search(span)
748
+ if marker is not None:
749
+ line = span
750
+ break
386
751
  if marker is None:
387
752
  return None
753
+ prefix = line[: marker.start("value")]
754
+ key = assignment_key(prefix)
755
+ if key is None or not is_sensitive_key(key):
756
+ return None
388
757
  value = marker.group("value").strip()
389
758
  if not value:
390
759
  return 0
@@ -419,12 +788,23 @@ def secret_or_private_key_redaction_label(line: str) -> str:
419
788
 
420
789
 
421
790
  class LineSanitizer:
422
- def __init__(self, *, show_paths: bool = False) -> None:
791
+ def __init__(
792
+ self,
793
+ *,
794
+ show_paths: bool = False,
795
+ context: SanitizationContext | str = "unknown_text",
796
+ private_roots: Iterable[str] = (),
797
+ ) -> None:
423
798
  self.show_paths = show_paths
799
+ self.context = coerce_sanitization_context(
800
+ context,
801
+ private_roots=private_roots,
802
+ )
424
803
  self.in_private_key_block = False
425
804
  self.multiline_secret_quote: str | None = None
426
805
  self.multiline_secret_expression_depth: int | None = None
427
806
  self.redactions = 0
807
+ self.path_redactions = 0
428
808
 
429
809
  def sanitize(self, raw_line: str) -> tuple[str, bool]:
430
810
  line = strip_ansi(raw_line)
@@ -481,6 +861,11 @@ class LineSanitizer:
481
861
  self.multiline_secret_expression_depth = expression_depth
482
862
  return self._finish(diff_prefix + "[REDACTED MULTILINE SECRET]\n", True)
483
863
 
864
+ # 헤더 두 consumer 는 ^ 로 고정되어 있으므로 스캐너가 떼어낸 remainder 에 적용하고
865
+ # 앞머리 prefix 를 손대지 않은 채 다시 붙인다. 바이트 재구성은 정확해야 한다.
866
+ # 헤더 두 consumer 는 ^ 로 고정되어 있어 location fragment 를 단 한 위치에서만
867
+ # 시도한다. 즉 이차 비용의 원인이 아니므로 원본 패턴을 그대로 쓰고, 자신이
868
+ # 앞머리 prefix 를 건너뛰게 둔다. 스캐너는 unanchored consumer 만 돕는다.
484
869
  new_line, count = AUTH_HEADER_RE.subn(r"\g<prefix>[REDACTED]", line)
485
870
  if count:
486
871
  redacted = True
@@ -491,22 +876,30 @@ class LineSanitizer:
491
876
  redacted = True
492
877
  line = new_line
493
878
 
494
- line, assignment_redacted = redact_secret_assignments(line)
879
+ line, assignment_redacted = redact_secret_assignments(
880
+ line,
881
+ context=self.context,
882
+ )
495
883
  if assignment_redacted:
496
884
  redacted = True
497
885
 
498
- for pattern, replacement in INLINE_PATTERNS:
499
- line, count = pattern.subn(replacement, line)
500
- if count:
501
- redacted = True
886
+ line, credential_redactions = redact_high_confidence_credentials(line)
887
+ if credential_redactions:
888
+ redacted = True
502
889
 
503
890
  return self._finish(line, redacted)
504
891
 
505
892
  def _finish(self, line: str, redacted: bool) -> tuple[str, bool]:
893
+ if not self.show_paths:
894
+ line, path_redactions = anonymize_paths_for_context(
895
+ line,
896
+ context=self.context,
897
+ )
898
+ if path_redactions:
899
+ self.path_redactions += path_redactions
900
+ redacted = True
506
901
  if redacted:
507
902
  self.redactions += 1
508
- if not self.show_paths:
509
- line = anonymize_absolute_paths(line)
510
903
  return line, redacted
511
904
 
512
905
 
@@ -600,7 +993,11 @@ class BoundedOutput:
600
993
 
601
994
 
602
995
  def sanitize_stream(stream: Iterable[str], args: argparse.Namespace) -> tuple[str, int, int]:
603
- sanitizer = LineSanitizer(show_paths=args.show_paths)
996
+ sanitizer = LineSanitizer(
997
+ show_paths=args.show_paths,
998
+ context=getattr(args, "context", "unknown_text"),
999
+ private_roots=getattr(args, "private_root", ()),
1000
+ )
604
1001
  bounded = BoundedOutput(
605
1002
  max_lines=args.max_lines,
606
1003
  max_chars=args.max_chars,
@@ -872,6 +1269,33 @@ def stdin_has_data(stdin: TextIO) -> bool:
872
1269
  return not stdin.isatty()
873
1270
 
874
1271
 
1272
+ def command_uses_search_diff_output(command: list[str]) -> bool:
1273
+ if not command:
1274
+ return False
1275
+ executable = os.path.basename(command[0])
1276
+ if executable in {"grep", "rg"}:
1277
+ return True
1278
+ if executable != "git":
1279
+ return False
1280
+ value_options = {"-C", "-c", "--git-dir", "--work-tree", "--namespace"}
1281
+ skip_next = False
1282
+ for arg in command[1:]:
1283
+ if skip_next:
1284
+ skip_next = False
1285
+ continue
1286
+ if arg == "--":
1287
+ break
1288
+ if arg in value_options:
1289
+ skip_next = True
1290
+ continue
1291
+ if any(arg.startswith(option + "=") for option in value_options if option.startswith("--")):
1292
+ continue
1293
+ if arg.startswith("-"):
1294
+ continue
1295
+ return arg in {"diff", "grep", "log", "show"}
1296
+ return False
1297
+
1298
+
875
1299
  def build_parser() -> argparse.ArgumentParser:
876
1300
  parser = argparse.ArgumentParser(
877
1301
  description="Redact secrets and budget grep/diff/log output before sending it to Claude."
@@ -896,6 +1320,30 @@ def build_parser() -> argparse.ArgumentParser:
896
1320
  action="store_true",
897
1321
  help="show raw absolute paths instead of basename#path:<hash>; local debugging only because private paths may be exposed",
898
1322
  )
1323
+ parser.add_argument(
1324
+ "--context",
1325
+ choices=SANITIZATION_MODES,
1326
+ default="unknown_text",
1327
+ help=(
1328
+ "sanitization origin policy (default: unknown_text); path-aware modes "
1329
+ "only anonymize structurally proven locations"
1330
+ ),
1331
+ )
1332
+ parser.add_argument(
1333
+ "--private-root",
1334
+ action="append",
1335
+ default=[],
1336
+ help=(
1337
+ "private root eligible for path anonymization in filesystem_listing "
1338
+ "mode; may be repeated"
1339
+ ),
1340
+ )
1341
+ parser.add_argument(
1342
+ "--context-guard-wrapper-v1",
1343
+ choices=("command_search_diff",),
1344
+ dest="wrapper_context",
1345
+ help=argparse.SUPPRESS,
1346
+ )
899
1347
  parser.add_argument("command", nargs=argparse.REMAINDER)
900
1348
  return parser
901
1349
 
@@ -907,6 +1355,30 @@ def main() -> int:
907
1355
  command = args.command
908
1356
  if command and command[0] == "--":
909
1357
  command = command[1:]
1358
+ if args.wrapper_context is not None:
1359
+ if len(command) != 3 or command[0:2] != ["bash", "-lc"] or not command[2]:
1360
+ print(
1361
+ "context-guard-sanitize-output: invalid context-guard wrapper v1 shape",
1362
+ file=sys.stderr,
1363
+ )
1364
+ return 2
1365
+ context = coerce_sanitization_context(
1366
+ args.wrapper_context,
1367
+ private_roots=args.private_root,
1368
+ )
1369
+ else:
1370
+ context = coerce_sanitization_context(
1371
+ args.context,
1372
+ private_roots=args.private_root,
1373
+ )
1374
+ if (
1375
+ context.mode == "unknown_text"
1376
+ and command_uses_search_diff_output(command)
1377
+ ):
1378
+ context = SanitizationContext(
1379
+ mode="command_search_diff",
1380
+ private_roots=context.private_roots,
1381
+ )
910
1382
 
911
1383
  proc: subprocess.Popen[bytes] | None = None
912
1384
  command_stream: TimedCommandStream | None = None
@@ -927,14 +1399,17 @@ def main() -> int:
927
1399
  print("context-guard-sanitize-output: missing command or stdin", file=sys.stderr)
928
1400
  return 2
929
1401
 
1402
+ args.context = context
930
1403
  output, _redactions, _line_count = sanitize_stream(stream, args)
931
1404
  rc: int | None = None
932
1405
  if proc is not None:
933
1406
  rc = command_stream.returncode() if command_stream is not None else proc.wait()
934
1407
  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
- )
1408
+ timeout_line, _redacted = LineSanitizer(
1409
+ show_paths=args.show_paths,
1410
+ context=context,
1411
+ private_roots=args.private_root,
1412
+ ).sanitize(command_stream.timeout_message())
938
1413
  command_stream.timeout_reported = True
939
1414
  output = output + timeout_line
940
1415