@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.ko.md +41 -24
  3. package/README.md +66 -26
  4. package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
  5. package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
  6. package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
  7. package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
  8. package/docs/distribution.md +10 -7
  9. package/docs/experimental-benchmark-fixtures.md +8 -1
  10. package/package.json +3 -6
  11. package/packaging/homebrew/context-guard.rb.template +1 -1
  12. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  13. package/plugins/context-guard/README.ko.md +9 -6
  14. package/plugins/context-guard/README.md +21 -13
  15. package/plugins/context-guard/bin/context-guard +113 -26
  16. package/plugins/context-guard/bin/context-guard-artifact +542 -46
  17. package/plugins/context-guard/bin/context-guard-cache-score +380 -0
  18. package/plugins/context-guard/bin/context-guard-compress +146 -1
  19. package/plugins/context-guard/bin/context-guard-cost +783 -4
  20. package/plugins/context-guard/bin/context-guard-experiments +99 -18
  21. package/plugins/context-guard/bin/context-guard-failed-nudge +3 -0
  22. package/plugins/context-guard/bin/context-guard-filter +163 -7
  23. package/plugins/context-guard/bin/context-guard-guard-read +3 -0
  24. package/plugins/context-guard/bin/context-guard-pack +602 -43
  25. package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
  26. package/plugins/context-guard/bin/context-guard-setup +165 -31
  27. package/plugins/context-guard/bin/context-guard-statusline +490 -283
  28. package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
  29. package/plugins/context-guard/bin/context-guard-tool-prune +241 -1
  30. package/plugins/context-guard/lib/context_guard_commands.py +206 -0
  31. package/plugins/context-guard/skills/setup/SKILL.md +1 -0
  32. package/context-guard-kit/README.md +0 -91
  33. package/context-guard-kit/benchmark_runner.py +0 -2401
  34. package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
  35. package/context-guard-kit/context_compress.py +0 -695
  36. package/context-guard-kit/context_escrow.py +0 -935
  37. package/context-guard-kit/context_filter.py +0 -637
  38. package/context-guard-kit/context_guard_cli.py +0 -325
  39. package/context-guard-kit/context_guard_diet.py +0 -1711
  40. package/context-guard-kit/context_pack.py +0 -2713
  41. package/context-guard-kit/cost_guard.py +0 -2349
  42. package/context-guard-kit/experimental_registry.py +0 -4348
  43. package/context-guard-kit/failed_attempt_nudge.py +0 -567
  44. package/context-guard-kit/guard_large_read.py +0 -690
  45. package/context-guard-kit/hook_secret_patterns.py +0 -43
  46. package/context-guard-kit/read_symbol.py +0 -483
  47. package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
  48. package/context-guard-kit/sanitize_output.py +0 -725
  49. package/context-guard-kit/settings.example.json +0 -67
  50. package/context-guard-kit/setup_wizard.py +0 -2515
  51. package/context-guard-kit/statusline.sh +0 -362
  52. package/context-guard-kit/statusline_merged.sh +0 -157
  53. package/context-guard-kit/tool_schema_pruner.py +0 -837
  54. 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, explicit forwarding acknowledgement, no credential "
376
- "forwarding or persistence, bounded bytes/timeouts, and optional diagnostic ledger rows that remain "
377
- "shifted-cost evidence only. External-forwarding design plans require threat model notes, explicit "
378
- "allowlists, credential redaction policy, and provider-evidence boundaries before any future runtime."
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(args: argparse.Namespace) -> dict[str, Any]:
2688
- input_payload, input_meta = read_local_proxy_payload(args)
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
- payload = local_proxy_plan_payload(args)
3005
- input_payload, _input_meta = read_local_proxy_payload(args)
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(handler: BaseHTTPRequestHandler, status_code: int, reason: str, *, sensitive: list[str] | None = None) -> None:
3289
- server_result.update({
3290
- "served_once": True,
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
- httpd.handle_request()
3451
- if not server_result["served_once"]:
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
- def read_json_limited(path: Path) -> tuple[Any | None, list[str]]:
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
- size = path.stat().st_size
92
- if size > MAX_CONFIG_BYTES:
93
- return None, [f"config file too large: {size}>{MAX_CONFIG_BYTES} bytes"]
94
- raw = path.read_text(encoding="utf-8")
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.strerror or exc.__class__.__name__}"]
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