@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.
- package/CHANGELOG.md +80 -0
- package/README.ko.md +128 -2
- package/README.md +144 -3
- package/docs/distribution.md +100 -0
- package/package.json +4 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +43 -1
- package/plugins/context-guard/README.md +44 -1
- package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
- package/plugins/context-guard/bin/context-guard-artifact +90 -9
- package/plugins/context-guard/bin/context-guard-audit +169 -66
- package/plugins/context-guard/bin/context-guard-bench +9865 -211
- package/plugins/context-guard/bin/context-guard-compress +90 -8
- package/plugins/context-guard/bin/context-guard-diet +1 -7
- package/plugins/context-guard/bin/context-guard-experiments +5 -1
- package/plugins/context-guard/bin/context-guard-failed-nudge +777 -83
- package/plugins/context-guard/bin/context-guard-guard-read +496 -57
- package/plugins/context-guard/bin/context-guard-mcp +2 -1
- package/plugins/context-guard/bin/context-guard-pack +1570 -150
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2669 -236
- package/plugins/context-guard/bin/context-guard-sanitize-output +723 -92
- package/plugins/context-guard/bin/context-guard-setup +1944 -222
- package/plugins/context-guard/bin/context-guard-statusline +163 -55
- package/plugins/context-guard/bin/context-guard-statusline-merged +78 -23
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +795 -48
- package/plugins/context-guard/brief/README.md +19 -0
- package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
- package/plugins/context-guard/lib/context_guard_commands.py +10 -2
- package/plugins/context-guard/lib/credential_policy.py +185 -0
- 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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
144
|
-
return
|
|
145
|
-
|
|
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
|
|
148
|
-
return
|
|
149
|
-
|
|
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.
|
|
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 =
|
|
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)
|