Harden code-agent release evidence privacy
ober
1e56788cbd5a89f8cc901d6e517c3ee23972ec57
--- 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", --- 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` --- 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 --- 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 new file mode 100755 --- /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 --- 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 --- 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' && --- 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" \