@ictechgy/context-guard 0.4.10 → 0.4.12
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 +17 -1
- package/README.ko.md +46 -28
- package/README.md +42 -33
- package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
- package/docs/benchmark-workflow-examples.md +3 -0
- package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
- package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
- package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
- package/docs/experimental-benchmark-fixtures.md +24 -7
- package/package.json +2 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +14 -11
- package/plugins/context-guard/README.md +15 -14
- package/plugins/context-guard/bin/context-guard +48 -17
- package/plugins/context-guard/bin/context-guard-artifact +342 -33
- package/plugins/context-guard/bin/context-guard-audit +36 -5
- package/plugins/context-guard/bin/context-guard-bench +1675 -44
- package/plugins/context-guard/bin/context-guard-cache-score +347 -35
- package/plugins/context-guard/bin/context-guard-compress +89 -27
- package/plugins/context-guard/bin/context-guard-cost +7 -2
- package/plugins/context-guard/bin/context-guard-experiments +364 -8
- package/plugins/context-guard/bin/context-guard-failed-nudge +6 -2
- package/plugins/context-guard/bin/context-guard-filter +88 -18
- package/plugins/context-guard/bin/context-guard-pack +329 -19
- package/plugins/context-guard/bin/context-guard-read-symbol +27 -0
- package/plugins/context-guard/bin/context-guard-sanitize-output +245 -18
- package/plugins/context-guard/bin/context-guard-setup +21 -5
- package/plugins/context-guard/bin/context-guard-tool-prune +287 -62
- package/plugins/context-guard/bin/context-guard-trim-output +394 -90
- package/plugins/context-guard/brief/README.md +5 -5
- package/plugins/context-guard/lib/context_guard_command_manifest_loader.py +123 -0
- package/plugins/context-guard/lib/context_guard_commands.py +217 -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
|
|
@@ -82,6 +87,8 @@ class Candidate:
|
|
|
82
87
|
index: int
|
|
83
88
|
score: float = 0.0
|
|
84
89
|
rank: int = 0
|
|
90
|
+
schema_bytes: int = 0
|
|
91
|
+
parameter_terms: frozenset[str] | None = None
|
|
85
92
|
|
|
86
93
|
|
|
87
94
|
def fail(message: str) -> NoReturn:
|
|
@@ -201,12 +208,26 @@ def sanitize_value(value: Any, *, sensitive_context: bool = False, sensitive_sch
|
|
|
201
208
|
|
|
202
209
|
|
|
203
210
|
def read_limited_path(path: Path, max_bytes: int) -> str:
|
|
211
|
+
if not NO_FOLLOW_SUPPORTED:
|
|
212
|
+
fail("catalog reads require O_NOFOLLOW support")
|
|
213
|
+
reject_parent_traversal(path, label="catalog")
|
|
214
|
+
# Preserve clear diagnostics for stable symlink paths, then anchor the real
|
|
215
|
+
# read to an opened no-follow parent fd so parent/leaf swaps after this
|
|
216
|
+
# precheck still fail closed.
|
|
204
217
|
reject_symlink_components(path)
|
|
205
|
-
|
|
218
|
+
parent_fd = open_private_directory_no_follow(path.parent, label="catalog directory", create=False)
|
|
219
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW
|
|
220
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
221
|
+
flags |= os.O_CLOEXEC
|
|
222
|
+
leaf = path.name
|
|
223
|
+
if leaf in {"", ".", ".."}:
|
|
224
|
+
os.close(parent_fd)
|
|
225
|
+
fail("catalog must name a regular file")
|
|
206
226
|
try:
|
|
207
|
-
fd = os.open(
|
|
227
|
+
fd = os.open(leaf, flags, dir_fd=parent_fd)
|
|
208
228
|
except OSError as exc:
|
|
209
|
-
|
|
229
|
+
os.close(parent_fd)
|
|
230
|
+
fail(f"catalog read failed: {os_error_detail(exc)}")
|
|
210
231
|
try:
|
|
211
232
|
st = os.fstat(fd)
|
|
212
233
|
if not stat.S_ISREG(st.st_mode):
|
|
@@ -216,6 +237,7 @@ def read_limited_path(path: Path, max_bytes: int) -> str:
|
|
|
216
237
|
data = os.read(fd, max_bytes + 1)
|
|
217
238
|
finally:
|
|
218
239
|
os.close(fd)
|
|
240
|
+
os.close(parent_fd)
|
|
219
241
|
if len(data) > max_bytes:
|
|
220
242
|
fail(f"catalog exceeds --max-catalog-bytes: > {max_bytes}")
|
|
221
243
|
return data.decode("utf-8", errors="replace")
|
|
@@ -256,7 +278,15 @@ def tool_schema_from_dict(raw: dict[str, Any], *, fallback_name: str | None = No
|
|
|
256
278
|
schema["description"] = description
|
|
257
279
|
if server and "server" not in schema:
|
|
258
280
|
schema["server"] = server
|
|
259
|
-
return Candidate(
|
|
281
|
+
return Candidate(
|
|
282
|
+
name=name,
|
|
283
|
+
server=cap_text(server, MAX_LABEL_CHARS) if server else None,
|
|
284
|
+
description=description,
|
|
285
|
+
schema=schema,
|
|
286
|
+
index=index,
|
|
287
|
+
schema_bytes=byte_len_json(schema),
|
|
288
|
+
parameter_terms=frozenset(terms(" ".join(collect_parameter_text(schema)))),
|
|
289
|
+
)
|
|
260
290
|
|
|
261
291
|
|
|
262
292
|
def normalize_catalog(raw: Any) -> list[Candidate]:
|
|
@@ -342,7 +372,11 @@ def score_candidate(candidate: Candidate, query_terms: set[str]) -> float:
|
|
|
342
372
|
return 0.0
|
|
343
373
|
name_terms = terms(candidate.name)
|
|
344
374
|
desc_terms = terms(candidate.description)
|
|
345
|
-
parameter_terms =
|
|
375
|
+
parameter_terms = (
|
|
376
|
+
set(candidate.parameter_terms)
|
|
377
|
+
if candidate.parameter_terms is not None
|
|
378
|
+
else terms(" ".join(collect_parameter_text(candidate.schema)))
|
|
379
|
+
)
|
|
346
380
|
score = 0.0
|
|
347
381
|
score += 4.0 * len(query_terms & name_terms)
|
|
348
382
|
score += 1.5 * len(query_terms & desc_terms)
|
|
@@ -359,14 +393,38 @@ def rank_candidates(candidates: list[Candidate], query: str) -> list[Candidate]:
|
|
|
359
393
|
query_terms = terms(query)
|
|
360
394
|
scored: list[Candidate] = []
|
|
361
395
|
for cand in candidates:
|
|
362
|
-
scored.append(Candidate(
|
|
396
|
+
scored.append(Candidate(
|
|
397
|
+
cand.name,
|
|
398
|
+
cand.server,
|
|
399
|
+
cand.description,
|
|
400
|
+
cand.schema,
|
|
401
|
+
cand.index,
|
|
402
|
+
score_candidate(cand, query_terms),
|
|
403
|
+
0,
|
|
404
|
+
schema_bytes=cand.schema_bytes,
|
|
405
|
+
parameter_terms=cand.parameter_terms,
|
|
406
|
+
))
|
|
363
407
|
scored.sort(key=lambda item: (-item.score, item.index))
|
|
364
408
|
ranked: list[Candidate] = []
|
|
365
409
|
for rank, cand in enumerate(scored, start=1):
|
|
366
|
-
ranked.append(Candidate(
|
|
410
|
+
ranked.append(Candidate(
|
|
411
|
+
cand.name,
|
|
412
|
+
cand.server,
|
|
413
|
+
cand.description,
|
|
414
|
+
cand.schema,
|
|
415
|
+
cand.index,
|
|
416
|
+
cand.score,
|
|
417
|
+
rank,
|
|
418
|
+
schema_bytes=cand.schema_bytes,
|
|
419
|
+
parameter_terms=cand.parameter_terms,
|
|
420
|
+
))
|
|
367
421
|
return ranked
|
|
368
422
|
|
|
369
423
|
|
|
424
|
+
def candidate_schema_bytes(cand: Candidate) -> int:
|
|
425
|
+
return cand.schema_bytes if cand.schema_bytes > 0 else byte_len_json(cand.schema)
|
|
426
|
+
|
|
427
|
+
|
|
370
428
|
def normalized_link_target(parent: Path, raw_target: str) -> Path:
|
|
371
429
|
target = Path(raw_target)
|
|
372
430
|
if not target.is_absolute():
|
|
@@ -409,15 +467,142 @@ def reject_symlink_components(path: Path) -> None:
|
|
|
409
467
|
fail(f"refusing path through non-directory component: {current}")
|
|
410
468
|
|
|
411
469
|
|
|
470
|
+
def dir_fd_replace_supported() -> bool:
|
|
471
|
+
try:
|
|
472
|
+
import inspect
|
|
473
|
+
|
|
474
|
+
signature = inspect.signature(os.replace)
|
|
475
|
+
except (TypeError, ValueError):
|
|
476
|
+
return True
|
|
477
|
+
return "src_dir_fd" in signature.parameters and "dst_dir_fd" in signature.parameters
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
DIR_FD_REPLACE_SUPPORTED = dir_fd_replace_supported()
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def reject_parent_traversal(path: Path, *, label: str) -> None:
|
|
484
|
+
if ".." in path.parts:
|
|
485
|
+
fail(f"{label} must not contain parent traversal")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def os_error_detail(exc: OSError) -> str:
|
|
489
|
+
detail = exc.strerror or str(exc) or exc.__class__.__name__
|
|
490
|
+
if exc.errno is not None:
|
|
491
|
+
return f"{detail} (errno {exc.errno})"
|
|
492
|
+
return detail
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def no_follow_dir_flags() -> int:
|
|
496
|
+
if not NO_FOLLOW_SUPPORTED:
|
|
497
|
+
fail("private store IO requires O_NOFOLLOW support")
|
|
498
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW
|
|
499
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
500
|
+
flags |= os.O_CLOEXEC
|
|
501
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
502
|
+
flags |= os.O_DIRECTORY
|
|
503
|
+
return flags
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def private_temp_file_flags() -> int:
|
|
507
|
+
if not NO_FOLLOW_SUPPORTED:
|
|
508
|
+
fail("private store IO requires O_NOFOLLOW support")
|
|
509
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
|
|
510
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
511
|
+
flags |= os.O_CLOEXEC
|
|
512
|
+
if hasattr(os, "O_NOCTTY"):
|
|
513
|
+
flags |= os.O_NOCTTY
|
|
514
|
+
return flags
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def open_private_directory_no_follow(path: Path, *, label: str, create: bool) -> int:
|
|
518
|
+
reject_parent_traversal(path, label=label)
|
|
519
|
+
path = normalize_allowed_first_absolute_symlink(path.expanduser())
|
|
520
|
+
if not DIR_FD_OPEN_SUPPORTED:
|
|
521
|
+
fail(f"{label} requires dir_fd open support")
|
|
522
|
+
if create and not DIR_FD_MKDIR_SUPPORTED:
|
|
523
|
+
fail(f"{label} requires dir_fd mkdir support")
|
|
524
|
+
flags = no_follow_dir_flags()
|
|
525
|
+
if path.is_absolute():
|
|
526
|
+
root_flags = os.O_RDONLY | (os.O_CLOEXEC if hasattr(os, "O_CLOEXEC") else 0)
|
|
527
|
+
current_fd = os.open(path.anchor or os.sep, root_flags)
|
|
528
|
+
parts = path.parts[1:]
|
|
529
|
+
else:
|
|
530
|
+
current_fd = os.open(".", flags)
|
|
531
|
+
parts = path.parts
|
|
532
|
+
try:
|
|
533
|
+
for part in parts:
|
|
534
|
+
if part in {"", "."}:
|
|
535
|
+
continue
|
|
536
|
+
if part == "..":
|
|
537
|
+
fail(f"{label} must not contain parent traversal")
|
|
538
|
+
try:
|
|
539
|
+
next_fd = os.open(part, flags, dir_fd=current_fd)
|
|
540
|
+
except FileNotFoundError:
|
|
541
|
+
if not create:
|
|
542
|
+
raise
|
|
543
|
+
os.mkdir(part, 0o700, dir_fd=current_fd)
|
|
544
|
+
next_fd = os.open(part, flags, dir_fd=current_fd)
|
|
545
|
+
try:
|
|
546
|
+
if not stat.S_ISDIR(os.fstat(next_fd).st_mode):
|
|
547
|
+
fail(f"{label} must not traverse non-directory components")
|
|
548
|
+
except Exception:
|
|
549
|
+
os.close(next_fd)
|
|
550
|
+
raise
|
|
551
|
+
os.close(current_fd)
|
|
552
|
+
current_fd = next_fd
|
|
553
|
+
owned_fd = current_fd
|
|
554
|
+
current_fd = -1
|
|
555
|
+
return owned_fd
|
|
556
|
+
except OSError as exc:
|
|
557
|
+
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
558
|
+
finally:
|
|
559
|
+
if current_fd >= 0:
|
|
560
|
+
os.close(current_fd)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def precheck_private_leaf(parent_fd: int, leaf: str, *, label: str) -> None:
|
|
564
|
+
if not DIR_FD_STAT_SUPPORTED:
|
|
565
|
+
fail(f"{label} requires dir_fd stat support")
|
|
566
|
+
try:
|
|
567
|
+
st = os.stat(leaf, dir_fd=parent_fd, follow_symlinks=False)
|
|
568
|
+
except FileNotFoundError:
|
|
569
|
+
return
|
|
570
|
+
except OSError as exc:
|
|
571
|
+
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
572
|
+
if not stat.S_ISREG(st.st_mode):
|
|
573
|
+
fail(f"{label} must be missing or a regular file")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def write_all_fd(fd: int, data: bytes) -> None:
|
|
577
|
+
view = memoryview(data)
|
|
578
|
+
offset = 0
|
|
579
|
+
while offset < len(view):
|
|
580
|
+
written = os.write(fd, view[offset:])
|
|
581
|
+
if written <= 0:
|
|
582
|
+
raise OSError("short write")
|
|
583
|
+
offset += written
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def fsync_best_effort(fd: int) -> None:
|
|
587
|
+
try:
|
|
588
|
+
os.fsync(fd)
|
|
589
|
+
except OSError:
|
|
590
|
+
pass
|
|
591
|
+
|
|
592
|
+
|
|
412
593
|
def ensure_private_dir(path: Path) -> None:
|
|
413
|
-
path =
|
|
414
|
-
reject_symlink_components(path)
|
|
594
|
+
reject_parent_traversal(path, label="store directory")
|
|
415
595
|
try:
|
|
416
|
-
path
|
|
417
|
-
reject_symlink_components(path)
|
|
418
|
-
os.chmod(path, 0o700)
|
|
596
|
+
fd = open_private_directory_no_follow(path, label="store directory", create=True)
|
|
419
597
|
except OSError as exc:
|
|
420
598
|
fail(f"store directory unavailable: {exc}")
|
|
599
|
+
try:
|
|
600
|
+
try:
|
|
601
|
+
os.fchmod(fd, 0o700)
|
|
602
|
+
except OSError:
|
|
603
|
+
pass
|
|
604
|
+
finally:
|
|
605
|
+
os.close(fd)
|
|
421
606
|
|
|
422
607
|
|
|
423
608
|
def write_private_json_atomic(path: Path, data: dict[str, Any], *, max_bytes: int, label: str) -> int:
|
|
@@ -425,43 +610,77 @@ def write_private_json_atomic(path: Path, data: dict[str, Any], *, max_bytes: in
|
|
|
425
610
|
size = byte_len_text(text)
|
|
426
611
|
if size > max_bytes:
|
|
427
612
|
fail(f"{label} exceeds size cap: {size} > {max_bytes}")
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
fail(f"{label} write
|
|
613
|
+
reject_parent_traversal(path, label=label)
|
|
614
|
+
if not DIR_FD_REPLACE_SUPPORTED:
|
|
615
|
+
fail(f"{label} write requires dir_fd replace support")
|
|
616
|
+
if not DIR_FD_UNLINK_SUPPORTED:
|
|
617
|
+
fail(f"{label} write requires dir_fd unlink support")
|
|
618
|
+
if not DIR_FD_STAT_SUPPORTED:
|
|
619
|
+
fail(f"{label} write requires dir_fd stat support")
|
|
620
|
+
parent_fd = open_private_directory_no_follow(path.parent, label="store directory", create=True)
|
|
621
|
+
fd = -1
|
|
622
|
+
temp_leaf: str | None = None
|
|
435
623
|
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
624
|
try:
|
|
445
|
-
os.
|
|
625
|
+
os.fchmod(parent_fd, 0o700)
|
|
446
626
|
except OSError:
|
|
447
627
|
pass
|
|
628
|
+
leaf = path.name
|
|
629
|
+
if leaf in {"", ".", ".."}:
|
|
630
|
+
fail(f"{label} must name a regular file")
|
|
631
|
+
precheck_private_leaf(parent_fd, leaf, label=label)
|
|
632
|
+
for _attempt in range(20):
|
|
633
|
+
candidate = f".{leaf}.{os.getpid()}.{time.time_ns()}.tmp"
|
|
634
|
+
try:
|
|
635
|
+
fd = os.open(candidate, private_temp_file_flags(), 0o600, dir_fd=parent_fd)
|
|
636
|
+
temp_leaf = candidate
|
|
637
|
+
break
|
|
638
|
+
except FileExistsError:
|
|
639
|
+
continue
|
|
640
|
+
if fd < 0 or temp_leaf is None:
|
|
641
|
+
fail(f"{label} write failed: could not create temporary file")
|
|
642
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
643
|
+
fail(f"{label} temporary file must be a regular file")
|
|
644
|
+
os.fchmod(fd, 0o600)
|
|
645
|
+
write_all_fd(fd, text.encode("utf-8"))
|
|
646
|
+
fsync_best_effort(fd)
|
|
647
|
+
os.close(fd)
|
|
648
|
+
fd = -1
|
|
649
|
+
fsync_best_effort(parent_fd)
|
|
650
|
+
os.replace(temp_leaf, leaf, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
|
651
|
+
temp_leaf = None
|
|
652
|
+
fsync_best_effort(parent_fd)
|
|
653
|
+
except OSError as exc:
|
|
654
|
+
fail(f"{label} write failed: {os_error_detail(exc)}")
|
|
448
655
|
except Exception:
|
|
449
|
-
try:
|
|
450
|
-
tmp.unlink()
|
|
451
|
-
except OSError:
|
|
452
|
-
pass
|
|
453
656
|
raise
|
|
657
|
+
finally:
|
|
658
|
+
if fd >= 0:
|
|
659
|
+
os.close(fd)
|
|
660
|
+
if temp_leaf is not None:
|
|
661
|
+
try:
|
|
662
|
+
os.unlink(temp_leaf, dir_fd=parent_fd)
|
|
663
|
+
except OSError:
|
|
664
|
+
pass
|
|
665
|
+
os.close(parent_fd)
|
|
454
666
|
return size
|
|
455
667
|
|
|
456
668
|
|
|
457
669
|
def read_private_text(path: Path, *, max_bytes: int, label: str) -> tuple[str, int]:
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
flags = os.O_RDONLY |
|
|
670
|
+
reject_parent_traversal(path, label=label)
|
|
671
|
+
parent_fd = open_private_directory_no_follow(path.parent, label=f"{label} directory", create=False)
|
|
672
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW
|
|
673
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
674
|
+
flags |= os.O_CLOEXEC
|
|
675
|
+
leaf = path.name
|
|
676
|
+
if leaf in {"", ".", ".."}:
|
|
677
|
+
os.close(parent_fd)
|
|
678
|
+
fail(f"{label} must name a regular file")
|
|
461
679
|
try:
|
|
462
|
-
fd = os.open(
|
|
680
|
+
fd = os.open(leaf, flags, dir_fd=parent_fd)
|
|
463
681
|
except OSError as exc:
|
|
464
|
-
|
|
682
|
+
os.close(parent_fd)
|
|
683
|
+
fail(f"{label} read failed: {os_error_detail(exc)}")
|
|
465
684
|
try:
|
|
466
685
|
st = os.fstat(fd)
|
|
467
686
|
if not stat.S_ISREG(st.st_mode):
|
|
@@ -471,32 +690,16 @@ def read_private_text(path: Path, *, max_bytes: int, label: str) -> tuple[str, i
|
|
|
471
690
|
data = os.read(fd, max_bytes + 1)
|
|
472
691
|
finally:
|
|
473
692
|
os.close(fd)
|
|
693
|
+
os.close(parent_fd)
|
|
474
694
|
if len(data) > max_bytes:
|
|
475
695
|
fail(f"{label} exceeds trusted size cap: > {max_bytes}")
|
|
476
696
|
return data.decode("utf-8", errors="replace"), len(data)
|
|
477
697
|
|
|
478
698
|
|
|
479
699
|
def read_private_json(path: Path, *, max_bytes: int, label: str) -> dict[str, Any]:
|
|
480
|
-
|
|
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}")
|
|
700
|
+
text, _size = read_private_text(path, max_bytes=max_bytes, label=label)
|
|
498
701
|
try:
|
|
499
|
-
parsed = json.loads(
|
|
702
|
+
parsed = json.loads(text)
|
|
500
703
|
except json.JSONDecodeError as exc:
|
|
501
704
|
fail(f"{label} is malformed JSON: {exc.msg}")
|
|
502
705
|
if not isinstance(parsed, dict):
|
|
@@ -542,7 +745,7 @@ def build_payload(receipt_id: str, ranked: list[Candidate], query: str, redactio
|
|
|
542
745
|
"description": cand.description,
|
|
543
746
|
"score": cand.score,
|
|
544
747
|
"rank": cand.rank,
|
|
545
|
-
"schema_bytes":
|
|
748
|
+
"schema_bytes": candidate_schema_bytes(cand),
|
|
546
749
|
"schema": cand.schema,
|
|
547
750
|
}
|
|
548
751
|
for cand in ranked
|
|
@@ -574,7 +777,7 @@ def retrieval_command(receipt_id: str, *, store_dir: str, tool_name: str | None
|
|
|
574
777
|
|
|
575
778
|
|
|
576
779
|
def selected_tool_record(cand: Candidate, receipt_id: str, budget_left: int, *, store_dir: str) -> tuple[dict[str, Any], int]:
|
|
577
|
-
schema_size =
|
|
780
|
+
schema_size = candidate_schema_bytes(cand)
|
|
578
781
|
record: dict[str, Any] = {
|
|
579
782
|
"name": cand.name,
|
|
580
783
|
"server": cand.server,
|
|
@@ -600,7 +803,7 @@ def deferred_tool_record(cand: Candidate, receipt_id: str, *, store_dir: str) ->
|
|
|
600
803
|
"score": cand.score,
|
|
601
804
|
"rank": cand.rank,
|
|
602
805
|
"description": cand.description,
|
|
603
|
-
"schema_bytes":
|
|
806
|
+
"schema_bytes": candidate_schema_bytes(cand),
|
|
604
807
|
"reason": "deferred_after_core_top",
|
|
605
808
|
"retrieval": retrieval_command(receipt_id, store_dir=store_dir, tool_name=cand.name),
|
|
606
809
|
}
|
|
@@ -843,8 +1046,15 @@ def defer_report(args: argparse.Namespace) -> str:
|
|
|
843
1046
|
store_dir=args.store_dir,
|
|
844
1047
|
namespace_top=namespace_top,
|
|
845
1048
|
)
|
|
846
|
-
all_schema_bytes = sum(
|
|
1049
|
+
all_schema_bytes = sum(candidate_schema_bytes(cand) for cand in ranked)
|
|
1050
|
+
listed_deferred_schema_bytes = sum(candidate_schema_bytes(cand) for cand in deferred_candidates)
|
|
1051
|
+
total_deferred_schema_bytes = sum(candidate_schema_bytes(cand) for cand in ranked[core_top:])
|
|
847
1052
|
tool_stub_report_bytes = byte_len_json(core_tools) + byte_len_json(deferred_tools)
|
|
1053
|
+
all_schema_tokens = proxy_tokens(all_schema_bytes)
|
|
1054
|
+
inline_core_schema_tokens = proxy_tokens(core_schema_bytes)
|
|
1055
|
+
listed_deferred_schema_tokens = proxy_tokens(listed_deferred_schema_bytes)
|
|
1056
|
+
total_deferred_schema_tokens = proxy_tokens(total_deferred_schema_bytes)
|
|
1057
|
+
tool_stub_report_tokens = proxy_tokens(tool_stub_report_bytes)
|
|
848
1058
|
result = {
|
|
849
1059
|
"tool": TOOL_NAME,
|
|
850
1060
|
"schema_version": DEFER_SCHEMA_VERSION,
|
|
@@ -862,6 +1072,7 @@ def defer_report(args: argparse.Namespace) -> str:
|
|
|
862
1072
|
"deferred_tools_truncated_count": max(0, len(ranked) - core_top - len(deferred_tools)),
|
|
863
1073
|
"deferred_namespaces": deferred_namespaces,
|
|
864
1074
|
"deferred_namespaces_truncated_count": deferred_namespaces_truncated_count,
|
|
1075
|
+
"deferred_schema_retrieval_required_before_use": True,
|
|
865
1076
|
"receipt": {
|
|
866
1077
|
**receipt,
|
|
867
1078
|
"bytes": receipt_size,
|
|
@@ -871,9 +1082,21 @@ def defer_report(args: argparse.Namespace) -> str:
|
|
|
871
1082
|
"method": "char4_proxy",
|
|
872
1083
|
"chars_per_token": TOKEN_PROXY_CHARS_PER_TOKEN,
|
|
873
1084
|
"all_schema_bytes": all_schema_bytes,
|
|
1085
|
+
"inline_core_schema_bytes": core_schema_bytes,
|
|
1086
|
+
"listed_deferred_schema_bytes": listed_deferred_schema_bytes,
|
|
1087
|
+
"total_deferred_schema_bytes": total_deferred_schema_bytes,
|
|
874
1088
|
"tool_stub_report_bytes": tool_stub_report_bytes,
|
|
875
|
-
"all_schema_tokens_estimated":
|
|
876
|
-
"
|
|
1089
|
+
"all_schema_tokens_estimated": all_schema_tokens,
|
|
1090
|
+
"inline_core_schema_tokens_estimated": inline_core_schema_tokens,
|
|
1091
|
+
"listed_deferred_schema_tokens_estimated": listed_deferred_schema_tokens,
|
|
1092
|
+
"total_deferred_schema_tokens_estimated": total_deferred_schema_tokens,
|
|
1093
|
+
"tool_stub_report_tokens_estimated": tool_stub_report_tokens,
|
|
1094
|
+
"gross_listed_deferred_schema_tokens_avoided": listed_deferred_schema_tokens,
|
|
1095
|
+
"gross_total_deferred_schema_tokens_avoided": total_deferred_schema_tokens,
|
|
1096
|
+
"net_initial_report_tokens_delta": tool_stub_report_tokens - all_schema_tokens,
|
|
1097
|
+
"net_initial_report_tokens_delta_semantics": "tool_stub_report_tokens_estimated_minus_all_schema_tokens_estimated",
|
|
1098
|
+
"estimated_initial_schema_tokens_avoided": max(0, all_schema_tokens - tool_stub_report_tokens),
|
|
1099
|
+
"estimated_initial_schema_tokens_avoided_semantics": "max(0, all_schema_tokens_estimated - tool_stub_report_tokens_estimated)",
|
|
877
1100
|
"claim_boundary": "proxy_only_not_provider_billed_tokens",
|
|
878
1101
|
},
|
|
879
1102
|
"provider_patterns": [
|
|
@@ -899,11 +1122,13 @@ def defer_report(args: argparse.Namespace) -> str:
|
|
|
899
1122
|
"provider_tool_search_configured": False,
|
|
900
1123
|
"hosted_api_token_or_cost_savings_claim_allowed": False,
|
|
901
1124
|
"requires_provider_measured_matched_tasks_for_savings_claims": True,
|
|
1125
|
+
"deferred_schema_retrieval_required_before_use": True,
|
|
902
1126
|
},
|
|
903
1127
|
"redaction": {"redacted_values": total_redactions},
|
|
904
1128
|
"caveats": [
|
|
905
1129
|
"Deferred loading is an application strategy report, not a native provider integration.",
|
|
906
1130
|
"Token proxy values are char/4 estimates over sanitized local JSON, not billed provider tokens.",
|
|
1131
|
+
"Deferred schema token fields are initial-prompt proxy accounting; full schemas must be retrieved before deferred tool use.",
|
|
907
1132
|
"Use receipt get commands to retrieve full sanitized schemas before using deferred tools.",
|
|
908
1133
|
],
|
|
909
1134
|
}
|