@ictechgy/context-guard 0.4.15 → 0.5.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.ko.md +128 -2
  3. package/README.md +144 -3
  4. package/docs/distribution.md +100 -0
  5. package/package.json +4 -1
  6. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  7. package/plugins/context-guard/README.ko.md +43 -1
  8. package/plugins/context-guard/README.md +44 -1
  9. package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
  10. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  11. package/plugins/context-guard/bin/context-guard-audit +169 -66
  12. package/plugins/context-guard/bin/context-guard-bench +9865 -211
  13. package/plugins/context-guard/bin/context-guard-compress +90 -8
  14. package/plugins/context-guard/bin/context-guard-diet +1 -7
  15. package/plugins/context-guard/bin/context-guard-experiments +5 -1
  16. package/plugins/context-guard/bin/context-guard-failed-nudge +777 -83
  17. package/plugins/context-guard/bin/context-guard-guard-read +496 -57
  18. package/plugins/context-guard/bin/context-guard-mcp +2 -1
  19. package/plugins/context-guard/bin/context-guard-pack +1570 -150
  20. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  21. package/plugins/context-guard/bin/context-guard-rewrite-bash +2669 -236
  22. package/plugins/context-guard/bin/context-guard-sanitize-output +723 -92
  23. package/plugins/context-guard/bin/context-guard-setup +1944 -222
  24. package/plugins/context-guard/bin/context-guard-statusline +163 -55
  25. package/plugins/context-guard/bin/context-guard-statusline-merged +78 -23
  26. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  27. package/plugins/context-guard/bin/context-guard-trim-output +795 -48
  28. package/plugins/context-guard/brief/README.md +19 -0
  29. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  30. package/plugins/context-guard/lib/context_guard_commands.py +10 -2
  31. package/plugins/context-guard/lib/credential_policy.py +185 -0
  32. package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
@@ -1,16 +1,67 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
+ # This script is also callable directly, so do not let workspace-controlled
5
+ # command lookup or Python startup hooks select its runtime dependencies.
6
+ PATH=/usr/bin:/bin
7
+ export PATH
8
+ unset BASH_ENV ENV CDPATH PYTHONHOME PYTHONPATH PYTHONSTARTUP
9
+
10
+ approved_python=''
11
+ statusline_args=()
12
+ while (( $# > 0 )); do
13
+ case "$1" in
14
+ --approved-python)
15
+ if (( $# < 2 )); then
16
+ printf '[runtime-error] missing approved Python path\n'
17
+ exit 0
18
+ fi
19
+ approved_python=$2
20
+ shift 2
21
+ ;;
22
+ *)
23
+ statusline_args+=("$1")
24
+ shift
25
+ ;;
26
+ esac
27
+ done
28
+
4
29
  if [[ -t 0 ]]; then
5
30
  echo "usage: pass Claude Code statusline JSON on stdin"
6
31
  exit 0
7
32
  fi
8
33
 
9
- if ! command -v python3 >/dev/null 2>&1; then
34
+ if [[ -z "$approved_python" ]]; then
35
+ approved_python=$(command -v python3 2>/dev/null || true)
36
+ fi
37
+ if [[ "$approved_python" != /* || ! -f "$approved_python" || -L "$approved_python" || ! -x "$approved_python" ]]; then
10
38
  echo "[needs-python3] install python3 for Claude token statusline"
11
39
  exit 0
12
40
  fi
13
41
 
42
+ statusline_path=$0
43
+ for _context_guard_symlink_hop in {1..32}; do
44
+ [[ -L "$statusline_path" ]] || break
45
+ statusline_link_dir=$(CDPATH= cd -P -- "$(dirname -- "$statusline_path")" && pwd) || break
46
+ statusline_link_target=$(readlink "$statusline_path" 2>/dev/null) || break
47
+ if [[ "$statusline_link_target" = /* ]]; then
48
+ statusline_path=$statusline_link_target
49
+ else
50
+ statusline_path=$statusline_link_dir/$statusline_link_target
51
+ fi
52
+ done
53
+ statusline_dir=$(CDPATH= cd -P -- "$(dirname -- "$statusline_path")" && pwd)
54
+ usage_reducer_file=''
55
+ for candidate in \
56
+ "$statusline_dir/transcript_usage_reducer.py" \
57
+ "$statusline_dir/../lib/transcript_usage_reducer.py"; do
58
+ if [[ -f "$candidate" && ! -L "$candidate" ]]; then
59
+ usage_reducer_file=$(CDPATH= cd -P -- "$(dirname -- "$candidate")" && printf '%s/%s\n' "$PWD" "$(basename -- "$candidate")")
60
+ break
61
+ fi
62
+ done
63
+ export CONTEXT_GUARD_USAGE_REDUCER_FILE="$usage_reducer_file"
64
+
14
65
  read -r -d '' CONTEXT_GUARD_STATUSLINE_PY <<'PYEOF' || true
15
66
  from __future__ import annotations
16
67
 
@@ -22,14 +73,18 @@ import re
22
73
  import stat
23
74
  import sys
24
75
  import time
76
+ import types
25
77
  from typing import Any
26
78
 
27
79
  TAIL_BYTES = 1024 * 1024
28
80
  MAX_RECORDS = 300
29
- CACHE_SCHEMA_VERSION = 1
81
+ CACHE_SCHEMA_VERSION = 2
82
+ CACHE_REDUCER_SCHEMA = "usage-reducer-v2"
83
+ USAGE_METRIC_LABEL = "usage_tail_v2"
30
84
  DEFAULT_CACHE_TTL_SECONDS = 2.0
31
85
  MAX_CACHE_TTL_SECONDS = 30.0
32
86
  MAX_CACHE_BYTES = 4096
87
+ MAX_REDUCER_BYTES = 512 * 1024
33
88
  METRIC_RE = re.compile(r"^\d+(?:\.\d)?$")
34
89
  SECRET_RE = re.compile(
35
90
  r"(gh[pousr]_|github_pat_|glpat-|xox[abprs]-|AKIA|ASIA|sk-|npm_|AIza|Bearer\s|Basic\s)",
@@ -39,6 +94,44 @@ OSC_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
39
94
  CSI_RE = re.compile(r"\x1b[@-_][0-?]*[ -/]*[@-~]")
40
95
  CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
41
96
 
97
+ def _load_usage_reducer(path: str) -> tuple[str, Any | None]:
98
+ if not path or not os.path.isabs(path):
99
+ return CACHE_REDUCER_SCHEMA, None
100
+ fd = -1
101
+ try:
102
+ flags = os.O_RDONLY
103
+ if hasattr(os, "O_CLOEXEC"):
104
+ flags |= os.O_CLOEXEC
105
+ if hasattr(os, "O_NOFOLLOW"):
106
+ flags |= os.O_NOFOLLOW
107
+ fd = os.open(path, flags)
108
+ opened = os.fstat(fd)
109
+ if not stat.S_ISREG(opened.st_mode) or opened.st_size > MAX_REDUCER_BYTES:
110
+ return CACHE_REDUCER_SCHEMA, None
111
+ source = os.read(fd, opened.st_size + 1)
112
+ if len(source) != opened.st_size:
113
+ return CACHE_REDUCER_SCHEMA, None
114
+ module_name = "_context_guard_statusline_usage_reducer"
115
+ module = types.ModuleType(module_name)
116
+ module.__file__ = path
117
+ sys.modules[module_name] = module
118
+ exec(compile(source, path, "exec"), module.__dict__)
119
+ schema = getattr(module, "REDUCER_SCHEMA", "")
120
+ reducer = getattr(module, "UsageReducer", None)
121
+ if not isinstance(schema, str) or reducer is None:
122
+ return CACHE_REDUCER_SCHEMA, None
123
+ return schema, reducer
124
+ except Exception:
125
+ return CACHE_REDUCER_SCHEMA, None
126
+ finally:
127
+ if fd >= 0:
128
+ os.close(fd)
129
+
130
+
131
+ REDUCER_SCHEMA, UsageReducer = _load_usage_reducer(
132
+ os.environ.get("CONTEXT_GUARD_USAGE_REDUCER_FILE", "")
133
+ )
134
+
42
135
 
43
136
  def _bounded_int_env(primary: str, legacy: str, default: int, *, lower: int, upper: int) -> int:
44
137
  raw = os.environ.get(primary, os.environ.get(legacy, str(default)))
@@ -159,31 +252,6 @@ def git_head_branch(current: str) -> str | None:
159
252
  return None
160
253
 
161
254
 
162
- def _int_or_zero(value: Any) -> int:
163
- """Coerce transcript usage token values. bool is an int subclass, so block it."""
164
- if isinstance(value, bool):
165
- return 0
166
- if isinstance(value, int):
167
- return max(0, value)
168
- return 0
169
-
170
-
171
- def _extract_usage(record: Any) -> dict[str, Any] | None:
172
- """Extract one known transcript usage object without recursively double-counting copies."""
173
- if not isinstance(record, dict):
174
- return None
175
- for path_keys in (("usage",), ("message", "usage"), ("response", "usage")):
176
- cur: Any = record
177
- for key in path_keys:
178
- if not isinstance(cur, dict):
179
- cur = None
180
- break
181
- cur = cur.get(key)
182
- if isinstance(cur, dict):
183
- return cur
184
- return None
185
-
186
-
187
255
  def _open_regular_transcript(path: str) -> tuple[int, os.stat_result] | None:
188
256
  flags = os.O_RDONLY
189
257
  if hasattr(os, "O_CLOEXEC"):
@@ -278,6 +346,7 @@ def _identity(path: str, st: os.stat_result) -> dict[str, int | str]:
278
346
  absolute = os.path.abspath(path)
279
347
  path_hash = hashlib.sha256(os.fsencode(absolute)).hexdigest()
280
348
  return {
349
+ "reducer_schema": CACHE_REDUCER_SCHEMA,
281
350
  "path_hash": path_hash,
282
351
  "size": int(st.st_size),
283
352
  "mtime_ns": int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1_000_000_000))),
@@ -326,7 +395,7 @@ def _validated_metric(value: Any, *, minimum: float, maximum: float) -> str | No
326
395
  return value
327
396
 
328
397
 
329
- def _metric_parts(cache_pct: Any, reuse_x: Any) -> str | None:
398
+ def _metric_parts(cache_pct: Any, reuse_x: Any, window_partial: Any) -> str | None:
330
399
  cache_pct = _validated_metric(cache_pct, minimum=0.0, maximum=100.0)
331
400
  if cache_pct is None:
332
401
  return None
@@ -334,7 +403,13 @@ def _metric_parts(cache_pct: Any, reuse_x: Any) -> str | None:
334
403
  reuse_x = _validated_metric(reuse_x, minimum=0.0, maximum=1_000_000.0)
335
404
  if reuse_x is None:
336
405
  return None
337
- parts = [f"cache_pct={cache_pct}"]
406
+ if not isinstance(window_partial, bool):
407
+ return None
408
+ parts = [
409
+ f"usage_scope={USAGE_METRIC_LABEL}",
410
+ f"window_partial={'true' if window_partial else 'false'}",
411
+ f"cache_pct={cache_pct}",
412
+ ]
338
413
  if reuse_x:
339
414
  parts.append(f"reuse_x={reuse_x}")
340
415
  return " ".join(parts)
@@ -360,6 +435,10 @@ def _read_cache(identity: dict[str, int | str], workspace_dir: str, ttl: float)
360
435
  return None
361
436
  if data.get("schema_version") != CACHE_SCHEMA_VERSION:
362
437
  return None
438
+ if data.get("reducer_schema") != CACHE_REDUCER_SCHEMA:
439
+ return None
440
+ if data.get("metric_label") != USAGE_METRIC_LABEL:
441
+ return None
363
442
  computed_at = float(data.get("computed_at", 0))
364
443
  now = time.time()
365
444
  if not math.isfinite(computed_at):
@@ -369,12 +448,22 @@ def _read_cache(identity: dict[str, int | str], workspace_dir: str, ttl: float)
369
448
  for key, value in identity.items():
370
449
  if data.get(key) != value:
371
450
  return None
372
- return _metric_parts(data.get("cache_pct"), data.get("reuse_x"))
451
+ return _metric_parts(
452
+ data.get("cache_pct"),
453
+ data.get("reuse_x"),
454
+ data.get("window_partial"),
455
+ )
373
456
  except Exception:
374
457
  return None
375
458
 
376
459
 
377
- def _write_cache(identity: dict[str, int | str], workspace_dir: str, cache_pct: str, reuse_x: str | None) -> None:
460
+ def _write_cache(
461
+ identity: dict[str, int | str],
462
+ workspace_dir: str,
463
+ cache_pct: str,
464
+ reuse_x: str | None,
465
+ window_partial: bool,
466
+ ) -> None:
378
467
  ttl = _cache_ttl_seconds()
379
468
  if ttl <= 0:
380
469
  return
@@ -383,6 +472,8 @@ def _write_cache(identity: dict[str, int | str], workspace_dir: str, cache_pct:
383
472
  return
384
473
  payload = {
385
474
  "schema_version": CACHE_SCHEMA_VERSION,
475
+ "metric_label": USAGE_METRIC_LABEL,
476
+ "window_partial": window_partial,
386
477
  **identity,
387
478
  "computed_at": time.time(),
388
479
  "cache_pct": cache_pct,
@@ -418,9 +509,12 @@ def _write_cache(identity: dict[str, int | str], workspace_dir: str, cache_pct:
418
509
 
419
510
 
420
511
  def transcript_metrics(path: str, workspace_dir: str) -> str | None:
421
- input_tokens = 0
422
- cache_read = 0
423
- cache_creation = 0
512
+ if UsageReducer is None or REDUCER_SCHEMA != CACHE_REDUCER_SCHEMA:
513
+ print(
514
+ "context-guard statusline: usage reducer unavailable; transcript metrics omitted",
515
+ file=sys.stderr,
516
+ )
517
+ return None
424
518
  try:
425
519
  opened = _open_regular_transcript(path)
426
520
  if opened is None:
@@ -437,38 +531,38 @@ def transcript_metrics(path: str, workspace_dir: str) -> str | None:
437
531
  finally:
438
532
  os.close(fd)
439
533
  lines = chunk.splitlines()
534
+ window_partial = size > read_size
440
535
  if size > read_size and lines:
441
536
  lines = lines[1:]
442
- for raw in lines[-MAX_RECORDS:]:
443
- if not raw.strip():
444
- continue
537
+ lines = [raw for raw in lines if raw.strip()]
538
+ if len(lines) > MAX_RECORDS:
539
+ window_partial = True
540
+ lines = lines[-MAX_RECORDS:]
541
+ reducer = UsageReducer()
542
+ for ordinal, raw in enumerate(lines):
445
543
  try:
446
544
  obj = json.loads(raw)
447
545
  except Exception:
546
+ reducer.note_invalid_row()
448
547
  continue
449
- usage = _extract_usage(obj)
450
- if not usage:
451
- continue
452
- input_tokens += _int_or_zero(usage.get("input_tokens"))
453
- cr = usage.get("cache_read_input_tokens")
454
- if cr is None:
455
- cr = usage.get("cacheRead")
456
- cache_read += _int_or_zero(cr)
457
- cc = usage.get("cache_creation_input_tokens")
458
- if cc is None:
459
- cc = usage.get("cacheCreation")
460
- cache_creation += _int_or_zero(cc)
548
+ reducer.observe(
549
+ obj,
550
+ file_identity=str(identity["path_hash"]),
551
+ row_ordinal=ordinal,
552
+ )
553
+ reduced = reducer.finalize()
554
+ window_partial = window_partial or reduced.partial
555
+ input_tokens = reduced.tokens.get("input", 0)
556
+ cache_read = reduced.tokens.get("cache_read", 0)
557
+ cache_creation = reduced.tokens.get("cache_creation", 0)
461
558
  denom = input_tokens + cache_read + cache_creation
462
559
  if denom <= 0 or cache_read <= 0:
463
560
  return None
464
561
  pct = max(0.0, min(100.0, cache_read / denom * 100))
465
562
  cache_pct = f"{pct:.0f}"
466
563
  reuse_x = f"{cache_read / cache_creation:.1f}" if cache_creation > 0 else None
467
- _write_cache(identity, workspace_dir, cache_pct, reuse_x)
468
- parts = [f"cache_pct={cache_pct}"]
469
- if reuse_x:
470
- parts.append(f"reuse_x={reuse_x}")
471
- return " ".join(parts)
564
+ _write_cache(identity, workspace_dir, cache_pct, reuse_x, window_partial)
565
+ return _metric_parts(cache_pct, reuse_x, window_partial)
472
566
  except Exception:
473
567
  return None
474
568
 
@@ -537,15 +631,26 @@ def render_statusline(payload: dict[str, Any]) -> str:
537
631
  if raw_metrics:
538
632
  cache_pct = ""
539
633
  reuse_x = ""
634
+ usage_scope = ""
635
+ window_partial = ""
540
636
  for metric in raw_metrics.split():
541
637
  if metric.startswith("cache_pct="):
542
638
  cache_pct = metric[len("cache_pct=") :]
543
639
  elif metric.startswith("reuse_x="):
544
640
  reuse_x = metric[len("reuse_x=") :]
641
+ elif metric.startswith("usage_scope="):
642
+ usage_scope = metric[len("usage_scope=") :]
643
+ elif metric.startswith("window_partial="):
644
+ window_partial = metric[len("window_partial=") :]
545
645
  if cache_pct:
546
646
  metrics_label = f" | cache {sanitize_status(cache_pct)}%"
547
647
  if reuse_x:
548
648
  metrics_label += f" | reuse {sanitize_status(reuse_x)}x"
649
+ if usage_scope == USAGE_METRIC_LABEL and window_partial in {"true", "false"}:
650
+ metrics_label += (
651
+ f" | {USAGE_METRIC_LABEL} "
652
+ f"window_partial={window_partial}"
653
+ )
549
654
 
550
655
  return f"[{model}] {dir_label}{branch_label} | ctx {context_label} | cost {cost}{metrics_label}"
551
656
 
@@ -566,4 +671,7 @@ except BrokenPipeError:
566
671
  raise SystemExit(0)
567
672
  PYEOF
568
673
 
569
- exec python3 -c "$CONTEXT_GUARD_STATUSLINE_PY" "$@"
674
+ if (( ${#statusline_args[@]} > 0 )); then
675
+ exec "$approved_python" -I -c "$CONTEXT_GUARD_STATUSLINE_PY" "${statusline_args[@]}"
676
+ fi
677
+ exec "$approved_python" -I -c "$CONTEXT_GUARD_STATUSLINE_PY"
@@ -5,7 +5,7 @@
5
5
  # ─────────────────────────────────────────────────────────────────────────
6
6
  # OMC HUD 존재? │ token-statusline 존재? │ 출력
7
7
  # ─────────────────────────────────────────────────────────────────────────
8
- # yes │ yes │ OMC HUD + cost/cache/reuse 결합 (1줄)
8
+ # yes │ yes │ OMC HUD + cost/cache/reuse/scope 결합 (1줄)
9
9
  # yes │ no │ OMC HUD 단독
10
10
  # no │ yes │ token-statusline 단독
11
11
  # no │ no │ "[hud unavailable]"
@@ -14,17 +14,54 @@
14
14
  # 입력: stdin 으로 Claude Code 가 넘기는 statusline JSON 한 줄.
15
15
  # 출력: stdout 한 줄.
16
16
  #
17
- # 환경변수(선택, 테스트/커스텀 설치용):
18
- # OMC_HUD_SCRIPT OMC HUD 스크립트 경로 (기본 $HOME/.claude/hud/omc-hud.mjs)
19
- # CONTEXT_GUARD_STATUSLINE_BIN context-guard-statusline 바이너리 경로
20
- # (legacy: CLAUDE_TOKEN_STATUSLINE_BIN)
21
- # (미지정 시 자기 옆 디렉토리만 사용; PATH 탐색 안 함)
17
+ # 외부 HUD/runtime 연동은 ambient 환경변수가 아니라 setup이 고정한 절대 경로
18
+ # 옵션으로만 허용한다. 미지정 자기 옆 ContextGuard statusline만 사용한다.
22
19
  set -u
23
20
 
24
- if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
25
- printf 'ContextGuard helper: context-guard-statusline-merged\n'
26
- exit 0
27
- fi
21
+ PATH=/usr/bin:/bin
22
+ export PATH
23
+ unset BASH_ENV ENV CDPATH PYTHONHOME PYTHONPATH PYTHONSTARTUP
24
+ unset OMC_HUD_SCRIPT CONTEXT_GUARD_STATUSLINE_BIN CLAUDE_TOKEN_STATUSLINE_BIN
25
+
26
+ approved_bash=''
27
+ approved_python=''
28
+ approved_token_statusline=''
29
+ approved_node=''
30
+ approved_omc_script=''
31
+ while (( $# > 0 )); do
32
+ case "$1" in
33
+ --help|-h)
34
+ printf 'ContextGuard helper: context-guard-statusline-merged\n'
35
+ exit 0
36
+ ;;
37
+ --approved-bash|--approved-python|--approved-token-statusline|--approved-node|--approved-omc-script)
38
+ if (( $# < 2 )); then
39
+ printf '[runtime-error] missing approved runtime path\n'
40
+ exit 0
41
+ fi
42
+ case "$1" in
43
+ --approved-bash) approved_bash=$2 ;;
44
+ --approved-python) approved_python=$2 ;;
45
+ --approved-token-statusline) approved_token_statusline=$2 ;;
46
+ --approved-node) approved_node=$2 ;;
47
+ --approved-omc-script) approved_omc_script=$2 ;;
48
+ esac
49
+ shift 2
50
+ ;;
51
+ *)
52
+ printf '[runtime-error] unsupported statusline option\n'
53
+ exit 0
54
+ ;;
55
+ esac
56
+ done
57
+
58
+ approved_regular_file() {
59
+ [[ "$1" = /* && -f "$1" && ! -L "$1" ]]
60
+ }
61
+
62
+ approved_executable_file() {
63
+ approved_regular_file "$1" && [[ -x "$1" ]]
64
+ }
28
65
 
29
66
  statusline_input_tmp=''
30
67
 
@@ -81,8 +118,8 @@ read_bounded_statusline_input() {
81
118
  read_bounded_statusline_input
82
119
 
83
120
  strip_terminal_sequences() {
84
- if command -v perl >/dev/null 2>&1; then
85
- perl -pe 's/\e\][^\a\e]*(?:\a|\e\\)//g; s/\e[@-_][0-?]*[ -\/]*[@-~]//g'
121
+ if [[ -x /usr/bin/perl && -f /usr/bin/perl && ! -L /usr/bin/perl ]]; then
122
+ /usr/bin/perl -pe 's/\e\][^\a\e]*(?:\a|\e\\)//g; s/\e[@-_][0-?]*[ -\/]*[@-~]//g'
86
123
  else
87
124
  cat
88
125
  fi
@@ -106,18 +143,17 @@ sanitize_statusline() {
106
143
 
107
144
  # ── 1) OMC HUD 출력 ──────────────────────────────────────────────────────────
108
145
  omc_out=''
109
- omc_script="${OMC_HUD_SCRIPT:-$HOME/.claude/hud/omc-hud.mjs}"
110
- if [[ -r "$omc_script" ]] && command -v node >/dev/null 2>&1; then
111
- omc_out=$(printf '%s' "$input" | node "$omc_script" 2>/dev/null || true)
146
+ if approved_executable_file "$approved_node" && approved_regular_file "$approved_omc_script"; then
147
+ omc_out=$(printf '%s' "$input" | "$approved_node" "$approved_omc_script" 2>/dev/null || true)
112
148
  omc_out=$(sanitize_statusline "$omc_out")
113
149
  fi
114
150
 
115
151
  # ── 2) context-guard-statusline 바이너리 위치 결정 ────────────────────────────
116
- # 우선순위: 환경변수 → 자기 옆 디렉토리
117
- # PATH fallback 은 workspace/plugin 경로 shadowing 으로 untrusted binary 를
118
- # 실행할 있어 사용하지 않는다. 외부 바이너리를 쓰려면 명시적으로
119
- # CONTEXT_GUARD_STATUSLINE_BIN 을 지정해야 한다.
120
- tok_bin="${CONTEXT_GUARD_STATUSLINE_BIN:-${CLAUDE_TOKEN_STATUSLINE_BIN:-}}"
152
+ # setup이 고정한 절대 helper → 자기 옆 디렉토리 순서만 허용한다.
153
+ tok_bin=''
154
+ if approved_executable_file "$approved_token_statusline"; then
155
+ tok_bin=$approved_token_statusline
156
+ fi
121
157
  if [[ -z "$tok_bin" ]]; then
122
158
  self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)"
123
159
  for cand in \
@@ -131,14 +167,30 @@ if [[ -z "$tok_bin" ]]; then
131
167
  fi
132
168
  tok_out=''
133
169
  if [[ -n "$tok_bin" && -x "$tok_bin" ]]; then
134
- tok_out=$(printf '%s' "$input" | "$tok_bin" 2>/dev/null || true)
170
+ tok_basename=${tok_bin##*/}
171
+ if [[ "$tok_basename" == "context-guard-statusline" || "$tok_basename" == "claude-token-statusline" || "$tok_basename" == "statusline.sh" ]]; then
172
+ tok_bash=$approved_bash
173
+ if ! approved_executable_file "$tok_bash"; then
174
+ tok_bash=$(command -v bash 2>/dev/null || true)
175
+ fi
176
+ if approved_executable_file "$tok_bash"; then
177
+ tok_command=("$tok_bash" --noprofile --norc "$tok_bin")
178
+ if approved_executable_file "$approved_python"; then
179
+ tok_command+=(--approved-python "$approved_python")
180
+ fi
181
+ tok_out=$(printf '%s' "$input" | "${tok_command[@]}" 2>/dev/null || true)
182
+ fi
183
+ else
184
+ tok_out=$(printf '%s' "$input" | "$tok_bin" 2>/dev/null || true)
185
+ fi
135
186
  tok_out=$(sanitize_statusline "$tok_out")
136
187
  fi
137
188
 
138
189
  # ── 3) 결합: OMC HUD 가 살아있을 때만 token 출력에서 compact extras 만 뽑아 붙임 ─
139
190
  # token-statusline 형식:
140
- # "[model] dir | branch | ctx N% | cost $N.NNN | cache N% | reuse N.Nx"
141
- # OMC HUD 와 중복되는 model/dir/branch/ctx 는 버리고 cost/cache/reuse 만 채택한다.
191
+ # "[model] dir | branch | ctx N% | cost $N.NNN | cache N% | reuse N.Nx | usage_tail_v2 window_partial=BOOL"
192
+ # OMC HUD 와 중복되는 model/dir/branch/ctx 는 버리고 cost/cache/reuse
193
+ # bounded-tail scope disclosure 만 채택한다.
142
194
  extras=''
143
195
  if [[ -n "$omc_out" && -n "$tok_out" ]]; then
144
196
  if [[ "$tok_out" =~ \|[[:space:]]+cost[[:space:]]+(\$[0-9.]+|n/a) ]]; then
@@ -150,6 +202,9 @@ if [[ -n "$omc_out" && -n "$tok_out" ]]; then
150
202
  if [[ "$tok_out" =~ \|[[:space:]]+reuse[[:space:]]+([0-9]+(\.[0-9]+)?x|n/a) ]]; then
151
203
  extras+=" | reuse ${BASH_REMATCH[1]}"
152
204
  fi
205
+ if [[ "$tok_out" =~ \|[[:space:]]+(usage_tail_v2)[[:space:]]+(window_partial=(true|false)) ]]; then
206
+ extras+=" | ${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
207
+ fi
153
208
  fi
154
209
 
155
210
  # ── 4) 출력 ──────────────────────────────────────────────────────────────────
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import argparse
12
12
  import hashlib
13
+ import importlib.util
13
14
  import json
14
15
  import os
15
16
  import shlex
@@ -19,6 +20,7 @@ import stat
19
20
  import sys
20
21
  import time
21
22
  from dataclasses import dataclass
23
+ from types import ModuleType
22
24
  from typing import Any, NoReturn
23
25
 
24
26
  TOOL_NAME = "context-guard-tool-prune"
@@ -68,9 +70,6 @@ SECRET_RE = re.compile(
68
70
  r"(?<![A-Za-z0-9])(?:api[_-]?key|apikey|token|secret|password|client[_-]?secret|authorization|credential|signature|sig|private[_-]?key|privatekey|pgp[_-]?private[_-]?key|pgpprivatekey|ssh[_-]?key|sshkey|(?:aws[_-]?)?access[_-]?key(?:[_-]?id)?|awsaccesskeyid)\s*[:=]\s*[^\s,}\]]+"
69
71
  r")"
70
72
  )
71
- SENSITIVE_KEY_RE = re.compile(
72
- r"(?i)(authorization|api[_-]?key|apikey|token|secret|password|passwd|pwd|client[_-]?secret|credential|signature|sig|x-amz-signature|x-amz-credential|awsaccesskeyid|(?:aws[_-]?)?access[_-]?key(?:[_-]?id)?|private[_-]?key|privatekey|pgp[_-]?private[_-]?key|pgpprivatekey|ssh[_-]?key|sshkey)"
73
- )
74
73
  VALUE_BEARING_KEY_RE = re.compile(r"(?i)^(default|const|enum|example|examples|value|values)$")
75
74
 
76
75
 
@@ -135,20 +134,52 @@ def cap_text(value: object, limit: int = MAX_LABEL_CHARS) -> str:
135
134
  return text[: max(0, limit - len(marker))] + marker
136
135
 
137
136
 
137
+ def load_credential_policy() -> ModuleType:
138
+ script_dir = Path(__file__).resolve().parent
139
+ candidate = (
140
+ script_dir.parent / "lib" / "credential_policy.py"
141
+ if script_dir.name == "bin"
142
+ else script_dir / "credential_policy.py"
143
+ )
144
+ spec = importlib.util.spec_from_file_location(
145
+ "_context_guard_tool_prune_credential_policy",
146
+ candidate,
147
+ )
148
+ if spec is None or spec.loader is None:
149
+ raise RuntimeError(f"could not load credential policy: {candidate}")
150
+ module = importlib.util.module_from_spec(spec)
151
+ spec.loader.exec_module(module)
152
+ return module
153
+
154
+
155
+ _CREDENTIAL_POLICY = load_credential_policy()
156
+ normalize_sensitive_key = _CREDENTIAL_POLICY.normalize_sensitive_key
157
+ is_sensitive_key = _CREDENTIAL_POLICY.is_sensitive_key
158
+ redact_high_confidence_credentials = _CREDENTIAL_POLICY.redact_high_confidence_credentials
159
+
160
+
138
161
  def redact_string(value: str) -> tuple[str, int]:
162
+ value, redactions = redact_high_confidence_credentials(value)
163
+
139
164
  def repl(match: re.Match[str]) -> str:
165
+ nonlocal redactions
140
166
  text = match.group(0)
167
+ replacement = "[REDACTED]"
141
168
  if "=" in text:
142
169
  key = text.split("=", 1)[0]
143
- if SENSITIVE_KEY_RE.search(key):
144
- return key + "=[REDACTED]"
145
- if ":" in text:
170
+ if not is_sensitive_key(key):
171
+ return text
172
+ replacement = key + "=[REDACTED]"
173
+ elif ":" in text:
146
174
  key = text.split(":", 1)[0]
147
- if SENSITIVE_KEY_RE.search(key):
148
- return key + ": [REDACTED]"
149
- return "[REDACTED]"
175
+ if not is_sensitive_key(key):
176
+ return text
177
+ replacement = key + ": [REDACTED]"
178
+ if replacement != text:
179
+ redactions += 1
180
+ return replacement
150
181
 
151
- return SECRET_RE.subn(repl, value)
182
+ return SECRET_RE.sub(repl, value), redactions
152
183
 
153
184
 
154
185
  def redact_whole_value(value: Any) -> tuple[Any, int]:
@@ -169,6 +200,8 @@ def redact_whole_value(value: Any) -> tuple[Any, int]:
169
200
  out.append(sanitized)
170
201
  count += item_redactions
171
202
  return out, count
203
+ if value == "[REDACTED]":
204
+ return value, 0
172
205
  return "[REDACTED]", 1
173
206
 
174
207
 
@@ -191,7 +224,7 @@ def sanitize_value(value: Any, *, sensitive_context: bool = False, sensitive_sch
191
224
  for key, item in value.items():
192
225
  raw_key = str(key)
193
226
  safe_key, key_redactions = redact_string(raw_key)
194
- key_sensitive = bool(SENSITIVE_KEY_RE.search(raw_key))
227
+ key_sensitive = is_sensitive_key(raw_key)
195
228
  value_bearing = bool(VALUE_BEARING_KEY_RE.search(raw_key))
196
229
  if key_sensitive and not isinstance(item, dict):
197
230
  sanitized, item_redactions = sanitize_value(item, sensitive_context=True)