@kinosec/cli 0.10.3 → 0.11.2

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.
@@ -0,0 +1,701 @@
1
+ #!/usr/bin/env python3
2
+ """ctxpack -- source-context ingestion for graybox CTF/pentest agents.
3
+
4
+ Turns a black-box agent into a graybox one by packing a challenge's SOURCE CODE
5
+ into a single inject-ready bundle. The only real problem it solves is that source
6
+ archives are padded with non-logic files (CSS frameworks, images, fonts, vendored
7
+ deps, lockfiles, editor junk); a cheap extension/path FILTER -- not a code graph,
8
+ embeddings, or a whitebox agent -- is what makes "just paste the source" work.
9
+
10
+ Pipeline: FILTER (KEEP vs DROP) -> GATE (sum kept-source est-tokens vs budget)
11
+ -> ROUTE (paste bundle if it fits, else a bodiless retrieve outline) -> MANIFEST
12
+ (every included + every excluded file, with reasons; no silent truncation).
13
+
14
+ Metric: tokens of logic-bearing source, estimated as bytes / 3.7 (no tokenizer
15
+ dependency). Threshold: --budget (default 120000). Both are tunable.
16
+
17
+ Usage:
18
+ ctxpack <SOURCE> [-o OUT] [--budget N] [--bytes-per-token F] [--stdout]
19
+ [--emit-context-file PATH] [--stage-retrieve] [--stage-dir DIR]
20
+ [--ctags] [--fork-diff [UPSTREAM_REF]]
21
+
22
+ Examples:
23
+ ctxpack ./challenge-src
24
+ ctxpack handout.zip -o /tmp/ctx --budget 120000
25
+ ctxpack ./big-repo --stage-retrieve --stage-dir /tmp/ctxpack_src
26
+ ctxpack . --fork-diff origin/main # emit planted delta vs upstream
27
+
28
+ Zero third-party dependencies -- Python standard library only.
29
+ """
30
+
31
+ import argparse
32
+ import fnmatch
33
+ import json
34
+ import os
35
+ import re
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ import tarfile
40
+ import tempfile
41
+ import zipfile
42
+
43
+ # ============================ CONFIG (edit freely) ============================
44
+ # Token estimate = bytes / BYTES_PER_TOKEN. The repo habit elsewhere is 4.0; we
45
+ # default to 3.7 (slightly more conservative) and expose --bytes-per-token.
46
+ BYTES_PER_TOKEN = 3.7
47
+ # Route paste vs retrieve on the sum of KEEP-file est-tokens (not raw bytes).
48
+ DEFAULT_BUDGET_TOKENS = 120_000
49
+
50
+ # --- DROP by extension -> reason group (checked case-insensitively). ---
51
+ DROP_EXT = {
52
+ "image": {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"},
53
+ "font": {".woff", ".woff2", ".ttf", ".eot", ".otf"},
54
+ "style": {".css", ".scss", ".sass", ".less"},
55
+ "media": {".mp4", ".mp3", ".pdf"},
56
+ "binary": {".zip", ".tar", ".gz", ".so", ".a", ".o", ".pyc",
57
+ ".class", ".wasm", ".bin"},
58
+ }
59
+ # Compound suffixes checked BEFORE single-extension rules: minified + sourcemaps.
60
+ DROP_SUFFIX_MINIFIED = (".min.js", ".min.css", ".map")
61
+
62
+ # --- DROP by exact basename -> reason. ---
63
+ DROP_FILENAMES = {
64
+ "package-lock.json": "lockfile", "yarn.lock": "lockfile",
65
+ "poetry.lock": "lockfile", "Pipfile.lock": "lockfile",
66
+ "Cargo.lock": "lockfile", "composer.lock": "lockfile", "go.sum": "lockfile",
67
+ ".DS_Store": "editor-junk", "Thumbs.db": "editor-junk",
68
+ }
69
+
70
+ # --- DROP by path segment (any path component match) -> reason path:<seg>. ---
71
+ DROP_PATH_SEGMENTS = {
72
+ "node_modules", "vendor", "dist", "build", "__pycache__", "third_party", ".git",
73
+ }
74
+
75
+ # --- KEEP hints. Anything not DROPped is kept regardless; these drive the
76
+ # logic-first ORDERING (and document intent). ---
77
+ KEEP_SOURCE_EXT = {
78
+ ".py", ".js", ".ts", ".go", ".rb", ".php", ".java", ".c", ".cpp", ".h",
79
+ ".rs", ".sh", ".jsx", ".tsx", ".cc", ".hpp", ".pyi", ".mjs", ".cjs",
80
+ }
81
+ KEEP_TEMPLATE_EXT = {
82
+ ".html", ".htm", ".jinja", ".j2", ".ejs", ".erb", ".twig", ".mako",
83
+ ".hbs", ".vue", ".svelte",
84
+ }
85
+ # Behavior-carrying config: exact names + glob patterns (matched on basename).
86
+ KEEP_CONFIG_NAMES = {"docker-compose.yml", "docker-compose.yaml", "package.json"}
87
+ KEEP_CONFIG_GLOBS = ("Dockerfile*", "docker-compose.y*ml", "*.conf",
88
+ "requirements*.txt", "nginx*.conf", "supervisor*.conf",
89
+ "*.env.example")
90
+
91
+ # Entrypoint/route/service hints -> logic-first tier 1 (matched as path tokens).
92
+ ENTRYPOINT_HINTS = {
93
+ "app", "main", "index", "server", "wsgi", "asgi", "manage", "run",
94
+ "route", "routes", "view", "views", "controller", "controllers",
95
+ "handler", "handlers", "api", "service", "services", "url", "urls",
96
+ "endpoint", "endpoints", "middleware",
97
+ }
98
+
99
+ # Known-CTF marker filename tokens + content regex (SIGNAL only; never acted on).
100
+ CTF_MARKER_TOKENS = {"flag", "flags", "challenge", "benchmark", "solve",
101
+ "solver", "exploit", "writeup"}
102
+ CTF_CONTENT_RE = re.compile(rb"(FLAG|CTF|HTB|flag)\{")
103
+ # ============================================================================
104
+
105
+ TIER_RANK = {"entrypoint": 0, "source": 1, "template": 2, "config": 3, "other": 4}
106
+ _TOKEN_RE = re.compile(r"[^a-z0-9]+")
107
+ _CHUNK = 8192
108
+ # Printable text bytes for the binary-content heuristic.
109
+ _TEXT_BYTES = bytes(range(0x20, 0x7F)) + b"\n\r\t\f\b\x1b"
110
+
111
+
112
+ def _dbg(on, msg):
113
+ """Per-decision trace to stderr (opt-in via --debug) so the filter is
114
+ auditable mid-event: exactly which files were kept/dropped and why."""
115
+ if on:
116
+ sys.stderr.write("[ctxpack] " + msg + "\n")
117
+
118
+
119
+ # ------------------------------- classification -----------------------------
120
+ def _path_tokens(rel_path):
121
+ return {t for t in _TOKEN_RE.split(rel_path.lower()) if t}
122
+
123
+
124
+ def _is_config(name):
125
+ if name in KEEP_CONFIG_NAMES:
126
+ return True
127
+ return any(fnmatch.fnmatchcase(name, pat) or fnmatch.fnmatchcase(name.lower(), pat.lower())
128
+ for pat in KEEP_CONFIG_GLOBS)
129
+
130
+
131
+ def classify(rel_path):
132
+ """Partition a file into KEEP or DROP.
133
+
134
+ Returns (keep: bool, tag: str). For DROP, tag is the reason code. For KEEP,
135
+ tag is the ordering tier (entrypoint/source/template/config/other).
136
+ Check order per spec: exact filename -> minified suffix -> extension ->
137
+ path segment -> else KEEP.
138
+ """
139
+ parts = rel_path.replace("\\", "/").split("/")
140
+ name = parts[-1]
141
+ lname = name.lower()
142
+
143
+ if name in DROP_FILENAMES:
144
+ return False, DROP_FILENAMES[name]
145
+
146
+ for suf in DROP_SUFFIX_MINIFIED:
147
+ if lname.endswith(suf):
148
+ return False, "ext:minified"
149
+
150
+ ext = os.path.splitext(lname)[1]
151
+ for group, exts in DROP_EXT.items():
152
+ if ext in exts:
153
+ return False, "ext:" + group
154
+
155
+ for seg in parts:
156
+ if seg in DROP_PATH_SEGMENTS:
157
+ return False, "path:" + seg
158
+
159
+ # KEEP -- assign a logic-first tier.
160
+ tokens = _path_tokens(rel_path)
161
+ if ext in KEEP_SOURCE_EXT:
162
+ return True, "entrypoint" if tokens & ENTRYPOINT_HINTS else "source"
163
+ if ext in KEEP_TEMPLATE_EXT:
164
+ return True, "template"
165
+ if _is_config(name):
166
+ return True, "config"
167
+ return True, "other"
168
+
169
+
170
+ def looks_binary(chunk):
171
+ """Classic file(1)/git heuristic: NUL byte, or >30% non-text bytes."""
172
+ if not chunk:
173
+ return False
174
+ if b"\x00" in chunk:
175
+ return True
176
+ nontext = chunk.translate(None, _TEXT_BYTES)
177
+ return len(nontext) / len(chunk) > 0.30
178
+
179
+
180
+ # --------------------------------- scanning ---------------------------------
181
+ def _iter_files(root):
182
+ """Yield relative POSIX paths under root, deterministically, not following
183
+ symlinked directories."""
184
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
185
+ dirnames.sort()
186
+ for fn in sorted(filenames):
187
+ ap = os.path.join(dirpath, fn)
188
+ if os.path.islink(ap):
189
+ continue # skip symlinked files (avoid escaping the tree)
190
+ rel = os.path.relpath(ap, root).replace(os.sep, "/")
191
+ yield rel, ap
192
+
193
+
194
+ def detect_stack(root, included):
195
+ """Best-effort web-framework fingerprint of the KEEP set, so a graybox launcher can
196
+ sanity-check the bundle against the live target before spending a run. Returns a short
197
+ lowercase name ("nextjs", "react", "express", "flask", "django", "php", ...) or "" when
198
+ nothing is recognized. Heuristic and intentionally conservative (only positive signals)."""
199
+ lowers = [r["path"].lower() for r in included]
200
+ bases = {os.path.basename(p) for p in lowers}
201
+
202
+ def _head(rel):
203
+ try:
204
+ with open(os.path.join(root, rel), "rb") as fh:
205
+ return fh.read(_CHUNK).decode("utf-8", "replace").lower()
206
+ except OSError:
207
+ return ""
208
+
209
+ # --- Node/JS family: trust package.json dependencies first.
210
+ pkg_rel = next((r["path"] for r in included
211
+ if os.path.basename(r["path"]).lower() == "package.json"), None)
212
+ has_next_cfg = any(b.startswith("next.config.") for b in bases)
213
+ if pkg_rel:
214
+ txt = _head(pkg_rel)
215
+ if '"next"' in txt or has_next_cfg:
216
+ return "nextjs"
217
+ if '"express"' in txt:
218
+ return "express"
219
+ if '"@angular/core"' in txt:
220
+ return "angular"
221
+ if '"vue"' in txt:
222
+ return "vue"
223
+ if '"svelte"' in txt:
224
+ return "svelte"
225
+ if ('"react"' in txt or '"react-dom"' in txt
226
+ or any(p.endswith((".tsx", ".jsx")) for p in lowers)):
227
+ return "react"
228
+ return "node"
229
+ if has_next_cfg:
230
+ return "nextjs"
231
+
232
+ # --- Python family: requirements first, then a bounded source sniff.
233
+ req_rels = [r["path"] for r in included
234
+ if os.path.basename(r["path"]).lower().startswith("requirements")]
235
+ reqtext = " ".join(_head(p) for p in req_rels)
236
+ if "manage.py" in bases or "django" in reqtext:
237
+ return "django"
238
+ if "fastapi" in reqtext:
239
+ return "fastapi"
240
+ if "flask" in reqtext:
241
+ return "flask"
242
+ sniffed = 0
243
+ for r in included:
244
+ if sniffed >= 20:
245
+ break
246
+ if r["path"].lower().endswith(".py"):
247
+ sniffed += 1
248
+ t = _head(r["path"])
249
+ if "from flask" in t or "import flask" in t:
250
+ return "flask"
251
+ if "from fastapi" in t or "import fastapi" in t:
252
+ return "fastapi"
253
+ if "from django" in t or "import django" in t:
254
+ return "django"
255
+
256
+ # --- Other ecosystems (coarse).
257
+ if any(p.endswith(".php") for p in lowers):
258
+ return "php"
259
+ if "gemfile" in bases or any(p.endswith(".rb") for p in lowers):
260
+ return "rails"
261
+ return ""
262
+
263
+
264
+ def scan(root, bytes_per_token, debug=False):
265
+ """Walk root, classify every file, and collect included/excluded records
266
+ plus signal evidence. Returns a dict."""
267
+ included, excluded = [], []
268
+ git_present = os.path.isdir(os.path.join(root, ".git"))
269
+ ctf_markers = set()
270
+ binary_artifact_seen = False
271
+
272
+ for rel, ap in _iter_files(root):
273
+ try:
274
+ size = os.path.getsize(ap)
275
+ except OSError:
276
+ excluded.append({"path": rel, "bytes": 0, "reason": "unreadable"})
277
+ _dbg(debug, "DROP unreadable {}".format(rel))
278
+ continue
279
+
280
+ base_tokens = _path_tokens(os.path.basename(rel))
281
+ if base_tokens & CTF_MARKER_TOKENS:
282
+ ctf_markers.add("name:" + rel)
283
+
284
+ keep, tag = classify(rel)
285
+ if not keep:
286
+ if tag == "ext:binary":
287
+ binary_artifact_seen = True
288
+ if ".git" in rel.split("/"):
289
+ git_present = True
290
+ excluded.append({"path": rel, "bytes": size, "reason": tag})
291
+ _dbg(debug, "DROP {:<17} {}".format(tag, rel))
292
+ continue
293
+
294
+ # KEEP candidate: read head once for binary + CTF-content detection.
295
+ try:
296
+ with open(ap, "rb") as fh:
297
+ head = fh.read(_CHUNK)
298
+ except OSError:
299
+ excluded.append({"path": rel, "bytes": size, "reason": "unreadable"})
300
+ _dbg(debug, "DROP unreadable {}".format(rel))
301
+ continue
302
+
303
+ if looks_binary(head):
304
+ binary_artifact_seen = True
305
+ excluded.append({"path": rel, "bytes": size, "reason": "binary-content"})
306
+ _dbg(debug, "DROP binary-content {}".format(rel))
307
+ continue
308
+
309
+ if CTF_CONTENT_RE.search(head):
310
+ ctf_markers.add("content:" + rel)
311
+
312
+ rec = {
313
+ "path": rel,
314
+ "bytes": size,
315
+ "est_tokens": round(size / bytes_per_token),
316
+ "tier": tag,
317
+ }
318
+ if tag == "other":
319
+ rec["note"] = "kept:default-unknown-ext"
320
+ included.append(rec)
321
+ _dbg(debug, "KEEP {:<17} {}".format(tag, rel))
322
+
323
+ binaries_only = (not included) and binary_artifact_seen
324
+ _dbg(debug, "scan done: {} kept, {} dropped, {} scanned".format(
325
+ len(included), len(excluded), len(included) + len(excluded)))
326
+ return {
327
+ "included": included,
328
+ "excluded": excluded,
329
+ "signals": {
330
+ "git_present": bool(git_present),
331
+ "binaries_only": bool(binaries_only),
332
+ "known_ctf_markers": sorted(ctf_markers),
333
+ "stack": detect_stack(root, included),
334
+ },
335
+ }
336
+
337
+
338
+ # --------------------------------- rendering --------------------------------
339
+ def _order_logic_first(included):
340
+ return sorted(included, key=lambda r: (TIER_RANK.get(r["tier"], 9), r["path"]))
341
+
342
+
343
+ def _read_text(ap):
344
+ with open(ap, "rb") as fh:
345
+ return fh.read().decode("utf-8", errors="replace")
346
+
347
+
348
+ def render_paste(root, included, kept_tokens, budget, source_label):
349
+ ordered = _order_logic_first(included)
350
+ pct = (100 * kept_tokens // budget) if budget else 0
351
+ out = [
352
+ "# ctxpack bundle -- mode=paste -- {n} files, ~{t} tokens "
353
+ "(budget {b}, ~{p}%) -- {s}".format(
354
+ n=len(ordered), t=kept_tokens, b=budget, p=pct, s=source_label),
355
+ "# Each file is prefixed with '===== <relative/path> ====='. "
356
+ "Ordering: entrypoints/routes/services, then source, templates, config.",
357
+ "",
358
+ ]
359
+ for rec in ordered:
360
+ out.append("===== {} =====".format(rec["path"]))
361
+ out.append(_read_text(os.path.join(root, rec["path"].replace("/", os.sep))))
362
+ out.append("")
363
+ return "\n".join(out)
364
+
365
+
366
+ def _build_tree(paths):
367
+ tree = {}
368
+ for p in paths:
369
+ node = tree
370
+ for part in p.split("/"):
371
+ node = node.setdefault(part, {})
372
+ lines = []
373
+
374
+ def walk(node, prefix=""):
375
+ entries = sorted(node.items())
376
+ for i, (name, child) in enumerate(entries):
377
+ last = i == len(entries) - 1
378
+ lines.append(prefix + ("└── " if last else "├── ") + name)
379
+ if child:
380
+ walk(child, prefix + (" " if last else "│ "))
381
+
382
+ walk(tree)
383
+ return "\n".join(lines)
384
+
385
+
386
+ def render_retrieve(included, kept_tokens, budget, source_label, staged_dir=None):
387
+ ordered = _order_logic_first(included)
388
+ where = (" Files staged under {} -- grep/read there.".format(staged_dir)
389
+ if staged_dir else "")
390
+ out = [
391
+ "# ctxpack outline -- mode=retrieve -- kept ~{t} tokens > budget {b} -- {s}".format(
392
+ t=kept_tokens, b=budget, s=source_label),
393
+ "# NO file bodies below.{} Grep/read these {} paths on demand.".format(
394
+ where, len(ordered)),
395
+ "",
396
+ "## FILE TREE (KEEP set)",
397
+ _build_tree([r["path"] for r in ordered]),
398
+ "",
399
+ "## KEEP PATHS (logic-first; path<TAB>bytes<TAB>est_tokens)",
400
+ ]
401
+ for rec in ordered:
402
+ out.append("{}\t{}\t{}".format(rec["path"], rec["bytes"], rec["est_tokens"]))
403
+ return "\n".join(out)
404
+
405
+
406
+ def render_manifest_md(manifest):
407
+ m = manifest
408
+ t = m["totals"]
409
+ sig = m["signals"]
410
+ lines = [
411
+ "# ctxpack manifest",
412
+ "",
413
+ "- **source:** `{}` ({})".format(m["source"], m["source_type"]),
414
+ "- **mode:** `{}` (fits budget: {})".format(m["mode"], m["fits"]),
415
+ "- **budget:** {} tokens **bytes/token:** {}".format(
416
+ m["budget_tokens"], m["bytes_per_token"]),
417
+ "- **kept source:** {} files, {} bytes, ~{} tokens".format(
418
+ t["included_files"], t["included_bytes"], t["included_est_tokens"]),
419
+ "- **excluded:** {} files, {} bytes".format(
420
+ t["excluded_files"], t["excluded_bytes"]),
421
+ "- **scanned total:** {} files".format(t["scanned_files"]),
422
+ "",
423
+ "## Signals",
424
+ "- git_present: {}".format(sig["git_present"]),
425
+ "- binaries_only: {}".format(sig["binaries_only"]),
426
+ "- stack: {}".format(sig.get("stack") or "(unknown)"),
427
+ "- known_ctf_markers: {}".format(", ".join(sig["known_ctf_markers"]) or "(none)"),
428
+ "",
429
+ "## Included ({}) -- path | bytes | est_tokens | tier".format(t["included_files"]),
430
+ ]
431
+ for r in _order_logic_first(m["included"]):
432
+ note = " _({})_".format(r["note"]) if r.get("note") else ""
433
+ lines.append("- `{}` | {} | {} | {}{}".format(
434
+ r["path"], r["bytes"], r["est_tokens"], r["tier"], note))
435
+ lines += ["", "## Excluded ({}) -- path | bytes | reason".format(t["excluded_files"])]
436
+ for r in sorted(m["excluded"], key=lambda x: (x["reason"], x["path"])):
437
+ lines.append("- `{}` | {} | {}".format(r["path"], r["bytes"], r["reason"]))
438
+ lines.append("")
439
+ return "\n".join(lines)
440
+
441
+
442
+ # ------------------------------ archive input -------------------------------
443
+ def _validate_member(dest, member_name):
444
+ dest_abs = os.path.abspath(dest)
445
+ target = os.path.abspath(os.path.join(dest, member_name))
446
+ if target != dest_abs and not target.startswith(dest_abs + os.sep):
447
+ raise ValueError("unsafe path in archive: {!r}".format(member_name))
448
+
449
+
450
+ def extract_archive(path, dest, zip_password=None):
451
+ """Safely extract a .zip/.tar[.gz/.bz2/.xz] into dest. Returns 'zip'/'tar'.
452
+ Password-encrypted zips need zip_password (stdlib supports classic ZipCrypto
453
+ only, not WinZip-AES)."""
454
+ if zipfile.is_zipfile(path):
455
+ with zipfile.ZipFile(path) as zf:
456
+ for n in zf.namelist():
457
+ _validate_member(dest, n)
458
+ encrypted = any(zi.flag_bits & 0x1 for zi in zf.infolist())
459
+ if encrypted and not zip_password:
460
+ raise ValueError("zip is password-encrypted; pass --zip-password PW "
461
+ "(stdlib supports classic ZipCrypto, not AES)")
462
+ pwd = zip_password.encode() if zip_password else None
463
+ try:
464
+ zf.extractall(dest, pwd=pwd)
465
+ except RuntimeError as e:
466
+ raise ValueError("zip extraction failed (wrong password?): {}".format(e))
467
+ return "zip"
468
+ if tarfile.is_tarfile(path):
469
+ with tarfile.open(path) as tf:
470
+ try:
471
+ tf.extractall(dest, filter="data") # py>=3.12 safe filter
472
+ except TypeError:
473
+ for m in tf.getmembers():
474
+ _validate_member(dest, m.name)
475
+ tf.extractall(dest)
476
+ return "tar"
477
+ raise ValueError("not a directory or supported archive: {}".format(path))
478
+
479
+
480
+ # --------------------------------- stretch ----------------------------------
481
+ def _stage_files(root, included, stage_dir, clean):
482
+ """Copy KEEP files into stage_dir (preserving relative layout). Only wipe the
483
+ dir first when it is the tool-owned default (clean=True); NEVER delete a
484
+ user-supplied --stage-dir wholesale (it is overlaid, not cleared)."""
485
+ if clean and os.path.isdir(stage_dir):
486
+ shutil.rmtree(stage_dir)
487
+ os.makedirs(stage_dir, exist_ok=True)
488
+ for rec in included:
489
+ src = os.path.join(root, rec["path"].replace("/", os.sep))
490
+ dst = os.path.join(stage_dir, rec["path"].replace("/", os.sep))
491
+ os.makedirs(os.path.dirname(dst) or stage_dir, exist_ok=True)
492
+ shutil.copy2(src, dst)
493
+ return stage_dir
494
+
495
+
496
+ def _run_ctags(stage_dir, out_dir):
497
+ if not shutil.which("ctags"):
498
+ return {"ctags": "skipped: ctags not found on PATH"}
499
+ tags_path = os.path.join(out_dir, "tags")
500
+ try:
501
+ subprocess.run(["ctags", "-R", "-f", tags_path, stage_dir],
502
+ check=True, capture_output=True)
503
+ return {"ctags": os.path.relpath(tags_path, out_dir)}
504
+ except subprocess.CalledProcessError as e:
505
+ return {"ctags": "failed: {}".format(e.stderr.decode("utf-8", "replace")[:200])}
506
+
507
+
508
+ def _fork_diff_bundle(source_dir, upstream_ref, source_label):
509
+ """Emit git log + diff vs upstream instead of the full bundle. Degrades to
510
+ None (caller falls back to a normal bundle) if not a usable git repo."""
511
+ if not shutil.which("git") or not os.path.isdir(os.path.join(source_dir, ".git")):
512
+ return None
513
+
514
+ def git(*args):
515
+ return subprocess.run(["git", "-C", source_dir, *args],
516
+ capture_output=True, text=True).stdout.strip()
517
+
518
+ ref = upstream_ref if isinstance(upstream_ref, str) else ""
519
+ if not ref:
520
+ for cand in ("@{upstream}", "origin/HEAD", "origin/main", "origin/master", "main"):
521
+ if git("rev-parse", "--verify", "--quiet", cand):
522
+ ref = cand
523
+ break
524
+ if not ref:
525
+ return None
526
+ log = git("log", "--oneline", "{}..HEAD".format(ref))
527
+ diff = git("diff", "{}...HEAD".format(ref))
528
+ return "\n".join([
529
+ "# ctxpack fork-diff -- planted delta vs {} -- {}".format(ref, source_label),
530
+ "", "## git log ({}..HEAD)".format(ref), log or "(no new commits)",
531
+ "", "## git diff ({}...HEAD)".format(ref), diff or "(no diff)", "",
532
+ ])
533
+
534
+
535
+ # ----------------------------------- core -----------------------------------
536
+ def pack(source, out_dir, budget_tokens=DEFAULT_BUDGET_TOKENS,
537
+ bytes_per_token=BYTES_PER_TOKEN, stage_retrieve=False, stage_dir=None,
538
+ do_ctags=False, fork_diff=None, emit_context_file=None, to_stdout=False,
539
+ debug=False, zip_password=None):
540
+ """Run the full FILTER->GATE->ROUTE->MANIFEST pipeline. Writes bundle.txt,
541
+ manifest.json and manifest.md into out_dir. Returns the manifest dict."""
542
+ source = os.path.abspath(source)
543
+ tmp_root = None
544
+ try:
545
+ if os.path.isdir(source):
546
+ root, source_type = source, "dir"
547
+ elif os.path.isfile(source):
548
+ tmp_root = tempfile.mkdtemp(prefix="ctxpack_x_")
549
+ source_type = extract_archive(source, tmp_root, zip_password=zip_password)
550
+ root = tmp_root
551
+ _dbg(debug, "extracted {} archive -> {}".format(source_type, root))
552
+ else:
553
+ raise ValueError("source not found: {}".format(source))
554
+
555
+ source_label = os.path.basename(source.rstrip("/\\")) or source
556
+ scanned = scan(root, bytes_per_token, debug=debug)
557
+ included, excluded = scanned["included"], scanned["excluded"]
558
+
559
+ inc_bytes = sum(r["bytes"] for r in included)
560
+ kept_tokens = sum(r["est_tokens"] for r in included)
561
+ exc_bytes = sum(r["bytes"] for r in excluded)
562
+ fits = kept_tokens <= budget_tokens
563
+ mode = "paste" if fits else "retrieve"
564
+
565
+ os.makedirs(out_dir, exist_ok=True)
566
+ extras = {}
567
+ staged_at = None
568
+
569
+ # --fork-diff overrides the bundle body (both modes) when usable.
570
+ fork_body = _fork_diff_bundle(root, fork_diff, source_label) if fork_diff else None
571
+
572
+ if fork_body is not None:
573
+ bundle = fork_body
574
+ extras["fork_diff"] = "emitted"
575
+ elif mode == "paste":
576
+ bundle = render_paste(root, included, kept_tokens, budget_tokens, source_label)
577
+ else:
578
+ if stage_retrieve or stage_dir or do_ctags:
579
+ target_stage = stage_dir or os.path.join(out_dir, "keep_files")
580
+ staged_at = _stage_files(root, included, target_stage,
581
+ clean=(stage_dir is None))
582
+ bundle = render_retrieve(included, kept_tokens, budget_tokens,
583
+ source_label, staged_dir=staged_at)
584
+ if do_ctags:
585
+ extras.update(_run_ctags(staged_at, out_dir))
586
+
587
+ manifest = {
588
+ "tool": "ctxpack",
589
+ "version": "1.0",
590
+ "source": source,
591
+ "source_type": source_type,
592
+ "budget_tokens": budget_tokens,
593
+ "bytes_per_token": bytes_per_token,
594
+ "mode": mode,
595
+ "fits": fits,
596
+ "totals": {
597
+ "included_files": len(included),
598
+ "included_bytes": inc_bytes,
599
+ "included_est_tokens": kept_tokens,
600
+ "excluded_files": len(excluded),
601
+ "excluded_bytes": exc_bytes,
602
+ "scanned_files": len(included) + len(excluded),
603
+ },
604
+ "included": included,
605
+ "excluded": excluded,
606
+ "signals": scanned["signals"],
607
+ "outputs": dict({"bundle": "bundle.txt",
608
+ "manifest_json": "manifest.json",
609
+ "manifest_md": "manifest.md"}, **extras),
610
+ }
611
+ if staged_at:
612
+ manifest["outputs"]["staged_dir"] = staged_at
613
+
614
+ bundle_path = os.path.join(out_dir, "bundle.txt")
615
+ _write(bundle_path, bundle)
616
+ _write(os.path.join(out_dir, "manifest.json"),
617
+ json.dumps(manifest, indent=2, sort_keys=False) + "\n")
618
+ _write(os.path.join(out_dir, "manifest.md"), render_manifest_md(manifest))
619
+ if emit_context_file:
620
+ os.makedirs(os.path.dirname(os.path.abspath(emit_context_file)) or ".",
621
+ exist_ok=True)
622
+ _write(emit_context_file, bundle)
623
+ if to_stdout:
624
+ sys.stdout.write(bundle)
625
+ if not bundle.endswith("\n"):
626
+ sys.stdout.write("\n")
627
+ return manifest
628
+ finally:
629
+ if tmp_root:
630
+ shutil.rmtree(tmp_root, ignore_errors=True)
631
+
632
+
633
+ def _write(path, text):
634
+ with open(path, "w", encoding="utf-8", newline="\n") as fh:
635
+ fh.write(text)
636
+
637
+
638
+ # ----------------------------------- CLI ------------------------------------
639
+ def build_parser():
640
+ p = argparse.ArgumentParser(
641
+ prog="ctxpack",
642
+ description="Pack a challenge's source into an inject-ready graybox bundle.")
643
+ p.add_argument("source", help="path to a source directory or archive (.zip/.tar[.gz])")
644
+ p.add_argument("-o", "--out", default="./ctxpack-out",
645
+ help="output directory (default: ./ctxpack-out)")
646
+ p.add_argument("--budget", type=int, default=DEFAULT_BUDGET_TOKENS,
647
+ help="token budget for paste vs retrieve (default: %(default)s)")
648
+ p.add_argument("--bytes-per-token", type=float, default=BYTES_PER_TOKEN,
649
+ help="token estimate divisor (default: %(default)s)")
650
+ p.add_argument("--stdout", action="store_true", help="also print the bundle to stdout")
651
+ p.add_argument("--emit-context-file", metavar="PATH",
652
+ help="also write the bundle to this exact path (for direct wiring)")
653
+ p.add_argument("--stage-retrieve", action="store_true",
654
+ help="in retrieve mode, also copy KEEP files to a staging dir")
655
+ p.add_argument("--stage-dir", metavar="DIR",
656
+ help="override staging location (default: OUT/keep_files)")
657
+ p.add_argument("--ctags", action="store_true",
658
+ help="[stretch] run ctags -R over KEEP files in retrieve mode")
659
+ p.add_argument("--fork-diff", nargs="?", const=True, metavar="UPSTREAM_REF",
660
+ help="[stretch] emit git log/diff vs upstream instead of the bundle")
661
+ p.add_argument("--zip-password", metavar="PW",
662
+ help="password for an encrypted zip handout (classic ZipCrypto)")
663
+ p.add_argument("--debug", action="store_true",
664
+ help="trace every KEEP/DROP decision to stderr")
665
+ return p
666
+
667
+
668
+ def main(argv=None):
669
+ args = build_parser().parse_args(argv)
670
+ try:
671
+ m = pack(
672
+ args.source, args.out, budget_tokens=args.budget,
673
+ bytes_per_token=args.bytes_per_token, stage_retrieve=args.stage_retrieve,
674
+ stage_dir=args.stage_dir, do_ctags=args.ctags, fork_diff=args.fork_diff,
675
+ emit_context_file=args.emit_context_file, to_stdout=args.stdout,
676
+ debug=args.debug, zip_password=args.zip_password,
677
+ )
678
+ except ValueError as e:
679
+ sys.stderr.write("ctxpack: error: {}\n".format(e))
680
+ return 2
681
+
682
+ t = m["totals"]
683
+ sig = m["signals"]
684
+ flags = [k for k in ("git_present", "binaries_only") if sig[k]]
685
+ if sig.get("stack"):
686
+ flags.append("stack={}".format(sig["stack"]))
687
+ if sig["known_ctf_markers"]:
688
+ flags.append("ctf_markers={}".format(len(sig["known_ctf_markers"])))
689
+ sys.stderr.write(
690
+ "ctxpack: mode={mode} kept={files} files ~{tok} tokens "
691
+ "(budget {bud}) excluded={exc}{sig}\n".format(
692
+ mode=m["mode"], files=t["included_files"], tok=t["included_est_tokens"],
693
+ bud=m["budget_tokens"], exc=t["excluded_files"],
694
+ sig=(" signals[{}]".format(",".join(flags)) if flags else "")))
695
+ sys.stderr.write("ctxpack: wrote {0}/bundle.txt , {0}/manifest.json , "
696
+ "{0}/manifest.md\n".format(os.path.abspath(args.out)))
697
+ return 0
698
+
699
+
700
+ if __name__ == "__main__":
701
+ sys.exit(main())