diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04e716fa..d28d6343 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -337,6 +337,17 @@ jobs: - name: Records name what they cover in scope; the older field stays with old records run: python scripts/check_scope_migration.py + publish_metadata: + name: no new personal metadata in published files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: "3.13" + - name: A file may not add a local path, address or metadata over the frozen baseline + run: python scripts/publish_metadata_gate.py + file_gate: name: 300-line gate, burn-down only shrinks runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 66713ed4..0e216490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ lists the changes in each release with the pull request that made them. ## Unreleased +Publish privacy +- `scripts/publish_metadata_gate.py` blocks a publish that adds personal metadata. The 1,370 findings already in the tree are frozen as counts in `project-docs/records/2026-11-04-publish-metadata-baseline.json`; a file that grows its count, a new file with a finding, or any hit from the machine name, the git global email or the untracked deny list fails. The baseline only shrinks. `publish_public.py` runs it on the staging tree before any push and takes `--deny-list`; CI runs it on the checkout. The export no longer copies `scripts/.publish-denylist.txt`, which it would have shipped. See `docs/features/publish-metadata.md`. + Datapipe - `harness.datapipe` takes one external data source from download to a shareable export with a receipt at each stage: a terms gate that needs a saved snapshot and a verbatim licence quote, `datapipe.record/1` rows with cell-level provenance, cleaning checks that flag and never delete, a revertible change log, and an export that blocks anything not cleared for redistribution. See `docs/DATAPIPE.md`. diff --git a/docs/features/publish-metadata.md b/docs/features/publish-metadata.md new file mode 100644 index 00000000..81b2e4e3 --- /dev/null +++ b/docs/features/publish-metadata.md @@ -0,0 +1,47 @@ +# Publish metadata: the check and the gate + +A published file can name its author in places nobody reads on screen: EXIF and +PNG text chunks, a PDF `/Author`, a docx creator, a local path in a test fixture, +the build machine's name, or a personal address in a fetch script's User-Agent. +Two scripts handle this before anything ships. + +## The check + +`scripts/check_publish_metadata.py PATH --mode report|fail|fix` reads every +tracked file and names each finding by category and label, never by value, so a +CI log does not republish it. `fix` strips image, PDF and Office metadata in +place and leaves text alone. Private patterns come from places that never ship: +the machine's own name, the git global `user.email`, and an untracked deny list +of regexes, one per line, from `--deny-list`, the `PUBLISH_DENYLIST` variable, or +the gitignored `scripts/.publish-denylist.txt`. + +## The gate + +`scripts/publish_metadata_gate.py [ROOT]` blocks a publish that adds metadata. +The public tree carried 1,370 findings in 214 files when the gate was frozen on +2026-11-04, nearly all workspace paths in test fixtures and scripts. Those counts +live in `project-docs/records/2026-11-04-publish-metadata-baseline.json` as path, +category and count, with no matched value. The gate fails when: + +- a file's count for a category grows past its frozen count; +- a file outside the baseline has any finding; +- any private pattern matches (machine name, git global email, deny list). The + baseline is written from the generic patterns only, so these always block. + +The baseline only shrinks. `--write-baseline` refuses a larger count unless +`--allow-growth` is passed, and a cleaned file drops out of it. + +Where it runs: + +- `scripts/publish_public.py` runs it over the staging tree after + `publish_lint --strict` and before any push. `--deny-list FILE` passes a deny + list through. The export never copies `scripts/.publish-denylist.txt`. +- CI runs it over the checkout in the `publish_metadata` job, with the generic + patterns only, since the runner holds no deny list. + +## Limits + +The text patterns are regexes over lines. A path split across two lines, or an +identifier spelled in a form no pattern names, passes. Git author and timezone +findings stay warnings: they describe history, which a noreply address and +`TZ=UTC` fix going forward. diff --git a/project-docs/records/2026-11-04-publish-metadata-baseline.json b/project-docs/records/2026-11-04-publish-metadata-baseline.json new file mode 100644 index 00000000..aef0b74a --- /dev/null +++ b/project-docs/records/2026-11-04-publish-metadata-baseline.json @@ -0,0 +1,649 @@ +{ + "schema": "flywheel.publish-metadata-baseline/1", + "patterns": "generic only (check_publish_metadata GENERIC)", + "total": 1370, + "files": { + "artifacts/agent_recovery_benchmark/20260708_230923/forum_benchmark_cases.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_230923/loop/cache/e7d325c9cd42556a.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_230923/report.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_231022/forum_benchmark_cases.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_231022/loop/cache/e7d325c9cd42556a.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_231022/report.json": { + "local-path": 5 + }, + "artifacts/agent_recovery_benchmark/20260708_231022/report.md": { + "local-path": 2 + }, + "artifacts/agent_recovery_benchmark/20260708_231654/forum_benchmark_cases.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_231654/loop/cache/e7d325c9cd42556a.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_231654/report.json": { + "local-path": 5 + }, + "artifacts/agent_recovery_benchmark/20260708_231654/report.md": { + "local-path": 2 + }, + "artifacts/agent_recovery_benchmark/20260708_232208/forum_benchmark_cases.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_232208/loop/cache/e7d325c9cd42556a.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_232208/report.json": { + "local-path": 5 + }, + "artifacts/agent_recovery_benchmark/20260708_232208/report.md": { + "local-path": 2 + }, + "artifacts/agent_recovery_benchmark/20260708_232512/forum_benchmark_cases.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_232512/loop/cache/e7d325c9cd42556a.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_232512/report.json": { + "local-path": 5 + }, + "artifacts/agent_recovery_benchmark/20260708_232512/report.md": { + "local-path": 2 + }, + "artifacts/agent_recovery_benchmark/20260708_232931/forum_benchmark_cases.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_232931/loop/cache/e7d325c9cd42556a.json": { + "local-path": 1 + }, + "artifacts/agent_recovery_benchmark/20260708_232931/report.json": { + "local-path": 5 + }, + "artifacts/agent_recovery_benchmark/20260708_232931/report.md": { + "local-path": 2 + }, + "artifacts/backend_recovery_matrix_latest/20260708_231804/backend_recovery_matrix.json": { + "local-path": 1 + }, + "artifacts/build_local_exe.stderr.log": { + "local-path": 68 + }, + "artifacts/build_local_exe.stdout.log": { + "local-path": 3 + }, + "artifacts/closeout/FW-2026-08-02-CLOSEOUT/task-2-governance-primitives/010-red-focused/stdout.txt": { + "local-path": 6 + }, + "artifacts/source_mined_backend_matrix/20260709_000252/source_mined_backend_matrix.json": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix/20260709_000252/source_mined_backend_matrix.md": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_dry_full/20260709_000900/source_mined_backend_matrix.json": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_dry_full/20260709_000900/source_mined_backend_matrix.md": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_execution/20260709_001150/source_mined_backend_matrix.json": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_execution/20260709_001150/source_mined_backend_matrix.md": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_execution/20260709_001747/source_mined_backend_matrix.json": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_execution/20260709_001747/source_mined_backend_matrix.md": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_local_full/20260709_000954/source_mined_backend_matrix.json": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_local_full/20260709_000954/source_mined_backend_matrix.md": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_user/20260709_004606/source_mined_backend_matrix.json": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_user/20260709_004606/source_mined_backend_matrix.md": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_user/latest_run/20260709_014106/source_mined_backend_matrix.json": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_user/latest_run/20260709_014106/source_mined_backend_matrix.md": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_user/latest_run/20260709_020905/source_mined_backend_matrix.json": { + "local-path": 1 + }, + "artifacts/source_mined_backend_matrix_user/latest_run/20260709_020905/source_mined_backend_matrix.md": { + "local-path": 1 + }, + "benchmarks/agentic-task-set-adapter-v1.json": { + "local-path": 1 + }, + "benchmarks/agentic-task-set-implementation-plan-v1.json": { + "local-path": 16 + }, + "benchmarks/dry-run-preflight-command-deck-v1.json": { + "local-path": 16 + }, + "benchmarks/forum-ledger-deep-verify-scaling-v1.json": { + "local-path": 2 + }, + "configs/qcr/amplitude-closure-manifest-v1.json": { + "local-path": 1 + }, + "dataset/adversarial_pressure_sources_2026-07-09.json": { + "local-path": 5 + }, + "dataset/buildlang_buildc_sources_2026-07-09.json": { + "local-path": 9 + }, + "dataset/tokenize_pack.py": { + "local-path": 1 + }, + "demos/scripts/telos-showcase.json": { + "local-path": 3 + }, + "demos/telos-showcase/player.html": { + "local-path": 1 + }, + "demos/telos-showcase/transcript.json": { + "local-path": 3 + }, + "desktop/android/app/src/main/res/mipmap-hdpi/ic_launcher.png": { + "image-metadata": 1 + }, + "desktop/android/app/src/main/res/mipmap-mdpi/ic_launcher.png": { + "image-metadata": 1 + }, + "desktop/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png": { + "image-metadata": 1 + }, + "desktop/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png": { + "image-metadata": 1 + }, + "desktop/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png": { + "image-metadata": 1 + }, + "desktop/lib/views/agent_mode_pane.dart": { + "local-path": 1 + }, + "desktop/lib/views/workflows_view.dart": { + "local-path": 1 + }, + "desktop/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png": { + "image-metadata": 1 + }, + "desktop/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png": { + "image-metadata": 1 + }, + "desktop/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png": { + "image-metadata": 1 + }, + "desktop/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png": { + "image-metadata": 1 + }, + "desktop/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png": { + "image-metadata": 1 + }, + "desktop/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png": { + "image-metadata": 1 + }, + "desktop/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png": { + "image-metadata": 1 + }, + "desktop/test/action_route_grants_test.dart": { + "local-path": 11 + }, + "desktop/test/agent_view_native_session_test.dart": { + "local-path": 1 + }, + "desktop/test/chat_navigation_model_test.dart": { + "local-path": 1 + }, + "docs/research/2026-07-14-domain-lean-invention.md": { + "local-path": 4 + }, + "docs/superpowers/plans/2026-07-21-qcr-falsifiable-lanes.md": { + "local-path": 2 + }, + "docs/superpowers/plans/2026-07-21-qcr-unblock-wave2.md": { + "local-path": 25 + }, + "docs/superpowers/plans/2026-09-18-native-lane-bundle-program.md": { + "local-path": 4 + }, + "docs/superpowers/specs/2026-07-21-qcr-unblock-wave2-design.md": { + "local-path": 3 + }, + "harness/preaction/openshell_launch.py": { + "local-path": 1 + }, + "harness/superproject.py": { + "local-path": 2 + }, + "project-docs/INFRASTRUCTURE.md": { + "local-path": 21 + }, + "project-docs/STATE.md": { + "local-path": 2 + }, + "project-docs/SUPERAPP.md": { + "local-path": 30 + }, + "project-docs/architecture/CLOSED-LOOP-UPLIFT-ENGINE-2026-07-08.md": { + "local-path": 14 + }, + "project-docs/enterprise/MNEME-RELAY-PLEXUS-READINESS-2026-07-08.md": { + "local-path": 10 + }, + "project-docs/flagship/DEMOS.md": { + "local-path": 1 + }, + "project-docs/plans/2026-07-25-cc1-phase1a-verification-core.md": { + "local-path": 4 + }, + "project-docs/records/2026-07-25-canonical-master-delta.md": { + "local-path": 1 + }, + "project-docs/records/2026-07-25-cc1-panel-synthesis.md": { + "local-path": 2 + }, + "project-docs/records/2026-07-25-epistemic-infra-research.md": { + "local-path": 7 + }, + "project-docs/records/2026-07-25-practitioner-patterns.md": { + "local-path": 1 + }, + "project-docs/records/2026-07-26-dev-assessment.md": { + "local-path": 4 + }, + "project-docs/records/2026-07-26-oracle-family-survey.md": { + "local-path": 2 + }, + "project-docs/records/2026-08-14-desktop-phase-1-journey-persistence.md": { + "local-path": 3 + }, + "project-docs/records/2026-08-25-local-installer-0.3.10.md": { + "local-path": 1 + }, + "project-docs/records/AGENTIC-BENCHMARK-TASK-SET-2026-07-09.md": { + "local-path": 1 + }, + "project-docs/records/AGENTIC-TASK-SET-ADAPTER-PLAN-2026-07-09.md": { + "local-path": 4 + }, + "project-docs/records/AGENTIC-TASK-SET-IMPLEMENTATION-RUNBOOK-2026-07-09.md": { + "local-path": 19 + }, + "project-docs/records/ARCHITECTURE-2026-07-09-historical-target.md": { + "local-path": 16 + }, + "project-docs/records/BENCHMARK-METHODOLOGY-2026-07-09.md": { + "local-path": 4 + }, + "project-docs/records/CAPABILITY-CATALOG-2026-07-09.md": { + "local-path": 258 + }, + "project-docs/records/CLOSED-LOOP-INTEGRATION-SCHEMATIC-2026-07-09.md": { + "local-path": 11 + }, + "project-docs/records/CODEX-FLYWHEEL-BENCHMARK-COMPARISON-2026-07-09.md": { + "local-path": 1 + }, + "project-docs/records/CROSS-HARNESS-ADAPTER-CONTRACT-2026-07-09.md": { + "local-path": 5 + }, + "project-docs/records/DRY-RUN-PREFLIGHT-COMMAND-DECK-2026-07-09.md": { + "local-path": 11 + }, + "project-docs/records/EXPERIMENTAL-OUTCOME-2026-07-09.md": { + "local-path": 2 + }, + "project-docs/records/FORUM-LEDGER-DEEP-VERIFY-BENCHMARK-PLAN-2026-07-09.md": { + "local-path": 5 + }, + "project-docs/records/HANDOFF-2026-07-04-phase2-superseded.md": { + "local-path": 8 + }, + "project-docs/records/HANDOFF-NEXT-SESSION-superseded.md": { + "local-path": 3 + }, + "project-docs/records/HARNESS-ARCHITECTURE-ENDPOINT-REPORT-2026-07-09.md": { + "local-path": 16 + }, + "project-docs/records/LOCAL-MODEL-BENCHMARK-SUMMARY-2026-07-09.md": { + "local-path": 4 + }, + "project-docs/records/MNEME-READINESS-REPORT-2026-07-09.md": { + "local-path": 3 + }, + "project-docs/records/MODEL-NAMING-PUBLISHING-PLAN-14B-32B-2026-07-09.md": { + "local-path": 2 + }, + "project-docs/records/MONITOR-OUTCOME-RECONCILIATION-2026-09-23.md": { + "local-path": 1 + }, + "project-docs/records/MONITOR-OUTCOME-SOURCE-CHECK-2026-09-23.md": { + "local-path": 3 + }, + "project-docs/records/OBJECTIVE-EVIDENCE-MATRIX-2026-07-09.json": { + "local-path": 70 + }, + "project-docs/records/OBJECTIVE-EVIDENCE-MATRIX-2026-07-09.md": { + "local-path": 11 + }, + "project-docs/records/PLEXUS-READINESS-REPORT-2026-07-09.md": { + "local-path": 3 + }, + "project-docs/records/RELAY-READINESS-REPORT-2026-07-09.md": { + "local-path": 3 + }, + "project-docs/records/ROADMAP-STATUS-2026-07-09.md": { + "local-path": 24 + }, + "project-docs/records/TOOL-INTEGRATION-REPORT-2026-07-09.md": { + "local-path": 37 + }, + "project-docs/records/WORKSPACE-CONTEXT-MAP-2026-07-08.md": { + "local-path": 20 + }, + "project-docs/records/WORKSPACE-CONTEXT-MAP-2026-07-09.md": { + "local-path": 17 + }, + "project-docs/release/MODEL-RELEASE-READINESS-14B-32B-2026-07-08.md": { + "local-path": 1 + }, + "project-docs/reports/ADVERSARIAL-BENCHMARK-HARDENING-2026-07-09.md": { + "local-path": 7 + }, + "project-docs/reports/BENCHMARK-GRANULARITY-AND-ALIGNMENT-LANE-2026-07-09.md": { + "local-path": 5 + }, + "project-docs/reports/BENCHMARK-METHODOLOGY-AND-STATUS-2026-07-08.md": { + "local-path": 29 + }, + "project-docs/reports/BENCHMARK-METHODOLOGY-OUTSIDE-OBSERVER-2026-07-09.md": { + "local-path": 1 + }, + "project-docs/reports/CLAUDE-OPENCODE-ENDPOINT-STATUS-2026-07-09.md": { + "local-path": 5 + }, + "project-docs/reports/CODEX-FLYWHEEL-EXPERIMENT-OUTCOME-2026-07-08.md": { + "local-path": 1 + }, + "project-docs/reports/CODEX-FLYWHEEL-EXPERIMENTAL-OUTCOME-2026-07-09.md": { + "local-path": 1 + }, + "project-docs/reports/DISCORD-GATHER-INTEGRATION-2026-07-09.md": { + "local-path": 11 + }, + "project-docs/reports/ENTERPRISE-PARITY-GAPS-2026-07-09.md": { + "local-path": 12 + }, + "project-docs/reports/ENTERPRISE-PARITY-GAPS-SPINE-2026-07-09.md": { + "local-path": 8 + }, + "project-docs/reports/EXPERIMENTAL-OUTCOME-CODEX-FLYWHEEL-LOCAL-ENGINE-2026-07-09.md": { + "local-path": 37 + }, + "project-docs/reports/PUBSCAN-ZERO-DEPENDENCY-INTEGRATION-2026-07-09.md": { + "local-path": 16 + }, + "project-docs/reports/RESEARCH-CURATION-2026-07-09.md": { + "local-path": 2 + }, + "project-docs/reports/UNISONAI-SOURCE-MINED-BENCHMARK-2026-07-09.md": { + "local-path": 9 + }, + "project-docs/research/MODEL-CARD-MINING-2026-07-08.md": { + "local-path": 2 + }, + "project-docs/research/NOLABELJUSTME-CONTEXT-SHAPE-2026-07-08.md": { + "local-path": 1 + }, + "project-docs/research/PUBLIC-THINKER-CONTEXT-SHAPES-2026-07-09.md": { + "local-path": 1 + }, + "project-docs/research/REDDIT-FORUM-CONTEXT-SHAPES-2026-07-08.md": { + "local-path": 5 + }, + "project-docs/schematics/architecture.graph.json": { + "local-path": 6 + }, + "pyproject.toml": { + "personal-email": 1 + }, + "scripts/_launch_confirmatory.py": { + "local-path": 2 + }, + "scripts/build_local_harness_exes.py": { + "local-path": 13 + }, + "scripts/chain_phase01.sh": { + "local-path": 1 + }, + "scripts/check_public_instructions.py": { + "local-path": 1 + }, + "scripts/install_torch_parallel.sh": { + "local-path": 1 + }, + "scripts/model_card_benchmark_shapes.py": { + "local-path": 1 + }, + "scripts/retest_consensus_typed.py": { + "local-path": 1 + }, + "scripts/run_agentic_task_set_manifest.py": { + "local-path": 2 + }, + "scripts/run_benchmark_execution_matrix.py": { + "local-path": 1 + }, + "scripts/run_benchmark_profile_manifest.py": { + "local-path": 1 + }, + "scripts/run_buildc_receipt_bridge.py": { + "local-path": 1 + }, + "scripts/run_closed_loop_benchmark_seed.py": { + "local-path": 23 + }, + "scripts/run_codex_mcp_launch_contract.py": { + "local-path": 15 + }, + "scripts/run_context_inventory.py": { + "local-path": 7 + }, + "scripts/run_embodied_realtime_multimodal_plan.py": { + "local-path": 1 + }, + "scripts/run_enterprise_readiness_report.py": { + "local-path": 1 + }, + "scripts/run_gather_readiness.py": { + "local-path": 2 + }, + "scripts/run_harness_architecture_report.py": { + "local-path": 5 + }, + "scripts/run_harness_cli.py": { + "local-path": 20 + }, + "scripts/run_index_receipt.py": { + "local-path": 1 + }, + "scripts/run_mcp_tool_health_receipts.py": { + "local-path": 11 + }, + "scripts/run_model_card_claim_table.py": { + "local-path": 1 + }, + "scripts/run_model_release_readiness.py": { + "local-path": 1 + }, + "scripts/run_model_repo_stage.py": { + "local-path": 2 + }, + "scripts/run_package_ship_doctor.py": { + "local-path": 2 + }, + "scripts/run_phase2.cmd": { + "local-path": 1 + }, + "scripts/run_phase2.sh": { + "local-path": 1 + }, + "scripts/run_pubscan_resource_profiles.py": { + "local-path": 2 + }, + "scripts/run_runtime_activation_contract.py": { + "local-path": 2 + }, + "scripts/run_tool_integration_contract.py": { + "local-path": 2 + }, + "scripts/run_tool_operator_guide.py": { + "local-path": 1 + }, + "scripts/run_tool_readiness_receipts.py": { + "local-path": 1 + }, + "scripts/smoke_boot_integration.py": { + "local-path": 3 + }, + "scripts/test_typed_battery.py": { + "local-path": 1 + }, + "tests/enterprise_envs/test_service_desk_review_route.py": { + "local-path": 1 + }, + "tests/test_agentic_task_set_manifest.py": { + "local-path": 1 + }, + "tests/test_benchmark_execution_matrix.py": { + "local-path": 1 + }, + "tests/test_closed_loop_benchmark_seed.py": { + "local-path": 27 + }, + "tests/test_codex_mcp_launch_contract.py": { + "local-path": 3 + }, + "tests/test_compose_claim.py": { + "local-path": 1 + }, + "tests/test_confirmatory_walk_guard.py": { + "local-path": 2 + }, + "tests/test_cross_harness_manifest.py": { + "local-path": 1 + }, + "tests/test_enterprise_readiness_report.py": { + "local-path": 3 + }, + "tests/test_export_site_pilot_record.py": { + "local-path": 1 + }, + "tests/test_harness_architecture_report.py": { + "local-path": 1 + }, + "tests/test_harness_cli.py": { + "local-path": 45 + }, + "tests/test_incident_case.py": { + "local-path": 1 + }, + "tests/test_lane_runtime.py": { + "local-path": 3 + }, + "tests/test_lane_runtime_public_status.py": { + "local-path": 4 + }, + "tests/test_local_model_launch_readiness.py": { + "local-path": 1 + }, + "tests/test_outcome_bulletin.py": { + "local-path": 2 + }, + "tests/test_outcome_bulletin_cli.py": { + "local-path": 1 + }, + "tests/test_preaction_openshell_launch.py": { + "local-path": 1 + }, + "tests/test_private_artifact_fs_review.py": { + "local-path": 1 + }, + "tests/test_public_instructions.py": { + "local-path": 8 + }, + "tests/test_publish_lint.py": { + "local-path": 4 + }, + "tests/test_python_lane_payload_build.py": { + "local-path": 1 + }, + "tests/test_qcr_amplitude_manifest.py": { + "local-path": 2 + }, + "tests/test_rowan_handoff_cut.py": { + "local-path": 1 + }, + "tests/test_rowan_handoff_redaction.py": { + "local-path": 5 + }, + "tests/test_run_index_receipt.py": { + "local-path": 6 + }, + "tests/test_run_paths.py": { + "local-path": 1 + }, + "tests/test_sandbox_policy.py": { + "local-path": 2 + }, + "tests/test_session_tools.py": { + "local-path": 1 + }, + "tests/test_studio_body_engine_resolution.py": { + "local-path": 1 + }, + "tests/test_studio_runtime_packaging.py": { + "local-path": 3 + }, + "tests/test_studio_runtime_source_pins.py": { + "local-path": 1 + }, + "tests/test_tool_hardening_plan.py": { + "local-path": 4 + }, + "tests/test_tool_integration_contract.py": { + "local-path": 1 + }, + "tests/test_tool_operator_guide.py": { + "local-path": 1 + }, + "workflows/memory_substrate.js": { + "local-path": 1 + }, + "workflows/slingshot.js": { + "local-path": 1 + } + } +} diff --git a/scripts/publish_metadata_gate.py b/scripts/publish_metadata_gate.py new file mode 100644 index 00000000..ff374f01 --- /dev/null +++ b/scripts/publish_metadata_gate.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""publish_metadata_gate.py -- block a publish that adds personal metadata. + +check_publish_metadata.py finds personal metadata in a tree. The public tree +already carried 1,370 findings when this gate was frozen (2026-11-04), most of +them workspace paths in test fixtures and scripts. A gate that failed on all of +them would block every publish, so the existing findings are frozen in a +baseline of counts, and from then on: + + - a file whose count for a category grows fails the gate, + - a file not in the baseline with any finding fails the gate, + - a finding from a private pattern (the machine name, the git global email, + an entry in the untracked deny list) always fails the gate, since the + baseline is written from the generic patterns only. + +The baseline holds paths, categories and counts, never a matched value, and it +can only shrink: `--write-baseline` refuses a count larger than the frozen one +unless `--allow-growth` is passed. scripts/publish_public.py runs this gate on +its staging tree before any push; CI runs it on the checkout. + +Usage: + publish_metadata_gate.py ROOT [--baseline FILE] [--deny-list FILE] + [--all-files] [--write-baseline [--allow-growth]] +Exit 0 when clean, 1 when blocked. Stdlib only. +""" +from __future__ import annotations + +import argparse +import json +import sys +from collections import Counter +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import check_publish_metadata as C # noqa: E402 + +REPO = Path(__file__).resolve().parent.parent +BASELINE = REPO / "project-docs" / "records" / "2026-11-04-publish-metadata-baseline.json" +SCHEMA = "flywheel.publish-metadata-baseline/1" + + +def collect(root: Path, patterns, all_files: bool = False) -> dict[str, Counter]: + """{relative path: Counter(category)} for every file with a finding.""" + files, base = C.select_files(root.resolve(), all_files, None) + out: dict[str, Counter] = {} + for p in files: + found, _fixed = C.scan_file(p, patterns, fix=False) + if found: + out[p.relative_to(base).as_posix()] = Counter(cat for _l, cat, _d in found) + return out + + +def load_baseline(path: Path) -> dict[str, dict[str, int]]: + if not path.is_file(): + return {} + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schema") != SCHEMA: + raise SystemExit(f"publish_metadata_gate: unknown baseline schema in {path.name}") + return data["files"] + + +def excess(current: dict[str, Counter], baseline: dict[str, dict[str, int]]): + """(path, category, count now, count allowed) for every count over its baseline.""" + rows = [] + for rel in sorted(current): + allowed = baseline.get(rel, {}) + for cat, n in sorted(current[rel].items()): + if n > allowed.get(cat, 0): + rows.append((rel, cat, n, allowed.get(cat, 0))) + return rows + + +def write_baseline(root: Path, path: Path, allow_growth: bool, stream=sys.stdout) -> int: + current = collect(root, C.T.GENERIC) + old = load_baseline(path) + grown = excess(current, old) if old else [] + if grown and not allow_growth: + for rel, cat, n, was in grown: + print(f"{rel}: {cat}: {n} > frozen {was}", file=stream) + print("refused: the baseline only shrinks; pass --allow-growth to override", + file=stream) + return 1 + files = {rel: dict(sorted(c.items())) for rel, c in sorted(current.items())} + body = {"schema": SCHEMA, "patterns": "generic only (check_publish_metadata GENERIC)", + "total": sum(sum(c.values()) for c in files.values()), "files": files} + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes((json.dumps(body, indent=1) + "\n").encode("utf-8")) + print(f"baseline written: {len(files)} files, {body['total']} findings", file=stream) + return 0 + + +def gate(root: Path, baseline_path: Path = BASELINE, deny_list=None, all_files=False, + stream=sys.stdout) -> int: + """Return 0 when no file adds a finding over the baseline, else 1.""" + patterns = C.T.GENERIC + C.T.local_patterns(deny_list) + current = collect(root, patterns, all_files) + rows = excess(current, load_baseline(baseline_path)) + for rel, cat, n, was in rows: + print(f"{rel}: {cat}: {n} finding(s), baseline allows {was}", file=stream) + total = sum(sum(c.values()) for c in current.values()) + print(f"publish metadata gate: {len(current)} files with findings, {total} findings, " + f"{len(rows)} over the baseline", file=stream) + if rows: + print("BLOCKED: remove the new metadata, or run check_publish_metadata.py " + "--mode fix for image, PDF and Office files", file=stream) + return 1 if rows else 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("root", nargs="?", default=str(REPO)) + ap.add_argument("--baseline", default=str(BASELINE)) + ap.add_argument("--deny-list", help="untracked file of private regexes, one per line") + ap.add_argument("--all-files", action="store_true", help="read untracked files too") + ap.add_argument("--write-baseline", action="store_true") + ap.add_argument("--allow-growth", action="store_true") + a = ap.parse_args(argv) + if a.write_baseline: + return write_baseline(Path(a.root), Path(a.baseline), a.allow_growth) + return gate(Path(a.root), Path(a.baseline), a.deny_list, a.all_files) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/publish_public.py b/scripts/publish_public.py index dd75840f..f42747f5 100644 --- a/scripts/publish_public.py +++ b/scripts/publish_public.py @@ -9,7 +9,10 @@ 1. EXPORT: copy only the publishable dirs/files to a staging directory. 2. GATE: run harness/publish_lint.py --strict over the staging tree. Any LOCAL_PATHS or SECRETS error aborts the publish BEFORE anything reaches - the public remote. The verifier must be able to fail. + the public remote. The verifier must be able to fail. Then run + scripts/publish_metadata_gate.py over the same tree: a file that adds + personal metadata over the frozen baseline, or any hit from the machine + name, the git global email or the untracked deny list, aborts too. 3. PUBLISH: commit the staging tree to the public repo and push. The public repo has its own history (its SHAs differ from local-model's), so @@ -20,6 +23,7 @@ python scripts/publish_public.py --dry-run # export + lint, no push python scripts/publish_public.py --staging /tmp/x # specify staging dir python scripts/publish_public.py # export + lint + push + python scripts/publish_public.py --dry-run --deny-list ~/private-deny.txt The public remote must be configured: `git remote add public `. If absent, the script prints the command to add it and exits. @@ -64,6 +68,9 @@ "run_huggingface_release_stage.py", "package_local_harness_release.py", # private release bundler "build_local_harness_exes.py", + # the untracked deny list of private patterns for the metadata gate; it + # sits under scripts/ and is gitignored, but this copy walks the tree + ".publish-denylist.txt", } # The internal docs that live at local-model root and MUST NOT ship. They fail @@ -133,6 +140,13 @@ def lint_gate(staging: Path) -> int: return r.returncode +def metadata_gate(staging: Path, deny_list: str | None = None) -> int: + """Run publish_metadata_gate over the staging tree. Returns 0 if clean.""" + sys.path.insert(0, str(REPO / "scripts")) + import publish_metadata_gate + return publish_metadata_gate.gate(staging, deny_list=deny_list, all_files=True) + + def check_public_remote() -> bool: """True if the 'public' remote is configured.""" r = subprocess.run(["git", "remote"], capture_output=True, text=True, cwd=str(REPO)) @@ -174,6 +188,9 @@ def main(argv=None) -> int: help="export + lint only; do not push") ap.add_argument("-m", "--message", default="Publish Flywheel: the one platform", help="commit message for the public push") + ap.add_argument("--deny-list", default=None, + help="untracked file of private regexes for the metadata gate " + "(default: PUBLISH_DENYLIST, then scripts/.publish-denylist.txt)") a = ap.parse_args(argv) staging = Path(a.staging) @@ -189,6 +206,13 @@ def main(argv=None) -> int: return 1 print(" gate passed: 0 errors, 0 warnings") + print("\nRunning the publish metadata gate ...") + if metadata_gate(staging, a.deny_list) != 0: + print("\nPUBLISH GATE FAILED: the staging tree adds personal metadata.", + file=sys.stderr) + print("The staging dir is preserved for inspection:", staging, file=sys.stderr) + return 1 + if a.dry_run: print("\n--dry-run: stopping before push. Staging tree at", staging) return 0 diff --git a/tests/test_publish_metadata_gate.py b/tests/test_publish_metadata_gate.py new file mode 100644 index 00000000..1be5add0 --- /dev/null +++ b/tests/test_publish_metadata_gate.py @@ -0,0 +1,125 @@ +"""publish_metadata_gate: the frozen baseline, private patterns, and the +publish_public wiring. + +Leak strings are built at run time so this file does not itself carry them. +""" +import importlib.util +import io +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) + + +def _load(name): + spec = importlib.util.spec_from_file_location(name, ROOT / "scripts" / f"{name}.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +G = _load("publish_metadata_gate") +P = _load("publish_public") +DEV_PATH = "c:" + "/" + "dev" + "/" + "public/x" +SECRET = "orchid" + "-lantern-" + "4471" + + +def _tree(tmp_path): + root = tmp_path / "tree" + (root / "tests").mkdir(parents=True) + (root / "tests" / "fixture.py").write_text(f'P = "{DEV_PATH}"\n', encoding="utf-8") + (root / "README.md").write_text("clean text\n", encoding="utf-8") + return root + + +def _run_gate(root, baseline, deny=None): + out = io.StringIO() + code = G.gate(root, baseline, deny_list=deny, all_files=True, stream=out) + return code, out.getvalue() + + +def test_frozen_finding_passes_and_growth_blocks(tmp_path): + root, base = _tree(tmp_path), tmp_path / "base.json" + assert G.write_baseline(root, base, False, stream=io.StringIO()) == 0 + assert json.loads(base.read_text())["files"] == {"tests/fixture.py": {"local-path": 1}} + assert _run_gate(root, base)[0] == 0 + with (root / "tests" / "fixture.py").open("a", encoding="utf-8") as f: + f.write(f'Q = "{DEV_PATH}"\n') + code, text = _run_gate(root, base) + assert code == 1 + assert "tests/fixture.py: local-path: 2 finding(s), baseline allows 1" in text + + +def test_new_file_with_finding_blocks(tmp_path): + root, base = _tree(tmp_path), tmp_path / "base.json" + G.write_baseline(root, base, False, stream=io.StringIO()) + (root / "docs.md").write_text(f"see {DEV_PATH}\n", encoding="utf-8") + code, text = _run_gate(root, base) + assert code == 1 and "docs.md: local-path" in text + + +def test_deny_list_hit_always_blocks_and_value_is_never_printed(tmp_path): + root, base = _tree(tmp_path), tmp_path / "base.json" + (root / "README.md").write_text(f"token {SECRET}\n", encoding="utf-8") + G.write_baseline(root, base, False, stream=io.StringIO()) + deny = tmp_path / "deny.txt" + deny.write_text(SECRET + "\n", encoding="utf-8") + assert "deny-list" not in base.read_text() + code, text = _run_gate(root, base, str(deny)) + assert code == 1 and "README.md: deny-list" in text + assert SECRET not in text + assert _run_gate(root, base)[0] == 0 + + +def test_baseline_only_shrinks(tmp_path): + root, base = _tree(tmp_path), tmp_path / "base.json" + G.write_baseline(root, base, False, stream=io.StringIO()) + (root / "more.md").write_text(f"{DEV_PATH}\n", encoding="utf-8") + out = io.StringIO() + assert G.write_baseline(root, base, False, stream=out) == 1 + assert "refused" in out.getvalue() + assert G.write_baseline(root, base, True, stream=io.StringIO()) == 0 + (root / "more.md").unlink() + (root / "tests" / "fixture.py").write_text("clean\n", encoding="utf-8") + assert G.write_baseline(root, base, False, stream=io.StringIO()) == 0 + assert json.loads(base.read_text())["files"] == {} + + +def test_committed_baseline_is_counts_only(): + data = json.loads(G.BASELINE.read_text(encoding="utf-8")) + assert data["schema"] == G.SCHEMA + for rel, cats in data["files"].items(): + assert not Path(rel).is_absolute() and ":" not in rel + assert all(isinstance(n, int) and n > 0 for n in cats.values()) + assert "deny-list" not in cats and "machine-name" not in cats + + +def test_publish_export_never_copies_the_deny_list(tmp_path, monkeypatch): + repo = tmp_path / "repo" + (repo / "scripts").mkdir(parents=True) + (repo / "scripts" / "tool.py").write_text("x = 1\n", encoding="utf-8") + (repo / "scripts" / ".publish-denylist.txt").write_text(SECRET + "\n", encoding="utf-8") + monkeypatch.setattr(P, "REPO", repo) + copied = P.export_subset(tmp_path / "staging") + assert (tmp_path / "staging" / "scripts" / "tool.py").is_file() + assert not (tmp_path / "staging" / "scripts" / ".publish-denylist.txt").exists() + assert all(".publish-denylist" not in c for c in copied) + + +def test_publish_aborts_before_push_when_metadata_gate_fails(tmp_path, monkeypatch): + calls = [] + monkeypatch.setattr(P, "export_subset", lambda staging: []) + monkeypatch.setattr(P, "lint_gate", lambda staging: 0) + monkeypatch.setattr(P, "metadata_gate", lambda staging, deny: calls.append(deny) or 1) + monkeypatch.setattr(P, "publish", lambda *a: calls.append("pushed") or 0) + monkeypatch.setattr(P, "check_public_remote", lambda: True) + code = P.main(["--staging", str(tmp_path / "s"), "--deny-list", "d.txt"]) + assert code == 1 and calls == ["d.txt"] + + +def test_publish_metadata_gate_runs_on_a_real_staging_tree(tmp_path): + root = _tree(tmp_path) + (root / "README.md").write_text(f"{DEV_PATH}\n", encoding="utf-8") + assert P.metadata_gate(root) == 1