@ictechgy/context-guard 0.4.9 → 0.4.10
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 +16 -0
- package/README.ko.md +41 -24
- package/README.md +66 -26
- package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
- package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
- package/docs/distribution.md +10 -7
- package/docs/experimental-benchmark-fixtures.md +8 -1
- package/package.json +3 -6
- package/packaging/homebrew/context-guard.rb.template +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +9 -6
- package/plugins/context-guard/README.md +21 -13
- package/plugins/context-guard/bin/context-guard +113 -26
- package/plugins/context-guard/bin/context-guard-artifact +542 -46
- package/plugins/context-guard/bin/context-guard-cache-score +380 -0
- package/plugins/context-guard/bin/context-guard-compress +146 -1
- package/plugins/context-guard/bin/context-guard-cost +783 -4
- package/plugins/context-guard/bin/context-guard-experiments +99 -18
- package/plugins/context-guard/bin/context-guard-failed-nudge +3 -0
- package/plugins/context-guard/bin/context-guard-filter +163 -7
- package/plugins/context-guard/bin/context-guard-guard-read +3 -0
- package/plugins/context-guard/bin/context-guard-pack +602 -43
- package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
- package/plugins/context-guard/bin/context-guard-setup +165 -31
- package/plugins/context-guard/bin/context-guard-statusline +490 -283
- package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
- package/plugins/context-guard/bin/context-guard-tool-prune +241 -1
- package/plugins/context-guard/lib/context_guard_commands.py +206 -0
- package/plugins/context-guard/skills/setup/SKILL.md +1 -0
- package/context-guard-kit/README.md +0 -91
- package/context-guard-kit/benchmark_runner.py +0 -2401
- package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
- package/context-guard-kit/context_compress.py +0 -695
- package/context-guard-kit/context_escrow.py +0 -935
- package/context-guard-kit/context_filter.py +0 -637
- package/context-guard-kit/context_guard_cli.py +0 -325
- package/context-guard-kit/context_guard_diet.py +0 -1711
- package/context-guard-kit/context_pack.py +0 -2713
- package/context-guard-kit/cost_guard.py +0 -2349
- package/context-guard-kit/experimental_registry.py +0 -4348
- package/context-guard-kit/failed_attempt_nudge.py +0 -567
- package/context-guard-kit/guard_large_read.py +0 -690
- package/context-guard-kit/hook_secret_patterns.py +0 -43
- package/context-guard-kit/read_symbol.py +0 -483
- package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
- package/context-guard-kit/sanitize_output.py +0 -725
- package/context-guard-kit/settings.example.json +0 -67
- package/context-guard-kit/setup_wizard.py +0 -2515
- package/context-guard-kit/statusline.sh +0 -362
- package/context-guard-kit/statusline_merged.sh +0 -157
- package/context-guard-kit/tool_schema_pruner.py +0 -837
- package/context-guard-kit/trim_command_output.py +0 -1449
|
@@ -26,6 +26,7 @@ from socketserver import TCPServer
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
import stat
|
|
28
28
|
import sys
|
|
29
|
+
import time
|
|
29
30
|
from typing import Any, NoReturn
|
|
30
31
|
import unicodedata
|
|
31
32
|
from urllib.parse import urlparse
|
|
@@ -89,6 +90,7 @@ LOCAL_PROXY_SENSITIVE_HEADER_NAMES = {
|
|
|
89
90
|
"cookie",
|
|
90
91
|
"set-cookie",
|
|
91
92
|
}
|
|
93
|
+
LOCAL_PROXY_NONCE_HEADER = "X-ContextGuard-Proxy-Nonce"
|
|
92
94
|
LOCAL_PROXY_HOP_BY_HOP_HEADERS = {
|
|
93
95
|
"connection",
|
|
94
96
|
"keep-alive",
|
|
@@ -336,8 +338,8 @@ EXPERIMENTS: tuple[Experiment, ...] = (
|
|
|
336
338
|
"context-guard experiments plan local-proxy",
|
|
337
339
|
"context-guard experiments plan local-proxy-external-forwarding",
|
|
338
340
|
"context-guard experiments record local-proxy-runtime-gate --ledger-jsonl <path>",
|
|
339
|
-
"context-guard experiments serve local-proxy --bind-host 127.0.0.1 --bind-port <port> --target-host 127.0.0.1 --target-port <port> --runtime-gate-ack --forwarding-gate-ack --once",
|
|
340
|
-
"context-guard experiments serve local-proxy --diagnostic-ledger-jsonl <path> ...",
|
|
341
|
+
"context-guard experiments serve local-proxy --bind-host 127.0.0.1 --bind-port <port> --target-host 127.0.0.1 --target-port <port> --runtime-gate-ack --forwarding-gate-ack --once --ready-file <path>",
|
|
342
|
+
"context-guard experiments serve local-proxy --ready-file <ready-file> --diagnostic-ledger-jsonl <path> ...",
|
|
341
343
|
),
|
|
342
344
|
opt_in_flags=(
|
|
343
345
|
"plan local-proxy",
|
|
@@ -356,6 +358,7 @@ EXPERIMENTS: tuple[Experiment, ...] = (
|
|
|
356
358
|
"--max-request-bytes",
|
|
357
359
|
"--max-response-bytes",
|
|
358
360
|
"--diagnostic-ledger-jsonl",
|
|
361
|
+
"--ready-file",
|
|
359
362
|
"--external-forwarding-intent",
|
|
360
363
|
"--external-forwarding-design-ack",
|
|
361
364
|
"--allow-host",
|
|
@@ -372,10 +375,11 @@ EXPERIMENTS: tuple[Experiment, ...] = (
|
|
|
372
375
|
),
|
|
373
376
|
evidence_contract=(
|
|
374
377
|
"Gate rows require localhost-only bind/target metadata and explicit runtime gate acknowledgement. Serve "
|
|
375
|
-
"evidence requires loopback-only bind/target IPs,
|
|
376
|
-
"forwarding or persistence, bounded bytes/timeouts, and optional diagnostic
|
|
377
|
-
"shifted-cost evidence only. External-forwarding design plans require threat model
|
|
378
|
-
"allowlists, credential redaction policy, and provider-evidence boundaries before any future
|
|
378
|
+
"evidence requires loopback-only bind/target IPs, a private ready-file nonce handoff, explicit forwarding "
|
|
379
|
+
"acknowledgement, no credential forwarding or persistence, bounded bytes/timeouts, and optional diagnostic "
|
|
380
|
+
"ledger rows that remain shifted-cost evidence only. External-forwarding design plans require threat model "
|
|
381
|
+
"notes, explicit allowlists, credential redaction policy, and provider-evidence boundaries before any future "
|
|
382
|
+
"runtime."
|
|
379
383
|
),
|
|
380
384
|
),
|
|
381
385
|
)
|
|
@@ -2684,8 +2688,13 @@ def coalesce_local_proxy_bool(args: argparse.Namespace, payload: dict[str, Any],
|
|
|
2684
2688
|
return parse_local_proxy_json_bool(payload.get(key))
|
|
2685
2689
|
|
|
2686
2690
|
|
|
2687
|
-
def local_proxy_plan_payload(
|
|
2688
|
-
|
|
2691
|
+
def local_proxy_plan_payload(
|
|
2692
|
+
args: argparse.Namespace,
|
|
2693
|
+
input_payload: dict[str, Any] | None = None,
|
|
2694
|
+
input_meta: dict[str, Any] | None = None,
|
|
2695
|
+
) -> dict[str, Any]:
|
|
2696
|
+
if input_payload is None or input_meta is None:
|
|
2697
|
+
input_payload, input_meta = read_local_proxy_payload(args)
|
|
2689
2698
|
bind_host_raw = coalesce_local_proxy_value(args, input_payload, "bind_host", "bind_host")
|
|
2690
2699
|
bind_port_raw = coalesce_local_proxy_value(args, input_payload, "bind_port", "bind_port")
|
|
2691
2700
|
target_host_raw = coalesce_local_proxy_value(args, input_payload, "target_host", "target_host")
|
|
@@ -3001,8 +3010,8 @@ def command_record_local_proxy_runtime_gate(args: argparse.Namespace) -> int:
|
|
|
3001
3010
|
|
|
3002
3011
|
|
|
3003
3012
|
def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
|
|
3004
|
-
|
|
3005
|
-
|
|
3013
|
+
input_payload, input_meta = read_local_proxy_payload(args)
|
|
3014
|
+
payload = local_proxy_plan_payload(args, input_payload=input_payload, input_meta=input_meta)
|
|
3006
3015
|
forwarding_gate_ack, forwarding_gate_ack_valid = coalesce_local_proxy_bool(
|
|
3007
3016
|
args,
|
|
3008
3017
|
input_payload,
|
|
@@ -3085,6 +3094,15 @@ def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
3085
3094
|
"bytes_written": 0,
|
|
3086
3095
|
"reason": None if diagnostic_ledger_raw else "not_requested",
|
|
3087
3096
|
}
|
|
3097
|
+
payload["client_auth"] = {
|
|
3098
|
+
"required": True,
|
|
3099
|
+
"type": "nonce_header",
|
|
3100
|
+
"header": LOCAL_PROXY_NONCE_HEADER,
|
|
3101
|
+
"delivery": "ready_file",
|
|
3102
|
+
"ready_file_required": True,
|
|
3103
|
+
"nonce_in_public_output": False,
|
|
3104
|
+
"nonce_forwarded_upstream": False,
|
|
3105
|
+
}
|
|
3088
3106
|
payload["_diagnostic_ledger_write_path"] = diagnostic_ledger_write_path
|
|
3089
3107
|
payload["forward_result"] = None
|
|
3090
3108
|
|
|
@@ -3210,6 +3228,12 @@ def local_proxy_has_sensitive_headers(headers: Any) -> list[str]:
|
|
|
3210
3228
|
found: list[str] = []
|
|
3211
3229
|
for name, value in headers.items():
|
|
3212
3230
|
lower = str(name).lower()
|
|
3231
|
+
if lower == LOCAL_PROXY_NONCE_HEADER.lower():
|
|
3232
|
+
# The per-run proxy nonce is a local client-auth secret delivered only
|
|
3233
|
+
# through the 0600 ready file. It is validated before this check and is
|
|
3234
|
+
# never forwarded upstream; do not let random nonce bytes
|
|
3235
|
+
# probabilistically trip the generic secret-like header detector.
|
|
3236
|
+
continue
|
|
3213
3237
|
if lower in LOCAL_PROXY_SENSITIVE_HEADER_NAMES:
|
|
3214
3238
|
found.append(lower)
|
|
3215
3239
|
elif local_proxy_secret_like(name):
|
|
@@ -3240,7 +3264,7 @@ def local_proxy_response_headers(headers: Any) -> list[tuple[str, str]]:
|
|
|
3240
3264
|
return result
|
|
3241
3265
|
|
|
3242
3266
|
|
|
3243
|
-
def write_local_proxy_ready_file(path: str | None, *, bind_host: str, bind_port: int) -> None:
|
|
3267
|
+
def write_local_proxy_ready_file(path: str | None, *, bind_host: str, bind_port: int, auth_nonce: str) -> None:
|
|
3244
3268
|
if not path:
|
|
3245
3269
|
return
|
|
3246
3270
|
ready_payload = {
|
|
@@ -3254,6 +3278,14 @@ def write_local_proxy_ready_file(path: str | None, *, bind_host: str, bind_port:
|
|
|
3254
3278
|
"host": bind_host,
|
|
3255
3279
|
"port": bind_port,
|
|
3256
3280
|
},
|
|
3281
|
+
"client_auth": {
|
|
3282
|
+
"required": True,
|
|
3283
|
+
"type": "nonce_header",
|
|
3284
|
+
"header": LOCAL_PROXY_NONCE_HEADER,
|
|
3285
|
+
"nonce": auth_nonce,
|
|
3286
|
+
"forwarded_upstream": False,
|
|
3287
|
+
"public_output": False,
|
|
3288
|
+
},
|
|
3257
3289
|
}
|
|
3258
3290
|
data = json.dumps(ready_payload, sort_keys=True).encode("utf-8") + b"\n"
|
|
3259
3291
|
write_regular_file_no_follow_exclusive(Path(path), data, label="local proxy ready file", mode=0o600)
|
|
@@ -3268,6 +3300,7 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
|
|
|
3268
3300
|
max_request_bytes = int(limits["max_request_bytes"])
|
|
3269
3301
|
max_response_bytes = int(limits["max_response_bytes"])
|
|
3270
3302
|
timeout_seconds = float(limits["timeout_seconds"])
|
|
3303
|
+
auth_nonce = secrets.token_urlsafe(32)
|
|
3271
3304
|
server_result: dict[str, Any] = {
|
|
3272
3305
|
"served_once": False,
|
|
3273
3306
|
"forwarded": False,
|
|
@@ -3283,16 +3316,30 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
|
|
|
3283
3316
|
"sensitive_headers_blocked": [],
|
|
3284
3317
|
"listener_started": False,
|
|
3285
3318
|
"ready_file_written": False,
|
|
3319
|
+
"client_auth_required": True,
|
|
3320
|
+
"client_auth_header": LOCAL_PROXY_NONCE_HEADER,
|
|
3321
|
+
"client_auth_delivered": False,
|
|
3322
|
+
"client_auth_nonce_forwarded": False,
|
|
3323
|
+
"auth_failures": 0,
|
|
3286
3324
|
}
|
|
3287
3325
|
|
|
3288
|
-
def finish_blocked(
|
|
3289
|
-
|
|
3290
|
-
|
|
3326
|
+
def finish_blocked(
|
|
3327
|
+
handler: BaseHTTPRequestHandler,
|
|
3328
|
+
status_code: int,
|
|
3329
|
+
reason: str,
|
|
3330
|
+
*,
|
|
3331
|
+
sensitive: list[str] | None = None,
|
|
3332
|
+
consume_once: bool = True,
|
|
3333
|
+
) -> None:
|
|
3334
|
+
updates = {
|
|
3291
3335
|
"forwarded": False,
|
|
3292
3336
|
"blocked_reason": reason,
|
|
3293
3337
|
"downstream_status": status_code,
|
|
3294
3338
|
"sensitive_headers_blocked": sorted(set(sensitive or [])),
|
|
3295
|
-
}
|
|
3339
|
+
}
|
|
3340
|
+
if consume_once:
|
|
3341
|
+
updates["served_once"] = True
|
|
3342
|
+
server_result.update(updates)
|
|
3296
3343
|
body = json.dumps({"status": "blocked", "reason": reason}, sort_keys=True).encode("utf-8")
|
|
3297
3344
|
handler.send_response(status_code)
|
|
3298
3345
|
handler.send_header("Content-Type", "application/json")
|
|
@@ -3309,9 +3356,28 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
|
|
|
3309
3356
|
def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - BaseHTTPRequestHandler API.
|
|
3310
3357
|
return
|
|
3311
3358
|
|
|
3359
|
+
def authorize_request(self) -> bool:
|
|
3360
|
+
values = self.headers.get_all(LOCAL_PROXY_NONCE_HEADER, [])
|
|
3361
|
+
if len(values) == 0:
|
|
3362
|
+
server_result["auth_failures"] = int(server_result.get("auth_failures", 0)) + 1
|
|
3363
|
+
finish_blocked(self, 403, "missing_proxy_nonce", consume_once=False)
|
|
3364
|
+
return False
|
|
3365
|
+
if len(values) != 1:
|
|
3366
|
+
server_result["auth_failures"] = int(server_result.get("auth_failures", 0)) + 1
|
|
3367
|
+
finish_blocked(self, 403, "duplicate_proxy_nonce", consume_once=False)
|
|
3368
|
+
return False
|
|
3369
|
+
candidate = str(values[0])
|
|
3370
|
+
if not secrets.compare_digest(candidate, auth_nonce):
|
|
3371
|
+
server_result["auth_failures"] = int(server_result.get("auth_failures", 0)) + 1
|
|
3372
|
+
finish_blocked(self, 403, "invalid_proxy_nonce", consume_once=False)
|
|
3373
|
+
return False
|
|
3374
|
+
return True
|
|
3375
|
+
|
|
3312
3376
|
def do_CONNECT(self) -> None:
|
|
3313
3377
|
server_result["request_method"] = "CONNECT"
|
|
3314
3378
|
server_result.update(local_proxy_request_target_meta(self.path))
|
|
3379
|
+
if not self.authorize_request():
|
|
3380
|
+
return
|
|
3315
3381
|
finish_blocked(self, 405, "connect_tunneling_not_allowed")
|
|
3316
3382
|
|
|
3317
3383
|
def do_HEAD(self) -> None:
|
|
@@ -3332,6 +3398,8 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
|
|
|
3332
3398
|
def block_method(self) -> None:
|
|
3333
3399
|
server_result["request_method"] = self.command
|
|
3334
3400
|
server_result.update(local_proxy_request_target_meta(self.path))
|
|
3401
|
+
if not self.authorize_request():
|
|
3402
|
+
return
|
|
3335
3403
|
finish_blocked(self, 405, "method_not_allowed")
|
|
3336
3404
|
|
|
3337
3405
|
def do_DELETE(self) -> None:
|
|
@@ -3346,6 +3414,8 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
|
|
|
3346
3414
|
def forward_request(self) -> None:
|
|
3347
3415
|
server_result["request_method"] = self.command
|
|
3348
3416
|
server_result.update(local_proxy_request_target_meta(self.path))
|
|
3417
|
+
if not self.authorize_request():
|
|
3418
|
+
return
|
|
3349
3419
|
if local_proxy_secret_like(self.path):
|
|
3350
3420
|
finish_blocked(self, 400, "secret_like_request_target")
|
|
3351
3421
|
return
|
|
@@ -3435,8 +3505,9 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
|
|
|
3435
3505
|
httpd.timeout = timeout_seconds
|
|
3436
3506
|
try:
|
|
3437
3507
|
try:
|
|
3438
|
-
write_local_proxy_ready_file(ready_file, bind_host=bind_host, bind_port=bind_port)
|
|
3508
|
+
write_local_proxy_ready_file(ready_file, bind_host=bind_host, bind_port=bind_port, auth_nonce=auth_nonce)
|
|
3439
3509
|
server_result["ready_file_written"] = bool(ready_file)
|
|
3510
|
+
server_result["client_auth_delivered"] = bool(ready_file)
|
|
3440
3511
|
server_result["listener_started"] = True
|
|
3441
3512
|
except RegistryError as exc:
|
|
3442
3513
|
server_result.update({
|
|
@@ -3447,8 +3518,14 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
|
|
|
3447
3518
|
"error": sanitize_local_proxy_value(str(exc)),
|
|
3448
3519
|
})
|
|
3449
3520
|
return server_result
|
|
3450
|
-
|
|
3451
|
-
|
|
3521
|
+
deadline = time.monotonic() + timeout_seconds
|
|
3522
|
+
while not server_result["served_once"]:
|
|
3523
|
+
remaining = deadline - time.monotonic()
|
|
3524
|
+
if remaining <= 0:
|
|
3525
|
+
break
|
|
3526
|
+
httpd.timeout = max(0.001, min(timeout_seconds, remaining))
|
|
3527
|
+
httpd.handle_request()
|
|
3528
|
+
if not server_result["served_once"] and not server_result.get("blocked_reason"):
|
|
3452
3529
|
server_result.update({
|
|
3453
3530
|
"blocked_reason": "timeout_waiting_for_request",
|
|
3454
3531
|
"downstream_status": None,
|
|
@@ -3461,6 +3538,10 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
|
|
|
3461
3538
|
def command_serve_local_proxy(args: argparse.Namespace) -> int:
|
|
3462
3539
|
payload = local_proxy_forward_payload(args)
|
|
3463
3540
|
diagnostic_ledger = payload.get("diagnostic_ledger") if isinstance(payload.get("diagnostic_ledger"), dict) else {}
|
|
3541
|
+
if payload["status"] == "ready_to_serve" and not args.ready_file:
|
|
3542
|
+
payload["status"] = "blocked_until_local_proxy_forwarding_ready"
|
|
3543
|
+
payload["review_plan"]["readiness_blockers"].append("missing_ready_file_for_proxy_nonce")
|
|
3544
|
+
diagnostic_ledger["reason"] = "not_forwarded" if diagnostic_ledger.get("write_requested") else diagnostic_ledger.get("reason")
|
|
3464
3545
|
if payload["status"] == "ready_to_serve" and diagnostic_ledger.get("write_requested"):
|
|
3465
3546
|
try:
|
|
3466
3547
|
preflight_append_jsonl_no_follow(
|
|
@@ -484,6 +484,9 @@ def count_consecutive_failures(entries: list[dict], fp: str) -> int:
|
|
|
484
484
|
|
|
485
485
|
|
|
486
486
|
def main() -> int:
|
|
487
|
+
if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
|
|
488
|
+
print("ContextGuard helper: context-guard-failed-nudge")
|
|
489
|
+
return 0
|
|
487
490
|
raw_payload, oversized = read_bounded_stdin_text()
|
|
488
491
|
if oversized:
|
|
489
492
|
sys.stderr.write("context-guard-failed-nudge: oversized hook JSON skipped\n")
|
|
@@ -16,6 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
import re
|
|
17
17
|
import shlex
|
|
18
18
|
import signal
|
|
19
|
+
import stat
|
|
19
20
|
import subprocess
|
|
20
21
|
import sys
|
|
21
22
|
import threading
|
|
@@ -86,16 +87,171 @@ def compact(text: str, limit: int = 160) -> str:
|
|
|
86
87
|
return text[: max(0, limit - 20)] + f"…[trimmed:{len(text)}]"
|
|
87
88
|
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
ALLOWED_FIRST_ABSOLUTE_SYMLINKS = {
|
|
91
|
+
"tmp": Path("/private/tmp"),
|
|
92
|
+
"var": Path("/private/var"),
|
|
93
|
+
}
|
|
94
|
+
NO_FOLLOW_SUPPORTED = hasattr(os, "O_NOFOLLOW")
|
|
95
|
+
DIR_FD_OPEN_SUPPORTED = bool(os.supports_dir_fd and os.open in os.supports_dir_fd)
|
|
96
|
+
DIR_FD_STAT_SUPPORTED = bool(os.supports_dir_fd and os.stat in os.supports_dir_fd)
|
|
97
|
+
DIR_FD_MKDIR_SUPPORTED = bool(os.supports_dir_fd and os.mkdir in os.supports_dir_fd)
|
|
98
|
+
NONBLOCK_SUPPORTED = hasattr(os, "O_NONBLOCK")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def os_error_detail(exc: OSError) -> str:
|
|
102
|
+
detail = exc.strerror or str(exc) or exc.__class__.__name__
|
|
103
|
+
if exc.errno is not None:
|
|
104
|
+
return f"{detail} (errno {exc.errno})"
|
|
105
|
+
return detail
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def normalized_link_target(parent: Path, raw_target: str) -> Path:
|
|
109
|
+
target = Path(raw_target)
|
|
110
|
+
if not target.is_absolute():
|
|
111
|
+
target = parent / target
|
|
112
|
+
return Path(os.path.normpath(str(target)))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def normalize_allowed_first_absolute_symlink(path: Path) -> Path:
|
|
116
|
+
if not path.is_absolute() or len(path.parts) < 2:
|
|
117
|
+
return path
|
|
118
|
+
first = path.parts[1]
|
|
119
|
+
expected = ALLOWED_FIRST_ABSOLUTE_SYMLINKS.get(first)
|
|
120
|
+
if expected is None:
|
|
121
|
+
return path
|
|
122
|
+
link = Path(path.anchor) / first
|
|
123
|
+
try:
|
|
124
|
+
if not stat.S_ISLNK(os.lstat(link).st_mode):
|
|
125
|
+
return path
|
|
126
|
+
if normalized_link_target(Path(path.anchor), os.readlink(link)) != expected:
|
|
127
|
+
return path
|
|
128
|
+
except OSError:
|
|
129
|
+
return path
|
|
130
|
+
return expected.joinpath(*path.parts[2:])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def normalize_config_path(path: Path) -> Path:
|
|
134
|
+
path = path.expanduser()
|
|
135
|
+
if any(part == ".." for part in path.parts):
|
|
136
|
+
raise OSError("config path must not contain parent traversal")
|
|
137
|
+
if not path.is_absolute():
|
|
138
|
+
path = Path.cwd() / path
|
|
139
|
+
return normalize_allowed_first_absolute_symlink(Path(os.path.normpath(str(path))))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def no_follow_dir_flags() -> int:
|
|
143
|
+
if not NO_FOLLOW_SUPPORTED:
|
|
144
|
+
raise OSError("O_NOFOLLOW is required for safe config reads")
|
|
145
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW
|
|
146
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
147
|
+
flags |= os.O_CLOEXEC
|
|
148
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
149
|
+
flags |= os.O_DIRECTORY
|
|
150
|
+
return flags
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def no_follow_file_flags() -> int:
|
|
154
|
+
if not NO_FOLLOW_SUPPORTED:
|
|
155
|
+
raise OSError("O_NOFOLLOW is required for safe config reads")
|
|
156
|
+
if not NONBLOCK_SUPPORTED:
|
|
157
|
+
raise OSError("O_NONBLOCK is required for safe config reads")
|
|
158
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
|
|
159
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
160
|
+
flags |= os.O_CLOEXEC
|
|
161
|
+
if hasattr(os, "O_NOCTTY"):
|
|
162
|
+
flags |= os.O_NOCTTY
|
|
163
|
+
return flags
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def open_config_parent_no_follow(path: Path) -> int:
|
|
167
|
+
if not DIR_FD_OPEN_SUPPORTED:
|
|
168
|
+
raise OSError("dir_fd open support is required for safe config reads")
|
|
169
|
+
flags = no_follow_dir_flags()
|
|
170
|
+
if path.is_absolute():
|
|
171
|
+
anchor = path.anchor or os.sep
|
|
172
|
+
current_fd = os.open(anchor, os.O_RDONLY | (os.O_CLOEXEC if hasattr(os, "O_CLOEXEC") else 0))
|
|
173
|
+
parts = path.parts[1:-1]
|
|
174
|
+
else:
|
|
175
|
+
current_fd = os.open(".", flags)
|
|
176
|
+
parts = path.parts[:-1]
|
|
177
|
+
try:
|
|
178
|
+
for part in parts:
|
|
179
|
+
if part in {"", "."}:
|
|
180
|
+
continue
|
|
181
|
+
if part == "..":
|
|
182
|
+
raise OSError("config path must not contain parent traversal")
|
|
183
|
+
next_fd = os.open(part, flags, dir_fd=current_fd)
|
|
184
|
+
try:
|
|
185
|
+
if not stat.S_ISDIR(os.fstat(next_fd).st_mode):
|
|
186
|
+
raise OSError("config path must not traverse non-directory components")
|
|
187
|
+
except Exception:
|
|
188
|
+
os.close(next_fd)
|
|
189
|
+
raise
|
|
190
|
+
os.close(current_fd)
|
|
191
|
+
current_fd = next_fd
|
|
192
|
+
owned_fd = current_fd
|
|
193
|
+
current_fd = -1
|
|
194
|
+
return owned_fd
|
|
195
|
+
finally:
|
|
196
|
+
if current_fd >= 0:
|
|
197
|
+
os.close(current_fd)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def read_config_text_no_follow(path: Path, max_bytes: int) -> tuple[str | None, list[str]]:
|
|
201
|
+
parent_fd = -1
|
|
202
|
+
fd = -1
|
|
90
203
|
try:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
204
|
+
path = normalize_config_path(path)
|
|
205
|
+
parent_fd = open_config_parent_no_follow(path)
|
|
206
|
+
leaf = path.name
|
|
207
|
+
if leaf in {"", ".", ".."}:
|
|
208
|
+
return None, ["config path must name a regular file"]
|
|
209
|
+
if not DIR_FD_STAT_SUPPORTED:
|
|
210
|
+
raise OSError("dir_fd stat support is required for safe config reads")
|
|
211
|
+
try:
|
|
212
|
+
st = os.stat(leaf, dir_fd=parent_fd, follow_symlinks=False)
|
|
213
|
+
except FileNotFoundError:
|
|
214
|
+
return None, ["could not read config: missing file"]
|
|
215
|
+
if not stat.S_ISREG(st.st_mode):
|
|
216
|
+
return None, ["config must be a regular file"]
|
|
217
|
+
if st.st_size > max_bytes:
|
|
218
|
+
return None, [f"config file too large: {st.st_size}>{max_bytes} bytes"]
|
|
219
|
+
fd = os.open(leaf, no_follow_file_flags(), dir_fd=parent_fd)
|
|
220
|
+
fst = os.fstat(fd)
|
|
221
|
+
if not stat.S_ISREG(fst.st_mode):
|
|
222
|
+
return None, ["config must be a regular file"]
|
|
223
|
+
if fst.st_size > max_bytes:
|
|
224
|
+
return None, [f"config file too large: {fst.st_size}>{max_bytes} bytes"]
|
|
225
|
+
chunks: list[bytes] = []
|
|
226
|
+
remaining = max_bytes + 1
|
|
227
|
+
while remaining > 0:
|
|
228
|
+
chunk = os.read(fd, min(64 * 1024, remaining))
|
|
229
|
+
if not chunk:
|
|
230
|
+
break
|
|
231
|
+
chunks.append(chunk)
|
|
232
|
+
remaining -= len(chunk)
|
|
233
|
+
raw = b"".join(chunks)
|
|
234
|
+
if len(raw) > max_bytes:
|
|
235
|
+
return None, [f"config file too large: >{max_bytes} bytes"]
|
|
236
|
+
try:
|
|
237
|
+
return raw.decode("utf-8"), []
|
|
238
|
+
except UnicodeDecodeError as exc:
|
|
239
|
+
return None, [f"could not decode config UTF-8: {exc.reason}"]
|
|
95
240
|
except OSError as exc:
|
|
96
|
-
return None, [f"could not read config: {exc
|
|
241
|
+
return None, [f"could not read config safely: {os_error_detail(exc)}"]
|
|
242
|
+
finally:
|
|
243
|
+
if fd >= 0:
|
|
244
|
+
os.close(fd)
|
|
245
|
+
if parent_fd >= 0:
|
|
246
|
+
os.close(parent_fd)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def read_json_limited(path: Path) -> tuple[Any | None, list[str]]:
|
|
250
|
+
raw, read_errors = read_config_text_no_follow(path, MAX_CONFIG_BYTES)
|
|
251
|
+
if read_errors:
|
|
252
|
+
return None, read_errors
|
|
97
253
|
try:
|
|
98
|
-
return json.loads(raw), []
|
|
254
|
+
return json.loads(raw if raw is not None else ""), []
|
|
99
255
|
except json.JSONDecodeError as exc:
|
|
100
256
|
return None, [f"invalid JSON at line {exc.lineno}: {exc.msg}"]
|
|
101
257
|
|
|
@@ -607,6 +607,9 @@ def deny_response(reason: str) -> dict[str, Any]:
|
|
|
607
607
|
|
|
608
608
|
|
|
609
609
|
def main() -> int:
|
|
610
|
+
if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
|
|
611
|
+
print("ContextGuard helper: context-guard-guard-read")
|
|
612
|
+
return 0
|
|
610
613
|
if truthy_disabled(env_value(GUARD_ENV, LEGACY_GUARD_ENV)):
|
|
611
614
|
print("{}")
|
|
612
615
|
return 0
|