Add LoRA SBOM and source reproducibility evidence

ober

273310ace08dc5fd781e0f30c638485a8b61022b

diff --git a/.jerboa/security.json b/.jerboa/security.json
index 83ff5f1..6fde48f 100644
--- a/.jerboa/security.json
+++ b/.jerboa/security.json
@@ -18,10 +18,22 @@
     "process": { "shellInterpolation": "deny" },
     "network": { "requireTimeouts": true },
     "eval": { "stringEval": "deny", "bareRead": "deny", "allowReadEval": false },
-    "secrets": { "scanGeneratedCorpora": true, "forbidTrainingSecrets": true }
+    "secrets": { "scanGeneratedCorpora": true, "forbidTrainingSecrets": true },
+    "releaseEvidence": {
+      "sbom": "source-config-training-manifests",
+      "reproducibility": "source-and-manifest-only",
+      "modelArtifacts": "blocked-private-split"
+    },
+    "targetEvidence": {
+      "splitProvenanceProof": "fail-closed",
+      "maxProofBytes": 65536,
+      "unsafeProofMaterial": "reject-before-copy"
+    }
   },
   "required_gates": [
     "make verify",
+    "make sbom",
+    "make reproducibility-report",
     "make release-evidence"
   ],
   "public_release_blockers": [
diff --git a/Makefile b/Makefile
index 4e7a63d..13d5740 100644
--- a/Makefile
+++ b/Makefile
@@ -3,8 +3,10 @@ SHELL := /bin/sh
 PYTHON ?= python3
 EVIDENCE_DIR ?= dist/release-evidence
 PROVENANCE_DIR ?= dist/provenance
+SBOM_DIR ?= dist/sbom
+REPRO_DIR ?= dist/reproducibility
 
-.PHONY: security test provenance-evidence verify release-evidence clean
+.PHONY: security test provenance-evidence sbom reproducibility-report verify release-evidence clean
 
 security:
 	sh scripts/security-check.sh
@@ -19,14 +21,27 @@ provenance-evidence:
 	JLORA_REQUIRE_SPLIT_PROVENANCE_PROOF="$(JLORA_REQUIRE_SPLIT_PROVENANCE_PROOF)" \
 	sh scripts/provenance-evidence.sh
 
-verify: security test provenance-evidence
+sbom:
+	JLORA_SBOM_DIR="$(SBOM_DIR)" \
+	PYTHON="$(PYTHON)" \
+	sh scripts/sbom.sh
+
+reproducibility-report:
+	JLORA_REPRO_DIR="$(REPRO_DIR)" \
+	sh scripts/reproducibility-report.sh
+
+verify: security test provenance-evidence sbom reproducibility-report
+	grep -q '^status=match$$' "$(REPRO_DIR)/report.txt"
 
 release-evidence:
-	rm -rf $(EVIDENCE_DIR)
+	rm -rf $(EVIDENCE_DIR) $(PROVENANCE_DIR) $(SBOM_DIR) $(REPRO_DIR)
 	mkdir -p $(EVIDENCE_DIR)
 	$(MAKE) security >$(EVIDENCE_DIR)/security.log 2>&1
 	$(MAKE) test >$(EVIDENCE_DIR)/python-syntax.log 2>&1
 	$(MAKE) provenance-evidence >$(EVIDENCE_DIR)/provenance-evidence.log 2>&1
+	$(MAKE) sbom >$(EVIDENCE_DIR)/sbom.log 2>&1
+	$(MAKE) reproducibility-report >$(EVIDENCE_DIR)/reproducibility.log 2>&1
+	grep -q '^status=match$$' "$(REPRO_DIR)/report.txt"
 	git rev-parse HEAD >$(EVIDENCE_DIR)/git-commit.txt
 	git status --short --ignored >$(EVIDENCE_DIR)/git-status-ignored.txt
 	git diff --stat >$(EVIDENCE_DIR)/diff-stat.txt
@@ -36,7 +51,12 @@ release-evidence:
 	git ls-files --cached --others --exclude-standard | grep -E '(\.py|\.sh|\.yaml|\.toml|\.json|\.md|Modelfile|LICENSE|SECURITY.md)$$' | xargs shasum -a 256 >$(EVIDENCE_DIR)/source-sha256.txt
 	rm -rf $(EVIDENCE_DIR)/provenance
 	cp -R $(PROVENANCE_DIR) $(EVIDENCE_DIR)/provenance
-	{ $(PYTHON) --version; uname -a; } >$(EVIDENCE_DIR)/build-env.txt
+	rm -rf $(EVIDENCE_DIR)/sbom
+	cp -R $(SBOM_DIR) $(EVIDENCE_DIR)/sbom
+	rm -rf $(EVIDENCE_DIR)/reproducibility
+	cp -R $(REPRO_DIR) $(EVIDENCE_DIR)/reproducibility
+	{ echo "repo=jerboa-lora"; echo "platform=$$(uname -srm)"; $(PYTHON) --version; } >$(EVIDENCE_DIR)/build-env.txt
+	sh scripts/sanitize-evidence.sh "$(EVIDENCE_DIR)"
 
 clean:
 	rm -rf dist
diff --git a/docs/release-evidence.md b/docs/release-evidence.md
index 5dd7795..73a92a4 100644
--- a/docs/release-evidence.md
+++ b/docs/release-evidence.md
@@ -11,12 +11,18 @@ Evidence is written under `dist/release-evidence/` and records:
 - private security gate output;
 - tracked Python syntax-check output;
 - private model/data card and split-provenance evidence;
+- SBOM-style source, config, training-data, docs, and release-control
+  manifests;
+- source-only reproducibility evidence for the private split package;
 - git commit, status, and diff stat;
 - tracked plus untracked-but-not-ignored source/config hashes;
 - ignored artifact/state inventory.
 
 Passing evidence means this private workspace is controlled enough to continue
 internal work. It does not mean the repo, data, or weights are public-ready.
+The reproducibility report intentionally records
+`reproducibility_scope=source-and-manifest-only`; generated model weights remain
+blocked private artifacts until a reviewed split package exists.
 The provenance evidence is expected to record
 `status=blocked-split-review-required` unless a separate reviewed split proof is
 attached. A required proof is supplied with:
@@ -37,5 +43,7 @@ Required proof markers are:
 - `weight_publication_status=reviewed-or-blocked`
 - `sensitive_artifact_policy=no-tokens-private-endpoints-pod-state-or-private-training-records`
 
-Missing, incomplete, or sensitive-looking required proof fails closed as
-`status=blocked-split-provenance-proof`.
+Missing, incomplete, oversized, host-private, or sensitive-looking required
+proof fails closed as `status=blocked-split-provenance-proof`. The default
+proof-size limit is 65,536 bytes and can be lowered with
+`JLORA_SPLIT_PROVENANCE_PROOF_MAX_BYTES`.
diff --git a/scripts/provenance-evidence.sh b/scripts/provenance-evidence.sh
index 5786393..63a4713 100755
--- a/scripts/provenance-evidence.sh
+++ b/scripts/provenance-evidence.sh
@@ -7,10 +7,27 @@ cd "$ROOT"
 OUT=${JLORA_PROVENANCE_DIR:-${PROVENANCE_DIR:-dist/provenance}}
 PROOF_FILE=${JLORA_SPLIT_PROVENANCE_PROOF_FILE:-}
 REQUIRE_PROOF=${JLORA_REQUIRE_SPLIT_PROVENANCE_PROOF:-0}
+MAX_PROOF_BYTES=${JLORA_SPLIT_PROVENANCE_PROOF_MAX_BYTES:-65536}
+SANITIZER=${JLORA_EVIDENCE_SANITIZER:-scripts/sanitize-evidence.sh}
 
 rm -rf "$OUT"
 mkdir -p "$OUT"
 
+if [ -x "$SANITIZER" ]; then
+  trap '"$SANITIZER" "$OUT"' EXIT
+fi
+
+case "$MAX_PROOF_BYTES" in
+  ''|*[!0-9]*)
+    echo "provenance-evidence: JLORA_SPLIT_PROVENANCE_PROOF_MAX_BYTES must be a positive integer" >&2
+    exit 1
+    ;;
+esac
+if [ "$MAX_PROOF_BYTES" -lt 1 ]; then
+  echo "provenance-evidence: JLORA_SPLIT_PROVENANCE_PROOF_MAX_BYTES must be a positive integer" >&2
+  exit 1
+fi
+
 sha256_file() {
   if command -v sha256sum >/dev/null 2>&1; then
     sha256sum "$1"
@@ -45,12 +62,21 @@ require_marker() {
 
 reject_sensitive_proof() {
   file=$1
+  proof_size=$(wc -c < "$file" | tr -d ' ')
+  if [ "$proof_size" -gt "$MAX_PROOF_BYTES" ]; then
+    echo "provenance-evidence: proof larger than JLORA_SPLIT_PROVENANCE_PROOF_MAX_BYTES" >&2
+    return 1
+  fi
   if grep -E -i \
     '(BEGIN (RSA|OPENSSH|EC|DSA|PRIVATE) KEY|github_pat_|ghp_|sk-(ant-api03|proj|svcacct)-|AKIA[0-9A-Z]{16}|runpod[_-]?(api|token|key)|together[_-]?(api|token|key)|hf[_-]?(token|api)|endpoint[_-]?id|pod[_-]?id|ssh-rsa|ssh-ed25519)' \
     "$file" >/dev/null 2>&1; then
     echo "provenance-evidence: proof contains sensitive-looking material" >&2
     return 1
   fi
+  if grep -E -i '(/Users/|~/mine|\$\(HOME\)/mine|git@|users-MacBook-Pro|uname[[:space:]]+-a)' "$file" >/dev/null 2>&1; then
+    echo "provenance-evidence: proof contains private local path or host material" >&2
+    return 1
+  fi
 }
 
 record_proof() {
diff --git a/scripts/reproducibility-report.sh b/scripts/reproducibility-report.sh
new file mode 100755
index 0000000..3601c7a
--- /dev/null
+++ b/scripts/reproducibility-report.sh
@@ -0,0 +1,69 @@
+#!/bin/sh
+set -eu
+
+ROOT=${JLORA_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}
+OUT=${JLORA_REPRO_DIR:-${REPRO_DIR:-"$ROOT/dist/reproducibility"}}
+SANITIZER=${JLORA_EVIDENCE_SANITIZER:-scripts/sanitize-evidence.sh}
+
+rm -rf "$OUT"
+mkdir -p "$OUT"
+cd "$ROOT"
+
+sha256_file() {
+  if command -v sha256sum >/dev/null 2>&1; then
+    sha256sum "$1"
+  else
+    shasum -a 256 "$1"
+  fi
+}
+
+write_source_manifest() {
+  out=$1
+  git ls-files | grep -E '(\.py$|\.sh$|\.ya?ml$|\.toml$|\.json$|\.jsonl$|\.md$|^Makefile$|^LICENSE$|^Modelfile|^\.github/|^\.jerboa/security\.json$)' \
+    | LC_ALL=C sort | while IFS= read -r file; do
+      [ -f "$file" ] || continue
+      sha256_file "$file"
+    done > "$out"
+}
+
+git ls-files | grep -E '(\.safetensors$|\.gguf$|\.npz$|\.bin$|\.pt$|\.pth$|^gguf/|^runpod-pipeline-final/|^mlx_adapters/|^jerboa-mlx-|^jerboa-v[0-9].*bf16/)' \
+  | LC_ALL=C sort > "$OUT/tracked-model-artifacts.txt" || : > "$OUT/tracked-model-artifacts.txt"
+
+write_source_manifest "$OUT/source-manifest-first.sha256"
+write_source_manifest "$OUT/source-manifest-second.sha256"
+
+first_manifest=$(sha256_file "$OUT/source-manifest-first.sha256" | awk '{print $1}')
+second_manifest=$(sha256_file "$OUT/source-manifest-second.sha256" | awk '{print $1}')
+
+if [ "$first_manifest" = "$second_manifest" ]; then
+  source_manifest_status=match
+else
+  source_manifest_status=mismatch
+fi
+
+if [ -s "$OUT/tracked-model-artifacts.txt" ]; then
+  tracked_model_artifact_status=present-blocked
+else
+  tracked_model_artifact_status=absent
+fi
+
+case "$source_manifest_status:$tracked_model_artifact_status" in
+  match:absent) status=match ;;
+  *) status=blocked ;;
+esac
+
+{
+  echo "repo=jerboa-lora"
+  echo "lane=D/split"
+  echo "reproducibility_scope=source-and-manifest-only"
+  echo "source_manifest_status=$source_manifest_status"
+  echo "tracked_model_artifact_status=$tracked_model_artifact_status"
+  echo "model_artifact_publication_status=blocked-private-split"
+  echo "artifact_archive_status=hashes-only"
+  echo "scratch_build_logs_status=not-archived"
+  echo "status=$status"
+} | tee "$OUT/report.txt"
+
+if [ -x "$SANITIZER" ]; then
+  "$SANITIZER" "$OUT"
+fi
diff --git a/scripts/sanitize-evidence.sh b/scripts/sanitize-evidence.sh
new file mode 100755
index 0000000..f8cd980
--- /dev/null
+++ b/scripts/sanitize-evidence.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+set -eu
+
+if [ "$#" -eq 0 ]; then
+    set -- dist/release-evidence
+fi
+
+for evidence_dir in "$@"; do
+    if [ ! -d "$evidence_dir" ]; then
+        continue
+    fi
+
+    find "$evidence_dir" -type f -print | while IFS= read -r file; do
+        perl -0pi -e '
+          s#/Users/[^[:space:]\x22\x27()]+#<local-path>#g;
+          if (defined $ENV{HOME} && length $ENV{HOME}) {
+            my $home = quotemeta($ENV{HOME});
+            s#$home#<home>#g;
+          }
+          s#~/mine#<local-path>#g;
+          s#\$\((HOME)\)/mine#<local-path>#g;
+          s#users-MacBook-Pro#<host>#g;
+          s#git\@#git-at-#g;
+        ' "$file"
+    done
+done
diff --git a/scripts/sbom.sh b/scripts/sbom.sh
new file mode 100755
index 0000000..6f0ed9a
--- /dev/null
+++ b/scripts/sbom.sh
@@ -0,0 +1,98 @@
+#!/bin/sh
+set -eu
+
+ROOT=${JLORA_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}
+OUT=${JLORA_SBOM_DIR:-${SBOM_DIR:-"$ROOT/dist/sbom"}}
+PYTHON_BIN=${PYTHON:-python3}
+SANITIZER=${JLORA_EVIDENCE_SANITIZER:-scripts/sanitize-evidence.sh}
+
+rm -rf "$OUT"
+mkdir -p "$OUT"
+cd "$ROOT"
+
+if [ -x "$SANITIZER" ]; then
+  trap '"$SANITIZER" "$OUT"' EXIT
+fi
+
+sha256_file() {
+  if command -v sha256sum >/dev/null 2>&1; then
+    sha256sum "$1"
+  else
+    shasum -a 256 "$1"
+  fi
+}
+
+tracked_files() {
+  git ls-files
+}
+
+write_list() {
+  pattern=$1
+  out=$2
+  tracked_files | grep -E "$pattern" | LC_ALL=C sort > "$out" || : > "$out"
+}
+
+hash_list() {
+  list=$1
+  out=$2
+  : > "$out"
+  while IFS= read -r file; do
+    [ -n "$file" ] || continue
+    [ -f "$file" ] || continue
+    sha256_file "$file" >> "$out"
+  done < "$list"
+}
+
+write_list '\.py$' "$OUT/python-files.txt"
+write_list '\.sh$' "$OUT/shell-files.txt"
+write_list '(\.ya?ml$|\.toml$|\.json$|^Modelfile)' "$OUT/config-files.txt"
+write_list '\.jsonl$' "$OUT/training-jsonl-files.txt"
+write_list '(^README\.md$|^SECURITY\.md$|^TODO\.md$|^TRAINING_.*\.md$|^docs/.*\.md$|.*-findings\.md$)' "$OUT/doc-files.txt"
+write_list '(^Makefile$|^LICENSE$|^\.github/|^\.jerboa/security\.json$)' "$OUT/release-control-files.txt"
+write_list '(\.safetensors$|\.gguf$|\.npz$|\.bin$|\.pt$|\.pth$|^gguf/|^runpod-pipeline-final/|^mlx_adapters/|^jerboa-mlx-|^jerboa-v[0-9].*bf16/)' "$OUT/tracked-model-artifacts.txt"
+
+hash_list "$OUT/python-files.txt" "$OUT/python.sha256"
+hash_list "$OUT/shell-files.txt" "$OUT/shell.sha256"
+hash_list "$OUT/config-files.txt" "$OUT/config.sha256"
+hash_list "$OUT/training-jsonl-files.txt" "$OUT/training-jsonl.sha256"
+hash_list "$OUT/doc-files.txt" "$OUT/docs.sha256"
+hash_list "$OUT/release-control-files.txt" "$OUT/release-control.sha256"
+
+if [ -f .jerboa/security.json ]; then
+  sha256_file .jerboa/security.json > "$OUT/jerboa-security.sha256"
+else
+  : > "$OUT/jerboa-security.sha256"
+fi
+
+{
+  echo "repo=jerboa-lora"
+  echo "lane=D/split"
+  echo "generated_at_utc=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+  echo "git_head=$(git rev-parse HEAD 2>/dev/null || true)"
+  echo "platform=$(uname -srm)"
+  echo "python=$($PYTHON_BIN --version 2>&1 || true)"
+  if [ -f requirements.txt ] || [ -f pyproject.toml ] || [ -f setup.py ]; then
+    echo "python_dependency_manifest_status=present"
+  else
+    echo "python_dependency_manifest_status=not-present"
+  fi
+  echo "training_jsonl_files=$(wc -l < "$OUT/training-jsonl-files.txt" | tr -d ' ')"
+  echo "config_files=$(wc -l < "$OUT/config-files.txt" | tr -d ' ')"
+  echo "python_files=$(wc -l < "$OUT/python-files.txt" | tr -d ' ')"
+  echo "tracked_model_artifacts=$(wc -l < "$OUT/tracked-model-artifacts.txt" | tr -d ' ')"
+  echo "model_artifact_publication_status=blocked-private-split"
+  echo "source_split_status=blocked-review-required"
+  echo "sbom_status=present"
+} > "$OUT/build-environment.txt"
+
+{
+  echo "repo=jerboa-lora"
+  echo "publication_status=private-only"
+  echo "direct_publication_status=blocked"
+  echo "sbom_scope=source-config-training-manifests"
+  echo "model_weights_status=not-release-artifacts"
+  echo "split_provenance_proof=fail-closed"
+  echo "target_evidence_status=see-provenance"
+} > "$OUT/dependency-posture.txt"
+
+echo "status=generated"
diff --git a/scripts/security-check.sh b/scripts/security-check.sh
index bb44c04..3fb3550 100755
--- a/scripts/security-check.sh
+++ b/scripts/security-check.sh
@@ -16,6 +16,9 @@ test -f docs/model-card.md || fail "missing docs/model-card.md"
 test -f docs/dataset-card.md || fail "missing docs/dataset-card.md"
 test -f docs/release-evidence.md || fail "missing docs/release-evidence.md"
 test -f scripts/provenance-evidence.sh || fail "missing scripts/provenance-evidence.sh"
+test -f scripts/sanitize-evidence.sh || fail "missing scripts/sanitize-evidence.sh"
+test -f scripts/sbom.sh || fail "missing scripts/sbom.sh"
+test -f scripts/reproducibility-report.sh || fail "missing scripts/reproducibility-report.sh"
 
 grep -q 'D/split' SECURITY.md || fail "SECURITY.md must mark this repo D/split"
 grep -q 'not publishable as-is' SECURITY.md || fail "SECURITY.md must block direct publication"
@@ -24,6 +27,9 @@ grep -q 'private D/split draft' docs/model-card.md || fail "model card must rema
 grep -q 'private D/split draft' docs/dataset-card.md || fail "dataset card must remain private D/split"
 grep -q 'blocked-split-provenance-proof' scripts/provenance-evidence.sh || fail "provenance evidence must fail closed on required proof"
 grep -q 'split_provenance_proof_status' scripts/provenance-evidence.sh || fail "provenance evidence must record proof status"
+grep -q 'reproducibility_scope=source-and-manifest-only' scripts/reproducibility-report.sh || fail "reproducibility report must be source-only for private split repo"
+grep -q 'sbom_status=present' scripts/sbom.sh || fail "SBOM script must record status"
+grep -q 'JLORA_SPLIT_PROVENANCE_PROOF_MAX_BYTES' scripts/provenance-evidence.sh || fail "provenance proof must have a size limit"
 test ! -f PUBLIC_RELEASE_ALLOWED || fail "PUBLIC_RELEASE_ALLOWED must not exist in private D/split repo"
 
 for path in \