@ictechgy/context-guard 0.4.12 → 0.4.13

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@ All notable changes for the ContextGuard plugin are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.13] - 2026-06-22
8
+
9
+ - Kept the Bash rewrite hook stdout JSON-parseable while routing sanitizer-worthy read-only pipelines through `context-guard-sanitize-output`.
10
+ - Preserved fail-closed handling for side-effecting shell operators, redirections, here-strings, `tee`, network commands, environment-prefixed filters, and file-reading/writing filter options.
11
+
7
12
  ## [0.4.12] - 2026-06-22
8
13
 
9
14
  - Published the post-merge README, Korean README, and GitHub Pages copy polish into the npm/package metadata so package consumers see the same setup, packaging, helper-trust, and conservative savings-claim guidance as the product site.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ictechgy/context-guard",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "ContextGuard CLI helpers for keeping AI coding agent context focused and local-first.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/ictechgy/context-guard#readme",
@@ -37,5 +37,5 @@
37
37
  "gated-experiments",
38
38
  "future-roadmap"
39
39
  ],
40
- "version": "0.4.12"
40
+ "version": "0.4.13"
41
41
  }
@@ -19,6 +19,7 @@ import sys
19
19
  SHELL_OPERATOR_TOKENS = {";", ";;", ";&", ";;&", "&", "&&", "|", "||", "<", ">", "<<", ">>", "<>", "(", ")"}
20
20
  SHELL_OPERATOR_CHARS = frozenset(";&|<>()")
21
21
  ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*")
22
+ SAFE_PIPE_FILTER_BASENAMES = frozenset({"cat", "head", "tail", "wc", "sort", "uniq"})
22
23
  WRAPPER_BASENAMES = frozenset({
23
24
  "trim_command_output.py",
24
25
  "context-guard-trim-output",
@@ -164,6 +165,59 @@ def split_single_safe_command(command: str) -> list[str] | None:
164
165
  return argv
165
166
 
166
167
 
168
+ def split_safe_sanitizer_pipeline(command: str) -> list[list[str]] | None:
169
+ """Return argv segments for a narrow read-only pipeline safe to sanitizer-wrap.
170
+
171
+ Compound search/diff/log commands are useful in practice (`git diff | cat`,
172
+ `rg token . | head`), but arbitrary shell operators can branch output to
173
+ files/network or change control flow before the sanitizer sees it. This
174
+ helper therefore allows only plain `|` pipelines where the first segment is
175
+ sanitizer-worthy and every later segment is a simple stdout filter. It
176
+ intentionally rejects redirection, here-doc/string, `tee`, `curl`, `&&`,
177
+ command substitution, and other shell syntax.
178
+ """
179
+ if not command.strip():
180
+ return None
181
+ if any(char in command for char in "\n\r\t`"):
182
+ return None
183
+ if "$(" in command or "${" in command:
184
+ return None
185
+ try:
186
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
187
+ lexer.whitespace_split = True
188
+ tokens = list(lexer)
189
+ except ValueError:
190
+ return None
191
+ if "|" not in tokens:
192
+ return None
193
+
194
+ segments: list[list[str]] = [[]]
195
+ for token in tokens:
196
+ is_operator = token in SHELL_OPERATOR_TOKENS or (
197
+ any(char in SHELL_OPERATOR_CHARS for char in token)
198
+ and all(char in SHELL_OPERATOR_CHARS for char in token)
199
+ )
200
+ if is_operator:
201
+ if token != "|":
202
+ return None
203
+ if not segments[-1]:
204
+ return None
205
+ segments.append([])
206
+ continue
207
+ if any(char in token for char in "`\n\r\t"):
208
+ return None
209
+ if "$(" in token or "${" in token:
210
+ return None
211
+ segments[-1].append(token)
212
+ if not segments or not segments[-1] or len(segments) < 2:
213
+ return None
214
+ if not (is_sanitizable_output_command(segments[0]) or is_log_streaming_command(segments[0])):
215
+ return None
216
+ if not all(is_safe_pipe_filter(segment) for segment in segments[1:]):
217
+ return None
218
+ return segments
219
+
220
+
167
221
  def command_basename(command: str) -> str:
168
222
  return os.path.basename(command)
169
223
 
@@ -214,6 +268,70 @@ def npm_script_args(rest: list[str]) -> list[str]:
214
268
  return rest[i:]
215
269
 
216
270
 
271
+ def _filter_args_are_stdin_only(first: str, args: list[str]) -> bool:
272
+ """Accept small, option-only filter argv forms that do not name files."""
273
+ if first == "cat":
274
+ return not args
275
+ long_no_value_options = {
276
+ "head": set(),
277
+ "tail": set(),
278
+ "wc": {"--bytes", "--chars", "--lines", "--words"},
279
+ "sort": {"--ignore-leading-blanks", "--dictionary-order", "--ignore-case", "--general-numeric-sort", "--human-numeric-sort", "--numeric-sort", "--reverse", "--unique"},
280
+ "uniq": {"--count", "--repeated", "--unique", "--ignore-case"},
281
+ }.get(first, set())
282
+ short_no_value_chars = {
283
+ "head": set(),
284
+ "tail": {"f", "F", "r"},
285
+ "wc": {"c", "m", "l", "w"},
286
+ "sort": {"b", "d", "f", "g", "h", "n", "r", "u"},
287
+ "uniq": {"c", "d", "u", "i"},
288
+ }.get(first, set())
289
+ value_options = {"-n", "--lines", "-c", "--bytes"} if first in {"head", "tail"} else set()
290
+ i = 0
291
+ while i < len(args):
292
+ arg = args[i]
293
+ if arg == "--":
294
+ return i == len(args) - 1
295
+ if arg.startswith("--") and "=" in arg:
296
+ name, value = arg.split("=", 1)
297
+ if name not in value_options:
298
+ return False
299
+ if not re.fullmatch(r"[+-]?\d+[KkMmGg]?", value):
300
+ return False
301
+ i += 1
302
+ continue
303
+ if arg in value_options:
304
+ if i + 1 >= len(args):
305
+ return False
306
+ if not re.fullmatch(r"[+-]?\d+[KkMmGg]?", args[i + 1]):
307
+ return False
308
+ i += 2
309
+ continue
310
+ if arg in long_no_value_options:
311
+ i += 1
312
+ continue
313
+ if arg.startswith("--"):
314
+ return False
315
+ if arg.startswith("-") and arg != "-":
316
+ if not set(arg[1:]).issubset(short_no_value_chars):
317
+ return False
318
+ i += 1
319
+ continue
320
+ if arg.startswith("-"):
321
+ return False
322
+ return False
323
+ return True
324
+
325
+
326
+ def is_safe_pipe_filter(argv: list[str]) -> bool:
327
+ if not argv:
328
+ return False
329
+ first = command_basename(argv[0])
330
+ if first not in SAFE_PIPE_FILTER_BASENAMES:
331
+ return False
332
+ return _filter_args_are_stdin_only(first, argv[1:])
333
+
334
+
217
335
  def is_noisy_command(argv: list[str]) -> bool:
218
336
  argv = strip_env_prefix(argv)
219
337
  if not argv:
@@ -423,6 +541,16 @@ def build_sanitized_command(wrapper: str, command: str) -> str:
423
541
  return shlex.join(wrapped_argv)
424
542
 
425
543
 
544
+ def print_updated_command(wrapped: str) -> None:
545
+ response = {
546
+ "hookSpecificOutput": {
547
+ "hookEventName": "PreToolUse",
548
+ "updatedInput": {"command": wrapped},
549
+ }
550
+ }
551
+ print(json.dumps(response, ensure_ascii=False))
552
+
553
+
426
554
  def main() -> int:
427
555
  if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
428
556
  print("ContextGuard helper: context-guard-rewrite-bash")
@@ -450,11 +578,25 @@ def main() -> int:
450
578
  argv = split_single_safe_command(command)
451
579
  if not argv:
452
580
  if unparseable_command_needs_sanitizer(command):
453
- deny(
454
- "Search/diff/log command contains shell operators that cannot be safely rewritten. "
455
- "Run the command through context-guard-sanitize-output explicitly, simplify it, or set "
456
- f"{FAIL_OPEN_ENV}=1 to run unsanitized intentionally."
457
- )
581
+ safe_pipeline = split_safe_sanitizer_pipeline(command)
582
+ if safe_pipeline is None:
583
+ deny(
584
+ "Search/diff/log command contains shell operators that are not in ContextGuard's "
585
+ "read-only pipe allowlist. Simplify to a plain pipeline ending in cat/head/tail/wc/sort/uniq, "
586
+ "run context-guard-sanitize-output explicitly after review, or set "
587
+ f"{FAIL_OPEN_ENV}=1 to run unsanitized intentionally."
588
+ )
589
+ return 0
590
+ wrapper = find_wrapper("sanitize")
591
+ if wrapper is None:
592
+ deny(
593
+ "Search/diff/log command blocked because it contains shell operators and "
594
+ "context-guard-sanitize-output is not installed next to context-guard-rewrite-bash. "
595
+ "Install the sanitizer or set "
596
+ f"{FAIL_OPEN_ENV}=1 to run unsanitized intentionally."
597
+ )
598
+ return 0
599
+ print_updated_command(build_sanitized_command(wrapper, command))
458
600
  return 0
459
601
  print_noop()
460
602
  return 0
@@ -490,13 +632,7 @@ def main() -> int:
490
632
  print("{}")
491
633
  return 0
492
634
 
493
- response = {
494
- "hookSpecificOutput": {
495
- "hookEventName": "PreToolUse",
496
- "updatedInput": {"command": wrapped},
497
- }
498
- }
499
- print(json.dumps(response, ensure_ascii=False))
635
+ print_updated_command(wrapped)
500
636
  return 0
501
637
 
502
638