@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.
|
|
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",
|
|
@@ -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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|
|