@ictechgy/context-guard 0.4.15 → 0.4.16
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 +8 -0
- package/README.ko.md +46 -1
- package/README.md +58 -2
- package/package.json +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +18 -0
- package/plugins/context-guard/README.md +18 -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 +5765 -224
- 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 +705 -83
- package/plugins/context-guard/bin/context-guard-guard-read +490 -55
- package/plugins/context-guard/bin/context-guard-pack +110 -11
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
- package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
- package/plugins/context-guard/bin/context-guard-setup +1073 -147
- package/plugins/context-guard/bin/context-guard-statusline +131 -54
- package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +89 -13
- 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 +6 -2
- package/plugins/context-guard/lib/credential_policy.py +177 -0
- package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
|
@@ -11,6 +11,29 @@ if ! command -v python3 >/dev/null 2>&1; then
|
|
|
11
11
|
exit 0
|
|
12
12
|
fi
|
|
13
13
|
|
|
14
|
+
statusline_path=$0
|
|
15
|
+
for _context_guard_symlink_hop in {1..32}; do
|
|
16
|
+
[[ -L "$statusline_path" ]] || break
|
|
17
|
+
statusline_link_dir=$(CDPATH= cd -P -- "$(dirname -- "$statusline_path")" && pwd) || break
|
|
18
|
+
statusline_link_target=$(readlink "$statusline_path" 2>/dev/null) || break
|
|
19
|
+
if [[ "$statusline_link_target" = /* ]]; then
|
|
20
|
+
statusline_path=$statusline_link_target
|
|
21
|
+
else
|
|
22
|
+
statusline_path=$statusline_link_dir/$statusline_link_target
|
|
23
|
+
fi
|
|
24
|
+
done
|
|
25
|
+
statusline_dir=$(CDPATH= cd -P -- "$(dirname -- "$statusline_path")" && pwd)
|
|
26
|
+
usage_reducer_file=''
|
|
27
|
+
for candidate in \
|
|
28
|
+
"$statusline_dir/transcript_usage_reducer.py" \
|
|
29
|
+
"$statusline_dir/../lib/transcript_usage_reducer.py"; do
|
|
30
|
+
if [[ -f "$candidate" && ! -L "$candidate" ]]; then
|
|
31
|
+
usage_reducer_file=$(CDPATH= cd -P -- "$(dirname -- "$candidate")" && printf '%s/%s\n' "$PWD" "$(basename -- "$candidate")")
|
|
32
|
+
break
|
|
33
|
+
fi
|
|
34
|
+
done
|
|
35
|
+
export CONTEXT_GUARD_USAGE_REDUCER_FILE="$usage_reducer_file"
|
|
36
|
+
|
|
14
37
|
read -r -d '' CONTEXT_GUARD_STATUSLINE_PY <<'PYEOF' || true
|
|
15
38
|
from __future__ import annotations
|
|
16
39
|
|
|
@@ -22,14 +45,18 @@ import re
|
|
|
22
45
|
import stat
|
|
23
46
|
import sys
|
|
24
47
|
import time
|
|
48
|
+
import types
|
|
25
49
|
from typing import Any
|
|
26
50
|
|
|
27
51
|
TAIL_BYTES = 1024 * 1024
|
|
28
52
|
MAX_RECORDS = 300
|
|
29
|
-
CACHE_SCHEMA_VERSION =
|
|
53
|
+
CACHE_SCHEMA_VERSION = 2
|
|
54
|
+
CACHE_REDUCER_SCHEMA = "usage-reducer-v2"
|
|
55
|
+
USAGE_METRIC_LABEL = "usage_tail_v2"
|
|
30
56
|
DEFAULT_CACHE_TTL_SECONDS = 2.0
|
|
31
57
|
MAX_CACHE_TTL_SECONDS = 30.0
|
|
32
58
|
MAX_CACHE_BYTES = 4096
|
|
59
|
+
MAX_REDUCER_BYTES = 512 * 1024
|
|
33
60
|
METRIC_RE = re.compile(r"^\d+(?:\.\d)?$")
|
|
34
61
|
SECRET_RE = re.compile(
|
|
35
62
|
r"(gh[pousr]_|github_pat_|glpat-|xox[abprs]-|AKIA|ASIA|sk-|npm_|AIza|Bearer\s|Basic\s)",
|
|
@@ -39,6 +66,44 @@ OSC_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
|
|
39
66
|
CSI_RE = re.compile(r"\x1b[@-_][0-?]*[ -/]*[@-~]")
|
|
40
67
|
CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
|
41
68
|
|
|
69
|
+
def _load_usage_reducer(path: str) -> tuple[str, Any | None]:
|
|
70
|
+
if not path or not os.path.isabs(path):
|
|
71
|
+
return CACHE_REDUCER_SCHEMA, None
|
|
72
|
+
fd = -1
|
|
73
|
+
try:
|
|
74
|
+
flags = os.O_RDONLY
|
|
75
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
76
|
+
flags |= os.O_CLOEXEC
|
|
77
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
78
|
+
flags |= os.O_NOFOLLOW
|
|
79
|
+
fd = os.open(path, flags)
|
|
80
|
+
opened = os.fstat(fd)
|
|
81
|
+
if not stat.S_ISREG(opened.st_mode) or opened.st_size > MAX_REDUCER_BYTES:
|
|
82
|
+
return CACHE_REDUCER_SCHEMA, None
|
|
83
|
+
source = os.read(fd, opened.st_size + 1)
|
|
84
|
+
if len(source) != opened.st_size:
|
|
85
|
+
return CACHE_REDUCER_SCHEMA, None
|
|
86
|
+
module_name = "_context_guard_statusline_usage_reducer"
|
|
87
|
+
module = types.ModuleType(module_name)
|
|
88
|
+
module.__file__ = path
|
|
89
|
+
sys.modules[module_name] = module
|
|
90
|
+
exec(compile(source, path, "exec"), module.__dict__)
|
|
91
|
+
schema = getattr(module, "REDUCER_SCHEMA", "")
|
|
92
|
+
reducer = getattr(module, "UsageReducer", None)
|
|
93
|
+
if not isinstance(schema, str) or reducer is None:
|
|
94
|
+
return CACHE_REDUCER_SCHEMA, None
|
|
95
|
+
return schema, reducer
|
|
96
|
+
except Exception:
|
|
97
|
+
return CACHE_REDUCER_SCHEMA, None
|
|
98
|
+
finally:
|
|
99
|
+
if fd >= 0:
|
|
100
|
+
os.close(fd)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
REDUCER_SCHEMA, UsageReducer = _load_usage_reducer(
|
|
104
|
+
os.environ.get("CONTEXT_GUARD_USAGE_REDUCER_FILE", "")
|
|
105
|
+
)
|
|
106
|
+
|
|
42
107
|
|
|
43
108
|
def _bounded_int_env(primary: str, legacy: str, default: int, *, lower: int, upper: int) -> int:
|
|
44
109
|
raw = os.environ.get(primary, os.environ.get(legacy, str(default)))
|
|
@@ -159,31 +224,6 @@ def git_head_branch(current: str) -> str | None:
|
|
|
159
224
|
return None
|
|
160
225
|
|
|
161
226
|
|
|
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
227
|
def _open_regular_transcript(path: str) -> tuple[int, os.stat_result] | None:
|
|
188
228
|
flags = os.O_RDONLY
|
|
189
229
|
if hasattr(os, "O_CLOEXEC"):
|
|
@@ -278,6 +318,7 @@ def _identity(path: str, st: os.stat_result) -> dict[str, int | str]:
|
|
|
278
318
|
absolute = os.path.abspath(path)
|
|
279
319
|
path_hash = hashlib.sha256(os.fsencode(absolute)).hexdigest()
|
|
280
320
|
return {
|
|
321
|
+
"reducer_schema": CACHE_REDUCER_SCHEMA,
|
|
281
322
|
"path_hash": path_hash,
|
|
282
323
|
"size": int(st.st_size),
|
|
283
324
|
"mtime_ns": int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1_000_000_000))),
|
|
@@ -326,7 +367,7 @@ def _validated_metric(value: Any, *, minimum: float, maximum: float) -> str | No
|
|
|
326
367
|
return value
|
|
327
368
|
|
|
328
369
|
|
|
329
|
-
def _metric_parts(cache_pct: Any, reuse_x: Any) -> str | None:
|
|
370
|
+
def _metric_parts(cache_pct: Any, reuse_x: Any, window_partial: Any) -> str | None:
|
|
330
371
|
cache_pct = _validated_metric(cache_pct, minimum=0.0, maximum=100.0)
|
|
331
372
|
if cache_pct is None:
|
|
332
373
|
return None
|
|
@@ -334,7 +375,13 @@ def _metric_parts(cache_pct: Any, reuse_x: Any) -> str | None:
|
|
|
334
375
|
reuse_x = _validated_metric(reuse_x, minimum=0.0, maximum=1_000_000.0)
|
|
335
376
|
if reuse_x is None:
|
|
336
377
|
return None
|
|
337
|
-
|
|
378
|
+
if not isinstance(window_partial, bool):
|
|
379
|
+
return None
|
|
380
|
+
parts = [
|
|
381
|
+
f"usage_scope={USAGE_METRIC_LABEL}",
|
|
382
|
+
f"window_partial={'true' if window_partial else 'false'}",
|
|
383
|
+
f"cache_pct={cache_pct}",
|
|
384
|
+
]
|
|
338
385
|
if reuse_x:
|
|
339
386
|
parts.append(f"reuse_x={reuse_x}")
|
|
340
387
|
return " ".join(parts)
|
|
@@ -360,6 +407,10 @@ def _read_cache(identity: dict[str, int | str], workspace_dir: str, ttl: float)
|
|
|
360
407
|
return None
|
|
361
408
|
if data.get("schema_version") != CACHE_SCHEMA_VERSION:
|
|
362
409
|
return None
|
|
410
|
+
if data.get("reducer_schema") != CACHE_REDUCER_SCHEMA:
|
|
411
|
+
return None
|
|
412
|
+
if data.get("metric_label") != USAGE_METRIC_LABEL:
|
|
413
|
+
return None
|
|
363
414
|
computed_at = float(data.get("computed_at", 0))
|
|
364
415
|
now = time.time()
|
|
365
416
|
if not math.isfinite(computed_at):
|
|
@@ -369,12 +420,22 @@ def _read_cache(identity: dict[str, int | str], workspace_dir: str, ttl: float)
|
|
|
369
420
|
for key, value in identity.items():
|
|
370
421
|
if data.get(key) != value:
|
|
371
422
|
return None
|
|
372
|
-
return _metric_parts(
|
|
423
|
+
return _metric_parts(
|
|
424
|
+
data.get("cache_pct"),
|
|
425
|
+
data.get("reuse_x"),
|
|
426
|
+
data.get("window_partial"),
|
|
427
|
+
)
|
|
373
428
|
except Exception:
|
|
374
429
|
return None
|
|
375
430
|
|
|
376
431
|
|
|
377
|
-
def _write_cache(
|
|
432
|
+
def _write_cache(
|
|
433
|
+
identity: dict[str, int | str],
|
|
434
|
+
workspace_dir: str,
|
|
435
|
+
cache_pct: str,
|
|
436
|
+
reuse_x: str | None,
|
|
437
|
+
window_partial: bool,
|
|
438
|
+
) -> None:
|
|
378
439
|
ttl = _cache_ttl_seconds()
|
|
379
440
|
if ttl <= 0:
|
|
380
441
|
return
|
|
@@ -383,6 +444,8 @@ def _write_cache(identity: dict[str, int | str], workspace_dir: str, cache_pct:
|
|
|
383
444
|
return
|
|
384
445
|
payload = {
|
|
385
446
|
"schema_version": CACHE_SCHEMA_VERSION,
|
|
447
|
+
"metric_label": USAGE_METRIC_LABEL,
|
|
448
|
+
"window_partial": window_partial,
|
|
386
449
|
**identity,
|
|
387
450
|
"computed_at": time.time(),
|
|
388
451
|
"cache_pct": cache_pct,
|
|
@@ -418,9 +481,12 @@ def _write_cache(identity: dict[str, int | str], workspace_dir: str, cache_pct:
|
|
|
418
481
|
|
|
419
482
|
|
|
420
483
|
def transcript_metrics(path: str, workspace_dir: str) -> str | None:
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
484
|
+
if UsageReducer is None or REDUCER_SCHEMA != CACHE_REDUCER_SCHEMA:
|
|
485
|
+
print(
|
|
486
|
+
"context-guard statusline: usage reducer unavailable; transcript metrics omitted",
|
|
487
|
+
file=sys.stderr,
|
|
488
|
+
)
|
|
489
|
+
return None
|
|
424
490
|
try:
|
|
425
491
|
opened = _open_regular_transcript(path)
|
|
426
492
|
if opened is None:
|
|
@@ -437,38 +503,38 @@ def transcript_metrics(path: str, workspace_dir: str) -> str | None:
|
|
|
437
503
|
finally:
|
|
438
504
|
os.close(fd)
|
|
439
505
|
lines = chunk.splitlines()
|
|
506
|
+
window_partial = size > read_size
|
|
440
507
|
if size > read_size and lines:
|
|
441
508
|
lines = lines[1:]
|
|
442
|
-
for raw in lines
|
|
443
|
-
|
|
444
|
-
|
|
509
|
+
lines = [raw for raw in lines if raw.strip()]
|
|
510
|
+
if len(lines) > MAX_RECORDS:
|
|
511
|
+
window_partial = True
|
|
512
|
+
lines = lines[-MAX_RECORDS:]
|
|
513
|
+
reducer = UsageReducer()
|
|
514
|
+
for ordinal, raw in enumerate(lines):
|
|
445
515
|
try:
|
|
446
516
|
obj = json.loads(raw)
|
|
447
517
|
except Exception:
|
|
518
|
+
reducer.note_invalid_row()
|
|
448
519
|
continue
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
cc = usage.get("cacheCreation")
|
|
460
|
-
cache_creation += _int_or_zero(cc)
|
|
520
|
+
reducer.observe(
|
|
521
|
+
obj,
|
|
522
|
+
file_identity=str(identity["path_hash"]),
|
|
523
|
+
row_ordinal=ordinal,
|
|
524
|
+
)
|
|
525
|
+
reduced = reducer.finalize()
|
|
526
|
+
window_partial = window_partial or reduced.partial
|
|
527
|
+
input_tokens = reduced.tokens.get("input", 0)
|
|
528
|
+
cache_read = reduced.tokens.get("cache_read", 0)
|
|
529
|
+
cache_creation = reduced.tokens.get("cache_creation", 0)
|
|
461
530
|
denom = input_tokens + cache_read + cache_creation
|
|
462
531
|
if denom <= 0 or cache_read <= 0:
|
|
463
532
|
return None
|
|
464
533
|
pct = max(0.0, min(100.0, cache_read / denom * 100))
|
|
465
534
|
cache_pct = f"{pct:.0f}"
|
|
466
535
|
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)
|
|
536
|
+
_write_cache(identity, workspace_dir, cache_pct, reuse_x, window_partial)
|
|
537
|
+
return _metric_parts(cache_pct, reuse_x, window_partial)
|
|
472
538
|
except Exception:
|
|
473
539
|
return None
|
|
474
540
|
|
|
@@ -537,15 +603,26 @@ def render_statusline(payload: dict[str, Any]) -> str:
|
|
|
537
603
|
if raw_metrics:
|
|
538
604
|
cache_pct = ""
|
|
539
605
|
reuse_x = ""
|
|
606
|
+
usage_scope = ""
|
|
607
|
+
window_partial = ""
|
|
540
608
|
for metric in raw_metrics.split():
|
|
541
609
|
if metric.startswith("cache_pct="):
|
|
542
610
|
cache_pct = metric[len("cache_pct=") :]
|
|
543
611
|
elif metric.startswith("reuse_x="):
|
|
544
612
|
reuse_x = metric[len("reuse_x=") :]
|
|
613
|
+
elif metric.startswith("usage_scope="):
|
|
614
|
+
usage_scope = metric[len("usage_scope=") :]
|
|
615
|
+
elif metric.startswith("window_partial="):
|
|
616
|
+
window_partial = metric[len("window_partial=") :]
|
|
545
617
|
if cache_pct:
|
|
546
618
|
metrics_label = f" | cache {sanitize_status(cache_pct)}%"
|
|
547
619
|
if reuse_x:
|
|
548
620
|
metrics_label += f" | reuse {sanitize_status(reuse_x)}x"
|
|
621
|
+
if usage_scope == USAGE_METRIC_LABEL and window_partial in {"true", "false"}:
|
|
622
|
+
metrics_label += (
|
|
623
|
+
f" | {USAGE_METRIC_LABEL} "
|
|
624
|
+
f"window_partial={window_partial}"
|
|
625
|
+
)
|
|
549
626
|
|
|
550
627
|
return f"[{model}] {dir_label}{branch_label} | ctx {context_label} | cost {cost}{metrics_label}"
|
|
551
628
|
|
|
@@ -566,4 +643,4 @@ except BrokenPipeError:
|
|
|
566
643
|
raise SystemExit(0)
|
|
567
644
|
PYEOF
|
|
568
645
|
|
|
569
|
-
exec python3 -c "$CONTEXT_GUARD_STATUSLINE_PY" "$@"
|
|
646
|
+
exec python3 -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]"
|
|
@@ -137,8 +137,9 @@ fi
|
|
|
137
137
|
|
|
138
138
|
# ── 3) 결합: OMC HUD 가 살아있을 때만 token 출력에서 compact extras 만 뽑아 붙임 ─
|
|
139
139
|
# 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
|
|
140
|
+
# "[model] dir | branch | ctx N% | cost $N.NNN | cache N% | reuse N.Nx | usage_tail_v2 window_partial=BOOL"
|
|
141
|
+
# OMC HUD 와 중복되는 model/dir/branch/ctx 는 버리고 cost/cache/reuse 및
|
|
142
|
+
# bounded-tail scope disclosure 만 채택한다.
|
|
142
143
|
extras=''
|
|
143
144
|
if [[ -n "$omc_out" && -n "$tok_out" ]]; then
|
|
144
145
|
if [[ "$tok_out" =~ \|[[:space:]]+cost[[:space:]]+(\$[0-9.]+|n/a) ]]; then
|
|
@@ -150,6 +151,9 @@ if [[ -n "$omc_out" && -n "$tok_out" ]]; then
|
|
|
150
151
|
if [[ "$tok_out" =~ \|[[:space:]]+reuse[[:space:]]+([0-9]+(\.[0-9]+)?x|n/a) ]]; then
|
|
151
152
|
extras+=" | reuse ${BASH_REMATCH[1]}"
|
|
152
153
|
fi
|
|
154
|
+
if [[ "$tok_out" =~ \|[[:space:]]+(usage_tail_v2)[[:space:]]+(window_partial=(true|false)) ]]; then
|
|
155
|
+
extras+=" | ${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
|
|
156
|
+
fi
|
|
153
157
|
fi
|
|
154
158
|
|
|
155
159
|
# ── 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)
|
|
@@ -143,8 +143,15 @@ class UnsafeAdjacentModuleError(RuntimeError):
|
|
|
143
143
|
|
|
144
144
|
|
|
145
145
|
class FallbackLineSanitizer:
|
|
146
|
-
def __init__(
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
show_paths: bool = False,
|
|
150
|
+
context: str = "unknown_text",
|
|
151
|
+
diagnostic: str | None = None,
|
|
152
|
+
) -> None:
|
|
147
153
|
self.show_paths = show_paths
|
|
154
|
+
self.context = context
|
|
148
155
|
self.diagnostic = diagnostic
|
|
149
156
|
self.diagnostic_emitted = False
|
|
150
157
|
self.redactions = 0
|
|
@@ -154,8 +161,6 @@ class FallbackLineSanitizer:
|
|
|
154
161
|
print(f"context-guard-kit: sanitizer fallback active: {self.diagnostic}", file=sys.stderr)
|
|
155
162
|
self.diagnostic_emitted = True
|
|
156
163
|
line = strip_ansi(raw_line)
|
|
157
|
-
if not self.show_paths:
|
|
158
|
-
line = anonymize_absolute_paths(line)
|
|
159
164
|
original = line
|
|
160
165
|
auth_match = FALLBACK_AUTH_HEADER_RE.match(line)
|
|
161
166
|
if auth_match:
|
|
@@ -223,11 +228,31 @@ def load_adjacent_python_module(script_dir: Path, name: str, *, module_prefix: s
|
|
|
223
228
|
module = types.ModuleType(module_name)
|
|
224
229
|
module.__file__ = str(script_dir / name)
|
|
225
230
|
module.__package__ = ""
|
|
226
|
-
|
|
231
|
+
sys.modules[module_name] = module
|
|
232
|
+
try:
|
|
233
|
+
exec(compile(source, str(script_dir / name), "exec"), module.__dict__)
|
|
234
|
+
except Exception:
|
|
235
|
+
sys.modules.pop(module_name, None)
|
|
236
|
+
raise
|
|
227
237
|
return module
|
|
228
238
|
|
|
229
239
|
|
|
230
|
-
def
|
|
240
|
+
def instantiate_line_sanitizer(factory: object, *, show_paths: bool, context: str) -> object:
|
|
241
|
+
try:
|
|
242
|
+
return factory(show_paths=show_paths, context=context) # type: ignore[operator]
|
|
243
|
+
except TypeError:
|
|
244
|
+
if context != "unknown_text":
|
|
245
|
+
raise RuntimeError(
|
|
246
|
+
"adjacent sanitizer does not support required explicit context"
|
|
247
|
+
)
|
|
248
|
+
# One compatibility window for unknown-text adjacent sanitizer stubs.
|
|
249
|
+
return factory(show_paths=show_paths) # type: ignore[operator]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def load_line_sanitizer(
|
|
253
|
+
show_paths: bool,
|
|
254
|
+
context: str = "unknown_text",
|
|
255
|
+
) -> object:
|
|
231
256
|
"""Reuse the stronger sanitizer when it is shipped next to this wrapper."""
|
|
232
257
|
script_dir = Path(__file__).resolve().parent
|
|
233
258
|
load_errors: list[str] = []
|
|
@@ -240,14 +265,22 @@ def load_line_sanitizer(show_paths: bool) -> object:
|
|
|
240
265
|
)
|
|
241
266
|
if module is None:
|
|
242
267
|
continue
|
|
243
|
-
return
|
|
268
|
+
return instantiate_line_sanitizer(
|
|
269
|
+
module.LineSanitizer,
|
|
270
|
+
show_paths=show_paths,
|
|
271
|
+
context=context,
|
|
272
|
+
)
|
|
244
273
|
except UnsafeAdjacentModuleError:
|
|
245
274
|
raise
|
|
246
275
|
except Exception as exc:
|
|
247
276
|
load_errors.append(f"{name} failed to load: {exc.__class__.__name__}: {exc}")
|
|
248
277
|
continue
|
|
249
278
|
diagnostic = "; ".join(load_errors) if load_errors else "strong sanitizer not found next to trim wrapper"
|
|
250
|
-
return FallbackLineSanitizer(
|
|
279
|
+
return FallbackLineSanitizer(
|
|
280
|
+
show_paths=show_paths,
|
|
281
|
+
context=context,
|
|
282
|
+
diagnostic=diagnostic,
|
|
283
|
+
)
|
|
251
284
|
|
|
252
285
|
|
|
253
286
|
def load_artifact_store_module() -> object:
|
|
@@ -508,7 +541,7 @@ def compact_item(
|
|
|
508
541
|
) -> str:
|
|
509
542
|
"""Normalize a failure-summary item without letting one log line dominate memory/output."""
|
|
510
543
|
if sanitizer is None:
|
|
511
|
-
sanitizer = load_line_sanitizer(show_paths)
|
|
544
|
+
sanitizer = load_line_sanitizer(show_paths, context="unknown_text")
|
|
512
545
|
sanitized, _ = sanitizer.sanitize(text) # type: ignore[attr-defined]
|
|
513
546
|
item = re.sub(r"\s+", " ", strip_ansi(sanitized).strip())
|
|
514
547
|
if len(item) <= limit:
|
|
@@ -529,7 +562,7 @@ class RunnerFailureSummary:
|
|
|
529
562
|
def __init__(self, max_items_per_runner: int, *, show_paths: bool = False) -> None:
|
|
530
563
|
self.max_items_per_runner = max(0, max_items_per_runner)
|
|
531
564
|
self.show_paths = show_paths
|
|
532
|
-
self.sanitizer = load_line_sanitizer(show_paths)
|
|
565
|
+
self.sanitizer = load_line_sanitizer(show_paths, context="unknown_text")
|
|
533
566
|
self.items: dict[str, list[str]] = collections.defaultdict(list)
|
|
534
567
|
self.seen: dict[str, set[str]] = collections.defaultdict(set)
|
|
535
568
|
self.jest_active = False
|
|
@@ -1478,6 +1511,15 @@ def main() -> int:
|
|
|
1478
1511
|
"(default: off; formats: markdown, json)"
|
|
1479
1512
|
),
|
|
1480
1513
|
)
|
|
1514
|
+
parser.add_argument(
|
|
1515
|
+
"--digest-always",
|
|
1516
|
+
action="store_true",
|
|
1517
|
+
help=(
|
|
1518
|
+
"keep the digest even when the command output is smaller than the digest; "
|
|
1519
|
+
"by default a smaller output is passed through so the digest cannot inflate "
|
|
1520
|
+
"context"
|
|
1521
|
+
),
|
|
1522
|
+
)
|
|
1481
1523
|
parser.add_argument(
|
|
1482
1524
|
"--artifact-receipt",
|
|
1483
1525
|
action="store_true",
|
|
@@ -1533,7 +1575,10 @@ def main() -> int:
|
|
|
1533
1575
|
return 2
|
|
1534
1576
|
|
|
1535
1577
|
try:
|
|
1536
|
-
line_sanitizer = load_line_sanitizer(
|
|
1578
|
+
line_sanitizer = load_line_sanitizer(
|
|
1579
|
+
args.show_paths,
|
|
1580
|
+
context="unknown_text",
|
|
1581
|
+
)
|
|
1537
1582
|
except UnsafeAdjacentModuleError as exc:
|
|
1538
1583
|
print(f"context-guard-kit: unsafe adjacent helper: {exc}", file=sys.stderr)
|
|
1539
1584
|
return 2
|
|
@@ -1693,10 +1738,41 @@ def main() -> int:
|
|
|
1693
1738
|
)
|
|
1694
1739
|
if guidance not in next_queries:
|
|
1695
1740
|
next_queries.insert(0, guidance)
|
|
1696
|
-
|
|
1697
|
-
|
|
1741
|
+
rendered = (
|
|
1742
|
+
render_digest_json(payload, args.max_chars)
|
|
1743
|
+
if args.digest == "json"
|
|
1744
|
+
else render_digest_markdown(payload, args.max_chars)
|
|
1745
|
+
)
|
|
1746
|
+
# digest 는 큰 출력을 줄이려는 기능이다. 출력이 작으면 digest 가 오히려 커져서
|
|
1747
|
+
# 컨텍스트를 늘리므로, 그럴 때는 원래 출력을 그대로 통과시킨다. artifact receipt 를
|
|
1748
|
+
# 저장한 경우에는 handle/재확장 명령이 digest 의 존재 이유이므로 폴백하지 않는다.
|
|
1749
|
+
passthrough = "".join(all_lines)
|
|
1750
|
+
marker = (
|
|
1751
|
+
"[context-guard-kit] digest skipped: it was larger than the command output\n"
|
|
1752
|
+
)
|
|
1753
|
+
digest_bytes = len(rendered.encode("utf-8"))
|
|
1754
|
+
passthrough_bytes = len(passthrough.encode("utf-8")) + len(marker.encode("utf-8"))
|
|
1755
|
+
# 폴백 조건은 보수적으로 둔다.
|
|
1756
|
+
# - 전체 출력이 예산 안에 들어와 all_lines 가 완전한 출력일 때만 통과시킨다.
|
|
1757
|
+
# 그렇지 않으면 잘린 출력을 원본처럼 내보내 정보를 잃는다.
|
|
1758
|
+
# - 실패한 명령에서는 digest 가 종료 코드/실패 signature 를 담으므로 유지한다.
|
|
1759
|
+
# - artifact receipt 를 요청했다면 handle/재확장 명령이 digest 의 존재 이유다.
|
|
1760
|
+
complete_output_available = (
|
|
1761
|
+
total <= args.max_lines
|
|
1762
|
+
and visible_chars <= args.max_chars
|
|
1763
|
+
and not any_line_capped
|
|
1764
|
+
)
|
|
1765
|
+
if (
|
|
1766
|
+
not args.digest_always
|
|
1767
|
+
and not args.artifact_receipt
|
|
1768
|
+
and rc == 0
|
|
1769
|
+
and complete_output_available
|
|
1770
|
+
and passthrough_bytes < digest_bytes
|
|
1771
|
+
):
|
|
1772
|
+
sys.stdout.write(marker)
|
|
1773
|
+
sys.stdout.write(passthrough)
|
|
1698
1774
|
else:
|
|
1699
|
-
sys.stdout.write(
|
|
1775
|
+
sys.stdout.write(rendered)
|
|
1700
1776
|
artifact_capture.close()
|
|
1701
1777
|
return rc
|
|
1702
1778
|
|
|
@@ -70,3 +70,22 @@ Each block is wrapped in stable markers:
|
|
|
70
70
|
To remove brief mode, delete the block between (and including) those two marker lines. Only
|
|
71
71
|
one brief-mode block should be present at a time; installing a different level replaces the
|
|
72
72
|
existing block rather than stacking a second one.
|
|
73
|
+
|
|
74
|
+
## Quiet narration is separate
|
|
75
|
+
|
|
76
|
+
[`narration-mode.quiet.md`](narration-mode.quiet.md) is a default-off, Claude-only rule for
|
|
77
|
+
reducing discretionary progress narration. It is not a brief-mode level and does not change
|
|
78
|
+
final-answer requirements or reasoning depth. Manage it only through the isolated,
|
|
79
|
+
project-scoped rules operation:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
context-guard setup --rules-only --agent claude --scope project --narration-mode quiet --plan
|
|
83
|
+
context-guard setup --rules-only --agent claude --scope project --narration-mode quiet --yes
|
|
84
|
+
context-guard setup --rules-only --agent claude --scope project --narration-mode default --yes
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This operation manages only the ContextGuard narration span in project `CLAUDE.md`; it does
|
|
88
|
+
not read or change Claude settings, hooks, permissions, statusline, model defaults, or other
|
|
89
|
+
agents' rule files. The rule preserves approvals and decisions, blockers, failures,
|
|
90
|
+
destructive or security warnings, required progress, final results, changed files, and
|
|
91
|
+
verification. It is best-effort guidance and does not guarantee token or cost savings.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!-- BEGIN context-guard:narration-mode mode=quiet version=1 -->
|
|
2
|
+
## ContextGuard quiet narration (advisory)
|
|
3
|
+
|
|
4
|
+
Best effort: reduce only discretionary intermediate narration. Skip routine preambles,
|
|
5
|
+
per-tool narration, filler, and repeated interim summaries when they add no useful
|
|
6
|
+
information.
|
|
7
|
+
|
|
8
|
+
Always preserve required user-facing communication:
|
|
9
|
+
|
|
10
|
+
- user approvals and decisions;
|
|
11
|
+
- blockers and failures;
|
|
12
|
+
- destructive-risk and security warnings;
|
|
13
|
+
- progress required by higher-priority instructions;
|
|
14
|
+
- the final result;
|
|
15
|
+
- changed files; and
|
|
16
|
+
- verification evidence.
|
|
17
|
+
|
|
18
|
+
This mode does not require a shorter final answer and does not change reasoning effort.
|
|
19
|
+
It asks Claude to reduce discretionary narration; it does not guarantee token or cost savings,
|
|
20
|
+
and no numeric savings should be claimed without matched provider evidence.
|
|
21
|
+
<!-- END context-guard:narration-mode -->
|