Harden code-agent release evidence privacy

ober

1e56788cbd5a89f8cc901d6e517c3ee23972ec57

diff --git a/.jerboa/security.json b/.jerboa/security.json
index 86700fc..d7ba750 100644
--- a/.jerboa/security.json
+++ b/.jerboa/security.json
@@ -12,6 +12,8 @@
       "docs/release-evidence.md"
     ],
     "localVerification": [
+      "make security",
+      "make verify",
       "make test",
       "make audit",
       "make release-evidence"
@@ -20,7 +22,8 @@
       "runtimeProof": "marker-validated-fail-closed",
       "fuzzSanitizerProof": "marker-validated-fail-closed",
       "cleanHostProof": "marker-validated-fail-closed"
-    }
+    },
+    "evidencePrivacy": "sanitized-host-neutral-hash-only-reproducibility"
   },
   "sensitiveSurfaces": [
     "filesystem writes",
diff --git a/Makefile b/Makefile
index e6bb9ad..2329006 100644
--- a/Makefile
+++ b/Makefile
@@ -53,7 +53,7 @@ else
 JCODE_OS_LIBS := -lm -ldl -lpthread -luuid -lncurses -lstdc++
 endif
 
-.PHONY: all help ensure-jerboa-tools build gen run test audit security-audit sbom target-evidence reproducibility-report release-evidence test-providers local-eval clean repl binary install tui-shim run-tui native-rs linux linux-check linux-amd64 linux-arm64 jcode-linux-amd64 jcode-linux-arm64 test-linux test-linux-amd64 freebsd freebsd-amd64 jcode-freebsd-amd64 purge-stale android android-clean vendor-deps vendor-clean
+.PHONY: all help ensure-jerboa-tools build gen run test security audit security-audit verify sbom target-evidence reproducibility-report release-evidence test-providers local-eval clean repl binary install tui-shim run-tui native-rs linux linux-check linux-amd64 linux-arm64 jcode-linux-amd64 jcode-linux-arm64 test-linux test-linux-amd64 freebsd freebsd-amd64 jcode-freebsd-amd64 purge-stale android android-clean vendor-deps vendor-clean
 
 all: help
 
@@ -236,6 +236,8 @@ test: build
 	DYLD_LIBRARY_PATH=$(LDPATH) LD_LIBRARY_PATH=$(LDPATH) \
 	$(JEXEC) test/run.ss
 
+security: security-audit
+
 audit: security-audit
 
 security-audit:
@@ -258,6 +260,8 @@ reproducibility-report:
 	JERBUILD="$(JERBUILD)" JSQLITE_DIR="$(JSQLITE_DIR)" JSQLITE_LIBDIR="$(JSQLITE_LIBDIR)" \
 	  MAKE="$(MAKE)" scripts/reproducibility-report.sh "$(REPRO_DIR)"
 
+verify: test audit sbom reproducibility-report target-evidence
+
 release-evidence:
 	rm -rf "$(RELEASE_EVIDENCE_DIR)"
 	mkdir -p "$(RELEASE_EVIDENCE_DIR)"
@@ -273,9 +277,9 @@ release-evidence:
 	git rev-parse HEAD > "$(RELEASE_EVIDENCE_DIR)/git-commit.txt" 2>/dev/null || true
 	git status --short > "$(RELEASE_EVIDENCE_DIR)/git-status.txt" 2>/dev/null || true
 	git diff --stat > "$(RELEASE_EVIDENCE_DIR)/diff-stat.txt" 2>/dev/null || true
-	uname -a > "$(RELEASE_EVIDENCE_DIR)/build-environment.txt"
-	"$(JERBUILD)" --version >> "$(RELEASE_EVIDENCE_DIR)/build-environment.txt"
-	"$(JERBUILD)" --jerboa-home >> "$(RELEASE_EVIDENCE_DIR)/build-environment.txt"
+	{ echo "platform=$$(uname -srm)"; printf 'jerbuild_version='; "$(JERBUILD)" --version 2>/dev/null || true; \
+	  if "$(JERBUILD)" --jerboa-home >/dev/null 2>&1; then echo "jerboa_home_status=present"; else echo "jerboa_home_status=missing"; fi; \
+	} > "$(RELEASE_EVIDENCE_DIR)/build-environment.txt"
 	(cc --version || cc -v) > "$(RELEASE_EVIDENCE_DIR)/cc-version.txt" 2>&1 || true
 	(rustc --version && cargo --version) > "$(RELEASE_EVIDENCE_DIR)/rust-version.txt" 2>&1 || true
 	./jcode --version > "$(RELEASE_EVIDENCE_DIR)/binary-version.txt" 2>&1
@@ -286,6 +290,7 @@ release-evidence:
 	grep -q '^sbom_status=present$$' "$(RELEASE_EVIDENCE_DIR)/sbom/manifest.txt"
 	grep -q '^status=match$$' "$(RELEASE_EVIDENCE_DIR)/reproducibility/result.txt"
 	grep -Eq '^(status=local-status-recorded|status=target-proof-recorded-review-required)$$' "$(RELEASE_EVIDENCE_DIR)/target-evidence/status.txt"
+	sh scripts/sanitize-evidence.sh "$(RELEASE_EVIDENCE_DIR)"
 
 # Live smoke test — one round-trip per provider with a configured key.
 # Providers without a key are SKIPped, not failed. Excluded from `test`
diff --git a/docs/release-evidence.md b/docs/release-evidence.md
index a1b7e01..f5d31a0 100644
--- a/docs/release-evidence.md
+++ b/docs/release-evidence.md
@@ -10,16 +10,18 @@ The release bundle includes:
 - `sbom/`, with component, dependency, toolchain, source, release-input,
   native-crate, termbox2, websearch, jsqlite, and artifact hashes,
 - `reproducibility/`, comparing two clean project binary builds and retaining
-  diagnostic intermediate WPO/FASL manifests,
+  hash-only diagnostic WPO/FASL manifests,
 - `./jcode --version` binary-smoke output,
 - `target-evidence/`, with explicit default blocked statuses for target-only
   sandbox/MCP/LSP/remote, fuzz/sanitizer, and clean-host reproducibility gates,
 - git commit, status, and diff summary,
-- compiler, Rust, Cargo, Jerboa toolchain, and host metadata,
+- compiler, Rust, Cargo, Jerboa toolchain, and host-neutral platform metadata,
 - final binary SHA-256 and release-input SHA-256 manifests.
 
 The reproducibility report uses a fixed Jerbuild binary object directory for
-each measured pass and keeps those generated inputs in the evidence directory.
+each measured pass and keeps only hashes, status files, and diffs in the
+evidence directory. Raw build logs, raw binaries, object directories, and smoke
+stdout/stderr are not archived.
 By default it compares two clean project builds after the native Rust target
 cache has been warmed. Set `JCODE_REPRO_NATIVE_CLEAN=1` for a stricter
 clean-native diagnostic run; that mode is tracked separately because the
@@ -33,6 +35,8 @@ It requires these fields in `reproducibility/result.txt`:
 - `native_artifact_status=match`
 - `source_manifest_status=match`
 - `binary_smoke_status=pass`
+- `artifact_archive_status=hashes-only`
+- `scratch_build_logs_status=not-archived`
 - `status=match`
 
 `intermediate_input_status` is diagnostic. It may expose nondeterminism in
@@ -46,6 +50,12 @@ target proof statuses set to `not-run` and target-only gates set to
 invalid proof file, or setting the corresponding required flag without proof,
 fails closed with `status=blocked-target-proof`.
 
+Proof files are rejected before copy if they are empty, larger than
+`JCODE_TARGET_PROOF_MAX_BYTES` (default 65536), contain host-private material
+such as `/Users/`, `~/mine`, `git@`, or `uname -a`, or contain high-confidence
+provider/API-token-shaped material. Rejections are recorded as `incomplete`,
+`too-large`, `private-material`, or `sensitive-material`.
+
 Runtime target proof is supplied with `JCODE_TARGET_RUNTIME_PROOF_FILE`. Set
 `JCODE_REQUIRE_TARGET_RUNTIME_PROOF=1` to require it. The proof file must
 contain these exact marker lines:
@@ -74,11 +84,16 @@ contain these exact marker lines:
 - `binary_status=match`
 - `source_manifest_status=match`
 
-Accepted proof files are copied into `target-evidence/` and accompanied by
-SHA-256 sidecar files. Accepted proof still records
+Accepted proof files are copied into `target-evidence/`, sanitized, and
+accompanied by SHA-256 sidecar files. Accepted proof still records
 `target-proof-recorded-review-required` because the bundle is evidence for a
 review decision, not an automatic production approval.
 
+`scripts/sanitize-evidence.sh` is run over release, SBOM, reproducibility, and
+target-evidence output. It redacts local home paths, `~/mine` references, the
+local workstation hostname marker, and raw `git@` URL prefixes from copied
+evidence.
+
 The SBOM manifest must contain `sbom_status=present`. It also records whether
 the vendored native Rust crate, termbox2 shim source, vendored websearch source,
 jsqlite dependency source, and target-evidence cross-reference were present
diff --git a/scripts/reproducibility-report.sh b/scripts/reproducibility-report.sh
index 33e563c..3556bd5 100755
--- a/scripts/reproducibility-report.sh
+++ b/scripts/reproducibility-report.sh
@@ -8,6 +8,53 @@ clean_target=${JCODE_REPRO_CLEAN_TARGET:-clean}
 native_clean=${JCODE_REPRO_NATIVE_CLEAN:-0}
 tmp_parent=${JCODE_REPRO_TMPDIR:-/tmp}
 tmp_dir=$tmp_parent/jcode-repro-$$
+sanitizer=${JCODE_EVIDENCE_SANITIZER:-scripts/sanitize-evidence.sh}
+
+publish_report() {
+  rm -rf "$out"
+  mkdir -p "$out"
+
+  for f in \
+    result.txt \
+    progress.log \
+    source-before.sha256 \
+    source-after.sha256 \
+    binary-byte-diff-sample.txt \
+    generated-inputs.diff \
+    intermediate-inputs.diff \
+    generated-libs.diff \
+    native-artifacts.diff \
+    source-before-after.diff \
+    source-builds.diff
+  do
+    if [ -f "$tmp_dir/$f" ]; then
+      cp "$tmp_dir/$f" "$out/$f"
+    fi
+  done
+
+  for pass in stabilization first second; do
+    if [ -d "$tmp_dir/$pass" ]; then
+      mkdir -p "$out/$pass"
+      for f in \
+        binary.sha256 \
+        generated-libs.sha256 \
+        native-artifacts.sha256 \
+        generated-inputs.sha256 \
+        intermediate-inputs.sha256 \
+        source-sha256.txt \
+        binary-smoke.status
+      do
+        if [ -f "$tmp_dir/$pass/$f" ]; then
+          cp "$tmp_dir/$pass/$f" "$out/$pass/$f"
+        fi
+      done
+    fi
+  done
+
+  if [ -x "$sanitizer" ]; then
+    "$sanitizer" "$out"
+  fi
+}
 
 cleanup() {
   status=$?
@@ -15,9 +62,7 @@ cleanup() {
     if [ ! -f "$tmp_dir/result.txt" ]; then
       printf 'status=failed-before-result\n' > "$tmp_dir/result.txt"
     fi
-    rm -rf "$out"
-    mkdir -p "$out"
-    cp -R "$tmp_dir"/. "$out"/
+    publish_report
   fi
   rm -rf "$tmp_dir"
   exit "$status"
@@ -303,12 +348,12 @@ fi
   printf 'warmup_build_used=1\n'
   printf 'stabilization_build_used=1\n'
   printf 'measurement_mode=two-clean-project-builds-native-clean=%s\n' "$native_clean"
+  printf 'artifact_archive_status=hashes-only\n'
+  printf 'scratch_build_logs_status=not-archived\n'
   printf 'status=%s\n' "$status"
 } > "$tmp_dir/result.txt"
 
-rm -rf "$out"
-mkdir -p "$out"
-cp -R "$tmp_dir"/. "$out"/
+publish_report
 
 cat "$out/result.txt"
 test "$status" = match
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
index 32ab78a..5a9c687 100755
--- a/scripts/sbom.sh
+++ b/scripts/sbom.sh
@@ -9,9 +9,13 @@ websearch_dir=${WEBSEARCH_DIR:-vendor/jerboa-websearch}
 termbox_dir=${TERMBOX_DIR:-vendor/termbox2}
 jsqlite_dir=${JSQLITE_DIR:-$HOME/mine/jsqlite}
 jsqlite_libdir=${JSQLITE_LIBDIR:-$jsqlite_dir/src}
+sanitizer=${JCODE_EVIDENCE_SANITIZER:-scripts/sanitize-evidence.sh}
 
 rm -rf "$out"
 mkdir -p "$out"
+if [ -x "$sanitizer" ]; then
+  trap '"$sanitizer" "$out"' EXIT
+fi
 
 now=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
 git_commit=$(git rev-parse HEAD 2>/dev/null || printf 'unknown')
@@ -27,13 +31,17 @@ status_file() {
 hash_tree() {
   root=$1
   dest=$2
+  label=$3
   if [ -d "$root" ]; then
-    find "$root" \( -path '*/.git' -o -path '*/target' -o -path '*/target/*' -o -path '*/.jerboa' -o -path '*/.jerboa/*' -o -path '*/bin' -o -path '*/bin/*' \) -prune -o \
-      -type f -print | LC_ALL=C sort | while IFS= read -r path; do
-        shasum -a 256 "$path"
-      done > "$dest"
+    (
+      cd "$root"
+      find . \( -path './.git' -o -path './.git/*' -o -path './target' -o -path './target/*' -o -path './.jerboa' -o -path './.jerboa/*' -o -path './bin' -o -path './bin/*' \) -prune -o \
+        -type f -print | LC_ALL=C sort | while IFS= read -r path; do
+          shasum -a 256 "$path"
+        done
+    ) > "$dest"
   else
-    printf 'missing=%s\n' "$root" > "$dest"
+    printf 'missing=%s\n' "$label" > "$dest"
   fi
 }
 
@@ -122,12 +130,14 @@ binary_status=$(status_file "$bin_name")
 } > "$out/components.txt"
 
 {
-  printf 'uname='
-  uname -a
+  printf 'platform=%s\n' "$(uname -srm)"
   printf 'jerbuild_version='
   "$jerbuild" --version
-  printf 'jerboa_home='
-  "$jerbuild" --jerboa-home
+  if "$jerbuild" --jerboa-home >/dev/null 2>&1; then
+    printf 'jerboa_home_status=present\n'
+  else
+    printf 'jerboa_home_status=missing\n'
+  fi
   if command -v cc >/dev/null 2>&1; then
     printf 'cc_version='
     cc --version 2>/dev/null | sed -n '1p' || cc -v 2>&1 | sed -n '1p'
@@ -149,10 +159,10 @@ binary_status=$(status_file "$bin_name")
 } > "$out/toolchain.txt"
 
 project_source_manifest > "$out/source-sha256.txt"
-hash_tree "$native_dir" "$out/native-crate-source.sha256"
-hash_tree "$websearch_dir/src" "$out/websearch-source.sha256"
-hash_tree "$termbox_dir" "$out/termbox2-source.sha256"
-hash_tree "$jsqlite_libdir" "$out/jsqlite-source.sha256"
+hash_tree "$native_dir" "$out/native-crate-source.sha256" native-crate
+hash_tree "$websearch_dir/src" "$out/websearch-source.sha256" websearch-source
+hash_tree "$termbox_dir" "$out/termbox2-source.sha256" termbox2-source
+hash_tree "$jsqlite_libdir" "$out/jsqlite-source.sha256" jsqlite-source
 
 {
   for f in Makefile .jerbuild .jerboa/security.json SECURITY.md README.md LICENSE main.ss main-binary.ss; do
diff --git a/scripts/target-evidence.sh b/scripts/target-evidence.sh
index ad24f1a..f2f0956 100755
--- a/scripts/target-evidence.sh
+++ b/scripts/target-evidence.sh
@@ -8,9 +8,25 @@ clean_host_proof=${JCODE_TARGET_CLEAN_HOST_PROOF_FILE:-}
 require_runtime=${JCODE_REQUIRE_TARGET_RUNTIME_PROOF:-0}
 require_fuzz=${JCODE_REQUIRE_TARGET_FUZZ_PROOF:-0}
 require_clean_host=${JCODE_REQUIRE_TARGET_CLEAN_HOST_PROOF:-0}
+max_proof_bytes=${JCODE_TARGET_PROOF_MAX_BYTES:-65536}
+sanitizer=${JCODE_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 "JCODE_TARGET_PROOF_MAX_BYTES must be a positive integer" >&2
+    exit 1
+    ;;
+esac
+if [ "$max_proof_bytes" -lt 1 ]; then
+  echo "JCODE_TARGET_PROOF_MAX_BYTES must be a positive integer" >&2
+  exit 1
+fi
 
 now=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
 git_commit=$(git rev-parse HEAD 2>/dev/null || printf 'unknown')
@@ -40,6 +56,48 @@ has_marker() {
   grep -qx "$2" "$1"
 }
 
+proof_contains() {
+  pattern=$1
+  file=$2
+  if command -v rg >/dev/null 2>&1; then
+    rg -q -I -e "$pattern" "$file" 2>/dev/null
+  else
+    grep -E -q "$pattern" "$file" 2>/dev/null
+  fi
+}
+
+reject_proof() {
+  proof_name=$1
+  proof_status=$2
+  case "$proof_name" in
+    runtime) target_runtime_proof_status=$proof_status ;;
+    fuzz) target_fuzz_proof_status=$proof_status ;;
+    clean-host) target_clean_host_proof_status=$proof_status ;;
+  esac
+  fail=1
+}
+
+validate_proof_material() {
+  proof=$1
+  proof_name=$2
+  size=$(wc -c < "$proof" | tr -d ' ')
+  if [ "$size" -gt "$max_proof_bytes" ]; then
+    reject_proof "$proof_name" too-large
+    return 1
+  fi
+
+  secret_pattern='(BEGIN (RSA|OPENSSH|EC|DSA|PRIVATE) KEY|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-(ant-api03|proj|svcacct)-[A-Za-z0-9_-]{30,}|AKIA[0-9A-Z]{16}|xox[abprs]-[A-Za-z0-9-]{20,}|(OPENAI|ANTHROPIC|GITHUB|AWS|PROVIDER|MCP|LSP)[A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD)[[:space:]]*[:=])'
+  private_pattern='(/Users/|~/mine|\$\(HOME\)/mine|git@|users-MacBook-Pro|uname[[:space:]]+-a)'
+  if proof_contains "$secret_pattern" "$proof"; then
+    reject_proof "$proof_name" sensitive-material
+    return 1
+  fi
+  if proof_contains "$private_pattern" "$proof"; then
+    reject_proof "$proof_name" private-material
+    return 1
+  fi
+}
+
 copy_proof() {
   src=$1
   dest=$2
@@ -54,6 +112,8 @@ if [ -n "$runtime_proof" ]; then
   elif [ ! -s "$runtime_proof" ]; then
     target_runtime_proof_status=incomplete
     fail=1
+  elif ! validate_proof_material "$runtime_proof" runtime; then
+    :
   elif has_marker "$runtime_proof" 'sandbox_runtime_status=target-evidence-recorded' &&
        has_marker "$runtime_proof" 'mcp_lsp_remote_status=target-evidence-recorded' &&
        has_marker "$runtime_proof" 'provider_cli_policy_status=reviewed' &&
@@ -81,6 +141,8 @@ if [ -n "$fuzz_proof" ]; then
   elif [ ! -s "$fuzz_proof" ]; then
     target_fuzz_proof_status=incomplete
     fail=1
+  elif ! validate_proof_material "$fuzz_proof" fuzz; then
+    :
   elif has_marker "$fuzz_proof" 'sandbox_fuzz_status=release-host-sustained-recorded' &&
        has_marker "$fuzz_proof" 'mcp_lsp_fuzz_status=release-host-sustained-recorded' &&
        has_marker "$fuzz_proof" 'remote_protocol_fuzz_status=release-host-sustained-recorded' &&
@@ -108,6 +170,8 @@ if [ -n "$clean_host_proof" ]; then
   elif [ ! -s "$clean_host_proof" ]; then
     target_clean_host_proof_status=incomplete
     fail=1
+  elif ! validate_proof_material "$clean_host_proof" clean-host; then
+    :
   elif has_marker "$clean_host_proof" 'clean_host_reproducibility_status=target-evidence-recorded' &&
        has_marker "$clean_host_proof" 'native_clean_wpo_status=target-evidence-recorded' &&
        has_marker "$clean_host_proof" 'binary_status=match' &&
diff --git a/tools/security-audit.sh b/tools/security-audit.sh
index 0f765e9..b135d97 100755
--- a/tools/security-audit.sh
+++ b/tools/security-audit.sh
@@ -36,6 +36,7 @@ for file in \
   scripts/sbom.sh \
   scripts/reproducibility-report.sh \
   scripts/target-evidence.sh \
+  scripts/sanitize-evidence.sh \
   .github/workflows/security-baseline.yml \
   .github/workflows/ci.yml
 do
@@ -56,7 +57,11 @@ for marker in \
   "JCODE_TARGET_RUNTIME_PROOF_FILE" \
   "JCODE_TARGET_FUZZ_PROOF_FILE" \
   "JCODE_TARGET_CLEAN_HOST_PROOF_FILE" \
+  "JCODE_TARGET_PROOF_MAX_BYTES" \
   "blocked-target-proof" \
+  "private-material" \
+  "sensitive-material" \
+  "too-large" \
   "sandbox_runtime_status=target-evidence-recorded" \
   "mcp_lsp_remote_status=target-evidence-recorded" \
   "provider_cli_policy_status=reviewed" \