@ictechgy/context-guard 0.7.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes for the ContextGuard plugin are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.1] - 2026-08-24
8
+
9
+ - Hardened three context-guard-kit security surfaces: `run_guarded_git()` now
10
+ resolves `git` through the fixed-path approved-runtime-executable resolver
11
+ instead of a PATH lookup, `FallbackLineSanitizer` gained PEM private-key
12
+ block and Cookie header redaction plus a corrected userinfo-credential
13
+ regex, and adjacent wrapper scripts are opened with `O_NOFOLLOW` before use
14
+ to reject a pre-planted symlink.
15
+ - Corrected `sanitize_output.py`'s docstring to accurately describe that
16
+ `anonymize_paths_for_context()` is a deliberate no-op for `unknown_text`/
17
+ `source_code` contexts (behavior unchanged).
18
+
7
19
  ## [0.7.0] - 2026-08-22
8
20
 
9
21
  - Added a zero-provider-context advisory mode for WeightClass-style routing.
package/README.ko.md CHANGED
@@ -230,7 +230,7 @@ plugin 배치에서는 기존 Bash trim 동작을 유지하고 setup이 referenc
230
230
  사용할 수 없다고 알립니다.
231
231
 
232
232
  ```bash
233
- npm install --save-exact @ictechgy/context-guard@0.7.0
233
+ npm install --save-exact @ictechgy/context-guard@0.7.1
234
234
  ./node_modules/.bin/context-guard setup --root . --agent claude --scope project --bash-reference-v1 --plan
235
235
  ./node_modules/.bin/context-guard setup --root . --agent claude --scope project --bash-reference-v1 --yes
236
236
  ```
package/README.md CHANGED
@@ -243,7 +243,7 @@ keeps the existing Bash trim behavior and setup reports the reference route as
243
243
  unavailable.
244
244
 
245
245
  ```bash
246
- npm install --save-exact @ictechgy/context-guard@0.7.0
246
+ npm install --save-exact @ictechgy/context-guard@0.7.1
247
247
  ./node_modules/.bin/context-guard setup --root . --agent claude --scope project --bash-reference-v1 --plan
248
248
  ./node_modules/.bin/context-guard setup --root . --agent claude --scope project --bash-reference-v1 --yes
249
249
  ```
@@ -31,14 +31,14 @@ Project scope is the default. `context-guard doctor` and `context-guard setup --
31
31
 
32
32
  `bash_reference_v1` is a narrower distribution than the ordinary CLI/plugin.
33
33
  It accepts only an exact project-local npm topology: root
34
- `@ictechgy/context-guard@0.7.0` declares
34
+ `@ictechgy/context-guard@0.7.1` declares
35
35
  `@ictechgy/context-guard-receipt: 0.2.1`, and the installed Receipt inventory
36
36
  must match the SHA-256 trust anchor embedded in the root policy. Hoisted and
37
37
  nested npm dependency layouts are supported; global npm, `npx`, Homebrew,
38
38
  source-checkout, arbitrary `PATH`, and marketplace-plugin layouts are refused.
39
39
 
40
40
  ```bash
41
- npm install --save-exact @ictechgy/context-guard@0.7.0
41
+ npm install --save-exact @ictechgy/context-guard@0.7.1
42
42
  ./node_modules/.bin/context-guard setup --root . --agent claude --scope project --bash-reference-v1 --plan
43
43
  ./node_modules/.bin/context-guard setup --root . --agent claude --scope project --bash-reference-v1 --yes
44
44
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ictechgy/context-guard",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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.7.0"
40
+ "version": "0.7.1"
41
41
  }
@@ -48,7 +48,7 @@ npx @ictechgy/context-guard --version
48
48
 
49
49
  짧은 `bash_reference_v1` Bash 출력 경로는 이 marketplace plugin/소스 배치에서
50
50
  의도적으로 활성화되지 않습니다. 정확한 프로젝트 로컬
51
- `@ictechgy/context-guard@0.7.0` npm 설치와
51
+ `@ictechgy/context-guard@0.7.1` npm 설치와
52
52
  `@ictechgy/context-guard-receipt@0.2.1` 의존성이 필요하며, 그 뒤
53
53
  `setup --agent claude --scope project --bash-reference-v1`로 명시적으로
54
54
  활성화해야 합니다. Plugin setup은 동작하지 않는 reference flag를 설치하지 않고
@@ -48,7 +48,7 @@ npx @ictechgy/context-guard --version
48
48
 
49
49
  The compact `bash_reference_v1` Bash-output route is intentionally not
50
50
  available from this marketplace-plugin/source layout. It requires an exact
51
- project-local npm installation of `@ictechgy/context-guard@0.7.0` and its
51
+ project-local npm installation of `@ictechgy/context-guard@0.7.1` and its
52
52
  `@ictechgy/context-guard-receipt@0.2.1` dependency, then explicit
53
53
  `setup --agent claude --scope project --bash-reference-v1`. Plugin setup keeps
54
54
  legacy trimming and warns instead of installing a no-op reference flag. See the
@@ -13,6 +13,7 @@ import json
13
13
  import os
14
14
  import re
15
15
  import shutil
16
+ import stat
16
17
  import subprocess
17
18
  import sys
18
19
 
@@ -174,7 +175,35 @@ def _runtime_shell_argv() -> tuple[str, ...]:
174
175
  )
175
176
 
176
177
 
178
+ class UnsafeAdjacentWrapperError(RuntimeError):
179
+ """Adjacent wrapper is not a regular file opened without symlink following."""
180
+
181
+
177
182
  def _isolated_wrapper_prefix(wrapper: str) -> list[str]:
183
+ # O_NOFOLLOW+fstat rejects a symlink planted before this call; it cannot
184
+ # close the gap between this check and the later, separate process that
185
+ # actually execs the returned path (the shell string this hook emits is
186
+ # run by the harness, not by a child of this process, so the validated
187
+ # fd cannot be carried across that boundary). A post-check swap of the
188
+ # wrapper still requires write/rename authority over the install
189
+ # directory itself - the same authority needed to replace the CLI - so
190
+ # this is accepted as a residual risk outside this hook's threat model.
191
+ if not hasattr(os, "O_NOFOLLOW"):
192
+ raise UnsafeAdjacentWrapperError("O_NOFOLLOW is required for adjacent wrappers")
193
+ flags = os.O_RDONLY | os.O_NOFOLLOW
194
+ if hasattr(os, "O_CLOEXEC"):
195
+ flags |= os.O_CLOEXEC
196
+ try:
197
+ fd = os.open(wrapper, flags)
198
+ except OSError as exc:
199
+ raise UnsafeAdjacentWrapperError(
200
+ f"wrapper could not be opened without following symlinks: {exc}"
201
+ ) from exc
202
+ try:
203
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
204
+ raise UnsafeAdjacentWrapperError("wrapper is not a regular file")
205
+ finally:
206
+ os.close(fd)
178
207
  return [_approved_python_runtime(), "-I", os.path.realpath(wrapper)]
179
208
 
180
209
  # kubectl/docker/podman/oc 글로벌 옵션 중 다음 토큰을 value로 소비하는 형태.
@@ -2395,6 +2424,51 @@ def _is_explicit_noop_command(argv: tuple[str, ...]) -> bool:
2395
2424
  )
2396
2425
 
2397
2426
 
2427
+ def _wclass_advisory_is_safe(argv: tuple[str, ...]) -> bool:
2428
+ # 토큰 형태(known flag/value pairing)만 검증한다 — `--repo`/`--task-file`
2429
+ # 필수 여부나 `--vendor`의 허용값 같은 CLI 자체의 필수 옵션·enum 검증은
2430
+ # 의도적으로 위임한다(다운스트림 argparse가 이미 거부함). 이 predicate가
2431
+ # 막는 건 "인식 못 하는 트레일링 토큰이 조용히 통과하는 것"이지 CLI
2432
+ # 문법 전체가 아니다.
2433
+ if len(argv) < 2:
2434
+ return False
2435
+ if argv[1] == "review":
2436
+ return len(argv) == 2
2437
+ if argv[1] != "run":
2438
+ return False
2439
+
2440
+ value_flags = {"--repo", "--task-file", "--vendor", "--workflow"}
2441
+ confirm_seen = False
2442
+ index = 2
2443
+ while index < len(argv):
2444
+ token = argv[index]
2445
+ if token == "--confirm-task-egress":
2446
+ confirm_seen = True
2447
+ index += 1
2448
+ continue
2449
+ if "=" in token:
2450
+ flag, value = token.split("=", 1)
2451
+ if flag not in value_flags or not value or value.startswith("-"):
2452
+ return False
2453
+ index += 1
2454
+ continue
2455
+ if token not in value_flags or index + 1 >= len(argv):
2456
+ return False
2457
+ value = argv[index + 1]
2458
+ if not value or value.startswith("-"):
2459
+ return False
2460
+ index += 2
2461
+ return confirm_seen
2462
+
2463
+
2464
+ # 심사된 정확 이름 확장 레지스트리 — 글롭/접두사 금지(R-12의 TERM*→TERMINFO
2465
+ # 실패 재현 방지). 각 값은 서브커맨드 토큰 하나만이 아니라 argv 전체 모양을
2466
+ # 검증하는 predicate여야 한다.
2467
+ CGW_EXACT_NAME_EXTENSIONS = {
2468
+ "wclass-advisory": _wclass_advisory_is_safe,
2469
+ }
2470
+
2471
+
2398
2472
  def command_search_diff(
2399
2473
  argv: tuple[str, ...],
2400
2474
  *,
@@ -2530,6 +2604,8 @@ def command_search_diff(
2530
2604
  route = "trim"
2531
2605
  else:
2532
2606
  route = "noop"
2607
+ elif first in CGW_EXACT_NAME_EXTENSIONS:
2608
+ route = "noop" if CGW_EXACT_NAME_EXTENSIONS[first](argv) else "deny"
2533
2609
  elif first in {"pytest", "tox", "jest", "vitest"}:
2534
2610
  route = "trim"
2535
2611
  elif first in {"find", "tree", "fd"}:
@@ -2834,7 +2910,7 @@ def _clear_git_command_scope_config(environment: dict[str, str]) -> None:
2834
2910
  environment.pop(name, None)
2835
2911
 
2836
2912
 
2837
- def _discover_git_filter_config_keys() -> tuple[str, ...]:
2913
+ def _discover_git_filter_config_keys(git_executable: str) -> tuple[str, ...]:
2838
2914
  discovery_env = os.environ.copy()
2839
2915
  _clear_git_command_scope_config(discovery_env)
2840
2916
  discovery_env.update(
@@ -2846,7 +2922,7 @@ def _discover_git_filter_config_keys() -> tuple[str, ...]:
2846
2922
  )
2847
2923
  result = subprocess.run(
2848
2924
  [
2849
- "git",
2925
+ git_executable,
2850
2926
  "config",
2851
2927
  "--null",
2852
2928
  "--name-only",
@@ -2903,13 +2979,14 @@ def run_guarded_git(argv: tuple[str, ...]) -> int:
2903
2979
  print("ContextGuard denied an invalid guarded Git invocation.", file=sys.stderr)
2904
2980
  return 126
2905
2981
  try:
2906
- filter_keys = _discover_git_filter_config_keys()
2982
+ git_executable = _approved_runtime_executable("git")
2983
+ filter_keys = _discover_git_filter_config_keys(git_executable)
2907
2984
  environment = _guarded_git_environment(filter_keys)
2908
- os.execvpe(guarded_argv[0], list(guarded_argv), environment)
2985
+ os.execve(git_executable, list(guarded_argv), environment)
2909
2986
  except (OSError, RuntimeError, subprocess.SubprocessError):
2910
2987
  print("ContextGuard could not neutralize Git execution configuration.", file=sys.stderr)
2911
2988
  return 126
2912
- raise AssertionError("os.execvpe returned unexpectedly")
2989
+ raise AssertionError("os.execve returned unexpectedly")
2913
2990
 
2914
2991
 
2915
2992
  def neutralize_git_config_execution(command: str, parsed: MiniShellParse) -> str:
@@ -2,8 +2,13 @@
2
2
  """Sanitize grep/diff/log output before it enters Claude context.
3
3
 
4
4
  The helper can wrap a command while preserving its exit code, or sanitize stdin.
5
- It redacts common credential patterns, anonymizes absolute paths by default, and
6
- keeps only bounded head/anchor/tail context when output is too large.
5
+ It redacts common credential patterns unconditionally and keeps only bounded
6
+ head/anchor/tail context when output is too large. Absolute-path anonymization
7
+ is scoped, not default: `anonymize_paths_for_context()` only rewrites paths it
8
+ can structurally prove are private (e.g. `filesystem_listing` matches under a
9
+ declared private root) - the `unknown_text`/`source_code` contexts used for
10
+ ordinary command output leave paths untouched by design, to avoid redacting
11
+ public/unrelated paths incorrectly.
7
12
  """
8
13
  from __future__ import annotations
9
14
 
@@ -109,13 +109,22 @@ FALLBACK_INLINE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
109
109
  (re.compile(r"(?i)\bAIza[0-9A-Za-z_\-]{20,}\b"), "[REDACTED]"),
110
110
  (re.compile(r"\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b"), "[REDACTED]"),
111
111
  (re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"), "[REDACTED]"),
112
- (re.compile(r"([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@", re.IGNORECASE), r"\1[REDACTED]@"),
112
+ (re.compile(r"([a-z][a-z0-9+.-]*://)[^/\s:@]+(?::[^/\s@]+)?@", re.IGNORECASE), r"\1[REDACTED]@"),
113
113
  (re.compile(rf"(?i)([?&#;](?:{SECRET_KEY})=)[^\s&#;]+"), r"\1[REDACTED]"),
114
114
  (re.compile(rf"(?i)(\b(?:{SECRET_KEY})\s*[:=]\s*)[^\s]+"), r"\1[REDACTED]"),
115
115
  )
116
116
  FALLBACK_AUTH_HEADER_RE = re.compile(
117
117
  r"(?i)^(?P<prefix>\s*(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:Proxy-)?Authorization\s*:\s*).+$"
118
118
  )
119
+ PRIVATE_KEY_BEGIN_RE = re.compile(
120
+ r"-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----"
121
+ )
122
+ PRIVATE_KEY_END_RE = re.compile(
123
+ r"-----END (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----"
124
+ )
125
+ COOKIE_HEADER_RE = re.compile(
126
+ r"(?i)^(?P<prefix>\s*(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:Set-)?Cookie\s*:\s*).+$"
127
+ )
119
128
  ERROR_RE = re.compile(
120
129
  r"(FAIL|FAILED|ERROR|Error:|Exception|Traceback|AssertionError|panic:|fatal:|"
121
130
  r"segmentation fault|not ok|\bE\s+assert|\[ERROR\]|✗|✖)",
@@ -171,6 +180,7 @@ class FallbackLineSanitizer:
171
180
  self.diagnostic = diagnostic
172
181
  self.diagnostic_emitted = False
173
182
  self.redactions = 0
183
+ self.in_private_key_block = False
174
184
 
175
185
  def sanitize(self, raw_line: str) -> tuple[str, bool]:
176
186
  if self.diagnostic and not self.diagnostic_emitted:
@@ -178,12 +188,22 @@ class FallbackLineSanitizer:
178
188
  self.diagnostic_emitted = True
179
189
  line = strip_ansi(raw_line)
180
190
  original = line
181
- auth_match = FALLBACK_AUTH_HEADER_RE.match(line)
182
- if auth_match:
183
- line = auth_match.group("prefix") + "[REDACTED]\n"
191
+ if self.in_private_key_block or PRIVATE_KEY_BEGIN_RE.search(line) or PRIVATE_KEY_END_RE.search(line):
192
+ line = "[REDACTED PRIVATE KEY BLOCK]\n"
193
+ if PRIVATE_KEY_END_RE.search(original):
194
+ self.in_private_key_block = False
195
+ elif PRIVATE_KEY_BEGIN_RE.search(original):
196
+ self.in_private_key_block = True
184
197
  else:
185
- for pattern, repl in FALLBACK_INLINE_PATTERNS:
186
- line = pattern.sub(repl, line)
198
+ auth_match = FALLBACK_AUTH_HEADER_RE.match(line)
199
+ cookie_match = COOKIE_HEADER_RE.match(line)
200
+ if auth_match:
201
+ line = auth_match.group("prefix") + "[REDACTED]\n"
202
+ elif cookie_match:
203
+ line = cookie_match.group("prefix") + "[REDACTED]\n"
204
+ else:
205
+ for pattern, repl in FALLBACK_INLINE_PATTERNS:
206
+ line = pattern.sub(repl, line)
187
207
  redacted = line != original
188
208
  if redacted:
189
209
  self.redactions += 1