diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 3f55dc26..0f4dd1a5 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -12,11 +12,6 @@ on: description: 'Generate build provenance attestations for release artifacts' type: boolean default: false - ui_only: - description: 'Build only the UI runtime archives' - type: boolean - default: false - permissions: contents: read id-token: write @@ -67,34 +62,7 @@ jobs: with: node-version: "22" - - name: Build standard binary - if: ${{ !inputs.ui_only }} - env: - VERSION: ${{ inputs.version }} - CC: ${{ matrix.cc }} - CXX: ${{ matrix.cxx }} - run: | - if [ -n "$VERSION" ]; then - scripts/build.sh --version "$VERSION" "CC=$CC" "CXX=$CXX" - else - scripts/build.sh "CC=$CC" "CXX=$CXX" - fi - - - name: Ad-hoc sign macOS standard binary - if: ${{ !inputs.ui_only && startsWith(matrix.os, 'macos') }} - run: codesign --sign - --force build/c/codebase-memory-mcp - - - name: Archive standard binary (canonical package-release.sh) - if: ${{ !inputs.ui_only }} - run: scripts/package-release.sh ${{ matrix.goos }} ${{ matrix.goarch }} - - - name: Attest standard binary provenance - if: ${{ inputs.attest && !inputs.ui_only }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz - - - name: Build UI binary + - name: Build release binary with UI env: VERSION: ${{ inputs.version }} CC: ${{ matrix.cc }} @@ -106,7 +74,7 @@ jobs: scripts/build.sh --with-ui "CC=$CC" "CXX=$CXX" fi - - name: Ad-hoc sign macOS UI binary + - name: Ad-hoc sign macOS release binary if: startsWith(matrix.os, 'macos') run: codesign --sign - --force build/c/codebase-memory-mcp @@ -116,10 +84,10 @@ jobs: # Archive layout/name live in the ONE canonical script the local # artifact-flow smoke lane also runs (venue-parity contract). - - name: Archive UI binary (canonical package-release.sh) + - name: Archive release binary (canonical package-release.sh) run: scripts/package-release.sh ${{ matrix.goos }} ${{ matrix.goarch }} --variant ui - - name: Attest UI binary provenance + - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: @@ -150,30 +118,7 @@ jobs: with: node-version: "22" - - name: Build standard binary - if: ${{ !inputs.ui_only }} - shell: msys2 {0} - env: - VERSION: ${{ inputs.version }} - run: | - if [ -n "$VERSION" ]; then - scripts/build.sh --version "$VERSION" CC=clang CXX=clang++ - else - scripts/build.sh CC=clang CXX=clang++ - fi - - - name: Archive standard binary (canonical package-release.sh) - if: ${{ !inputs.ui_only }} - shell: msys2 {0} - run: scripts/package-release.sh windows amd64 CC=clang CXX=clang++ - - - name: Attest standard binary provenance - if: ${{ inputs.attest && !inputs.ui_only }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: codebase-memory-mcp-windows-amd64.zip - - - name: Build UI binary + - name: Build release binary with UI shell: msys2 {0} env: VERSION: ${{ inputs.version }} @@ -186,11 +131,11 @@ jobs: # Archive layout/name live in the ONE canonical script the local # artifact-flow smoke lane also runs (venue-parity contract). - - name: Archive UI binary (canonical package-release.sh) + - name: Archive release binary (canonical package-release.sh) shell: msys2 {0} run: scripts/package-release.sh windows amd64 --variant ui CC=clang CXX=clang++ - - name: Attest UI binary provenance + - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: @@ -225,30 +170,7 @@ jobs: with: node-version: "22" - - name: Build standard binary - if: ${{ !inputs.ui_only }} - shell: msys2 {0} - env: - VERSION: ${{ inputs.version }} - run: | - if [ -n "$VERSION" ]; then - scripts/build.sh --version "$VERSION" CC=clang CXX=clang++ - else - scripts/build.sh CC=clang CXX=clang++ - fi - - - name: Archive standard binary (canonical package-release.sh) - if: ${{ !inputs.ui_only }} - shell: msys2 {0} - run: scripts/package-release.sh windows arm64 CC=clang CXX=clang++ - - - name: Attest standard binary provenance - if: ${{ inputs.attest && !inputs.ui_only }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: codebase-memory-mcp-windows-arm64.zip - - - name: Build UI binary + - name: Build release binary with UI shell: msys2 {0} env: VERSION: ${{ inputs.version }} @@ -259,11 +181,11 @@ jobs: scripts/build.sh --with-ui CC=clang CXX=clang++ fi - - name: Archive UI binary (canonical package-release.sh) + - name: Archive release binary (canonical package-release.sh) shell: msys2 {0} run: scripts/package-release.sh windows arm64 --variant ui CC=clang CXX=clang++ - - name: Attest UI binary provenance + - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: @@ -297,34 +219,7 @@ jobs: with: node-version: "22" - - name: Build standard binary (static) - if: ${{ !inputs.ui_only }} - env: - VERSION: ${{ inputs.version }} - run: | - if [ -n "$VERSION" ]; then - scripts/build.sh --version "$VERSION" CC=gcc CXX=g++ STATIC=1 - else - scripts/build.sh CC=gcc CXX=g++ STATIC=1 - fi - - - name: Verify standard static linking - if: ${{ !inputs.ui_only }} - run: | - file build/c/codebase-memory-mcp - ldd build/c/codebase-memory-mcp 2>&1 | grep -q "not a dynamic executable" || ldd build/c/codebase-memory-mcp 2>&1 | grep -q "statically linked" - - - name: Archive standard binary (canonical package-release.sh) - if: ${{ !inputs.ui_only }} - run: scripts/package-release.sh linux ${{ matrix.arch }}-portable - - - name: Attest standard binary provenance - if: ${{ inputs.attest && !inputs.ui_only }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: codebase-memory-mcp-linux-${{ matrix.arch }}-portable.tar.gz - - - name: Build UI binary (static) + - name: Build release binary with UI (static) env: VERSION: ${{ inputs.version }} run: | @@ -334,15 +229,15 @@ jobs: scripts/build.sh --with-ui CC=gcc CXX=g++ STATIC=1 fi - - name: Verify UI static linking + - name: Verify release static linking run: | file build/c/codebase-memory-mcp ldd build/c/codebase-memory-mcp 2>&1 | grep -q "not a dynamic executable" || ldd build/c/codebase-memory-mcp 2>&1 | grep -q "statically linked" - - name: Archive UI binary (canonical package-release.sh) + - name: Archive release binary (canonical package-release.sh) run: scripts/package-release.sh linux ${{ matrix.arch }}-portable --variant ui - - name: Attest UI binary provenance + - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index fca2f040..21c06b85 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -8,11 +8,6 @@ on: description: 'Smoke the shipped binaries on the broad platform matrix (extra OS versions) instead of the core set' type: boolean default: false - ui_only: - description: 'Smoke only the UI runtime archives' - type: boolean - default: false - permissions: contents: read @@ -34,7 +29,6 @@ jobs: id: set env: BROAD: ${{ inputs.broad_platforms }} - UI_ONLY: ${{ inputs.ui_only }} run: | CORE_UNIX='[ {"os":"ubuntu-latest","goos":"linux","goarch":"amd64"}, @@ -77,14 +71,9 @@ jobs: WIN=$(jq -cn --argjson a "$CORE_WIN" '$a') PORTABLE=$(jq -cn --argjson a "$CORE_PORTABLE" '$a') fi - # Keep the variant dimension explicit because the smoke wrappers use it - # to require and validate the UI pack. VT-focused dry runs select only - # UI; release compatibility retains both variants until migration. - if [ "$UI_ONLY" = "true" ]; then - VARIANTS='["ui"]' - else - VARIANTS='["standard","ui"]' - fi + # Keep the UI marker explicit because the smoke wrappers use it to + # require and validate the shipped content-addressed frontend pack. + VARIANTS='["ui"]' UNIX_M=$(jq -cn --argjson a "$UNIX" --argjson v "$VARIANTS" '{include:[$a[] as $o | $v[] as $t | ($o + {variant:$t})]}') WIN_M=$(jq -cn --argjson a "$WIN" --argjson v "$VARIANTS" '{include:[$a[] as $o | $v[] as $t | ($o + {variant:$t})]}') PORTABLE_M=$(jq -cn --argjson a "$PORTABLE" --argjson v "$VARIANTS" '{include:[$a[] as $o | $v[] as $t | ($o + {variant:$t})]}') @@ -140,7 +129,7 @@ jobs: scripts/security-network.sh "$BIN" - name: MCP robustness test (selected linux-amd64 runtime) - if: matrix.goos == 'linux' && matrix.goarch == 'amd64' && (inputs.ui_only || matrix.variant == 'standard') + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' run: | BIN="$RUNNER_TEMP/cbm-artifact/codebase-memory-mcp" scripts/security-fuzz.sh "$BIN" diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index e747c67d..14f20df8 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -91,7 +91,6 @@ jobs: uses: ./.github/workflows/_build.yml with: attest: false - ui_only: true # ── Smoke test every binary ──────────────────────────────────── # Run unless builds were skipped or a build leg FAILED. Every build leg @@ -103,7 +102,6 @@ jobs: uses: ./.github/workflows/_smoke.yml with: broad_platforms: true - ui_only: true # ── Soak tests (optional, parallel with smoke) ──────────────── soak: @@ -148,8 +146,8 @@ jobs: merge-multiple: true path: ${{ runner.temp }}/release-archives - # Same extraction the release verify job performs, so the bytes handed to - # VirusTotal here are the bytes a user would download and run. + # Same extraction the release verify job performs. VirusTotal receives + # only extracted files and unpacked UI assets, never .tar.gz/.zip containers. - name: Extract archive artifacts run: | scripts/ci/extract-release-archives.sh "$RUNNER_TEMP/release-archives" binaries \ @@ -167,7 +165,7 @@ jobs: files: binaries/objects/* request_rate: 4 - # Same ZERO-tolerance gate as the release. Every artifact must arrive + # Same ZERO-tolerance gate as the release. Every extracted object must arrive # naturally clean; any malicious or suspicious verdict fails this run. - name: Wait for VirusTotal results env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81c30e3b..a2d806b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -216,15 +216,16 @@ jobs: mkdir -p "$ARCHIVE_DIR" binaries gh release download "$VERSION" --dir "$ARCHIVE_DIR" --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz' --pattern '*.zip' scripts/ci/extract-release-archives.sh "$ARCHIVE_DIR" binaries \ - --expect-archives=16 \ - --expect-binaries=16 \ + --archive-scope=ui \ + --expect-archives=8 \ + --expect-binaries=8 \ --expect-packs=8 \ - --expect-runtime-files=64 + --expect-runtime-files=32 - - name: Security audits on all unique release objects + - name: Security audits on all unique extracted release objects run: | - # Audit every distinct archive/member/UI-asset byte sequence. The - # associations manifest preserves every shipped archive location. + # Audit every distinct extracted member/UI-asset byte sequence. The + # associations manifest retains each source archive's SHA-256. # security-strings.sh detects file type and applies binary-only # rules (URL allowlist, dangerous-command detection) only to real # binaries; for shell scripts it still runs credential and base64 @@ -243,7 +244,7 @@ jobs: files: binaries/objects/* request_rate: 4 - # ZERO tolerance: every artifact must arrive naturally clean. Any + # ZERO tolerance: every extracted object must arrive naturally clean. Any # malicious or suspicious verdict blocks the release with no exception. - name: Wait for VirusTotal results env: @@ -275,7 +276,7 @@ jobs: VT_RESULTS_PATH: binaries/vt-results.tsv run: bash scripts/ci/publish-vt-evidence.sh - # Renders the exact scan scope plus archive SHA-256/engine evidence. It + # Renders extracted-file scan results plus archive SHA-256 provenance. It # runs only after the exact-set zero-tolerance gate passed. - name: Append VirusTotal scan links to release notes env: diff --git a/README.md b/README.md index 203d6f1c..520b95b3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si > **Research** — The design and benchmarks behind this project are described in the preprint [*Codebase-Memory: Tree-Sitter-Based Knowledge Graphs for LLM Code Exploration via MCP*](https://arxiv.org/abs/2603.27277) (arXiv:2603.27277). Evaluated across 31 real-world repositories: 83% answer quality, 10× fewer tokens, 2.1× fewer tool calls vs. file-by-file exploration. -> **Security & Trust** — This tool reads your codebase and writes to your agent configuration files. That is what it is designed to do. If you prefer to audit before running, the [full source is here](https://github.com/DeusData/codebase-memory-mcp). Release archives are signed and checksummed; every distinct shipped archive, member, and UI-asset byte object is submitted to VirusTotal. The release notes record the measured engine count and exact 0/N result; publication requires at least 50 decisive engine results with zero malicious and zero suspicious verdicts. All processing happens 100% locally; your code never leaves your machine. Found a security issue? We want to know — see [SECURITY.md](SECURITY.md). Security is Priority #1 for us. +> **Security & Trust** — This tool reads your codebase and writes to your agent configuration files. That is what it is designed to do. If you prefer to audit before running, the [full source is here](https://github.com/DeusData/codebase-memory-mcp). Release archives are signed and checksummed; every distinct extracted member and unpacked UI asset is submitted to VirusTotal, while the downloadable `.tar.gz`/`.zip` containers themselves are not. The release notes record the measured engine count and exact 0/N result; publication requires at least 50 decisive engine results with zero malicious and zero suspicious verdicts. All processing happens 100% locally; your code never leaves your machine. Found a security issue? We want to know — see [SECURITY.md](SECURITY.md). Security is Priority #1 for us.
No. All indexing and querying happen 100% locally. There is no embedded LLM and no API key. Release
- archives are signed and checksummed, and every distinct shipped archive, member, and UI-asset byte object
- is submitted to VirusTotal. Release notes
- record its measured 0/N result; at least 50 decisive engines and zero malicious or suspicious verdicts are required.
@@ -791,7 +791,7 @@ internal/cbm/ Vendored tree-sitter grammars (158 languages) + AST extrac
Every release is verified through a multi-layer pipeline before publication:
-- **VirusTotal** — every release object is scanned (minimum 50 decisive engines; zero malicious and zero suspicious required). Release notes summarize the archive rows and link durable public association, exact-scan-set, and per-object result files containing every measured engine count and VirusTotal report URL.
+- **VirusTotal** — every distinct extracted release file and unpacked UI asset is scanned; downloadable `.tar.gz`/`.zip` containers are not submitted (minimum 50 decisive engines; zero malicious and zero suspicious required). Release notes retain archive SHA-256 provenance and link durable public association, exact-scan-set, and per-object result files containing every measured engine count and VirusTotal report URL.
- **SLSA Level 3** — cryptographic build provenance generated by the trusted GitHub Actions build workflow; verify with `gh attestation verify Does codebase-memory-mcp send my code anywhere?
.tar.gz/.zip containers are not. Release notes record the scanned files' measured 0/N
+ results; at least 50 decisive engines and zero malicious or suspicious verdicts are required.
Yes. Alongside structural and full-text search, search_graph's semantic_query
diff --git a/scripts/ci/README.md b/scripts/ci/README.md
index 2d482ebb..c00f81fe 100644
--- a/scripts/ci/README.md
+++ b/scripts/ci/README.md
@@ -15,4 +15,4 @@ CI and the local infrastructure — both of which the venue-parity contract
| `generate-sbom.py` | The release SPDX SBOM (vendored versions reviewable here, diffable by vendoring PRs — was inline YAML). | `release.yml` |
| `require-all-green.sh` | The aggregate gate: fail unless every needed job succeeded or legitimately skipped (was inline YAML). | `pr.yml ci-ok` |
| `verify-shard-union.sh` | Prove sharded test legs lost nothing: shard count agreement, indices 1..n, identical suite lists, union of slices == full list (was inline YAML). | `_test.yml` shard-completeness |
-| `check-virustotal.sh` | Release-asset VirusTotal lookups. | `release.yml` |
+| `check-virustotal.sh` | Exact extracted-file VirusTotal lookups. | `release.yml` |
diff --git a/scripts/ci/append-vt-notes.sh b/scripts/ci/append-vt-notes.sh
index 58804617..18974851 100755
--- a/scripts/ci/append-vt-notes.sh
+++ b/scripts/ci/append-vt-notes.sh
@@ -106,7 +106,7 @@ asset_base = (
)
association_meta, associations = read_tsv(
association_path,
- "cbm-release-scan-associations-v2",
+ "cbm-release-scan-associations-v3",
ASSOCIATION_FIELDS,
)
result_meta, results = read_tsv(
@@ -171,19 +171,22 @@ for association in associations:
):
fail(f"result hash/size differs from association: {association['scan_path']}")
-archive_rows = [row for row in associations if row["association_type"] == "archive"]
member_rows = [row for row in associations if row["association_type"] == "member"]
asset_rows = [row for row in associations if row["association_type"] == "pack_asset"]
-if len(archive_rows) != association_meta.get("archives"):
- fail("downloadable archive association count is inconsistent")
-archive_names = [row["archive"] for row in archive_rows]
-if len(set(archive_names)) != len(archive_names):
- fail("downloadable archive association is duplicated")
-archive_hashes = {row["archive"]: row["archive_sha256"] for row in archive_rows}
-if any(row["archive_sha256"] != row["object_sha256"] for row in archive_rows):
- fail("downloadable archive SHA is not bound to its scan object")
-if any(archive_hashes.get(row["archive"]) != row["archive_sha256"] for row in associations):
- fail("member/asset association is not bound to its downloadable archive")
+if len(member_rows) + len(asset_rows) != len(associations):
+ fail("association manifest contains a non-extracted scan target")
+archive_hashes: Dict[str, str] = {}
+for association in associations:
+ archive = association["archive"]
+ archive_sha256 = association["archive_sha256"]
+ if not archive or re.fullmatch(r"[0-9a-f]{64}", archive_sha256) is None:
+ fail("member/asset association lacks valid archive SHA provenance")
+ previous_sha256 = archive_hashes.get(archive)
+ if previous_sha256 is not None and previous_sha256 != archive_sha256:
+ fail(f"conflicting archive SHA provenance: {archive}")
+ archive_hashes[archive] = archive_sha256
+if len(archive_hashes) != association_meta.get("archives"):
+ fail("distinct archive provenance count is inconsistent")
engine_counts = [int(row["completed_engines"]) for row in results]
minimum = min(engine_counts)
@@ -198,14 +201,20 @@ section = [
"## Security Verification",
"",
(
- f"VirusTotal completed **{len(results)} distinct byte objects** covering "
- f"**{len(associations)} exact release associations**: "
- f"{len(archive_rows)} downloadable archives, {len(member_rows)} archive members, "
- f"and {len(asset_rows)} independently extracted UI-pack assets."
+ f"VirusTotal completed **{len(results)} distinct extracted byte objects** covering "
+ f"**{len(associations)} exact extracted-file associations**: "
+ f"{len(member_rows)} archive members and "
+ f"{len(asset_rows)} independently extracted UI-pack assets."
+ ),
+ (
+ f"The extraction manifest binds those associations to "
+ f"**{len(archive_hashes)} downloadable "
+ f"{'archive' if len(archive_hashes) == 1 else 'archives'}** by SHA-256 provenance. "
+ "Downloadable .tar.gz/.zip release containers were not submitted to VirusTotal."
),
"",
(
- f"Every object returned **0 malicious and 0 suspicious** verdicts with "
+ f"Every scanned object returned **0 malicious and 0 suspicious** verdicts with "
f"{engine_range} decisive engine results (required minimum: {policy})."
),
(
@@ -217,20 +226,17 @@ section = [
"Durable public evidence: "
f"[associations]({asset_base}/virustotal-associations.tsv), "
f"[exact scan set]({asset_base}/virustotal-scan-set.tsv), "
- f"[per-object results and report links]({asset_base}/virustotal-results.tsv), "
+ f"[per-extracted-object results and report links]({asset_base}/virustotal-results.tsv), "
f"[evidence checksums]({asset_base}/virustotal-evidence-checksums.txt)."
),
"",
- "| Downloadable archive | SHA-256 | Engines | VirusTotal |",
- "|---|---|---:|---|",
+ "Archive SHA-256 provenance (from the extraction manifest):",
+ "",
+ "| Downloadable archive | SHA-256 provenance |",
+ "|---|---|",
]
-for association in sorted(archive_rows, key=lambda row: row["archive"]):
- result = results_by_path[association["scan_path"]]
- sha256 = result["sha256"]
- section.append(
- f"| `{association['archive']}` | `{sha256}` | "
- f"{result['completed_engines']} | [0 detections]({result['virustotal_url']}) |"
- )
+for archive, archive_sha256 in sorted(archive_hashes.items()):
+ section.append(f"| `{archive}` | `{archive_sha256}` |")
section.append(END)
replacement = "\n".join(section)
diff --git a/scripts/ci/check-virustotal.sh b/scripts/ci/check-virustotal.sh
index 427d398d..d186df13 100755
--- a/scripts/ci/check-virustotal.sh
+++ b/scripts/ci/check-virustotal.sh
@@ -186,7 +186,7 @@ def parse_versioned_tsv(
def load_expected(path: pathlib.Path) -> Tuple[List[ExpectedObject], int]:
metadata, rows = parse_versioned_tsv(
path,
- marker="cbm-release-scan-set-v1",
+ marker="cbm-release-scan-set-v2",
fields=SCAN_FIELDS,
)
expected_count = metadata.get("scan_objects")
@@ -217,7 +217,7 @@ def load_expected(path: pathlib.Path) -> Tuple[List[ExpectedObject], int]:
size = int(row["size"])
association_count = int(row["association_count"])
kinds = row["association_kinds"].split(",")
- allowed_kinds = {"archive", "binary", "pack", "runtime", "ui_asset"}
+ allowed_kinds = {"binary", "pack", "runtime", "ui_asset"}
if (
association_count < 1
or any(not kind or kind not in allowed_kinds for kind in kinds)
@@ -258,7 +258,7 @@ def validate_associations(
) -> None:
metadata, rows = parse_versioned_tsv(
path,
- marker="cbm-release-scan-associations-v2",
+ marker="cbm-release-scan-associations-v3",
fields=ASSOCIATION_FIELDS,
)
if (
@@ -273,29 +273,26 @@ def validate_associations(
seen: set[Tuple[str, str, str, str]] = set()
archive_hashes: Dict[str, str] = {}
for row in rows:
- if row["association_type"] != "archive":
- continue
archive = row["archive"]
- if not archive or archive in archive_hashes or row["archive_sha256"] != row["object_sha256"]:
- raise GateError(f"malformed or duplicate archive association: {archive}")
- archive_hashes[archive] = row["archive_sha256"]
+ archive_sha256 = row["archive_sha256"]
+ if not archive or SHA256_RE.fullmatch(archive_sha256) is None:
+ raise GateError(f"malformed archive provenance: {archive}")
+ previous = archive_hashes.setdefault(archive, archive_sha256)
+ if previous != archive_sha256:
+ raise GateError(f"conflicting archive provenance: {archive}")
+ if metadata.get("archives") != len(archive_hashes):
+ raise GateError("archive provenance count does not match the association manifest")
for row in rows:
association_type = row["association_type"]
key = (association_type, row["archive"], row["member"], row["asset_path"])
if key in seen:
raise GateError(f"duplicate release association: {key}")
seen.add(key)
- if association_type not in {"archive", "member", "pack_asset"} or not row["archive"]:
+ if association_type not in {"member", "pack_asset"} or not row["archive"]:
raise GateError(f"malformed release association: {key}")
if archive_hashes.get(row["archive"]) != row["archive_sha256"]:
- raise GateError(f"association is not bound to its archive object: {key}")
- if association_type == "archive":
- semantic_valid = (
- not row["member"]
- and not row["asset_path"]
- and row["kind"] == "archive"
- )
- elif association_type == "member":
+ raise GateError(f"association is not bound to its archive provenance: {key}")
+ if association_type == "member":
semantic_valid = bool(row["member"]) and not row["asset_path"]
else:
semantic_valid = bool(row["member"] and row["asset_path"] and row["mime"]) and row["kind"] == "ui_asset"
diff --git a/scripts/ci/extract-release-archives.sh b/scripts/ci/extract-release-archives.sh
index 48785727..48e7aaf7 100755
--- a/scripts/ci/extract-release-archives.sh
+++ b/scripts/ci/extract-release-archives.sh
@@ -9,12 +9,14 @@
#
# The output directory is published as one atomic bundle:
# objects/ one file per distinct byte sequence
-# associations.tsv every archive, member and CBMUIPK asset -> scan object
+# associations.tsv every extracted member and CBMUIPK asset -> scan object
# scan-set.tsv the exact path/hash/size set the VT action must return
#
-# The release archives themselves, their exact members, and the uncompressed
-# HTML/JavaScript/CSS/etc payloads inside every CBMUIPK v1 pack are all covered.
-# Identical bytes are uploaded once, but no shipped association is discarded.
+# Every archive is validated and hashed for provenance, but downloadable
+# .tar.gz/.zip release containers are not scanned. Their exact members and the
+# uncompressed HTML/JavaScript/CSS/etc payloads inside every CBMUIPK v1 pack are
+# covered. Identical bytes are uploaded once, but no extracted member/asset
+# association is discarded.
set -euo pipefail
if [ "$#" -lt 2 ]; then
@@ -733,24 +735,16 @@ def main(argv: Sequence[str]) -> None:
staged_output = pathlib.Path(temporary) / "bundle"
staged_output.mkdir(mode=0o700)
store = ObjectStore(staged_output / "objects")
+ archive_store = ObjectStore(pathlib.Path(temporary) / "archives")
for archive_path in archive_paths:
archive_name = archive_path.name
variant = "ui" if archive_name.startswith("codebase-memory-mcp-ui-") else "standard"
- archive_object = store.ingest_path(
+ archive_object = archive_store.ingest_path(
archive_path,
ceiling=MAX_ARCHIVE_BYTES,
label=archive_name,
)
archive_sha256 = archive_object.sha256
- add_association(
- rows,
- archive_object,
- association_type="archive",
- archive=archive_name,
- archive_sha256=archive_sha256,
- variant=variant,
- kind="archive",
- )
_, kinds, member_total = (
process_tar(
archive_object.path,
@@ -792,7 +786,7 @@ def main(argv: Sequence[str]) -> None:
rows.sort(
key=lambda row: (
str(row["archive"]),
- {"archive": 0, "member": 1, "pack_asset": 2}[str(row["association_type"])],
+ {"member": 0, "pack_asset": 1}[str(row["association_type"])],
str(row["member"]),
str(row["asset_path"]),
)
@@ -808,7 +802,7 @@ def main(argv: Sequence[str]) -> None:
)
write_tsv(
staged_output / "associations.tsv",
- marker="cbm-release-scan-associations-v2",
+ marker="cbm-release-scan-associations-v3",
metadata=((key, counts[key]) for key in metadata_order),
fields=ASSOCIATION_FIELDS,
rows=rows,
@@ -825,7 +819,7 @@ def main(argv: Sequence[str]) -> None:
]
write_tsv(
staged_output / "scan-set.tsv",
- marker="cbm-release-scan-set-v1",
+ marker="cbm-release-scan-set-v2",
metadata=((key, counts[key]) for key in ("scan_objects", "associations")),
fields=SCAN_SET_FIELDS,
rows=scan_rows,
@@ -848,7 +842,7 @@ def main(argv: Sequence[str]) -> None:
)
print(
f"scan bundle: {counts['scan_objects']} distinct byte objects cover "
- f"{counts['associations']} archive/member/asset associations"
+ f"{counts['associations']} extracted member/asset associations"
)
print(f"associations: {output_dir / 'associations.tsv'}")
print(f"expected scan set: {output_dir / 'scan-set.tsv'}")
diff --git a/scripts/ci/publish-vt-evidence.sh b/scripts/ci/publish-vt-evidence.sh
index 3369855a..c079012b 100644
--- a/scripts/ci/publish-vt-evidence.sh
+++ b/scripts/ci/publish-vt-evidence.sh
@@ -38,8 +38,8 @@ publish_copy() {
chmod 0644 "$WORK/$destination"
}
-publish_copy "$VT_ASSOCIATIONS" cbm-release-scan-associations-v2 virustotal-associations.tsv
-publish_copy "$VT_EXPECTED_SCAN_SET" cbm-release-scan-set-v1 virustotal-scan-set.tsv
+publish_copy "$VT_ASSOCIATIONS" cbm-release-scan-associations-v3 virustotal-associations.tsv
+publish_copy "$VT_EXPECTED_SCAN_SET" cbm-release-scan-set-v2 virustotal-scan-set.tsv
publish_copy "$VT_RESULTS_PATH" cbm-virustotal-results-v1 virustotal-results.tsv
for name in virustotal-associations.tsv virustotal-results.tsv virustotal-scan-set.tsv; do
diff --git a/tests/test_release_archive_extractor_contract.sh b/tests/test_release_archive_extractor_contract.sh
index 9797c8d3..b07eb37f 100644
--- a/tests/test_release_archive_extractor_contract.sh
+++ b/tests/test_release_archive_extractor_contract.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
-# Contract for the release scan boundary: exact archives, exact members,
-# independently parsed UI-pack assets, byte-exact deduplication and atomic
-# publication of a complete association manifest plus scan set.
+# Contract for the release scan boundary: archives are validated and hashed;
+# only exact extracted members and independently parsed UI-pack assets enter
+# the byte-deduplicated, atomically published scan set.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -298,7 +298,6 @@ def build_matrix(
path = archive_dir / name
write_archive(path, entries)
if mutation is None:
- expected[("archive", name, "", "")] = path.read_bytes()
for entry in entries:
if entry["kind"] != "regular":
continue
@@ -380,10 +379,10 @@ with tempfile.TemporaryDirectory(prefix="cbm-release-scan-contract-") as tempora
fail("scan bundle must publish exactly objects/ plus its two manifests")
association_metadata, rows = parse_manifest(
- output / "associations.tsv", "cbm-release-scan-associations-v2"
+ output / "associations.tsv", "cbm-release-scan-associations-v3"
)
expected_pack_assets = 8 * len(asset_payloads)
- expected_associations = 16 + 88 + expected_pack_assets
+ expected_associations = 88 + expected_pack_assets
expected_metadata = {
**expected_base_counts,
"pack_assets": expected_pack_assets,
@@ -393,13 +392,19 @@ with tempfile.TemporaryDirectory(prefix="cbm-release-scan-contract-") as tempora
if association_metadata != expected_metadata:
fail(f"association metadata mismatch: {association_metadata!r}")
if len(rows) != expected_associations:
- fail("association manifest does not contain every archive/member/asset association")
+ fail("association manifest does not contain every extracted member/asset association")
actual_associations = {
(row["association_type"], row["archive"], row["member"], row["asset_path"]): row
for row in rows
}
+ if any(row["association_type"] == "archive" or row["kind"] == "archive" for row in rows):
+ fail("archive containers must not appear in the VirusTotal association set")
if set(actual_associations) != set(expected):
fail("association manifest keys differ from exact shipped associations")
+ archive_hashes = {
+ name: hashlib.sha256((valid / "archives" / name).read_bytes()).hexdigest()
+ for name in archive_names
+ }
for association, data in expected.items():
row = actual_associations[association]
object_path = output / row["scan_path"]
@@ -407,6 +412,8 @@ with tempfile.TemporaryDirectory(prefix="cbm-release-scan-contract-") as tempora
fail(f"scan object changed bytes for association {association!r}")
if row["object_sha256"] != hashlib.sha256(data).hexdigest() or int(row["size"]) != len(data):
fail(f"association digest/size is wrong for {association!r}")
+ if row["archive_sha256"] != archive_hashes[row["archive"]]:
+ fail(f"association archive provenance is wrong for {association!r}")
object_paths = sorted((output / "objects").iterdir())
if len(object_paths) != len(set(expected.values())):
@@ -424,7 +431,7 @@ with tempfile.TemporaryDirectory(prefix="cbm-release-scan-contract-") as tempora
if len(script_rows) != 8 or len({row["scan_path"] for row in script_rows}) != 1:
fail("each pack's JavaScript association must reach one exact deduplicated script object")
- scan_metadata, scan_rows = parse_manifest(output / "scan-set.tsv", "cbm-release-scan-set-v1")
+ scan_metadata, scan_rows = parse_manifest(output / "scan-set.tsv", "cbm-release-scan-set-v2")
if scan_metadata != {
"scan_objects": expected_metadata["scan_objects"],
"associations": expected_associations,
@@ -450,9 +457,9 @@ with tempfile.TemporaryDirectory(prefix="cbm-release-scan-contract-") as tempora
if ui_result.returncode != 0:
fail(f"canonical UI-only matrix was rejected: {ui_result.stdout.strip()}")
ui_metadata, ui_rows = parse_manifest(
- ui_output / "associations.tsv", "cbm-release-scan-associations-v2"
+ ui_output / "associations.tsv", "cbm-release-scan-associations-v3"
)
- ui_associations = 8 + 48 + expected_pack_assets
+ ui_associations = 48 + expected_pack_assets
if ui_metadata != {
**ui_expected_base_counts,
"pack_assets": expected_pack_assets,
@@ -509,7 +516,7 @@ source = extractor.read_text(encoding="utf-8")
if "files_equal(candidate, existing.path)" not in source:
fail("deduplication no longer exact-compares bytes after SHA-256+size grouping")
if source.count("archive_object.path,") < 2:
- fail("members are not parsed from the same retained bytes staged as the archive object")
+ fail("members are not parsed from the same retained archive bytes used for provenance")
for ceiling in ("MAX_ARCHIVE_BYTES", "MAX_MEMBER_BYTES", "MAX_TOTAL_MEMBER_BYTES"):
if ceiling not in source:
fail(f"extractor lost required size ceiling {ceiling}")
@@ -518,16 +525,16 @@ release = (root / ".github/workflows/release.yml").read_text(encoding="utf-8")
dry_run = (root / ".github/workflows/dry-run.yml").read_text(encoding="utf-8")
build = (root / ".github/workflows/_build.yml").read_text(encoding="utf-8")
smoke = (root / ".github/workflows/_smoke.yml").read_text(encoding="utf-8")
-if "ui_only:" not in build or build.count("if: ${{ !inputs.ui_only }}") < 4:
- fail("canonical build workflow must gate every standard build family behind ui_only")
+if "ui_only" in build or "Build standard binary" in build:
+ fail("canonical build workflow must not retain a non-UI release path")
if build.count("--variant ui") != 4:
- fail("canonical build workflow must package exactly its four UI build families")
-if "UI_ONLY: ${{ inputs.ui_only }}" not in smoke or "VARIANTS='[\"ui\"]'" not in smoke:
- fail("release smoke matrix must support an explicit UI-only selection")
-if dry_run.count("ui_only: true") != 2 or "--archive-scope=ui" not in dry_run:
- fail("dry-run must build, smoke and extract only the UI runtime set")
-if "--archive-scope=ui" in release:
- fail("release workflow must retain its dual-variant compatibility scope")
+ fail("canonical build workflow must package exactly its four UI-enabled build families")
+if "ui_only" in smoke or "VARIANTS='[\"ui\"]'" not in smoke or "standard" in smoke.split("VARIANTS=", 1)[1].split("\n", 1)[0]:
+ fail("release smoke matrix must expose only the UI-enabled runtime")
+if "ui_only" in dry_run or "--archive-scope=ui" not in dry_run:
+ fail("dry-run must use the canonical UI-only build/smoke/extraction path")
+if "--archive-scope=ui" not in release:
+ fail("release verification must accept only the shipped UI-enabled archives")
if (
"path: ${{ runner.temp }}/release-archives" not in dry_run
or 'extract-release-archives.sh "$RUNNER_TEMP/release-archives" binaries' not in dry_run
@@ -544,7 +551,7 @@ if (
):
fail("release verification must isolate downloaded archives from tracked checkout assets")
for workflow_name, workflow, arguments in (
- ("release", release, count_args),
+ ("release", release, ui_count_args),
("dry-run", dry_run, ui_count_args),
):
for argument in arguments:
diff --git a/tests/test_smoke_fixture_contract.sh b/tests/test_smoke_fixture_contract.sh
index eb33ddda..17f35df0 100755
--- a/tests/test_smoke_fixture_contract.sh
+++ b/tests/test_smoke_fixture_contract.sh
@@ -424,15 +424,15 @@ require(
"release verification must feed the canonical extractor from an isolated archive directory",
)
require(
- "cbm-release-scan-associations-v2" in release_extractor
- and "cbm-release-scan-set-v1" in release_extractor
+ "cbm-release-scan-associations-v3" in release_extractor
+ and "cbm-release-scan-set-v2" in release_extractor
and "files_equal(candidate, existing.path)" in release_extractor
and "parse_pack_assets" in release_extractor
and "PACK_NAME.fullmatch" in release_extractor
and "non-regular archive member" in release_extractor
and "duplicate archive member" in release_extractor,
- "release extraction must validate every exact archive/member/UI-asset association "
- "and retain one exact-compared scan object per distinct byte sequence",
+ "release extraction must validate and hash every archive, then retain only exact "
+ "member/UI-asset associations and one scan object per distinct extracted byte sequence",
)
require(
"MSYS2_ARG_CONV_EXCL='*'" in smoke_test
diff --git a/tests/test_vt_gate_zero_tolerance_contract.sh b/tests/test_vt_gate_zero_tolerance_contract.sh
index 4e7f9d6f..33f3f11f 100644
--- a/tests/test_vt_gate_zero_tolerance_contract.sh
+++ b/tests/test_vt_gate_zero_tolerance_contract.sh
@@ -33,32 +33,34 @@ printf 'release-bytes\n' > "$FIX/work/binaries/objects/probe"
printf 'second-object\n' > "$FIX/work/binaries/objects/probe2"
PROBE_SHA="$(hash_file "$FIX/work/binaries/objects/probe")"
PROBE2_SHA="$(hash_file "$FIX/work/binaries/objects/probe2")"
+ARCHIVE_SHA='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
+ARCHIVE2_SHA='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
PROBE_SIZE="$(wc -c < "$FIX/work/binaries/objects/probe" | tr -d ' ')"
PROBE2_SIZE="$(wc -c < "$FIX/work/binaries/objects/probe2" | tr -d ' ')"
write_manifest() { # one|two
- local count=1 associations=3
- if [ "$1" = "two" ]; then count=2; associations=4; fi
+ local count=1 associations=2 archives=1
+ if [ "$1" = "two" ]; then count=2; associations=3; archives=2; fi
{
- echo '# cbm-release-scan-set-v1'
+ echo '# cbm-release-scan-set-v2'
echo "# scan_objects=$count"
echo "# associations=$associations"
printf 'scan_path\tsha256\tsize\tassociation_count\tassociation_kinds\n'
- printf 'objects/probe\t%s\t%s\t3\tarchive,binary,runtime\n' "$PROBE_SHA" "$PROBE_SIZE"
+ printf 'objects/probe\t%s\t%s\t2\tbinary,runtime\n' "$PROBE_SHA" "$PROBE_SIZE"
if [ "$1" = "two" ]; then
- printf 'objects/probe2\t%s\t%s\t1\tarchive\n' "$PROBE2_SHA" "$PROBE2_SIZE"
+ printf 'objects/probe2\t%s\t%s\t1\truntime\n' "$PROBE2_SHA" "$PROBE2_SIZE"
fi
} > "$FIX/work/binaries/scan-set.tsv"
{
- echo '# cbm-release-scan-associations-v2'
+ echo '# cbm-release-scan-associations-v3'
+ echo "# archives=$archives"
echo "# associations=$associations"
echo "# scan_objects=$count"
printf 'association_type\tarchive\tarchive_sha256\tvariant\tkind\tmember\tasset_path\tmime\tscan_path\tobject_sha256\tsize\n'
- printf 'archive\tprobe.tar.gz\t%s\tstandard\tarchive\t\t\t\tobjects/probe\t%s\t%s\n' "$PROBE_SHA" "$PROBE_SHA" "$PROBE_SIZE"
- printf 'member\tprobe.tar.gz\t%s\tstandard\tbinary\tcodebase-memory-mcp\t\t\tobjects/probe\t%s\t%s\n' "$PROBE_SHA" "$PROBE_SHA" "$PROBE_SIZE"
- printf 'member\tprobe.tar.gz\t%s\tstandard\truntime\tLICENSE\t\t\tobjects/probe\t%s\t%s\n' "$PROBE_SHA" "$PROBE_SHA" "$PROBE_SIZE"
+ printf 'member\tprobe.tar.gz\t%s\tstandard\tbinary\tcodebase-memory-mcp\t\t\tobjects/probe\t%s\t%s\n' "$ARCHIVE_SHA" "$PROBE_SHA" "$PROBE_SIZE"
+ printf 'member\tprobe.tar.gz\t%s\tstandard\truntime\tLICENSE\t\t\tobjects/probe\t%s\t%s\n' "$ARCHIVE_SHA" "$PROBE_SHA" "$PROBE_SIZE"
if [ "$1" = "two" ]; then
- printf 'archive\tprobe2.zip\t%s\tstandard\tarchive\t\t\t\tobjects/probe2\t%s\t%s\n' "$PROBE2_SHA" "$PROBE2_SHA" "$PROBE2_SIZE"
+ printf 'member\tprobe2.zip\t%s\tstandard\truntime\tREADME.md\t\t\tobjects/probe2\t%s\t%s\n' "$ARCHIVE2_SHA" "$PROBE2_SHA" "$PROBE2_SIZE"
fi
} > "$FIX/work/binaries/associations.tsv"
}
@@ -247,7 +249,21 @@ done
# The independently validated association rows, not a mutable summary cell,
# decide whether an object is executable and therefore requires Microsoft.
write_manifest one
-sed -i.bak 's/archive,binary,runtime/archive,runtime/' "$FIX/work/binaries/scan-set.tsv"
+sed -i.bak 's/binary,runtime/archive,binary,runtime/' "$FIX/work/binaries/scan-set.tsv"
+rm -f "$FIX/work/binaries/scan-set.tsv.bak"
+[ "$(run_gate "$clean_output")" != "0" ] || \
+ fail "archive container kinds must be rejected from the VirusTotal scan set"
+grep -q 'unassociated object in expected set' "$FIX/last.log" || \
+ fail "archive-container scan-set rejection is not diagnosable"
+write_manifest one
+sed -i.bak $'s/^member\t/archive\t/' "$FIX/work/binaries/associations.tsv"
+rm -f "$FIX/work/binaries/associations.tsv.bak"
+[ "$(run_gate "$clean_output")" != "0" ] || \
+ fail "archive containers must be rejected from the VirusTotal association set"
+grep -q 'malformed release association' "$FIX/last.log" || \
+ fail "archive-container association rejection is not diagnosable"
+write_manifest one
+sed -i.bak 's/binary,runtime/runtime/' "$FIX/work/binaries/scan-set.tsv"
rm -f "$FIX/work/binaries/scan-set.tsv.bak"
[ "$(run_gate "$clean_output")" != "0" ] || \
fail "scan-set kinds that contradict association rows must block"
diff --git a/tests/test_vt_release_notes_contract.sh b/tests/test_vt_release_notes_contract.sh
index 3488fa9b..6427ce48 100644
--- a/tests/test_vt_release_notes_contract.sh
+++ b/tests/test_vt_release_notes_contract.sh
@@ -10,32 +10,32 @@ trap 'rm -rf "$FIX"' EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
mkdir -p "$FIX/bin" "$FIX/binaries"
-SHA_A='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
-SHA_B='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
+ARCHIVE_SHA='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
+MEMBER_SHA='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
+ASSET_SHA='cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
cat > "$FIX/binaries/associations.tsv" <