@ictechgy/context-guard 0.6.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 +31 -0
- package/README.ko.md +4 -1
- package/README.md +4 -1
- package/docs/distribution.md +2 -2
- package/package.json +1 -1
- package/packaging/homebrew/context-guard.rb.template +5 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +2 -1
- package/plugins/context-guard/README.md +3 -2
- package/plugins/context-guard/bin/context-guard-cost +327 -0
- package/plugins/context-guard/bin/context-guard-rewrite-bash +82 -5
- package/plugins/context-guard/bin/context-guard-sanitize-output +7 -2
- package/plugins/context-guard/bin/context-guard-trim-output +26 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@ 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
|
+
|
|
19
|
+
## [0.7.0] - 2026-08-22
|
|
20
|
+
|
|
21
|
+
- Added a zero-provider-context advisory mode for WeightClass-style routing.
|
|
22
|
+
Small tasks bypass without standing instructions, while larger tasks select
|
|
23
|
+
only locally eligible log trimming, symbol slicing, adaptive packing, or
|
|
24
|
+
cached graph expansion candidates that pass the configured gross-byte floor
|
|
25
|
+
and local-overhead budget. Those gates do not guarantee provider token or
|
|
26
|
+
cost savings. The bounded sample harness now counterbalances arm order,
|
|
27
|
+
charges preprocessing per advisory run, rejects ambiguous usage and cache
|
|
28
|
+
accounting, and keeps invalid historical measurements excluded.
|
|
29
|
+
- Kept live Claude collection behind safe mode, an empty tool surface, trusted
|
|
30
|
+
executable ancestry, a minimal non-redirectable environment, exact quality
|
|
31
|
+
checks, and explicit provider-egress confirmation. Live Codex collection
|
|
32
|
+
fails closed before local or provider action until the subscription CLI has
|
|
33
|
+
a preventive no-tools mode; provider-free Codex planning remains available.
|
|
34
|
+
- Hardened Homebrew formula verification through an isolated temporary tap,
|
|
35
|
+
preserved pre-existing installation state, rendered release-safe formula
|
|
36
|
+
syntax, and registered the formula template as an explicit support surface.
|
|
37
|
+
|
|
7
38
|
## [0.6.0] - 2026-08-21
|
|
8
39
|
|
|
9
40
|
- Added a provider-free `context-guard-receipt evaluate phase` surface with
|
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.
|
|
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
|
```
|
|
@@ -422,10 +422,13 @@ context-guard-pack auto --root . --query "checkout retry 수정" --diff worktree
|
|
|
422
422
|
```bash
|
|
423
423
|
./plugins/context-guard/bin/context-guard route-advisor --workload workload.json --json
|
|
424
424
|
./plugins/context-guard/bin/context-guard-cost route-advisor --feature batch_api=true --feature structured_outputs=true --json < workload.json
|
|
425
|
+
./plugins/context-guard/bin/context-guard cost advisory --workload advisory-workload.json --json
|
|
425
426
|
```
|
|
426
427
|
|
|
427
428
|
`context-guard route-advisor`는 로컬 passive advisor입니다. caller가 제공한 workload JSON, provider feature 선언, usage telemetry, 외부·로컬 shifted cost를 읽고 total-cost accounting, batchability blocker, batch API·prompt-cache prefix 보존·structured outputs·저비용 모델 평가 같은 route 후보를 출력합니다. queue를 시작하거나 provider를 호출하거나 pricing 문서를 새로 가져오지 않으며, provider feature는 caller-supplied 또는 unknown/recheck-required로 표시합니다. 추천은 후보일 뿐입니다. hosted token/cost 절감을 주장하려면 matched successful task, 비열등 quality gate, shifted-cost evidence가 필요합니다.
|
|
428
429
|
|
|
430
|
+
`context-guard cost advisory`는 WeightClass/router용 zero-persistent-context gate입니다. 닫힌 숫자·불리언 capability 신호만 받고, 모든 경로에서 provider context를 빈 값으로 유지하며, 작거나 순이익이 없는 작업을 bypass하고, cached positive replacement 근거가 있을 때만 graph를 허용합니다. 자세한 계약은 [WeightClass advisory mode](https://github.com/ictechgy/context-guard/blob/main/docs/weightclass-advisory-mode.md)를 참고하세요.
|
|
431
|
+
|
|
429
432
|
### 선택한 로컬 텍스트를 보수적으로 압축하기
|
|
430
433
|
|
|
431
434
|
```bash
|
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.
|
|
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
|
```
|
|
@@ -459,10 +459,13 @@ The packer uses deterministic standard-library heuristics only: no network, mode
|
|
|
459
459
|
```bash
|
|
460
460
|
./plugins/context-guard/bin/context-guard route-advisor --workload workload.json --json
|
|
461
461
|
./plugins/context-guard/bin/context-guard-cost route-advisor --feature batch_api=true --feature structured_outputs=true --json < workload.json
|
|
462
|
+
./plugins/context-guard/bin/context-guard cost advisory --workload advisory-workload.json --json
|
|
462
463
|
```
|
|
463
464
|
|
|
464
465
|
`context-guard route-advisor` is a local, passive advisor. It reads caller-supplied workload JSON, provider feature declarations, usage telemetry, and shifted external/local costs, then emits total-cost accounting, batchability blockers, and candidate routes such as batch API, prompt-cache prefix preservation, structured outputs, or cheaper-model evaluation. It does not start a queue, call providers, refresh pricing docs, or treat bundled provider feature knowledge as authoritative; unknown or caller-supplied features are marked recheck-required. Treat recommendations as candidates only. Hosted token or cost savings claims require matched successful tasks, non-inferior quality, and shifted-cost evidence.
|
|
465
466
|
|
|
467
|
+
`context-guard cost advisory` is the zero-persistent-context WeightClass/router gate. It accepts only closed numeric and boolean capability signals, returns an empty provider context on every path, bypasses small or non-profitable work, and permits graph only for cached positive replacement evidence. See [WeightClass advisory mode](https://github.com/ictechgy/context-guard/blob/main/docs/weightclass-advisory-mode.md).
|
|
468
|
+
|
|
466
469
|
### Compress selected local text conservatively
|
|
467
470
|
|
|
468
471
|
```bash
|
package/docs/distribution.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
1
4
|
# Homebrew formula draft for ContextGuard.
|
|
2
5
|
# Replace URL/SHA/tag during release packaging; do not publish with SHA placeholder.
|
|
3
6
|
class ContextGuard < Formula
|
|
@@ -30,6 +33,7 @@ class ContextGuard < Formula
|
|
|
30
33
|
test do
|
|
31
34
|
assert_match version.to_s, shell_output("#{bin}/context-guard --version")
|
|
32
35
|
mkdir testpath/"project"
|
|
33
|
-
system bin/"context-guard", "setup", "--root", testpath/"project",
|
|
36
|
+
system bin/"context-guard", "setup", "--root", testpath/"project",
|
|
37
|
+
"--agent", "codex", "--scope", "project", "--plan"
|
|
34
38
|
end
|
|
35
39
|
end
|
|
@@ -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.
|
|
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를 설치하지 않고
|
|
@@ -84,6 +84,7 @@ context-guard-compress --json < large-output.txt
|
|
|
84
84
|
context-guard cost preflight --request request.json --budget-krw 3000 --json
|
|
85
85
|
context-guard cost observe --usage usage.json --json
|
|
86
86
|
context-guard route-advisor --workload workload.json --json
|
|
87
|
+
context-guard cost advisory --workload advisory-workload.json --json
|
|
87
88
|
context-guard-trim-output --max-lines 120 -- npm test
|
|
88
89
|
context-guard-read-symbol path/to/file.py TargetSymbol
|
|
89
90
|
context-guard-sanitize-output -- rg -n "TOKEN|SECRET" .
|
|
@@ -48,8 +48,8 @@ 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.
|
|
52
|
-
`@ictechgy/context-guard-receipt@0.2.
|
|
51
|
+
project-local npm installation of `@ictechgy/context-guard@0.7.1` and its
|
|
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
|
|
55
55
|
repository distribution guide for activation, disablement, seven-day handle,
|
|
@@ -88,6 +88,7 @@ context-guard-compress --json --type prose --mode readable < sanitized-prose.txt
|
|
|
88
88
|
context-guard cost preflight --request request.json --budget-krw 3000 --json
|
|
89
89
|
context-guard cost observe --usage usage.json --json
|
|
90
90
|
context-guard route-advisor --workload workload.json --json
|
|
91
|
+
context-guard cost advisory --workload advisory-workload.json --json
|
|
91
92
|
context-guard-trim-output --max-lines 120 -- npm test
|
|
92
93
|
context-guard-read-symbol path/to/file.py TargetSymbol
|
|
93
94
|
context-guard-sanitize-output -- rg -n "TOKEN|SECRET" .
|
|
@@ -91,6 +91,37 @@ ROUTE_STRUCTURED_TASK_KINDS = {
|
|
|
91
91
|
"batch_eval",
|
|
92
92
|
"eval",
|
|
93
93
|
}
|
|
94
|
+
ADVISORY_WORKLOAD_SCHEMA_VERSION = "contextguard.advisory-workload.v1"
|
|
95
|
+
ADVISORY_DECISION_SCHEMA_VERSION = "contextguard.advisory-decision.v1"
|
|
96
|
+
ADVISORY_MAX_INTEGER = 1_000_000_000
|
|
97
|
+
ADVISORY_TOP_LEVEL_KEYS = {"invocation", "limits", "schema_version", "signals", "vendor"}
|
|
98
|
+
ADVISORY_INVOCATION_KEYS = {
|
|
99
|
+
"explicit_wrappers_available",
|
|
100
|
+
"hooks_available",
|
|
101
|
+
"host_tool_surface_equal_to_control",
|
|
102
|
+
"rules_loaded",
|
|
103
|
+
"safe_mode",
|
|
104
|
+
"skills_loaded",
|
|
105
|
+
}
|
|
106
|
+
ADVISORY_SIGNAL_INTEGER_KEYS = {
|
|
107
|
+
"candidate_context_bytes",
|
|
108
|
+
"estimated_local_overhead_ms",
|
|
109
|
+
"graph_candidate_bytes",
|
|
110
|
+
"graph_candidate_count",
|
|
111
|
+
"graph_replacement_bytes",
|
|
112
|
+
"largest_file_bytes",
|
|
113
|
+
"log_bytes",
|
|
114
|
+
"selected_file_count",
|
|
115
|
+
"task_prompt_bytes",
|
|
116
|
+
}
|
|
117
|
+
ADVISORY_SIGNAL_KEYS = ADVISORY_SIGNAL_INTEGER_KEYS | {"repo_map_cached"}
|
|
118
|
+
ADVISORY_LIMIT_KEYS = {
|
|
119
|
+
"inline_log_bytes",
|
|
120
|
+
"max_local_overhead_ms",
|
|
121
|
+
"minimum_gross_context_savings_bytes",
|
|
122
|
+
"pack_bytes",
|
|
123
|
+
"symbol_slice_bytes",
|
|
124
|
+
}
|
|
94
125
|
ALLOWED_FIRST_COMPONENT_SYMLINKS = {
|
|
95
126
|
"tmp": Path("/private/tmp"),
|
|
96
127
|
"var": Path("/private/var"),
|
|
@@ -272,6 +303,33 @@ def load_json_input(path: str, *, max_bytes: int = DEFAULT_MAX_BYTES) -> tuple[A
|
|
|
272
303
|
return data, truncated
|
|
273
304
|
|
|
274
305
|
|
|
306
|
+
def reject_duplicate_json_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
307
|
+
result: dict[str, Any] = {}
|
|
308
|
+
for key, value in pairs:
|
|
309
|
+
if key in result:
|
|
310
|
+
fail("duplicate JSON key is not allowed")
|
|
311
|
+
result[key] = value
|
|
312
|
+
return result
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def load_advisory_json_input(path: str, *, max_bytes: int) -> Any:
|
|
316
|
+
text, truncated = read_text_path(path, max_bytes=max_bytes)
|
|
317
|
+
if truncated:
|
|
318
|
+
fail("JSON input exceeded max bytes")
|
|
319
|
+
try:
|
|
320
|
+
return json.loads(
|
|
321
|
+
text,
|
|
322
|
+
parse_constant=reject_json_constant,
|
|
323
|
+
object_pairs_hook=reject_duplicate_json_pairs,
|
|
324
|
+
)
|
|
325
|
+
except json.JSONDecodeError as exc:
|
|
326
|
+
fail(f"invalid JSON input at line {exc.lineno}: {exc.msg}")
|
|
327
|
+
except ValueError as exc:
|
|
328
|
+
if isinstance(exc, CostGuardError):
|
|
329
|
+
raise
|
|
330
|
+
fail(f"invalid JSON input: {exc}")
|
|
331
|
+
|
|
332
|
+
|
|
275
333
|
def secret_count_in_text(text: str) -> int:
|
|
276
334
|
return sum(1 for _ in SECRET_RE.finditer(text))
|
|
277
335
|
|
|
@@ -2532,6 +2590,80 @@ def route_recommendations(
|
|
|
2532
2590
|
return recs
|
|
2533
2591
|
|
|
2534
2592
|
|
|
2593
|
+
def advisory_plain_object(value: Any, label: str, expected_keys: set[str]) -> dict[str, Any]:
|
|
2594
|
+
if type(value) is not dict:
|
|
2595
|
+
fail(f"{label} must be a plain JSON object")
|
|
2596
|
+
if set(value) != expected_keys:
|
|
2597
|
+
fail(f"{label} has missing or unknown fields")
|
|
2598
|
+
return value
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
def advisory_bool(value: Any, label: str) -> bool:
|
|
2602
|
+
if type(value) is not bool:
|
|
2603
|
+
fail(f"{label} must be boolean")
|
|
2604
|
+
return value
|
|
2605
|
+
|
|
2606
|
+
|
|
2607
|
+
def advisory_integer(value: Any, label: str, *, positive: bool = False) -> int:
|
|
2608
|
+
if type(value) is not int:
|
|
2609
|
+
fail(f"{label} must be an integer")
|
|
2610
|
+
lower_bound = 1 if positive else 0
|
|
2611
|
+
if value < lower_bound or value > ADVISORY_MAX_INTEGER:
|
|
2612
|
+
relation = "> 0" if positive else ">= 0"
|
|
2613
|
+
fail(f"{label} must be {relation} and bounded")
|
|
2614
|
+
return value
|
|
2615
|
+
|
|
2616
|
+
|
|
2617
|
+
def advisory_result(
|
|
2618
|
+
*,
|
|
2619
|
+
activation_status: str,
|
|
2620
|
+
decision: str,
|
|
2621
|
+
reason: str,
|
|
2622
|
+
measurement_eligible: bool,
|
|
2623
|
+
capabilities: dict[str, bool],
|
|
2624
|
+
selected_features: dict[str, bool],
|
|
2625
|
+
actions: list[dict[str, Any]],
|
|
2626
|
+
control_bytes: int,
|
|
2627
|
+
treatment_bytes: int,
|
|
2628
|
+
minimum_savings: int,
|
|
2629
|
+
local_overhead_ms: int,
|
|
2630
|
+
max_local_overhead_ms: int,
|
|
2631
|
+
graph_replacement_delta_bytes: int,
|
|
2632
|
+
) -> dict[str, Any]:
|
|
2633
|
+
gross_saved = max(0, control_bytes - treatment_bytes)
|
|
2634
|
+
return {
|
|
2635
|
+
"schema_version": ADVISORY_DECISION_SCHEMA_VERSION,
|
|
2636
|
+
"mode": "router_advisory",
|
|
2637
|
+
"activation_status": activation_status,
|
|
2638
|
+
"decision": decision,
|
|
2639
|
+
"reason": reason,
|
|
2640
|
+
"provider_context": "",
|
|
2641
|
+
"provider_context_bytes": 0,
|
|
2642
|
+
"persistent_writes_allowed": False,
|
|
2643
|
+
"receipts_enabled": False,
|
|
2644
|
+
"measurement_eligible": measurement_eligible,
|
|
2645
|
+
"capabilities": capabilities,
|
|
2646
|
+
"selected_features": selected_features,
|
|
2647
|
+
"actions": actions,
|
|
2648
|
+
"accounting": {
|
|
2649
|
+
"control_candidate_context_bytes": control_bytes,
|
|
2650
|
+
"estimated_treatment_context_bytes": treatment_bytes,
|
|
2651
|
+
"estimated_gross_context_saved_bytes": gross_saved,
|
|
2652
|
+
"minimum_gross_context_savings_bytes": minimum_savings,
|
|
2653
|
+
"estimated_local_overhead_ms": local_overhead_ms,
|
|
2654
|
+
"max_local_overhead_ms": max_local_overhead_ms,
|
|
2655
|
+
"graph_replacement_delta_bytes": graph_replacement_delta_bytes,
|
|
2656
|
+
},
|
|
2657
|
+
"claim_boundary": {
|
|
2658
|
+
"provider_token_or_cost_savings_claim_allowed": False,
|
|
2659
|
+
"requires_paired_provider_measurement": True,
|
|
2660
|
+
"requires_non_inferior_quality": True,
|
|
2661
|
+
"task_content_accepted": False,
|
|
2662
|
+
"persistent_context_allowed": False,
|
|
2663
|
+
},
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
|
|
2535
2667
|
def route_advisor_command(args: argparse.Namespace) -> int:
|
|
2536
2668
|
workload_raw, _truncated = load_json_input(args.workload, max_bytes=args.max_bytes)
|
|
2537
2669
|
workload = require_json_object(workload_raw.get("workload") if isinstance(workload_raw, dict) and isinstance(workload_raw.get("workload"), dict) else workload_raw, "workload")
|
|
@@ -2599,6 +2731,182 @@ def route_advisor_command(args: argparse.Namespace) -> int:
|
|
|
2599
2731
|
return 0
|
|
2600
2732
|
|
|
2601
2733
|
|
|
2734
|
+
def advisory_decision(raw: Any) -> dict[str, Any]:
|
|
2735
|
+
workload = advisory_plain_object(raw, "advisory workload", ADVISORY_TOP_LEVEL_KEYS)
|
|
2736
|
+
if workload["schema_version"] != ADVISORY_WORKLOAD_SCHEMA_VERSION:
|
|
2737
|
+
fail("advisory workload schema version is unsupported")
|
|
2738
|
+
vendor = workload["vendor"]
|
|
2739
|
+
if type(vendor) is not str or vendor not in {"claude", "codex"}:
|
|
2740
|
+
fail("advisory vendor must be claude or codex")
|
|
2741
|
+
invocation = advisory_plain_object(
|
|
2742
|
+
workload["invocation"], "advisory invocation", ADVISORY_INVOCATION_KEYS
|
|
2743
|
+
)
|
|
2744
|
+
invocation_values = {
|
|
2745
|
+
key: advisory_bool(invocation[key], f"advisory invocation.{key}")
|
|
2746
|
+
for key in sorted(ADVISORY_INVOCATION_KEYS)
|
|
2747
|
+
}
|
|
2748
|
+
if invocation_values["safe_mode"] and vendor != "claude":
|
|
2749
|
+
fail("advisory safe_mode is only valid for claude")
|
|
2750
|
+
signals = advisory_plain_object(
|
|
2751
|
+
workload["signals"], "advisory signals", ADVISORY_SIGNAL_KEYS
|
|
2752
|
+
)
|
|
2753
|
+
signal_values = {
|
|
2754
|
+
key: advisory_integer(signals[key], f"advisory signals.{key}")
|
|
2755
|
+
for key in sorted(ADVISORY_SIGNAL_INTEGER_KEYS)
|
|
2756
|
+
}
|
|
2757
|
+
signal_values["repo_map_cached"] = advisory_bool(
|
|
2758
|
+
signals["repo_map_cached"], "advisory signals.repo_map_cached"
|
|
2759
|
+
)
|
|
2760
|
+
limits = advisory_plain_object(
|
|
2761
|
+
workload["limits"], "advisory limits", ADVISORY_LIMIT_KEYS
|
|
2762
|
+
)
|
|
2763
|
+
limit_values = {
|
|
2764
|
+
key: advisory_integer(
|
|
2765
|
+
limits[key],
|
|
2766
|
+
f"advisory limits.{key}",
|
|
2767
|
+
positive=key in {"inline_log_bytes", "pack_bytes", "symbol_slice_bytes"},
|
|
2768
|
+
)
|
|
2769
|
+
for key in sorted(ADVISORY_LIMIT_KEYS)
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
control_bytes = signal_values["candidate_context_bytes"]
|
|
2773
|
+
if signal_values["largest_file_bytes"] > control_bytes:
|
|
2774
|
+
fail("largest_file_bytes exceeds candidate_context_bytes")
|
|
2775
|
+
if signal_values["log_bytes"] > control_bytes:
|
|
2776
|
+
fail("log_bytes exceeds candidate_context_bytes")
|
|
2777
|
+
if signal_values["graph_replacement_bytes"] > control_bytes:
|
|
2778
|
+
fail("graph_replacement_bytes exceeds candidate_context_bytes")
|
|
2779
|
+
graph_count = signal_values["graph_candidate_count"]
|
|
2780
|
+
graph_candidate_bytes = signal_values["graph_candidate_bytes"]
|
|
2781
|
+
graph_replacement_bytes = signal_values["graph_replacement_bytes"]
|
|
2782
|
+
if graph_count == 0 and (graph_candidate_bytes or graph_replacement_bytes):
|
|
2783
|
+
fail("graph bytes require at least one graph candidate")
|
|
2784
|
+
if graph_count > 0 and not (graph_candidate_bytes and graph_replacement_bytes):
|
|
2785
|
+
fail("graph candidates require nonzero candidate and replacement bytes")
|
|
2786
|
+
|
|
2787
|
+
hooks_effective = bool(
|
|
2788
|
+
invocation_values["hooks_available"]
|
|
2789
|
+
and not (vendor == "claude" and invocation_values["safe_mode"])
|
|
2790
|
+
)
|
|
2791
|
+
persistent_context_absent = not (
|
|
2792
|
+
invocation_values["rules_loaded"] or invocation_values["skills_loaded"]
|
|
2793
|
+
)
|
|
2794
|
+
capabilities = {
|
|
2795
|
+
"hooks_effective": hooks_effective,
|
|
2796
|
+
"explicit_wrappers_available": invocation_values["explicit_wrappers_available"],
|
|
2797
|
+
"persistent_context_absent": persistent_context_absent,
|
|
2798
|
+
"host_tool_surface_equal_to_control": invocation_values[
|
|
2799
|
+
"host_tool_surface_equal_to_control"
|
|
2800
|
+
],
|
|
2801
|
+
}
|
|
2802
|
+
no_features = {
|
|
2803
|
+
"adaptive": False,
|
|
2804
|
+
"graph": False,
|
|
2805
|
+
"symbol": False,
|
|
2806
|
+
"trim_output": False,
|
|
2807
|
+
}
|
|
2808
|
+
result_common = {
|
|
2809
|
+
"capabilities": capabilities,
|
|
2810
|
+
"control_bytes": control_bytes,
|
|
2811
|
+
"minimum_savings": limit_values["minimum_gross_context_savings_bytes"],
|
|
2812
|
+
"local_overhead_ms": signal_values["estimated_local_overhead_ms"],
|
|
2813
|
+
"max_local_overhead_ms": limit_values["max_local_overhead_ms"],
|
|
2814
|
+
}
|
|
2815
|
+
if not persistent_context_absent:
|
|
2816
|
+
return advisory_result(
|
|
2817
|
+
activation_status="bypass", decision="bypass",
|
|
2818
|
+
reason="persistent_context_loaded", measurement_eligible=False,
|
|
2819
|
+
selected_features=no_features, actions=[], treatment_bytes=control_bytes,
|
|
2820
|
+
graph_replacement_delta_bytes=0, **result_common,
|
|
2821
|
+
)
|
|
2822
|
+
if not invocation_values["host_tool_surface_equal_to_control"]:
|
|
2823
|
+
return advisory_result(
|
|
2824
|
+
activation_status="bypass", decision="bypass",
|
|
2825
|
+
reason="host_tool_surface_mismatch", measurement_eligible=False,
|
|
2826
|
+
selected_features=no_features, actions=[], treatment_bytes=control_bytes,
|
|
2827
|
+
graph_replacement_delta_bytes=0, **result_common,
|
|
2828
|
+
)
|
|
2829
|
+
|
|
2830
|
+
candidates: list[dict[str, Any]] = []
|
|
2831
|
+
log_bytes = signal_values["log_bytes"]
|
|
2832
|
+
inline_log_bytes = limit_values["inline_log_bytes"]
|
|
2833
|
+
if log_bytes > inline_log_bytes:
|
|
2834
|
+
candidates.append({
|
|
2835
|
+
"decision": "trim_output", "reason": "log_savings",
|
|
2836
|
+
"treatment_bytes": control_bytes - (log_bytes - inline_log_bytes),
|
|
2837
|
+
"features": {**no_features, "trim_output": True},
|
|
2838
|
+
"actions": [{"kind": "trim_output", "max_inline_bytes": inline_log_bytes}],
|
|
2839
|
+
"graph_replacement_delta_bytes": 0,
|
|
2840
|
+
})
|
|
2841
|
+
largest_file_bytes = signal_values["largest_file_bytes"]
|
|
2842
|
+
symbol_slice_bytes = limit_values["symbol_slice_bytes"]
|
|
2843
|
+
if largest_file_bytes > symbol_slice_bytes:
|
|
2844
|
+
candidates.append({
|
|
2845
|
+
"decision": "symbol_slice", "reason": "symbol_slice_savings",
|
|
2846
|
+
"treatment_bytes": control_bytes - (largest_file_bytes - symbol_slice_bytes),
|
|
2847
|
+
"features": {**no_features, "symbol": True},
|
|
2848
|
+
"actions": [{"kind": "symbol_slice", "max_bytes": symbol_slice_bytes}],
|
|
2849
|
+
"graph_replacement_delta_bytes": 0,
|
|
2850
|
+
})
|
|
2851
|
+
if signal_values["selected_file_count"] >= 3 and control_bytes > limit_values["pack_bytes"]:
|
|
2852
|
+
graph_net_saved = 0
|
|
2853
|
+
graph_selected = False
|
|
2854
|
+
if (
|
|
2855
|
+
signal_values["repo_map_cached"] and graph_count > 0
|
|
2856
|
+
and graph_replacement_bytes > graph_candidate_bytes
|
|
2857
|
+
):
|
|
2858
|
+
graph_net_saved = graph_replacement_bytes - graph_candidate_bytes
|
|
2859
|
+
graph_selected = True
|
|
2860
|
+
pack_bytes = limit_values["pack_bytes"]
|
|
2861
|
+
candidates.append({
|
|
2862
|
+
"decision": "adaptive_pack", "reason": "context_pack_savings",
|
|
2863
|
+
"treatment_bytes": pack_bytes,
|
|
2864
|
+
"features": {**no_features, "adaptive": True, "graph": graph_selected},
|
|
2865
|
+
"actions": [{
|
|
2866
|
+
"kind": "context_pack", "budget_bytes": limit_values["pack_bytes"],
|
|
2867
|
+
"adaptive": True, "symbol": False, "graph": graph_selected,
|
|
2868
|
+
}],
|
|
2869
|
+
"graph_replacement_delta_bytes": graph_net_saved,
|
|
2870
|
+
})
|
|
2871
|
+
candidate = min(candidates, key=lambda item: item["treatment_bytes"]) if candidates else None
|
|
2872
|
+
if candidate is None or (
|
|
2873
|
+
control_bytes - int(candidate["treatment_bytes"])
|
|
2874
|
+
< limit_values["minimum_gross_context_savings_bytes"]
|
|
2875
|
+
):
|
|
2876
|
+
return advisory_result(
|
|
2877
|
+
activation_status="bypass", decision="bypass", reason="below_break_even",
|
|
2878
|
+
measurement_eligible=True, selected_features=no_features, actions=[],
|
|
2879
|
+
treatment_bytes=control_bytes, graph_replacement_delta_bytes=0, **result_common,
|
|
2880
|
+
)
|
|
2881
|
+
if signal_values["estimated_local_overhead_ms"] > limit_values["max_local_overhead_ms"]:
|
|
2882
|
+
return advisory_result(
|
|
2883
|
+
activation_status="bypass", decision="bypass",
|
|
2884
|
+
reason="local_overhead_budget_exceeded", measurement_eligible=True,
|
|
2885
|
+
selected_features=no_features, actions=[], treatment_bytes=control_bytes,
|
|
2886
|
+
graph_replacement_delta_bytes=0, **result_common,
|
|
2887
|
+
)
|
|
2888
|
+
if not invocation_values["explicit_wrappers_available"]:
|
|
2889
|
+
return advisory_result(
|
|
2890
|
+
activation_status="inactive", decision="bypass",
|
|
2891
|
+
reason="explicit_wrappers_unavailable", measurement_eligible=False,
|
|
2892
|
+
selected_features=no_features, actions=[], treatment_bytes=control_bytes,
|
|
2893
|
+
graph_replacement_delta_bytes=0, **result_common,
|
|
2894
|
+
)
|
|
2895
|
+
return advisory_result(
|
|
2896
|
+
activation_status="active", decision=str(candidate["decision"]),
|
|
2897
|
+
reason=str(candidate["reason"]), measurement_eligible=True,
|
|
2898
|
+
selected_features=dict(candidate["features"]), actions=list(candidate["actions"]),
|
|
2899
|
+
treatment_bytes=int(candidate["treatment_bytes"]),
|
|
2900
|
+
graph_replacement_delta_bytes=int(candidate["graph_replacement_delta_bytes"]), **result_common,
|
|
2901
|
+
)
|
|
2902
|
+
|
|
2903
|
+
|
|
2904
|
+
def advisory_command(args: argparse.Namespace) -> int:
|
|
2905
|
+
workload = load_advisory_json_input(args.workload, max_bytes=args.max_bytes)
|
|
2906
|
+
emit(advisory_decision(workload), json_mode=args.json)
|
|
2907
|
+
return 0
|
|
2908
|
+
|
|
2909
|
+
|
|
2602
2910
|
def usage_int(data: dict[str, Any], key: str) -> int:
|
|
2603
2911
|
value = data.get(key, 0)
|
|
2604
2912
|
try:
|
|
@@ -3044,6 +3352,12 @@ def emit(data: dict[str, Any], *, json_mode: bool) -> None:
|
|
|
3044
3352
|
f"candidates={routing.get('candidate_count', 0)} conditional={routing.get('conditional_count', 0)} "
|
|
3045
3353
|
f"total_with_shift=${total.get('total_cost_with_shift_usd', 0)}"
|
|
3046
3354
|
)
|
|
3355
|
+
elif mode == "router_advisory":
|
|
3356
|
+
accounting = data.get("accounting", {}) if isinstance(data.get("accounting"), dict) else {}
|
|
3357
|
+
print(
|
|
3358
|
+
f"{TOOL_NAME}: advisory {data.get('decision', 'bypass')} "
|
|
3359
|
+
f"gross_context_bytes={accounting.get('estimated_gross_context_saved_bytes', 0)}"
|
|
3360
|
+
)
|
|
3047
3361
|
else:
|
|
3048
3362
|
summary = data.get("summary", {}) if isinstance(data.get("summary"), dict) else {}
|
|
3049
3363
|
print(f"{TOOL_NAME}: ledger entries={summary.get('entries', 0)}")
|
|
@@ -3113,6 +3427,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
3113
3427
|
add_common_cost_args(route)
|
|
3114
3428
|
route.set_defaults(func=route_advisor_command)
|
|
3115
3429
|
|
|
3430
|
+
advisory = sub.add_parser(
|
|
3431
|
+
"advisory",
|
|
3432
|
+
help="plan zero-persistent-context WeightClass/router advisory actions",
|
|
3433
|
+
description=(
|
|
3434
|
+
"select a zero-provider-instruction bypass or explicit local wrapper from "
|
|
3435
|
+
"closed numeric capability signals; never reads task text or project files"
|
|
3436
|
+
),
|
|
3437
|
+
)
|
|
3438
|
+
advisory.add_argument("--workload", default="-", help="closed advisory workload JSON path, or '-' for stdin")
|
|
3439
|
+
advisory.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES, help=f"maximum advisory JSON bytes (default: {DEFAULT_MAX_BYTES})")
|
|
3440
|
+
advisory.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
3441
|
+
advisory.set_defaults(func=advisory_command)
|
|
3442
|
+
|
|
3116
3443
|
return parser
|
|
3117
3444
|
|
|
3118
3445
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
6
|
-
|
|
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:@]
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|