@ictechgy/context-guard 0.4.16 → 0.6.0

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 (30) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.ko.md +91 -1
  3. package/README.md +95 -1
  4. package/docs/distribution.md +100 -0
  5. package/package.json +5 -1
  6. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  7. package/plugins/context-guard/README.ko.md +34 -1
  8. package/plugins/context-guard/README.md +36 -1
  9. package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
  10. package/plugins/context-guard/bin/context-guard-artifact +1 -1
  11. package/plugins/context-guard/bin/context-guard-bench +4216 -103
  12. package/plugins/context-guard/bin/context-guard-failed-nudge +95 -23
  13. package/plugins/context-guard/bin/context-guard-guard-read +6 -2
  14. package/plugins/context-guard/bin/context-guard-mcp +2 -1
  15. package/plugins/context-guard/bin/context-guard-pack +2086 -142
  16. package/plugins/context-guard/bin/context-guard-rewrite-bash +497 -45
  17. package/plugins/context-guard/bin/context-guard-sanitize-output +178 -22
  18. package/plugins/context-guard/bin/context-guard-setup +901 -28
  19. package/plugins/context-guard/bin/context-guard-statusline +33 -2
  20. package/plugins/context-guard/bin/context-guard-statusline-merged +71 -20
  21. package/plugins/context-guard/bin/context-guard-task-memory +635 -0
  22. package/plugins/context-guard/bin/context-guard-trim-output +706 -35
  23. package/plugins/context-guard/lib/context_guard_commands.py +25 -1
  24. package/plugins/context-guard/lib/context_pack_git_boundary.py +19 -0
  25. package/plugins/context-guard/lib/context_pack_identity.py +115 -0
  26. package/plugins/context-guard/lib/context_pack_receipts.py +9 -0
  27. package/plugins/context-guard/lib/context_pack_rendering.py +12 -0
  28. package/plugins/context-guard/lib/context_pack_scanning.py +10 -0
  29. package/plugins/context-guard/lib/context_pack_selection.py +6 -0
  30. package/plugins/context-guard/lib/credential_policy.py +10 -2
@@ -17,6 +17,7 @@ import os
17
17
  from pathlib import Path, PurePosixPath
18
18
  import queue
19
19
  import re
20
+ import shutil
20
21
  import signal
21
22
  import subprocess
22
23
  import sys
@@ -26,6 +27,20 @@ from types import ModuleType
26
27
  from typing import BinaryIO, Iterable, Iterator, NamedTuple, TextIO
27
28
 
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
+
29
44
  def load_credential_policy() -> ModuleType:
30
45
  script_dir = Path(__file__).resolve().parent
31
46
  candidate = (
@@ -110,10 +125,11 @@ COOKIE_HEADER_RE = re.compile(
110
125
  # 복원한다. 이 형태가 예전 형태와 언어·스팬·그룹 모두에서 동치임은
111
126
  # tests/test_sanitize_output_redos.py의 차분 배터리로 고정한다.
112
127
  QUOTED_VALUE_BODY = r"(?:\\.|(?!(?P=quote))[^\\])*\\?"
128
+ SECRET_ASSIGNMENT_SEPARATOR = r"[ \t]*[:=][ \t]*"
113
129
  INLINE_QUOTED_SECRET_ASSIGNMENT_RE = re.compile(
114
130
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
115
131
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
116
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
132
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
117
133
  rf"(?P<quote>[\"'])(?P<value>{QUOTED_VALUE_BODY})(?P=quote)(?P<tail>[^\s,;}}\]]*)"
118
134
  )
119
135
  CODE_IDENTIFIER = r"[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*"
@@ -121,7 +137,7 @@ CALL_ARGUMENT_CHUNK = r"(?:[^()\"'\n;]|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|\(
121
137
  INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE = re.compile(
122
138
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
123
139
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
124
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
140
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
125
141
  rf"(?P<value>(?![\"']){CODE_IDENTIFIER}\({CALL_ARGUMENT_CHUNK}\))"
126
142
  )
127
143
  SECRET_IDENTIFIER_PART = (
@@ -133,7 +149,7 @@ FALLBACK_SECRET_OPERAND = rf"(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*{SECRET_IDENTIFIER_P
133
149
  INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE = re.compile(
134
150
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
135
151
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
136
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
152
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
137
153
  rf"(?P<value>(?![\"']|\[REDACTED\])"
138
154
  rf"[^;\n]*?(?:\bor\b|\|\||\?\?|\belse\b|\?[^:\n;]*:)\s*"
139
155
  rf"(?:[\"'](?:\\.|[^\"'\\])*[\"']|{FALLBACK_SECRET_OPERAND})[^;\n]*)"
@@ -141,20 +157,32 @@ INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE = re.compile(
141
157
  INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE = re.compile(
142
158
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
143
159
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
144
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
160
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
145
161
  rf"(?P<value>(?![\"']|\[REDACTED\])"
146
162
  rf"[^\s,;}}\]]*(?:\([^;\n]*?\)|\{{[^;\n]*?\}}|\[[^;\n]*?\])[^\s,;}}\]]*)"
147
163
  )
148
164
  INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE = re.compile(
149
165
  rf"(?i)(?P<lead>^|[\s;{{\[,])"
150
166
  rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
151
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
167
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
152
168
  rf"(?P<value>(?![\"']|\[REDACTED\])[^\s,;}}\]]+)"
153
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
+ )
154
182
  UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE = re.compile(
155
183
  rf"(?i)(?:^|[\s;{{\[,])"
156
184
  rf"(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
157
- rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*(?P<value>(?![\"']).*)$"
185
+ rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR}(?P<value>(?![\"']).*)$"
158
186
  )
159
187
  # F-14 one-pass scanner.
160
188
  #
@@ -181,7 +209,11 @@ LOCATION_PREFIX_SCAN_RE = re.compile(
181
209
  # 경로로 인정하되, 그 스팬도 함께 검사한다. 예: "src/it's.py:5:api_key='x'".
182
210
  LOCATION_PREFIX_ASSIGNMENT_SIGNAL_RE = re.compile(
183
211
  rf"(?i)(?:-----BEGIN|(?:Proxy-)?Authorization\s*:|(?:Set-)?Cookie\s*:"
184
- rf"|[\"']?(?:{SECRET_KEY})[\"']?\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]+"
185
217
  )
186
218
  LOCATION_PREFIX_PATH_ONLY_SIGNAL_RE = re.compile(r"[\"']")
187
219
  NINE_LOCATION_CONSUMERS = (
@@ -246,7 +278,10 @@ def scan_location_prefix(line: str) -> ScannedLine:
246
278
  return ScannedLine("", line, 0)
247
279
  end = match.end()
248
280
  candidate = line[:end]
249
- if LOCATION_PREFIX_ASSIGNMENT_SIGNAL_RE.search(candidate):
281
+ if (
282
+ LOCATION_PREFIX_ASSIGNMENT_SIGNAL_RE.search(candidate)
283
+ or LOCATION_PREFIX_WHITESPACE_ASSIGNMENT_SIGNAL_RE.match(candidate)
284
+ ):
250
285
  # 값의 일부를 경로로 오인한 경우다. 분리하면 앞부분만 가려져 뒤가 남는다.
251
286
  return ScannedLine("", line, 0, False)
252
287
  declined = bool(LOCATION_PREFIX_PATH_ONLY_SIGNAL_RE.search(candidate))
@@ -312,8 +347,33 @@ SAFE_GETTER_KEY_NAMES = {
312
347
  "sid",
313
348
  "token",
314
349
  }
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
+ )
315
372
  ASSIGNMENT_KEY_RE = re.compile(
316
- r"(?P<key>[A-Za-z_$][A-Za-z0-9_$.-]*)[\"']?\s*[:=]\s*$"
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]+$"
317
377
  )
318
378
  SOURCE_SAFE_VALUE_RE = re.compile(
319
379
  r"^(?:"
@@ -534,14 +594,32 @@ def normalize_getter_key(key: str) -> str:
534
594
 
535
595
 
536
596
  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
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
539
602
 
540
603
 
541
604
  def is_safe_getter_key(key: str) -> bool:
542
605
  return normalize_getter_key(key) in SAFE_GETTER_KEY_NAMES
543
606
 
544
607
 
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
+
545
623
  def should_redact_unquoted_secret_value(
546
624
  line: str,
547
625
  match: re.Match[str],
@@ -601,11 +679,20 @@ def redact_secret_assignments(
601
679
  redacted = True
602
680
  return f"{match.group('lead')}{match.group('prefix')}{match.group('quote')}[REDACTED]{match.group('quote')}"
603
681
 
604
- 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:
605
687
  nonlocal redacted
606
688
  key = assignment_key(match.group("prefix"))
607
689
  if key is None or not is_sensitive_key(key):
608
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)
609
696
  if not should_redact_unquoted_secret_value(line, match, context=context):
610
697
  return match.group(0)
611
698
  redacted = True
@@ -618,6 +705,11 @@ def redact_secret_assignments(
618
705
  span = INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
619
706
  span = INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
620
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
+ )
621
713
  span = INLINE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
622
714
  return span
623
715
 
@@ -630,7 +722,15 @@ def redact_secret_assignments(
630
722
 
631
723
  MULTILINE_SECRET_ASSIGNMENT_RE = re.compile(
632
724
  rf"(?i)(?:^|[\s;{{\[,])(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
633
- rf"[\"']?(?P<key>{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>(?![\"']).*)$"
634
734
  )
635
735
 
636
736
 
@@ -702,12 +802,19 @@ def detect_multiline_secret_assignment(line: str) -> str | None:
702
802
  # 보아 location prefix 바로 뒤에 붙은 키(콜론 직후)까지 잡는다. 둘 다 한 번씩이라 선형.
703
803
  spans = (line, scan.remainder) if scan.prefix else (line,)
704
804
  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
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
711
818
  return None
712
819
 
713
820
 
@@ -744,9 +851,15 @@ def detect_multiline_secret_expression(line: str) -> int | None:
744
851
  spans = (line, scan.remainder) if scan.prefix else (line,)
745
852
  marker = None
746
853
  for span in spans:
747
- marker = UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE.search(span)
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
748
862
  if marker is not None:
749
- line = span
750
863
  break
751
864
  if marker is None:
752
865
  return None
@@ -754,6 +867,8 @@ def detect_multiline_secret_expression(line: str) -> int | None:
754
867
  key = assignment_key(prefix)
755
868
  if key is None or not is_sensitive_key(key):
756
869
  return None
870
+ if whitespace_separator and is_ambiguous_bare_whitespace_key(key):
871
+ return None
757
872
  value = marker.group("value").strip()
758
873
  if not value:
759
874
  return 0
@@ -1233,6 +1348,7 @@ def run_command(
1233
1348
  timeout_seconds: int,
1234
1349
  *,
1235
1350
  max_line_chars: int = MAX_LINE_CHARS_LIMIT,
1351
+ environment: dict[str, str] | None = None,
1236
1352
  ) -> tuple[Iterable[str], subprocess.Popen[bytes] | None, int | None]:
1237
1353
  popen_kwargs: dict[str, object] = {}
1238
1354
  if os.name != "nt":
@@ -1240,6 +1356,7 @@ def run_command(
1240
1356
  try:
1241
1357
  proc = subprocess.Popen(
1242
1358
  command,
1359
+ env=environment,
1243
1360
  stdout=subprocess.PIPE,
1244
1361
  stderr=subprocess.STDOUT,
1245
1362
  text=False,
@@ -1265,6 +1382,14 @@ def run_command(
1265
1382
  )
1266
1383
 
1267
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
+
1268
1393
  def stdin_has_data(stdin: TextIO) -> bool:
1269
1394
  return not stdin.isatty()
1270
1395
 
@@ -1355,13 +1480,43 @@ def main() -> int:
1355
1480
  command = args.command
1356
1481
  if command and command[0] == "--":
1357
1482
  command = command[1:]
1483
+ command_environment: dict[str, str] | None = None
1358
1484
  if args.wrapper_context is not None:
1359
- if len(command) != 3 or command[0:2] != ["bash", "-lc"] or not command[2]:
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:
1360
1512
  print(
1361
1513
  "context-guard-sanitize-output: invalid context-guard wrapper v1 shape",
1362
1514
  file=sys.stderr,
1363
1515
  )
1364
1516
  return 2
1517
+ if legacy_shape:
1518
+ command = [*trusted_prefix, command[2]]
1519
+ command_environment = sanitized_wrapper_environment()
1365
1520
  context = coerce_sanitization_context(
1366
1521
  args.wrapper_context,
1367
1522
  private_roots=args.private_root,
@@ -1388,6 +1543,7 @@ def main() -> int:
1388
1543
  command,
1389
1544
  args.timeout_seconds,
1390
1545
  max_line_chars=COMMAND_MAX_UNTERMINATED_LINE_CHARS,
1546
+ environment=command_environment,
1391
1547
  )
1392
1548
  if isinstance(stream, TimedCommandStream):
1393
1549
  command_stream = stream