@ictechgy/context-guard 0.4.10 → 0.4.11

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 (27) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.ko.md +32 -21
  3. package/README.md +38 -29
  4. package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
  5. package/docs/benchmark-workflow-examples.md +3 -0
  6. package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
  7. package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
  8. package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
  9. package/docs/experimental-benchmark-fixtures.md +24 -7
  10. package/package.json +2 -1
  11. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  12. package/plugins/context-guard/README.ko.md +14 -11
  13. package/plugins/context-guard/README.md +15 -14
  14. package/plugins/context-guard/bin/context-guard +46 -11
  15. package/plugins/context-guard/bin/context-guard-artifact +342 -33
  16. package/plugins/context-guard/bin/context-guard-audit +33 -2
  17. package/plugins/context-guard/bin/context-guard-bench +1542 -31
  18. package/plugins/context-guard/bin/context-guard-cache-score +318 -33
  19. package/plugins/context-guard/bin/context-guard-cost +7 -2
  20. package/plugins/context-guard/bin/context-guard-experiments +364 -8
  21. package/plugins/context-guard/bin/context-guard-failed-nudge +6 -2
  22. package/plugins/context-guard/bin/context-guard-pack +301 -17
  23. package/plugins/context-guard/bin/context-guard-sanitize-output +76 -12
  24. package/plugins/context-guard/bin/context-guard-tool-prune +241 -54
  25. package/plugins/context-guard/bin/context-guard-trim-output +288 -41
  26. package/plugins/context-guard/brief/README.md +5 -5
  27. package/plugins/context-guard/lib/context_guard_commands.py +214 -190
@@ -38,6 +38,11 @@ MAX_TOP = 200
38
38
  MAX_DEFERRED_TOP = 1_000
39
39
  MAX_NAMESPACE_TOP = 200
40
40
  MAX_LABEL_CHARS = 160
41
+ NO_FOLLOW_SUPPORTED = hasattr(os, "O_NOFOLLOW")
42
+ DIR_FD_OPEN_SUPPORTED = bool(os.supports_dir_fd and os.open in os.supports_dir_fd)
43
+ DIR_FD_MKDIR_SUPPORTED = bool(os.supports_dir_fd and os.mkdir in os.supports_dir_fd)
44
+ DIR_FD_STAT_SUPPORTED = bool(os.supports_dir_fd and os.stat in os.supports_dir_fd)
45
+ DIR_FD_UNLINK_SUPPORTED = bool(os.supports_dir_fd and os.unlink in os.supports_dir_fd)
41
46
  MAX_DESCRIPTION_CHARS = 360
42
47
  MAX_OMITTED_TOOLS = 30
43
48
  TOKEN_PROXY_CHARS_PER_TOKEN = 4
@@ -201,12 +206,26 @@ def sanitize_value(value: Any, *, sensitive_context: bool = False, sensitive_sch
201
206
 
202
207
 
203
208
  def read_limited_path(path: Path, max_bytes: int) -> str:
209
+ if not NO_FOLLOW_SUPPORTED:
210
+ fail("catalog reads require O_NOFOLLOW support")
211
+ reject_parent_traversal(path, label="catalog")
212
+ # Preserve clear diagnostics for stable symlink paths, then anchor the real
213
+ # read to an opened no-follow parent fd so parent/leaf swaps after this
214
+ # precheck still fail closed.
204
215
  reject_symlink_components(path)
205
- flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
216
+ parent_fd = open_private_directory_no_follow(path.parent, label="catalog directory", create=False)
217
+ flags = os.O_RDONLY | os.O_NOFOLLOW
218
+ if hasattr(os, "O_CLOEXEC"):
219
+ flags |= os.O_CLOEXEC
220
+ leaf = path.name
221
+ if leaf in {"", ".", ".."}:
222
+ os.close(parent_fd)
223
+ fail("catalog must name a regular file")
206
224
  try:
207
- fd = os.open(str(path), flags)
225
+ fd = os.open(leaf, flags, dir_fd=parent_fd)
208
226
  except OSError as exc:
209
- fail(f"catalog read failed: {exc}")
227
+ os.close(parent_fd)
228
+ fail(f"catalog read failed: {os_error_detail(exc)}")
210
229
  try:
211
230
  st = os.fstat(fd)
212
231
  if not stat.S_ISREG(st.st_mode):
@@ -216,6 +235,7 @@ def read_limited_path(path: Path, max_bytes: int) -> str:
216
235
  data = os.read(fd, max_bytes + 1)
217
236
  finally:
218
237
  os.close(fd)
238
+ os.close(parent_fd)
219
239
  if len(data) > max_bytes:
220
240
  fail(f"catalog exceeds --max-catalog-bytes: > {max_bytes}")
221
241
  return data.decode("utf-8", errors="replace")
@@ -409,15 +429,142 @@ def reject_symlink_components(path: Path) -> None:
409
429
  fail(f"refusing path through non-directory component: {current}")
410
430
 
411
431
 
432
+ def dir_fd_replace_supported() -> bool:
433
+ try:
434
+ import inspect
435
+
436
+ signature = inspect.signature(os.replace)
437
+ except (TypeError, ValueError):
438
+ return True
439
+ return "src_dir_fd" in signature.parameters and "dst_dir_fd" in signature.parameters
440
+
441
+
442
+ DIR_FD_REPLACE_SUPPORTED = dir_fd_replace_supported()
443
+
444
+
445
+ def reject_parent_traversal(path: Path, *, label: str) -> None:
446
+ if ".." in path.parts:
447
+ fail(f"{label} must not contain parent traversal")
448
+
449
+
450
+ def os_error_detail(exc: OSError) -> str:
451
+ detail = exc.strerror or str(exc) or exc.__class__.__name__
452
+ if exc.errno is not None:
453
+ return f"{detail} (errno {exc.errno})"
454
+ return detail
455
+
456
+
457
+ def no_follow_dir_flags() -> int:
458
+ if not NO_FOLLOW_SUPPORTED:
459
+ fail("private store IO requires O_NOFOLLOW support")
460
+ flags = os.O_RDONLY | os.O_NOFOLLOW
461
+ if hasattr(os, "O_CLOEXEC"):
462
+ flags |= os.O_CLOEXEC
463
+ if hasattr(os, "O_DIRECTORY"):
464
+ flags |= os.O_DIRECTORY
465
+ return flags
466
+
467
+
468
+ def private_temp_file_flags() -> int:
469
+ if not NO_FOLLOW_SUPPORTED:
470
+ fail("private store IO requires O_NOFOLLOW support")
471
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
472
+ if hasattr(os, "O_CLOEXEC"):
473
+ flags |= os.O_CLOEXEC
474
+ if hasattr(os, "O_NOCTTY"):
475
+ flags |= os.O_NOCTTY
476
+ return flags
477
+
478
+
479
+ def open_private_directory_no_follow(path: Path, *, label: str, create: bool) -> int:
480
+ reject_parent_traversal(path, label=label)
481
+ path = normalize_allowed_first_absolute_symlink(path.expanduser())
482
+ if not DIR_FD_OPEN_SUPPORTED:
483
+ fail(f"{label} requires dir_fd open support")
484
+ if create and not DIR_FD_MKDIR_SUPPORTED:
485
+ fail(f"{label} requires dir_fd mkdir support")
486
+ flags = no_follow_dir_flags()
487
+ if path.is_absolute():
488
+ root_flags = os.O_RDONLY | (os.O_CLOEXEC if hasattr(os, "O_CLOEXEC") else 0)
489
+ current_fd = os.open(path.anchor or os.sep, root_flags)
490
+ parts = path.parts[1:]
491
+ else:
492
+ current_fd = os.open(".", flags)
493
+ parts = path.parts
494
+ try:
495
+ for part in parts:
496
+ if part in {"", "."}:
497
+ continue
498
+ if part == "..":
499
+ fail(f"{label} must not contain parent traversal")
500
+ try:
501
+ next_fd = os.open(part, flags, dir_fd=current_fd)
502
+ except FileNotFoundError:
503
+ if not create:
504
+ raise
505
+ os.mkdir(part, 0o700, dir_fd=current_fd)
506
+ next_fd = os.open(part, flags, dir_fd=current_fd)
507
+ try:
508
+ if not stat.S_ISDIR(os.fstat(next_fd).st_mode):
509
+ fail(f"{label} must not traverse non-directory components")
510
+ except Exception:
511
+ os.close(next_fd)
512
+ raise
513
+ os.close(current_fd)
514
+ current_fd = next_fd
515
+ owned_fd = current_fd
516
+ current_fd = -1
517
+ return owned_fd
518
+ except OSError as exc:
519
+ fail(f"could not inspect {label}: {os_error_detail(exc)}")
520
+ finally:
521
+ if current_fd >= 0:
522
+ os.close(current_fd)
523
+
524
+
525
+ def precheck_private_leaf(parent_fd: int, leaf: str, *, label: str) -> None:
526
+ if not DIR_FD_STAT_SUPPORTED:
527
+ fail(f"{label} requires dir_fd stat support")
528
+ try:
529
+ st = os.stat(leaf, dir_fd=parent_fd, follow_symlinks=False)
530
+ except FileNotFoundError:
531
+ return
532
+ except OSError as exc:
533
+ fail(f"could not inspect {label}: {os_error_detail(exc)}")
534
+ if not stat.S_ISREG(st.st_mode):
535
+ fail(f"{label} must be missing or a regular file")
536
+
537
+
538
+ def write_all_fd(fd: int, data: bytes) -> None:
539
+ view = memoryview(data)
540
+ offset = 0
541
+ while offset < len(view):
542
+ written = os.write(fd, view[offset:])
543
+ if written <= 0:
544
+ raise OSError("short write")
545
+ offset += written
546
+
547
+
548
+ def fsync_best_effort(fd: int) -> None:
549
+ try:
550
+ os.fsync(fd)
551
+ except OSError:
552
+ pass
553
+
554
+
412
555
  def ensure_private_dir(path: Path) -> None:
413
- path = normalize_allowed_first_absolute_symlink(path)
414
- reject_symlink_components(path)
556
+ reject_parent_traversal(path, label="store directory")
415
557
  try:
416
- path.mkdir(parents=True, exist_ok=True)
417
- reject_symlink_components(path)
418
- os.chmod(path, 0o700)
558
+ fd = open_private_directory_no_follow(path, label="store directory", create=True)
419
559
  except OSError as exc:
420
560
  fail(f"store directory unavailable: {exc}")
561
+ try:
562
+ try:
563
+ os.fchmod(fd, 0o700)
564
+ except OSError:
565
+ pass
566
+ finally:
567
+ os.close(fd)
421
568
 
422
569
 
423
570
  def write_private_json_atomic(path: Path, data: dict[str, Any], *, max_bytes: int, label: str) -> int:
@@ -425,43 +572,77 @@ def write_private_json_atomic(path: Path, data: dict[str, Any], *, max_bytes: in
425
572
  size = byte_len_text(text)
426
573
  if size > max_bytes:
427
574
  fail(f"{label} exceeds size cap: {size} > {max_bytes}")
428
- ensure_private_dir(path.parent)
429
- tmp = path.with_name(path.name + f".tmp-{os.getpid()}-{time.time_ns()}")
430
- flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
431
- try:
432
- fd = os.open(str(tmp), flags, 0o600)
433
- except OSError as exc:
434
- fail(f"{label} write failed: {exc}")
575
+ reject_parent_traversal(path, label=label)
576
+ if not DIR_FD_REPLACE_SUPPORTED:
577
+ fail(f"{label} write requires dir_fd replace support")
578
+ if not DIR_FD_UNLINK_SUPPORTED:
579
+ fail(f"{label} write requires dir_fd unlink support")
580
+ if not DIR_FD_STAT_SUPPORTED:
581
+ fail(f"{label} write requires dir_fd stat support")
582
+ parent_fd = open_private_directory_no_follow(path.parent, label="store directory", create=True)
583
+ fd = -1
584
+ temp_leaf: str | None = None
435
585
  try:
436
- with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
437
- handle.write(text)
438
- handle.flush()
439
- try:
440
- os.fsync(handle.fileno())
441
- except OSError:
442
- pass
443
- os.replace(tmp, path)
444
586
  try:
445
- os.chmod(path, 0o600)
587
+ os.fchmod(parent_fd, 0o700)
446
588
  except OSError:
447
589
  pass
590
+ leaf = path.name
591
+ if leaf in {"", ".", ".."}:
592
+ fail(f"{label} must name a regular file")
593
+ precheck_private_leaf(parent_fd, leaf, label=label)
594
+ for _attempt in range(20):
595
+ candidate = f".{leaf}.{os.getpid()}.{time.time_ns()}.tmp"
596
+ try:
597
+ fd = os.open(candidate, private_temp_file_flags(), 0o600, dir_fd=parent_fd)
598
+ temp_leaf = candidate
599
+ break
600
+ except FileExistsError:
601
+ continue
602
+ if fd < 0 or temp_leaf is None:
603
+ fail(f"{label} write failed: could not create temporary file")
604
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
605
+ fail(f"{label} temporary file must be a regular file")
606
+ os.fchmod(fd, 0o600)
607
+ write_all_fd(fd, text.encode("utf-8"))
608
+ fsync_best_effort(fd)
609
+ os.close(fd)
610
+ fd = -1
611
+ fsync_best_effort(parent_fd)
612
+ os.replace(temp_leaf, leaf, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
613
+ temp_leaf = None
614
+ fsync_best_effort(parent_fd)
615
+ except OSError as exc:
616
+ fail(f"{label} write failed: {os_error_detail(exc)}")
448
617
  except Exception:
449
- try:
450
- tmp.unlink()
451
- except OSError:
452
- pass
453
618
  raise
619
+ finally:
620
+ if fd >= 0:
621
+ os.close(fd)
622
+ if temp_leaf is not None:
623
+ try:
624
+ os.unlink(temp_leaf, dir_fd=parent_fd)
625
+ except OSError:
626
+ pass
627
+ os.close(parent_fd)
454
628
  return size
455
629
 
456
630
 
457
631
  def read_private_text(path: Path, *, max_bytes: int, label: str) -> tuple[str, int]:
458
- if path.is_symlink():
459
- fail(f"{label} must not be a symlink")
460
- flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
632
+ reject_parent_traversal(path, label=label)
633
+ parent_fd = open_private_directory_no_follow(path.parent, label=f"{label} directory", create=False)
634
+ flags = os.O_RDONLY | os.O_NOFOLLOW
635
+ if hasattr(os, "O_CLOEXEC"):
636
+ flags |= os.O_CLOEXEC
637
+ leaf = path.name
638
+ if leaf in {"", ".", ".."}:
639
+ os.close(parent_fd)
640
+ fail(f"{label} must name a regular file")
461
641
  try:
462
- fd = os.open(str(path), flags)
642
+ fd = os.open(leaf, flags, dir_fd=parent_fd)
463
643
  except OSError as exc:
464
- fail(f"{label} read failed: {exc}")
644
+ os.close(parent_fd)
645
+ fail(f"{label} read failed: {os_error_detail(exc)}")
465
646
  try:
466
647
  st = os.fstat(fd)
467
648
  if not stat.S_ISREG(st.st_mode):
@@ -471,32 +652,16 @@ def read_private_text(path: Path, *, max_bytes: int, label: str) -> tuple[str, i
471
652
  data = os.read(fd, max_bytes + 1)
472
653
  finally:
473
654
  os.close(fd)
655
+ os.close(parent_fd)
474
656
  if len(data) > max_bytes:
475
657
  fail(f"{label} exceeds trusted size cap: > {max_bytes}")
476
658
  return data.decode("utf-8", errors="replace"), len(data)
477
659
 
478
660
 
479
661
  def read_private_json(path: Path, *, max_bytes: int, label: str) -> dict[str, Any]:
480
- if path.is_symlink():
481
- fail(f"{label} must not be a symlink")
482
- flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
483
- try:
484
- fd = os.open(str(path), flags)
485
- except OSError as exc:
486
- fail(f"{label} read failed: {exc}")
487
- try:
488
- st = os.fstat(fd)
489
- if not stat.S_ISREG(st.st_mode):
490
- fail(f"{label} must be a regular file")
491
- if st.st_size > max_bytes:
492
- fail(f"{label} exceeds trusted size cap: {st.st_size} > {max_bytes}")
493
- data = os.read(fd, max_bytes + 1)
494
- finally:
495
- os.close(fd)
496
- if len(data) > max_bytes:
497
- fail(f"{label} exceeds trusted size cap: > {max_bytes}")
662
+ text, _size = read_private_text(path, max_bytes=max_bytes, label=label)
498
663
  try:
499
- parsed = json.loads(data.decode("utf-8", errors="replace"))
664
+ parsed = json.loads(text)
500
665
  except json.JSONDecodeError as exc:
501
666
  fail(f"{label} is malformed JSON: {exc.msg}")
502
667
  if not isinstance(parsed, dict):
@@ -844,7 +1009,14 @@ def defer_report(args: argparse.Namespace) -> str:
844
1009
  namespace_top=namespace_top,
845
1010
  )
846
1011
  all_schema_bytes = sum(byte_len_json(cand.schema) for cand in ranked)
1012
+ listed_deferred_schema_bytes = sum(byte_len_json(cand.schema) for cand in deferred_candidates)
1013
+ total_deferred_schema_bytes = sum(byte_len_json(cand.schema) for cand in ranked[core_top:])
847
1014
  tool_stub_report_bytes = byte_len_json(core_tools) + byte_len_json(deferred_tools)
1015
+ all_schema_tokens = proxy_tokens(all_schema_bytes)
1016
+ inline_core_schema_tokens = proxy_tokens(core_schema_bytes)
1017
+ listed_deferred_schema_tokens = proxy_tokens(listed_deferred_schema_bytes)
1018
+ total_deferred_schema_tokens = proxy_tokens(total_deferred_schema_bytes)
1019
+ tool_stub_report_tokens = proxy_tokens(tool_stub_report_bytes)
848
1020
  result = {
849
1021
  "tool": TOOL_NAME,
850
1022
  "schema_version": DEFER_SCHEMA_VERSION,
@@ -862,6 +1034,7 @@ def defer_report(args: argparse.Namespace) -> str:
862
1034
  "deferred_tools_truncated_count": max(0, len(ranked) - core_top - len(deferred_tools)),
863
1035
  "deferred_namespaces": deferred_namespaces,
864
1036
  "deferred_namespaces_truncated_count": deferred_namespaces_truncated_count,
1037
+ "deferred_schema_retrieval_required_before_use": True,
865
1038
  "receipt": {
866
1039
  **receipt,
867
1040
  "bytes": receipt_size,
@@ -871,9 +1044,21 @@ def defer_report(args: argparse.Namespace) -> str:
871
1044
  "method": "char4_proxy",
872
1045
  "chars_per_token": TOKEN_PROXY_CHARS_PER_TOKEN,
873
1046
  "all_schema_bytes": all_schema_bytes,
1047
+ "inline_core_schema_bytes": core_schema_bytes,
1048
+ "listed_deferred_schema_bytes": listed_deferred_schema_bytes,
1049
+ "total_deferred_schema_bytes": total_deferred_schema_bytes,
874
1050
  "tool_stub_report_bytes": tool_stub_report_bytes,
875
- "all_schema_tokens_estimated": proxy_tokens(all_schema_bytes),
876
- "tool_stub_report_tokens_estimated": proxy_tokens(tool_stub_report_bytes),
1051
+ "all_schema_tokens_estimated": all_schema_tokens,
1052
+ "inline_core_schema_tokens_estimated": inline_core_schema_tokens,
1053
+ "listed_deferred_schema_tokens_estimated": listed_deferred_schema_tokens,
1054
+ "total_deferred_schema_tokens_estimated": total_deferred_schema_tokens,
1055
+ "tool_stub_report_tokens_estimated": tool_stub_report_tokens,
1056
+ "gross_listed_deferred_schema_tokens_avoided": listed_deferred_schema_tokens,
1057
+ "gross_total_deferred_schema_tokens_avoided": total_deferred_schema_tokens,
1058
+ "net_initial_report_tokens_delta": tool_stub_report_tokens - all_schema_tokens,
1059
+ "net_initial_report_tokens_delta_semantics": "tool_stub_report_tokens_estimated_minus_all_schema_tokens_estimated",
1060
+ "estimated_initial_schema_tokens_avoided": max(0, all_schema_tokens - tool_stub_report_tokens),
1061
+ "estimated_initial_schema_tokens_avoided_semantics": "max(0, all_schema_tokens_estimated - tool_stub_report_tokens_estimated)",
877
1062
  "claim_boundary": "proxy_only_not_provider_billed_tokens",
878
1063
  },
879
1064
  "provider_patterns": [
@@ -899,11 +1084,13 @@ def defer_report(args: argparse.Namespace) -> str:
899
1084
  "provider_tool_search_configured": False,
900
1085
  "hosted_api_token_or_cost_savings_claim_allowed": False,
901
1086
  "requires_provider_measured_matched_tasks_for_savings_claims": True,
1087
+ "deferred_schema_retrieval_required_before_use": True,
902
1088
  },
903
1089
  "redaction": {"redacted_values": total_redactions},
904
1090
  "caveats": [
905
1091
  "Deferred loading is an application strategy report, not a native provider integration.",
906
1092
  "Token proxy values are char/4 estimates over sanitized local JSON, not billed provider tokens.",
1093
+ "Deferred schema token fields are initial-prompt proxy accounting; full schemas must be retrieved before deferred tool use.",
907
1094
  "Use receipt get commands to retrieve full sanitized schemas before using deferred tools.",
908
1095
  ],
909
1096
  }