@ictechgy/context-guard 0.4.14 → 0.4.16
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 +15 -0
- package/README.ko.md +72 -1
- package/README.md +85 -2
- package/docs/benchmark-fixtures/image-context-pack-full-evidence.prompt.example.md +28 -0
- package/docs/benchmark-fixtures/image-context-pack-packed-evidence.prompt.example.md +31 -0
- package/docs/benchmark-fixtures/image-context-pack.evidence.example.jsonl +2 -0
- package/docs/benchmark-fixtures/image-context-pack.tasks.example.json +18 -0
- package/docs/benchmark-fixtures/image-context-pack.variants.example.json +10 -0
- package/docs/benchmark-workflow-examples.md +16 -0
- package/docs/experimental-benchmark-fixtures.md +52 -1
- package/package.json +2 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +43 -0
- package/plugins/context-guard/README.md +43 -0
- package/plugins/context-guard/bin/context-guard-artifact +90 -9
- package/plugins/context-guard/bin/context-guard-audit +169 -66
- package/plugins/context-guard/bin/context-guard-bench +7038 -307
- package/plugins/context-guard/bin/context-guard-compress +90 -8
- package/plugins/context-guard/bin/context-guard-diet +1 -7
- package/plugins/context-guard/bin/context-guard-experiments +3085 -134
- package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
- package/plugins/context-guard/bin/context-guard-guard-read +490 -55
- package/plugins/context-guard/bin/context-guard-mcp +999 -0
- package/plugins/context-guard/bin/context-guard-pack +744 -20
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
- package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
- package/plugins/context-guard/bin/context-guard-setup +1073 -147
- package/plugins/context-guard/bin/context-guard-statusline +131 -54
- package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +89 -13
- package/plugins/context-guard/brief/README.md +19 -0
- package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
- package/plugins/context-guard/lib/context_guard_commands.py +14 -2
- package/plugins/context-guard/lib/credential_policy.py +177 -0
- package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
|
@@ -11,8 +11,10 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
13
|
import ast
|
|
14
|
+
from collections import Counter, deque
|
|
14
15
|
import copy
|
|
15
16
|
import hashlib
|
|
17
|
+
import heapq
|
|
16
18
|
import importlib.machinery
|
|
17
19
|
import importlib.util
|
|
18
20
|
import json
|
|
@@ -35,6 +37,8 @@ DEFAULT_BUDGET_BYTES = 12_000
|
|
|
35
37
|
MIN_BUDGET_BYTES = 0
|
|
36
38
|
MAX_BUDGET_BYTES = 2_000_000
|
|
37
39
|
MAX_RECEIPT_BYTES = 64_000
|
|
40
|
+
ROLLING_DELTA_SAMPLE_BYTES = 65_536
|
|
41
|
+
ROLLING_DELTA_WINDOW_BYTES = 64
|
|
38
42
|
MAX_MANIFEST_BYTES = 1_000_000
|
|
39
43
|
MAX_LABEL_CHARS = 160
|
|
40
44
|
MAX_REASON_CHARS = 120
|
|
@@ -45,6 +49,16 @@ AUTO_EXPLAIN_SCHEMA_VERSION = "contextguard.pack-auto-explain.v1"
|
|
|
45
49
|
REPO_MAP_SCHEMA_VERSION = "contextguard.pack-repo-map.v1"
|
|
46
50
|
ADAPTIVE_K_SCHEMA_VERSION = "contextguard.pack-adaptive-k.v1"
|
|
47
51
|
SYMBOL_MEMORY_SCHEMA_VERSION = "contextguard.pack-symbol-memory.v1"
|
|
52
|
+
CONTENT_ADDRESS_SCHEMA_VERSION = "contextguard.pack-content-address.v1"
|
|
53
|
+
ROLLING_DELTA_SCHEMA_VERSION = "contextguard.pack-rolling-delta.v1"
|
|
54
|
+
SKETCH_DUPLICATE_SHINGLE_WIDTH = 5
|
|
55
|
+
SKETCH_DUPLICATE_RETAINED_DIGESTS = 64
|
|
56
|
+
SKETCH_DUPLICATE_MIN_CARDINALITY = 12
|
|
57
|
+
SKETCH_DUPLICATE_THRESHOLD_NUMERATOR = 9
|
|
58
|
+
SKETCH_DUPLICATE_THRESHOLD_DENOMINATOR = 10
|
|
59
|
+
SKETCH_DUPLICATE_COMPARISON_CAP = 100_000
|
|
60
|
+
SKETCH_DUPLICATE_SHINGLE_DOMAIN = b"context-guard-pack/sketch-duplicate-veto/shingle/v1\x00"
|
|
61
|
+
SKETCH_DUPLICATE_TOKEN_RE = re.compile(r"\w+")
|
|
48
62
|
DEFAULT_SUGGEST_TOP = 8
|
|
49
63
|
MAX_SUGGEST_TOP = 50
|
|
50
64
|
DEFAULT_SUGGEST_CONTEXT_LINES = 20
|
|
@@ -151,6 +165,7 @@ class SourceSpec:
|
|
|
151
165
|
label: str | None = None
|
|
152
166
|
input_index: int = 0
|
|
153
167
|
origin: str = "cli"
|
|
168
|
+
sanitization_context: str = "source_code"
|
|
154
169
|
|
|
155
170
|
|
|
156
171
|
@dataclass
|
|
@@ -165,6 +180,18 @@ class ResolvedSource:
|
|
|
165
180
|
redacted_lines: int
|
|
166
181
|
|
|
167
182
|
|
|
183
|
+
@dataclass
|
|
184
|
+
class _PairedCandidate:
|
|
185
|
+
source: ResolvedSource
|
|
186
|
+
canonical: dict[str, Any]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class _DuplicateSignature:
|
|
191
|
+
exact_digest: bytes
|
|
192
|
+
sketch: frozenset[bytes] | None
|
|
193
|
+
|
|
194
|
+
|
|
168
195
|
@dataclass
|
|
169
196
|
class SuggestCandidate:
|
|
170
197
|
path: str
|
|
@@ -179,9 +206,32 @@ class PackError(ValueError):
|
|
|
179
206
|
pass
|
|
180
207
|
|
|
181
208
|
|
|
209
|
+
SANITIZATION_CONTEXTS = frozenset(
|
|
210
|
+
{
|
|
211
|
+
"unknown_text",
|
|
212
|
+
"command_search_diff",
|
|
213
|
+
"filesystem_listing",
|
|
214
|
+
"source_code",
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parse_sanitization_context(value: object) -> str:
|
|
220
|
+
context = str(value or "unknown_text")
|
|
221
|
+
if context not in SANITIZATION_CONTEXTS:
|
|
222
|
+
raise PackError(f"unsupported sanitization context: {context}")
|
|
223
|
+
return context
|
|
224
|
+
|
|
225
|
+
|
|
182
226
|
class FallbackLineSanitizer:
|
|
183
|
-
def __init__(
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
*,
|
|
230
|
+
show_paths: bool = False,
|
|
231
|
+
context: str = "unknown_text",
|
|
232
|
+
) -> None:
|
|
184
233
|
self.show_paths = show_paths
|
|
234
|
+
self.context = context
|
|
185
235
|
self.redactions = 0
|
|
186
236
|
|
|
187
237
|
def sanitize(self, raw_line: str) -> tuple[str, bool]:
|
|
@@ -226,7 +276,12 @@ def load_line_sanitizer_factory() -> Any:
|
|
|
226
276
|
if spec is None:
|
|
227
277
|
raise RuntimeError("import spec unavailable")
|
|
228
278
|
module = importlib.util.module_from_spec(spec)
|
|
229
|
-
loader.
|
|
279
|
+
sys.modules[loader.name] = module
|
|
280
|
+
try:
|
|
281
|
+
loader.exec_module(module)
|
|
282
|
+
except Exception:
|
|
283
|
+
sys.modules.pop(loader.name, None)
|
|
284
|
+
raise
|
|
230
285
|
_LINE_SANITIZER_FACTORY_CACHE = module.LineSanitizer
|
|
231
286
|
return _LINE_SANITIZER_FACTORY_CACHE
|
|
232
287
|
except Exception as exc:
|
|
@@ -235,13 +290,53 @@ def load_line_sanitizer_factory() -> Any:
|
|
|
235
290
|
return _LINE_SANITIZER_FACTORY_CACHE
|
|
236
291
|
|
|
237
292
|
|
|
238
|
-
def
|
|
293
|
+
def instantiate_line_sanitizer(
|
|
294
|
+
factory: Any,
|
|
295
|
+
*,
|
|
296
|
+
show_paths: bool,
|
|
297
|
+
context: str,
|
|
298
|
+
private_roots: tuple[str, ...] = (),
|
|
299
|
+
) -> object:
|
|
300
|
+
try:
|
|
301
|
+
return factory(
|
|
302
|
+
show_paths=show_paths,
|
|
303
|
+
context=context,
|
|
304
|
+
private_roots=private_roots,
|
|
305
|
+
)
|
|
306
|
+
except TypeError:
|
|
307
|
+
if context != "unknown_text" or private_roots:
|
|
308
|
+
raise RuntimeError(
|
|
309
|
+
"adjacent sanitizer does not support required explicit context"
|
|
310
|
+
)
|
|
311
|
+
return factory(show_paths=show_paths)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def load_line_sanitizer(
|
|
315
|
+
show_paths: bool = False,
|
|
316
|
+
context: str = "unknown_text",
|
|
317
|
+
private_roots: tuple[str, ...] = (),
|
|
318
|
+
) -> object:
|
|
239
319
|
sanitizer_factory = load_line_sanitizer_factory()
|
|
240
|
-
return
|
|
320
|
+
return instantiate_line_sanitizer(
|
|
321
|
+
sanitizer_factory,
|
|
322
|
+
show_paths=show_paths,
|
|
323
|
+
context=context,
|
|
324
|
+
private_roots=private_roots,
|
|
325
|
+
)
|
|
241
326
|
|
|
242
327
|
|
|
243
|
-
def sanitize_text(
|
|
244
|
-
|
|
328
|
+
def sanitize_text(
|
|
329
|
+
text: str,
|
|
330
|
+
*,
|
|
331
|
+
show_paths: bool = False,
|
|
332
|
+
context: str = "unknown_text",
|
|
333
|
+
private_roots: tuple[str, ...] = (),
|
|
334
|
+
) -> tuple[str, int]:
|
|
335
|
+
sanitizer = load_line_sanitizer(
|
|
336
|
+
show_paths,
|
|
337
|
+
context=context,
|
|
338
|
+
private_roots=private_roots,
|
|
339
|
+
)
|
|
245
340
|
redacted = 0
|
|
246
341
|
out: list[str] = []
|
|
247
342
|
for line in text.splitlines(True):
|
|
@@ -252,7 +347,13 @@ def sanitize_text(text: str, *, show_paths: bool = False) -> tuple[str, int]:
|
|
|
252
347
|
return "".join(out), redacted
|
|
253
348
|
|
|
254
349
|
|
|
255
|
-
def sanitize_source_lines(
|
|
350
|
+
def sanitize_source_lines(
|
|
351
|
+
handle: Any,
|
|
352
|
+
requested: LineRange | None,
|
|
353
|
+
*,
|
|
354
|
+
context: str = "source_code",
|
|
355
|
+
private_roots: tuple[str, ...] = (),
|
|
356
|
+
) -> tuple[list[str], int, int]:
|
|
256
357
|
"""Sanitize a source stream while retaining only the requested line window.
|
|
257
358
|
|
|
258
359
|
Explicit line-window retrieval still scans the complete file so global
|
|
@@ -260,7 +361,10 @@ def sanitize_source_lines(handle: Any, requested: LineRange | None) -> tuple[lis
|
|
|
260
361
|
outputs, but it no longer materializes a sanitized all-lines list before
|
|
261
362
|
slicing.
|
|
262
363
|
"""
|
|
263
|
-
sanitizer = load_line_sanitizer(
|
|
364
|
+
sanitizer = load_line_sanitizer(
|
|
365
|
+
context=context,
|
|
366
|
+
private_roots=private_roots,
|
|
367
|
+
)
|
|
264
368
|
selected: list[str] = []
|
|
265
369
|
redacted = 0
|
|
266
370
|
total_lines = 0
|
|
@@ -290,6 +394,303 @@ def sha256_text(text: str) -> str:
|
|
|
290
394
|
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
|
|
291
395
|
|
|
292
396
|
|
|
397
|
+
def _sketch_duplicate_shingle_digest(tokens: tuple[str, ...]) -> bytes:
|
|
398
|
+
if len(tokens) != SKETCH_DUPLICATE_SHINGLE_WIDTH:
|
|
399
|
+
raise ValueError("sketch duplicate shingles require exactly five tokens")
|
|
400
|
+
digest = hashlib.sha256()
|
|
401
|
+
digest.update(SKETCH_DUPLICATE_SHINGLE_DOMAIN)
|
|
402
|
+
for token in tokens:
|
|
403
|
+
encoded = token.encode("utf-8", errors="strict")
|
|
404
|
+
digest.update(len(encoded).to_bytes(8, "big", signed=False))
|
|
405
|
+
digest.update(encoded)
|
|
406
|
+
return digest.digest()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _retain_bottom_sketch_digest(heap: list[tuple[int, bytes]], retained: set[bytes], digest: bytes) -> None:
|
|
410
|
+
if digest in retained:
|
|
411
|
+
return
|
|
412
|
+
number = int.from_bytes(digest, "big", signed=False)
|
|
413
|
+
if len(heap) < SKETCH_DUPLICATE_RETAINED_DIGESTS:
|
|
414
|
+
heapq.heappush(heap, (-number, digest))
|
|
415
|
+
retained.add(digest)
|
|
416
|
+
return
|
|
417
|
+
largest = heap[0][1]
|
|
418
|
+
if digest >= largest:
|
|
419
|
+
return
|
|
420
|
+
_negative, removed = heapq.heapreplace(heap, (-number, digest))
|
|
421
|
+
retained.remove(removed)
|
|
422
|
+
retained.add(digest)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _sketch_duplicate_signature(lines: list[str], *, include_sketch: bool) -> _DuplicateSignature:
|
|
426
|
+
exact = hashlib.sha256()
|
|
427
|
+
if not include_sketch:
|
|
428
|
+
for line in lines:
|
|
429
|
+
exact.update(line.encode("utf-8", errors="replace"))
|
|
430
|
+
return _DuplicateSignature(exact.digest(), None)
|
|
431
|
+
|
|
432
|
+
token_window: deque[str] = deque(maxlen=SKETCH_DUPLICATE_SHINGLE_WIDTH)
|
|
433
|
+
heap: list[tuple[int, bytes]] = []
|
|
434
|
+
retained: set[bytes] = set()
|
|
435
|
+
for line in lines:
|
|
436
|
+
exact.update(line.encode("utf-8", errors="replace"))
|
|
437
|
+
for match in SKETCH_DUPLICATE_TOKEN_RE.finditer(line.casefold()):
|
|
438
|
+
token_window.append(match.group(0))
|
|
439
|
+
if len(token_window) == SKETCH_DUPLICATE_SHINGLE_WIDTH:
|
|
440
|
+
digest = _sketch_duplicate_shingle_digest(tuple(token_window))
|
|
441
|
+
_retain_bottom_sketch_digest(heap, retained, digest)
|
|
442
|
+
return _DuplicateSignature(exact.digest(), frozenset(retained))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _sanitized_source_bytes_equal(left: ResolvedSource, right: ResolvedSource) -> bool:
|
|
446
|
+
if len(left.selected_lines) != len(right.selected_lines):
|
|
447
|
+
return False
|
|
448
|
+
return all(
|
|
449
|
+
left_line.encode("utf-8", errors="replace") == right_line.encode("utf-8", errors="replace")
|
|
450
|
+
for left_line, right_line in zip(left.selected_lines, right.selected_lines)
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _sketch_sets_match(left: frozenset[bytes], right: frozenset[bytes]) -> bool:
|
|
455
|
+
intersection = len(left & right)
|
|
456
|
+
union = len(left) + len(right) - intersection
|
|
457
|
+
return (
|
|
458
|
+
union > 0
|
|
459
|
+
and SKETCH_DUPLICATE_THRESHOLD_DENOMINATOR * intersection
|
|
460
|
+
>= SKETCH_DUPLICATE_THRESHOLD_NUMERATOR * union
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _ordered_sketch_winner_ids(
|
|
465
|
+
sketch: frozenset[bytes],
|
|
466
|
+
postings: dict[bytes, list[int]],
|
|
467
|
+
) -> Any:
|
|
468
|
+
heap: list[tuple[int, int, int, list[int]]] = []
|
|
469
|
+
for ordinal, digest in enumerate(sorted(sketch)):
|
|
470
|
+
winner_ids = postings.get(digest)
|
|
471
|
+
if winner_ids:
|
|
472
|
+
heapq.heappush(heap, (winner_ids[0], ordinal, 0, winner_ids))
|
|
473
|
+
last_yielded: int | None = None
|
|
474
|
+
while heap:
|
|
475
|
+
winner_id, ordinal, index, winner_ids = heapq.heappop(heap)
|
|
476
|
+
next_index = index + 1
|
|
477
|
+
if next_index < len(winner_ids):
|
|
478
|
+
heapq.heappush(heap, (winner_ids[next_index], ordinal, next_index, winner_ids))
|
|
479
|
+
if winner_id != last_yielded:
|
|
480
|
+
last_yielded = winner_id
|
|
481
|
+
yield winner_id
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _sketch_duplicate_omission(source: ResolvedSource, *, root_arg: str) -> dict[str, Any]:
|
|
485
|
+
requested = source.requested_lines or LineRange(1, source.total_lines)
|
|
486
|
+
item = omission(
|
|
487
|
+
source.spec,
|
|
488
|
+
"sketch_duplicate_source",
|
|
489
|
+
path=source.display_path,
|
|
490
|
+
redacted_path=source.redacted_path,
|
|
491
|
+
)
|
|
492
|
+
item["requested_lines"] = requested.as_dict()
|
|
493
|
+
retrieval, retrieval_omitted_reason = retrieval_for(
|
|
494
|
+
root_arg,
|
|
495
|
+
source.display_path,
|
|
496
|
+
requested,
|
|
497
|
+
redacted_path=source.redacted_path,
|
|
498
|
+
)
|
|
499
|
+
if retrieval:
|
|
500
|
+
item["retrieval_cli"] = retrieval
|
|
501
|
+
item.pop("retrieval_omitted_reason", None)
|
|
502
|
+
elif retrieval_omitted_reason:
|
|
503
|
+
item["retrieval_omitted_reason"] = retrieval_omitted_reason
|
|
504
|
+
return item
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _apply_sketch_duplicate_veto(
|
|
508
|
+
candidates: list[_PairedCandidate],
|
|
509
|
+
omitted: list[dict[str, Any]],
|
|
510
|
+
*,
|
|
511
|
+
root_arg: str,
|
|
512
|
+
) -> tuple[list[ResolvedSource], bool]:
|
|
513
|
+
winners: list[_PairedCandidate] = []
|
|
514
|
+
exact_winners: dict[bytes, list[int]] = {}
|
|
515
|
+
winner_sketches: dict[int, frozenset[bytes]] = {}
|
|
516
|
+
postings: dict[bytes, list[int]] = {}
|
|
517
|
+
comparisons_remaining = SKETCH_DUPLICATE_COMPARISON_CAP
|
|
518
|
+
comparison_cap_reached = False
|
|
519
|
+
|
|
520
|
+
for candidate in candidates:
|
|
521
|
+
signature = _sketch_duplicate_signature(
|
|
522
|
+
candidate.source.selected_lines,
|
|
523
|
+
include_sketch=not comparison_cap_reached,
|
|
524
|
+
)
|
|
525
|
+
duplicate = False
|
|
526
|
+
for winner_id in exact_winners.get(signature.exact_digest, ()):
|
|
527
|
+
if _sanitized_source_bytes_equal(candidate.source, winners[winner_id].source):
|
|
528
|
+
duplicate = True
|
|
529
|
+
break
|
|
530
|
+
|
|
531
|
+
skipped_pair = False
|
|
532
|
+
sketch = signature.sketch
|
|
533
|
+
if (
|
|
534
|
+
not duplicate
|
|
535
|
+
and not comparison_cap_reached
|
|
536
|
+
and sketch is not None
|
|
537
|
+
and len(sketch) >= SKETCH_DUPLICATE_MIN_CARDINALITY
|
|
538
|
+
):
|
|
539
|
+
for winner_id in _ordered_sketch_winner_ids(sketch, postings):
|
|
540
|
+
if comparisons_remaining == 0:
|
|
541
|
+
comparison_cap_reached = True
|
|
542
|
+
skipped_pair = True
|
|
543
|
+
sketch = None
|
|
544
|
+
break
|
|
545
|
+
comparisons_remaining -= 1
|
|
546
|
+
if _sketch_sets_match(sketch, winner_sketches[winner_id]):
|
|
547
|
+
duplicate = True
|
|
548
|
+
break
|
|
549
|
+
|
|
550
|
+
if duplicate:
|
|
551
|
+
candidate.canonical["status"] = "sketch_duplicate_source"
|
|
552
|
+
omitted.append(_sketch_duplicate_omission(candidate.source, root_arg=root_arg))
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
winner_id = len(winners)
|
|
556
|
+
winners.append(candidate)
|
|
557
|
+
exact_winners.setdefault(signature.exact_digest, []).append(winner_id)
|
|
558
|
+
if (
|
|
559
|
+
not skipped_pair
|
|
560
|
+
and not comparison_cap_reached
|
|
561
|
+
and sketch is not None
|
|
562
|
+
and len(sketch) >= SKETCH_DUPLICATE_MIN_CARDINALITY
|
|
563
|
+
):
|
|
564
|
+
winner_sketches[winner_id] = sketch
|
|
565
|
+
for digest in sketch:
|
|
566
|
+
postings.setdefault(digest, []).append(winner_id)
|
|
567
|
+
|
|
568
|
+
return [candidate.source for candidate in winners], comparison_cap_reached
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def pack_id_arg(value: str) -> str:
|
|
572
|
+
if re.fullmatch(r"[0-9a-f]{20}", value) is None:
|
|
573
|
+
raise argparse.ArgumentTypeError("PACK_ID must be exactly 20 lowercase hexadecimal characters")
|
|
574
|
+
return value
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def content_address(digest: str, bytes_count: int) -> dict[str, Any]:
|
|
578
|
+
return {
|
|
579
|
+
"schema_version": CONTENT_ADDRESS_SCHEMA_VERSION,
|
|
580
|
+
"id": f"sha256:{digest}",
|
|
581
|
+
"algorithm": "sha256",
|
|
582
|
+
"digest": digest,
|
|
583
|
+
"bytes": bytes_count,
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def rolling_sample_metadata(body: bytes) -> dict[str, Any]:
|
|
588
|
+
sampled_bytes = min(len(body), ROLLING_DELTA_SAMPLE_BYTES)
|
|
589
|
+
if sampled_bytes == 0:
|
|
590
|
+
window_count = 0
|
|
591
|
+
elif sampled_bytes < ROLLING_DELTA_WINDOW_BYTES:
|
|
592
|
+
window_count = 1
|
|
593
|
+
else:
|
|
594
|
+
window_count = sampled_bytes - ROLLING_DELTA_WINDOW_BYTES + 1
|
|
595
|
+
return {
|
|
596
|
+
"total_bytes": len(body),
|
|
597
|
+
"sampled_bytes": sampled_bytes,
|
|
598
|
+
"window_count": window_count,
|
|
599
|
+
"truncated": len(body) > ROLLING_DELTA_SAMPLE_BYTES,
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def rolling_window_multiset(body: bytes) -> Counter[bytes]:
|
|
604
|
+
sample = body[:ROLLING_DELTA_SAMPLE_BYTES]
|
|
605
|
+
if not sample:
|
|
606
|
+
return Counter()
|
|
607
|
+
if len(sample) < ROLLING_DELTA_WINDOW_BYTES:
|
|
608
|
+
return Counter((hashlib.sha256(sample).digest(),))
|
|
609
|
+
return Counter(
|
|
610
|
+
hashlib.sha256(sample[index:index + ROLLING_DELTA_WINDOW_BYTES]).digest()
|
|
611
|
+
for index in range(len(sample) - ROLLING_DELTA_WINDOW_BYTES + 1)
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def rolling_delta_algorithm() -> dict[str, Any]:
|
|
616
|
+
return {
|
|
617
|
+
"name": "sha256_sliding_window_multiset",
|
|
618
|
+
"window_bytes": ROLLING_DELTA_WINDOW_BYTES,
|
|
619
|
+
"stride_bytes": 1,
|
|
620
|
+
"max_sample_bytes_per_pack": ROLLING_DELTA_SAMPLE_BYTES,
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def rolling_delta_claim_boundary() -> dict[str, bool]:
|
|
625
|
+
return {
|
|
626
|
+
"diagnostic_only": True,
|
|
627
|
+
"changes_manifest_selection_or_pack": False,
|
|
628
|
+
"provider_token_or_cost_savings_claim_allowed": False,
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def build_rolling_delta(
|
|
633
|
+
current_pack: str,
|
|
634
|
+
previous_pack: str,
|
|
635
|
+
previous_pack_id: str,
|
|
636
|
+
current_address: str,
|
|
637
|
+
) -> dict[str, Any]:
|
|
638
|
+
current_body = current_pack.encode("utf-8")
|
|
639
|
+
previous_body = previous_pack.encode("utf-8")
|
|
640
|
+
current_meta = rolling_sample_metadata(current_body)
|
|
641
|
+
previous_meta = rolling_sample_metadata(previous_body)
|
|
642
|
+
current_windows = rolling_window_multiset(current_body)
|
|
643
|
+
previous_windows = rolling_window_multiset(previous_body)
|
|
644
|
+
matched = sum((current_windows & previous_windows).values())
|
|
645
|
+
current_count = current_meta["window_count"]
|
|
646
|
+
previous_count = previous_meta["window_count"]
|
|
647
|
+
both_empty = current_count == 0 and previous_count == 0
|
|
648
|
+
if both_empty:
|
|
649
|
+
current_ratio = 1.0
|
|
650
|
+
previous_ratio = 1.0
|
|
651
|
+
else:
|
|
652
|
+
current_ratio = round(matched / current_count, 6) if current_count else 0.0
|
|
653
|
+
previous_ratio = round(matched / previous_count, 6) if previous_count else 0.0
|
|
654
|
+
return {
|
|
655
|
+
"schema_version": ROLLING_DELTA_SCHEMA_VERSION,
|
|
656
|
+
"status": "partial" if current_meta["truncated"] or previous_meta["truncated"] else "available",
|
|
657
|
+
"previous_pack_id": previous_pack_id,
|
|
658
|
+
"current_content_address": current_address,
|
|
659
|
+
"previous_content_address": f"sha256:{hashlib.sha256(previous_body).hexdigest()}",
|
|
660
|
+
"algorithm": rolling_delta_algorithm(),
|
|
661
|
+
"current": current_meta,
|
|
662
|
+
"previous": previous_meta,
|
|
663
|
+
"matched_window_count": matched,
|
|
664
|
+
"current_reuse_ratio_proxy": current_ratio,
|
|
665
|
+
"previous_retention_ratio_proxy": previous_ratio,
|
|
666
|
+
"reason": None,
|
|
667
|
+
"claim_boundary": rolling_delta_claim_boundary(),
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def unavailable_rolling_delta(
|
|
672
|
+
current_pack: str,
|
|
673
|
+
previous_pack_id: str,
|
|
674
|
+
current_address: str,
|
|
675
|
+
reason: str,
|
|
676
|
+
) -> dict[str, Any]:
|
|
677
|
+
return {
|
|
678
|
+
"schema_version": ROLLING_DELTA_SCHEMA_VERSION,
|
|
679
|
+
"status": "unavailable",
|
|
680
|
+
"previous_pack_id": previous_pack_id,
|
|
681
|
+
"current_content_address": current_address,
|
|
682
|
+
"previous_content_address": None,
|
|
683
|
+
"algorithm": rolling_delta_algorithm(),
|
|
684
|
+
"current": rolling_sample_metadata(current_pack.encode("utf-8")),
|
|
685
|
+
"previous": {"total_bytes": 0, "sampled_bytes": 0, "window_count": 0, "truncated": False},
|
|
686
|
+
"matched_window_count": 0,
|
|
687
|
+
"current_reuse_ratio_proxy": 0.0,
|
|
688
|
+
"previous_retention_ratio_proxy": 0.0,
|
|
689
|
+
"reason": reason,
|
|
690
|
+
"claim_boundary": rolling_delta_claim_boundary(),
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
|
|
293
694
|
def path_hash(path: Path) -> str:
|
|
294
695
|
return hashlib.sha256(str(path).encode("utf-8", "replace")).hexdigest()[:12]
|
|
295
696
|
|
|
@@ -563,6 +964,9 @@ def read_manifest(path: Path) -> list[SourceSpec]:
|
|
|
563
964
|
lines=lines,
|
|
564
965
|
label=cap_label(item.get("label")),
|
|
565
966
|
origin="manifest",
|
|
967
|
+
sanitization_context=parse_sanitization_context(
|
|
968
|
+
item.get("sanitization_context", item.get("context"))
|
|
969
|
+
),
|
|
566
970
|
))
|
|
567
971
|
return out
|
|
568
972
|
|
|
@@ -594,6 +998,9 @@ def parse_source_spec(raw: str) -> SourceSpec:
|
|
|
594
998
|
lines=lines,
|
|
595
999
|
label=cap_label(values.get("label")),
|
|
596
1000
|
origin="cli",
|
|
1001
|
+
sanitization_context=parse_sanitization_context(
|
|
1002
|
+
values.get("sanitization_context", values.get("context"))
|
|
1003
|
+
),
|
|
597
1004
|
)
|
|
598
1005
|
|
|
599
1006
|
|
|
@@ -771,7 +1178,16 @@ def resolve_source(root: Path, spec: SourceSpec) -> tuple[ResolvedSource | None,
|
|
|
771
1178
|
try:
|
|
772
1179
|
with handle:
|
|
773
1180
|
requested = spec.lines
|
|
774
|
-
selected, total_lines, redacted_lines = sanitize_source_lines(
|
|
1181
|
+
selected, total_lines, redacted_lines = sanitize_source_lines(
|
|
1182
|
+
handle,
|
|
1183
|
+
requested,
|
|
1184
|
+
context=spec.sanitization_context,
|
|
1185
|
+
private_roots=(
|
|
1186
|
+
(str(root),)
|
|
1187
|
+
if spec.sanitization_context == "filesystem_listing"
|
|
1188
|
+
else ()
|
|
1189
|
+
),
|
|
1190
|
+
)
|
|
775
1191
|
except OSError:
|
|
776
1192
|
return None, omission(spec, "unsafe_path", path=display, redacted_path=redacted_path)
|
|
777
1193
|
if total_lines <= 0:
|
|
@@ -1168,6 +1584,211 @@ def shrink_receipt_for_write(data: dict[str, Any]) -> tuple[dict[str, Any], bool
|
|
|
1168
1584
|
return receipt, capped
|
|
1169
1585
|
|
|
1170
1586
|
|
|
1587
|
+
class ReceiptJSONError(ValueError):
|
|
1588
|
+
pass
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def strict_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
1592
|
+
result: dict[str, Any] = {}
|
|
1593
|
+
for key, value in pairs:
|
|
1594
|
+
if key in result:
|
|
1595
|
+
raise ReceiptJSONError("duplicate key")
|
|
1596
|
+
result[key] = value
|
|
1597
|
+
return result
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def reject_json_constant(_value: str) -> Any:
|
|
1601
|
+
raise ReceiptJSONError("non-finite number")
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def parse_receipt_int(value: str) -> int:
|
|
1605
|
+
digits = value.removeprefix("-")
|
|
1606
|
+
if len(digits) > 20:
|
|
1607
|
+
raise ReceiptJSONError("integer too large")
|
|
1608
|
+
return int(value)
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def json_depth(value: Any, depth: int = 1) -> int:
|
|
1612
|
+
if depth > 100:
|
|
1613
|
+
raise ReceiptJSONError("maximum depth exceeded")
|
|
1614
|
+
if isinstance(value, dict):
|
|
1615
|
+
for item in value.values():
|
|
1616
|
+
json_depth(item, depth + 1)
|
|
1617
|
+
elif isinstance(value, list):
|
|
1618
|
+
for item in value:
|
|
1619
|
+
json_depth(item, depth + 1)
|
|
1620
|
+
return depth
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def prior_read_capabilities_available() -> bool:
|
|
1624
|
+
return (
|
|
1625
|
+
hasattr(os, "O_NOFOLLOW")
|
|
1626
|
+
and hasattr(os, "geteuid")
|
|
1627
|
+
and os.open in getattr(os, "supports_dir_fd", set())
|
|
1628
|
+
and os.stat in getattr(os, "supports_dir_fd", set())
|
|
1629
|
+
and os.stat in getattr(os, "supports_follow_symlinks", set())
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def private_receipt_stat_safe(st: os.stat_result, *, directory: bool) -> bool:
|
|
1634
|
+
expected_mode = 0o700 if directory else 0o600
|
|
1635
|
+
expected_type = stat.S_ISDIR(st.st_mode) if directory else stat.S_ISREG(st.st_mode)
|
|
1636
|
+
return (
|
|
1637
|
+
expected_type
|
|
1638
|
+
and st.st_uid == os.geteuid()
|
|
1639
|
+
and stat.S_IMODE(st.st_mode) == expected_mode
|
|
1640
|
+
and (directory or st.st_nlink == 1)
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def stat_identity(st: os.stat_result) -> tuple[int, int, int, int, int, int]:
|
|
1645
|
+
return (st.st_dev, st.st_ino, st.st_mode, st.st_uid, st.st_nlink, st.st_size)
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
def read_previous_receipt(root: Path, requested_id: str) -> tuple[str | None, str | None]:
|
|
1649
|
+
if not prior_read_capabilities_available():
|
|
1650
|
+
return None, "previous_receipt_unsafe"
|
|
1651
|
+
current_fd: int | None = None
|
|
1652
|
+
file_fd: int | None = None
|
|
1653
|
+
parent_stats: list[os.stat_result] = []
|
|
1654
|
+
try:
|
|
1655
|
+
current_fd = open_dir_no_follow(root)
|
|
1656
|
+
for part in (".context-guard", "packs"):
|
|
1657
|
+
try:
|
|
1658
|
+
next_fd = open_dir_no_follow(part, dir_fd=current_fd)
|
|
1659
|
+
except FileNotFoundError:
|
|
1660
|
+
return None, "previous_receipt_not_found"
|
|
1661
|
+
except (OSError, PackError, NotImplementedError):
|
|
1662
|
+
return None, "previous_receipt_unsafe"
|
|
1663
|
+
os.close(current_fd)
|
|
1664
|
+
current_fd = next_fd
|
|
1665
|
+
try:
|
|
1666
|
+
parent_stat = os.fstat(current_fd)
|
|
1667
|
+
except OSError:
|
|
1668
|
+
return None, "previous_receipt_unsafe"
|
|
1669
|
+
parent_stats.append(parent_stat)
|
|
1670
|
+
|
|
1671
|
+
filename = f"{requested_id}.json"
|
|
1672
|
+
try:
|
|
1673
|
+
before = os.stat(filename, dir_fd=current_fd, follow_symlinks=False)
|
|
1674
|
+
except FileNotFoundError:
|
|
1675
|
+
return None, "previous_receipt_not_found"
|
|
1676
|
+
except (OSError, NotImplementedError):
|
|
1677
|
+
return None, "previous_receipt_unsafe"
|
|
1678
|
+
unsafe_parent = any(not private_receipt_stat_safe(item, directory=True) for item in parent_stats)
|
|
1679
|
+
if unsafe_parent or not private_receipt_stat_safe(before, directory=False):
|
|
1680
|
+
return None, "previous_receipt_unsafe"
|
|
1681
|
+
if before.st_size > MAX_RECEIPT_BYTES:
|
|
1682
|
+
return None, "previous_receipt_too_large"
|
|
1683
|
+
|
|
1684
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW
|
|
1685
|
+
for name in ("O_CLOEXEC", "O_NONBLOCK", "O_NOCTTY"):
|
|
1686
|
+
flags |= getattr(os, name, 0)
|
|
1687
|
+
try:
|
|
1688
|
+
file_fd = os.open(filename, flags, dir_fd=current_fd)
|
|
1689
|
+
except FileNotFoundError:
|
|
1690
|
+
return None, "previous_receipt_invalid"
|
|
1691
|
+
except (OSError, NotImplementedError):
|
|
1692
|
+
return None, "previous_receipt_unsafe"
|
|
1693
|
+
opened = os.fstat(file_fd)
|
|
1694
|
+
if not private_receipt_stat_safe(opened, directory=False):
|
|
1695
|
+
return None, "previous_receipt_unsafe"
|
|
1696
|
+
if opened.st_size > MAX_RECEIPT_BYTES:
|
|
1697
|
+
return None, "previous_receipt_too_large"
|
|
1698
|
+
chunks: list[bytes] = []
|
|
1699
|
+
observed = 0
|
|
1700
|
+
while observed < MAX_RECEIPT_BYTES + 1:
|
|
1701
|
+
chunk = os.read(file_fd, min(16 * 1024, MAX_RECEIPT_BYTES + 1 - observed))
|
|
1702
|
+
if not chunk:
|
|
1703
|
+
break
|
|
1704
|
+
chunks.append(chunk)
|
|
1705
|
+
observed += len(chunk)
|
|
1706
|
+
raw = b"".join(chunks)
|
|
1707
|
+
after = os.fstat(file_fd)
|
|
1708
|
+
try:
|
|
1709
|
+
path_after = os.stat(filename, dir_fd=current_fd, follow_symlinks=False)
|
|
1710
|
+
except FileNotFoundError:
|
|
1711
|
+
return None, "previous_receipt_invalid"
|
|
1712
|
+
except (OSError, NotImplementedError):
|
|
1713
|
+
return None, "previous_receipt_unsafe"
|
|
1714
|
+
if not private_receipt_stat_safe(after, directory=False) or not private_receipt_stat_safe(path_after, directory=False):
|
|
1715
|
+
return None, "previous_receipt_unsafe"
|
|
1716
|
+
if len(raw) > MAX_RECEIPT_BYTES or after.st_size > MAX_RECEIPT_BYTES or path_after.st_size > MAX_RECEIPT_BYTES:
|
|
1717
|
+
return None, "previous_receipt_too_large"
|
|
1718
|
+
identities = (stat_identity(before), stat_identity(opened), stat_identity(after), stat_identity(path_after))
|
|
1719
|
+
if len(set(identities)) != 1 or len(raw) != after.st_size:
|
|
1720
|
+
return None, "previous_receipt_invalid"
|
|
1721
|
+
except OSError:
|
|
1722
|
+
return None, "previous_receipt_unsafe"
|
|
1723
|
+
finally:
|
|
1724
|
+
if file_fd is not None:
|
|
1725
|
+
try:
|
|
1726
|
+
os.close(file_fd)
|
|
1727
|
+
except OSError:
|
|
1728
|
+
pass
|
|
1729
|
+
if current_fd is not None:
|
|
1730
|
+
try:
|
|
1731
|
+
os.close(current_fd)
|
|
1732
|
+
except OSError:
|
|
1733
|
+
pass
|
|
1734
|
+
|
|
1735
|
+
try:
|
|
1736
|
+
receipt = json.loads(
|
|
1737
|
+
raw.decode("utf-8", errors="strict"),
|
|
1738
|
+
object_pairs_hook=strict_json_object,
|
|
1739
|
+
parse_constant=reject_json_constant,
|
|
1740
|
+
parse_int=parse_receipt_int,
|
|
1741
|
+
)
|
|
1742
|
+
json_depth(receipt)
|
|
1743
|
+
except (UnicodeDecodeError, ValueError, RecursionError):
|
|
1744
|
+
return None, "previous_receipt_invalid"
|
|
1745
|
+
if not isinstance(receipt, dict):
|
|
1746
|
+
return None, "previous_receipt_invalid"
|
|
1747
|
+
prior_id = receipt.get("pack_id")
|
|
1748
|
+
prior_bytes = receipt.get("pack_bytes")
|
|
1749
|
+
pack_present = "pack" in receipt
|
|
1750
|
+
address_present = "content_address" in receipt
|
|
1751
|
+
prior_pack = receipt.get("pack")
|
|
1752
|
+
prior_address = receipt.get("content_address")
|
|
1753
|
+
if not isinstance(prior_id, str) or re.fullmatch(r"[0-9a-f]{20}", prior_id) is None:
|
|
1754
|
+
return None, "previous_receipt_invalid"
|
|
1755
|
+
if not isinstance(prior_bytes, int) or isinstance(prior_bytes, bool) or prior_bytes < 0:
|
|
1756
|
+
return None, "previous_receipt_invalid"
|
|
1757
|
+
if pack_present and not isinstance(prior_pack, str):
|
|
1758
|
+
return None, "previous_receipt_invalid"
|
|
1759
|
+
if address_present and not isinstance(prior_address, dict):
|
|
1760
|
+
return None, "previous_receipt_invalid"
|
|
1761
|
+
if prior_id != requested_id:
|
|
1762
|
+
return None, "previous_pack_integrity_mismatch"
|
|
1763
|
+
if not pack_present:
|
|
1764
|
+
if receipt.get("pack_omitted_from_receipt") is True:
|
|
1765
|
+
return None, "previous_pack_body_unavailable"
|
|
1766
|
+
return None, "previous_receipt_invalid"
|
|
1767
|
+
assert isinstance(prior_pack, str)
|
|
1768
|
+
try:
|
|
1769
|
+
prior_body = prior_pack.encode("utf-8", errors="strict")
|
|
1770
|
+
except UnicodeEncodeError:
|
|
1771
|
+
return None, "previous_receipt_invalid"
|
|
1772
|
+
digest = hashlib.sha256(prior_body).hexdigest()
|
|
1773
|
+
if len(prior_body) != prior_bytes:
|
|
1774
|
+
return None, "previous_pack_integrity_mismatch"
|
|
1775
|
+
if address_present and prior_address != content_address(digest, prior_bytes):
|
|
1776
|
+
return None, "previous_pack_integrity_mismatch"
|
|
1777
|
+
return prior_pack, None
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def rolling_delta_from_receipt(
|
|
1781
|
+
root: Path,
|
|
1782
|
+
current_pack: str,
|
|
1783
|
+
previous_pack_id: str,
|
|
1784
|
+
current_address: str,
|
|
1785
|
+
) -> dict[str, Any]:
|
|
1786
|
+
previous_pack, reason = read_previous_receipt(root, previous_pack_id)
|
|
1787
|
+
if reason is not None or previous_pack is None:
|
|
1788
|
+
return unavailable_rolling_delta(current_pack, previous_pack_id, current_address, reason or "previous_receipt_invalid")
|
|
1789
|
+
return build_rolling_delta(current_pack, previous_pack, previous_pack_id, current_address)
|
|
1790
|
+
|
|
1791
|
+
|
|
1171
1792
|
def store_receipt(root: Path, result: dict[str, Any]) -> dict[str, Any]:
|
|
1172
1793
|
out_dir, dir_fd, dir_error = ensure_private_pack_dir(root)
|
|
1173
1794
|
if out_dir is None or dir_fd is None:
|
|
@@ -1204,9 +1825,19 @@ def store_receipt(root: Path, result: dict[str, Any]) -> dict[str, Any]:
|
|
|
1204
1825
|
}
|
|
1205
1826
|
|
|
1206
1827
|
|
|
1207
|
-
def build_pack(
|
|
1828
|
+
def build_pack(
|
|
1829
|
+
root: Path,
|
|
1830
|
+
specs: list[SourceSpec],
|
|
1831
|
+
*,
|
|
1832
|
+
budget_bytes: int,
|
|
1833
|
+
root_arg: str,
|
|
1834
|
+
store_artifact: bool,
|
|
1835
|
+
delta_from_pack_id: str | None = None,
|
|
1836
|
+
sketch_duplicate_veto: bool = False,
|
|
1837
|
+
) -> dict[str, Any]:
|
|
1208
1838
|
seen: set[tuple[str, str]] = set()
|
|
1209
1839
|
resolved: list[ResolvedSource] = []
|
|
1840
|
+
paired_candidates: list[_PairedCandidate] = []
|
|
1210
1841
|
omitted: list[dict[str, Any]] = []
|
|
1211
1842
|
canonical_specs: list[dict[str, Any]] = []
|
|
1212
1843
|
for spec in specs:
|
|
@@ -1237,8 +1868,20 @@ def build_pack(root: Path, specs: list[SourceSpec], *, budget_bytes: int, root_a
|
|
|
1237
1868
|
continue
|
|
1238
1869
|
assert source is not None
|
|
1239
1870
|
resolved.append(source)
|
|
1240
|
-
|
|
1241
|
-
|
|
1871
|
+
canonical = {"path": source.display_path, "priority": spec.priority, "lines": identity_lines, "status": "candidate"}
|
|
1872
|
+
canonical_specs.append(canonical)
|
|
1873
|
+
paired_candidates.append(_PairedCandidate(source, canonical))
|
|
1874
|
+
paired_candidates.sort(key=lambda item: (-item.source.spec.priority, item.source.spec.input_index, item.source.display_path))
|
|
1875
|
+
all_resolved = resolved
|
|
1876
|
+
comparison_cap_reached = False
|
|
1877
|
+
if sketch_duplicate_veto:
|
|
1878
|
+
resolved, comparison_cap_reached = _apply_sketch_duplicate_veto(
|
|
1879
|
+
paired_candidates,
|
|
1880
|
+
omitted,
|
|
1881
|
+
root_arg=root_arg,
|
|
1882
|
+
)
|
|
1883
|
+
else:
|
|
1884
|
+
resolved = [item.source for item in paired_candidates]
|
|
1242
1885
|
header = "# Context Pack\n\nGenerated by context-guard-pack. Token counts are estimated proxies; byte counts are observed.\n\n"
|
|
1243
1886
|
parts: list[str] = []
|
|
1244
1887
|
included: list[dict[str, Any]] = []
|
|
@@ -1267,7 +1910,8 @@ def build_pack(root: Path, specs: list[SourceSpec], *, budget_bytes: int, root_a
|
|
|
1267
1910
|
omitted.append(budget_omission(source, root_arg=root_arg))
|
|
1268
1911
|
pack = "".join(parts)
|
|
1269
1912
|
pack_bytes = current_pack_bytes
|
|
1270
|
-
|
|
1913
|
+
pack_digest = sha256_text(pack)
|
|
1914
|
+
redacted_lines = sum(source.redacted_lines for source in all_resolved)
|
|
1271
1915
|
partial_count = sum(1 for item in included if item.get("status") == "partial")
|
|
1272
1916
|
omitted_sorted = sorted(omitted, key=lambda item: (item.get("input_index", 0), str(item.get("path", "")), str(item.get("reason", ""))))
|
|
1273
1917
|
canonical = {
|
|
@@ -1275,7 +1919,7 @@ def build_pack(root: Path, specs: list[SourceSpec], *, budget_bytes: int, root_a
|
|
|
1275
1919
|
"root": display_root(root),
|
|
1276
1920
|
"budget_bytes": budget_bytes,
|
|
1277
1921
|
"sources": canonical_specs,
|
|
1278
|
-
"pack_sha256":
|
|
1922
|
+
"pack_sha256": pack_digest,
|
|
1279
1923
|
"omission_summary": sorted({str(item.get("reason")) for item in omitted_sorted}),
|
|
1280
1924
|
}
|
|
1281
1925
|
pack_id = hashlib.sha256(json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:20]
|
|
@@ -1287,6 +1931,7 @@ def build_pack(root: Path, specs: list[SourceSpec], *, budget_bytes: int, root_a
|
|
|
1287
1931
|
"budget_bytes": budget_bytes,
|
|
1288
1932
|
"pack_bytes": pack_bytes,
|
|
1289
1933
|
"pack": pack,
|
|
1934
|
+
"content_address": content_address(pack_digest, pack_bytes),
|
|
1290
1935
|
"token_proxy": {"measurement": "estimated", "method": f"chars_div_{TOKEN_PROXY_CHARS_PER_TOKEN}", "pack": token_proxy(pack)},
|
|
1291
1936
|
"sources": {"total": len(specs), "included": len(included) - partial_count, "partial": partial_count, "omitted": len(omitted_sorted)},
|
|
1292
1937
|
"included_sources": included,
|
|
@@ -1295,6 +1940,15 @@ def build_pack(root: Path, specs: list[SourceSpec], *, budget_bytes: int, root_a
|
|
|
1295
1940
|
"artifact": {"stored": False, "path": None, "bytes": 0, "capped": False, "cap_bytes": MAX_RECEIPT_BYTES},
|
|
1296
1941
|
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
1297
1942
|
}
|
|
1943
|
+
if sketch_duplicate_veto:
|
|
1944
|
+
result["sketch_duplicate_veto"] = {"comparison_cap_reached": comparison_cap_reached}
|
|
1945
|
+
if delta_from_pack_id is not None:
|
|
1946
|
+
result["rolling_delta"] = rolling_delta_from_receipt(
|
|
1947
|
+
root,
|
|
1948
|
+
pack,
|
|
1949
|
+
delta_from_pack_id,
|
|
1950
|
+
result["content_address"]["id"],
|
|
1951
|
+
)
|
|
1298
1952
|
if store_artifact:
|
|
1299
1953
|
artifact = store_receipt(root, result)
|
|
1300
1954
|
result["artifact"] = artifact
|
|
@@ -1416,10 +2070,16 @@ def run_git_diff(root: Path, diff_ref: str) -> str:
|
|
|
1416
2070
|
except (OSError, UnicodeError, subprocess.TimeoutExpired) as exc:
|
|
1417
2071
|
raise PackError(f"could not read diff: {exc.__class__.__name__}") from exc
|
|
1418
2072
|
if proc.returncode != 0:
|
|
1419
|
-
detail = sanitize_text(
|
|
2073
|
+
detail = sanitize_text(
|
|
2074
|
+
proc.stderr or proc.stdout or "git diff failed",
|
|
2075
|
+
context="command_search_diff",
|
|
2076
|
+
)[0].strip().splitlines()
|
|
1420
2077
|
message = detail[0] if detail else "git diff failed"
|
|
1421
2078
|
raise PackError(f"could not read diff: {cap_label(message, default='git diff failed', limit=160)}")
|
|
1422
|
-
return sanitize_text(
|
|
2079
|
+
return sanitize_text(
|
|
2080
|
+
proc.stdout[:MAX_SUGGEST_INPUT_BYTES],
|
|
2081
|
+
context="command_search_diff",
|
|
2082
|
+
)[0]
|
|
1423
2083
|
|
|
1424
2084
|
|
|
1425
2085
|
def collect_diff_candidates(root: Path, diff_ref: str, query_terms: set[str], context_lines: int) -> list[SuggestCandidate]:
|
|
@@ -3267,7 +3927,15 @@ def auto_pack(root: Path, args: argparse.Namespace, *, root_arg: str) -> tuple[d
|
|
|
3267
3927
|
manifest = suggest_payload["manifest"]
|
|
3268
3928
|
specs = manifest_to_source_specs(manifest)
|
|
3269
3929
|
budget = bounded_int(args.budget_bytes, DEFAULT_BUDGET_BYTES, MIN_BUDGET_BYTES, MAX_BUDGET_BYTES)
|
|
3270
|
-
build_payload = build_pack(
|
|
3930
|
+
build_payload = build_pack(
|
|
3931
|
+
root,
|
|
3932
|
+
specs,
|
|
3933
|
+
budget_bytes=budget,
|
|
3934
|
+
root_arg=root_arg,
|
|
3935
|
+
store_artifact=False,
|
|
3936
|
+
delta_from_pack_id=args.delta_from_pack_id,
|
|
3937
|
+
sketch_duplicate_veto=getattr(args, "sketch_duplicate_veto", False),
|
|
3938
|
+
)
|
|
3271
3939
|
if not args.no_artifact:
|
|
3272
3940
|
receipt_rel = Path(PACK_DIR) / f"{build_payload['pack_id']}.json"
|
|
3273
3941
|
if manifest_rel is not None:
|
|
@@ -3415,9 +4083,15 @@ def print_suggest_text(payload: dict[str, Any]) -> None:
|
|
|
3415
4083
|
|
|
3416
4084
|
|
|
3417
4085
|
def print_auto_text(payload: dict[str, Any]) -> None:
|
|
4086
|
+
build_payload = payload.get("build", {}) if isinstance(payload.get("build"), dict) else {}
|
|
4087
|
+
sketch_duplicate = build_payload.get("sketch_duplicate_veto")
|
|
4088
|
+
sketch_suffix = ""
|
|
4089
|
+
if isinstance(sketch_duplicate, dict):
|
|
4090
|
+
cap_reached = str(bool(sketch_duplicate.get("comparison_cap_reached"))).lower()
|
|
4091
|
+
sketch_suffix = f" sketch_comparison_cap_reached={cap_reached}"
|
|
3418
4092
|
print(
|
|
3419
4093
|
f"context-guard-pack auto: {payload['sources']['suggested']} suggested source(s), "
|
|
3420
|
-
f"pack {payload['pack_bytes']}/{payload['budget_bytes']} bytes"
|
|
4094
|
+
f"pack {payload['pack_bytes']}/{payload['budget_bytes']} bytes{sketch_suffix}"
|
|
3421
4095
|
)
|
|
3422
4096
|
explain = payload.get("explain")
|
|
3423
4097
|
if isinstance(explain, dict):
|
|
@@ -3477,6 +4151,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
3477
4151
|
build.add_argument("--budget-bytes", type=int, default=DEFAULT_BUDGET_BYTES)
|
|
3478
4152
|
build.add_argument("--json", action="store_true", help="emit JSON payload")
|
|
3479
4153
|
build.add_argument("--no-artifact", action="store_true", help="do not write .context-guard/packs receipt")
|
|
4154
|
+
build.add_argument(
|
|
4155
|
+
"--sketch-duplicate-veto",
|
|
4156
|
+
action="store_true",
|
|
4157
|
+
help=(
|
|
4158
|
+
"omit later rank-stable sanitized exact/sketch-set duplicates; use a fixed 100,000 verified-pair cap, "
|
|
4159
|
+
"then fail open; when enabled report sketch_comparison_cap_reached=true|false in text and "
|
|
4160
|
+
"sketch_duplicate_veto.comparison_cap_reached in JSON"
|
|
4161
|
+
),
|
|
4162
|
+
)
|
|
4163
|
+
build.add_argument(
|
|
4164
|
+
"--delta-from-pack-id",
|
|
4165
|
+
type=pack_id_arg,
|
|
4166
|
+
metavar="PACK_ID",
|
|
4167
|
+
help=(
|
|
4168
|
+
"compare against one private local pack receipt using bounded rolling diagnostics; "
|
|
4169
|
+
"visible only in --json output or a stored receipt (--no-artifact requires --json)"
|
|
4170
|
+
),
|
|
4171
|
+
)
|
|
3480
4172
|
slice_cmd = sub.add_parser("slice", help="retrieve an exact sanitized file slice")
|
|
3481
4173
|
slice_cmd.add_argument("--root", default=".", help="project root; must not be a symlink")
|
|
3482
4174
|
slice_cmd.add_argument("--path", required=True, help="relative file path under root")
|
|
@@ -3512,6 +4204,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
3512
4204
|
auto.add_argument("--pack-out", help="write the built Markdown pack to this relative path under root")
|
|
3513
4205
|
auto.add_argument("--json", action="store_true", help="emit JSON payload")
|
|
3514
4206
|
auto.add_argument("--no-artifact", action="store_true", help="do not write .context-guard/packs receipt")
|
|
4207
|
+
auto.add_argument(
|
|
4208
|
+
"--sketch-duplicate-veto",
|
|
4209
|
+
action="store_true",
|
|
4210
|
+
help=(
|
|
4211
|
+
"omit later rank-stable sanitized exact/sketch-set duplicates; use a fixed 100,000 verified-pair cap, "
|
|
4212
|
+
"then fail open; when enabled report sketch_comparison_cap_reached=true|false in text and "
|
|
4213
|
+
"sketch_duplicate_veto.comparison_cap_reached in JSON"
|
|
4214
|
+
),
|
|
4215
|
+
)
|
|
4216
|
+
auto.add_argument(
|
|
4217
|
+
"--delta-from-pack-id",
|
|
4218
|
+
type=pack_id_arg,
|
|
4219
|
+
metavar="PACK_ID",
|
|
4220
|
+
help=(
|
|
4221
|
+
"compare against one private local pack receipt using bounded rolling diagnostics; "
|
|
4222
|
+
"visible only in --json output or a stored receipt (--no-artifact requires --json)"
|
|
4223
|
+
),
|
|
4224
|
+
)
|
|
3515
4225
|
auto.add_argument("--explain", action="store_true", help="include deterministic local selection/build explanation metadata")
|
|
3516
4226
|
auto.add_argument("--adaptive-k", action="store_true", help="include local score/budget top-k advisory metadata without changing the manifest or pack")
|
|
3517
4227
|
auto.add_argument("--adaptive-k-policy", choices=ADAPTIVE_K_POLICIES, default="balanced", help="local adaptive-k recommendation policy used when --adaptive-k is set")
|
|
@@ -3531,15 +4241,29 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
3531
4241
|
if not specs:
|
|
3532
4242
|
raise PackError("provide --manifest or --source")
|
|
3533
4243
|
budget = bounded_int(args.budget_bytes, DEFAULT_BUDGET_BYTES, MIN_BUDGET_BYTES, MAX_BUDGET_BYTES)
|
|
3534
|
-
result = build_pack(
|
|
4244
|
+
result = build_pack(
|
|
4245
|
+
root,
|
|
4246
|
+
specs,
|
|
4247
|
+
budget_bytes=budget,
|
|
4248
|
+
root_arg=str(args.root),
|
|
4249
|
+
store_artifact=not args.no_artifact,
|
|
4250
|
+
delta_from_pack_id=args.delta_from_pack_id,
|
|
4251
|
+
sketch_duplicate_veto=args.sketch_duplicate_veto,
|
|
4252
|
+
)
|
|
3535
4253
|
if args.json:
|
|
3536
4254
|
json.dump(result, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
|
|
3537
4255
|
sys.stdout.write("\n")
|
|
3538
4256
|
else:
|
|
3539
4257
|
sys.stdout.write(str(result["pack"]))
|
|
4258
|
+
sketch_suffix = ""
|
|
4259
|
+
sketch_duplicate = result.get("sketch_duplicate_veto")
|
|
4260
|
+
if isinstance(sketch_duplicate, dict):
|
|
4261
|
+
cap_reached = str(bool(sketch_duplicate.get("comparison_cap_reached"))).lower()
|
|
4262
|
+
sketch_suffix = f" sketch_comparison_cap_reached={cap_reached}"
|
|
3540
4263
|
print(
|
|
3541
4264
|
f"[context-guard-pack] pack_id={result['pack_id']} bytes={result['pack_bytes']}/{result['budget_bytes']} "
|
|
3542
|
-
f"included={result['sources']['included']} partial={result['sources']['partial']} omitted={result['sources']['omitted']}"
|
|
4265
|
+
f"included={result['sources']['included']} partial={result['sources']['partial']} omitted={result['sources']['omitted']}"
|
|
4266
|
+
f"{sketch_suffix}",
|
|
3543
4267
|
file=sys.stderr,
|
|
3544
4268
|
)
|
|
3545
4269
|
return 0
|