次が、現在手元に置いているものです。ディレクトリを走査し、除外するものを数え、秘密情報らしき行を探して、問題がなければ zip と一覧表(_MANIFEST.tsv)を作ります。
#!/usr/bin/env python3"""agzip.py: エージェントに渡す .zip を、検査してから作る。"""import argparse, fnmatch, hashlib, os, re, sys, zipfileSKIP_DIRS = {".git", "node_modules", ".next", "dist", "build", ".venv", "__pycache__", ".wrangler", ".turbo", ".cache"}SKIP_FILES = [".env", ".env.*", "*.pem", "*.key", "id_rsa*", "*credentials*.json", ".DS_Store", "*.log"]KEEP_FILES = [".env.example", ".env.sample"] # 変数名だけ知らせたいので通すSECRET_RES = { "github-token": re.compile(r"gh[pousr]_[A-Za-z0-9]{30,}"), "aws-access-key": re.compile(r"AKIA[0-9A-Z]{16}"), "google-api-key": re.compile(r"AIza[0-9A-Za-z_\-]{35}"), "private-key": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), "generic-secret": re.compile(r"(?i)(secret|token|password)\s*[:=]\s*['\"][^'\"\s]{16,}['\"]"),}# 説明用のダミー値は警告から外す(ここを広げすぎると本物も見逃す)PLACEHOLDER = re.compile(r"(?i)(x{4,}|your_|<[^>]+>|\$\{|example|dummy|changeme|_test_|\.\.\.)")TEXT_LIMIT = 1_000_000def line_id(line): return hashlib.sha256(line.strip().encode()).hexdigest()[:8]def skipped_file(name): if name in KEEP_FILES: return False return any(fnmatch.fnmatch(name, p) for p in SKIP_FILES)def dir_bytes(path): return sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(path) for f in fs if not os.path.islink(os.path.join(r, f)))def collect(src): kept, dropped, links = [], {"dirs": 0, "files": 0, "bytes": 0}, [] for root, dirs, files in os.walk(src, followlinks=False): keep = [] for d in sorted(dirs): full = os.path.join(root, d) if os.path.islink(full): # リンク先が圏外を指すかもしれないので入れない links.append(os.path.relpath(full, src)) elif d in SKIP_DIRS: dropped["dirs"] += 1; dropped["bytes"] += dir_bytes(full) else: keep.append(d) dirs[:] = keep for f in sorted(files): full = os.path.join(root, f); rel = os.path.relpath(full, src) if os.path.islink(full): links.append(rel) elif skipped_file(f): dropped["files"] += 1 else: kept.append((rel, os.path.getsize(full))) return kept, dropped, linksdef scan(src, kept): hits = [] for rel, size in kept: if size > TEXT_LIMIT: continue try: with open(os.path.join(src, rel), encoding="utf-8") as fh: for n, line in enumerate(fh, 1): for kind, rx in SECRET_RES.items(): m = rx.search(line) if m and not PLACEHOLDER.search(m.group(0)): hits.append((rel, n, kind, line_id(line))) except (UnicodeDecodeError, OSError): continue # バイナリは正規表現の対象外 return hitsdef main(): ap = argparse.ArgumentParser() ap.add_argument("src"); ap.add_argument("out") ap.add_argument("--max-mb", type=float, default=20.0) ap.add_argument("--ack", action="append", default=[], help="目視で誤検知と確認した行。path:id の形(id は警告に出る 8 桁)") a = ap.parse_args() kept, dropped, links = collect(a.src) hits = [h for h in scan(a.src, kept) if f"{h[0]}:{h[3]}" not in a.ack] print(f"files={len(kept)} raw={sum(s for _, s in kept)/1e6:.2f}MB " f"dropped_dirs={dropped['dirs']}({dropped['bytes']/1e6:.2f}MB) " f"dropped_files={dropped['files']} symlinks={len(links)}") for rel, n, kind, lid in hits: print(f"SECRET? {rel}:{n} [{kind}] ack={rel}:{lid}") # 値そのものは出さない if hits: print("中止: 秘密情報らしき行があります"); return 2 with zipfile.ZipFile(a.out, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z: manifest = ["path\tbytes\tsha256_8"] for rel, size in kept: zname = rel.replace(os.sep, "/") # Windows でも zip 内は / 区切りにそろえる data = open(os.path.join(a.src, rel), "rb").read() manifest.append(f"{zname}\t{size}\t{hashlib.sha256(data).hexdigest()[:8]}") z.writestr(zipfile.ZipInfo(zname, (2026, 1, 1, 0, 0, 0)), data, zipfile.ZIP_DEFLATED) z.writestr("_MANIFEST.tsv", "\n".join(manifest) + "\n") mb = os.path.getsize(a.out) / 1e6 print(f"zip={mb:.2f}MB") if mb > a.max_mb: print(f"中止: {a.max_mb}MB を超えました"); return 3 return 0if __name__ == "__main__": sys.exit(main())
なぜこう書いたかを、三点だけ補います。
一つ目は、シンボリックリンクを入れないことです。リンクの先がフォルダの外を指していると、固めた時点で外のファイルを抱え込むかもしれません。二つ目は、zip 内の更新時刻を固定していることです。同じ入力から同じ zip が作れるので、前回との差を見比べられます。三つ目は、_MANIFEST.tsv を同梱していることです。エージェントが最初に開く一覧になり、私自身も「何を渡したか」をあとから確かめられます。