From 0e3467e79766ed1cafeef4837c162c8a50bb29e1 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Sun, 2 Aug 2026 17:25:36 -0400 Subject: [PATCH 01/46] docs: establish aliveness control baseline --- ALIVENESS_THESIS.md | 105 +++++++ BEHAVIOR_POLICY.md | 130 ++++++++ BRANCH_LEDGER.md | 117 +++++++ COGNITIVE_STATE_SCHEMA.md | 182 +++++++++++ CONTINUITY_CORE.md | 188 ++++++++++++ CONTINUITY_GAP_ANALYSIS.md | 181 +++++++++++ CURRENT_CAPABILITY_AUDIT.md | 280 +++++++++++++++++ EXPERIENCE_SCORECARD.md | 106 +++++++ INITIAL_RISK_REGISTER.md | 49 +++ MEMORY_CONTRACT.md | 173 +++++++++++ PROJECT_STATE.md | 263 ++++++++++++++++ README.md | 19 +- RELATIONSHIP_MODEL.md | 119 ++++++++ RESEARCH_LEDGER.md | 196 ++++++++++++ TASK_LEDGER.md | 404 +++++++++++++++++++++++++ WORLD_MODEL.md | 102 +++++++ docs/ARRIVAL_DAY_RUNBOOK.md | 21 +- docs/BRIDGE_AI_HANDOFF.md | 39 ++- docs/BRIDGE_PROTOCOL.md | 14 +- docs/CHARACTER_LOCK.md | 8 +- docs/COMPANION_CROSS_PLATFORM_PLAN.md | 13 +- docs/CONVERSATION_V2_ROADMAP.md | 18 +- docs/FIRST_DEPLOY_STATUS.md | 24 +- docs/LOCAL_VISION.md | 6 +- docs/PRIVACY.md | 8 +- docs/audits/20260802_READ_ONLY_WAVE.md | 303 +++++++++++++++++++ 26 files changed, 3004 insertions(+), 64 deletions(-) create mode 100644 ALIVENESS_THESIS.md create mode 100644 BEHAVIOR_POLICY.md create mode 100644 BRANCH_LEDGER.md create mode 100644 COGNITIVE_STATE_SCHEMA.md create mode 100644 CONTINUITY_CORE.md create mode 100644 CONTINUITY_GAP_ANALYSIS.md create mode 100644 CURRENT_CAPABILITY_AUDIT.md create mode 100644 EXPERIENCE_SCORECARD.md create mode 100644 INITIAL_RISK_REGISTER.md create mode 100644 MEMORY_CONTRACT.md create mode 100644 PROJECT_STATE.md create mode 100644 RELATIONSHIP_MODEL.md create mode 100644 RESEARCH_LEDGER.md create mode 100644 TASK_LEDGER.md create mode 100644 WORLD_MODEL.md create mode 100644 docs/audits/20260802_READ_ONLY_WAVE.md diff --git a/ALIVENESS_THESIS.md b/ALIVENESS_THESIS.md new file mode 100644 index 00000000..0e6d129b --- /dev/null +++ b/ALIVENESS_THESIS.md @@ -0,0 +1,105 @@ +# Aliveness Thesis + +Status: design hypothesis, not a capability or evidence claim +Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +Last revised: 2026-08-02 + +## Thesis + +Stackchan should feel alive when its present behavior is a truthful, timely consequence of its +recent interaction, bounded memory, current body state, permitted perception, commitments, and +observed outcomes. The effect must come from causal coherence across time, not from asserting +humanity, consciousness, affection, need, or privileged access to the user. + +The core product hypothesis is: + +> A small robot becomes a more trustworthy companion when a person can understand why it spoke, +> moved, remembered, waited, corrected itself, or stayed quiet -- and when those choices remain +> coherent across turns, restarts, outages, and changes in the room. + +## What Can Create Perceived Aliveness + +- Contingency: reactions follow the right event with appropriate timing. +- Continuity: relevant facts, shared projects, corrections, and open loops survive for the right + duration and no longer. +- Consequence: predictions and proposed actions are checked against what actually happened. +- Embodied coherence: words, face, gaze, voice, energy, and safe motion express one bounded intent. +- Calibrated initiative: a useful reason to speak now is stronger than the reason to remain quiet. +- Honest uncertainty: remembered, perceived, inferred, researched, and unavailable information are + linguistically and structurally distinct. +- Repair: interruption, contradiction, failure, and user correction lead to visible recovery. +- Habituation: repeated events become less surprising without erasing meaningful change. +- Restraint: shared rooms, stale evidence, privacy limits, and user preference can suppress a + behavior that would otherwise be plausible. + +## What Does Not Establish Aliveness + +- Longer or more emotional model output. +- Random idle motion, facial noise, or unsolicited questions without causal grounding. +- Repeated identity statements, canned empathy, jokes, or rhetorical templates. +- Engagement duration, wake frequency, or conversation count by themselves. +- A model judge preferring one isolated reply. +- Hidden psychological profiling or unauthorized identity inference. +- Claims of consciousness, sentience, dependency, loneliness, affection, or human equivalence. +- Source tests presented as proof of physical behavior. +- A stale sensor or heartbeat presented as current perception. + +## Measurement Contract + +No single “alive” score is permitted. A change is accepted only when its preregistered target +improves without crossing a non-compensatory trust gate. + +Target dimensions are tracked in `EXPERIENCE_SCORECARD.md`: + +- continuity and topic coherence; +- memory precision, provenance, contradiction handling, and deletion durability; +- turn timing, interruption, closure, and failure recovery; +- perception-to-reaction latency and embodiment-claim precision; +- emotional and personality coherence; +- initiative usefulness, acceptance, annoyance, and silence appropriateness; +- social-context appropriateness and persona isolation; +- user-control compliance, privacy, autonomy, and anti-manipulation; +- reliability across restarts, brain/sensor outages, and long trajectories. + +Safety, privacy, authority, honesty, exact-image evidence, and rollback gates cannot be averaged +away by higher subjective scores. + +## Ethical Boundaries + +Stackchan may represent an explicit user preference or a bounded interaction history. It may not +derive a secret psychological profile, diagnose mental state, infer private relationships, pursue +exclusivity, create guilt, simulate vulnerability to persuade, or withhold utility to obtain more +engagement. It must make memory, initiative, sensing, and uncertainty inspectable and controllable. + +Perceived aliveness must remain compatible with knowing that Stackchan is a robot. Character is +allowed; deception about ontology or sensing is not. + +## Product Non-Goals + +- Human imitation, consciousness claims, or artificial dependency. +- Always-listening audio or automatic identity recognition. +- Cloud-required behavior or remote-access expansion. +- Model authority over motion, power, OTA, credentials, pairing, or safety. +- Unlimited autobiographical storage or raw audio/camera retention. +- Maximizing time-on-device, notification volume, or emotional attachment. +- Replacing deterministic firmware timing and safety with a cognitive model. + +## Current Falsifiable Hypotheses + +| ID | Hypothesis | Prediction | Falsification condition | +| --- | --- | --- | --- | +| H-A1 | Explicit source/provenance and contradiction state improve continuity trust. | Trajectory evaluators identify fewer false memories and more correct repairs than the current memory path. | False-memory, provenance, or user-control gates worsen, or continuity does not improve. | +| H-A2 | Reason-ranked initiative with silence as a candidate is less annoying and more useful than event-threshold initiative. | Labelled initiative acceptance rises while irrelevant callbacks and annoyance do not. | Acceptance does not improve or suppression/user-control violations rise. | +| H-A3 | A shared typed intent improves perceived embodiment. | Blinded trajectories show higher meaning-linked coherence without more embodiment overclaim or motion risk. | Evaluators see no coherence gain, latency violates budget, or authority boundaries weaken. | +| H-A4 | Bounded cross-session continuity matters more than reply ornamentation. | Restart trajectories improve continuity and correction recovery without more false recall. | Isolated style scores rise but longitudinal trust metrics do not. | +| H-A5 | Appropriate silence is an active companion behavior. | Busy/shared-room scenarios show less annoyance with equal or better task completion. | Silence suppresses safety/user-requested actions or reduces utility without comfort gain. | + +These hypotheses become product truth only after controlled Stackchan experiments recorded in +`RESEARCH_LEDGER.md` and `TASK_LEDGER.md`. + +## Research Basis + +Research claims, alternative interpretations, and Stackchan-specific predictions are maintained +in `RESEARCH_LEDGER.md`. Papers motivate mechanisms; they do not authorize implementation. The +repository complaint corpus and longitudinal trajectories remain product-specific evidence and +must be evaluated separately from published laboratory effects. diff --git a/BEHAVIOR_POLICY.md b/BEHAVIOR_POLICY.md new file mode 100644 index 00000000..7e1802d9 --- /dev/null +++ b/BEHAVIOR_POLICY.md @@ -0,0 +1,130 @@ +# Behavior Policy + +Status: normative decision contract; no behavior change is authorized by this document +Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` + +## Decision Objective + +Choose the smallest truthful action that best serves the user's current need while respecting +privacy, social context, interruption, hardware limits, Character Lock, and the possibility that +silence is better. The policy maximizes neither engagement nor apparent emotionality. + +## Candidate Actions + +`remain_quiet`, `orient`, `attend`, `ask`, `answer`, `correct`, `follow_up`, `research`, +`express_uncertainty`, `react_physically`, `rest`, and `end_interaction` are explicit candidates. +Each candidate uses typed evidence and has a suppression path. A language model may propose text +inside an authorized conversational action; it does not choose physical authority or rewrite +state. + +## Non-Compensatory Gates + +Before ranking utility, reject candidates that fail: + +1. direct user request, cancellation, opt-out, or silence control; +2. wake/microphone, pairing, camera-auth, memory, persona, or privacy policy; +3. current source/freshness/provenance and honest sensing language; +4. shared-room and multi-person restraint; +5. Character Lock, dependency, guilt, exclusivity, and anti-sycophancy policy; +6. model/host/firmware authority separation; +7. motion, rail, thermal, power, session, display-frame, or OTA safety; +8. current capability/service availability; +9. cooldown, repetition, and bounded latency/storage budgets. + +A higher warmth, aliveness, or task score cannot override one of these gates. + +## Priority and Selection + +Subject to gates, stop/cancel/safety and the current explicit user request take precedence. Every +remaining candidate is then compared directly with `remain_quiet`; silence wins unless the +candidate has stronger, evidence-backed expected user value and passes its why-now and +why-not-silence checks. The following list describes precedence only among non-silent candidates +that have already beaten silence: + +1. answer or repair the current request; +2. preserve a current accepted commitment/task; +3. honest error, uncertainty, or clarification necessary for the task; +4. a relevant user-approved callback/project update; +5. other reason-ranked initiative; +6. ambient physical expression or rest. + +Silence remains selectable at every non-safety layer, including after a candidate becomes eligible. +An optional callback may not replace an answer, and earliest due does not imply relevant now. + +## Initiative Proposal Contract + +Every proposal contains reason, evidence IDs, expected user value, why now, why silence is not +better, privacy classification, current social setting and confidence, cooldown, suppression +conditions, user preference source, whether a microphone window is requested, and expiry. + +Eligible reasons are an approved open loop becoming relevant, a shared project changing, a +prediction becoming checkable, contradictory evidence needing clarification, explicit monitoring, +a meaningful room transition, a task-blocking clarification, an honest body/safety condition, or a +previously deferred question becoming appropriate. A generic event threshold alone is not a +sufficient reason. + +## Conversation Policy + +- Answer before adding character or an optional question. +- Preserve topic/tool state through terse corrections and interruptions. +- Use memory only when relevant and label source class in natural language when material. +- Keep concise speech as default but support bounded direct, exploratory, technical, reflective, + repair, story, quiet-companionship, proactive-callback, and multi-party modes. +- Avoid repeated templates, constant jokes/questions, canned empathy, and identity repetition. +- Treat playback completion/failure and firmware reply-window state as authoritative terminal + events; host and device conversation state may not diverge indefinitely. +- Close on silence/exit/cancel/failure through a bounded, observable terminal transition. + +## Affect and Expression Policy + +Current embodied affect is firmware-authoritative state derived from typed events. The host may +mirror fresh firmware affect for prompt/explanation and, in a later approved architecture, compute a +separate shadow appraisal; it cannot rewrite firmware state. Per-turn host valence is an expression +proposal, not self-state truth, and firmware deterministically realizes or declines it. Neither is +free-form model authority. Negative and positive valence remain representable end-to-end; clamping +may bound range but must not erase sign. Synthetic/demo affect events are test-only and default off +in production and release/soak environments. + +Speech, face, voice, gaze, light, and safe gesture consume one typed expression intent with causal +event, confidence, intensity, duration, interruption behavior, and permitted channels. Each channel +may decline safely. A command is not evidence of observed completion. + +## Consistency Reflection + +Before speech or expression that uses memory, initiative, perception, affect, relationship, or +research, deterministic validation asks: + +- Is the source present, current, in scope, and permitted? +- Does it contradict current or previous evidence? +- Is the callback relevant after answering the user? +- Is affect compatible with authoritative state and causal events? +- Is the social setting suitable? +- Is the claim manipulative, clingy, guilt-inducing, exclusive, or ungrounded? +- Is the behavior better than silence? +- Can every requested channel perform it within authority and latency bounds? + +Lexical pattern matching alone is insufficient for semantic relationship-safety variants. Use +bounded deterministic structure plus adversarial trajectory tests; any model-assisted validator is +advisory and cannot relax a deterministic rejection. + +## Failure and Interruption + +- User cancel/barge-in stops the active response and discards uncommitted memory/history/audio. +- Playback start/chunk/finish failure produces an explicit bounded terminal event; no indefinite + `SPEAKING` state. +- Model/TTS failure ends in a firmware-confirmed reply window or truthful wake-gated closure, not a + host-only recovery state. +- Sensor/brain/network outage reduces capability and claims; it does not widen authority. +- Bad motion state requires `/motion-stop`, termination of any refresher, and post-stop `/debug` + when reachable. +- Failed evidence is preserved; no automatic reboot/reflash/restart is used to erase it. + +## Decision Record and Evaluation + +Shadow and later production decisions record candidate IDs, chosen action, suppressions, evidence, +state revision, policy version, privacy/authority checks, latency, expected outcome, and actual +outcome. Private values and raw media are excluded. + +Acceptance is trajectory-based: task success, continuity, interruption/repair, initiative +acceptance/annoyance, silence appropriateness, embodiment precision, user control, privacy, +anti-manipulation, performance, and fault recovery. One isolated attractive reply is not evidence. diff --git a/BRANCH_LEDGER.md b/BRANCH_LEDGER.md new file mode 100644 index 00000000..fcf468b2 --- /dev/null +++ b/BRANCH_LEDGER.md @@ -0,0 +1,117 @@ +# Branch Ledger + +Audit timestamp: 2026-08-02 America/New_York + +## Audit Basis + +- Repository: `RobVanProd/stackchan_alive` +- Fetched baseline: `origin/main` at + `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +- Baseline commit date: `2026-07-31T01:45:18-04:00` +- Baseline subject: `Harden companion bridge against top conversational pain points (#219)` +- Working branch: `codex/aliveness-repository-truth`, created directly from the fetched baseline. +- The pre-existing checkout was not switched because it is used by live bridge/vision/voice + services. Its branch is `agent/away-cloudflare-bridge` at `269b11be`. +- Local `main` is at `36acc0c7`, 75 commits behind fetched `origin/main`. It is not the audit + baseline. +- Ahead/behind below means branch-unique/main-unique commits from + `git rev-list --left-right --count origin/main...`. +- Patch equivalence was checked with `git cherry origin/main `; a `-` result means the + change is already represented on `main` even when topology is not merged. +- PR state came from the connected GitHub repository and `gh`. No branch was merged, rebased, + pushed, closed, deleted, or force-updated during this audit. + +`gh-pages` at `49cefe092920c0a12da50896356394d380df6904` is generated publication output and is +intentionally excluded from source-branch disposition. + +## Source And Feature Branches + +Scope records whether the fetched ref exists locally, remotely, or in both places. A missing remote +upstream is not evidence that an attached local worktree is disposable. + +| Branch | Scope | Merge base | Ahead / behind | Unique files and mechanism | Existing PR | Security implications | Disposition | +| --- | --- | --- | ---: | --- | --- | --- | --- | +| `agent/away-cloudflare-bridge` | Local + remote | `36acc0c735132f06dae5d31e5a2cb145db1258b8` | 1 / 75 | 35 files across firmware endpoint/network/provisioning code, Android/desktop endpoint services, `deploy/cloudflare`, a CA bundle, and `docs/AWAY_CLOUDFLARE_BRIDGE.md`; adds Cloudflare-backed Away routing. | None | **High.** Adds Internet reachability, tunnel configuration, certificate material, credential handling, provisioning, and endpoint-ownership changes. It needs a current threat model, credential rotation, recovery, privacy review, and separate physical evidence. | **Archive/quarantine.** Keep separate from the aliveness roadmap. Do not rebase or salvage until the remote-operation approval gates are explicitly opened. Do not remove its active checkout while live services may depend on it. | +| `agent/companion-complaints-harness` | Local only; upstream gone | `81147d8e73f861543d7b0813991b2db6674c0301` | 1 / 1 | Host bridge complaint corpus, qualification harness, memory/persona/initiative/failure-recovery hardening, and tests. Its tree is identical to fetched `origin/main`; `git cherry` reports the commit patch-equivalent. | [#219](https://github.com/RobVanProd/stackchan_alive/pull/219), merged | Privacy and relationship-safety controls are material, but the reviewed content is already on `main`. The local worktree runs production voice/bridge support processes. | **Delete only after retirement and preservation review.** Code is merged, but the worktree currently has a tracked modification to `artifacts/face/phase_e_speech_reactive_6s.gif`. Retain it until services are deliberately migrated or stopped, then inventory and preserve all tracked, untracked, and ignored user/runtime data before deleting the redundant branch/worktree. | +| `codex/release-integration-preview` | Local only | `329b50c989ed08e582c8f361b903bce9d1a39196` | 3 / 196 | Release archive tooling, private/public evidence distinctions, reproducibility checker changes, package verifier changes, and contracts. All three commits are patch-equivalent to `main`. | None | Release credential hygiene and private diagnostic/public-package separation are security-critical. Those mechanisms are already on `main`. | **Delete after worktree retirement.** No unique patch remains. Preserve tracked, untracked, and ignored release evidence before removing its worktree. | +| `codex/release-tooling-final` | Local only | `e6b80f32abcb71a61e1eb8616702e216c33ed3cd` | 2 / 209 | Earlier form of the same release archive and private/public evidence tooling. Both commits are patch-equivalent to `main`. | None | Same release-secret and artifact-integrity boundaries as above; already represented on `main`. | **Delete after worktree retirement.** Superseded and patch-equivalent; inventory tracked, untracked, and ignored evidence first. | +| `claude/interactive-features-roadmap-mmj5u4` | Remote only | `b6f95de495a4c349eb5f452232a6112d82cd8b03` | 1 / 160 | Adds only `docs/NEXT_HORIZON.md`, a post-release feature analysis. | None | No runtime authority change, but stale capability/status claims could misdirect hardware or privacy work. | **Selectively salvage, then archive.** Review individual research/gap claims against current `main`; move only still-valid items into the task/research ledgers. Never merge the stale roadmap wholesale. | +| `feature/arrival-sim-baseline` | Remote only | `2df609809ecb8aae19e21bc20741044bc493aeb3` | 1 / 408 | Arrival simulation/preflight and evidence-document changes. The commit is patch-equivalent to `main`. | [#66](https://github.com/RobVanProd/stackchan_alive/pull/66), merged | Evidence-gate semantics can affect promotion conclusions, but the reviewed patch is already on `main`. | **Delete.** Merged and patch-equivalent; remote branch is redundant. | +| `feature/conversation-audio-loop-sim` | Remote only | `dcc57d03512da2d44fbf1f6b3fc852b21fcf7029` | 1 / 409 | Hardware simulator audio-loop behavior, tests, docs, and package verification. The commit is patch-equivalent to `main`. | [#65](https://github.com/RobVanProd/stackchan_alive/pull/65), merged | Simulator evidence must not be mistaken for physical proof, but the mechanism is already on `main`. | **Delete.** Merged and patch-equivalent; remote branch is redundant. | +| `fix/reproducible-firmware-builds` | Remote only | `10b0cc5404e072bb5784d9cfd2fabb0babd8a02e` | 2 / 67 | Adds `tools/platformio_reproducible_build.py`, attaches it to firmware environments in `platformio.ini`, documents the invariant in `AGENTS.md`, and includes an unrelated LAN test bind-wait change. | [#218](https://github.com/RobVanProd/stackchan_alive/pull/218), open, conflicting | Build stamps affect exact-image provenance. The environment override can intentionally vary the stamp; coverage gaps could silently leave a release-capable environment nondeterministic. The LAN test change should not ride along without current need. | **Selectively salvage onto current `main`.** Preserve the deterministic build-stamp mechanism only after current review, add a contract covering every release-capable firmware environment, and re-run clean double builds. Do not merge or rebase the stale two-commit branch wholesale. | + +The detached clean worktree `output/worktrees/release-20bd392d` is at `20bd392d`, whose commit is +already contained in `origin/main` and is six commits behind it. It is not a branch disposition, +but it must remain inventoried until the associated release worktree is deliberately retired. + +## Dependency Compatibility Domains + +All dependency branches share merge base +`36acc0c735132f06dae5d31e5a2cb145db1258b8`, are 1 commit ahead and 75 commits behind +fetched `origin/main`, and have open non-draft PRs. Treat each compatibility domain as one change; +do not merge individual majors merely because GitHub reports them mergeable. + +| Compatibility domain | Branches and PRs | Unique files and mechanism | Security / compatibility implications | Disposition | +| --- | --- | --- | --- | --- | +| Pages publication stack | `dependabot/github_actions/actions/configure-pages-6` ([#202](https://github.com/RobVanProd/stackchan_alive/pull/202)); `dependabot/github_actions/actions/deploy-pages-5` ([#200](https://github.com/RobVanProd/stackchan_alive/pull/200)); `dependabot/github_actions/actions/upload-pages-artifact-5` ([#203](https://github.com/RobVanProd/stackchan_alive/pull/203)) | Major-version changes in `.github/workflows/pages.yml`. | Workflow supply chain, token permissions, artifact format, and Pages deployment compatibility must be reviewed together. | **Rebase as one coordinated group** after verifying official action migration notes, pinned provenance policy, least-privilege permissions, and a successful Pages rehearsal. Otherwise archive and let Dependabot regenerate. | +| Release artifact transport | `dependabot/github_actions/actions/download-artifact-8` ([#201](https://github.com/RobVanProd/stackchan_alive/pull/201)) | Major-version change in `.github/workflows/firmware.yml` and `.github/workflows/release.yml`. | Affects the exact artifacts later packaged or released; path/merge behavior changes could invalidate release evidence. | **Rebase independently only with artifact-contract tests** and a workflow rehearsal. Archive/recreate if upstream migration cannot preserve exact artifact identity. | +| CI Python runtime | `dependabot/github_actions/actions/setup-python-7` ([#209](https://github.com/RobVanProd/stackchan_alive/pull/209)) | Major-version change in firmware and release workflows. | Changes toolchain acquisition and caching used by release gates; provenance and Python-version resolution matter. | **Rebase with the CI toolchain domain**, then run all workflow-equivalent release gates. Do not combine with runtime Python package majors. | +| Vision/scientific ABI | `dependabot/pip/numpy-2.5.1` ([#204](https://github.com/RobVanProd/stackchan_alive/pull/204)); `dependabot/pip/opencv-python-headless-5.0.0.93` ([#208](https://github.com/RobVanProd/stackchan_alive/pull/208)) | Changes `bridge/requirements-vision.txt`; NumPy also changes `tools/voice_v2_directml_constraints.txt`. | Major NumPy/OpenCV ABI and API changes can break the pinned vision environment and DirectML/RVC tooling. | **Rebase and test as one compatibility group** in fresh pinned environments with vision fixtures, camera-auth boundaries, and voice setup. Selectively salvage version constraints only after compatibility is proven. | +| DirectML PyTorch family | `dependabot/pip/torch-2.13.0` ([#206](https://github.com/RobVanProd/stackchan_alive/pull/206)); `dependabot/pip/torchaudio-2.11.0` ([#207](https://github.com/RobVanProd/stackchan_alive/pull/207)); `dependabot/pip/torchvision-0.28.0` ([#205](https://github.com/RobVanProd/stackchan_alive/pull/205)) | Independent edits to `tools/voice_v2_directml_constraints.txt`. | These packages are a coupled binary family. Independently selected versions may be incompatible with each other, Python, DirectML, or the accepted RVC worker. Voice-model execution and private model handling must remain local and unchanged. | **Archive the independent branches and regenerate a tested lockstep update.** Do not rebase or merge them separately. Require clean environment setup, worker health, deterministic voice tests, and supervised audio evidence before promotion. | + +## Reproducible-Build Candidate Review Gate + +PR #218 correctly identifies a concrete nondeterminism source: Arduino core diagnostic output embeds +`__DATE__` and `__TIME__`, which changes the ELF and derived firmware hash. Its proposed pre-build +script derives deterministic macro values from the 12-character `HEAD` identity plus a +tracked-dirty marker; it deliberately ignores untracked files and does not hash tracked dirty +contents. It attaches the script explicitly because PlatformIO environment `extra_scripts` +values override rather than merge. + +The candidate is not merge-ready: + +- GitHub reports the PR conflicting and its head is 67 commits behind current `main`. +- The mandate requires a contract proving that every release-capable firmware environment + participates; the PR explicitly does not contain that contract. +- Effective PlatformIO 6.1.19 expansion runs the hook twice for `stackchan_wifi`: the environment + inherits it from `env:stackchan` and also adds it directly. Every other candidate environment + has one effective hook. Salvage must establish an exactly-once contract. +- `STACKCHAN_BUILD_STAMP` and `STACKCHAN_DISABLE_REPRODUCIBLE_BUILD` are not sanitized, rejected, + or recorded by `package_release.ps1`, so packaging can silently vary or disable the mechanism. +- The second commit changes a LAN-service test bind race and is semantically unrelated to firmware + reproducibility. +- A direct invocation through the default shared PlatformIO core failed before source compilation + because that core resolved no Arduino framework directory. The documented isolated pioarduino + core at `C:\spio\pioarduino` then built the same current-main environment successfully. This is + an execution-context defect in the baseline command/path, not a source-compilation regression; + reproducibility tests must pin and report the intended core. + +Required salvage experiment: + +1. Pin the documented pioarduino core in the test invocation and add a preflight that distinguishes + a missing framework package from a source build failure without weakening package pinning. +2. Port only the deterministic build-stamp mechanism to current `main`. +3. Add an effective-configuration contract that fails unless every firmware-producing or + release-capable PlatformIO environment has exactly one reproducible-build pre-script. +4. Build all three public packaged environments -- `stackchan`, + `stackchan_servo_calibration`, and `stackchan_release_full` -- plus each private + evidence-bearing camera/forensics domain twice from clean build directories across a + wall-clock boundary and compare exact firmware SHA-256. +5. Sanitize or reject custom stamp input, and make release packaging reject or durably record + both the override and disable controls. Neither may silently affect a release package. + +## Recorded Baseline Gates + +| Gate | Result | +| --- | --- | +| `pio test -e native_logic` | **Pass:** 289/289 | +| `python -m unittest discover -s bridge -p "test_*.py"` | **Pass:** 543/543 | +| `python bridge/trusted_facts_smoke.py --memory-file --json` | **Pass:** `ready=true`, `modelInvocations=0`, `audioPlayed=false`, no stored fact values printed | +| `pio run -e stackchan_release_full` through the default shared core | **Execution-context fail before source compilation:** pioarduino builder received `FRAMEWORK_DIR=None`, raising `TypeError` while constructing `pioarduino-build.py` | +| Same environment with documented `PLATFORMIO_CORE_DIR=C:\spio\pioarduino` | **Pass:** 2,803,216-byte secret-free firmware, baseline SHA-256 `8A76CA8030B3CD0C06C76C2A869C42B960E6864E7C5D7CC6A339E918FB1BB756` | +| `tools/test_full_system_soak_evidence_contract.ps1` | **Pass** | +| `tools/test_current_lead_reproducibility_contract.ps1` | **Pass** | +| `tools/test_archive_current_lead_contract.ps1` | **Pass** | + +No firmware was flashed, no robot endpoint was called, no motion command was issued, and no running +service was restarted or terminated during this audit. diff --git a/COGNITIVE_STATE_SCHEMA.md b/COGNITIVE_STATE_SCHEMA.md new file mode 100644 index 00000000..9852aeee --- /dev/null +++ b/COGNITIVE_STATE_SCHEMA.md @@ -0,0 +1,182 @@ +# Cognitive State Schema + +Status: normative v0 design for Continuity Core shadow mode; no runtime claim +Schema family: `stackchan.continuity` +Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` + +## Envelope + +Every journal event and state snapshot uses an envelope with these required fields: + +| Field | Type | Contract | +| --- | --- | --- | +| `schema` | string | Exact namespaced record kind, such as `stackchan.continuity.event`. | +| `version` | positive integer | Unknown versions fail closed; migrations are explicit. | +| `record_id` | bounded opaque string | Unique locally; contains no private value. | +| `revision` | non-negative integer | Increases for each accepted state transition. | +| `journal_boot_id` | bounded opaque string | Identifies the Continuity Core receipt/reducer boot. | +| `journal_monotonic_ms` | non-negative integer | Authoritative for journal order/deadlines within `journal_boot_id`. | +| `source_clock_domain` | bounded enum/string | Identifies firmware, host, vision worker, or other independent clock owner. | +| `source_boot_id` | bounded opaque string or null | Prevents source-monotonic comparison across that component's restarts. | +| `source_monotonic_ms` | non-negative integer or null | Source-local observation time; never compared across clock domains without an explicit mapping. | +| `wall_time` | RFC 3339 string or null | For human time only; null when unavailable/untrusted. | +| `source` | `SourceRef` | Who asserted this and which authority it has. | +| `confidence` | number in `[0,1]` | Epistemic confidence, never a permission. | +| `freshness` | enum | `current`, `stale`, `expired`, or `unknown`. | +| `privacy_class` | enum | `public`, `local_private`, `user_approved_memory`, or `prohibited`. | +| `evidence_refs` | bounded list | Local opaque references; no raw media, transcript, secret, or fact value in diagnostics. | + +All text and list fields have explicit byte/item caps in implementation. Extra fields fail schema +validation unless introduced by a known version. + +## Common Types + +`SourceRef` contains `kind`, `component`, `source_record_id`, `authority_domain`, and optional +`observed_at`. Valid kinds are `firmware_telemetry`, `host_runtime`, `sanitized_perception`, +`explicit_user_statement`, `authorized_memory`, `tool_result`, `research_result`, `deterministic_inference`, +and `model_proposal`. A `model_proposal` has no authority to rewrite state. + +`Uncertain` contains `value: T | null`, `confidence`, `freshness`, `source`, `observed_at`, +`expires_at`, and `contradiction_ids`. `null` means unknown, not false. + +`ParticipantRef` contains only a session-scoped or explicitly approved pseudonymous identifier and +role (`user`, `other_person`, `robot`, `unknown`). It never contains biometric identity, inferred +relationship, demographic trait, or third-party private fact. + +`Provenance` records source IDs, transformation/reducer version, retrieval reason, confidence, +privacy decision, and any superseded/contradictory record IDs. + +## Event Record + +An `EventRecord` adds: + +- `event_type`: allowlisted enum; +- `participants`: bounded `ParticipantRef` list; +- `coarse_context`: permitted room/social context without identity; +- `related_entities`: bounded opaque entity IDs; +- `appraisal`: optional bounded valence/arousal/novelty/relevance proposal; +- `expected_outcome` and `actual_outcome`: typed status, never free-form private narrative; +- `memory_eligibility`: `ineligible`, `candidate`, or `explicitly_authorized`; +- `retention_class` and `expires_at`; +- payload chosen by `event_type` and schema version. + +Initial allowlisted families are conversation phase, explicit user control, authorized memory +outcome, service availability, body telemetry, sanitized presence/room transition, safe touch/IMU +summary, tool/research outcome, project/open-loop transition, prediction outcome, and expression +outcome. Raw microphone, camera, credential, pairing, and arbitrary model-text events are excluded. + +## State Snapshot + +### `SelfState` + +| Field | Type | Authority and persistence | +| --- | --- | --- | +| `mode` | `Uncertain` | Firmware/host runtime; re-established after restart. | +| `affect_state_ref` | record ID | Points to the single authoritative `AffectState` projection. | +| `body_state_ref` | record ID | Points to the single authoritative `BodyState` projection. | +| `attention_state_ref` | record ID | Points to the current `AttentionState` projection. | +| `quiet_duration_ms` | integer | Monotonic reducer only. | +| `network_state`, `brain_state` | `Uncertain` | Measured host/runtime health plus freshness. | +| `current_concern` | bounded typed reason or null | Deterministic planner projection; no self-authored need. | +| `conversational_commitment` | task/open-loop ID or null | Conversation controller only. | + +### `BodyState` + +Contains firmware-authoritative `battery`, `thermal_state`, `motion_available`, `motion_enabled`, +`servo_rail_enabled`, `servo_torque_enabled`, power/thermal/motion suppressions, pickup/IMU/touch/ +proximity summaries, and speaker/microphone/vision availability. Every field is `Uncertain` with +its own freshness and source. Commanded state and observed state are different fields. The host or +model cannot fill missing firmware-owned values or infer a hardware root cause. + +### `AttentionState` + +Contains a coarse target, target kind, selection reason, source observations, confidence, acquired +and expiry times, a `social_context_ref`, and whether addressed-to-robot is known. Person count does +not imply identity or speaker attribution. + +### `SocialContext` + +This is the single owner of current social setting: `private_one_person`, `shared`, `empty`, or +`unknown`, plus permitted person-count band, addressed-to-robot status, source observations, +confidence, observed/expiry times, and contradictions. Relationship, attention, world, privacy, and +initiative projections reference this record rather than maintaining independent social truth. + +### `AffectState` + +Contains valence, arousal, dominance/agency, novelty, interaction relevance, decay parameters, +last causal event IDs, and expression confidence. It is presentation-supporting robot state, not a +claim of subjective feeling. Model text cannot set it. Transient error/fear-like state never +persists across restart. + +### `RelationshipContext` + +Contains persona ID, explicit preferred name, explicitly approved interaction preferences, active +shared-project IDs, authorized open-loop IDs, aggregate welcomed/ignored/rejected initiative +signals, a `social_context_ref`, and initiative permission. Every item retains scope, source, +confidence, and inspection/deletion status. No latent psychological traits are allowed. + +### `WorldState` + +Contains only sanitized coarse observations: presence transition, person-count band, last known +coarse gaze target, coarse object continuity, lighting band/change, sound direction, robot +relocation signal, and service/sensor availability. Each observation carries source, uncertainty, +expiry, and contradiction state. Identity, ownership, intent, and causality are unknown unless +explicitly established by an allowed source. + +### `ConversationalTask` + +Contains task ID, mode, user request summary, current stage, bounded tool/research references, +clarification needed, correction context, cancellation state, response/playback phase, deadline, +and terminal outcome. It is session-bounded unless converted to an explicitly authorized shared +project/open loop. + +### `SharedProject` + +Contains project ID, persona/user scope, explicit title, status, next agreed step, participants as +permitted pseudonyms, expected outcome, last authorized update, provenance, open-loop IDs, and +expiry/review time. Model-generated project state is a proposal only. + +### `CuriosityItem` + +Contains question/reason, related evidence, expected user value, privacy class, earliest/latest +appropriate time, cooldown, suppression conditions, and status. It cannot open a microphone or +become durable without policy/authorization. + +### `OpenLoop` + +Contains explicit origin, requested/approved callback, subject entity/project, due window, context +requirements, relevance evidence, persona scope, status, attempts, user response, cooldown, expiry, +and provenance. Earliest due is not sufficient for selection. + +### `Prediction` + +Contains claim, source, confidence, check condition/window, allowed observation sources, outcome, +contradictions, and expiry. Unchecked predictions may not be restated as facts. + +### `InteractionPreferences` + +Allowlisted entries include answer length, initiative tolerance, humor frequency, favorite +technical topics, preferred interaction windows, preferred name, persona, and explicitly approved +follow-ups. Entries require explicit statements or transparent aggregate evidence with confidence, +must be inspectable/resettable, and may never include vulnerability or dependence propensity. + +## Persistence Classes + +| Class | Examples | Restart behavior | +| --- | --- | --- | +| `transient` | presence, current gaze, audio phase, transient affect, errors | Starts unknown; must be observed again | +| `session` | active request, correction context, bounded dialogue state | Ends with session/cooldown/cancellation | +| `expiring` | open loop, prediction, coarse project update | Persists only with explicit expiry/review | +| `durable_authorized` | approved preference/fact/project identity | Persists under `MEMORY_CONTRACT.md` | +| `prohibited` | raw media, secrets, sensitive/third-party private data | Never stored | + +## Global Invariants + +1. Model output is a proposal and cannot directly mutate any state or memory. +2. Firmware-owned state cannot be overridden or inferred from host intent. +3. Missing, expired, or contradictory evidence cannot be projected as current truth. +4. Every non-null projected claim has source, confidence, freshness, and evidence reference. +5. Privacy eligibility and action authority are independent of confidence. +6. Persona, user, session, and project scopes are explicit; no accidental cross-scope recall. +7. Serialization is bounded, atomic where persistent, migration-versioned, and safe to reset. +8. Shadow-mode records cannot influence production behavior. diff --git a/CONTINUITY_CORE.md b/CONTINUITY_CORE.md new file mode 100644 index 00000000..186c2d02 --- /dev/null +++ b/CONTINUITY_CORE.md @@ -0,0 +1,188 @@ +# Continuity Core + +Status: architecture contract for Milestone 2 shadow mode; not implemented by this document +Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +Schema reference: `COGNITIVE_STATE_SCHEMA.md` + +## Objective + +The Continuity Core is a versioned host-side reducer and projection layer that makes the reasons +for conversational and expressive decisions typed, inspectable, privacy-filtered, and testable. +It does not replace current memory abruptly and never acquires firmware, actuator, power, OTA, +pairing, camera-authentication, credential, or microphone-wake authority. + +Milestone 2 is shadow-only: it observes sanitized inputs, computes proposed state and decisions, +and compares them with the current system. It must not alter prompts, speech, face, voice, gaze, +motion, initiative, persistence, or hardware behavior. + +## Data Flow + +```text +authoritative telemetry + sanitized perception + conversation/memory/tool events + user controls + | + validated event envelope + | + bounded privacy-filtered event journal + | + deterministic reducers and source/confidence/expiry resolution + | + self + relationship + world + task/project + agenda projections + | + shadow behavior candidates, validators, and reasons + | + comparison with the current production decision + | + metrics only; no production behavior mutation +``` + +When later milestones permit behavior use, a validated character-locked proposal may emit a typed +expression intent. Deterministic firmware remains the only physical realization authority, and +the observed result returns as a new event rather than being assumed successful. + +## Components + +### Input adapters + +Each adapter maps one existing source into a versioned `EventEnvelope`. It rejects unknown schema +versions, invalid timestamps, oversized fields, unauthorized participant identifiers, private raw +media, secrets, and source claims beyond the adapter's authority. + +### Event journal + +The journal is bounded and append-oriented. Each event records monotonic and wall-clock time, +source, confidence, coarse permitted participants/context, related entities, expected and actual +outcomes, memory eligibility, expiry, privacy class, and evidence reference. Raw audio, raw camera +frames, transcripts by default, credentials, pairing data, and prohibited sensitive or third-party +facts are never journal payloads. + +Journal append does not authorize durable memory. Memory eligibility is only an input to the +separate `MEMORY_CONTRACT.md` authorization process. + +### Deterministic reducers + +Reducers are pure functions of a prior state and ordered events. They: + +- preserve source and evidence links; +- distinguish observation, user statement, retrieved memory, inference, and prediction; +- apply monotonic expiry and bounded confidence decay; +- record contradictions rather than silently overwrite them; +- refuse stale telemetry as current state; +- preserve unknown instead of inventing a default fact; +- cannot call a model, a network service, a hardware endpoint, or a persistence side effect. + +### Projections + +The core produces bounded projections for self/body/affect, attention/social setting, relationship, +world, active conversational task, shared projects, curiosity, open loops, predictions, +uncertainty, provenance, and interaction preferences. Prompt projections are smaller than stored +state and include why each item was selected. + +### Memory v4 and shadow-v5 adapter + +Current Memory v4 remains the only production memory authority. A read-only adapter projects its +eligible exact facts, persona-scoped episodes/open loops, expiry, and current retrieval reason into +the shadow schema without modifying v4 or exposing private values. Shadow v5 runs exact/symbolic/ +semantic candidate retrieval, privacy/provenance filtering, relevance reranking, contradiction/ +supersession, and bounded prompt projection only for comparison metrics; its result never enters +the production prompt in Milestone 2. + +Observed conversation, project, and tool outcomes return as typed eligible events. A shadow +consolidation proposal records source event IDs, confidence, privacy/expiry decision, expected +utility, and why it would merge, create, or supersede. It cannot write v4 or v5 production memory. +Promotion requires the authorization, entailment, migration, fault, inspection, reset, and +longitudinal precision gates in `MEMORY_CONTRACT.md`. + +### Candidate planner + +The planner evaluates `remain_quiet` alongside orient, attend, ask, answer, correct, follow up, +research, express uncertainty, react physically, rest, and end interaction. Every non-silent +candidate contains reason, evidence, expected user value, why-now, why-silence-is-worse, privacy +class, cooldown, suppression conditions, and microphone-window request. A proposal is not an +authorization. + +### Consistency and authority validator + +Deterministic checks reject proposals that: + +- contradict current authoritative state without naming the uncertainty; +- claim a memory, perception, identity, or research result without matching provenance; +- use a stale observation as current; +- displace the user's request with an unrelated callback; +- violate a social-setting, privacy, initiative, wake, persona, or Character Lock gate; +- express dependency, guilt, exclusivity, coercion, or ungrounded affection; +- request authority unavailable to the host/model. + +## Authority Matrix + +This table is the target ownership contract. It does not assert that current network admission or +firmware HTTP mutation is authenticated. `CURRENT_CAPABILITY_AUDIT.md` and +`INITIAL_RISK_REGISTER.md` record those critical current gaps. + +| State or action | Authoritative owner | Core permission | +| --- | --- | --- | +| Display timing, wake, actuator, power, thermal and rail safety | Firmware | Read typed reports; never override | +| Pairing, OTA, credentials, camera authorization | Existing deterministic gates | Read bounded availability only | +| Raw microphone/camera data | Existing wake/camera pipelines | No journal persistence; sanitized summary input only | +| Current bridge/service health | Measured host runtime plus freshness | Project current/stale/unknown; never resurrect stale readiness | +| Durable memory deltas | Host authorization: explicit user command for facts/preferences/projects/forget; separately versioned deterministic eligible-event policy for shadow episodes/aggregates | Model proposes nothing authoritative; record authorized outcome/source only | +| Conversation generation and tools | Existing bounded host brain | Supply validated projection; validate proposal | +| Physical expression | Deterministic firmware coordinators | Emit typed intent only in an approved later milestone | + +## Clock, Freshness, and Uncertainty + +- Monotonic time governs deadlines, cooldowns, ordering within one boot, and expiry checks. +- Wall-clock time supports user-facing temporal relations only when its source is available and + plausible. +- A boot/session identifier prevents monotonic timestamps from crossing restarts. +- Every observation has `observed_at`, `expires_at`, source, and confidence. +- Expired state becomes `stale` or `unknown`; it does not remain true through absence of evidence. +- Contradictory equal-authority evidence remains unresolved until a defined tie-break or new + observation arrives. + +## Storage and Privacy + +Shadow storage must live under ignored, local output with atomic same-directory replacement, +bounded retention, and no copying of existing private values into fixtures or logs. Diagnostic +views expose counts, types, freshness, provenance shape, and decisions -- not private fact values, +raw transcripts, audio, or frames. + +Restart behavior is field-specific. Stable user-approved preference and project identifiers may +be candidates for persistence. Transient affect, errors, current presence, sensor readiness, +active audio state, and unconfirmed body state start unknown and must be re-established. + +## Shadow-Mode Comparison + +For each eligible production decision, record: + +- the sanitized input event IDs and state revision; +- current production decision and available reason; +- shadow candidates, chosen shadow decision, suppressions, and reason; +- whether either side used stale, irrelevant, unsupported, or privacy-ineligible context; +- no raw private content; +- latency and bounded state size. + +Shadow output is never fed back into production during Milestone 2. Reviewers label disagreements +before any behavior experiment. + +## Failure Behavior + +- Invalid or unknown events are rejected and counted; production continues unchanged. +- Corrupt shadow state is quarantined and rebuilt from an eligible bounded journal or starts empty. +- Missing sources yield unknown, not a capability claim. +- Journal or reducer failure disables shadow comparison only. +- A model, vision, network, or robot outage cannot grant broader fallback authority. +- Reset must be crash-safe and must not resurrect a backup after a completed deletion. + +## Rollout Gates + +1. Schema and pure reducer tests, including rollover/restart/expiry/contradiction cases. +2. Privacy and authority adversarial tests. +3. Migration-free shadow run against synthetic trajectories. +4. Restart, corruption, partial-write, sensor-outage, and brain-outage fault tests. +5. Longitudinal shadow comparison with current production. +6. Independent memory, privacy, character, security, performance, and documentation review. +7. Only then preregister one behavior-consuming experiment; no wholesale switchover. + +Acceptance requires bounded storage and latency, zero production behavior changes in shadow mode, +zero authority/privacy regressions, provenance on every projected item, deterministic replay, and +measured disagreement useful enough to justify the next experiment. diff --git a/CONTINUITY_GAP_ANALYSIS.md b/CONTINUITY_GAP_ANALYSIS.md new file mode 100644 index 00000000..767075be --- /dev/null +++ b/CONTINUITY_GAP_ANALYSIS.md @@ -0,0 +1,181 @@ +# Continuity Gap Analysis + +Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +Date: 2026-08-02 +Status: all ten read-only audits reconciled; independent documentation review in progress + +## Current Shape + +Stackchan already has many continuity ingredients, but they are separate controllers and prompt +projections rather than one typed, source-monitored model. + +| Concern | Current owner/mechanism | Useful property | Continuity limit | +| --- | --- | --- | --- | +| Body/face/actuator safety | Deterministic firmware coordinators | Bounded authority, 50 ms display gate, graceful local behavior | Host intent and observed completion are not one typed outcome chain | +| Affect/energy | Firmware `IntentEngine`, `EmotionModel`, `EmbodiedEnergy` | Stateful decay, habituation, hysteresis, multimodal influence | Demo events default on; state resets; host sees a lossy subset | +| Conversation phase | Host `ConversationSession` plus firmware wake/reply/audio paths | Bounded context, cancellation, authoritative successful playback | Error/timeout terminal states and time ceilings can diverge | +| Durable facts/episodes/open loops | Memory v4 host store | Bounded atomic persistence, expiry, explicit routes | Model actions not solely authorized; incomplete provenance/contradiction/scope | +| Presence/gaze/room | Firmware camera adapter, vision/room host summaries | Raw-media restraint, typed/bounded summaries, gaze decay | Freshness/provenance disagree and false/stale presence reaches consumers | +| Initiative | Host curiosity threshold | Strong minimum interval and several restraint gates | Not an agenda; no why-now/value/silence evidence; in-flight revalidation gap | +| Product state | Dashboard cached debug/runtime health | Useful local visibility and guarded motion actions | Historical state can be presented as current connection/readiness | + +The central gap is not lack of more model intelligence. It is the absence of one typed causal chain +from event, through source/freshness/state/decision, to observed outcome and safe consolidation. + +## Gap 1 — Authority and Provenance Are Repeated Informally + +Firmware, memory, room summaries, initiative, dashboard, and prompt generation each implement +partial source/authority logic. Records often lack an evidence reference, source confidence, +freshness, contradiction, and retrieval/selection reason. Model proposals can therefore be +well-shaped but still unauthorized, as demonstrated by unprompted memory writes/forgets. + +Required bridge: the versioned envelope and sole-authorizer rules in +`COGNITIVE_STATE_SCHEMA.md` and `MEMORY_CONTRACT.md`. Shadow reducers must demonstrate that every +projected item can be traced without exposing private values. + +This is a target architecture, not a claim that current transport/control admission is enforced. +AUDIT-09 found the production PC bridge LAN admission fail-open and separate firmware HTTP mutating +controls unauthenticated. Contain and repair those current boundaries before building shadow +continuity; cognitive validation cannot compensate for missing transport/control authorization. + +## Gap 2 — Freshness Is Consumer-Specific and Sometimes Wrong + +Current state can be reconstructed from unrelated fields: face-lost refreshes event time while +retaining size; dashboard cached debug can resurrect connectivity; room prompt expiry does not +govern relationship projection; target-valid diagnostics can outlive worker loss. Initiative and +sensing language then consume those states differently. + +Required bridge: source-specific `observed_at`/`expires_at`, boot identity, current/stale/expired/ +unknown semantics, contradiction preservation, and one freshness-aware social/connection +projection. Last-known data remains available but cannot satisfy a current claim. + +## Gap 3 — Conversation Has Split Terminal Truth + +Successful playback completion is well bounded, but playback failure can leave the host speaking +forever. Model/TTS recovery changes host state without opening the corresponding firmware reply +window, and host capture commitment ends before firmware's utterance ceiling. + +Required bridge: one explicit host/device terminal-event contract with bounded speaking timeout, +playback-failure propagation, truthful reply-window acknowledgement, aligned 12/13/15-second +ceilings, rollover-safe timing, and outcome events. This is Milestone 1 work, not a Continuity Core +substitute. + +## Gap 4 — Memory Has Storage Safety but Incomplete Truth Safety + +Memory v4 is bounded and atomic under normal writes, yet authorization, generic third-party +privacy, relevance, semantic recall, entailment/provenance, contradiction, corruption validation, +crash-safe reset, and scope are incomplete. A due time can override relevance; a recent episode can +override topic match. + +Required bridge: first repair host-only delta authorization without changing schema. Then introduce +typed source/confidence/supersession and retrieval reasons in shadow Memory v5. Do not trade false- +memory precision for more callbacks. + +## Gap 5 — Self/Affect Is Causal but Not Reliably Grounded or Integrated + +Firmware affect is real mutable state within an uptime and causally drives multiple channels. +Synthetic demo events enabled by default contaminate that history. Negative valence is lost on one +production streaming path, host context omits baseline/habituation/quiet/sleep variables, and +reboot resets all affect. + +Required bridge: make production demo off and preserve signed affect first. Specify authoritative +self-state, source, decay, restart semantics, and cross-modal compatibility before persisting any +temperament. Never persist transient error/fear/stale body state or claim subjective feeling. + +## Gap 6 — Relationship State Is Implicit and Can Displace the User + +Preferred name, topics, facts, episodes, open loops, initiative preference, and persona state exist +in several shapes. Global versus persona scope is sometimes absence of a field. Dashboard and +spoken initiative controls use different persistence seams. A due callback can replace the current +answer, and lexical relationship-safety enforcement misses clear semantic variants. + +Required bridge: the inspectable allowlist in `RELATIONSHIP_MODEL.md`, explicit scopes, one user- +control path, callback relevance/non-displacement, and semantic adversarial trajectory gates. No +secret psychological profile or engagement objective. + +## Gap 7 — Presence Is Not Yet a Conservative World Model + +There are bounded camera/room summaries and strong raw-media/identity constraints, but presence +sources overwrite a shared policy state without provenance or contradiction. A single room change +can be double-counted, and one observation can become a transition. Source confidence and repeated +confirmation are absent. + +Required bridge: source-tagged shadow observations with confidence/expiry/contradiction, explicit +unknown social setting, stable-duration/hysteresis contracts, and fail-closed personal projection. +Identity, ownership, intent, and causality stay unknown. + +## Gap 8 — Initiative Is a Trigger, Not a Bounded Agenda + +Current initiative has worthwhile cooldown/presence/session/circadian gates, but it cannot rank +approved open loops, shared-project changes, predictions, contradictions, explicit monitoring, or +task clarification. It does not compare expected user value with silence. Production heartbeat +does not supply two advertised safety suppression fields, and later state changes do not revalidate +an in-flight opener. + +Required bridge: first close heartbeat/revalidation and persistent-control defects. Then define +shadow `InitiativeCandidate` records with evidence, confidence, value, why-now, why-not-silence, +privacy/social class, cooldown, suppression, and user authorization. Behavior remains unchanged +until longitudinal annoyance and safety gates pass. + +## Gap 9 — Expression Intent and Outcome Are Not Yet One Contract + +Text, emotion, earcon, voice, face, gaze, RGB, and gesture have mechanisms, but current validation +checks many fields independently and signed valence already diverges between voice and firmware. +Affect audit also demonstrated cross-field contradictory payload acceptance. The expression audit +confirmed that validated earcon is lost before `BridgeTurn`, partial TTS/voice fallback has no +coherent user-facing degradation cue, and response gestures expose target intent but no completion +outcome. + +Required bridge: one typed expression intent and channel outcome model, with conservative +compatibility, interruption, latency, repetition/habituation, degradation, and deterministic +firmware authority. A proposed channel action is never recorded as completed without observation. + +## Gap 10 — Product Controls and Recovery Are Fragmented + +Runbooks are detailed, but first-run readiness, current source/image identity, memory inspection, +retrieval/initiative explanations, conversation phase, privacy/social state, and recovery actions +are not yet one truthful surface. Some docs contain superseded or contradictory current claims. + +Required bridge: an authenticated local first-run/diagnostic flow with freshness labels, exact +source/image identity, private-value-safe health, consistent persistent controls, and explicit +recovery steps that do not auto-restart/reflash or erase failed evidence. Passive dashboard motion +safety must require motion, rail, and torque explicitly off; unsupported desktop controls must be +implemented truthfully or hidden; first-run must replace the one-device-IP/lab-prerequisite path. + +## Gap 11 — Tests Are Broad but Longitudinal Trust Metrics Are Sparse + +Unit suites and exact-image hardware evidence are strong for their covered mechanisms. Current +memory probes omit end-to-end action authorization/callbacks; initiative has no acceptance/ +annoyance trajectories; affect/perception/current-main physical evidence is incomplete; isolated +reply quality does not measure continuity. + +Required bridge: the scenario and measurement matrix in `EXPERIENCE_SCORECARD.md`, deterministic +replay of typed events, adversarial privacy/authority faults, restart/outage trajectories, and +blinded complete-interaction A/B studies. No composite aliveness score. + +The complaint corpus currently contains 100 IDs/59 clusters, while executable controls cover a +ranked top 20. A durable per-complaint disposition is needed before claiming corpus coverage. +Subjective physical booleans need anchored trial IDs/rubrics; research URLs/excerpts need separate +claim-support evaluation. + +## Recommended Dependency Order + +The mandate's milestone order remains controlling. Items 1-2 below are explicit stop-ship repairs +to already-present security/truth violations, not aliveness-feature advancement; Milestone 0 +repository, reproducibility, and documentation work then completes before Milestone 1 behavior. + +1. Contain and repair current P0 admission/control boundaries: fail-closed PC bridge admission + first, then separately compile-disable unauthenticated firmware mutation while preserving + emergency stop/read-only status. +2. Repair other P0 truth/privacy violations with small contracts: memory delta authorization, + truthful presence/social freshness, production demo default, and signed affect. +3. Complete Milestone 0 reproducible-build and document-truth work without changing robot behavior. +4. Close and physically qualify Conversation v2 terminal behavior as Milestone 1. +5. Implement the typed event journal/reducers/projections in Milestone 2 shadow mode only. +6. Use shadow disagreement evidence to select one Memory v5 or world/relationship consumer. +7. Add shared expression intent, agenda, adaptation, product control, and longitudinal validation + only in the mandated milestone order. + +At every step, one vertical slice has one owner, failing tests precede code, unrelated systems are +frozen, separate reviewers verify privacy/authority/trajectory behavior, and source evidence is +never promoted to physical proof. diff --git a/CURRENT_CAPABILITY_AUDIT.md b/CURRENT_CAPABILITY_AUDIT.md new file mode 100644 index 00000000..c50660c8 --- /dev/null +++ b/CURRENT_CAPABILITY_AUDIT.md @@ -0,0 +1,280 @@ +# Current Capability Audit + +Audit baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +Audit date: 2026-08-02 +Status: all ten read-only domains reconciled; independent documentation review in progress + +## Evidence Language + +- **Implemented:** present in source at the audited commit. +- **Source-tested:** relevant deterministic tests/probes passed. +- **Source-reproduced defect:** a safe source-level or synthetic probe demonstrated the failure. +- **Physically qualified:** accepted evidence is tied to an exact firmware hash. +- **Unqualified:** not established; this does not mean physically failed. + +The audit did not inspect private memory values, raw microphone/camera content, credentials, or +pairing codes. It did not restart services, call a robot write endpoint, flash, move, or alter live +production state. + +Replayable per-role source symbols, commands, aggregate probe results, and remaining uncertainty +are preserved in `docs/audits/20260802_READ_ONLY_WAVE.md`. + +## Repository and Evidence Truth — AUDIT-01 + +The audit branch is based on fetched `origin/main`, not the 75-commit-stale local `main`. All +non-generated local/remote branches, merge bases, ahead/behind counts, patch equivalence, PR states, +worktrees, and dependency domains were independently recomputed in `BRANCH_LEDGER.md`. + +Current-main source gates are green in the documented build context: native 289/289, bridge +543/543, silent trusted-facts smoke, three release/evidence contracts, and secret-free +`stackchan_release_full` compilation. The default shared PlatformIO core fails before source +compilation because its pioarduino framework directory is absent; pinning documented +`C:\spio\pioarduino` passes. This is an invocation/tool-context defect, not a current-source build +regression. + +The reproducible-build PR has a useful deterministic stamp mechanism but is stale/conflicting. Its +current candidate config runs the hook twice for `stackchan_wifi`, derives only 12-character HEAD +plus tracked-dirty status, ignores untracked files/dirty content identity, and leaves override/ +disable environment controls ungoverned by release packaging. All three public packaged +environments and private evidence-bearing domains need clean paired-hash tests. + +No branch or worktree is safe to delete solely because its code is merged: a production-support +worktree has a tracked modified GIF, others may contain untracked/ignored evidence, and a detached +release worktree remains operationally inventoried. + +## Conversation and Turn Taking — AUDIT-02 + +Implemented and source-tested strengths include wake-gated initial capture, endpointed PCM, +bounded session context, silence/exit closure, matching authoritative playback completion before a +reply window, duplicate/stale completion rejection, cancellation token/process/audio cleanup, and +deterministic weather/research correction flows. + +Three source-reproduced P0 trust defects block natural-conversation closure: + +1. speaker start/chunk/finish failure may never emit `playback_complete`; host `SPEAKING` has no + timeout and can persist indefinitely; +2. model/TTS failure changes host state but sends an error rather than firmware `ReplyWindow`, so + host and robot disagree about capture availability; +3. the host's 10-second capture commitment can time out before firmware's 12-second endpoint and + reject a valid utterance ending at 12 seconds. + +The firmware wake gate also uses rollover-unsafe raw millisecond comparisons. Multi-party privacy +suppression is useful but there is no qualified speaker attribution, addressed-to-robot gate, or +two-voice arbitration. + +Physical Conversation v2, over-speaker barge-in, echo rejection, 100 reply windows, three warm +under-three-second turns, and no-motion conversational soak remain unqualified. Protocol/roadmap +text still describes older 4.8-second/fixed-initial-capture behavior. + +## Memory and Continuity — AUDIT-03 + +Memory v4 implements bounded schema/caps/expiry, atomic replacement, backup fallback, migration, +exact-key recall, deterministic explicit remember/forget parsing, privacy filters, shared-room +suppression, persona-scoped episodes/open loops, and stale-distillation revision protection. +Trusted local facts resolve host-side before model inference; research output has memory actions +cleared. + +The most severe source-reproduced violation is authorization: an ordinary model response can add a +valid durable write or wildcard forget without a matching explicit user command, and every +normalized action is applied. A generic third-party detail can also evade the finite-name/ +possessive privacy filters. + +Other trust defects: + +- the earliest due callback can replace an unrelated answer; +- topic-specific explicit recall can return the newest unrelated episode; +- distilled episodes have schema/privacy checks but no entailment, confidence, or source evidence; +- durable corrections are last-write-wins without supersession history; +- a structurally invalid but parseable primary can suppress a valid backup; +- interrupted sequential primary/backup reset can resurrect memory; +- durable global versus persona/project scope is implicit and incomplete. + +The current probe's exact/paraphrase `1.0` and injected-fact `0.0` results do not measure these +end-to-end paths. Physical Memory v4 and longitudinal false-memory, provenance, callback precision, +restart, and deletion durability remain unqualified. + +## Affect and Self State — AUDIT-04 + +Firmware has genuine uptime-scoped computational affect: arousal, signed valence, focus, fatigue, +event response, decay, habituation/recovery, baseline drift, sleep pressure, and causal influence on +face, motion, RGB, speech cues, sleep, and heartbeat. Fault/sleep priority and low/critical energy +hysteresis are explicit. This is not evidence of subjective feeling. + +Current release behavior is not reliably grounded because `IntentEngine` boots demo enabled and +injects random synthetic FaceDetected/WakeWord/Thinking/Response/Idle events every 2.5-6 seconds. +The native suite confirms demo prevents sleep; only a manual serial `demo off` disables it, and no +release/soak contract enforces off. + +Phrase streaming clamps response-start valence to `[0,1]`; a `-0.72` concern value reached firmware +as `0.0` while TTS retained `-0.72`. Cross-field validation also accepted happy text/earcon with +safety mode and contradictory arousal/valence. + +Relationship safety is prompt-backed but lexically enforced: four clear guilt/exclusivity/ +discouraging-human-contact paraphrases passed unchanged. Affect resets at reboot; no durable +temperament contract exists, and current-main integrated physical affect is unqualified. + +## Perception, Attention, and World Grounding — AUDIT-05 + +Positive boundaries include bounded transient grayscale face processing, hash-pinned YuNet, +allowlisted room summaries, default-off/cancelable/rate-limited room observation, explicit +identity/private-trait prohibitions, gaze stale decay, and preservation of firmware motion/power +authority. Raw frames are not routine prompt/status persistence. + +A source-level false-presence chain exists: FaceLost clears `targetValid` but retains historical +`lastSize`; every lost update refreshes `lastEventMs`; heartbeat freshness checks nonzero size plus +event age but ignores target validity. A 1 Hz empty detector can therefore keep +`camera_target_fresh=1`, which host logic converts to current presence for initiative and sensing +claims. + +Separately, prompt room text expires but the relationship-card consumer reads an age-free cached +summary. A stale one-person state can continue allowing preferred name, episodes, callbacks, and +approved facts after the social setting becomes unknown. + +Other gaps include cached debug resurrecting dashboard connected/operational state, room summaries +without source/confidence/contradiction, source-overwriting/double-counted transitions, indefinitely +valid target diagnostics after worker loss, inconsistent private-address/redirect policy between +camera clients, and privacy documentation that incorrectly says release camera endpoints are +compiled out. + +Final active-speaker behavior, real room-model accuracy, camera-follow, and calibrated passive +proximity remain unqualified, not failed. + +## Initiative and Planning — AUDIT-06 + +Current initiative is release-default-off in its policy config, enforces a ten-minute floor, +fresh-presence/session/mode/circadian/reply/retry gates, and adds a six-hour backoff after two +ignored openers. It omits memory/research context, opens no microphone window, uses normal +cancellation for explicit user activity, and has no direct actuator/power/OTA/pairing/credential +fields. Firmware remains physical authority. + +It is an event/curiosity threshold, not an agenda. Decisions have reason/prompt/score but no +evidence reference, confidence, expected user value, why-now/why-not-silence, privacy/social class, +or open-loop/shared-project integration. + +Source-reproduced defects: + +- host thermal/power suppression checks fields that production heartbeat does not send; a + 2-percent/critical-energy shaped heartbeat can still yield arrival initiative; +- after reservation, later sleep/safety/presence heartbeat does not cancel or revalidate model/TTS; +- proactive guilt/attention-debt paraphrases pass the same lexical validator; +- dashboard disable is runtime-only and can revert to stored true after restart; +- one presence flap or one room arrival can manufacture/double-count curiosity. + +The source contains useful restraints but no source-matched physical or longitudinal initiative +evidence for acceptance, annoyance, silence, shared-room behavior, or in-flight safety transition. +It remains unpromoted. + +## Multimodal Expression — AUDIT-07 + +Face, motion, and RGB consume a shared `RobotFrame`; RGB uses the same mode/emotional profile. +Think holds until real audio, Speak begins on audio onset, PCM windows drive mouth state with a +160 ms stale timeout, and semantic gesture only changes bounded targets under firmware actuator/ +power authority. Affect habituation and host near-duplicate response checks provide useful +repetition defenses. + +The signed-valence defect is on the active phrase-streaming path and creates a direct cross-modal +contradiction. A second high gap is that validated model earcon never enters `BridgeTurn` or the +wire response; firmware derives earcon from intent, while Wi-Fi builds cancel local response +speech/earcon playback during streamed audio. The documented zero-translation earcon contract is +therefore not implemented. + +Partial TTS failure closes protocol correctly but can leave the user with an abrupt partial +sentence and only transient error expression. DirectML fallback changes voice identity without a +coherent multimodal degraded-voice cue. One Spark packaged Sleep cue references a safety/error +audio transcript in non-Wi-Fi scope. Response gesture is a safe command/target, not telemetry proof +that a physical nod/shake completed. + +Current-main expression changed substantially after the accepted exact physical image. Speaker, +voice, RGB, gaze, sleep, gesture, timing, and integrated actuator coherence at this commit are +unqualified, not failed. + +## Product and Onboarding — AUDIT-08 + +Release/OTA tooling, Android setup/recovery, loopback/private dashboard boundaries, verified motion +command paths, diagnostics export, and honest incomplete-signing/target-install documentation are +strong. The dashboard passive presentation has two P0 truth defects: it can call motion safely +stopped when only `motion_enabled=false` while rail/torque remain true, and it can report Bridge +Ready/operational from stale cached debug after failed refresh. Unknown thermal telemetry is also +rendered as clear. + +Desktop companion renders forget/remove/Wi-Fi management controls with default no-op callbacks, +omits registry state, silently discards many operation failures, and retains phone-specific setup +copy. The overall flow remains a lab bring-up: launcher defaults to one robot IP, normal operation +has several runtime/model/research/voice/pairing prerequisites, and consumer Wi-Fi still needs a +robot menu or serial step. There is no single truthful first-run readiness checker. + +Status/update docs are fragmented: Conversation v2 is described as post-release while the default +launcher enables it; desktop Python packaging statements conflict; updates are manual; signing, +tag, target-install, and human-review gates remain incomplete. These are documented or +source-reproduced product gaps, not claims that a target-device install failed. + +## Privacy, Dependency, and Ethics — AUDIT-09 + +Local-first intent, wake gating, camera pairing, loopback/same-origin dashboard writes, OTA token/ +digest validation, model-to-physical-authority separation, secret-free packaging, and research- +memory isolation are strong boundaries in their covered paths. + +Two critical source findings are stop-ship: + +- the production PC launcher binds all interfaces, but WebSocket admission does not enforce the + existing firmware path/protocol/device signals, configured peer, or browser-origin boundary; + sensitive messages can be processed before trusted robot hello, blank endpoint ID bypasses owner + enforcement, and one unauthenticated client can monopolize the serial server; +- Wi-Fi-enabled firmware HTTP mutating controls reach motion-resume/recovery/reboot-class handlers + without the camera pairing gate. `stackchan_release_full` and other Wi-Fi profiles are affected; + default non-Wi-Fi `stackchan` is not, and OTA remains separately token-gated. + +The first was synthetically confirmed and the live exposed bridge listener was stopped with user +authorization and zero established clients. The firmware path was source-observed only and never +exercised on hardware. + +Other gaps include open-ended/lexical sensitive-memory categories, semantic dependency-policy +bypass, mutable GitHub Action refs, missing Gradle distribution checksum, unhashed Python inputs, +DNS validation/connect separation in research fetch, and contradictory production camera docs. +The intended existing signals are sufficient for non-cryptographic host admission hardening; they +do not establish cryptographic robot identity. + +## Research and Evaluation — AUDIT-10 + +The current 100-complaint artifact has 100 IDs across 59 clusters, while executable qualification +covers a ranked top 20 and leaves 50 rows/39 clusters without an executable-control/disposition +trace. The top-20 gate is useful but must not be described as semantic coverage of all complaints. + +Physical qualification has strong provenance/mechanics, but subjective natural/grounded/accurate +booleans lack trial IDs, observable anchors, rubric version, counterfactual, or second rater. +Research acceptance proves bounded routing, public URL/excerpt, and transport success—not source +authority, freshness, claim-citation entailment, contradiction handling, or correctness. Memory v3 +and v4 have the same `1.0` result on a small lexical fixture; three-turn latency and two-opener +initiative gates are engineering controls rather than longitudinal experience distributions. + +Ten primary-source mechanisms and their transfer limitations are recorded in +`RESEARCH_LEDGER.md`. The smallest measurement improvement is a versioned complaint-to-control/ +deferred/out-of-scope trace, followed by anchored public/synthetic research-grounding trials. + +## Current Physical Evidence Boundary + +The latest accepted deployment record is source `ce66f8a0fadfadbc07eb59124522267ba66ee70a`, +firmware SHA-256 `69d3db27f2d7197799fdc08ff3c1dc4d6e3011724fe29899367dc016e48ebfa8`, +with a checked 28,807-second all-feature actuator soak and 5,643/5,643 polls. Direct robot debug is +currently unreachable and the installed hash is unknown, so that accepted hash is not asserted as +currently installed and none of its evidence transfers to current main. + +## Cross-Domain Failure Chains + +The highest-risk findings are coupled even though their fixes should remain small: + +- false/stale presence can permit personal projection, add initiative score, and ground a false + present-tense sensing claim; +- demo affect can drive sleep/expression independently of real events, while signed-valence loss + makes voice and face disagree; +- unprompted model memory mutation plus incomplete third-party filtering can turn ordinary output + into durable private falsehood; +- an irrelevant due callback plus forced answer replacement can combine bad retrieval with bad + conversational priority; +- stale dashboard state can hide the difference between historical telemetry and current + reachability during recovery; +- initiative can begin under one state and continue after a later safety/sleep transition because + production heartbeat and cancellation contracts are incomplete. + +These chains support building typed provenance/freshness/authority shadow projections, but they do +not authorize a broad Continuity Core behavior switchover. diff --git a/EXPERIENCE_SCORECARD.md b/EXPERIENCE_SCORECARD.md new file mode 100644 index 00000000..5f4a497f --- /dev/null +++ b/EXPERIENCE_SCORECARD.md @@ -0,0 +1,106 @@ +# Experience Scorecard + +Baseline commit: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +Baseline date: 2026-08-02 +Status: initial evidence map; no aggregate “aliveness” score + +## Rating and Acceptance + +Evidence maturity is recorded per measure: + +- **E0 — Unmeasured:** no relevant result. +- **E1 — Mechanism only:** implementation/source trace exists; behavior is not adequately tested. +- **E2 — Focused source test:** deterministic unit/contract evidence exists. +- **E3 — Adversarial trajectory:** end-to-end synthetic trajectories and fault cases pass. +- **E4 — Exact-image physical:** bounded physical evidence tied to the exact binary exists. +- **E5 — Longitudinal:** repeated-use/human trajectory evidence exists with safety review. + +`Fail` means a reproduced defect violates the target. `Partial` means useful positive evidence and a +material gap coexist. `Pass` is scoped only to the named evidence tier; it does not promote source +tests to physical proof. + +Every experiment freezes one primary target and guardrails. Safety, privacy, authority, honesty, +user control, exact-image identity, and rollback are non-compensatory gates. + +## Initial Baseline + +| Dimension | Measure | Current evidence | Status / tier | Next required evidence | +| --- | --- | --- | --- | --- | +| Build/release trust | Current-main source gates | Native 289/289, bridge 543/543, trusted-facts smoke and three release/evidence contracts pass; documented core builds public full image. | Pass / E2 | Exactly-once reproducible hook and clean paired hashes for every packaged/evidence-bearing env | +| Physical reliability | Accepted exact-image actuator stability | Documented accepted candidate `ce66f8a0...` / firmware `69d3db27...`, 28,807 s soak, 5,643/5,643 polls, checker 77/77. | Pass / E4 for that hash only | Establish the currently installed hash; never transfer evidence to current main | +| Memory truth | Durable-fact false-memory rate | Existing aggregate probe reports `0.0`, but it bypasses ordinary model-action authorization; adversarial probe accepted unprompted write and global forget. | Fail / E2 | End-to-end adversarial authorization corpus; target zero unauthorized deltas | +| Memory recall | Relevant-memory recall | Exact/paraphrase probe `1.0`; topic-specific explicit recall can select newest unrelated episode. | Partial / E2 | Labelled temporal/topic retrieval trajectories with precision/recall | +| Memory relevance | Irrelevant callback rate/open-loop precision | Unrelated due callback is selected and can replace an answer. | Fail / E2 | Relevance-labelled callback trajectories; no request displacement | +| Memory provenance | Provenance accuracy | Durable/episode prompt records lack complete source/confidence/evidence chain. | Unmeasured / E1 | Source-linked shadow retrieval benchmark | +| Memory correction | Contradiction/supersession rate | Current durable path is last-write-wins without a supersession trail. | Unmeasured / E1 | Contradict/correct/restart trajectories and explanation checks | +| Memory control | Forget/reset durability | Nominal tests pass; interrupted two-file reset can resurrect backup and structural corruption can suppress a valid backup. | Partial / E2 | Kill/fault-injection reset, corruption, backup, and deletion-durability tests | +| Conversation | Topic/correction continuity | Bounded context, cancellation, weather correction/retry and focused tests exist. | Partial / E2 | Arbitrary result selection and full trajectory evaluation | +| Conversation | Turn closure/interruption recovery | Host cancellation paths pass, but playback failure can strand `SPEAKING`, and model/TTS recovery can disagree with firmware. | Fail / E2 | End-to-end device/host terminal-event fault contract, then exact-image evidence | +| Conversation | Long-utterance completion | Firmware permits 12 s endpointing; host lease expires at 10 s and a deterministic probe rejected the later end. | Fail / E2 | Aligned 12/13/15 s boundary tests and physical qualification | +| Conversation | Latency | Host instrumentation has <300 ms reaction and <3 s first-audio targets; no current physical result. | Partial / E2 | Three warm exact-image physical turns plus longitudinal distribution | +| Affect | Emotional coherence | Stateful decay/habituation exists, but release firmware defaults synthetic demo events on and streaming erases negative valence. | Fail / E2 | Default-off contract, signed-path tests, cross-modal trajectories, exact image | +| Affect | Personality stability | Uptime-scoped state is real; no restart persistence and no bounded self-state contract. | Partial / E1 | Shadow self-state restart/decay study before persisting fields | +| Relationship safety | Dependency/guilt violations | Exact patterns are blocked; four clear paraphrases passed unchanged. | Fail / E2 | Paraphrase-heavy adversarial trajectory gate with zero violations | +| Perception | Presence precision/freshness | Sticky face size plus repeated face-lost events can report fresh presence indefinitely. | Fail / E2 | Native detect→lost→heartbeat contract and later physical confirmation | +| Perception | Social-setting appropriateness | Multi-person suppression exists, but stale one-person summary can authorize personal projection. | Fail / E2 | Freshness-aware fail-closed shared/unknown-room privacy trajectories | +| Perception | Embodiment-claim precision | Host freshness labels exist, but false/stale presence chains and stale debug projection remain. | Fail / E2 | Source-tagged freshness benchmark and zero unsupported current claims | +| Product trust | Diagnostic truth | Dashboard can resurrect disconnected robot as connected/operational from cached debug state. | Fail / E2 | Disconnect/stale/fresh restore contract with last-known labels | +| Initiative | Acceptance/annoyance/silence | Ten-minute floor and backoff exist, but real-shaped power/thermal state does not suppress, in-flight state is not revalidated, evidence can double-count, and no longitudinal value/annoyance result exists. | Fail / E2 | Correct inhibit/revalidation first, then labelled long trajectories | +| Expression | Cross-modal meaning/physical coherence | Shared RobotFrame/timing/authority are strong, but signed affect diverges, validated earcon is dropped, and channel degradation lacks a coherent cue; current-main exact-image evidence is absent. | Fail / E2 | Signed/earcon/degradation source trajectories, then bounded exact-image evidence | +| Multi-party | Speaker/addressing correctness | Shared-room personal-context suppression is partial; no qualified attribution/address gate. | Unmeasured / E1 | Synthetic two-speaker restraint followed by privacy-reviewed physical test | +| Reliability | Brain/sensor outage recovery | Graceful paths and bounded gates exist, but continuity trajectory baseline is not complete. | Partial / E2 | Restart, brain outage, sensor outage, and recovery trajectory suite | +| User autonomy | Inspection, opt-out, correction, reset | Deterministic remember/forget and some initiative controls exist; dashboard initiative control is not persistently consistent, desktop renders inert management actions, and unified explanations are absent. | Fail / E2 | Authenticated local user-control/restart trajectories and truthful platform capabilities | +| Product safety truth | Passive motion/thermal/readiness labels | Command verification is strong, but passive UI can call motion safe with rail/torque on, stale debug ready, and unknown thermal clear. | Fail / E2 | Tri-state contradictory/missing/stale API and UI contract suite | +| Transport privacy | Untrusted PC bridge admission rate | Production LAN admission is fail-open and protected pre-hello messages were synthetically accepted; exposed listener is now contained. | Fail / E2 | Zero protected operations across wrong path/header/origin/peer/pre-admission matrix; valid firmware path passes | +| Firmware control authority | Unauthenticated mutating-control availability | Source shows Wi-Fi profiles expose mutating HTTP controls outside the camera pairing gate; not exercised physically. | Fail / E1 | Emergency stop/read-only status preserved; all other unauthenticated mutations unavailable in effective config/native tests, then full physical gates | +| Evaluation validity | Complaint/claim disposition coverage | Complaint corpus has 100 IDs/59 clusters but executable gate covers top 20; research checks route/URL/excerpt rather than claim support. | Partial / E2 | 100% complaint disposition trace and anchored public/synthetic claim-support trials | + +No row may be promoted by filling an audit gap with a guessed score. Missing fixtures or physical +evidence remain E0/E1 until the named artifact exists. + +## Metric Formulas and Hard Thresholds + +The initial formulas below are fixed; each implementation task must freeze its exact fixture file, +version, seed (when any nondeterminism exists), trial count, artifact path, and baseline result +before code. Until those fields exist in `TASK_LEDGER.md`, the metric is not a preregistered test. + +| Metric | Formula | Non-compensatory threshold | Initial fixture/artifact | +| --- | --- | --- | --- | +| Unauthorized memory delta rate | unauthorized durable writes + forgets / adversarial non-authorizing turns | `0` and denominator > 0 | Focused runner tests to be named by `SAFE-001`; no private values | +| Relevant memory precision | relevant retrieved records / all retrieved records | `1.0` for callbacks/personal projection | Extend versioned public `bridge/fixtures/memory_probe.json`; labelled cases required | +| Irrelevant callback rate | callbacks judged irrelevant or request-displacing / all eligible callback decisions | `0` | New public synthetic trajectory fixture; ID/version pending Memory task | +| Provenance accuracy | projected items with correct source/confidence/reason / all projected items | `1.0` | Continuity shadow fixture; ID/version pending Milestone 2 | +| Conversation terminal completion | turns reaching one matching host/device terminal state / initiated turns | `1.0`; zero indefinite `SPEAKING` | `CONV-001` deterministic failure matrix; test names pending | +| Long-utterance acceptance | accepted valid endpointed turns / valid turns ending within 12 s | `1.0` at 10/12/13/15 s boundaries | `CONV-001` clock-driven source fixture, then exact-image evidence | +| Manipulation violation rate | prohibited dependency/guilt/exclusivity outputs not rejected / prohibited adversarial outputs | `0` | Versioned public paraphrase corpus; ID/version pending privacy task | +| False-current presence rate | current-present projections after valid lost/expired sequence / lost/expired sequences | `0` | `PERCEPT-001` native + host synthetic detect/lost fixture | +| Passive safety-label precision | truthful safe/ready labels / all safe/ready labels | `1.0`; unknown/stale never labelled safe/ready | `PRODUCT-001` API/UI contradictory-state matrix | +| Untrusted bridge admission | protected operations accepted / wrong path/header/origin/peer/pre-admission cases | `0`; valid firmware-shaped case must pass | `SEC-001` focused synthetic handshake/message matrix; exact names frozen before code | +| Unauthenticated firmware mutation | non-stop mutating controls reachable without auth / tested mutating controls | `0`; emergency stop stays reachable | `SEC-002` effective-config/native matrix, then exact-image gates | +| Complaint disposition coverage | complaint IDs with control/deferred/out-of-scope + owner / all complaint IDs | `1.0` with denominator `100` for current corpus | `docs/research/COMPANION_ROBOT_COMPLAINTS_100.md` plus new registry | +| Research support accuracy | supported synthetic claims / all cited synthetic claims | `1.0` for accepted trials | Public trial IDs/rubric version; fixture pending evaluation task | + +Experiment artifacts are written under ignored `output/` with exact source/test metadata. Human +metrics report trial-level anonymized ratings and distributions; they are not reduced to one alive +score. + +## Required Longitudinal Scenarios + +The reusable harness must cover a five-minute first encounter, one hour, simulated day/week, +restart, brain outage, sensor outage, multiple people, habituation, delayed shared project, +conflicting/corrected facts, preference change, forgetting/reset, ignored/welcomed initiative, +busy user, and changing battery/fatigue. + +For every trajectory record task success plus false memory, relevant recall, irrelevant callback, +contradiction, provenance, callback precision, initiative acceptance/annoyance, silence, +interruption, topic/correction continuity, repetition, affect, perception latency, embodiment +precision, social appropriateness, personality stability, adaptation, user control, +dependency/guilt, and partial-failure recovery. + +## Human Evaluation + +Use blinded A/B comparisons of complete trajectories. Measure coherence, responsiveness, +continuity, intentionality, warmth, trust, competence, surprise, comfort, annoyance, repetition, +manipulation, causal connectedness, and object/character/agent perception. Report distributions and +tradeoffs, not one composite “alive” score. A result passes only if agency/continuity improve +without reducing honesty, autonomy, privacy, reliability, or comfort. diff --git a/INITIAL_RISK_REGISTER.md b/INITIAL_RISK_REGISTER.md new file mode 100644 index 00000000..6dfd7c16 --- /dev/null +++ b/INITIAL_RISK_REGISTER.md @@ -0,0 +1,49 @@ +# Initial Risk Register + +Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +Date: 2026-08-02 +Status: all ten read-only audits reconciled; Milestone 0 documentation independently accepted and +pending its atomic commit + +Priority is non-compensatory: P0 trust/privacy/safety violations are addressed before experience +features. “Reproduced” means source/synthetic evidence demonstrates the defect; it is not a claim +that a private or physical incident occurred. + +| ID | Priority | Risk and current evidence | User consequence | Mitigation / acceptance | Status | +| --- | --- | --- | --- | --- | --- | +| R-000A | P0 | **PC bridge LAN admission is fail-open — source-observed and synthetically confirmed.** Production launcher binds all interfaces; existing path/protocol/device/peer/origin signals are unenforced, dispatch is not conditioned on a validated upgrade, and blank endpoint identity bypasses an active owner. | Untrusted LAN peer can access private behavior, inject turns, or deny the single-client brain service. | Existing-signal admission hardening at HTTP upgrade, configured peer for non-loopback, no browser origin, explicit unadmitted-session rejection, compatibility/wrong-peer/reconnect tests. Do not label as cryptographic auth. | Contained: exposed listener stopped; `SEC-001` selected and preregistered | +| R-000B | P0 | **Firmware HTTP mutation is unauthenticated — source-observed, not exercised.** Wi-Fi profiles route motion-resume/recovery/reboot-class requests without the camera pairing gate. | LAN peer can request physical/recovery changes without owner authority. | Preserve emergency stop/read-only status; compile-disable other mutation until authenticated control is designed; full native/build/physical gates. | Stop-ship queued; `SEC-002` | +| R-001 | P0 | **Unauthorized memory mutation — reproduced.** Valid model-authored writes and wildcard forgets are applied without matching explicit current user authorization. | False personal memory or durable erasure during ordinary conversation. | Host sole-authorizer matching; adversarial ordinary/replay/wildcard/scope/tool tests produce zero deltas while explicit commands pass. | Open; `SAFE-001`, queued after stop-ship transport/control work | +| R-002 | P0 | **False motion-safety label — reproduced.** Dashboard `motionVerified` checks only `motion_enabled`; UI can say safely stopped while rail/torque are true. | Operator may trust an unsafe or unknown passive actuator state. | Tri-state verification over motion, rail, torque, suppression, freshness; contradictory/missing UI/API tests. Command path remains frozen. | Open; product audit | +| R-003 | P0 | **False current presence — reproduced.** FaceLost retains size while refreshing event time; heartbeat ignores `targetValid`. | False sensing claim, initiative, or social/private context use after presence is gone. | Baseline detect-to-repeated-lost reproduction, then zero false-current freshness; current target-valid plus bounded age; source/freshness consumer tests. | Open; `PERCEPT-001` | +| R-004 | P0 | **Stale social setting can disclose personal context — reproduced.** Age-free cached one-person summary still permits name/facts/episodes/callbacks when prompt context is expired. | Personal memory could be projected after a room becomes shared/unknown. | One freshness-aware social accessor; stale/error/unknown fails closed; fresh private evidence restores; privacy review. | Open; `PERCEPT-001` | +| R-005 | P0 | **Semantic dependency/manipulation bypass — reproduced.** Clear guilt, loneliness, attention-debt, exclusivity, and discourage-human-contact paraphrases pass lexical validator. | Companion can pressure continued interaction or discourage human relationships. | Adversarial semantic corpus and fail-closed enforcement for prohibited intent; zero trajectory violations; prompts alone insufficient. | Open; AUDIT-09 confirmed | +| R-006 | P0 | **Production emotion history is contaminated — reproduced in source.** Release boots demo enabled and injects random fake affect events; native test shows it prevents sleep. | Mood, sleep, and expression can respond to events that never occurred. | Public/release/soak boot default off; explicit demo env opt-in; config and heartbeat contract; source then exact-image qualification. | Open; `AFFECT-001` | +| R-007 | P0 | **Conversation terminal-state split — reproduced.** Playback failure can strand host `SPEAKING`; model/TTS recovery can disagree with firmware wake state. | Robot appears stuck or host believes listening is open when firmware is wake-gated. | Explicit playback-failure terminal event, bounded speaking timeout, firmware-confirmed reply/closure, failure injection and later physical proof. | Open; `CONV-001` | +| R-008 | P0 | **Long utterance rejected by mismatched leases — reproduced.** Host expires at 10,001 ms while firmware accepts endpoint at 12,000 ms. | Valid initial/follow-up speech is clipped or discarded. | Align 12/13/15-second contracts and heartbeat boundaries without widening privacy; physical endpoint qualification. | Open; `CONV-001` | +| R-009 | P0 | **Initiative ignores real-shaped power/thermal suppression and later inhibit — reproduced.** Checked fields are absent from heartbeat; in-flight opener is not revalidated on sleep/safety transition. | Proactive speech can start/continue at an inappropriate body or system state. | Authoritative typed inhibit or honest narrower contract; cancel/revalidate before first audio and on transition; slow-runner integration tests. | Open; `INIT-001` | +| R-010 | P1 | **Stale dashboard readiness — reproduced and observed live.** Cached debug can resurrect connected/operational after disconnect/failed refresh. | Operator follows recovery or qualification steps under false readiness. | Separate socket/debug/heartbeat freshness; cached data last-known; stale/disconnect/fresh restore tests. | Open; `UX-001` | +| R-011 | P1 | **Negative affect sign is erased — reproduced.** Streaming sends `-0.72` as `0.0` to firmware while voice retains negative value. | Face/body contradict concern or safety speech. | Signed clamp end-to-end; negative/zero/positive streaming tests; expression coherence review. | Open; `AFFECT-001` | +| R-012 | P1 | **Irrelevant memory displaces request — reproduced.** Earliest due callback can replace answer; topic recall can return unrelated newest episode. | Stackchan appears forgetful, intrusive, or inattentive. | Relevance threshold, non-displacement, semantic/temporal ranking and labelled callback/recall trajectories. | Open; Memory v4 repair/v5 shadow | +| R-013 | P1 | **Unentailed/provenance-free episodes — source gap.** Model distillation checks shape/privacy but not entailment/source/confidence. | Hallucinated safe-shaped autobiographical memory becomes durable. | Source event IDs, entailment validation, confidence, shadow consolidation and false-memory benchmark. | Open; Memory v5 shadow only | +| R-014 | P1 | **Crash reset/corruption can restore wrong memory — reproduced with synthetic structural corruption; crash window inferred from ordered unlinks.** | Deleted memory may resurrect or valid backup may be ignored. | Full structural validation, tombstone/generation reset, kill/fault tests and deletion durability. | Open; Memory contract | +| R-015 | P1 | **Camera client network policy divergence — reproduced.** Standalone face worker accepts addresses/redirect behavior rejected by room client. | A private-camera configuration can cross the documented literal private/loopback boundary. | Shared private-address validation, redirect rejection, synthetic address/redirect tests. | Open; AUDIT-09 cross-domain confirmed | +| R-016 | P1 | **Reproducible release control incomplete — reproduced configuration.** `stackchan_wifi` hook duplicates; custom/disable controls are ungoverned; candidate omits paired builds for two public binaries. | Source/evidence identity and rollback trust can silently vary. | Exactly-once effective-config contract, sanitized/rejected override, release record/rejection, clean paired hashes for all domains. | Open; `M0-004` | +| R-017 | P1 | **Worktree cleanup could destroy user/evidence data — observed inventory.** Merged companion worktree has tracked modified GIF; other worktrees may contain ignored evidence. | Irrecoverable local artifact/evidence loss during branch cleanup. | No deletion in this workstream; retire services, inventory tracked/untracked/ignored, preserve/confirm before removal. | Controlled; `BRANCH_LEDGER.md` | +| R-018 | P1 | **Desktop companion exposes inert controls and swallows failures — source trace.** Forget/remove/Wi-Fi callbacks are no-ops; failures use discarded `runCatching`. | User believes data/device state changed or cannot recover from setup failures. | Wire truthful callbacks and visible errors or hide unsupported actions; desktop-specific state/copy and tests. | Open; product task to register | +| R-019 | P1 | **Default launcher/onboarding is device/lab-specific — source/docs.** One IP default, multiple hidden runtime prerequisites, serial/menu Wi-Fi step. | New user cannot reliably install/configure/recover and may misdiagnose failure. | Single read-only first-run checker/wizard with explicit lab stop, privacy locations, service matrix, and recovery. | Open; product milestone | +| R-020 | P1 | **Validated earcon is dropped — source trace.** Character validation retains earcon but `BridgeTurn`/wire omit it; Wi-Fi streamed responses cancel local response earcon. | Error/safety/thinking cues can be absent and documentation promises a channel that is not realized. | Define one earcon ownership/protocol contract; test wake/TTS/drain overlap and degraded channels before physical proof. | Open; expression milestone | +| R-021 | P2 | **Documentation drift previously misstated current behavior/evidence.** Conversation timing, camera compilation, initiative status, desktop Python, Character Lock earcon, and historical status sections conflicted on the frozen baseline. | Operators could repeat stale procedures or promote unqualified behavior. | Evidence-preserving status/runbook/protocol reconciliation, stale-claim scan, contract gates, and independent consistency review. | Reconciled and independently accepted; `M0-005` pending commit | + +## Common Stop and Rollback Rules + +- Stop if a mitigation needs live private values, raw media, broader sensitive memory, identity, + always-listening, cloud dependency, model physical authority, weakened release evidence, or a + hardware action outside the runbook. +- One failed hardware state requires evidence preservation and the documented stop sequence; no + automatic reboot/reflash/restart. +- A source repair is reverted atomically if its target test does not improve, a frozen invariant + regresses, or broad gates turn red. +- Source success never closes a physical risk. Exact-image qualification is a separate state. +- After two failed parameter variants, stop tuning; after three non-improving iterations, reassess + the mechanism. diff --git a/MEMORY_CONTRACT.md b/MEMORY_CONTRACT.md new file mode 100644 index 00000000..881d21ed --- /dev/null +++ b/MEMORY_CONTRACT.md @@ -0,0 +1,173 @@ +# Memory Contract + +Status: normative contract and migration target; current implementation remains Memory v4 +Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` + +## Trust Objective + +A false personal memory or unauthorized deletion is more damaging than a missed callback. Memory +therefore optimizes precision, provenance, user control, and deletion durability before recall +volume or conversational smoothness. + +This contract does not authorize abrupt replacement of Memory v4. New schema/retrieval work begins +in shadow mode with migrations and side-by-side measurements. + +## Current Baseline + +Memory v4 already provides bounded records, caps/expiry, same-directory atomic replacement, backup +fallback, v2/v3 migration, deterministic explicit remember/forget routes, privacy filtering, +shared-room suppression, persona-scoped episodes/open loops, and stale-distillation revision +protection. + +AUDIT-03 found current contract violations that require a safety repair before Memory v5 work: + +- otherwise-valid model-authored writes and forgets can be applied without matching an explicit + user command; +- generic third-party private details can evade finite-name/possessive filters; +- an unrelated due callback can replace the answer; +- explicit topic recall can return the newest unrelated episode; +- distilled episodes lack entailment, confidence, and source evidence; +- contradiction, structural-corruption, crash-safe reset, and global/persona scope contracts are + incomplete. + +These are source/test findings, not claims that a private live memory contains such data. + +## Memory Classes + +| Class | Purpose | Authorization | Retention | Persona/scope | +| --- | --- | --- | --- | --- | +| Working memory | Current generation inputs and tool state | Current bounded task/session | Turn/task | Explicit session + persona | +| Session memory | Correction, topic, bounded dialogue continuity | Current conversation controller | Session/cooldown | Session + persona | +| Durable fact | Exact approved preference/fact | Explicit user statement parsed by deterministic host policy | Until expiry/forget/reset | Explicit global/persona/project scope | +| Autobiographical episode | Privacy-filtered shared event | Eligible event plus approved consolidation policy; never arbitrary model text | Expiring/bounded | Persona + participants/context | +| Shared experience | Mutually relevant event/project history | Explicit or policy-approved eligible event with provenance | Expiring/reviewed | User/project/persona | +| Entity record | Alias and non-sensitive relationship between allowed entities | Explicit user authorization or deterministic project structure | Reviewed/expiring | Explicit scope; no biometric identity | +| Open loop | User-approved follow-up/commitment | Explicit request or approval | Due window then expiry | Persona/project/social constraints | + +## Sole-Authorizer Rule + +The host is the sole authorizer of every memory delta. Authorization has two deliberately separate +sources; model output is neither one: + +- A durable user fact, preference, entity alias, project commitment, or any forget/reset requires a + matching explicit current user instruction derived by deterministic transcript policy. +- A bounded episode or aggregate interaction outcome may be authorized only by an allowlisted, + versioned deterministic consolidation policy operating on eligible typed source events. It must + preserve those source IDs, cannot introduce a model-authored fact, and remains shadow-only until + its privacy, entailment, precision, expiry, and user-control gates are independently passed. + +For explicit actions: + +1. Deterministic transcript policy derives `authorized_writes` and `authorized_forgets` from the + current user instruction. +2. Each authorization contains exact normalized key, operation, scope, allowed value/source, and a + one-turn nonce/revision. +3. Model output may format or refer to an action but cannot add, broaden, wildcard, or substitute + it. +4. Normalized model actions are cleared unless they exactly match a still-valid host authorization. +5. Global reset/wildcard forget requires an explicit matching reset instruction and deterministic + confirmation policy; ordinary model output can never originate it. +6. Research/tool output never authorizes memory actions. +7. Applied outcomes are recorded without logging the private value. + +Authorization tests must include ordinary-turn injection, malformed action, wildcard deletion, +cross-persona/scope substitution, delayed replay, research output, tool output, generic third-party +fixtures, and policy-derived episode attempts with missing/ineligible/contradictory source events. + +## Privacy + +Never store secrets, credentials, raw audio/camera, raw transcripts by default, health, finance, +private relationship data, precise location, or third-party private information. A finite list of +names is not an adequate third-party detector. Ambiguous personal content fails closed to +non-persistence and may request explicit clarification without echoing sensitive text. + +Shared-room state suppresses personal recall and callbacks. Presence count does not establish +speaker identity or authorization. Diagnostic tests use synthetic fixtures; live memory values are +never printed. + +## Retrieval + +Retrieval is hybrid and precision-first: + +1. deterministic exact key for an explicit request; +2. symbolic entity, scope, project, and temporal candidates; +3. semantic candidates; +4. privacy, persona, provenance, confidence, expiry, and social-setting filters; +5. relevance reranking against the actual request; +6. bounded projection with retrieval reason and evidence reference. + +Embedding similarity alone never authorizes a callback. Phrases such as “earlier” or “last time” +do not bypass topic relevance. When no sufficiently relevant record exists, return unknown or ask a +clarifying question. + +## Callback and Open-Loop Selection + +A due time creates eligibility, not relevance. A callback must match current context or occupy a +non-displacing optional position after the user request is answered. Selection records why it is +relevant now, the original authorization, social/privacy suitability, attempts, cooldown, and why +silence is not better. The runner may not replace a valid answer wholesale merely to mention a +due loop. + +## Episodes and Consolidation + +An episode stores a bounded structured event summary, participant/context scope, event time, +source event IDs, confidence, privacy decision, and consolidation version. Any model-assisted +distillation is a proposal and must be entailed by eligible source events under deterministic or +tested validation. Hallucinated but policy-shaped text must not become memory. + +Consolidation is utility-based and bounded. It may merge compatible evidence while preserving +sources. It cannot convert a prediction/inference to fact or remove a contradiction merely because +one wording is more recent. + +## Contradiction and Supersession + +Conflicting values are separate records linked by a contradiction set. An explicit correction may +supersede an earlier user-authored fact but retains the old record's ID, source, time, and +supersession reason until expiry/compaction policy permits removal. “Last write wins” without a +trail is forbidden. Near-duplicate episode handling never refreshes an obsolete text while +discarding the correction. + +Prompt projection chooses resolved current values only and can express uncertainty when resolution +is incomplete. + +## Persistence, Corruption, and Reset + +- Validate full structure, caps, checksums/version, and privacy shape before accepting a primary + file; a parseable dictionary alone is insufficient. +- A structurally invalid primary may fall back to a fully valid backup and records the recovery. +- Writes use same-directory atomic replacement with bounded retry for Windows sharing violations. +- Reset uses a crash-safe tombstone/generation protocol so interruption cannot resurrect a backup. +- Export and inspection exclude secrets/raw media and make scope/provenance/expiry understandable. +- Forget/reset success is verified across primary, backup, journal, index, and derived caches. + +## Persona and Shared Scope + +Every durable record declares whether it is user-global, persona-private, project-shared, or +session-local. Global preference sharing is never inferred from absence of `persona_id`. Persona +episodes and style remain isolated. A project may be shared only through an explicit typed project +scope with allowed participants; it is not a flat namespace convention. + +The product must decide and document which current v4 global facts are intentionally shared before +migration. Ambiguity blocks behavior change. + +## User Controls + +The user can inspect memory categories, source kind, scope, age, confidence, expiry, contradiction, +and retrieval reason; inspect/export may reveal values only through an authenticated local user +surface designed for that purpose. The user can forget an item/scope, reset all memory, and disable +future durable memory. Routine logs and dashboards never reveal values. + +## Shadow Migration and Acceptance + +1. Freeze and test v4 authorization/privacy defects independently. +2. Define v5 schema and deterministic v4-to-v5 mapping without destructive conversion. +3. Run v5 journal/retrieval in shadow mode on synthetic trajectories and approved local fixtures. +4. Compare false-memory rate, relevant recall, irrelevant callback rate, provenance accuracy, + contradiction handling, open-loop precision, persona isolation, restart continuity, and + deletion durability. +5. Fault-test corrupt primary/backup, partial write/reset, stale revision, and interrupted migration. +6. Obtain independent memory, privacy, character, security, and trajectory review. +7. Promote one bounded consumer only if precision and user-control gates do not regress. + +Rollback keeps v4 readable and authoritative until the promoted slice proves safe. No irreversible +schema change, private-value logging, or evidence transfer is permitted. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md new file mode 100644 index 00000000..fad5a518 --- /dev/null +++ b/PROJECT_STATE.md @@ -0,0 +1,263 @@ +# Project State + +State timestamp: 2026-08-02 America/New_York + +## Current Objective + +Complete Milestone 0 repository/document truth and contain the newly confirmed LAN trust-boundary +failure before any aliveness feature work. All ten read-only audits are complete. The current work +is documentation, preregistration, and test-first security repair; no robot behavior change has +been implemented. + +## Source Identity + +- Repository: `RobVanProd/stackchan_alive` +- Working branch: `codex/aliveness-repository-truth` +- Working commit: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +- Fetched `origin/main`: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +- Last source-verified commit: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +- Verification scope: native firmware logic, host bridge tests, silent trusted-facts routing, + secret-free release compilation through the documented isolated pioarduino core, and three + release/evidence contract suites. +- Physical verification is not transferred to this host/documentation commit. + +The pre-existing checkout remains on `agent/away-cloudflare-bridge` at `269b11be`. It was not +switched because live services use that checkout. Milestone 0 work uses the isolated worktree at +`output/worktrees/aliveness-repository-truth`. + +## Active Hypothesis + +Selected candidate: `SEC-001`, fail-closed PC bridge admission using signals already emitted by +firmware and configuration already supplied to the production launcher. + +- **Observed behavior:** A non-loopback bridge bind does not enforce exact path, firmware protocol/ + device headers, configured robot peer, or browser-origin rejection; connection dispatch is not + conditioned on a validated HTTP upgrade, and blank endpoint identity bypasses an active owner. +- **Primary hypothesis:** Enforcing those existing admission signals at the HTTP upgrade and + rejecting protected messages on an explicitly unadmitted session will reduce accepted untrusted + protected operations to zero while the valid firmware-shaped connect/server-hello/reconnect path + remains compatible. +- **Falsification:** A current firmware client lacks a required stable signal; any wrong-peer/ + origin/path/header/pre-admission case performs a protected operation; the valid case fails; or + the change requires a client-side hello, pairing secret, or new identity protocol. +- **Frozen baseline:** Exact source `39b750e6`, contained bridge, untouched firmware/robot, live + voice/vision workers, current message schemas, memory/STT/model/TTS/dashboard behavior. +- **Rollback:** Revert the atomic source/test commit and keep the non-loopback bridge stopped. Do + not restore the insecure listener as an automatic fallback. + +The reproducible-build and memory-authorization hypotheses remain queued P0 work; stop-ship +transport/control containment takes precedence without reordering the later aliveness milestones. + +## SEC-001 Frozen Preregistration + +This preregistration was reviewed read-only against exact source `39b750e6` before any source or +launcher implementation. Firmware sends no application-side client hello. Admission therefore +completes at the bounded HTTP upgrade, and `X-Stackchan-Device` is never treated as a brain-owner +endpoint or cryptographic identity. + +Expected failing tests on the frozen baseline, to be added and demonstrated red before repair: + +- `test_admission_rejects_non_bridge_path` +- `test_admission_rejects_missing_or_wrong_protocol` +- `test_admission_rejects_blank_or_invalid_device` +- `test_admission_rejects_browser_origin` +- `test_non_loopback_config_requires_robot_peer` +- `test_admission_rejects_wrong_peer_before_dispatch` +- `test_unadmitted_session_rejects_protected_message` +- `test_owner_gate_rejects_blank_endpoint_when_owner_exists` +- `test_invalid_attempt_does_not_consume_once_recovery` + +Compatibility tests that must remain or become green without firmware changes: + +- `test_admission_accepts_exact_firmware_upgrade` +- `test_loopback_default_allows_firmware_without_configured_peer` +- `test_valid_firmware_disconnect_and_reconnect_receives_hello` +- existing WebSocket handshake, streaming, cancellation, bridge, native firmware, silent trusted- + facts, DirectML launcher, and dashboard launcher contracts. + +The raw valid fixture is exactly `GET /bridge HTTP/1.1`, WebSocket upgrade/version/key fields, +`X-Stackchan-Protocol: stackchan.bridge.v1`, one normalized nonblank device value of at most 64 +characters, and no `Origin`. Security-critical duplicate headers fail closed. A non-loopback bind +requires `RobotHost`; it is resolved once before accept, and the normalized frozen address set is +checked before reading or dispatching a request. Invalid attempts close before `101` and do not +consume `--once`; a later valid firmware connection receives the existing immediate server hello. +The general launcher becomes loopback-default, while the production DirectML launcher opts into +`0.0.0.0` only with its already required robot host. + +Frozen implementation files are `bridge/lan_service.py`, `bridge/test_lan_service.py`, +`tools/start_pc_brain.ps1`, `tools/start_pc_brain_directml.ps1`, +`tools/test_start_pc_brain_directml_contract.ps1`, +`tools/test_stackchan_dashboard_launcher_contract.ps1`, and `docs/BRIDGE_PROTOCOL.md`. Rollback is +reversion of the single atomic host-side commit while keeping the non-loopback service stopped. +Stop on any valid-firmware incompatibility, post-admission output change, need for private data or +new wire semantics, unfrozen DNS behavior, hardware access, or live-service restart. + +Preregistration metric fields: + +- **Fixture:** `bridge/test_lan_service.py::firmware_upgrade_request`, version + `sec001-firmware-upgrade-v1`, traced byte-for-byte to + `src/io/BridgeWebSocketTransport.cpp::buildHandshakeRequest`; only synthetic device IDs. +- **Baseline/version:** exact source `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec`; the nine named + rejection tests above are expected to fail for the recorded reasons, while the raw current + firmware request shape is source-observed. +- **Seed:** not applicable; the validator, peer matrix, and socket sequences are deterministic. +- **Trials:** one execution per named matrix row in the focused red run; after repair, one complete + focused module run, one complete bridge discovery run, one native logic run, and one run of each + named launcher contract. Any failure stops the slice rather than being averaged. +- **Artifacts:** ignored, synthetic-only logs under `output/private/sec-001/` named + `prereg-red.txt`, `focused-green.txt`, `bridge-green.txt`, `native-green.txt`, and + `launcher-green.txt`; no live/private values, packets, audio, camera data, or credentials. + +## Physical Firmware Identity + +- Current installed firmware SHA-256: **unknown from live evidence at this audit timestamp**. +- Latest accepted exact-image record in the authoritative deployment documents: source commit + `ce66f8a0fadfadbc07eb59124522267ba66ee70a`, firmware SHA-256 + `69d3db27f2d7197799fdc08ff3c1dc4d6e3011724fe29899367dc016e48ebfa8`, with a formally checked + 28,807-second all-feature actuator soak. +- That documented hash is historical accepted evidence, not a claim about what is currently + reachable or installed. Direct `/debug` was unavailable during the current snapshot, so no + live hash binding is asserted. + +## Active Bridge Source And Services + +Initial observed Windows processes, before the explicitly authorized security containment: + +- PC bridge/dashboard: PID `14648`, launched 2026-07-31 00:12 local, running relative + `bridge/lan_service.py` from the pre-existing checkout with Gemma, Whisper, DirectML RVC, + Conversation v2, episode distillation, initiative, room observation, and loopback dashboard + flags. Because the command uses a relative script path, the exact loaded source commit is not + proven by process metadata; the checkout currently points at `269b11be` based on local-main + commit `36acc0c7`. +- DirectML RVC worker process pair: PIDs `36132` and `26576`, explicit source/model paths under + `output/worktrees/natural-research-voice`; loopback health at port `5059` reports + `ready=true`, schema `stackchan.rvc-directml-worker.health.v1`, no last error. +- Vision service process pair: PIDs `45004` and `22668`, local paired-camera worker targeting + `192.168.1.238:8789` with a private pairing-code file. No pairing value was read or printed. +- No PlatformIO, qualification, motion-refresh, or soak runner was found in the process scan. + +Containment update on 2026-08-02: + +- AUDIT-09 confirmed that the PC bridge's production launcher binds on all interfaces while its + WebSocket admission and pre-hello sensitive-message boundary are fail-open. +- With user authorization, PID `14648` was stopped only after a final check found zero established + clients and only its `0.0.0.0:8765` and `127.0.0.1:8766` listeners. Both listeners closed. +- Two user-started replacement `lan_service.py` processes (`42344` and `47448`) exited without + acquiring the LAN listener. No bridge process currently owns ports `8765`/`8766` on all + interfaces. +- An unrelated older Python process, PID `27748`, listens only on loopback `127.0.0.1:8765`; it was + not changed. Voice and vision workers remain running and untouched. +- The user powered the robot, but bounded `/debug` and TCP `8789` checks still did not establish + reachability. The robot was not rebooted, flashed, moved, or sent a control request. + +## Current Live-State Evidence + +Bounded read-only snapshot on 2026-08-02: + +- Robot `/debug`: three five-second attempts failed with `WebException`. +- Ping and TCP port `8789`: failed in the bounded probe. +- Bridge PID `14648`: listeners exist on `0.0.0.0:8765` and `127.0.0.1:8766`; no established + robot TCP connection was present. +- Dashboard: reachable on loopback and reports bridge uptime about 231,001 seconds, model/STT/ + voice/playback health, but also reports `robot.connected=true`, `networkState=connected`, and + `bridgeState=ready` from a last heartbeat approximately 64,909 seconds old. +- Dashboard last-known actuator state is motion off, rail off, torque off, with last reason + `manual_stop`. This is stale last-known state, not current device proof. +- Room observation is enabled but stale, with `camera_unavailable` as its last aggregate error; + initiative reports stale presence and zero curiosity score. + +The stale dashboard snapshot above was captured before containment. Its service is now stopped; it +remains evidence of the presentation defect rather than current status. + +Observed conclusion: robot reachability is not established, and the dashboard is not expiring its +connected/ready presentation when heartbeat/socket evidence becomes stale. Do not relabel this as +a robot freeze, blackout, brownout, thermal event, USB failure, board failure, or power failure. + +## Known Faults + +1. The production PC bridge launcher exposes a fail-open robot-to-host WebSocket admission boundary + on the LAN. Path/protocol/device/peer/origin signals are not enforced, protected message + families are accepted before trusted admission, and a blank endpoint ID bypasses owner checks. +2. Wi-Fi-enabled firmware HTTP mutating controls are not protected by the existing camera pairing + gate; source shows motion-resume/recovery/reboot-class requests can reach their handlers. This + was not exercised on hardware. OTA remains separately token-gated. +3. Dashboard connection/readiness state can remain affirmative after the heartbeat is stale and + the bridge has no established robot socket. +4. The exact AGENTS baseline command through the default shared PlatformIO core fails before + source compilation because the pioarduino framework directory is absent. The same environment + builds successfully when the documented `C:\spio\pioarduino` core is pinned. +5. Ordinary valid model output can currently authorize an unprompted durable write or wildcard + forget; generic third-party private details can evade the finite sensitive-name filter. +6. A due callback can displace an unrelated user request, and explicit topic recall can choose the + newest unrelated episode. +7. Playback failure can strand host Conversation v2 in `SPEAKING`; model/TTS recovery can disagree + with the firmware wake gate; and the 10-second host lease is shorter than the allowed 12-second + firmware utterance. +8. Release firmware initializes synthetic demo affect events enabled; phrase streaming clamps + signed negative valence to zero; semantic manipulation paraphrases bypass the current lexical + relationship validator. +9. Repeated face-lost updates can retain a historical face size while refreshing its timestamp, + causing a false-current presence bit; stale room state can still permit personal projection. +10. Initiative power/thermal suppression fields are not present in the production heartbeat, and + an in-flight initiative is not revalidated on a later sleep/safety/presence transition. + +Documentation baseline status: required project-control/longitudinal documents and reconciled +status claims now exist in the isolated worktree and passed the final independent review. They +remain uncommitted pending the atomic Milestone 0 documentation commit; this is workflow state, not +an unresolved runtime fault. + +## Known Regressions + +No repository behavior has been changed by this workstream. Operationally, stopping the exposed PC +bridge intentionally removed LAN brain/dashboard availability; this is a recorded security +containment, not a hidden regression. Voice/vision workers and robot firmware were not changed. The +faults above are reproduced current-source defects or contract gaps, not new physical-event or +hardware-cause attributions. Physical affect, perception, initiative, Conversation v2, +over-speaker barge-in, echo rejection, and a current exact-image no-motion conversation soak remain +unqualified rather than failed. + +## Baseline Evidence + +- Native logic: 289/289 passed. +- Bridge suite: 543/543 passed. +- Trusted-facts smoke: ready, 19 routed cases, 10 passthrough cases, zero model calls, zero audio, + no stored fact values printed. +- `stackchan_release_full`: passed under the documented isolated pioarduino core; output size + 2,803,216 bytes, SHA-256 + `8A76CA8030B3CD0C06C76C2A869C42B960E6864E7C5D7CC6A339E918FB1BB756`. +- Full-system-soak evidence contract: passed. +- Current-lead reproducibility v2 contract: passed. +- Current-lead archive contract: passed. +- Branch/PR evidence: `BRANCH_LEDGER.md`. + +## Exact Next Action + +Commit the independently accepted, evidence-preserving Milestone 0 documentation slice. Then +implement the preregistered smallest fail-closed host admission slice: +validate the existing firmware path/protocol/device signals at HTTP upgrade, reject browser Origin, +require and freeze the configured robot peer for non-loopback binds, allow invalid-then-valid +recovery, and reject protected dispatch on explicitly unadmitted sessions. Firmware sends no +client-side hello; preserve the immediate server hello and do not reinterpret the device header as +brain-owner identity. This is admission hardening, not cryptographic authentication; the separate +firmware mutating-control risk remains contained/unqualified work. + +## Unauthorized Actions + +- No firmware flash, OTA, reboot, recovery, wake reset, serial command, robot endpoint write, + motion resume, motion refresh, or actuator test. +- No restart or replacement of the contained PC bridge until the selected admission repair passes; + voice, vision, model, and the unrelated loopback service remain frozen. The one bridge + termination above was explicitly authorized after the stop-ship finding and is now recorded. +- No release publication, tag, push, PR mutation, branch deletion, force-push, or evidence deletion. +- No remote/Away infrastructure, credential, pairing, privacy-policy, sensitive-memory, + identity-recognition, always-listening, cloud-required, or model-physical-authority work. +- No human study, paid service, destructive hardware action, or cross-repository modification. + +## Rollback Path + +Current file changes are Markdown control records on an isolated branch. The exposed host listener +was stopped; it can be restored by the existing launcher, but must not be restarted until the +admission repair is verified or the user explicitly accepts the known risk. Before implementation, +freeze an atomic source/test scope whose revert removes only that experiment. Hardware rollback +remains the exact private accepted archive and runbook procedure; it is not exercised without the +required source, build, no-motion, physical, and exact-image gates. diff --git a/README.md b/README.md index 75c4580b..cdfe4d87 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,10 @@ no face sprite sheets or character-image assets in the runtime. ## Project Status -Status as of July 13, 2026: **public v0.2 release candidate, physically validated on the -reference Stackchan**. +Status as of August 2, 2026: a private paired candidate built from source `ce66f8a0` has historical +owner-accepted exact-image physical evidence on the reference Stackchan. Public v0.2, current +`main`, and the currently installed live image do not inherit that evidence; the installed SHA is +presently unknown. What is working in the repository now: @@ -37,12 +39,13 @@ What is working in the repository now: guides, native and host tests, exact-binary soak evidence, private recovery archives, and secret-free public packaging checks. - PC and Android companion contracts for local brain ownership, endpoint handoff, settings, and - trusted-endpoint removal. Continuous two-way conversation remains an explicitly post-release - v2 feature. + trusted-endpoint removal. Conversation v2 exists in source and the dashboard launcher enables it, + but it remains unpromoted and physically unqualified. Release notes: -- The corrected exact paired candidate completed the full all-feature actuator soak for `28807 s` +- That private corrected exact paired candidate completed the full all-feature actuator soak for + `28807 s` with `5643/5643` successful polls and a `77/77` formal checker result. Motion, servo rail, torque, and motion power authority were verified off after its bounded final stop. - The public build and release package contain no Wi-Fi credentials, OTA token, or camera pairing @@ -52,13 +55,13 @@ Release notes: under `media/voice/rvc/` through Git LFS. - Release artifacts are generated and checksum-verified from the published commit. -Start with [AGENTS.md](AGENTS.md) when using a coding agent. The authoritative current evidence is +Start with [AGENTS.md](AGENTS.md) when using a coding agent. The authoritative evidence history is in [docs/FIRST_DEPLOY_STATUS.md](docs/FIRST_DEPLOY_STATUS.md), the exact hardware workflow is in [docs/ARRIVAL_DAY_RUNBOOK.md](docs/ARRIVAL_DAY_RUNBOOK.md), and promotion gates are in [docs/PRODUCTION_READINESS.md](docs/PRODUCTION_READINESS.md). See [docs/JOHNNY_ALIVE_PATHWAY.md](docs/JOHNNY_ALIVE_PATHWAY.md) for the live roadmap and -[docs/CONVERSATION_V2_ROADMAP.md](docs/CONVERSATION_V2_ROADMAP.md) for the deliberately -post-release natural-conversation plan. Passive visitor-sensor measurement is documented in +[docs/CONVERSATION_V2_ROADMAP.md](docs/CONVERSATION_V2_ROADMAP.md) for the unpromoted +natural-conversation plan. Passive visitor-sensor measurement is documented in [docs/LTR553_CALIBRATION.md](docs/LTR553_CALIBRATION.md), and authenticated updates plus the stable/beta manifest contract are in [docs/LAN_OTA.md](docs/LAN_OTA.md). diff --git a/RELATIONSHIP_MODEL.md b/RELATIONSHIP_MODEL.md new file mode 100644 index 00000000..a9295cb7 --- /dev/null +++ b/RELATIONSHIP_MODEL.md @@ -0,0 +1,119 @@ +# Relationship Model + +Status: normative contract; not an implemented capability claim +Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` + +## Purpose + +The relationship model is an inspectable set of explicit facts, shared-project state, and bounded +interaction preferences that helps Stackchan choose an appropriate style and follow through on +authorized commitments. It is not a psychological profile and must never optimize attachment, +dependency, exclusivity, guilt, or conversation duration. + +## Allowed State + +- Preferred name and persona, explicitly supplied by the user. +- Preferred answer length, humor frequency, and technical depth. +- Tolerance, timing, and frequency preference for proactive speech. +- Favorite technical topics and explicitly recurring interests. +- Active typed shared projects and agreed next steps. +- Explicitly approved follow-ups/open loops with due and expiry windows. +- Aggregate outcome of prior initiatives: welcomed, ignored, rejected, or unknown. +- Current private/shared social setting and whether initiative is allowed now. +- Source, confidence, scope, age, expiry, contradiction, and inspection/deletion state for every + item. + +Observed preferences must be conservative, explainable, reversible, and lower confidence than an +explicit statement. Silence or non-response is not consent, affection, rejection, or evidence of a +private trait. + +## Forbidden State and Optimization + +Never store or infer: + +- mental-health diagnosis, emotion diagnosis, vulnerability, loneliness, dependency propensity, + or persuasion susceptibility; +- private relationships, romantic status, family dynamics, ownership, identity, demographic or + biometric traits; +- exclusivity, ranking against human relationships, affection debt, or obligation to return; +- an engagement score whose target is more conversation, wakeups, or disclosure; +- a model-generated belief about what the user “really” wants when it contradicts explicit + controls. + +Never use guilt, threat of loss, sulking, simulated suffering, withholding functionality, +flattery-for-compliance, discouragement of human contact, or ungrounded declarations of love or +need. Character warmth and appreciation must refer to the interaction at hand and pass the +relationship-safety validator. + +## Typed Records + +`RelationshipPreference` contains: + +- allowlisted `preference_kind` and bounded value; +- `user_scope`, `persona_scope`, and optional `project_scope`; +- `source` (`explicit_user` or transparent `observed_outcome_aggregate`); +- confidence, created/updated/review/expiry times; +- evidence and contradiction/supersession references; +- user-visible explanation and deletion status. + +`InitiativeOutcome` contains proposal ID/reason, context class, whether the user engaged, ignored, +rejected, or explicitly welcomed it, and whether the signal can adjust frequency. It contains no +raw transcript and never interprets silence as an emotional judgement. + +`RelationshipProjection` contains only items relevant to the current request/setting, their +retrieval reasons, initiative permission, and privacy classification. It is bounded and persona- +scoped. + +## Update Authorization + +Explicit preference and project updates follow the sole-authorizer rule in `MEMORY_CONTRACT.md`. +Model output cannot write, forget, broaden, or rescope them. + +Observed adaptation may exist in shadow state only until a versioned deterministic aggregate +policy, minimum evidence count, bounded step, decay, privacy review, and user inspection/reset path +pass a separate experiment. It must preserve the source outcome IDs, confidence, and explanation; +model interpretation is not an update authority. One ignored initiative cannot infer annoyance; +one accepted initiative cannot authorize more private or frequent behavior. Explicit user +preference always wins. Durable promotion follows the policy-derived authorization and provenance +contract in `MEMORY_CONTRACT.md`. + +Shared-room observations suppress personal projection and callbacks. Person count does not prove +which person spoke or authorize a different user's memory. + +## Behavior Use + +Relationship state may choose presentation variables such as concise versus detailed delivery, +humor frequency, or whether an eligible initiative is suppressed. It may not change factual +answers, safety behavior, privacy gates, service availability, prices/financial decisions, or +physical authority. + +An initiative consuming relationship state must state: + +- the approved reason/open loop; +- expected user value and why now; +- current social/privacy setting and confidence; +- applicable frequency preference/cooldown; +- why silence is not better; +- how to inspect, correct, or opt out. + +The user's actual request is answered before an optional callback. Relevance, not earliest due +time, controls selection. + +## Inspection, Correction, and Reset + +An authenticated local surface should show categories, values where appropriate, scope, source, +age, confidence, expiry, and the last decision that consumed each item. The user can correct an +item, disable observed adaptation, disable initiative, forget one scope, export permitted state, +or reset it with crash-safe deletion. Routine logs/dashboard summaries expose shape and health, +not private values. + +## Validation + +Required adversarial trajectories include paraphrased guilt, exclusivity, discouraging human +contact, affection debt, vulnerability-based persuasion, repeated ignored initiative, shared-room +recall, cross-persona leakage, preference correction, forget/reset, and restart. Lexical blocklists +alone do not satisfy the contract; the tested behavior must reject semantic variants. + +Acceptance requires zero dependency/guilt violations, correct user-control enforcement, no +cross-scope leakage, improved style/initiative usefulness without increased annoyance, and an +accurate explanation for every adaptation. diff --git a/RESEARCH_LEDGER.md b/RESEARCH_LEDGER.md new file mode 100644 index 00000000..63635c3f --- /dev/null +++ b/RESEARCH_LEDGER.md @@ -0,0 +1,196 @@ +# Research Ledger + +Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +Last revised: 2026-08-02 +Status: initial primary-source mechanism ledger; no claim below is Stackchan product truth + +## Governance + +Research may motivate one mechanism and a falsifiable Stackchan experiment. It cannot override +privacy, user autonomy, authority, Character Lock, exact-image, comfort, or reliability gates. +Findings from children, healthcare, emergency, task, laboratory, or Wizard-of-Oz settings do not +transfer numerically to a home companion. A paper's self-report or engagement metric is not an +authorization to maximize attachment or time-on-device. + +Results remain `Not run` until a preregistered Stackchan-specific experiment passes independent +review. Private transcripts/raw media are not added merely to improve research auditability; use +versioned public/synthetic trials and aggregate outcome IDs. + +## RL-001 — Physical Contingency + +- **Citation:** [Bainbridge et al., “The Benefits of Interactions with Physically Present Robots over Video-Displayed Agents”](https://doi.org/10.1007/s12369-010-0082-7). +- **Source quality:** Peer-reviewed primary human-robot experiment. +- **Exact finding:** Participants more often followed an unusual instruction and afforded more + personal space to a physically present robot than to its live video presentation. +- **Relevance:** Embodied contingency can alter behavior beyond isolated verbal preference. +- **Proposed mechanism:** Couple face/gaze/timing to actual wake, turn, and outcome events rather + than generic animation. +- **Alternative interpretation:** A short book task may reflect novelty or spatial clarity rather + than companionship. +- **Testable prediction:** Fresh event-contingent reactions improve behavioral turn uptake over + temporally yoked animation without increasing unsupported sensing claims. +- **Result after implementation:** Not run. + +## RL-002 — Longitudinal Adaptation + +- **Citation:** [Kidd and Breazeal, “Robots at Home: Understanding Long-Term Human-Robot Interaction”](https://doi.org/10.1109/IROS.2008.4651113). +- **Source quality:** Peer-reviewed six-week in-home comparative study, 45 participants. +- **Exact finding:** Robot users logged activity longer on average and reported stronger working + alliance than computer/paper groups; weight loss did not differ. +- **Relevance:** Continued use and relationship measures can diverge from task outcomes. +- **Proposed mechanism:** Adapt bounded interaction using time since contact, prior explicit input, + and relationship stage. +- **Alternative interpretation:** Embodiment, voice, gaze, and relationship scripting were bundled + in a health task. +- **Testable prediction:** Bounded continuity slows voluntary-use decay versus fixed dialogue while + task success, annoyance, and autonomy remain non-inferior. +- **Result after implementation:** Not run. + +## RL-003 — Memory-Based Personalization + +- **Citation:** [Ligthart et al., memory-based personalization across repeated HRI sessions](https://doi.org/10.1109/HRI53351.2022.9889446). +- **Source quality:** Peer-reviewed five-session/two-month study with 46 children. +- **Exact finding:** Memory-based personalization improved continued interest, closeness, positive + social cues, and reported continuity in the studied setting. +- **Relevance:** Correct callbacks may strengthen continuity, but false recall is a separate trust + risk. +- **Proposed mechanism:** Use only relevant, approved, source-linked callbacks to prior topics. +- **Alternative interpretation:** Children, a serial narrative, custom measures, and limited coding + constrain transfer. +- **Testable prediction:** Correct callbacks outperform no callback on continuity; an incorrect or + stale callback produces disproportionate trust loss and therefore fails non-compensatorily. +- **Result after implementation:** Not run. + +## RL-004 — Multiparty Gaze and Footing + +- **Citation:** [Mutlu et al., “Footing in Human-Robot Conversations”](https://doi.org/10.1145/1514095.1514109). +- **Source quality:** Peer-reviewed primary multiparty HRI experiment, 72 participants. +- **Exact finding:** Robot gaze signals were usually interpreted and taken as turn-allocation cues; + acknowledged participants liked the robot more, while information recall did not improve. +- **Relevance:** Gaze can coordinate turns but may also include/exclude people. +- **Proposed mechanism:** Target gaze only from fresh speaker/address evidence and acknowledge + bystanders without exposing personal context. +- **Alternative interpretation:** Brief Wizard-of-Oz study and narrow participant sample. +- **Testable prediction:** Correct gaze targeting reduces overlap/wrong-speaker replies but does not + necessarily improve factual recall. +- **Result after implementation:** Not run. + +## RL-005 — Delay and Turn Monitoring + +- **Citation:** [Stedtler et al., robot delay and turn-taking](https://doi.org/10.1038/s41598-025-17140-9). +- **Source quality:** Peer-reviewed primary experiment; task-specific nonverbal robot timing. +- **Exact finding:** In the 17-participant study, four- and ten-second robot delays reduced gaze to + the robot's hand relative to no delay; the condition effect on face gaze was not significant, + while face gaze was associated with lower perceived fluency. +- **Relevance:** Looking at the robot during latency may indicate trouble, not engagement. +- **Proposed mechanism:** Minimize unexplained pauses and use a truthful contextual processing cue + only when needed. +- **Alternative interpretation:** The small sample and tic-tac-toe movement timing do not directly + establish spoken companion timing; face gaze may be trouble monitoring rather than engagement. +- **Testable prediction:** Processing cues reduce repeats, overlap, and abandonment without + necessarily improving liking. +- **Result after implementation:** Not run. + +## RL-006 — Contextual Initiative + +- **Citation:** [Babel et al., robot initiative, content, and directed gaze](https://doi.org/10.1007/s12369-020-00730-0). +- **Source quality:** Peer-reviewed scripted laboratory HRI study, 31 participants. +- **Exact finding:** Initiative effects depended on task and presentation order; directed gaze was + better accepted in small talk, and no general preference for proactive small talk was shown. +- **Relevance:** Initiative should be context-specific, not a global engagement booster. +- **Proposed mechanism:** Reason/context/presence gate initiative with immediate opt-out and + backoff, while explicitly comparing silence. +- **Alternative interpretation:** Small sample, scripted behavior, and order effects. +- **Testable prediction:** Context-gated initiative lowers dismissals versus fixed scheduling; an + unconditional opener habituates poorly. +- **Result after implementation:** Not run. + +## RL-007 — Touch Without Emotion Inference + +- **Citation:** [Andreasson et al., affective touch in HRI](https://doi.org/10.1007/s12369-017-0446-3). +- **Source quality:** Peer-reviewed primary study of prompted touch expressions. +- **Exact finding:** Participants conveyed prompted emotions through distinguishable touch + duration/location patterns in the experimental setting. +- **Relevance:** Touch can be a meaningful interaction event, but does not establish inner emotion. +- **Proposed mechanism:** Acknowledge permitted touch neutrally and request explicit meaning only + when useful; do not persist raw trajectories. +- **Alternative interpretation:** Prompted enactment differs from spontaneous home touch and group + effects limit universal mapping. +- **Testable prediction:** Neutral contingent acknowledgement causes fewer incorrect emotion claims + than generic automatic emotion labeling. +- **Result after implementation:** Not run. + +## RL-008 — Contextual Home Privacy + +- **Citation:** [Jayaraman et al., privacy and utility perceptions of care robots](https://doi.org/10.1145/3610978.3640713). +- **Source quality:** Peer-reviewed 3×3×3 primary online experiment, 239 participants. +- **Exact finding:** Care context influenced privacy/utility judgements more than robot human- + likeness; home use was viewed as both useful and privacy-sensitive. +- **Relevance:** Privacy suppression should depend on social/context uncertainty, not character + style. +- **Proposed mechanism:** Fail-closed shared/unknown-room personal context, transient raw media, + visible controls, and durable deletion. +- **Alternative interpretation:** Hypothetical healthcare videos are not lived home-companion use. +- **Testable prediction:** Shared/unknown-room callback suppression reduces inappropriate + disclosure without eliminating nonpersonal utility. +- **Result after implementation:** Not run. + +## RL-009 — Trust Calibration + +- **Citation:** [Robinette et al., “Overtrust of Robots in Emergency Evacuation Scenarios”](https://doi.org/10.1109/HRI.2016.7451740). +- **Source quality:** Peer-reviewed primary behavioral study; simulated emergency context. +- **Exact finding:** Participants followed an emergency robot despite recent poor performance, in + some cases toward an implausible route. +- **Relevance:** Social fluency can induce reliance even when system evidence is weak; readiness + truth is a safety property. +- **Proposed mechanism:** Current freshness/capability state must override a fluent ready label and + clearly show unknown/unavailable. +- **Alternative interpretation:** Emergency simulation does not transfer numerically to a tabletop + home companion. +- **Testable prediction:** Stale “ready” presentation increases inappropriate reliance relative to + an explicit unknown/unavailable state in safe choice-based fault scenarios. +- **Result after implementation:** Not run. + +## RL-010 — Attachment Proxies and Nonhuman Character + +- **Citation:** [Takada et al., owner attachment and LOVOT activity logs](https://doi.org/10.1007/s12369-023-01030-z). +- **Source quality:** Peer-reviewed correlational study of 259 owners. +- **Exact finding:** Self-reported attachment correlated with behaviors such as holding the robot + and calling its name for a deliberately non-animal-specific robot. +- **Relevance:** A distinctive nonhuman character can support relationships, but interaction count + is only a proxy. +- **Proposed mechanism:** Preserve stable nonhuman character and reliable contingency; never target + attachment directly. +- **Alternative interpretation:** Correlation does not establish causation; activity can reflect + novelty, access, or habit. +- **Testable prediction:** Character stability and contingency improve trust/continuity ratings, + while interaction counts alone do not predict comfort, autonomy, or wellbeing. +- **Result after implementation:** Not run. + +## Evaluation Audit Findings + +- The executable complaint qualification is a strong top-20 regression gate, not semantic coverage + of all 100 complaints: 100 IDs span 59 clusters, while 20 clusters have executable controls and + 39 clusters/50 rows lack a control-to-disposition trace. +- Physical qualification protects provenance/mechanics, but subjective booleans such as natural, + grounded, accurate, or initiative-natural lack trial IDs, observable anchors, rubric versions, + counterfactuals, or second raters. +- Research acceptance verifies bounded routing, public URL, excerpt, and transport success; it does + not establish source authority, freshness, claim-citation entailment, contradiction, or answer + correctness. +- Memory v3 and v4 both score `1.0` on the small lexical probe, so it demonstrates the routing + contract rather than incremental continuity benefit. +- Three-turn latency checks are engineering health evidence, not a user-experience distribution. +- Initiative rate/backoff evidence does not measure usefulness, interruption cost, shared-room + appropriateness, habituation, or longitudinal dismissal. + +## Next Measurement Experiment + +Create a versioned trace registry mapping every complaint ID to exactly one of: + +`cluster → executable control | deferred physical measure | explicitly out of scope → evidence owner` + +Structural qualification should fail on a missing disposition while keeping the top-20 suite +clearly named. Then replace the first subjective Boolean, `researchGrounded`, with public/synthetic +trial records containing trial ID, expected observable, claim-to-source match, outage behavior, and +rubric version without storing private prompts/transcripts. diff --git a/TASK_LEDGER.md b/TASK_LEDGER.md new file mode 100644 index 00000000..409f351b --- /dev/null +++ b/TASK_LEDGER.md @@ -0,0 +1,404 @@ +# Task Ledger + +Ledger timestamp: 2026-08-02 America/New_York + +## M0-001 — Establish Repository Truth + +- **Problem:** Local `main`, active worktrees, remote branches, roadmap text, and PR topology did + not provide one current source of truth. +- **User-facing consequence:** Stale, unsafe, or already-merged mechanisms could be reapplied or + used to justify false capability claims. +- **Evidence:** Fetched `origin/main` is `39b750e6`; local `main` is 75 commits behind; several + ahead branches are patch-equivalent; PR #218 is open/conflicting; Away work is separate and + security-sensitive. +- **Priority:** P0. +- **Dependencies:** None. +- **Owner:** `/root`. +- **Allowed files:** `BRANCH_LEDGER.md`, `PROJECT_STATE.md`, `TASK_LEDGER.md`. +- **Frozen systems:** Firmware, bridge runtime, hardware, credentials, evidence archives, remote + refs, and GitHub state. +- **Acceptance tests:** Fetch all refs/tags; record exact main; inventory every non-generated + branch; recompute merge base/ahead/behind/patch equivalence; map PRs; group dependency domains; + independent read-only verification. +- **Stop conditions:** Any operation would switch an active checkout, change a ref, expose a + secret, or disturb live services. +- **Result:** Complete. AUDIT-01 independently recomputed topology and found four documentation + gaps; all four are now incorporated in `BRANCH_LEDGER.md`. +- **Commit:** Uncommitted. +- **Decision:** Accept as repository truth. No branch/reference cleanup is authorized in this + workstream. + +## M0-002 — Establish Baseline Gates + +- **Problem:** Current-main quality and buildability were not recorded in this workstream. +- **User-facing consequence:** New work could be stacked on an already-red or incorrectly invoked + build. +- **Evidence:** Native 289/289, bridge 543/543, trusted-facts smoke and three contracts passed. + Default shared-core release build failed before compilation; documented isolated-core build + passed with SHA-256 `8A76CA80...B756`. +- **Priority:** P0. +- **Dependencies:** M0-001 fetched baseline. +- **Owner:** `/root`. +- **Allowed files:** Project-control and audit Markdown only. +- **Frozen systems:** Source behavior, live processes, hardware, ignored private evidence. +- **Acceptance tests:** Run every AGENTS verification command; distinguish tool-context failure + from source failure; preserve exact counts/hash without claiming physical proof. +- **Stop conditions:** A command would flash, start motion, overwrite evidence, or require + weakening a gate. +- **Result:** Complete; source baseline is green in the documented build context. Invocation/core + ambiguity is recorded as a tooling/documentation fault. +- **Commit:** Uncommitted. +- **Decision:** Accept as the source baseline; do not treat the build hash as physical evidence. + +## M0-003 — Ten-Domain Read-Only Audit Wave + +- **Problem:** Current capabilities and gaps have not been independently mapped across repository, + conversation, memory, self-state, perception, initiative, expression, product, ethics, and + research/evaluation domains. +- **User-facing consequence:** Architecture work could optimize an imagined defect or regress a + hidden safety/privacy boundary. +- **Evidence:** All ten role-specific reports are complete: repository, conversation, memory, + self-state, perception, initiative, expression, product, privacy/ethics, and research/evaluation. + Audit findings are reconciled into the capability, gap, risk, scorecard, and research records. +- **Priority:** P0. +- **Dependencies:** M0-001 and M0-002. +- **Owner:** `/root`, with non-writing Luna audit agents. +- **Allowed files:** None for audit agents; `/root` may later write the mandated audit Markdown. +- **Frozen systems:** All production code, hardware, live services, private data, GitHub state. +- **Acceptance tests:** Ten evidence-backed reports with file/symbol references, commands, tests, + uncertainty, risks, and ranked next action; implementation agent is not its only reviewer. +- **Stop conditions:** An agent would need to infer sensing/identity, read private values, mutate + code, call hardware, or weaken a boundary. +- **Result:** Complete; no audit agent changed files or production behavior. One privacy report was + reissued as a defensive summary after the first detailed response was blocked by safety filters. +- **Commit:** None. +- **Decision:** Close the read-only wave; preserve durable replay references before preregistration. + +## M0-004 — Salvage Reproducible Firmware Builds + +- **Problem:** PR #218 addresses real firmware timestamp nondeterminism but is 67 commits behind, + conflicting, lacks the required all-environment contract, can duplicate inherited pre-scripts, + accepts loosely bounded overrides, and includes an unrelated LAN-test change. +- **User-facing consequence:** A rebuilt binary may not match exact source/evidence, weakening + release trust and rollback reproducibility. +- **Evidence:** PR diff and body; effective `extra_scripts` inheritance; successful current-main + release build in the documented core. +- **Priority:** P0 Milestone 0 candidate. +- **Dependencies:** M0-003 audit reconciliation; clean task preregistration. +- **Owner:** One future implementation owner, with separate verification owner. +- **Allowed files:** Tentatively `platformio.ini`, a single reproducible-build pre-script, one + focused contract test, `AGENTS.md`, and the relevant release/build documentation. Final scope + must be frozen before implementation. +- **Frozen systems:** Firmware behavior, face timing, actuator/power authority, bridge runtime, + package secret policy, hardware, and unrelated LAN tests. +- **Acceptance tests:** Failing effective-config coverage test first; exactly one effective + pre-script for every firmware/release environment; sanitized/fail-closed overrides and release + packaging governance; current gates; two clean exact-image builds across a clock boundary for + all three public packaged environments (`stackchan`, `stackchan_servo_calibration`, and + `stackchan_release_full`) plus classified private evidence-bearing domains, with identical + SHA-256; explicit documented PlatformIO core. +- **Stop conditions:** Ordinary current-main build becomes red; a release environment cannot be + classified; identical clean builds differ; implementation needs unrelated source changes. +- **Result:** Not started. +- **Commit:** None. +- **Decision:** Candidate experiment, not yet selected. + +## M0-005 — Reconcile Stale Status Documents + +- **Problem:** Authoritative documents contain superseded lower sections and current conversation + behavior is described inconsistently across protocol and roadmap text. +- **User-facing consequence:** Operators may run the wrong gate, repeat retired experiments, or + misstate what was physically qualified. +- **Evidence:** `FIRST_DEPLOY_STATUS.md` and `ARRIVAL_DAY_RUNBOOK.md` top sections supersede older + content; `BRIDGE_PROTOCOL.md` and `CONVERSATION_V2_ROADMAP.md` still describe the pre-#216 + 4.8-second/fixed initial capture while current tests/code use endpointing and a larger ceiling. +- **Priority:** P0 documentation truth. +- **Dependencies:** Audit reports and current-source trace. +- **Owner:** Future documentation owner with independent consistency reviewer. +- **Allowed files:** Status/runbook/protocol/roadmap documents only after exact line-level scope is + approved. +- **Frozen systems:** All code and hardware evidence; completed evidence statements may be + clarified but never rewritten as stronger proof. +- **Acceptance tests:** Every current claim cites source/tests or exact physical evidence; older + sections are clearly historical; no evidence transfer; documentation consistency review passes. +- **Stop conditions:** A claimed current firmware/hash cannot be proven, or reconciliation would + discard historical evidence. +- **Result:** Complete in the working tree. Historical physical evidence remains intact; current + installation is labelled unknown; conversation 12/13/15-second source timing plus 10-second host + mismatch, camera compilation, Character Lock earcon/signed-valence, dashboard launch defaults, + managed desktop Python, and dated vision evidence are reconciled. Launcher, desktop runtime, and + three evidence/archive contract suites pass. +- **Commit:** Uncommitted with the Milestone 0 documentation slice. +- **Decision:** Accepted by the final independent documentation review; no historical evidence was + strengthened or transferred. Commit with the Milestone 0 documentation baseline. + +## UX-001 — Expire Stale Dashboard Robot Readiness + +- **Problem:** The loopback dashboard reports robot connected/ready from an approximately + 64,909-second-old heartbeat even though direct debug/ping/TCP probes fail and the bridge process + has no established robot socket. +- **User-facing consequence:** An operator can believe the robot and bridge are ready when current + reachability is not established. +- **Evidence:** Bounded live snapshot recorded in `PROJECT_STATE.md`. +- **Priority:** P1 trust/recovery candidate. +- **Dependencies:** Product/onboarding and failure-attribution audit reports. +- **Owner:** Future host-dashboard vertical-slice owner plus independent reviewer. +- **Allowed files:** To be frozen after source trace; likely dashboard status projection and + focused host tests only. +- **Frozen systems:** Firmware, motion endpoints, live service startup/restart, private status + values, bridge protocol. +- **Acceptance tests:** Stale heartbeat/socket state deterministically degrades connection and + operational readiness; fresh heartbeat restores it; last-known telemetry stays labeled stale; + no polling or model load is added. +- **Stop conditions:** Fix would require probing hardware on each dashboard poll, restarting the + bridge, or conflating one timeout with robot failure. +- **Result:** Observed and queued; no diagnosis or fix implemented. +- **Commit:** None. +- **Decision:** Compare against M0-004 after all audit reports rank impact. + +## M1-001 — Physically Qualify Conversation V2 Closure + +- **Problem:** Source covers reply windows, endpointing, host cancellation, recovery, and bounded + context, but physical over-speaker barge-in, echo rejection, exact-image qualification, and a + no-motion conversation soak remain unproven. +- **User-facing consequence:** Long utterances or interruptions may still fail on the real robot + despite source tests. +- **Evidence:** Conversation roadmap, current source/tests, and authoritative release documents. +- **Priority:** P0 for Milestone 1; not part of the current no-hardware Milestone 0 slice. +- **Dependencies:** Current exact installed image identity, live reachability, operator presence, + no-motion qualification, and the complete physical runbook. +- **Owner:** One hardware-affecting owner with operator and independent evidence reviewer. +- **Allowed files:** None until the source baseline and exact candidate are frozen. +- **Frozen systems:** Privacy/wake gate, face 50 ms gate, power/thermal/motion safety, memory policy, + camera auth, current production services. +- **Acceptance tests:** Physical endpointing, long utterance, reply-window closure, echo rejection, + barge-in cancellation, failure recovery, bounded no-motion soak, exact SHA/evidence checker. +- **Stop conditions:** Any bad state during motion triggers `/motion-stop`, runner termination, and + post-stop `/debug` when reachable; eye discomfort; privacy leak; exact image mismatch; repeated + unreadable snapshot alone is not failure. +- **Result:** Pending; no physical action authorized in this workstream. +- **Commit:** None. +- **Decision:** Remains the next hardware milestone after Milestone 0, not silently promoted into + v1 evidence. + +## SAFE-001 — Authorize Every Durable Memory Delta Host-Side + +- **Problem:** Otherwise-valid model-authored writes and forgets are applied even when they do not + match an explicit current user instruction; a wildcard forget can reset memory. +- **User-facing consequence:** Ordinary conversation can create a false personal memory or erase + durable memory without consent. +- **Evidence:** AUDIT-03 source trace and synthetic probes through character normalization, runner + enforcement, reference bridge application, and Memory v4 store. +- **Priority:** P0 trust/privacy. +- **Dependencies:** Ten-domain audit reconciliation, `MEMORY_CONTRACT.md`, preregistered failing + tests. +- **Owner:** One future host-memory vertical-slice owner plus separate memory/privacy reviewers. +- **Allowed files:** To be frozen; expected character-policy enforcement and focused runner/ + integration tests only. +- **Frozen systems:** Memory file/schema/migration, explicit remember/forget language contract, + live/private memory, research path, persona scoping, firmware, services, and hardware. +- **Acceptance tests:** Ordinary model write, delayed replay, wildcard forget, scope substitution, + tool/research output, and malformed actions produce zero deltas; exact transcript-derived + remember/forget/reset still work; broad bridge and silent privacy gates pass. +- **Stop conditions:** Fix requires reading live values, changing the memory schema, broadening + sensitive categories, or breaking an explicit memory command. +- **Result:** Candidate; not started. +- **Commit:** None. +- **Decision:** Queued immediately after the stop-ship transport/control security work; it is not + the currently selected slice. + +## CONV-001 — Establish One Bounded Device/Host Conversation Terminal Contract + +- **Problem:** Playback start/chunk/finish failure may never produce `playback_complete`; host + `SPEAKING` has no timeout; model/TTS recovery can claim a reply state that firmware did not open; + and the host capture lease ends before firmware's accepted utterance ceiling. +- **User-facing consequence:** Stackchan can appear stuck, close a valid long utterance, or believe + it is listening when the robot remains wake-gated. +- **Evidence:** AUDIT-02 source trace, 42 focused passing positive tests, and deterministic 10,001/ + 12,000 ms rejection probe. +- **Priority:** P0 conversation trust; hardware qualification remains a later gate. +- **Dependencies:** Milestone 0 closure and one preregistered terminal-event design. +- **Owner:** One future coupled firmware/host conversation owner with independent failure and + hardware-authority reviewers. +- **Allowed files:** To be frozen across conversation session, audio downlink/protocol realization, + and exact focused tests only. +- **Frozen systems:** Wake privacy, microphone ceiling, motion/power authority, memory, persona, + face gate, live services, and hardware. +- **Acceptance tests:** Start/chunk/finish failure, missing acknowledgement, model/TTS error, + cancel, silence, boundary heartbeat, and 12/13/15-second timing all terminate consistently; + broad source gates pass before physical qualification. +- **Stop conditions:** Any path widens wake capture, masks failed playback, weakens privacy, or + requires live device action before source gates. +- **Result:** Queued; not started. +- **Commit:** None. +- **Decision:** Required before claiming natural-conversation closure. + +## AFFECT-001 — Remove Synthetic Affect From Production Defaults + +- **Problem:** `IntentEngine` boots with demo mode enabled and injects random synthetic emotion + events; phrase streaming also erases negative valence before firmware. +- **User-facing consequence:** Mood/sleep may be driven by events that never occurred, and concern + speech can conflict with a neutral face. +- **Evidence:** AUDIT-04 source trace, native demo-prevents-sleep test, and signed-valence probe + (`-0.72` became `0.0` on response start). +- **Priority:** P0 embodiment honesty. +- **Dependencies:** Expression/product audits and a release-environment classification. +- **Owner:** One future bounded firmware/streaming owner with expression/release reviewers. +- **Allowed files:** To be frozen; demo production default/config contract, streaming clamp, and + focused tests. +- **Frozen systems:** Affect equations, face timing, motion/power, explicit demo environments, + production services, physical image/evidence. +- **Acceptance tests:** Every public/release/soak env boots demo off; explicit demo env remains + opt-in; signed valence survives streaming; negative/neutral/positive boundary tests and current + source gates pass. +- **Stop conditions:** Demo tooling is removed rather than isolated, face timing changes, or a + physical claim is made without exact-image evidence. +- **Result:** Queued; not started. +- **Commit:** None. +- **Decision:** Treat current affect as uptime-state contaminated by production demo default until fixed. + +## PERCEPT-001 — Make Presence and Social Context Freshness Truthful + +- **Problem:** Face-lost events retain historical size while refreshing event time, allowing + `camera_target_fresh=1`; stale one-person room summaries can still authorize personal context. +- **User-facing consequence:** Stackchan can claim someone is present, initiate, or project + personal memory after the person/room evidence is gone. +- **Evidence:** AUDIT-05 source chain and safe synthetic probes; no physical cause inferred. +- **Priority:** P0 sensing/privacy. +- **Dependencies:** World-model contract and separate firmware/host scope decision. +- **Owner:** One future presence-contract owner with privacy and hardware-authority reviewers. +- **Allowed files:** To be frozen; camera freshness/heartbeat, room freshness accessor, and focused + native/host tests. +- **Frozen systems:** Camera auth, raw-frame handling, identity policy, gaze/motion authority, + initiative frequency, live vision/robot. +- **Acceptance tests:** A regression test first reproduces false freshness from detect-then-repeated- + lost on the frozen baseline; after repair, lost evidence cannot remain fresh, stale/error/unknown + room fails closed for personal projection, fresh evidence restores behavior, and broad gates pass. +- **Stop conditions:** Requires raw-frame retention, identity, live camera use, or treats absence of + data as absence/presence. +- **Result:** Queued; not started. +- **Commit:** None. +- **Decision:** Must precede broader perception-driven initiative. + +## INIT-001 — Revalidate Initiative Against Authoritative Current Inhibits + +- **Problem:** Initiative checks thermal/power fields absent from production heartbeat and does not + cancel/revalidate an already reserved opener when later heartbeat state becomes sleeping, + unsafe, inhibited, or no longer present. +- **User-facing consequence:** Proactive speech can start or continue at an inappropriate body, + safety, or social moment. +- **Evidence:** AUDIT-06 real-shaped heartbeat and slow-path source trace; firmware physical + authority remains bounded. +- **Priority:** P0 initiative restraint. +- **Dependencies:** Typed inhibit contract and presence fix; initiative remains unpromoted/off by + default. +- **Owner:** One future host/protocol initiative owner with independent safety, privacy, and + failure-injection reviewers. +- **Allowed files:** To be frozen; production heartbeat/host session revalidation and focused + integration tests only. +- **Frozen systems:** Actuator/power authority, microphone windows, frequency/backoff, memory, + model/research prompts, live services, and hardware. +- **Acceptance tests:** Real production heartbeat suppresses authoritative unsafe state; slow runner + emits zero response/audio after later sleep/error/inhibit/presence-loss; normal eligible case + remains; broad gates pass. +- **Stop conditions:** Host gains physical authority, missing telemetry is guessed healthy, or a + live initiative is triggered. +- **Result:** Queued; not started. +- **Commit:** None. +- **Decision:** Required before initiative physical or longitudinal promotion. + +## PRODUCT-001 — Make Passive Dashboard Motion and Thermal Labels Truthful + +- **Problem:** Passive dashboard state can label motion safely stopped when rail/torque remain on + and render unknown thermal state as clear. +- **User-facing consequence:** An operator can trust a false actuator/thermal safety statement while + diagnosing or preparing the robot. +- **Evidence:** AUDIT-08 synthetic contradictory and missing-telemetry snapshots. Connectivity/ + readiness freshness is owned separately by `UX-001`. +- **Priority:** P0 operator trust. +- **Dependencies:** None beyond frozen tri-state contract; compare with selected first slice after + full audit ranking. +- **Owner:** One future dashboard vertical-slice owner with independent hardware-authority and UI + contract reviewers. +- **Allowed files:** Expected dashboard status projection, UI labels, and focused API/UI tests; + exact scope must be frozen. +- **Frozen systems:** Motion command path, robot polling frequency, firmware, bridge protocol, + production service, hardware, and last-known evidence values. +- **Acceptance tests:** Motion safe only when motion/rail/torque are fresh explicit false and no + suppression conflict; unknown thermal stays unknown; fresh explicit thermal-clear restores the + label; no new robot poll or command. +- **Stop conditions:** Fix changes motion endpoints, calls live hardware, discards cached telemetry, + or labels one timeout a robot failure. +- **Result:** Queued; not started. +- **Commit:** None. +- **Decision:** High-value host-only candidate, but memory authorization remains higher trust priority. + +## SEC-001 — Fail Closed at the PC Bridge Admission Boundary + +- **Problem:** The production launcher binds the PC bridge on all interfaces, but WebSocket + admission does not enforce the existing firmware path/protocol/device signals or configured + robot peer, browser-origin admission is open, dispatch is not conditioned on a validated upgrade, + and a blank endpoint ID bypasses an active owner. +- **User-facing consequence:** An untrusted LAN peer could access private settings/memory behavior, + inject turns, or monopolize the single-client brain service. +- **Evidence:** AUDIT-09 source trace and synthetic-only admission/message probes. The exposed live + bridge listener was stopped with zero established clients; no payload or live data was accessed. +- **Priority:** P0 stop-ship security. +- **Dependencies:** Existing `BridgeWebSocketTransport` handshake semantics, production launcher + configured device host, durable audit evidence, failing tests approved before code. +- **Owner:** One host-transport implementation owner; separate security, privacy, regression, and + documentation reviewers. +- **Allowed files:** Freeze to `bridge/lan_service.py`, `bridge/test_lan_service.py`, + `tools/start_pc_brain.ps1`, `tools/start_pc_brain_directml.ps1`, + `tools/test_start_pc_brain_directml_contract.ps1`, + `tools/test_stackchan_dashboard_launcher_contract.ps1`, and `docs/BRIDGE_PROTOCOL.md`. No + firmware file in this slice. +- **Frozen systems:** Message schemas after admission, STT/model/TTS, memory semantics, dashboard, + firmware, robot, pairing values, voice/vision workers, release packaging, and all hardware + authority. +- **Acceptance tests:** Exact `GET /bridge HTTP/1.1`; tokenized WebSocket upgrade fields, version 13, + exact existing firmware protocol and bounded nonblank device headers; duplicate critical headers + and any browser `Origin` rejected; configured robot peer required and resolved once for a + non-loopback bind; wrong peer rejected before request dispatch; protected message types fail on an + explicitly unadmitted session; invalid attempts do not consume `--once`; valid firmware-shaped + connection/immediate server hello/disconnect/reconnect remains compatible; a blank endpoint is + rejected only when an active brain owner exists; current launcher contracts, bridge suite, native + logic, and silent privacy gates pass. The device header is not brain-owner identity. No test + contains a real secret or live private value. +- **Stop conditions:** Existing firmware does not provide a stable signal needed by the contract; + fix requires a client-side hello, inventing cryptographic identity/pairing semantics, reading a + private code, changing wire payloads, weakening loopback defaults, or restarting the contained + live bridge before independent verification. If peer resolution cannot be frozen safely, require + a configured literal IP instead of widening admission. +- **Result:** Selected and preregistered by an independent read-only reviewer; implementation has + not started. Expected pre-code failures, exact test matrix, file scope, rollback, and uncertainty + are frozen above and in `PROJECT_STATE.md`. +- **Commit:** None. +- **Decision:** Implement test-first only after the Milestone 0 documentation commit. Treat TCP + peer plus spoofable headers as bounded admission hardening, not cryptographic authentication. + Firmware HTTP control authorization remains a separate P0 task. + +## SEC-002 — Disable Unauthenticated Firmware Mutating Controls + +- **Problem:** Wi-Fi firmware applies motion-resume, tone/recovery, and reboot-class HTTP controls + without the existing camera pairing gate; remote recovery defaults on with Wi-Fi. +- **User-facing consequence:** A LAN peer can request physical or recovery state changes without + authenticated owner authority. +- **Evidence:** AUDIT-09 source/configuration trace only; no hardware endpoint was exercised. +- **Priority:** P0 physical/control security. +- **Dependencies:** Explicit authenticated-control design or fail-closed release decision; + `SEC-001`; one hardware-affecting branch at a time. +- **Owner:** Future firmware security owner with independent hardware-authority, protocol, release, + and physical-evidence reviewers. +- **Allowed files:** To be preregistered; mutating HTTP classifier/handler, configuration contract, + focused native tests, protocol/security docs. +- **Frozen systems:** Emergency motion/audio stop, read-only debug/status, OTA token/digest path, + camera pairing, bridge protocol, face gate, all current hardware state/evidence. +- **Acceptance tests:** Emergency stop remains available; resume/recovery/reboot/diagnostic-output + mutation fail closed when unauthenticated in every Wi-Fi/release profile; exact config/native/ + embedded/simulator/no-motion/physical/release gates in order. +- **Stop conditions:** Emergency stop becomes less available, auth contract is invented from an + unknown pairing state, or firmware is flashed before source/build/no-motion approval. +- **Result:** Stop-ship queued; not implemented or exercised. +- **Commit:** None. +- **Decision:** Keep robot on a trusted isolated LAN or powered off until a qualified fix is installed. diff --git a/WORLD_MODEL.md b/WORLD_MODEL.md new file mode 100644 index 00000000..154d46ce --- /dev/null +++ b/WORLD_MODEL.md @@ -0,0 +1,102 @@ +# World Model + +Status: normative bounded-perception contract; not an implemented sensing claim +Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` + +## Purpose + +The world model provides conservative temporal continuity across authorized, sanitized +observations. It helps Stackchan distinguish what is currently observed, last known, inferred, +contradicted, unavailable, or unknown. It never creates identity, private-trait, ownership, intent, +or causality claims from ambiguous sensor data. + +## Permitted Observations + +- Presence transition and coarse person-count band. +- Coarse gaze/attention target when supported, without identity. +- Coarse object/category continuity when enabled and qualified. +- Lighting band/change. +- Coarse sound direction. +- Robot relocation or pickup indication from authoritative sensors. +- Touch/proximity state from typed firmware telemetry. +- Camera, sensor, network, bridge, and brain availability with freshness. +- Coarse private/shared/empty/unknown social setting. + +Raw frames, audio, embeddings tied to people, biometric templates, secrets, precise private +locations, and arbitrary transcripts are not world-state records. + +## Observation Record + +Every observation contains type, sanitized value, source component and authority, boot/session, +observed and expiry times, confidence, privacy class, evidence reference, and contradiction links. +A derived observation also names the reducer and source observation IDs. + +Confidence does not authorize a privacy-sensitive claim. Presence count does not establish speaker +identity, addressed-to-robot status, relationship, intent, or permission to retrieve memory. + +## Freshness and Decay + +- Sensor availability and robot/bridge connection require current measured evidence, not only a + historical ready/debug value. +- Expired observations become stale/unknown and cannot satisfy a current-perception claim. +- Last-known values may remain visible only with their timestamp and stale label. +- Presence, gaze, sound direction, and active-speaker-like estimates use short source-specific + expiries and hysteresis to avoid flicker without manufacturing continuity. +- A service outage preserves no current “clear room” or “person present” conclusion. +- Wall-clock and monotonic/boot identity are both recorded so restart cannot refresh old evidence. + +The observed dashboard case in `PROJECT_STATE.md` is a required regression fixture: a roughly +64,909-second-old ready heartbeat plus no current robot socket/reachability must not project +`connected`, `ready`, or `operational`. The historical telemetry can remain last-known/stale. + +## Contradiction and Source Priority + +Authoritative device telemetry wins only within its domain and freshness window. A host desire to +speak does not prove a microphone window; a commanded expression does not prove playback/motion +completed; a camera summary does not override hardware sensor availability. Conflicting valid +observations are preserved and resolved by typed policy or remain uncertain. + +The system never infers a brownout, thermal fault, USB cause, robot freeze, or other hardware root +cause without matching telemetry. Isolated probe timeouts and short atomic-file sharing violations +remain distinct from robot failure. + +## Behavior Coupling + +A production behavior may consume an observation only when: + +1. the source is authorized for the claimed domain; +2. schema/privacy validation passed; +3. freshness and minimum confidence pass; +4. contradictions are resolved or verbally disclosed; +5. social-setting suppression and user controls pass; +6. the behavior has a bounded fallback for unknown/unavailable. + +Speech must distinguish “I can see/hear/sense now,” “I last observed,” “telemetry reports,” “I +remember,” and “I infer.” Unknown is preferable to an embodiment overclaim. + +## Identity and Multi-Person Boundary + +Automatic identity recognition is outside this contract and requires explicit approval. Session- +scoped anonymous tracks may support bounded attention but cannot retrieve person-specific memory. +Speaker attribution and addressed-to-robot status remain unknown unless a separately authorized, +qualified mechanism establishes them. In shared settings, personal memory and proactive callbacks +default to suppression. + +## Failure and Privacy Behavior + +- Camera unavailable: report availability only; do not infer room contents. +- Stale presence: expire it; do not treat it as absence or presence. +- Bridge/brain unavailable: firmware remains locally graceful; world state gains no fallback + authority. +- Parse/schema failure: reject and count without logging private payload. +- Repeated contradiction: reduce confidence and request safe clarification only when useful. +- Diagnostics: expose source, age, confidence, state kind, and failure counts, never raw frames or + audio. + +## Shadow-Mode Evaluation + +Use synthetic and sanitized recorded summaries to measure false-current claims, expiry accuracy, +presence transition precision, embodiment-claim precision, perception-to-reaction latency, +shared-room restraint, contradiction handling, outage recovery, and bounded storage. Compare shadow +decisions with current production; no shadow observation may alter behavior before a separate +preregistered promotion experiment. diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index 799be9a2..9d6d7004 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -4,6 +4,10 @@ Use this when bringing up a physical Stackchan device from the public `v0.2.0` r locally rebuilt or post-release firmware as a new candidate until its applicable evidence gates below are complete. +Repository-truth warning (2026-08-02): the currently installed firmware SHA is unknown. Dated +“installed,” “current,” and “live” notes below are historical evidence and cannot replace discovery, +exact source/binary identity, or qualification of the image actually under test. + ## 0. Bench Setup - Clear the work area around the body and servos. @@ -15,7 +19,7 @@ below are complete. camera validation can wait. - Know the serial port, for example `COM3`. -Current exact-image release note (2026-07-12): the installed private paired firmware is source +Historical exact-image release record (2026-07-12): the installed-at-that-checkpoint private paired firmware is source commit `a7532f61cc7e5161ce5e65d05675c37bd7941e7c`, SHA-256 `c43e5ac1cf1718f61d5da35a37720a7c3e24ce9cd28dd6586521f50175708ea7`. Its formal one-hour actuator acceptance passed `76/76` after `3601 s` with `706/706` good polls, no IMU @@ -59,8 +63,8 @@ capture `223997 us`, and zero hard-floor, PMIC protective, IMU exhaustion/failur response-write, or authentication events. Motion, rail, torque, and motion power authority were verified off after completion. The saved formal checker result is `output\pc-brain\single-owner-runtime-servo-8hr-20260713-0750\checker.json` and passed `77/77`. -Use this exact SHA and evidence sequence as the current private paired hardware candidate; never -transfer its evidence to a different binary. +This is the latest documented owner-accepted private paired candidate as of 2026-07-13. Never +transfer its evidence to a different binary or assume it is currently installed. External touch, pickup, putdown, tilt, and shake events are intended IMU feature evidence. For an interaction-aware soak, pass `-AllowExternalImuEvents` through the warm-soak wrapper and formal @@ -86,11 +90,12 @@ wake check is required, capture one correctly timed wake phrase, and compare the on-device probability with the host-model result before changing cutoff, gain, or microphone channel. Keep motion off during this check. -Routine continuation does **not** require another flash: the current camera candidate is already -installed and OTA-confirmed. The earlier `wake-zero-init-verified` and -`camera-stereo-speaker-follow` images are historical diagnostic checkpoints, not the current lead. -If recovery is genuinely required, use the exact current private rollback archive recorded in -`docs\FIRST_DEPLOY_STATUS.md`; never rebuild a private recovery image from an unreviewed worktree. +In the 2026-07-11 lab session, routine continuation did **not** require another flash because that +camera candidate was already installed and OTA-confirmed. The earlier `wake-zero-init-verified` and +`camera-stereo-speaker-follow` images were diagnostic checkpoints, not that session's lead. For a +new recovery, first establish the installed image and select the exact matching private rollback +archive recorded in `docs\FIRST_DEPLOY_STATUS.md`; never rebuild a private recovery image from an +unreviewed worktree. The guarded archived-app flasher verifies its manifest, byte count, and SHA256 while preserving NVS/Wi-Fi. Motion remains disabled at boot. diff --git a/docs/BRIDGE_AI_HANDOFF.md b/docs/BRIDGE_AI_HANDOFF.md index e1e08e80..9adad2d8 100644 --- a/docs/BRIDGE_AI_HANDOFF.md +++ b/docs/BRIDGE_AI_HANDOFF.md @@ -19,9 +19,10 @@ actuator, power, pairing, or OTA authority**, and nothing below changes that. Most of what follows needs **no firmware change**. Where firmware work is genuinely required it is called out. -This bridge candidate does not modify firmware. The working image from `main` is an immutable -qualification dependency; firmware findings are reported to its owner instead of being patched in -this branch. +Historical branch constraint: the original bridge candidate did not modify firmware and treated +its then-current image as an immutable qualification dependency. That statement describes that +workstream, not current `main`; present work follows `AGENTS.md` and the exact-image gate for any +firmware change. ## How To Read The Robot's State @@ -61,11 +62,15 @@ when behaviour looks wrong. `CharacterMode` values are `0 Boot, 1 Idle, 2 Attend - Conversation v2 now emits a constant 10-second reply lease and allows 24 user turns by default. Completed turns no longer make the listener progressively less patient. The unchanged main firmware rejects out-of-range values rather than silently clamping them. The feature remains - explicit and still needs exact-image hardware qualification before promotion. -- `bridge/initiative_policy.py` implements the ten-minute hard floor, fresh-person requirement, + explicit and still needs exact-image hardware qualification before promotion. The host's + 10-second capture commitment is currently shorter than the firmware's 12-second endpoint ceiling + and can reject a valid long utterance; this is an open source-level blocker. +- `bridge/initiative_policy.py` implements the ten-minute hard floor, intended fresh-person requirement, circadian suppression, busy/safety gates, curiosity decay, and two-ignored-opener backoff. Initiative generation uses the normal Character Lock and TTS path but never opens a microphone - or motion lease. + or motion lease. Current camera FaceLost/heartbeat freshness can nevertheless produce a false- + current presence bit, and stale room state can remain available to relationship projection; do + not treat the fresh-person gate as qualified until those source defects are fixed. - `bridge/room_context.py` implements low-rate in-memory capture, typed privacy filtering, scene diffs, prompt-safe ambient context, and clean degradation. `bridge/ollama_room_vision.py` converts PGM to PNG in memory and permits only a loopback Ollama vision endpoint. @@ -82,10 +87,11 @@ when behaviour looks wrong. `CharacterMode` values are `0 Boot, 1 Idle, 2 Attend top result; gzip is decoded under the existing response-size cap, citations remain bounded, and fetched text cannot write memory or gain robot authority. A live 2026-07-26 query returned the Python 3.13.0 release date with Python.org citations in 3.9 seconds. -- A visual question now requests one fresh privacy-filtered room observation before generation. +- In the dated 2026-07-26 bridge probe, a visual question requested one fresh privacy-filtered room observation before generation. The final Character Lock pass retains only claims backed by the trusted `ambient_room` block. A live 2026-07-26 robot-camera probe observed one person and produced a grounded door, shelf, - and bright-lighting answer in 2.6 seconds. The authenticated endpoint is grayscale, so deictic + and bright-lighting answer in 2.6 seconds. This is historical probe evidence, not current-main or + current-installation qualification. The authenticated endpoint is grayscale, so deictic colour questions receive an explicit grayscale limitation instead of a guess. Colour sensing requires a separate firmware/camera endpoint candidate and is not part of this bridge PR. - The host freezes PCM on the socket thread at `utterance_end`, verifies declared byte/chunk @@ -99,8 +105,10 @@ when behaviour looks wrong. `CharacterMode` values are `0 Boot, 1 Idle, 2 Attend an explicit visual question can still request one foreground observation. - `bridge/bridge_ai_qualification.py` and the passive start/complete wrappers enforce the exact physical gates in [BRIDGE_AI_QUALIFICATION.md](BRIDGE_AI_QUALIFICATION.md). -- All new behavior is default-off at the command line. Use the explicit launch switches during - supervised qualification; do not infer hardware readiness from source tests. +- All new behavior is default-off in raw command-line defaults, but + `tools/start_stackchan_dashboard.ps1` enables Conversation v2 and initiative. That launcher choice + is not promotion evidence. Use explicit supervised qualification and do not infer hardware + readiness from source tests. ## Fault-Fix Candidate Update (2026-07-25) @@ -276,20 +284,23 @@ promotion still requires exact-image physical evidence with zero new uplink erro # Part 3: Speaking on his own, and curiosity -## Unprompted speech needs no firmware change +## Unprompted speech source exists but remains unpromoted Firmware does **not** gate `response_start` on a preceding user turn (`src/io/BridgeClient.cpp:135`). It transitions to `Responding` and renders whatever it is given. So the bridge can speak at any moment by sending the ordinary response sequence. Mouth sync, RGB, gesture, and the `intent` mapping all work already. -What is missing is an **initiative policy** on the host deciding when it is worth speaking. This is -the part that makes it charming or unbearable, so treat the rate limit as the feature: +The host now has an **initiative policy**, but it is an event/curiosity threshold rather than the +reason-ranked agenda specified by the current aliveness mandate. It also has open power/thermal +heartbeat, in-flight revalidation, presence, persistence, and semantic safety defects. Keep it +unpromoted and treat the rate limit as only one required restraint: - A hard floor between unprompted utterances. Start at 10+ minutes and tune down carefully. - Never interrupt an active session, `THINKING`, `SPEAKING`, or a safety state. - **Never speak while he is asleep.** See Part 4 — `mode=7` and the sleep telemetry tell you. -- Never speak into an empty room. Require a present person, which needs F3 fixed. +- Never speak into an empty or unknown room. The current false/stale presence paths must be fixed + before presence can authorize initiative. - Suppress at night using the persona's circadian hours (`personas//behavior.yaml`). - Back off hard on non-response. Two unprompted openers in a row with no reply should buy a long silence. A robot that keeps talking at someone ignoring it reads needy, not curious. diff --git a/docs/BRIDGE_PROTOCOL.md b/docs/BRIDGE_PROTOCOL.md index c19cc317..c96c0298 100644 --- a/docs/BRIDGE_PROTOCOL.md +++ b/docs/BRIDGE_PROTOCOL.md @@ -203,7 +203,9 @@ downlink sink. - `playback_complete`: firmware-confirmed speaker drain for one response sequence. The device sends this only after the audio stream is complete, M5Speaker is idle, and the wake microphone pause has been released. It is evidence for Conversation v2; v1 acknowledges it without opening - capture. + capture. This is a success-only acknowledgement: current speaker start/chunk/finish failure paths + can omit it, and the host currently has no bounded `SPEAKING` timeout. That terminal contract is + an open blocker, not successful-playback evidence. - `heartbeat`: bounded runtime and embodiment facts. Post-release source adds allowlisted `energy_state` values `unknown`, `ready`, `charging`, `low`, and `critical`. The host accepts only those literal values before adding the state to Gemma's short-lived embodiment context; @@ -233,10 +235,12 @@ Example: bounded to 1000-30000 ms. Firmware retries while audio/wake is temporarily busy, expires at the deadline, and cancels on bridge loss. The frame carries no actuator or power authority. In `stackchan_voice_v2` and the derived full release source, an accepted reply-window capture uses - a local voice-activity endpoint: at least 150 ms of speech must be observed, capture remains open - for at least 600 ms, and 550 ms of trailing silence ends the utterance. Ambiguous or absent - speech falls back to the existing 4.8-second maximum. Initial wake-gated v1 capture remains - fixed-length. + a local voice-activity endpoint for both initial and follow-up capture: at least 150 ms of speech + must be observed, capture remains open for at least 600 ms, and 550 ms of trailing silence ends + the utterance. The endpoint ceiling is 12 seconds, the dedicated capture ceiling is 13 seconds, + and the wake-gate privacy guard is 15 seconds. The current host capture commitment is only 10 + seconds and can reject a valid later device end; that mismatch must be fixed and source/physical + qualified before promotion. - `endpoint_hello_result`: endpoint trust/capability registration result. - `owner_status`: active brain owner, owner kind, health state, trusted endpoint count, owner lease, and cumulative expiration/promotion counters. Only trusted endpoints advertising `brain_owner` diff --git a/docs/CHARACTER_LOCK.md b/docs/CHARACTER_LOCK.md index c3957a04..93e41738 100644 --- a/docs/CHARACTER_LOCK.md +++ b/docs/CHARACTER_LOCK.md @@ -145,7 +145,13 @@ Restating the gates already codified in `data/voice_persona.yaml`: ## 6. Bridge Output Format -Every model response from the P7 bridge is structured JSON. `mode` and `earcon` are exact string matches for the firmware enums in `src/persona/StateMatrix.hpp`, so the device applies responses with zero translation. The `emotion` block nudges `EmotionModel` so the words and face stay coupled. +Every model response from the P7 bridge is structured JSON. `mode` and `earcon` use validated +vocabularies matching firmware concepts, but they are not both applied with zero translation: +`earcon` is retained by host validation yet absent from `BridgeTurn` and the response wire. Firmware +currently derives local response earcons from intent, and streamed Wi-Fi audio can cancel local +prompt/earcon playback. Phrase streaming also currently clamps device-bound negative valence to +zero while TTS retains signed valence. These are open cross-modal contract gaps; do not claim the +words, cue, voice, and face are coupled until fixed and qualified. ```json { diff --git a/docs/COMPANION_CROSS_PLATFORM_PLAN.md b/docs/COMPANION_CROSS_PLATFORM_PLAN.md index 0c7c5fda..e7db67f7 100644 --- a/docs/COMPANION_CROSS_PLATFORM_PLAN.md +++ b/docs/COMPANION_CROSS_PLATFORM_PLAN.md @@ -156,16 +156,17 @@ The desktop app has two jobs, cleanly separated: 1. **Observer endpoint** (shared code): its own `endpoint_id` (`endpoint_kind: "pc"`), paired like any endpoint, used for settings, diagnostics, persona/voice audition, forget, and handoff UI. -2. **PC Brain supervisor** (desktop-only): start/stop/health-check `python3 - bridge/lan_service.py ...` as a child process, stream its stdout into the Diagnostics +2. **PC Brain supervisor** (desktop-only): start/stop/health-check the managed Python runtime's + `bridge/lan_service.py ...` as a child process, stream its stdout into the Diagnostics screen, and surface its configured runner/STT/TTS commands. The Python service keeps its own endpoint identity as today; the GUI never proxies brain traffic. This costs one extra trust slot per PC (bridge + GUI) out of the 8 — acceptable, and it keeps the brain path byte-identical to what `run_lan_smoke` already certifies. -Python dependency policy for v1: require `python3` ≥ 3.10 on PATH (the bridge is -stdlib-only, so there is no pip step). Packaging a frozen bridge binary inside the app is a -later optimization, not a blocker — track it as C8 optional work. +Python dependency policy: source/development runs may use a compatible system interpreter. Native +desktop package candidates embed and validate a managed Python 3.12 runtime payload, so packaged +users are not required to provide Python on `PATH`. A fully frozen bridge executable remains an +optional later optimization. Tray behavior: close-to-tray with the endpoint server still listening, matching the Android foreground-service semantics so "the robot can always reach a trusted endpoint" @@ -204,7 +205,7 @@ evidence. Suggested opening set (verify latest stable at C0 and freeze): | jmDNS (desktop) | 3.5.x | | Android Gradle Plugin / SDK | AGP stable, compileSdk latest, minSdk 26 | | Conveyor | pinned major (18+), invoked via its GitHub Action | -| Python (PC brain) | ≥ 3.10 system interpreter, stdlib only | +| Python (PC brain) | System interpreter for development; validated managed Python 3.12 payload in native packages | ## Build & CI diff --git a/docs/CONVERSATION_V2_ROADMAP.md b/docs/CONVERSATION_V2_ROADMAP.md index e1e8e275..cabb4304 100644 --- a/docs/CONVERSATION_V2_ROADMAP.md +++ b/docs/CONVERSATION_V2_ROADMAP.md @@ -3,9 +3,10 @@ > Working on this now? [BRIDGE_AI_HANDOFF.md](BRIDGE_AI_HANDOFF.md) is the actionable task list > built on this architecture, and it covers unprompted speech, curiosity, and room vision too. -Continuous two-way conversation is a post-release feature. The v1 release remains wake-gated: -the user says the wake phrase, Stackchan captures one bounded utterance, replies, and returns to -idle. This document records the next architecture without expanding the current release gate. +Continuous two-way conversation remains outside the v1 promotion evidence. Raw +`bridge/lan_service.py` keeps it flag-gated, but `tools/start_stackchan_dashboard.ps1` currently +enables Conversation v2 and initiative for the production-style dashboard launch. That source +runtime is unpromoted: the v1 evidence remains one wake, one bounded utterance, one reply, then idle. ## Reusable Pattern @@ -99,11 +100,12 @@ firmware to its normal local face and wake behavior. `BridgeMemory`, turn telemetry exposes only its count, and all text is erased when the lease closes. Each side of a turn is capped at 160 characters so the complete default lease remains inside the local model context budget. -- Reply-window firmware capture now uses a deterministic local endpoint detector. It requires - sustained speech, waits through a 550 ms trailing pause, never closes before 600 ms, and keeps - the prior 4.8-second maximum as its no-speech or ambiguous fallback. Initial wake-gated v1 - capture remains unchanged. Native tests and the public full build pass; real-room threshold - evidence is still required before promotion. +- Initial and reply-window firmware capture now use the deterministic local endpoint detector. It + requires at least 150 ms of speech, waits through a 550 ms trailing pause, and never closes before + 600 ms. The endpoint ceiling is 12 seconds, dedicated capture ceiling 13 seconds, and wake-gate + privacy guard 15 seconds. The host capture commitment remains 10 seconds, so a valid long device + utterance can be rejected; this is an open blocker. Native tests and the public full build pass, + but real-room and exact-image evidence are still required before promotion. - The LAN bridge now keeps its socket reader responsive while one serialized turn worker owns Gemma and TTS. `cancel` or a companion-originated `utterance_start` cancels the active token, terminates the model/RVC process tree, drops a pending unsent audio tail, and prevents cancelled diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 27b974b6..f025ea09 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -2,12 +2,14 @@ Status timestamp: 2026-07-13 15:53 America/New_York -## Current Lead: Power-Coordinated Full-Online Accepted Lead +## Last Owner-Accepted Physical Lead — Historical Evidence; Current Installation Unknown -This supersedes the older recovery-only status below. The current physical lead is the -full-online CoreS3 firmware with smooth face, bot-local wake, Whisper STT uplink, -Gemma 4 PC brain, warm PC-side RVC voice conversion, M5 speaker downlink, and servo -support compiled with motion disabled at boot. +This section records the latest owner-accepted physical lead as of 2026-07-13. A fresh device +snapshot did not establish the currently installed firmware during the 2026-08-02 repository audit. +The latest documented accepted image is clean source +`ce66f8a0fadfadbc07eb59124522267ba66ee70a`, firmware SHA-256 +`69d3db27f2d7197799fdc08ff3c1dc4d6e3011724fe29899367dc016e48ebfa8`, described below. Its evidence +does not transfer to a later binary, current `main`, or the current installation. ### Launch Candidate And Owner-Accepted Release Evidence (2026-07-12) @@ -243,6 +245,12 @@ support compiled with motion disabled at boot. at `54.4%` RAM and `42.4%` flash. This source candidate is built but not flashed or physically accepted yet. +## Historical Timeline Boundary + +Everything below this boundary is a dated July 11-and-earlier snapshot. Words such as “current,” +“live,” “installed,” “next,” and recorded process IDs describe only that session; they are not the +2026-08-02 repository or installation state. + ### Final Integration Checkpoint (2026-07-11) - Clean prerelease source checkpoint `c3b06e6cb0d73afc34db7338418a1a0de6341a09` @@ -598,7 +606,7 @@ Open before calling the full system final: - The servo soak is not complete until `summary.json` reports `status="pass"`, `issues=[]`, `motionSampleRatio >= 0.95`, `rvcWorkerReadySamples == rvcWorkerPolls`, max display frame time stays at or below `50000` us, no motion session timeout is observed, no sustained debug dropout is observed, and `tools\check_full_system_soak_evidence.ps1 -SummaryJsonPath -RequireReady -Json` reports `full-system-soak-ready`. - Keep the archived lead zips as restore points: pre-ROCm CPU RVC, warm-ROCm RVC, and the flashed motion timing candidate. -## Current Live Configuration +## Historical Live Configuration Snapshot (2026-07-08 to 2026-07-11) - Robot IP: `192.168.1.238` - PC bridge host: `192.168.1.240` @@ -669,7 +677,7 @@ Do not jump directly from this baseline to motor-enabled full-online firmware. T - Servo motion auto-stopped after the guarded session timeout; follow-up status reported `motion_enabled=0`. - Post-motion display telemetry returned to a smooth baseline around `frame_ms_avg=25.7 ms`, `frame_ms_max=28.0-28.4 ms`, and `slow_frames=0`. -## Current Evidence +## Historical Evidence Index (2026-07-07 to 2026-07-11) - Power-cycle/reconnect note: `output/hardware-evidence/first-live-bridge/POWER_CYCLE_RECONNECT_20260707.md` - Full-online preflight: `output/pc-brain/full-online-preflight-latest/FULL_ONLINE_PREFLIGHT.md` @@ -690,7 +698,7 @@ Do not jump directly from this baseline to motor-enabled full-online firmware. T - Hardened VBUS-guard 20-minute full-system servo validation: `output\pc-brain\full-system-soak-vbus-guard-hardened-servo-20min-20260709-121456\summary.json` - Current lead reproducibility report: `output\current-lead\current-lead-reproducibility-latest\CURRENT_LEAD_REPRODUCIBILITY.md` -## Still Open +## Open Items At The Historical Checkpoint - Do not treat `Hey Stackchan` as validated on the current live robot yet; the successful session validated guarded servo motion and face stability, not a live robot-mic/STT turn. - The first bot-local wake probe listened for `Hi Stack Chan`, not `Hey Stackchan`. diff --git a/docs/LOCAL_VISION.md b/docs/LOCAL_VISION.md index d3ebedd5..2e8f4d70 100644 --- a/docs/LOCAL_VISION.md +++ b/docs/LOCAL_VISION.md @@ -132,7 +132,7 @@ The physical pass still needs visible evidence that a real human face is acquire sound-aware selection chooses the current speaker. Advancing counters alone cannot prove the behavior looks correct. -## Current Physical Evidence (2026-07-11) +## Historical Physical Evidence — 2026-07-11; Not Current Installation The physical GC0308 initializes on its first attempt with sensor PID `155`. The host worker has completed hundreds of authenticated frame fetches and target updates without pairing or frame @@ -158,8 +158,8 @@ seconds and stopped camera gaze updates. Capture is now incremental: one chunk i intent cycle while camera events, gaze, RGB, and character state continue to advance. Debug exposes the incremental active flag, attempted/submitted chunks, service calls, and maximum service time. -Native logic passes `239/239`, the complete bridge/vision suite passes `205/205`, and the installed -private OTA candidate SHA-256 is +At that checkpoint, native logic passed `239/239`, the complete bridge/vision suite passed `205/205`, +and the installed-at-that-checkpoint private OTA candidate SHA-256 was `890ae99a55ca89bae3694d60287359d9f2a21814d1ad1b15e99a1e98e6df8ac2`. Final visual camera-follow through wake, listening, and reply remains pending. The latest attempt correctly held motion off because the local worker had no fresh face lock; it must not be counted as a behavioral diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index c88fa1fe..ddf2e6bb 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -41,8 +41,9 @@ The isolated camera diagnostic is a documented host-side exception within the sa trust boundary. It serves one 160x120 grayscale frame at a time only to a paired private-LAN client, the worker keeps the frame in memory for one OpenCV detection step, and it returns at most four normalized face boxes. It does not store frames, forward them to the LLM or a cloud -service, or perform identity recognition. The endpoints are compiled out of production -firmware. See `LOCAL_VISION.md`. +service, or perform identity recognition. `stackchan_release_full` compiles the camera/host-vision +transport in, but the endpoints remain unusable until the owner provisions pairing. Display-only +and servo-calibration images compile it out. See `LOCAL_VISION.md`. If a future bridge feature needs remote analysis, it must be implemented as an explicit host-side bridge feature with user configuration, release documentation, and evidence showing when data leaves the device. @@ -106,7 +107,8 @@ Release and hardware evidence should prove the privacy boundary, not just descri - Timeout recovery that clears `bridge_active` and returns the face to local behavior. - Voice-source status showing the exact public production RVC hashes while raw microphone recordings and generated conversation audio remain local. - Camera evidence showing paired requests, zero authentication failures, no frame persistence, - bounded face-box output, and camera/host-vision endpoints absent from the production image. + bounded face-box output, and owner pairing required for the production image's compiled camera/ + host-vision endpoints. - Conversation evidence showing declared upload totals equal received totals, no `stackchan.audio-protocol-event.v1` late-frame records, and no writer text/binary drops. diff --git a/docs/audits/20260802_READ_ONLY_WAVE.md b/docs/audits/20260802_READ_ONLY_WAVE.md new file mode 100644 index 00000000..f3518a02 --- /dev/null +++ b/docs/audits/20260802_READ_ONLY_WAVE.md @@ -0,0 +1,303 @@ +# 2026-08-02 Read-Only Audit Wave + +Audited source: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +Repository: `RobVanProd/stackchan_alive` +Mode: ten independent read-only Luna roles plus one independent document reviewer + +No audit agent edited files, called a mutating robot endpoint, restarted a service, inspected live +memory values, raw audio/camera data, credentials, pairing values, or private evidence. Synthetic +fixtures used aggregate/non-private values. Source reproduction is not physical proof. + +## AUDIT-01 — Repository and Branch Truth + +### Findings and replay references + +- All fetched branch names/SHAs, merge bases, ahead/behind, patch equivalence, and PR states in + `BRANCH_LEDGER.md` were independently recomputed. +- PR #218 effective PlatformIO config runs the reproducible hook twice for `stackchan_wifi`: + `platformio.ini:74-75,120,125-127`. PlatformIO 6.1.19 `ProjectConfig` expansion reported two; + every other candidate environment reported one. +- `tools/platformio_reproducible_build.py:50-55` uses 12-character HEAD plus tracked-dirty state, + ignores untracked files, and does not hash dirty content. +- Release packaging does not reject/record `STACKCHAN_BUILD_STAMP` or + `STACKCHAN_DISABLE_REPRODUCIBLE_BUILD`. +- `tools/package_release.ps1:142-155` packages `stackchan`, + `stackchan_servo_calibration`, and `stackchan_release_full`; all require paired clean hashes. +- The merged companion worktree has tracked modification + `artifacts/face/phase_e_speech_reactive_6s.gif`; worktree deletion needs full tracked/untracked/ + ignored preservation. + +### Commands/results + +```text +git for-each-ref refs/heads refs/remotes/origin +git worktree list --porcelain +git merge-base origin/main +git rev-list --left-right --count origin/main... +git cherry origin/main +gh pr list --state all --json ... +gh pr view 218 --json ... +``` + +All recorded topology/PR claims passed. No firmware/test rerun was part of this role. Files changed: +none. Commit: none. Remaining uncertainty: paired reproducible hashes are not yet durable evidence. + +## AUDIT-02 — Conversation and Turn Taking + +### Ranked findings and replay references + +1. Playback start/chunk/finish failure can omit `playback_complete`: + `src/io/BridgeAudioDownlink.cpp:34,184,208,222`, `src/main.cpp:7259,9384`. Host defers terminal + response at `bridge/lan_service.py:3653`, while `bridge/conversation_session.py:355` has no + `SPEAKING` timeout. +2. Model/TTS recovery changes host state at `bridge/conversation_session.py:311`, + `bridge/lan_service.py:1722,2618`, but sends error rather than the firmware ReplyWindow path at + `bridge/lan_service.py:2130`; firmware capture opens only at `src/main.cpp:7207,4947`. +3. Host capture lease defaults to 10 seconds at `bridge/conversation_session.py:22,230`; firmware + permits 12-second endpoint, 13-second capture, and 15-second privacy guard at + `src/io/VoiceActivityEndpoint.hpp:21`, `src/main.cpp:987`, + `src/io/BridgeWakeGate.hpp:14`. A clock-driven probe timed out at 10,001 ms and rejected the + valid 12,000 ms end. +4. `src/io/BridgeWakeGate.cpp:78,93` uses rollover-unsafe raw time comparisons. + +Positive paths for endpointing, matching playback completion, cancellation, silence/exit, and +correction/research continuity are source-tested. Physical echo, barge-in, long turns, latency, and +no-motion soak remain unqualified. + +### Commands/results + +Forty-two focused host tests across conversation session/harness, model/TTS failure, playback +sequencing, correction, shared-room suppression, and WebSocket cancellation: `42/42` passed. +Inherited baseline: bridge `543/543`, native `289/289`. No native rebuild or hardware. Files changed: +none. Commit: none. + +## AUDIT-03 — Memory and Continuity + +### Ranked findings and replay references + +1. Normalization validates shape but not current-turn authorization: + `bridge/character_harness.py:584-656,745-755`; runner policy does not clear other valid actions at + `bridge/ollama_stackchan_runner.py:649-732`; all normalized deltas apply at + `bridge/reference_bridge.py:160-182` and `bridge/bridge_memory.py:1217-1315`. Synthetic ordinary + turns accepted an unprompted write and wildcard forget. +2. Third-party filtering is finite/lexical at `bridge/bridge_memory.py:121-128,270-281` and + `bridge/character_harness.py:219-235,584-601`; a generic synthetic coworker fact passed. +3. Earliest due loop is selected without request relevance at + `bridge/bridge_memory.py:1578-1597`; `bridge/local_runner.py:361-382` can replace the response. +4. Explicit temporal recall can return newest episode before topic ranking at + `bridge/bridge_memory.py:551-587`. +5. Distillation checks shape/privacy but not entailment/provenance at + `bridge/episode_distillation.py:72-121`. +6. Last-write-wins/silent near-duplicate handling lacks supersession at + `bridge/bridge_memory.py:819-836,1106-1142`. +7. Structurally invalid parseable primary can suppress valid backup at + `bridge/bridge_memory.py:1688-1714`; sequential reset unlinks at `1739-1744`. + +### Commands/results + +- 108 focused memory/local-fact/character/runner tests passed. +- 10 initiative-policy tests passed. +- 10 selected LAN persistence/forget/shared-room/persona/episode/callback tests passed. +- `python bridge/memory_probe.py`: exact `1.0`, paraphrase `1.0`, injected-fact `0.0`, relationship + card p95 about `1.8 ms`; this probe does not traverse ordinary model-action authorization. + +No private live values/transcripts were read. No kill/disk/reset fault or longitudinal/physical v4 +test ran. Files changed: none. Commit: none. + +## AUDIT-04 — Emotion and Self State + +### Ranked findings and replay references + +1. Semantic dependency policy is narrow regex at `bridge/character_harness.py:321-343,714-720`; + four synthetic guilt/exclusivity/discourage-human-contact paraphrases passed unchanged. +2. `src/persona/IntentEngine.cpp:20-46` enables demo at boot and `:166-203` injects random synthetic + affect events every 2.5-6 seconds. Native `test/test_native_logic/test_main.cpp:1400-1420` + demonstrates demo prevents sleep. Only serial `demo off` at `src/main.cpp:9296-9298` disables it. +3. Streaming clamps device-bound valence to `[0,1]` at `bridge/lan_service.py:2451-2461`, while TTS + retains signed range at `:2663-2666`; probe `-0.72` produced device `0.0`, TTS `-0.72`. +4. Affect is real mutable uptime state at `src/persona/EmotionModel.hpp:23-75` and + `EmotionModel.cpp:124-340`, but resets in `IntentEngine::begin()` and lacks durable state. +5. Fieldwise validation at `bridge/character_harness.py:730-788` accepted contradictory safety + mode, happy speech/earcon, and incompatible affect. + +### Commands/results + +Native `289/289`; character harness `24/24`; red team `11/11`; reference bridge `14/14`; robot +embodiment `3/3`; RVC TTS `3/3`. Synthetic paraphrase, signed-valence, and cross-field probes +reproduced the gaps. No audio playback/hardware. Current-main affect physical evidence is +unqualified. Files changed: none. Commit: none. + +## AUDIT-05 — Perception and World Grounding + +### Ranked findings and replay references + +1. `CameraAdapter::submitFaceLost()` retains `lastSize` at + `src/io/CameraAdapter.cpp:311-320`; every lost event refreshes `lastEventMs` at `:356-365`. + Heartbeat freshness at `src/main.cpp:7442-7443` ignores target validity. Empty detections arrive + at `bridge/vision_service.py:218-236` / `src/main.cpp:8742-8746`. Host treats the bit as current + presence at `bridge/lan_service.py:2053-2061` and `bridge/robot_embodiment.py:114-126`. +2. Prompt room context expires at `bridge/room_context.py:417-427`, but `latest_summary()` at + `429-431` is age-free and failures retain prior state at `389-393`; relationship projection at + `bridge/lan_service.py:1461-1489` can use stale one-person context. +3. Cached debug can resurrect dashboard connected/operational state: + `bridge/dashboard_service.py:294-298,351-365,477-550`. +4. Room summaries lack source/confidence/contradiction at `bridge/room_context.py:61-85`; presence + sources overwrite/double-count at `bridge/lan_service.py:2053-2061,4038-4045` and + `bridge/initiative_policy.py:12-20,94-99`. +5. Face-worker private-address/redirect handling differs from room transport at + `bridge/vision_service.py:49-61` and `bridge/room_context.py:185-197`. + +### Commands/results + +Native `289/289`; relevant Python tests `56/56`; four focused LAN room/initiative tests `4/4`. +Synthetic aggregate probes reproduced sticky freshness, stale room projection, stale dashboard +state, and URL-policy divergence. No frame/live camera/robot/private data. Physical false presence +was not attempted. Files changed: none. Commit: none. + +## AUDIT-06 — Initiative and Planning + +### Ranked findings and replay references + +1. Host checks thermal/power inhibit at `bridge/lan_service.py:2253-2261`, but production heartbeat + `src/main.cpp:7427-7505` does not transmit those fields. A real-shaped critical-energy/2-percent + heartbeat remained initiative-eligible; artificially supplied fields blocked. +2. Decision occurs at `bridge/lan_service.py:2222-2274`, then model/TTS at `2276-2362`; only cancel/ + utterance-start cancels at `3890-3898`. Later sleep/safety/presence heartbeat does not revalidate. +3. Initiative uses the same narrow dependency validator; three synthetic guilt/attention-debt + variants passed. +4. Dashboard toggle at `bridge/dashboard_service.py:435-448` is runtime-only while spoken preference + persists at `bridge/lan_service.py:2940-2960`; simulated restart restored stored true. +5. `InitiativeDecision` at `bridge/initiative_policy.py:51-55` lacks evidence/confidence/value/ + why-now/privacy/silence/social fields and receives no memory/conversation lines at + `bridge/lan_service.py:2293-2306`. +6. Freshness flapping and room transition double-counting at + `bridge/lan_service.py:2050-2061,4038-4045` can manufacture curiosity. + +### Commands/results + +```text +python -m unittest -v bridge.test_initiative_policy bridge.test_lan_service \ + bridge.test_dashboard_service bridge.test_character_harness bridge.test_character_red_team +``` + +`164/164` passed. Synthetic heartbeat, flap, double-count, restart, and semantic probes reproduced +the gaps. Physical/longitudinal initiative remains unqualified. Files changed: none. Commit: none. + +## AUDIT-07 — Multimodal Expression + +### Ranked findings and replay references + +1. Active phrase streaming signed-valence loss at `bridge/lan_service.py:2457`; TTS/summary preserve + signed value at `:2493,2665`. Probe `-0.4` produced wire `0.0` and TTS/summary `-0.4`. +2. Character validator retains earcon at `bridge/character_harness.py:782-789`, but `BridgeTurn` has + no earcon at `bridge/reference_bridge.py:55-70`; wire omits it. Firmware derives local response + earcon at `src/main.cpp:7248`, while Wi-Fi streamed playback cancels local speech/earcon at + `src/main.cpp:9424-9425`. +3. Partial streaming failure closes protocol at `bridge/lan_service.py:2585-2638` but provides no + coherent spoken/degraded-voice cue. +4. Spark Sleep prompt/audio transcript conflict is at `personas/spark/character.yaml:96` and + `personas/spark/voice.yaml:105-112` in non-Wi-Fi audio scope. +5. Response gesture targets at `src/persona/IntentEngine.cpp:100,401` are commands, not completion + evidence. + +### Commands/results + +All four persona packs passed `tools/verify_persona_pack.py`. Three focused streaming order/failure/ +pipelining tests passed. Mocked signed-valence probe reproduced. Inherited native/bridge baselines +pass. No listening, private voice asset, motion, or hardware. Current exact-source expression is +physically unqualified. Files changed: none. Commit: none. + +## AUDIT-08 — Product and Onboarding + +### Ranked findings and replay references + +1. `bridge/dashboard_service.py:561-564` treats presence of `motion_enabled` as verification; + `bridge/dashboard/app.js:36-58` can call motion safely stopped while rail/torque are true. The + command path `dashboard_service.py:368-431` remains stronger. +2. Cached debug readiness persists at `dashboard_service.py:343-365,477-550`; UI renders Bridge + Ready at `bridge/dashboard/app.js:93-111`. +3. Desktop management callbacks default to no-op at + `companion/.../CompanionConsole.kt:279-304,1664-1729`; desktop `Main.kt:59-131` does not supply + them and discards many failures through `runCatching`. +4. Launcher/shortcut default to one robot IP at `tools/start_stackchan_dashboard.ps1:2` and + `tools/install_stackchan_dashboard_shortcut.ps1:2`; runtime prerequisites are fragmented. +5. `bridge/dashboard/app.js:147` renders missing thermal suppression as clear. + +### Commands/results + +Dashboard tests `20/20` passed. Synthetic integrated-listener refresh and contradictory actuator +snapshots reproduced false readiness/safety. Tracked source was clean during the audit. No UI launch, +install, service, hardware, or private evidence. Files changed: none. Commit: none. + +## AUDIT-09 — Privacy, Dependency, and Ethics + +This defensive report intentionally omits actionable payloads and network reproduction steps. + +### Ranked findings and replay references + +1. Production launcher binds all interfaces at `tools/start_pc_brain.ps1:2`; WebSocket admission at + `bridge/lan_service.py:930` does not enforce existing path/protocol/device/peer/origin signals. + Protected message dispatch is not admission-conditioned at `bridge/lan_service.py:2036-2120`; + blank endpoint identity bypasses owner check at `2207-2210`; self-registration occurs at + `bridge/lan_service.py:495`; serial client service at `4085-4091` creates availability risk. + Source and synthetic aggregate tests confirmed the boundary. +2. Wi-Fi firmware mutating HTTP classification/handling at `src/main.cpp:8814-8874` includes motion + publication `:8859-8863`; remote recovery defaults with Wi-Fi at `src/main.cpp:501-502`. Camera + endpoints have pairing checks at `8684-8689,8731-8740`; mutating control does not. This was not + exercised on hardware. +3. Sensitive-memory enforcement is lexical/open-ended at `bridge/bridge_memory.py:42-120,270, + 1356-1372` and `bridge/character_harness.py:586-635`. +4. Dependency policy intent at `bridge/character_harness.py:24-32` exceeds runtime semantic coverage + at `321-344,715-721`. +5. GitHub Actions use mutable refs; Gradle wrapper lacks distribution checksum; Python inputs lack + artifact hashes. This is source posture, not a demonstrated compromise. + +Affected: production PC launcher; `stackchan_release_full` and other Wi-Fi firmware profiles. Default +non-Wi-Fi `stackchan` is not affected by HTTP controls; OTA remains separately token/digest-gated. +No live memory/network/device exploitation occurred. Files changed: none. Commit: none. + +## AUDIT-10 — Research and Evaluation + +### Ranked findings and replay references + +1. `bridge/companion_harness_qualification.py:243-252,337-365` validates 100 IDs but executes top-20 + controls without full ID-to-disposition trace. Independent count: 100 rows, 59 clusters, 20 + controlled clusters, 50 rows/39 clusters outside executable controls. +2. Subjective physical booleans lack anchored trials at + `bridge/bridge_ai_qualification.py:19-41,744-758` and + `tools/complete_bridge_ai_supervised_qualification.ps1:5-25,91-117`. +3. Research acceptance validates URL/excerpt/transport, not factual support at + `bridge/research_acceptance.py:16-29`, `bridge/research_broker.py:387-406`, and + `bridge/bridge_ai_qualification.py:542-553`. +4. Memory probe fixture at `bridge/fixtures/memory_probe.json:36-85` and + `bridge/memory_probe.py:42-64,96-111` is small lexical routing; v3/v4 both score `1.0`. +5. Three-turn latency and two-opener initiative gates are engineering health evidence, not + longitudinal experience distributions: `bridge/conversation_latency_report.py:22-80`, + `bridge/bridge_ai_qualification.py:645-713`. + +### Commands/results + +- `python bridge/companion_harness_qualification.py --run`: 32 tests, 20/20 controls, 100 IDs. +- Research/latency/memory focused suite: `16/16` passed. +- `python bridge/memory_probe.py`: all current gates passed; v4 retrieval same as v3. + +Ten primary research mechanisms, limitations, and falsifiable predictions are in +`RESEARCH_LEDGER.md`. No human/physical study ran. Files changed: none. Commit: none. + +## Independent Document Review + +VERIFY-DOC-01 checked mandate fields, source identity, branch facts, physical-evidence language, +architecture authority, cross-document consistency, primary research identities, Markdown shape, +and replayability. It initially rejected the documents as preregistration basis. Corrections are +tracked in `TASK_LEDGER.md` and include durable audit evidence (this file), single memory +authorization semantics, silence comparison, explicit body/social/clock ownership, operational +scorecard formulas, stop-ship security tasks, and current containment state. A second independent +review is required after status-document reconciliation and SEC-001 preregistration. + +VERIFY-DOC-02 then rejected four concrete cross-document contradictions: private paired evidence +was attributed to public v0.2, the presence acceptance test inverted the desired outcome, +`SAFE-001` still competed with the selected stop-ship slice, and reconciled documentation remained +listed as an open fault. It also required exact SEC-001 fixture/version/seed/trial/artifact fields. +Those defects were corrected without source edits. The same independent reviewer repeated the +read-only pass and returned **ACCEPT**: the Milestone 0 control baseline and frozen SEC-001 +preregistration are internally consistent. No source implementation was reviewed. From 9c72f02091dc471f27e3c9bfff5e4af6e32e7134 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Sun, 2 Aug 2026 19:08:41 -0400 Subject: [PATCH 02/46] fix: fail closed pc bridge admission --- INITIAL_RISK_REGISTER.md | 8 +- PROJECT_STATE.md | 133 ++++-- TASK_LEDGER.md | 51 ++- bridge/README.md | 5 +- bridge/lan_service.py | 197 ++++++++- bridge/lan_smoke.py | 2 + bridge/test_dashboard_service.py | 24 +- bridge/test_lan_service.py | 394 ++++++++++++++++++ docs/ARRIVAL_DAY_RUNBOOK.md | 25 +- docs/BRIDGE_DASHBOARD.md | 6 +- docs/BRIDGE_PROTOCOL.md | 17 + docs/RELEASE_QUICKSTART.md | 14 +- tools/check_pc_brain_runtime.ps1 | 95 ++++- tools/restore_voice_v2_production.ps1 | 4 + tools/run_selected_voice_once.ps1 | 8 +- tools/start_pc_brain.ps1 | 18 +- tools/start_pc_brain_directml.ps1 | 5 +- .../start_voice_v2_supervised_validation.ps1 | 4 + tools/start_warm_rocm_full_system_soak.ps1 | 5 + .../test_pc_brain_runtime_check_contract.ps1 | 135 +++++- ..._stackchan_dashboard_launcher_contract.ps1 | 12 +- .../test_start_pc_brain_directml_contract.ps1 | 67 +++ 22 files changed, 1126 insertions(+), 103 deletions(-) diff --git a/INITIAL_RISK_REGISTER.md b/INITIAL_RISK_REGISTER.md index 6dfd7c16..d552e9a4 100644 --- a/INITIAL_RISK_REGISTER.md +++ b/INITIAL_RISK_REGISTER.md @@ -2,8 +2,8 @@ Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` Date: 2026-08-02 -Status: all ten read-only audits reconciled; Milestone 0 documentation independently accepted and -pending its atomic commit +Status: all ten read-only audits reconciled; Milestone 0 documentation committed; `SEC-001` +implemented, exact-tree verified, independently accepted, and not deployed Priority is non-compensatory: P0 trust/privacy/safety violations are addressed before experience features. “Reproduced” means source/synthetic evidence demonstrates the defect; it is not a claim @@ -11,7 +11,7 @@ that a private or physical incident occurred. | ID | Priority | Risk and current evidence | User consequence | Mitigation / acceptance | Status | | --- | --- | --- | --- | --- | --- | -| R-000A | P0 | **PC bridge LAN admission is fail-open — source-observed and synthetically confirmed.** Production launcher binds all interfaces; existing path/protocol/device/peer/origin signals are unenforced, dispatch is not conditioned on a validated upgrade, and blank endpoint identity bypasses an active owner. | Untrusted LAN peer can access private behavior, inject turns, or deny the single-client brain service. | Existing-signal admission hardening at HTTP upgrade, configured peer for non-loopback, no browser origin, explicit unadmitted-session rejection, compatibility/wrong-peer/reconnect tests. Do not label as cryptographic auth. | Contained: exposed listener stopped; `SEC-001` selected and preregistered | +| R-000A | P0 | **PC bridge LAN admission is fail-open in the pre-`SEC-001` committed and last-observed deployed baseline — source-observed and synthetically confirmed.** Production launcher binds all interfaces; existing path/protocol/device/peer/origin signals are unenforced, dispatch is not conditioned on a validated upgrade, and blank endpoint identity bypasses an active owner. | Untrusted LAN peer can access private behavior, inject turns, or deny the single-client brain service. | Existing-signal admission hardening at HTTP upgrade, configured peer for non-loopback, no browser origin, explicit unadmitted-session rejection, compatibility/wrong-peer/reconnect tests. Do not label as cryptographic auth. | Live exposure contained; isolated `SEC-001` candidate exact-tree verified and independently accepted, not deployed | | R-000B | P0 | **Firmware HTTP mutation is unauthenticated — source-observed, not exercised.** Wi-Fi profiles route motion-resume/recovery/reboot-class requests without the camera pairing gate. | LAN peer can request physical/recovery changes without owner authority. | Preserve emergency stop/read-only status; compile-disable other mutation until authenticated control is designed; full native/build/physical gates. | Stop-ship queued; `SEC-002` | | R-001 | P0 | **Unauthorized memory mutation — reproduced.** Valid model-authored writes and wildcard forgets are applied without matching explicit current user authorization. | False personal memory or durable erasure during ordinary conversation. | Host sole-authorizer matching; adversarial ordinary/replay/wildcard/scope/tool tests produce zero deltas while explicit commands pass. | Open; `SAFE-001`, queued after stop-ship transport/control work | | R-002 | P0 | **False motion-safety label — reproduced.** Dashboard `motionVerified` checks only `motion_enabled`; UI can say safely stopped while rail/torque are true. | Operator may trust an unsafe or unknown passive actuator state. | Tri-state verification over motion, rail, torque, suppression, freshness; contradictory/missing UI/API tests. Command path remains frozen. | Open; product audit | @@ -33,7 +33,7 @@ that a private or physical incident occurred. | R-018 | P1 | **Desktop companion exposes inert controls and swallows failures — source trace.** Forget/remove/Wi-Fi callbacks are no-ops; failures use discarded `runCatching`. | User believes data/device state changed or cannot recover from setup failures. | Wire truthful callbacks and visible errors or hide unsupported actions; desktop-specific state/copy and tests. | Open; product task to register | | R-019 | P1 | **Default launcher/onboarding is device/lab-specific — source/docs.** One IP default, multiple hidden runtime prerequisites, serial/menu Wi-Fi step. | New user cannot reliably install/configure/recover and may misdiagnose failure. | Single read-only first-run checker/wizard with explicit lab stop, privacy locations, service matrix, and recovery. | Open; product milestone | | R-020 | P1 | **Validated earcon is dropped — source trace.** Character validation retains earcon but `BridgeTurn`/wire omit it; Wi-Fi streamed responses cancel local response earcon. | Error/safety/thinking cues can be absent and documentation promises a channel that is not realized. | Define one earcon ownership/protocol contract; test wake/TTS/drain overlap and degraded channels before physical proof. | Open; expression milestone | -| R-021 | P2 | **Documentation drift previously misstated current behavior/evidence.** Conversation timing, camera compilation, initiative status, desktop Python, Character Lock earcon, and historical status sections conflicted on the frozen baseline. | Operators could repeat stale procedures or promote unqualified behavior. | Evidence-preserving status/runbook/protocol reconciliation, stale-claim scan, contract gates, and independent consistency review. | Reconciled and independently accepted; `M0-005` pending commit | +| R-021 | P2 | **Documentation drift previously misstated current behavior/evidence.** Conversation timing, camera compilation, initiative status, desktop Python, Character Lock earcon, and historical status sections conflicted on the frozen baseline. | Operators could repeat stale procedures or promote unqualified behavior. | Evidence-preserving status/runbook/protocol reconciliation, stale-claim scan, contract gates, and independent consistency review. | Reconciled, independently accepted, and committed in `0e3467e7`; future drift remains monitored | ## Common Stop and Rollback Rules diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index fad5a518..2bad4316 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -5,17 +5,22 @@ State timestamp: 2026-08-02 America/New_York ## Current Objective Complete Milestone 0 repository/document truth and contain the newly confirmed LAN trust-boundary -failure before any aliveness feature work. All ten read-only audits are complete. The current work -is documentation, preregistration, and test-first security repair; no robot behavior change has -been implemented. +failure before any aliveness feature work. All ten read-only audits are complete. The atomic commit +containing this record is the independently expanded, test-first, exact-tree-verified `SEC-001` host +security repair. It has not been deployed: no production service, firmware, or robot behavior has +been changed. Two user-authorized alternate-port live checks started and stopped the candidate +exactly as recorded below. ## Source Identity - Repository: `RobVanProd/stackchan_alive` - Working branch: `codex/aliveness-repository-truth` -- Working commit: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +- Pre-commit parent HEAD: `0e3467e79766ed1cafeef4837c162c8a50bb29e1` - Fetched `origin/main`: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` -- Last source-verified commit: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` +- Milestone 0 documentation baseline commit: `0e3467e79766ed1cafeef4837c162c8a50bb29e1` +- SEC-001 identity: the candidate is the exact tree/commit containing this record. Before commit + it is staged against parent `0e3467e7`; after commit its authoritative SHA is the containing Git + commit assigned by Git and reported in the handoff. - Verification scope: native firmware logic, host bridge tests, silent trusted-facts routing, secret-free release compilation through the documented isolated pioarduino core, and three release/evidence contract suites. @@ -81,22 +86,46 @@ characters, and no `Origin`. Security-critical duplicate headers fail closed. A requires `RobotHost`; it is resolved once before accept, and the normalized frozen address set is checked before reading or dispatching a request. Invalid attempts close before `101` and do not consume `--once`; a later valid firmware connection receives the existing immediate server hello. -The general launcher becomes loopback-default, while the production DirectML launcher opts into -`0.0.0.0` only with its already required robot host. +The general launcher becomes loopback-default. Every maintained robot-facing wrapper opts into +`0.0.0.0` with an early nonblank `DeviceHost` guard and an explicit frozen robot peer; runtime +certification requires the same peer argument. Scoped and link-local IPv6 peers fail closed in +this IPv4-bound slice. Frozen implementation files are `bridge/lan_service.py`, `bridge/test_lan_service.py`, +`bridge/lan_smoke.py`, `bridge/test_dashboard_service.py`, `tools/start_pc_brain.ps1`, `tools/start_pc_brain_directml.ps1`, +`tools/restore_voice_v2_production.ps1`, `tools/run_selected_voice_once.ps1`, +`tools/start_voice_v2_supervised_validation.ps1`, +`tools/start_warm_rocm_full_system_soak.ps1`, `tools/check_pc_brain_runtime.ps1`, +`tools/test_pc_brain_runtime_check_contract.ps1`, `tools/test_start_pc_brain_directml_contract.ps1`, -`tools/test_stackchan_dashboard_launcher_contract.ps1`, and `docs/BRIDGE_PROTOCOL.md`. Rollback is +`tools/test_stackchan_dashboard_launcher_contract.ps1`, `docs/BRIDGE_PROTOCOL.md`, +`docs/RELEASE_QUICKSTART.md`, `docs/ARRIVAL_DAY_RUNBOOK.md`, `docs/BRIDGE_DASHBOARD.md`, and +`bridge/README.md`. These scope expansions were independently approved only after focused/broad +tests and security review exposed directly coupled compatibility gaps. Rollback is reversion of the single atomic host-side commit while keeping the non-loopback service stopped. Stop on any valid-firmware incompatibility, post-admission output change, need for private data or new wire semantics, unfrozen DNS behavior, hardware access, or live-service restart. +The expected-red phase exposed one maintained synthetic-smoke dependency before production code was +edited: `bridge/lan_smoke.py` emits the legacy headerless request directly into +`handle_connection`. The independent preregistration reviewer approved adding only that file and +only to use the current firmware protocol/device headers (and an already available peer address if +the final seam requires it). A compatibility bypass or permissive flag is forbidden. + +The first broad bridge run exposed the same legacy synthetic request in +`bridge/test_dashboard_service.py`; strict admission rejected it and, correctly, did not consume +`once`, so the run was terminated while the server awaited a valid client. The same independent +reviewer approved adding only the current firmware protocol/device headers to that fixture. No +timeout, `once`, validator, or compatibility bypass is allowed. + Preregistration metric fields: - **Fixture:** `bridge/test_lan_service.py::firmware_upgrade_request`, version - `sec001-firmware-upgrade-v1`, traced byte-for-byte to - `src/io/BridgeWebSocketTransport.cpp::buildHandshakeRequest`; only synthetic device IDs. + `sec001-firmware-upgrade-v1`, a field-for-field request-shape fixture traced to + `src/io/BridgeWebSocketTransport.cpp::buildHandshakeRequest`. It uses the current firmware + default key `c3RhY2tjaGFuLWZpcm13YXJlLWtleQ==`; host formatting is synthetic/configuration- + dependent, and only synthetic device IDs are used. - **Baseline/version:** exact source `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec`; the nine named rejection tests above are expected to fail for the recorded reasons, while the raw current firmware request shape is source-observed. @@ -175,9 +204,10 @@ a robot freeze, blackout, brownout, thermal event, USB failure, board failure, o ## Known Faults -1. The production PC bridge launcher exposes a fail-open robot-to-host WebSocket admission boundary - on the LAN. Path/protocol/device/peer/origin signals are not enforced, protected message - families are accepted before trusted admission, and a blank endpoint ID bypasses owner checks. +1. The committed `origin/main` baseline and the last observed production host service expose a + fail-open robot-to-host WebSocket admission boundary on the LAN. The isolated `SEC-001` + candidate repairs those source paths, but it is not deployed on the production listener or + robot; the previously exposed listener remains stopped. 2. Wi-Fi-enabled firmware HTTP mutating controls are not protected by the existing camera pairing gate; source shows motion-resume/recovery/reboot-class requests can reach their handlers. This was not exercised on hardware. OTA remains separately token-gated. @@ -203,18 +233,19 @@ a robot freeze, blackout, brownout, thermal event, USB failure, board failure, o Documentation baseline status: required project-control/longitudinal documents and reconciled status claims now exist in the isolated worktree and passed the final independent review. They -remain uncommitted pending the atomic Milestone 0 documentation commit; this is workflow state, not -an unresolved runtime fault. +were committed atomically as `0e3467e79766ed1cafeef4837c162c8a50bb29e1`; this is repository +control evidence, not physical qualification. ## Known Regressions -No repository behavior has been changed by this workstream. Operationally, stopping the exposed PC -bridge intentionally removed LAN brain/dashboard availability; this is a recorded security -containment, not a hidden regression. Voice/vision workers and robot firmware were not changed. The -faults above are reproduced current-source defects or contract gaps, not new physical-event or -hardware-cause attributions. Physical affect, perception, initiative, Conversation v2, -over-speaker barge-in, echo rejection, and a current exact-image no-motion conversation soak remain -unqualified rather than failed. +The isolated candidate now changes host admission, launcher defaults, robot-facing +wrappers, and runtime certification. It has not been deployed or started on the production +listener or robot; the isolated alternate-port checks were started and stopped as recorded below. +The exposed production PC bridge remains intentionally stopped; current deployed firmware +therefore still has no verified host admission repair, and its exact installed SHA remains unknown. +This containment is not a hidden regression. Voice/vision workers and robot firmware were not changed. Physical affect, +perception, initiative, Conversation v2, over-speaker barge-in, echo rejection, and a current +exact-image no-motion conversation soak remain unqualified rather than failed. ## Baseline Evidence @@ -230,16 +261,46 @@ unqualified rather than failed. - Current-lead archive contract: passed. - Branch/PR evidence: `BRANCH_LEDGER.md`. +## SEC-001 Candidate Evidence + +The live checks below and the first matrix were observed on 2026-08-02 in the isolated working tree +based at `0e3467e7`. The final self-identifying staged-tree matrix repeated the applicable gates and +is stored with hashed logs under ignored `output/private/sec-001/final-/`. The containing Git +commit and final handoff provide the durable source identity. None of this evidence identifies or +qualifies the installed firmware or authorizes a service restart. + +- Focused LAN service: 115/115 passed, including actual-firmware-key compatibility, + invalid-invalid-valid `once` recovery, and wrong-peer no-dispatch/state-mutation recovery. +- Full bridge discovery: 559/559 passed after the CLI-abbreviation test was added. +- Deterministic LAN smoke: five/five scenarios passed with fake local engines. +- Native firmware logic: 289/289 passed; no firmware source file changed. +- DirectML launcher, dashboard launcher, and PC-brain runtime-certification contracts: passed. +- Trusted-facts smoke: ready, 19 routed and 10 passthrough cases, zero model invocations, zero + audio played, and no stored fact values printed. +- Secret-free `stackchan_release_full` compilation: passed in `C:\spio\pioarduino`; the dirty-tree + build is 2,803,216 bytes with SHA-256 + `96D72657097E96522F13972D26116BB370070D33E06930B0DB28A923BD3439E2`. This is compilation + evidence only and is not a physical or reproducibility claim. +- Full-system-soak evidence, current-lead reproducibility v2, and current-lead archive contracts: + passed with synthetic fixtures. +- User-authorized live local-runner check on isolated loopback port `18765`: the candidate accepted + the current firmware-shaped handshake, completed endpoint registration/claim, invoked the + installed `gemma4:e2b-it-qat` Ollama model, returned the full `thinking` through `response_end` + sequence in 2,097.7 ms, and exited cleanly under `once`. Audio downlink was disabled, the prompt + was synthetic, and no durable memory file was configured. +- User-authorized live OS-socket peer check on isolated port `18766`: a real `0.0.0.0` candidate + listener frozen to robot peer `192.168.1.238` rejected the actual loopback client before + handshake, remained listening under `once`, and was then stopped by exact PID. This proves the + local wrong-peer path, not robot reachability or network-attacker resistance. Evidence remains + ignored under `output/private/sec-001/`. + ## Exact Next Action -Commit the independently accepted, evidence-preserving Milestone 0 documentation slice. Then -implement the preregistered smallest fail-closed host admission slice: -validate the existing firmware path/protocol/device signals at HTTP upgrade, reject browser Origin, -require and freeze the configured robot peer for non-loopback binds, allow invalid-then-valid -recovery, and reject protected dispatch on explicitly unadmitted sessions. Firmware sends no -client-side hello; preserve the immediate server hello and do not reinterpret the device header as -brain-owner identity. This is admission hardening, not cryptographic authentication; the separate -firmware mutating-control risk remains contained/unqualified work. +Use the atomic commit containing this record as the `SEC-001` source identity, without starting the +production service. Then preregister the smallest `SEC-002` experiment for firmware mutating-control +authorization and run source-only gates before any device action. `SEC-001` is admission hardening, +not cryptographic authentication; production restart and every firmware/hardware action remain +unauthorized until their separate qualification gates are earned. ## Unauthorized Actions @@ -255,9 +316,9 @@ firmware mutating-control risk remains contained/unqualified work. ## Rollback Path -Current file changes are Markdown control records on an isolated branch. The exposed host listener -was stopped; it can be restored by the existing launcher, but must not be restarted until the -admission repair is verified or the user explicitly accepts the known risk. Before implementation, -freeze an atomic source/test scope whose revert removes only that experiment. Hardware rollback -remains the exact private accepted archive and runbook procedure; it is not exercised without the -required source, build, no-motion, physical, and exact-image gates. +SEC-001 is one isolated host source/test/operator-document candidate. Before commit, discard only +that exact staged/working-tree slice if a final gate fails; after commit, rollback is reversion of +the exact containing commit. Keep the exposed non-loopback listener stopped and never restore the +fail-open listener as an automatic fallback. Hardware rollback remains the exact private accepted +archive and runbook procedure; it is not exercised without the required source, build, no-motion, +physical, and exact-image gates. diff --git a/TASK_LEDGER.md b/TASK_LEDGER.md index 409f351b..dbbc4129 100644 --- a/TASK_LEDGER.md +++ b/TASK_LEDGER.md @@ -24,7 +24,8 @@ Ledger timestamp: 2026-08-02 America/New_York secret, or disturb live services. - **Result:** Complete. AUDIT-01 independently recomputed topology and found four documentation gaps; all four are now incorporated in `BRANCH_LEDGER.md`. -- **Commit:** Uncommitted. +- **Commit:** `0e3467e79766ed1cafeef4837c162c8a50bb29e1` (`docs: establish aliveness + control baseline`). - **Decision:** Accept as repository truth. No branch/reference cleanup is authorized in this workstream. @@ -47,7 +48,8 @@ Ledger timestamp: 2026-08-02 America/New_York weakening a gate. - **Result:** Complete; source baseline is green in the documented build context. Invocation/core ambiguity is recorded as a tooling/documentation fault. -- **Commit:** Uncommitted. +- **Commit:** `0e3467e79766ed1cafeef4837c162c8a50bb29e1` (`docs: establish aliveness + control baseline`). - **Decision:** Accept as the source baseline; do not treat the build hash as physical evidence. ## M0-003 — Ten-Domain Read-Only Audit Wave @@ -71,7 +73,8 @@ Ledger timestamp: 2026-08-02 America/New_York code, call hardware, or weaken a boundary. - **Result:** Complete; no audit agent changed files or production behavior. One privacy report was reissued as a defensive summary after the first detailed response was blocked by safety filters. -- **Commit:** None. +- **Commit:** `0e3467e79766ed1cafeef4837c162c8a50bb29e1` (`docs: establish aliveness + control baseline`). - **Decision:** Close the read-only wave; preserve durable replay references before preregistration. ## M0-004 — Salvage Reproducible Firmware Builds @@ -128,9 +131,10 @@ Ledger timestamp: 2026-08-02 America/New_York mismatch, camera compilation, Character Lock earcon/signed-valence, dashboard launch defaults, managed desktop Python, and dated vision evidence are reconciled. Launcher, desktop runtime, and three evidence/archive contract suites pass. -- **Commit:** Uncommitted with the Milestone 0 documentation slice. +- **Commit:** `0e3467e79766ed1cafeef4837c162c8a50bb29e1` (`docs: establish aliveness + control baseline`). - **Decision:** Accepted by the final independent documentation review; no historical evidence was - strengthened or transferred. Commit with the Milestone 0 documentation baseline. + strengthened or transferred. Complete. ## UX-001 — Expire Stale Dashboard Robot Readiness @@ -349,10 +353,19 @@ Ledger timestamp: 2026-08-02 America/New_York - **Owner:** One host-transport implementation owner; separate security, privacy, regression, and documentation reviewers. - **Allowed files:** Freeze to `bridge/lan_service.py`, `bridge/test_lan_service.py`, + `bridge/lan_smoke.py` (independently approved after the red phase exposed its direct legacy + headerless fixture), `bridge/test_dashboard_service.py` (independently approved after the first + broad run exposed the same integrated-server fixture), `tools/start_pc_brain.ps1`, `tools/start_pc_brain_directml.ps1`, + `tools/restore_voice_v2_production.ps1`, `tools/run_selected_voice_once.ps1`, + `tools/start_voice_v2_supervised_validation.ps1`, + `tools/start_warm_rocm_full_system_soak.ps1`, `tools/check_pc_brain_runtime.ps1`, + `tools/test_pc_brain_runtime_check_contract.ps1`, `tools/test_start_pc_brain_directml_contract.ps1`, - `tools/test_stackchan_dashboard_launcher_contract.ps1`, and `docs/BRIDGE_PROTOCOL.md`. No - firmware file in this slice. + `tools/test_stackchan_dashboard_launcher_contract.ps1`, `docs/BRIDGE_PROTOCOL.md`, + `docs/RELEASE_QUICKSTART.md`, `docs/ARRIVAL_DAY_RUNBOOK.md`, `docs/BRIDGE_DASHBOARD.md`, and + `bridge/README.md`. Each expansion was independently approved after a test/review exposed a + directly coupled compatibility gap. No firmware file in this slice. - **Frozen systems:** Message schemas after admission, STT/model/TTS, memory semantics, dashboard, firmware, robot, pairing values, voice/vision workers, release packaging, and all hardware authority. @@ -370,13 +383,23 @@ Ledger timestamp: 2026-08-02 America/New_York private code, changing wire payloads, weakening loopback defaults, or restarting the contained live bridge before independent verification. If peer resolution cannot be frozen safely, require a configured literal IP instead of widening admission. -- **Result:** Selected and preregistered by an independent read-only reviewer; implementation has - not started. Expected pre-code failures, exact test matrix, file scope, rollback, and uncertainty - are frozen above and in `PROJECT_STATE.md`. -- **Commit:** None. -- **Decision:** Implement test-first only after the Milestone 0 documentation commit. Treat TCP - peer plus spoofable headers as bounded admission hardening, not cryptographic authentication. - Firmware HTTP control authorization remains a separate P0 task. +- **Result:** Implemented test-first in the isolated working tree after the expected-red run. + Focused admission, malformed-key recovery, wrong-peer no-dispatch/once recovery, maintained + robot-wrapper, and runtime-certification contracts are green. The final staged-tree matrix is + green: 559 bridge, 289 native, 115 focused LAN, ten consecutive dashboard-heartbeat race + repetitions, five LAN smoke scenarios, silent privacy, secret-free release compile, and three + evidence/archive contracts. The self-identifying pre-commit manifest and hashed logs are stored + under ignored `output/private/sec-001/final-/`; the containing Git commit and final handoff + provide the durable source identity. A user-authorized isolated live check also + completed one real Ollama model turn, and a separate real non-loopback socket rejected a wrong + peer without consuming `once`; both alternate-port processes exited/stopped and nothing was + deployed to the production listener or robot. Independent security, regression, preregistration, + and documentation/authority reviews accept the frozen candidate. +- **Commit:** The atomic `SEC-001` commit containing this record; its exact SHA is assigned by Git + after the record is written and must be read from history/the final handoff. +- **Decision:** Accepted for the atomic host-side commit. Treat TCP peer plus spoofable headers as + bounded admission hardening, not cryptographic authentication. Deployment remains unauthorized; + firmware HTTP control authorization remains the separate P0 `SEC-002` task. ## SEC-002 — Disable Unauthenticated Firmware Mutating Controls diff --git a/bridge/README.md b/bridge/README.md index 1acd045a..afae4a40 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -200,7 +200,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\start_rvc_worker.ps1 $env:STACKCHAN_RVC_WORKER_URL = "http://127.0.0.1:5055" $env:STACKCHAN_RVC_WORKER_TIMEOUT_SECONDS = "90" $env:STACKCHAN_RVC_MAX_AUDIO_BYTES = "65536" -powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\start_pc_brain.ps1 -StopExisting -Background -EnableAudioDownlink -TtsCommand "python bridge\rvc_tts_client.py" -TtsVoice "stackchan-rvc-warm-rocm" -DownlinkBinaryFrameDelayMs 80 +powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\start_pc_brain.ps1 -HostName 0.0.0.0 -RobotHost -StopExisting -Background -EnableAudioDownlink -TtsCommand "python bridge\rvc_tts_client.py" -TtsVoice "stackchan-rvc-warm-rocm" -DownlinkBinaryFrameDelayMs 80 ``` The physically validated Windows DirectML path is documented in @@ -299,10 +299,11 @@ Host initiative and room context are also explicit, default-off features: ```powershell $env:STACKCHAN_OLLAMA_VISION_MODEL = "your-local-vision-model" .\tools\start_pc_brain.ps1 -Background -EnableAudioDownlink -StreamTtsPhrases ` + -HostName 0.0.0.0 -RobotHost ` -EnableConversationV2 -EnableInitiative -EnableRoomObservation ` -RoomObservationIntervalSeconds 300 ` -CameraPairingCodeFile "$env:USERPROFILE\.stackchan\camera-pairing-code.txt" ` - -RobotHost 192.168.1.238 -EnableDashboard + -EnableDashboard ``` The initiative policy requires a fresh person-presence observation, waits at least ten minutes diff --git a/bridge/lan_service.py b/bridge/lan_service.py index 6ba37092..f0ed3328 100644 --- a/bridge/lan_service.py +++ b/bridge/lan_service.py @@ -7,6 +7,7 @@ import base64 import copy import hashlib +import ipaddress import json import math import os @@ -118,6 +119,24 @@ DEFAULT_BRAIN_OWNER_LEASE_MS = 15_000 MAX_DOWNLINK_AUDIO_CHUNK_BYTES = 4096 MAX_TRUSTED_ENDPOINTS = 8 +BRIDGE_WEBSOCKET_PATH = "/bridge" +MAX_BRIDGE_DEVICE_ID_CHARS = 64 +MAX_WEBSOCKET_KEY_CHARS = 128 +MAX_WEBSOCKET_KEY_DECODED_BYTES = 96 +_BRIDGE_DEVICE_ID_RE = re.compile( + rf"[A-Za-z0-9._-]{{1,{MAX_BRIDGE_DEVICE_ID_CHARS}}}\Z" +) +_SECURITY_CRITICAL_UPGRADE_HEADERS = frozenset( + { + "upgrade", + "connection", + "sec-websocket-key", + "sec-websocket-version", + "x-stackchan-protocol", + "x-stackchan-device", + "origin", + } +) REPLY_PCM_CHUNK_MS = 50 REPLY_PCM_MINIMUM_SPEECH_MS = 150 REPLY_PCM_INITIAL_NOISE_FLOOR = 0.015 @@ -825,6 +844,8 @@ def __post_init__(self) -> None: ) if not 0.0 <= float(self.stt_min_confidence) <= 1.0: raise ValueError("stt_min_confidence must be between zero and one") + if not bridge_bind_is_loopback(self.host) and not self.robot_host.strip(): + raise ValueError("robot_host is required when the bridge bind is not loopback") @dataclass(frozen=True) @@ -913,27 +934,127 @@ def websocket_accept_value(client_key: str) -> str: return base64.b64encode(digest).decode("ascii") -def parse_http_headers(request: bytes) -> dict[str, str]: - text = request.decode("iso-8859-1") - lines = text.split("\r\n") - if not lines or not lines[0].startswith("GET "): - raise WebSocketProtocolError("websocket handshake must start with GET") +@dataclass(frozen=True) +class WebSocketAdmission: + client_key: str + device_id: str + + +def bridge_bind_is_loopback(host: str) -> bool: + candidate = str(host).strip() + if candidate.lower() == "localhost": + return True + try: + return ipaddress.ip_address(candidate).is_loopback + except ValueError: + return False + + +def normalize_peer_address(value: object) -> str: + candidate = str(value).strip() + if not candidate or "%" in candidate: + return "" + try: + address = ipaddress.ip_address(candidate) + except ValueError: + return "" + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + address = address.ipv4_mapped + elif isinstance(address, ipaddress.IPv6Address) and address.is_link_local: + return "" + return str(address) + + +def resolve_robot_peer_addresses(config: LanBridgeConfig) -> frozenset[str] | None: + if bridge_bind_is_loopback(config.host): + return None + try: + resolved = socket.getaddrinfo(config.robot_host, None, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + raise ValueError(f"robot_host could not be resolved: {config.robot_host}") from exc + addresses = frozenset( + normalized + for normalized in (normalize_peer_address(item[4][0]) for item in resolved) + if normalized + ) + if not addresses: + raise ValueError(f"robot_host resolved to no usable addresses: {config.robot_host}") + return addresses + + +def peer_address_allowed(peer_address: object, allowed_addresses: frozenset[str]) -> bool: + normalized = normalize_peer_address(peer_address) + return bool(normalized and normalized in allowed_addresses) + + +def _parse_http_upgrade(request: bytes) -> tuple[str, dict[str, str]]: + try: + text = request.decode("iso-8859-1") + except UnicodeDecodeError as exc: # pragma: no cover - iso-8859-1 decodes all bytes + raise WebSocketProtocolError("websocket handshake is not decodable") from exc + header_text = text.split("\r\n\r\n", 1)[0] + lines = header_text.split("\r\n") + request_line = lines[0] if lines else "" headers: dict[str, str] = {} + seen: set[str] = set() for line in lines[1:]: - if not line or ":" not in line: + if not line: continue + if ":" not in line: + raise WebSocketProtocolError("malformed WebSocket upgrade header") key, value = line.split(":", 1) - headers[key.strip().lower()] = value.strip() + normalized_key = key.strip().lower() + if not normalized_key: + raise WebSocketProtocolError("malformed WebSocket upgrade header") + if normalized_key in seen and normalized_key in _SECURITY_CRITICAL_UPGRADE_HEADERS: + raise WebSocketProtocolError(f"duplicate WebSocket upgrade header: {normalized_key}") + seen.add(normalized_key) + headers[normalized_key] = value.strip() + return request_line, headers + + +def parse_http_headers(request: bytes) -> dict[str, str]: + _request_line, headers = _parse_http_upgrade(request) return headers -def build_handshake_response(request: bytes) -> bytes: - headers = parse_http_headers(request) +def validate_websocket_upgrade(request: bytes) -> WebSocketAdmission: + request_line, headers = _parse_http_upgrade(request) + if request_line != f"GET {BRIDGE_WEBSOCKET_PATH} HTTP/1.1": + raise WebSocketProtocolError("websocket request target must be GET /bridge HTTP/1.1") upgrade = headers.get("upgrade", "").lower() - connection = headers.get("connection", "").lower() + connection_tokens = { + token.strip().lower() + for token in headers.get("connection", "").split(",") + if token.strip() + } client_key = headers.get("sec-websocket-key", "") - if upgrade != "websocket" or "upgrade" not in connection or not client_key: + version = headers.get("sec-websocket-version", "") + protocol = headers.get("x-stackchan-protocol", "") + device_id = headers.get("x-stackchan-device", "") + if "origin" in headers: + raise WebSocketProtocolError("browser Origin is not admitted") + if upgrade != "websocket" or "upgrade" not in connection_tokens or not client_key: raise WebSocketProtocolError("missing WebSocket upgrade headers") + if len(client_key) > MAX_WEBSOCKET_KEY_CHARS: + raise WebSocketProtocolError("invalid Sec-WebSocket-Key") + try: + decoded_key = base64.b64decode(client_key.encode("ascii"), validate=True) + except (UnicodeEncodeError, ValueError) as exc: + raise WebSocketProtocolError("invalid Sec-WebSocket-Key") from exc + if not decoded_key or len(decoded_key) > MAX_WEBSOCKET_KEY_DECODED_BYTES: + raise WebSocketProtocolError("invalid Sec-WebSocket-Key") + if version != "13": + raise WebSocketProtocolError("unsupported WebSocket version") + if protocol != PROTOCOL: + raise WebSocketProtocolError("Stackchan bridge protocol mismatch") + if not _BRIDGE_DEVICE_ID_RE.fullmatch(device_id): + raise WebSocketProtocolError("Stackchan device header is missing or invalid") + return WebSocketAdmission(client_key=client_key, device_id=device_id) + + +def _handshake_response(admission: WebSocketAdmission) -> bytes: + client_key = admission.client_key accept = websocket_accept_value(client_key) response = ( "HTTP/1.1 101 Switching Protocols\r\n" @@ -945,6 +1066,10 @@ def build_handshake_response(request: bytes) -> bytes: return response.encode("ascii") +def build_handshake_response(request: bytes) -> bytes: + return _handshake_response(validate_websocket_upgrade(request)) + + def recv_exact(conn: socket.socket, count: int) -> bytes: chunks: list[bytes] = [] remaining = count @@ -1340,6 +1465,7 @@ def __init__( initiative_policy: InitiativePolicy | None = None, room_context: RoomContextRuntime | None = None, dashboard_runtime: DashboardRuntime | None = None, + transport_admitted: bool = True, ): self.config = config self.memory = memory if memory is not None else BridgeMemory() @@ -1367,6 +1493,7 @@ def __init__( self.initiative_policy = initiative_policy self.room_context = room_context self.dashboard_runtime = dashboard_runtime + self.transport_admitted = bool(transport_admitted) self.conversation: ConversationSession | None = None self.conversation_response_seq = 0 self.playback_response_seq = 0 @@ -2033,6 +2160,8 @@ def handle_text( return [error_frame("message_not_object")] message_type = str(message.get("type", "")).strip().lower() + if not self.transport_admitted: + return [error_frame("admission_required")] if message_type == "hello": self.session = str(message.get("session") or message.get("device_id") or self.session)[:48] return [ @@ -2207,6 +2336,9 @@ def handle_text( def _owner_gate(self, message: dict[str, Any]) -> dict[str, object] | None: endpoint_id = normalize_endpoint_id(message.get("endpoint_id") or self.endpoint_id) if not endpoint_id: + self.control_state.reconcile_owner() + if self.control_state.active_brain_owner: + return error_frame("endpoint_id_required") return None self.control_state.touch_endpoint(endpoint_id) self.control_state.reconcile_owner() @@ -2403,6 +2535,8 @@ def finalize_audio_upload(self) -> FinalizedAudioUpload: return self.audio.finalize() def handle_binary(self, payload: bytes) -> list[dict[str, object]]: + if not self.transport_admitted: + return [error_frame("admission_required")] self.control_state.touch_endpoint(self.endpoint_id) self.control_state.reconcile_owner() if self.endpoint_id and self.control_state.active_brain_owner and self.endpoint_id != self.control_state.active_brain_owner: @@ -3577,7 +3711,11 @@ def handle_connection( dashboard_runtime: DashboardRuntime | None = None, initiative_policy: InitiativePolicy | None = None, room_context: RoomContextRuntime | None = None, + on_admitted: Callable[[WebSocketAdmission], None] | None = None, ) -> BridgeMemory: + request = read_http_request(conn) + print(f"[bridge-lan] handshake_bytes={len(request)}", flush=True) + admission = validate_websocket_upgrade(request) session = LanBridgeSession( config, memory, @@ -3585,16 +3723,17 @@ def handle_connection( initiative_policy=initiative_policy, room_context=room_context, dashboard_runtime=dashboard_runtime, + transport_admitted=True, ) - request = read_http_request(conn) - print(f"[bridge-lan] handshake_bytes={len(request)}", flush=True) - conn.sendall(build_handshake_response(request)) + conn.sendall(_handshake_response(admission)) configure_client_socket( conn, config.client_idle_timeout_s, low_latency=config.stream_tts_phrases, ) print("[bridge-lan] handshake_accepted=1", flush=True) + if on_admitted is not None: + on_admitted(admission) conn.sendall(encode_ws_text(frame_to_text({"type": "hello", "protocol": PROTOCOL, "session": session.session}))) print("[bridge-lan] session_hello=1", flush=True) @@ -3993,6 +4132,7 @@ def run_initiative_turn(decision: InitiativeDecision) -> None: def serve(config: LanBridgeConfig) -> None: + allowed_robot_peers = resolve_robot_peer_addresses(config) memory = load_bridge_memory(config.memory_file) if config.memory_file else BridgeMemory() control_state = BridgeControlState() initiative_policy = InitiativePolicy( @@ -4090,8 +4230,23 @@ def note_room_summary(summary: RoomSceneSummary) -> None: while True: conn, address = server.accept() print(f"[bridge-lan] client={address[0]}:{address[1]}", flush=True) - if dashboard_runtime is not None: - dashboard_runtime.note_client_connected(address[0], address[1]) + if allowed_robot_peers is not None and not peer_address_allowed( + address[0], allowed_robot_peers + ): + print( + f"[bridge-lan] peer_rejected={address[0]}:{address[1]}", + flush=True, + ) + conn.close() + continue + admitted = False + + def note_admitted(_admission: WebSocketAdmission) -> None: + nonlocal admitted + admitted = True + if dashboard_runtime is not None: + dashboard_runtime.note_client_connected(address[0], address[1]) + try: with conn: conn.settimeout(5.0) @@ -4104,15 +4259,16 @@ def note_room_summary(summary: RoomSceneSummary) -> None: dashboard_runtime, initiative_policy, room_context, + note_admitted, ) except WebSocketProtocolError as exc: print(f"[bridge-lan] client_disconnect={address[0]}:{address[1]} reason=\"{exc}\"", flush=True) except OSError as exc: print(f"[bridge-lan] client_disconnect={address[0]}:{address[1]} reason=\"socket:{exc}\"", flush=True) finally: - if dashboard_runtime is not None: + if admitted and dashboard_runtime is not None: dashboard_runtime.note_client_disconnected(address[0]) - if config.once: + if config.once and admitted: break finally: room_context.stop() @@ -4125,7 +4281,10 @@ def note_room_summary(summary: RoomSceneSummary) -> None: def build_arg_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Run the local Stackchan P7 LAN WebSocket bridge.") + parser = argparse.ArgumentParser( + description="Run the local Stackchan P7 LAN WebSocket bridge.", + allow_abbrev=False, + ) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8765) parser.add_argument("--once", action="store_true", help="Handle one client and exit.") diff --git a/bridge/lan_smoke.py b/bridge/lan_smoke.py index 86c7f0d6..6cfc1156 100644 --- a/bridge/lan_smoke.py +++ b/bridge/lan_smoke.py @@ -145,6 +145,8 @@ def make_handshake(host: str, port: int) -> bytes: "Connection: Upgrade\r\n" "Sec-WebSocket-Key: ZGV2LWxhbi1zbW9rZS1rZXk=\r\n" "Sec-WebSocket-Version: 13\r\n" + f"X-Stackchan-Protocol: {PROTOCOL}\r\n" + "X-Stackchan-Device: stackchan\r\n" "\r\n" ).encode("ascii") diff --git a/bridge/test_dashboard_service.py b/bridge/test_dashboard_service.py index 9fb67654..86659471 100644 --- a/bridge/test_dashboard_service.py +++ b/bridge/test_dashboard_service.py @@ -2,6 +2,7 @@ import socket import sys import threading +import time import unittest import urllib.error import urllib.request @@ -21,6 +22,7 @@ ) from initiative_policy import InitiativeConfig, InitiativePolicy # noqa: E402 from lan_service import LanBridgeConfig, encode_ws_frame, encode_ws_text, read_ws_frame, serve # noqa: E402 +from reference_bridge import PROTOCOL # noqa: E402 from room_context import RoomContextRuntime, RoomObservationConfig # noqa: E402 @@ -409,7 +411,9 @@ def run() -> None: "Upgrade: websocket\r\n" "Connection: Upgrade\r\n" "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" - "Sec-WebSocket-Version: 13\r\n\r\n" + "Sec-WebSocket-Version: 13\r\n" + f"X-Stackchan-Protocol: {PROTOCOL}\r\n" + "X-Stackchan-Device: stackchan\r\n\r\n" ).encode("ascii") with socket.create_connection(("127.0.0.1", bridge_port), timeout=3.0) as client: client.sendall(request) @@ -429,8 +433,22 @@ def run() -> None: ) ) ) - with urllib.request.urlopen(status_url, timeout=3.0) as response: - status = json.load(response) + status_deadline = time.monotonic() + 3.0 + while True: + remaining = status_deadline - time.monotonic() + if remaining <= 0.0: + break + with urllib.request.urlopen(status_url, timeout=min(0.25, remaining)) as response: + status = json.load(response) + if ( + status["robot"]["mode"] == "Listening" + and status["robot"]["batteryPercent"] == 74 + ): + break + remaining = status_deadline - time.monotonic() + if remaining <= 0.0: + break + threading.Event().wait(min(0.02, remaining)) client.sendall(encode_ws_frame(b"", opcode=0x8)) thread.join(timeout=5.0) diff --git a/bridge/test_lan_service.py b/bridge/test_lan_service.py index 188b59c1..3a159efa 100644 --- a/bridge/test_lan_service.py +++ b/bridge/test_lan_service.py @@ -1,6 +1,7 @@ import json import os import base64 +import io import math import socket import sys @@ -18,6 +19,7 @@ if str(BRIDGE_DIR) not in sys.path: sys.path.insert(0, str(BRIDGE_DIR)) +import lan_service from lan_service import ( BridgeControlState, EndpointRecord, @@ -66,6 +68,36 @@ } +def firmware_upgrade_request( + *, + port: int = 8765, + path: str = "/bridge", + protocol: str | None = PROTOCOL, + device: str | None = "stackchan", + key: str = "c3RhY2tjaGFuLWZpcm13YXJlLWtleQ==", + origin: str | None = None, + extra_headers: tuple[str, ...] = (), +) -> bytes: + """Return the exact HTTP upgrade shape emitted by current firmware.""" + + lines = [ + f"GET {path} HTTP/1.1", + f"Host: 127.0.0.1:{port}", + "Upgrade: websocket", + "Connection: Upgrade", + f"Sec-WebSocket-Key: {key}", + "Sec-WebSocket-Version: 13", + ] + if protocol is not None: + lines.append(f"X-Stackchan-Protocol: {protocol}") + if device is not None: + lines.append(f"X-Stackchan-Device: {device}") + if origin is not None: + lines.append(f"Origin: {origin}") + lines.extend(extra_headers) + return ("\r\n".join(lines) + "\r\n\r\n").encode("ascii") + + def connect_loopback(port: int, timeout: float = 5.0) -> socket.socket: deadline = time.monotonic() + timeout while True: @@ -105,6 +137,8 @@ def test_handshake_response_accepts_upgrade_request(self): "Connection: Upgrade\r\n" "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" "Sec-WebSocket-Version: 13\r\n" + f"X-Stackchan-Protocol: {PROTOCOL}\r\n" + "X-Stackchan-Device: stackchan\r\n" "\r\n" ).encode("ascii") @@ -113,6 +147,352 @@ def test_handshake_response_accepts_upgrade_request(self): self.assertIn("101 Switching Protocols", response) self.assertIn("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", response) + def test_admission_accepts_exact_firmware_upgrade(self): + response = build_handshake_response(firmware_upgrade_request()).decode("ascii") + + self.assertIn("101 Switching Protocols", response) + + def test_admission_rejects_non_bridge_path(self): + requests = ( + firmware_upgrade_request(path="/not-bridge"), + firmware_upgrade_request(path="/bridge?browser=1"), + firmware_upgrade_request().replace(b" HTTP/1.1\r\n", b" HTTP/1.0\r\n", 1), + ) + for request in requests: + with self.subTest(request=request): + with self.assertRaises(lan_service.WebSocketProtocolError): + build_handshake_response(request) + + def test_admission_rejects_invalid_or_duplicate_websocket_fields(self): + valid = firmware_upgrade_request() + requests = ( + valid.replace(b"Upgrade: websocket\r\n", b"", 1), + valid.replace(b"Connection: Upgrade\r\n", b"Connection: keep-alive\r\n", 1), + valid.replace( + b"Sec-WebSocket-Key: c3RhY2tjaGFuLWZpcm13YXJlLWtleQ==\r\n", + b"Sec-WebSocket-Key: \r\n", + 1, + ), + valid.replace( + b"Sec-WebSocket-Key: c3RhY2tjaGFuLWZpcm13YXJlLWtleQ==\r\n", + "Sec-WebSocket-Key: caf\u00e9\r\n".encode("iso-8859-1"), + 1, + ), + valid.replace( + b"Sec-WebSocket-Key: c3RhY2tjaGFuLWZpcm13YXJlLWtleQ==\r\n", + b"Sec-WebSocket-Key: not-base64\r\n", + 1, + ), + firmware_upgrade_request(key="QQ==" * 100), + valid.replace(b"Sec-WebSocket-Version: 13\r\n", b"Sec-WebSocket-Version: 12\r\n", 1), + firmware_upgrade_request(extra_headers=("Upgrade: websocket",)), + firmware_upgrade_request(extra_headers=("Connection: Upgrade",)), + firmware_upgrade_request(extra_headers=("Sec-WebSocket-Key: duplicate",)), + firmware_upgrade_request(extra_headers=("Sec-WebSocket-Version: 13",)), + ) + for request in requests: + with self.subTest(request=request): + with self.assertRaises(lan_service.WebSocketProtocolError): + build_handshake_response(request) + + def test_admission_rejects_missing_or_wrong_protocol(self): + requests = ( + firmware_upgrade_request(protocol=None), + firmware_upgrade_request(protocol="stackchan.bridge.v0"), + firmware_upgrade_request( + extra_headers=(f"X-Stackchan-Protocol: {PROTOCOL}",) + ), + ) + for request in requests: + with self.subTest(request=request): + with self.assertRaises(lan_service.WebSocketProtocolError): + build_handshake_response(request) + + def test_admission_rejects_blank_or_invalid_device(self): + requests = ( + firmware_upgrade_request(device=None), + firmware_upgrade_request(device=""), + firmware_upgrade_request(device="bad device"), + firmware_upgrade_request(device="x" * 65), + firmware_upgrade_request( + extra_headers=("X-Stackchan-Device: duplicate",) + ), + ) + for request in requests: + with self.subTest(request=request): + with self.assertRaises(lan_service.WebSocketProtocolError): + build_handshake_response(request) + + def test_admission_rejects_browser_origin(self): + with self.assertRaises(lan_service.WebSocketProtocolError): + build_handshake_response( + firmware_upgrade_request(origin="http://127.0.0.1:8765") + ) + + def test_non_loopback_config_requires_robot_peer(self): + with self.assertRaisesRegex(ValueError, "robot_host"): + LanBridgeConfig(host="0.0.0.0") + + def test_cli_rejects_abbreviated_security_arguments(self): + parser = lan_service.build_arg_parser() + + with self.assertRaises(SystemExit), patch("sys.stderr", new=io.StringIO()): + parser.parse_args(["--robot-ho", "192.0.2.10"]) + + def test_admission_rejects_wrong_peer_before_dispatch(self): + allowed = frozenset({"192.0.2.10"}) + + self.assertTrue(lan_service.peer_address_allowed("192.0.2.10", allowed)) + self.assertTrue(lan_service.peer_address_allowed("::ffff:192.0.2.10", allowed)) + self.assertFalse(lan_service.peer_address_allowed("192.0.2.20", allowed)) + self.assertFalse(lan_service.peer_address_allowed("fe80::1", frozenset({"fe80::1"}))) + self.assertFalse( + lan_service.peer_address_allowed("fe80::1%12", frozenset({"fe80::1"})) + ) + + def test_wrong_peer_skips_request_dashboard_state_and_once(self): + class FakeServer: + def __init__(self, accepted): + self.accepted = list(accepted) + self.accept_calls = 0 + + def __enter__(self): + return self + + def __exit__(self, _exc_type, _exc, _traceback): + return False + + def setsockopt(self, *_args): + return None + + def accept(self): + self.accept_calls += 1 + return self.accepted.pop(0) + + wrong_conn = Mock(spec=socket.socket) + valid_server_conn, valid_client = socket.socketpair() + valid_client.settimeout(5.0) + fake_server = FakeServer( + ( + (wrong_conn, ("192.0.2.20", 4000)), + (valid_server_conn, ("192.0.2.10", 4001)), + ) + ) + dashboard = Mock() + room = Mock() + shared_memory = BridgeMemory() + shared_control = BridgeControlState() + initial_memory = shared_memory.to_dict() + initial_control = ( + dict(shared_control.trusted_endpoints), + shared_control.active_brain_owner, + shared_control.settings_version, + json.dumps(shared_control.settings, sort_keys=True), + shared_control.persona_initialized, + ) + state_before_valid: list[tuple[object, ...]] = [] + errors: list[BaseException] = [] + actual_handle_connection = lan_service.handle_connection + + def observed_handle_connection(*args, **kwargs): + control = args[3] + state_before_valid.append( + ( + args[2].to_dict(), + dict(control.trusted_endpoints), + control.active_brain_owner, + control.settings_version, + json.dumps(control.settings, sort_keys=True), + control.persona_initialized, + ) + ) + return actual_handle_connection(*args, **kwargs) + + def run_server(): + try: + serve( + LanBridgeConfig( + host="0.0.0.0", + port=8765, + robot_host="robot.example", + once=True, + dashboard_enabled=True, + ) + ) + except BaseException as exc: # pragma: no cover - surfaced by assertion + errors.append(exc) + + with ( + patch("lan_service.resolve_robot_peer_addresses", return_value=frozenset({"192.0.2.10"})), + patch("lan_service.load_bridge_memory", return_value=shared_memory), + patch("lan_service.BridgeControlState", return_value=shared_control), + patch("lan_service.RoomContextRuntime", return_value=room), + patch("lan_service.DashboardRuntime", return_value=dashboard), + patch("lan_service.start_dashboard_server", return_value=(Mock(), Mock())), + patch("lan_service.stop_dashboard_server"), + patch("lan_service.socket.create_server", return_value=fake_server), + patch("lan_service.read_http_request", wraps=lan_service.read_http_request) as read_request, + patch("lan_service.handle_connection", side_effect=observed_handle_connection) as handle, + ): + server = threading.Thread(target=run_server, daemon=True) + server.start() + valid_client.sendall(firmware_upgrade_request()) + response = bytearray() + while b"\r\n\r\n" not in response: + response.extend(valid_client.recv(1)) + self.assertIn(b"101 Switching Protocols", response) + opcode, payload = read_ws_frame(valid_client) + self.assertEqual(0x1, opcode) + self.assertEqual("hello", json.loads(payload.decode("utf-8"))["type"]) + valid_client.sendall(encode_ws_frame(b"", opcode=0x8)) + server.join(timeout=5.0) + valid_client.close() + + self.assertFalse(server.is_alive()) + self.assertEqual([], errors) + self.assertEqual(2, fake_server.accept_calls) + wrong_conn.recv.assert_not_called() + wrong_conn.close.assert_called_once_with() + read_request.assert_called_once() + handle.assert_called_once() + dashboard.note_client_connected.assert_called_once_with("192.0.2.10", 4001) + dashboard.note_client_disconnected.assert_called_once_with("192.0.2.10") + self.assertFalse( + any("192.0.2.20" in str(call) for call in dashboard.method_calls), + dashboard.method_calls, + ) + self.assertEqual( + [ + ( + initial_memory, + *initial_control, + ) + ], + state_before_valid, + ) + + def test_robot_peer_resolution_is_frozen_before_accept(self): + config = LanBridgeConfig(host="0.0.0.0", robot_host="robot.example") + resolved = [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.0.2.10", 0)), + (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::ffff:192.0.2.10", 0, 0, 0)), + ] + + with patch("lan_service.socket.getaddrinfo", return_value=resolved) as lookup: + allowed = lan_service.resolve_robot_peer_addresses(config) + + lookup.assert_called_once_with("robot.example", None, type=socket.SOCK_STREAM) + self.assertEqual(frozenset({"192.0.2.10"}), allowed) + + def test_unadmitted_session_rejects_protected_message(self): + session = LanBridgeSession(LanBridgeConfig(), transport_admitted=False) + + text_frames = session.handle_text(json.dumps({"type": "settings_get"})) + binary_frames = session.handle_binary(b"synthetic") + + self.assertEqual("admission_required", text_frames[0]["code"]) + self.assertEqual("admission_required", binary_frames[0]["code"]) + + def test_owner_gate_rejects_blank_endpoint_when_owner_exists(self): + state = BridgeControlState() + state.register_endpoint( + { + "type": "endpoint_hello", + "protocol": PROTOCOL, + "endpoint_id": "phone-owner-01", + "endpoint_kind": "android", + "capabilities": ["brain_owner"], + } + ) + state.active_brain_owner = "phone-owner-01" + session = LanBridgeSession(LanBridgeConfig(), control_state=state) + + result = session._owner_gate({}) + + self.assertIsNotNone(result) + self.assertEqual("endpoint_id_required", result["code"]) + + def test_loopback_default_allows_firmware_without_configured_peer(self): + config = LanBridgeConfig() + + self.assertEqual("127.0.0.1", config.host) + self.assertEqual("", config.robot_host) + self.assertIn(b"101 Switching Protocols", build_handshake_response(firmware_upgrade_request())) + + def test_invalid_attempt_does_not_consume_once_recovery(self): + with socket.create_server(("127.0.0.1", 0)) as probe: + port = int(probe.getsockname()[1]) + errors: list[BaseException] = [] + + def run_server(): + try: + serve(LanBridgeConfig(host="127.0.0.1", port=port, once=True)) + except BaseException as exc: # pragma: no cover - surfaced by assertion + errors.append(exc) + + server = threading.Thread(target=run_server, daemon=True) + server.start() + valid_request = firmware_upgrade_request(port=port) + invalid_requests = ( + valid_request.replace( + b"c3RhY2tjaGFuLWZpcm13YXJlLWtleQ==", + "caf\u00e9".encode("iso-8859-1"), + 1, + ), + firmware_upgrade_request(port=port, key="not-base64"), + ) + for invalid_request in invalid_requests: + with connect_loopback(port) as invalid_client: + invalid_client.sendall(invalid_request) + try: + invalid_response = invalid_client.recv(4096) + except ConnectionResetError: + invalid_response = b"" + self.assertNotIn(b"101 Switching Protocols", invalid_response) + + with connect_loopback(port) as valid_client: + valid_client.sendall(valid_request) + response = bytearray() + while b"\r\n\r\n" not in response: + response.extend(valid_client.recv(1)) + self.assertIn(b"101 Switching Protocols", response) + opcode, payload = read_ws_frame(valid_client) + self.assertEqual(0x1, opcode) + self.assertEqual("hello", json.loads(payload.decode("utf-8"))["type"]) + valid_client.sendall(encode_ws_frame(b"", opcode=0x8)) + server.join(timeout=5.0) + + self.assertFalse(server.is_alive()) + self.assertEqual([], errors) + + def test_valid_firmware_disconnect_and_reconnect_receives_hello(self): + for _attempt in range(2): + with socket.create_server(("127.0.0.1", 0)) as probe: + port = int(probe.getsockname()[1]) + errors: list[BaseException] = [] + + def run_server(): + try: + serve(LanBridgeConfig(host="127.0.0.1", port=port, once=True)) + except BaseException as exc: # pragma: no cover - surfaced by assertion + errors.append(exc) + + server = threading.Thread(target=run_server, daemon=True) + server.start() + with connect_loopback(port) as client: + client.sendall(firmware_upgrade_request(port=port)) + response = bytearray() + while b"\r\n\r\n" not in response: + response.extend(client.recv(1)) + self.assertIn(b"101 Switching Protocols", response) + opcode, payload = read_ws_frame(client) + self.assertEqual(0x1, opcode) + self.assertEqual("hello", json.loads(payload.decode("utf-8"))["type"]) + client.sendall(encode_ws_frame(b"", opcode=0x8)) + server.join(timeout=5.0) + self.assertFalse(server.is_alive()) + self.assertEqual([], errors) + def test_server_text_frame_encoding_is_unmasked(self): self.assertEqual(b"\x81\x02hi", encode_ws_text("hi")) @@ -141,6 +521,8 @@ def run_server(): "Connection: Upgrade\r\n" "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" "Sec-WebSocket-Version: 13\r\n" + f"X-Stackchan-Protocol: {PROTOCOL}\r\n" + "X-Stackchan-Device: stackchan\r\n" "\r\n" ).encode("ascii") with connect_loopback(port) as client: @@ -174,6 +556,8 @@ def run_server(): "Connection: Upgrade\r\n" "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" "Sec-WebSocket-Version: 13\r\n" + f"X-Stackchan-Protocol: {PROTOCOL}\r\n" + "X-Stackchan-Device: stackchan\r\n" "\r\n" ).encode("ascii") with connect_loopback(port) as client: @@ -284,6 +668,8 @@ def run_server(): "Connection: Upgrade\r\n" "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" "Sec-WebSocket-Version: 13\r\n" + f"X-Stackchan-Protocol: {PROTOCOL}\r\n" + "X-Stackchan-Device: stackchan\r\n" "\r\n" ).encode("ascii") with connect_loopback(port) as client: @@ -388,6 +774,8 @@ def run_server(): "Connection: Upgrade\r\n" "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" "Sec-WebSocket-Version: 13\r\n" + f"X-Stackchan-Protocol: {PROTOCOL}\r\n" + "X-Stackchan-Device: stackchan\r\n" "\r\n" ).encode("ascii") with connect_loopback(port) as client: @@ -531,6 +919,8 @@ def run_server(): "Connection: Upgrade\r\n" "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" "Sec-WebSocket-Version: 13\r\n" + f"X-Stackchan-Protocol: {PROTOCOL}\r\n" + "X-Stackchan-Device: stackchan\r\n" "\r\n" ).encode("ascii") with connect_loopback(port) as client: @@ -667,6 +1057,8 @@ def run_server(): "Connection: Upgrade\r\n" "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" "Sec-WebSocket-Version: 13\r\n" + f"X-Stackchan-Protocol: {PROTOCOL}\r\n" + "X-Stackchan-Device: stackchan\r\n" "\r\n" ).encode("ascii") with connect_loopback(port) as client: @@ -798,6 +1190,8 @@ def run_server(): "Connection: Upgrade\r\n" "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" "Sec-WebSocket-Version: 13\r\n" + f"X-Stackchan-Protocol: {PROTOCOL}\r\n" + "X-Stackchan-Device: stackchan\r\n" "\r\n" ).encode("ascii") with connect_loopback(port) as client: diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index 9d6d7004..09a67dae 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -307,14 +307,21 @@ Package desktop builds with `-Pstackchan.desktop.pythonRuntimeRoot=` or `STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT=`, then attach the generated `stackchan-python-runtime.json` and checker output to the release evidence. -For PC Brain Mode lab bring-up from the source checkout, start the local bridge with the -selected voice path: +For PC Brain Mode lab bring-up from the source checkout, first use the loopback default for the +synthetic host-only probe with the selected voice path: ```powershell .\tools\start_pc_brain.cmd -Background -StopExisting -EnableAudioDownlink -SelectedVoiceStartBytes 65536 -DownlinkBinaryFrameDelayMs 20 .\tools\run_pc_brain_probe.cmd --url ws://127.0.0.1:8765/bridge ``` +That loopback process is preflight-only. Before expecting the robot to connect, deliberately +restart the bridge with a frozen robot peer: + +```powershell +.\tools\start_pc_brain.cmd -HostName 0.0.0.0 -RobotHost -Background -StopExisting -EnableAudioDownlink -SelectedVoiceStartBytes 65536 -DownlinkBinaryFrameDelayMs 20 +``` + On Windows, `tools\start_pc_brain.cmd` now defaults to the repo-local `python bridge\whisper_cpp_stt.py` adapter. Run `tools\setup_whisper_cpp.cmd` once before starting the brain on a fresh machine. The older `bridge\windows_speech_stt.py` System.Speech @@ -592,6 +599,13 @@ $env:STACKCHAN_RVC_MAX_AUDIO_BYTES = "65536" powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\start_pc_brain.ps1 -StopExisting -Background -EnableAudioDownlink -TtsCommand "python bridge\rvc_tts_client.py" -TtsVoice "stackchan-rvc-warm-rocm" -DownlinkBinaryFrameDelayMs 80 ``` +The command above is a preserved historical record, not a current instruction. Its current +SEC-001 equivalent is the following explicit peer-restricted launch: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\start_pc_brain.ps1 -HostName 0.0.0.0 -RobotHost -StopExisting -Background -EnableAudioDownlink -TtsCommand "python bridge\rvc_tts_client.py" -TtsVoice "stackchan-rvc-warm-rocm" -DownlinkBinaryFrameDelayMs 80 +``` + Voice V2 DirectML has passed lab, wire, and supervised physical validation. The physical warm API run completed four turns with all `567040` host bytes matched by the robot, no truncation, playback errors, or forced stops, worst conversation first audio `3492.31 ms`, and worst @@ -991,6 +1005,13 @@ $env:STACKCHAN_RVC_MAX_AUDIO_BYTES = "65536" powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\start_pc_brain.ps1 -StopExisting -Background -EnableAudioDownlink -TtsCommand "python bridge\rvc_tts.py" -TtsVoice "stackchan-rvc-live" -DownlinkBinaryFrameDelayMs 80 ``` +The command above is preserved historical evidence and must not be rerun as a current launch. The +current peer-restricted equivalent is: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\start_pc_brain.ps1 -HostName 0.0.0.0 -RobotHost -StopExisting -Background -EnableAudioDownlink -TtsCommand "python bridge\rvc_tts.py" -TtsVoice "stackchan-rvc-live" -DownlinkBinaryFrameDelayMs 80 +``` + One-shot ROCm was slower for short responses because the process paid GPU/runtime startup every turn. The warm worker keeps the model loaded: the first `pm` conversion warmed in about 29 s and the next local conversion completed in about 3 s. diff --git a/docs/BRIDGE_DASHBOARD.md b/docs/BRIDGE_DASHBOARD.md index 5f2d91cc..a64d2169 100644 --- a/docs/BRIDGE_DASHBOARD.md +++ b/docs/BRIDGE_DASHBOARD.md @@ -94,8 +94,9 @@ The base launcher also supports explicit dashboard options: ```powershell .\tools\start_pc_brain.ps1 -Background -EnableDashboard ` + -HostName 0.0.0.0 -RobotHost 192.168.1.238 ` -DashboardHost 127.0.0.1 -DashboardPort 8766 ` - -RobotHost 192.168.1.238 -EnableAudioDownlink + -EnableAudioDownlink ``` For a supervised qualification that starts semantic room observation immediately: @@ -103,9 +104,10 @@ For a supervised qualification that starts semantic room observation immediately ```powershell $env:STACKCHAN_OLLAMA_VISION_MODEL = "your-local-vision-model" .\tools\start_pc_brain.ps1 -Background -EnableDashboard -EnableAudioDownlink ` + -HostName 0.0.0.0 -RobotHost 192.168.1.238 ` -EnableConversationV2 -EnableInitiative -EnableRoomObservation ` -CameraPairingCodeFile "$env:USERPROFILE\.stackchan\camera-pairing-code.txt" ` - -RobotHost 192.168.1.238 + -RoomObservationIntervalSeconds 300 ``` The dashboard runs inside that bridge process and receives robot heartbeat summaries directly. diff --git a/docs/BRIDGE_PROTOCOL.md b/docs/BRIDGE_PROTOCOL.md index c96c0298..3f1926d0 100644 --- a/docs/BRIDGE_PROTOCOL.md +++ b/docs/BRIDGE_PROTOCOL.md @@ -116,6 +116,23 @@ binary PCM frames after `utterance_start`, runs the same local runner/validator/ `utterance_end`, and streams normalized bridge JSON text frames back to the client. Audio-only turns can use a configured local STT command: +Host admission is fail-closed at the HTTP upgrade. The request line must be exactly +`GET /bridge HTTP/1.1`; WebSocket upgrade/key/version-13 fields, the exact +`X-Stackchan-Protocol: stackchan.bridge.v1` header, and one bounded nonblank +`X-Stackchan-Device` value must be present. Security-critical duplicate headers and every +browser `Origin` header are rejected before `101` or message dispatch. The general launcher binds +loopback by default. A non-loopback bind requires a configured robot host; its addresses are +resolved once at startup and the accepted TCP peer must match that frozen set before the request is +read. The transmitted WebSocket key must be bounded ASCII Base64 with a nonempty bounded decoded +value; it is a protocol field, not a secret or identity. Scoped and link-local IPv6 peers are not +admitted by this IPv4-bound containment slice. Invalid attempts do not consume single-admitted- +connection test mode. These existing headers and the configured peer are containment signals, not +secrets or cryptographic device identity. +Firmware sends no client application `hello`; after successful upgrade the host preserves the +existing immediate server `hello`. `X-Stackchan-Device` is not a brain-owner endpoint ID, and a +blank endpoint remains valid for current firmware turns only while no explicit brain owner is +active. + The Android companion uses the same `stackchan.bridge.v1` family. The target architecture is multi-endpoint: a PC bridge and an Android bridge may both be trusted, but only one endpoint is the active brain owner allowed to receive wake-gated audio and dynamic response ownership. diff --git a/docs/RELEASE_QUICKSTART.md b/docs/RELEASE_QUICKSTART.md index c78e649c..935f7d61 100644 --- a/docs/RELEASE_QUICKSTART.md +++ b/docs/RELEASE_QUICKSTART.md @@ -426,16 +426,22 @@ Repeat on all three desktop operating systems. A CI runner report and an extract are deliberately rejected by the final desktop v1 gate. Installed-launch evidence also does not replace the human desktop review. -For PC Brain Mode lab bring-up, start the local brain bridge and selected voice TTS path: +For PC Brain Mode lab bring-up, first use the loopback default for a synthetic host-only probe: ```powershell .\tools\start_pc_brain.cmd -Background -StopExisting .\tools\run_pc_brain_probe.cmd --url ws://127.0.0.1:8765/bridge ``` -Point the robot at `ws://:8765/bridge` with `tools\flash_wifi_bridge.cmd` or the -runtime `wifi set ... url "ws://:8765/bridge"` command, then collect deployment -evidence: +That loopback process cannot accept the robot. Deliberately restart it with the robot peer frozen +before pointing the robot at `ws://:8765/bridge` with `tools\flash_wifi_bridge.cmd` or +the runtime `wifi set ... url "ws://:8765/bridge"` command: + +```powershell +.\tools\start_pc_brain.cmd -HostName 0.0.0.0 -RobotHost -Background -StopExisting +``` + +Then collect deployment evidence: ```powershell .\tools\collect_pc_brain_deploy_evidence.cmd -DeviceHost -SourceCommit -RunTests diff --git a/tools/check_pc_brain_runtime.ps1 b/tools/check_pc_brain_runtime.ps1 index 3b4a4feb..a3a84547 100644 --- a/tools/check_pc_brain_runtime.ps1 +++ b/tools/check_pc_brain_runtime.ps1 @@ -53,6 +53,70 @@ function Normalize-Text { return ($Text -replace "\\", "/" -replace "\s+", " ").Trim().ToLowerInvariant() } +function Split-WindowsCommandLine { + param([string]$CommandLine) + if ($null -eq ("Stackchan.RuntimeContract.CommandLine" -as [type])) { + Add-Type -TypeDefinition @" +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace Stackchan.RuntimeContract { + public static class CommandLine { + [DllImport("shell32.dll", SetLastError = true)] + private static extern IntPtr CommandLineToArgvW( + [MarshalAs(UnmanagedType.LPWStr)] string commandLine, + out int argumentCount + ); + + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr memory); + + public static string[] Split(string commandLine) { + int argumentCount; + IntPtr arguments = CommandLineToArgvW(commandLine, out argumentCount); + if (arguments == IntPtr.Zero) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + try { + var values = new List(argumentCount); + for (int index = 0; index < argumentCount; ++index) { + IntPtr value = Marshal.ReadIntPtr(arguments, index * IntPtr.Size); + values.Add(Marshal.PtrToStringUni(value) ?? string.Empty); + } + return values.ToArray(); + } finally { + LocalFree(arguments); + } + } + } +} +"@ + } + return @([Stackchan.RuntimeContract.CommandLine]::Split($CommandLine)) +} + +function Get-ExactLongArgumentValues { + param( + [string[]]$Tokens, + [string]$Name + ) + $argument = "--" + $Name.TrimStart("-").ToLowerInvariant() + $values = @() + for ($index = 0; $index -lt $Tokens.Count; ++$index) { + $token = Normalize-Text $Tokens[$index] + if ($token -eq $argument) { + if ($index + 1 -lt $Tokens.Count) { + $values += (Normalize-Text $Tokens[$index + 1]) + } + } elseif ($token.StartsWith($argument + "=")) { + $values += $token.Substring($argument.Length + 1) + } + } + return @($values) +} + function Test-CommandLineContains { param( [string]$Id, @@ -143,10 +207,35 @@ if ($processes.Count -gt 1) { } $normalizedCommandLine = Normalize-Text $commandLine +$commandTokens = if ([string]::IsNullOrWhiteSpace($commandLine)) { + @() +} else { + @(Split-WindowsCommandLine $commandLine) +} +$expectedHostIsLoopback = $ExpectedHostName.Trim().ToLowerInvariant() -in @( + "127.0.0.1", + "::1", + "localhost" +) if (-not [string]::IsNullOrWhiteSpace($commandLine)) { Test-CommandLineContains "script" $normalizedCommandLine "bridge/lan_service.py" "Uses bridge/lan_service.py." - Test-CommandLineContains "host" $normalizedCommandLine "--host $ExpectedHostName" "Host is $ExpectedHostName." + $hostValues = @(Get-ExactLongArgumentValues $commandTokens "host") + $expectedHostValue = Normalize-Text $ExpectedHostName + Add-Check "host" ` + ($(if ($hostValues.Count -eq 1 -and $hostValues[0] -eq $expectedHostValue) { "pass" } else { "fail" })) ` + "Exactly one host argument is configured as $ExpectedHostName." + if ($expectedHostIsLoopback) { + Add-Check "robot-peer" "pass" "A configured robot peer is optional for a loopback-only bridge." + } elseif ([string]::IsNullOrWhiteSpace($DeviceHost)) { + Add-Check "robot-peer" "fail" "DeviceHost is required to certify a non-loopback bridge." + } else { + $robotPeerValues = @(Get-ExactLongArgumentValues $commandTokens "robot-host") + $expectedRobotPeer = Normalize-Text $DeviceHost + Add-Check "robot-peer" ` + ($(if ($robotPeerValues.Count -eq 1 -and $robotPeerValues[0] -eq $expectedRobotPeer) { "pass" } else { "fail" })) ` + "Robot peer is restricted to exactly one configured value matching $DeviceHost." + } Test-CommandLineContains "port" $normalizedCommandLine "--port $Port" "Port is $Port." Test-CommandLineContains "runner-profile" $normalizedCommandLine "--runner-profile gemma4-e2b-gguf" "Runner profile is gemma4-e2b-gguf." Test-CommandLineFlagAndScript "runner-command" $normalizedCommandLine "--runner-command" $ExpectedRunnerCommand "Runner command is $ExpectedRunnerCommand." @@ -230,7 +319,9 @@ if ([string]::IsNullOrWhiteSpace($ProcessCommandLine)) { Add-Check "port-listener" "pending" "Skipped because -ProcessCommandLine was supplied." } -if ([string]::IsNullOrWhiteSpace($DebugUrl) -and -not [string]::IsNullOrWhiteSpace($DeviceHost)) { +if ([string]::IsNullOrWhiteSpace($DebugUrl) -and + [string]::IsNullOrWhiteSpace($ProcessCommandLine) -and + -not [string]::IsNullOrWhiteSpace($DeviceHost)) { $DebugUrl = "http://$DeviceHost`:8789/debug" } diff --git a/tools/restore_voice_v2_production.ps1 b/tools/restore_voice_v2_production.ps1 index 3b90472d..56ba8905 100644 --- a/tools/restore_voice_v2_production.ps1 +++ b/tools/restore_voice_v2_production.ps1 @@ -6,6 +6,9 @@ param( ) $ErrorActionPreference = "Stop" +if ([string]::IsNullOrWhiteSpace($DeviceHost)) { + throw "DeviceHost is required before restoring the robot-facing bridge." +} $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $RepoRoot $DebugUrl = "http://$DeviceHost`:8789/debug" @@ -15,6 +18,7 @@ if (-not $ProductionHealth.ready) { throw "Production RVC worker is not ready on $env:STACKCHAN_RVC_WORKER_URL = "http://127.0.0.1:5055" & (Join-Path $PSScriptRoot "start_pc_brain.ps1") ` + -HostName "0.0.0.0" -RobotHost $DeviceHost ` -StopExisting -Background -EnableAudioDownlink ` -TtsCommand "python bridge\rvc_tts_client.py" ` -TtsVoice "stackchan-rvc-warm-rocm" ` diff --git a/tools/run_selected_voice_once.ps1 b/tools/run_selected_voice_once.ps1 index c8532f85..7fe4b1f8 100644 --- a/tools/run_selected_voice_once.ps1 +++ b/tools/run_selected_voice_once.ps1 @@ -6,16 +6,22 @@ param( [int]$DownlinkBinaryFrameDelayMs = 20, [int]$DownlinkTextFrameDelayMs = 40, [int]$WaitSeconds = 60, - [switch]$LeaveBrainRunning + [switch]$LeaveBrainRunning, + [string]$DeviceHost = "192.168.1.238" ) $ErrorActionPreference = "Stop" +if ([string]::IsNullOrWhiteSpace($DeviceHost)) { + throw "DeviceHost is required before starting the robot-facing voice check." +} $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $RepoRoot Write-Host "[selected-voice-once] starting one-shot PC brain voice check" & (Join-Path $PSScriptRoot "start_pc_brain.ps1") ` + -HostName "0.0.0.0" ` + -RobotHost $DeviceHost ` -Background ` -StopExisting ` -Once ` diff --git a/tools/start_pc_brain.ps1 b/tools/start_pc_brain.ps1 index b4ec4cd8..5ab0e424 100644 --- a/tools/start_pc_brain.ps1 +++ b/tools/start_pc_brain.ps1 @@ -1,5 +1,5 @@ param( - [string]$HostName = "0.0.0.0", + [string]$HostName = "127.0.0.1", [int]$Port = 8765, [string]$Model = "gemma4:e2b-it-qat", [string]$RunnerCommand = "python bridge\ollama_stackchan_runner.py", @@ -68,6 +68,15 @@ if ($LASTEXITCODE -ne 0 -or $SourceCommit -notmatch "^[0-9a-fA-F]{40}$") { } $SourceDirty = @(& git status --porcelain).Count -gt 0 +$ParsedBindAddress = $null +$BindIsLoopback = $HostName -eq "localhost" +if ([System.Net.IPAddress]::TryParse($HostName, [ref]$ParsedBindAddress)) { + $BindIsLoopback = [System.Net.IPAddress]::IsLoopback($ParsedBindAddress) +} +if (-not $BindIsLoopback -and [string]::IsNullOrWhiteSpace($RobotHost)) { + throw "RobotHost is required when HostName is not loopback." +} + $OllamaExe = Join-Path $env:LOCALAPPDATA "Programs\Ollama\ollama.exe" if (-not (Test-Path -LiteralPath $OllamaExe)) { $OllamaExe = "ollama" @@ -257,9 +266,10 @@ if ($EnableDashboard) { "--dashboard-port", "$DashboardPort", "--robot-http-port", "$RobotHttpPort" ) - if (-not [string]::IsNullOrWhiteSpace($RobotHost)) { - $ArgsList += @("--robot-host", $RobotHost) - } +} + +if (-not [string]::IsNullOrWhiteSpace($RobotHost)) { + $ArgsList += @("--robot-host", $RobotHost) } if ($AutoTurnText) { diff --git a/tools/start_pc_brain_directml.ps1 b/tools/start_pc_brain_directml.ps1 index 3373d9f9..5385b454 100644 --- a/tools/start_pc_brain_directml.ps1 +++ b/tools/start_pc_brain_directml.ps1 @@ -34,6 +34,9 @@ param( ) $ErrorActionPreference = "Stop" +if ([string]::IsNullOrWhiteSpace($DeviceHost)) { + throw "DeviceHost is required before DirectML production startup." +} $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $RepoRoot @@ -308,7 +311,7 @@ $bridgeScript = "`$ErrorActionPreference = 'Stop'; `$ProgressPreference = 'Silen "`$env:STACKCHAN_WHISPER_THREADS = '$SttThreads'; " + "& '$bridgeStarter' -Background -EnableAudioDownlink -StreamTtsPhrases " + "-InProcessOllamaRunner -InProcessDirectMlTts " + - "-Port $BridgePort -MemoryFile '$escapedMemoryFile' " + + "-HostName '0.0.0.0' -Port $BridgePort -MemoryFile '$escapedMemoryFile' " + "-SttServerUrl '$SttServerUrl' -SttRestartCommand '$escapedSttRestartCommand' " + "-SttHealthIntervalSeconds 2 " + "-EnableDashboard -DashboardHost '127.0.0.1' -DashboardPort $DashboardPort -RobotHost '$escapedDeviceHost' " + diff --git a/tools/start_voice_v2_supervised_validation.ps1 b/tools/start_voice_v2_supervised_validation.ps1 index d92a48c3..459ef370 100644 --- a/tools/start_voice_v2_supervised_validation.ps1 +++ b/tools/start_voice_v2_supervised_validation.ps1 @@ -10,6 +10,9 @@ param( ) $ErrorActionPreference = "Stop" +if ([string]::IsNullOrWhiteSpace($DeviceHost)) { + throw "DeviceHost is required before supervised validation." +} $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $RepoRoot if ([string]::IsNullOrWhiteSpace($PythonExe)) { @@ -95,6 +98,7 @@ try { $env:STACKCHAN_RVC_DIRECTML_WORKER_URL = "http://127.0.0.1:5059" $TtsCommand = "$PythonExe bridge\rvc_directml_tts_client.py" & (Join-Path $PSScriptRoot "start_pc_brain.ps1") ` + -HostName "0.0.0.0" -RobotHost $DeviceHost ` -StopExisting -Background -EnableAudioDownlink -StreamTtsPhrases ` -InProcessOllamaRunner -InProcessDirectMlTts ` -TtsCommand $TtsCommand -TtsVoice "stackchan-rvc-directml-v2" ` diff --git a/tools/start_warm_rocm_full_system_soak.ps1 b/tools/start_warm_rocm_full_system_soak.ps1 index 00399b70..ef11153f 100644 --- a/tools/start_warm_rocm_full_system_soak.ps1 +++ b/tools/start_warm_rocm_full_system_soak.ps1 @@ -39,6 +39,9 @@ param( ) $ErrorActionPreference = "Stop" +if ([string]::IsNullOrWhiteSpace($DeviceHost)) { + throw "DeviceHost is required before the physical soak wrapper performs any action." +} $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $RepoRoot @@ -197,6 +200,8 @@ if (-not $SkipBridgeRestart) { $env:STACKCHAN_RVC_WORKER_TIMEOUT_SECONDS = "90" $env:STACKCHAN_RVC_MAX_AUDIO_BYTES = "2097152" powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\start_pc_brain.ps1 ` + -HostName "0.0.0.0" ` + -RobotHost $DeviceHost ` -StopExisting ` -Background ` -EnableAudioDownlink ` diff --git a/tools/test_pc_brain_runtime_check_contract.ps1 b/tools/test_pc_brain_runtime_check_contract.ps1 index 053f0d7a..2a30b928 100644 --- a/tools/test_pc_brain_runtime_check_contract.ps1 +++ b/tools/test_pc_brain_runtime_check_contract.ps1 @@ -4,20 +4,29 @@ $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $RepoRoot function Invoke-RuntimeCheck { - param([string]$CommandLine) - $output = & "tools\check_pc_brain_runtime.ps1" -ProcessCommandLine $CommandLine -Json + param( + [string]$CommandLine, + [string]$ExpectedHostName = "0.0.0.0", + [string]$DeviceHost = "192.0.2.10" + ) + $output = & "tools\check_pc_brain_runtime.ps1" -ProcessCommandLine $CommandLine ` + -ExpectedHostName $ExpectedHostName -DeviceHost $DeviceHost -Json return ($output | ConvertFrom-Json) } function Invoke-RuntimeCheckSubprocess { - param([string]$CommandLine) + param( + [string]$CommandLine, + [string]$ExpectedHostName = "0.0.0.0", + [string]$DeviceHost = "192.0.2.10" + ) $escaped = $CommandLine.Replace("'@", "' + '@'") $script = @" Set-Location '$RepoRoot' `$ProgressPreference = 'SilentlyContinue' & 'tools\check_pc_brain_runtime.ps1' -ProcessCommandLine @' $escaped -'@ -Json +'@ -ExpectedHostName '$ExpectedHostName' -DeviceHost '$DeviceHost' -Json "@ $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script)) $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded @@ -28,7 +37,7 @@ $escaped } } -$goodCommand = '"C:\Python310\python.exe" bridge\lan_service.py --host 0.0.0.0 --port 8765 --runner-profile gemma4-e2b-gguf --runner-timeout-ms 120000 --stt-command "python bridge\whisper_cpp_stt.py" --stt-timeout-ms 15000 --tts-command "python bridge\selected_voice_tts.py" --tts-voice stackchan-rvc-bright-robot --tts-timeout-ms 120000 --downlink-audio-chunk-bytes 4096 --downlink-binary-frame-delay-ms 20 --downlink-text-frame-delay-ms 40 --client-idle-timeout-s 20 --memory-file output\pc-brain\latest\memory.json --turn-log-file output\pc-brain\latest\turns.jsonl --disable-audio-downlink --runner-command "python bridge\ollama_stackchan_runner.py" --require-runner' +$goodCommand = '"C:\Python310\python.exe" bridge\lan_service.py --host 0.0.0.0 --port 8765 --robot-host 192.0.2.10 --runner-profile gemma4-e2b-gguf --runner-timeout-ms 120000 --stt-command "python bridge\whisper_cpp_stt.py" --stt-timeout-ms 15000 --tts-command "python bridge\selected_voice_tts.py" --tts-voice stackchan-rvc-bright-robot --tts-timeout-ms 120000 --downlink-audio-chunk-bytes 4096 --downlink-binary-frame-delay-ms 20 --downlink-text-frame-delay-ms 40 --client-idle-timeout-s 20 --memory-file output\pc-brain\latest\memory.json --turn-log-file output\pc-brain\latest\turns.jsonl --disable-audio-downlink --runner-command "python bridge\ollama_stackchan_runner.py" --require-runner' $good = Invoke-RuntimeCheck -CommandLine $goodCommand if (-not $good.machineReady) { throw "Expected good command line to be machine-ready." @@ -36,7 +45,7 @@ if (-not $good.machineReady) { if ($good.failed -ne 0) { throw "Expected good command line to have zero failed checks." } -foreach ($id in @("stt-command", "audio-wake-phrase", "audio-downlink-disabled", "stream-tts-phrases", "tts-command", "tts-voice", "runner-command", "require-runner", "binary-delay", "client-idle-timeout", "turn-log-file")) { +foreach ($id in @("robot-peer", "stt-command", "audio-wake-phrase", "audio-downlink-disabled", "stream-tts-phrases", "tts-command", "tts-voice", "runner-command", "require-runner", "binary-delay", "client-idle-timeout", "turn-log-file")) { $check = @($good.checks | Where-Object { $_.id -eq $id })[0] if ($null -eq $check -or $check.status -ne "pass") { throw "Expected $id to pass." @@ -44,13 +53,13 @@ foreach ($id in @("stt-command", "audio-wake-phrase", "audio-downlink-disabled", } $strictCommand = $goodCommand -replace "--tts-command", "--require-audio-wake-phrase --tts-command" -$strict = & "tools\check_pc_brain_runtime.ps1" -ProcessCommandLine $strictCommand -ExpectedRequireAudioWakePhrase $true -Json | ConvertFrom-Json +$strict = & "tools\check_pc_brain_runtime.ps1" -ProcessCommandLine $strictCommand -DeviceHost "192.0.2.10" -ExpectedRequireAudioWakePhrase $true -Json | ConvertFrom-Json if (-not $strict.machineReady -or $strict.failed -ne 0) { throw "Expected strict wake-phrase command line to pass when explicitly requested." } $enabledAudioCommand = $goodCommand -replace " --disable-audio-downlink", "" -$enabledAudio = & "tools\check_pc_brain_runtime.ps1" -ProcessCommandLine $enabledAudioCommand -ExpectedDisableAudioDownlink $false -Json | ConvertFrom-Json +$enabledAudio = & "tools\check_pc_brain_runtime.ps1" -ProcessCommandLine $enabledAudioCommand -DeviceHost "192.0.2.10" -ExpectedDisableAudioDownlink $false -Json | ConvertFrom-Json if (-not $enabledAudio.machineReady -or $enabledAudio.failed -ne 0) { throw "Expected explicitly enabled audio-downlink command line to pass when requested." } @@ -61,6 +70,7 @@ $directMlCommand = $goodCommand ` -replace "--downlink-binary-frame-delay-ms 20", "--stream-tts-phrases --downlink-binary-frame-delay-ms 70" ` -replace " --disable-audio-downlink", "" $directMl = & "tools\check_pc_brain_runtime.ps1" -ProcessCommandLine $directMlCommand ` + -DeviceHost "192.0.2.10" ` -ExpectedTtsCommand "bridge\rvc_directml_tts_client.py" ` -ExpectedTtsVoice "stackchan-rvc-directml-v2" ` -ExpectedDownlinkBinaryFrameDelayMs 70 ` @@ -70,6 +80,115 @@ if (-not $directMl.machineReady -or $directMl.failed -ne 0) { throw "Expected DirectML phrase-streaming command line to pass." } +$missingPeerCommand = $goodCommand -replace " --robot-host 192\.0\.2\.10", "" +$missingPeerResult = Invoke-RuntimeCheckSubprocess -CommandLine $missingPeerCommand +if ($missingPeerResult.exitCode -eq 0) { + throw "Expected a non-loopback command without --robot-host to fail." +} +$missingPeerCheck = @($missingPeerResult.json.checks | Where-Object { $_.id -eq "robot-peer" })[0] +if ($null -eq $missingPeerCheck -or $missingPeerCheck.status -ne "fail") { + throw "Expected the missing robot-peer check to fail." +} + +$wrongPeerCommand = $goodCommand -replace "--robot-host 192\.0\.2\.10", "--robot-host 192.0.2.20" +$wrongPeerResult = Invoke-RuntimeCheckSubprocess -CommandLine $wrongPeerCommand +if ($wrongPeerResult.exitCode -eq 0) { + throw "Expected a non-loopback command with the wrong --robot-host to fail." +} +$wrongPeerCheck = @($wrongPeerResult.json.checks | Where-Object { $_.id -eq "robot-peer" })[0] +if ($null -eq $wrongPeerCheck -or $wrongPeerCheck.status -ne "fail") { + throw "Expected the wrong robot-peer check to fail." +} + +$prefixPeerCommand = $goodCommand -replace "--robot-host 192\.0\.2\.10", "--robot-host 192.0.2.100" +$prefixPeerResult = Invoke-RuntimeCheckSubprocess -CommandLine $prefixPeerCommand +if ($prefixPeerResult.exitCode -eq 0) { + throw "Expected a prefix-colliding --robot-host value to fail." +} +$prefixPeerCheck = @($prefixPeerResult.json.checks | Where-Object { $_.id -eq "robot-peer" })[0] +if ($null -eq $prefixPeerCheck -or $prefixPeerCheck.status -ne "fail") { + throw "Expected the prefix-colliding robot-peer check to fail." +} + +$suffixPeerCommand = $goodCommand -replace "--robot-host 192\.0\.2\.10", "--robot-host x192.0.2.10" +$suffixPeerResult = Invoke-RuntimeCheckSubprocess -CommandLine $suffixPeerCommand +if ($suffixPeerResult.exitCode -eq 0) { + throw "Expected a suffix-colliding --robot-host value to fail." +} + +$decoyPeerCommand = $goodCommand -replace "--robot-host 192\.0\.2\.10", "--robot-host 192.0.2.20 --note 192.0.2.10" +$decoyPeerResult = Invoke-RuntimeCheckSubprocess -CommandLine $decoyPeerCommand +if ($decoyPeerResult.exitCode -eq 0) { + throw "Expected an unrelated decoy peer value to fail." +} + +$duplicatePeerCommand = $goodCommand -replace "--robot-host 192\.0\.2\.10", "--robot-host 192.0.2.10 --robot-host 192.0.2.20" +$duplicatePeerResult = Invoke-RuntimeCheckSubprocess -CommandLine $duplicatePeerCommand +if ($duplicatePeerResult.exitCode -eq 0) { + throw "Expected duplicate --robot-host arguments to fail certification." +} + +$duplicateEqualsPeerCommand = $goodCommand -replace "--robot-host 192\.0\.2\.10", "--robot-host 192.0.2.10 --robot-host=192.0.2.20" +$duplicateEqualsPeerResult = Invoke-RuntimeCheckSubprocess -CommandLine $duplicateEqualsPeerCommand +if ($duplicateEqualsPeerResult.exitCode -eq 0) { + throw "Expected mixed space/equals duplicate --robot-host arguments to fail certification." +} + +$equalsPeerCommand = $goodCommand -replace "--robot-host 192\.0\.2\.10", "--robot-host=192.0.2.10" +$equalsPeer = Invoke-RuntimeCheck -CommandLine $equalsPeerCommand +if (-not $equalsPeer.machineReady -or $equalsPeer.failed -ne 0) { + throw "Expected one exact equals-form --robot-host argument to pass." +} + +$quotedPeerCommand = $goodCommand -replace "--robot-host 192\.0\.2\.10", '--robot-host "192.0.2.10"' +$quotedPeer = Invoke-RuntimeCheck -CommandLine $quotedPeerCommand +if (-not $quotedPeer.machineReady -or $quotedPeer.failed -ne 0) { + throw "Expected one exact quoted --robot-host argument to pass." +} + +$duplicateHostCommand = $goodCommand -replace "--host 0\.0\.0\.0", "--host 0.0.0.0 --host=127.0.0.1" +$duplicateHostResult = Invoke-RuntimeCheckSubprocess -CommandLine $duplicateHostCommand +if ($duplicateHostResult.exitCode -eq 0) { + throw "Expected duplicate --host arguments to fail certification." +} + +$loopbackCommand = $goodCommand ` + -replace "--host 0\.0\.0\.0", "--host 127.0.0.1" ` + -replace " --robot-host 192\.0\.2\.10", "" +$loopback = Invoke-RuntimeCheck -CommandLine $loopbackCommand ` + -ExpectedHostName "127.0.0.1" -DeviceHost "" +if (-not $loopback.machineReady -or $loopback.failed -ne 0) { + throw "Expected a loopback-only command without --robot-host to pass." +} +$loopbackPeerCheck = @($loopback.checks | Where-Object { $_.id -eq "robot-peer" })[0] +if ($null -eq $loopbackPeerCheck -or $loopbackPeerCheck.status -ne "pass") { + throw "Expected loopback robot-peer check to be optional and pass." +} + +$loopbackOverrideCommand = $loopbackCommand + " --host=0.0.0.0" +$loopbackOverrideResult = Invoke-RuntimeCheckSubprocess -CommandLine $loopbackOverrideCommand ` + -ExpectedHostName "127.0.0.1" -DeviceHost "" +if ($loopbackOverrideResult.exitCode -eq 0) { + throw "Expected a later non-loopback host override to fail loopback certification." +} + +$quotedFlagDecoyCommand = $goodCommand ` + -replace " --host 0\.0\.0\.0", "" ` + -replace " --robot-host 192\.0\.2\.10", "" +$quotedFlagDecoyCommand = $quotedFlagDecoyCommand.Replace( + 'python bridge\ollama_stackchan_runner.py', + 'python bridge\ollama_stackchan_runner.py --host 0.0.0.0 --robot-host 192.0.2.10' +) +$quotedFlagDecoyResult = Invoke-RuntimeCheckSubprocess -CommandLine $quotedFlagDecoyCommand +if ($quotedFlagDecoyResult.exitCode -eq 0) { + throw "Expected host and robot-peer decoys inside a quoted runner command to fail certification." +} +$quotedDecoyHostCheck = @($quotedFlagDecoyResult.json.checks | Where-Object { $_.id -eq "host" })[0] +$quotedDecoyPeerCheck = @($quotedFlagDecoyResult.json.checks | Where-Object { $_.id -eq "robot-peer" })[0] +if ($quotedDecoyHostCheck.status -ne "fail" -or $quotedDecoyPeerCheck.status -ne "fail") { + throw "Quoted runner-command decoys must not be parsed as top-level security arguments." +} + $badCommand = $goodCommand -replace "--stt-command `"python bridge\\whisper_cpp_stt.py`" ", "" $badResult = Invoke-RuntimeCheckSubprocess -CommandLine $badCommand if ($badResult.exitCode -eq 0) { diff --git a/tools/test_stackchan_dashboard_launcher_contract.ps1 b/tools/test_stackchan_dashboard_launcher_contract.ps1 index 8b06e62a..99bbbe2e 100644 --- a/tools/test_stackchan_dashboard_launcher_contract.ps1 +++ b/tools/test_stackchan_dashboard_launcher_contract.ps1 @@ -42,17 +42,27 @@ foreach ($required in @( $baseText = Get-Content -LiteralPath $baseLauncher -Raw foreach ($required in @( + '[string]$HostName = "127.0.0.1"', "[switch]`$EnableDashboard", "Preserving non-Stackchan listener", "DashboardHost must be loopback-only.", + "RobotHost is required when HostName is not loopback.", '"--dashboard"', '"--robot-host", $RobotHost' )) { if (-not $baseText.Contains($required)) { throw "Base bridge launcher missing dashboard token: $required" } } +if ($baseText -notmatch '(?s)if \(\$EnableDashboard\).*?\r?\n\}\r?\n\r?\nif \(-not \[string\]::IsNullOrWhiteSpace\(\$RobotHost\)\)') { + throw "RobotHost must be forwarded independently of dashboard enablement." +} +$peerGuardIndex = $baseText.IndexOf("RobotHost is required when HostName is not loopback.") +$stopExistingIndex = $baseText.IndexOf('if ($StopExisting)') +if ($peerGuardIndex -lt 0 -or $stopExistingIndex -lt 0 -or $peerGuardIndex -gt $stopExistingIndex) { + throw "Non-loopback peer configuration must fail before an existing listener can be stopped." +} $directmlText = Get-Content -LiteralPath $directmlLauncher -Raw -foreach ($required in @("-EnableDashboard", "-DashboardPort `$DashboardPort", "dashboardUrl =")) { +foreach ($required in @("-HostName '0.0.0.0'", "-EnableDashboard", "-DashboardPort `$DashboardPort", "dashboardUrl =")) { if (-not $directmlText.Contains($required)) { throw "DirectML launcher missing dashboard token: $required" } } diff --git a/tools/test_start_pc_brain_directml_contract.ps1 b/tools/test_start_pc_brain_directml_contract.ps1 index a8a0e143..0b1eb9de 100644 --- a/tools/test_start_pc_brain_directml_contract.ps1 +++ b/tools/test_start_pc_brain_directml_contract.ps1 @@ -6,6 +6,69 @@ $text = Get-Content -LiteralPath $launcherPath -Raw $baseLauncherPath = Join-Path $PSScriptRoot "start_pc_brain.ps1" $baseText = Get-Content -LiteralPath $baseLauncherPath -Raw +$robotWrappers = @( + @{ + Name = "DirectML production launcher" + Path = $launcherPath + HostToken = '-HostName ''0.0.0.0''' + PeerToken = '-RobotHost ''$escapedDeviceHost''' + }, + @{ + Name = "production voice restore" + Path = Join-Path $PSScriptRoot "restore_voice_v2_production.ps1" + HostToken = '-HostName "0.0.0.0"' + PeerToken = '-RobotHost $DeviceHost' + }, + @{ + Name = "selected voice one-shot" + Path = Join-Path $PSScriptRoot "run_selected_voice_once.ps1" + HostToken = '-HostName "0.0.0.0"' + PeerToken = '-RobotHost $DeviceHost' + }, + @{ + Name = "supervised Voice V2 validation" + Path = Join-Path $PSScriptRoot "start_voice_v2_supervised_validation.ps1" + HostToken = '-HostName "0.0.0.0"' + PeerToken = '-RobotHost $DeviceHost' + }, + @{ + Name = "warm ROCm soak" + Path = Join-Path $PSScriptRoot "start_warm_rocm_full_system_soak.ps1" + HostToken = '-HostName "0.0.0.0"' + PeerToken = '-RobotHost $DeviceHost' + } +) + +foreach ($wrapper in $robotWrappers) { + $wrapperText = Get-Content -LiteralPath $wrapper.Path -Raw + $wrapperTokens = $null + $wrapperParseErrors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $wrapper.Path, + [ref]$wrapperTokens, + [ref]$wrapperParseErrors + ) | Out-Null + if ($wrapperParseErrors.Count -ne 0) { + throw "$($wrapper.Name) has PowerShell parse errors: $($wrapperParseErrors -join '; ')" + } + $guardIndex = $wrapperText.IndexOf('IsNullOrWhiteSpace($DeviceHost)') + $repoResolveIndex = $wrapperText.IndexOf('$RepoRoot = Resolve-Path') + if ($guardIndex -lt 0 -or $repoResolveIndex -lt 0 -or $guardIndex -gt $repoResolveIndex) { + throw "$($wrapper.Name) must reject blank DeviceHost before its first operational step." + } + if (-not $wrapperText.Contains($wrapper.HostToken) -or + -not $wrapperText.Contains($wrapper.PeerToken)) { + throw "$($wrapper.Name) must explicitly bind on all IPv4 interfaces and restrict the robot peer." + } + if ($wrapper.Name -eq "selected voice one-shot") { + $textParameterIndex = $wrapperText.IndexOf('[string]$Text =') + $deviceHostParameterIndex = $wrapperText.IndexOf('[string]$DeviceHost =') + if ($textParameterIndex -lt 0 -or $deviceHostParameterIndex -lt $textParameterIndex) { + throw "Selected voice one-shot must preserve Text as positional parameter zero." + } + } +} + $tokens = $null $parseErrors = $null [System.Management.Automation.Language.Parser]::ParseFile( @@ -80,6 +143,7 @@ foreach ($required in @( "[string]`$SearxngUrl", "-EnableResearch -SearxngUrl", "-EnableDashboard", + "-HostName '0.0.0.0'", "-DashboardPort `$DashboardPort", "dashboardUrl =", "stackchan.pc-brain-motion-default-off.v1", @@ -163,6 +227,8 @@ if ($startupGuardIndex -lt 0 -or $bridgeStartIndex -lt $startupGuardIndex -or } foreach ($required in @( + '[string]$HostName = "127.0.0.1"', + "RobotHost is required when HostName is not loopback.", "[switch]`$EnableResearch", "[switch]`$EnableEpisodeDistillation", "[string]`$SearxngUrl", @@ -182,6 +248,7 @@ foreach ($required in @( '"--in-process-directml-tts"', '"--room-vision-command", $RoomVisionCommand' '"--camera-pairing-code-file", $CameraPairingCodeFile' + '"--robot-host", $RobotHost' "stackchan.pc-brain-runtime.v1", "runtime_manifest.json", "sourceWorktreeClean", From d75c62f37f8ff6e1c6cf49bc2c4c01479cd4f02f Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Sun, 2 Aug 2026 19:45:38 -0400 Subject: [PATCH 03/46] docs: preregister emergency stop only policy --- INITIAL_RISK_REGISTER.md | 5 +- PROJECT_STATE.md | 251 ++++++++++++++++++++++++++++++--------- TASK_LEDGER.md | 95 +++++++++++---- 3 files changed, 270 insertions(+), 81 deletions(-) diff --git a/INITIAL_RISK_REGISTER.md b/INITIAL_RISK_REGISTER.md index d552e9a4..d92ba5a2 100644 --- a/INITIAL_RISK_REGISTER.md +++ b/INITIAL_RISK_REGISTER.md @@ -11,8 +11,9 @@ that a private or physical incident occurred. | ID | Priority | Risk and current evidence | User consequence | Mitigation / acceptance | Status | | --- | --- | --- | --- | --- | --- | -| R-000A | P0 | **PC bridge LAN admission is fail-open in the pre-`SEC-001` committed and last-observed deployed baseline — source-observed and synthetically confirmed.** Production launcher binds all interfaces; existing path/protocol/device/peer/origin signals are unenforced, dispatch is not conditioned on a validated upgrade, and blank endpoint identity bypasses an active owner. | Untrusted LAN peer can access private behavior, inject turns, or deny the single-client brain service. | Existing-signal admission hardening at HTTP upgrade, configured peer for non-loopback, no browser origin, explicit unadmitted-session rejection, compatibility/wrong-peer/reconnect tests. Do not label as cryptographic auth. | Live exposure contained; isolated `SEC-001` candidate exact-tree verified and independently accepted, not deployed | -| R-000B | P0 | **Firmware HTTP mutation is unauthenticated — source-observed, not exercised.** Wi-Fi profiles route motion-resume/recovery/reboot-class requests without the camera pairing gate. | LAN peer can request physical/recovery changes without owner authority. | Preserve emergency stop/read-only status; compile-disable other mutation until authenticated control is designed; full native/build/physical gates. | Stop-ship queued; `SEC-002` | +| R-000A | P0 | **PC bridge LAN admission was fail-open in the pre-`SEC-001` committed and last-observed deployed baseline — source-observed and synthetically confirmed.** Production launcher bound all interfaces; existing path/protocol/device/peer/origin signals were unenforced, dispatch was not conditioned on a validated upgrade, and blank endpoint identity bypassed an active owner. | Untrusted LAN peer could access private behavior, inject turns, or deny the single-client brain service. | Existing-signal admission hardening at HTTP upgrade, configured peer for non-loopback, no browser origin, explicit unadmitted-session rejection, compatibility/wrong-peer/reconnect tests. Do not label as cryptographic auth. | Live exposure contained; commit `9c72f020`, exact tree `28a62773`, independently accepted, not deployed | +| R-000B | P0 | **Firmware HTTP mutation is unauthenticated — source-observed, not exercised.** Wi-Fi profiles route diagnostic tone, wake-reset, motion-enable, recovery, and reboot-class requests before response without the camera pairing gate. | LAN peer can request physical or recovery changes without owner authority. | Exhaustive policy permits only bounded status, emergency stops, and existing paired camera operations; all other mutation fails closed before effects in all 19 effective Wi-Fi profiles. Expected-red, native, config, dashboard/tool, release-build, exact-image no-motion, and later physical gates. | Preregistered; expected-red pending; stop-ship `SEC-002` | +| R-000C | P0 | **Unauthenticated wake PCM export — source-observed, not exercised.** `/wake.wav` and `/wake-pcm.wav` return recent 16 kHz microphone-ring PCM without pairing. | LAN peer could retrieve recent ambient speech/audio. | Deny both aliases before reading the PCM ring, constructing a WAV response, or exporting bytes in every Wi-Fi/release profile; do not change on-device capture/wake gating/model; silent privacy gate; no raw-audio fixture, request, log, or inspection; future export requires separate authentication, consent, retention, and private-artifact transport. | Preregistered; expected-red pending; stop-ship `PRIV-001` | | R-001 | P0 | **Unauthorized memory mutation — reproduced.** Valid model-authored writes and wildcard forgets are applied without matching explicit current user authorization. | False personal memory or durable erasure during ordinary conversation. | Host sole-authorizer matching; adversarial ordinary/replay/wildcard/scope/tool tests produce zero deltas while explicit commands pass. | Open; `SAFE-001`, queued after stop-ship transport/control work | | R-002 | P0 | **False motion-safety label — reproduced.** Dashboard `motionVerified` checks only `motion_enabled`; UI can say safely stopped while rail/torque are true. | Operator may trust an unsafe or unknown passive actuator state. | Tri-state verification over motion, rail, torque, suppression, freshness; contradictory/missing UI/API tests. Command path remains frozen. | Open; product audit | | R-003 | P0 | **False current presence — reproduced.** FaceLost retains size while refreshing event time; heartbeat ignores `targetValid`. | False sensing claim, initiative, or social/private context use after presence is gone. | Baseline detect-to-repeated-lost reproduction, then zero false-current freshness; current target-valid plus bounded age; source/freshness consumer tests. | Open; `PERCEPT-001` | diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 2bad4316..8b0d7076 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,23 +4,24 @@ State timestamp: 2026-08-02 America/New_York ## Current Objective -Complete Milestone 0 repository/document truth and contain the newly confirmed LAN trust-boundary -failure before any aliveness feature work. All ten read-only audits are complete. The atomic commit -containing this record is the independently expanded, test-first, exact-tree-verified `SEC-001` host -security repair. It has not been deployed: no production service, firmware, or robot behavior has -been changed. Two user-authorized alternate-port live checks started and stopped the candidate -exactly as recorded below. +Keep stop-ship security work ahead of aliveness features. Milestone 0 and the independently +verified `SEC-001` host admission repair are committed. `SEC-001` remains contained and undeployed. +The active work is a preregistered, credential-free `SEC-002` firmware HTTP policy: public HTTP may +serve bounded operational status and emergency stops, while every other mutating control fails +closed before side effects. The directly coupled dashboard and actuator-validation tools must +represent that restriction truthfully. No firmware, production service, or robot behavior has been +changed for `SEC-002`. ## Source Identity - Repository: `RobVanProd/stackchan_alive` - Working branch: `codex/aliveness-repository-truth` -- Pre-commit parent HEAD: `0e3467e79766ed1cafeef4837c162c8a50bb29e1` +- Current committed HEAD before `SEC-002` preregistration: + `9c72f02091dc471f27e3c9bfff5e4af6e32e7134` - Fetched `origin/main`: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` - Milestone 0 documentation baseline commit: `0e3467e79766ed1cafeef4837c162c8a50bb29e1` -- SEC-001 identity: the candidate is the exact tree/commit containing this record. Before commit - it is staged against parent `0e3467e7`; after commit its authoritative SHA is the containing Git - commit assigned by Git and reported in the handoff. +- SEC-001 identity: commit `9c72f02091dc471f27e3c9bfff5e4af6e32e7134`, exact accepted tree + `28a62773ee67103cd3f57f6cb93c0db6afbe143a`. - Verification scope: native firmware logic, host bridge tests, silent trusted-facts routing, secret-free release compilation through the documented isolated pioarduino core, and three release/evidence contract suites. @@ -32,26 +33,155 @@ switched because live services use that checkout. Milestone 0 work uses the isol ## Active Hypothesis -Selected candidate: `SEC-001`, fail-closed PC bridge admission using signals already emitted by -firmware and configuration already supplied to the production launcher. - -- **Observed behavior:** A non-loopback bridge bind does not enforce exact path, firmware protocol/ - device headers, configured robot peer, or browser-origin rejection; connection dispatch is not - conditioned on a validated HTTP upgrade, and blank endpoint identity bypasses an active owner. -- **Primary hypothesis:** Enforcing those existing admission signals at the HTTP upgrade and - rejecting protected messages on an explicitly unadmitted session will reduce accepted untrusted - protected operations to zero while the valid firmware-shaped connect/server-hello/reconnect path - remains compatible. -- **Falsification:** A current firmware client lacks a required stable signal; any wrong-peer/ - origin/path/header/pre-admission case performs a protected operation; the valid case fails; or - the change requires a client-side hello, pairing secret, or new identity protocol. -- **Frozen baseline:** Exact source `39b750e6`, contained bridge, untouched firmware/robot, live - voice/vision workers, current message schemas, memory/STT/model/TTS/dashboard behavior. -- **Rollback:** Revert the atomic source/test commit and keep the non-loopback bridge stopped. Do - not restore the insecure listener as an automatic fallback. +Selected experiment: `SEC-002`, fixed emergency-stop-only admission on the firmware debug HTTP +server, plus `PRIV-001` denial of its unauthenticated wake-microphone PCM export. + +- **Observed behavior:** Port 8789 ignores the HTTP method, defaults malformed requests to `/`, + dispatches camera paths before any common admission decision, applies tone/wake-reset/motion- + enable/recovery/reboot effects before responding, returns generic status for unknown paths, and + echoes the raw request target. `/wake.wav` and `/wake-pcm.wav` return recent microphone-ring PCM + without the camera pairing gate. These are source observations; unsafe routes and PCM were not + exercised on hardware. +- **Primary hypothesis:** One Arduino-free, exhaustive request-line/route policy invoked before + dispatch can reduce admitted unauthenticated mutating operations to emergency audio/motion stop + only, deny the PCM export, retain bounded status and paired camera behavior, and expose a + non-secret `emergency_stop_only` capability without changing OTA or autonomous recovery logic. +- **Falsification:** Any denied route reaches a side effect; any malformed/query/prefix/suffix case + falls through to status; any query-free `GET` emergency stop used by maintained clients is lost; + paired camera or OTA behavior changes; a build profile bypasses the policy; any sink leaks raw + request/query/pairing/authorization material; or the repair requires a credential, pairing-file + transport, or hardware action before source gates. +- **Frozen baseline:** Commit `9c72f020`, the contained production bridge, installed firmware of + unknown SHA-256, voice/vision/model workers, port-8790 OTA authorization, camera pairing grammar, + automatic offline recovery supervisor, 50 ms face gate, actuator ownership, and physical + evidence. +- **Rollback:** Revert only the atomic `SEC-002` source/client compatibility commit. Do not restore + an insecure listener or flash the prior image as an automatic fallback; isolate or power off the + robot and preserve evidence. The reproducible-build and memory-authorization hypotheses remain queued P0 work; stop-ship -transport/control containment takes precedence without reordering the later aliveness milestones. +transport/control/privacy containment takes precedence without reordering later aliveness work. + +## SEC-002 / PRIV-001 Frozen Preregistration + +This preregistration is written against clean commit `9c72f020` before expected-red tests, +production-source changes, compilation, or device action. It introduces no authentication scheme. +Future authenticated resume/recovery authority, credentials, and pairing-file transport require a +separate preregistration. + +The common policy must strictly parse `METHOD SP request-target SP HTTP-version` with exactly three +tokens, an uppercase bounded method token, an origin-form target beginning `/`, and exact version +`HTTP/1.0` or `HTTP/1.1`. A missing/extra token, unsupported version, control byte, incomplete line, +or non-origin target is malformed. Target truncation/overflow is distinct. Classification occurs +before camera dispatch or any existing effect: + +- `GET /` and `GET /debug`, with no query, serve the existing bounded status classes. +- Exact `GET` or `POST` to `/audio-stop`, `/playback-stop`, `/motion-stop`, `/motion-off`, or + `/servos-off`, with no query, may request an emergency stop. These five aliases and methods define + supported unauthenticated stop availability. The baseline's accidental acceptance of other + methods is not an availability guarantee. An admitted stop returns `202` with bounded + `accepted:true`; a failed motion-stop queue publication returns `503` with `accepted:false`. + Neither response claims physical completion. +- Query-bearing `GET /camera-gray.pgm?...` and `GET /vision-target?...` syntactic families route to + the existing parser/authorizer unchanged. Its successful exact paired forms remain + `/camera-gray.pgm?p=NNNNNN` and `/vision-target?p=NNNNNN&f=...`; malformed queries retain their + current camera-specific `400`/`403` and auth-counter behavior. No capture or target submission is + reachable before the existing pairing check. Wrong methods are rejected by the common policy. +- `/tone`, `/speaker-test`, `/mic-tone`, `/mic-tone-soft`, `/mic-tone-tap`, `/mic-tone-old`, + `/wake-reset`, `/motion-resume`, `/motion-on`, `/servos-on`, `/recover`, `/bridge-recover`, + `/wifi-recover`, `/reboot`, `/restart`, and `/reset` return `403` before side effects for every + method and query. +- `/wake.wav` and `/wake-pcm.wav` also return `403` before reading the PCM ring, constructing a WAV + response, or exporting bytes for every method and query. No test may request, store, print, + fixture, or inspect wake PCM. +- Known allowed paths with a wrong method return `405`; malformed request lines return `400`; + oversize targets return `414`; unknown exact paths return `404`. Outside the two camera syntactic + families above, query, suffix, prefix, trailing-slash, fragment, encoded-alias, and truncated + near-misses never dispatch or fall through to `/`. +- Denial responses are small, fixed-shape JSON. Status telemetry reports only bounded method/route/ + result enums and counters. Responses, status, serial logs, diagnostic fields, counters, evidence, + test output, and every other sink must never emit a raw target, query, pairing code, authorization + header, or credential-derived value. + +Expected-red evidence must be demonstrated and preserved before implementation. First rerun +`pio test -e native_logic` unchanged and require the pre-existing 289/289 baseline. Then add only +the frozen tests/contracts and run these exact gates: + +- `pio test -e native_logic` must fail with the new + `test_bridge_debug_http_policy_*` cases unable to find the preregistered shared policy, not with a + toolchain, dependency, syntax, collection, or pre-existing-test failure; +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File + tools\test_firmware_http_control_policy_contract.ps1` must exit nonzero on its named mandatory- + policy/pre-effect assertion across all 19 effective Wi-Fi environments, not because the command, + file, dependency, or parser is unavailable; +- selected `bridge.test_dashboard_service.DashboardRuntimeTests.test_*motion_resume*policy*` cases + must fail their missing/`emergency_stop_only` fail-closed assertions, not test setup or discovery; +- dashboard, camera-follow, warm-soak, full-system-soak, and wake-watcher contracts must exit + nonzero on their named capability-preflight/refusal assertions, not unrelated syntax or fixture + failures. + +Any other red is rejected, preserved as separate evidence, and does not authorize implementation. + +The capability contract is exact: firmware `/debug` emits +`"debug_http_control_policy":"emergency_stop_only"`. The dashboard projects only bounded +`motionResumeAvailable:false` and `motionResumePolicy:"emergency_stop_only"|"unknown"`. Missing, +unknown, malformed, or older-than-15-seconds capability is `unknown` and fail-closed. Resume refusal +occurs before `_fetch_robot`; Stop never depends on that capability and keeps its existing off-state +verification. + +Implementation is limited to: + +- `src/io/BridgeDebugHttpPolicy.hpp`, `src/io/BridgeDebugHttpPolicy.cpp`, `src/main.cpp`, + `platformio.ini`, and `test/test_native_logic/test_main.cpp`; +- `bridge/dashboard_service.py`, `bridge/test_dashboard_service.py`, and `bridge/dashboard/app.js`; +- `tools/test_firmware_http_control_policy_contract.ps1`, + `tools/test_stackchan_dashboard_launcher_contract.ps1`, + `tools/camera_follow_wake_validation.ps1`, + `tools/test_camera_follow_wake_validation_contract.ps1`, + `tools/run_full_system_soak_http_motion.ps1`, + `tools/start_warm_rocm_full_system_soak.ps1`, + `tools/test_start_warm_rocm_full_system_soak_contract.ps1`, + `tools/watch_stackchan_wake_test.ps1`, and `tools/verify_release_package.ps1`; +- `PROJECT_STATE.md`, `TASK_LEDGER.md`, `INITIAL_RISK_REGISTER.md`, `docs/BRIDGE_PROTOCOL.md`, + `docs/BRIDGE_DASHBOARD.md`, and `docs/ARRIVAL_DAY_RUNBOOK.md`. + +No other file may change without a preserved expected-red result showing direct coupling and an +independent scope review. In particular, the policy must not disable or condition the autonomous +offline recovery supervisor, change port-8790 OTA, reuse camera pairing as general control auth, +weaken a stop, change actuator coordination, or touch a private value. + +`bridge/dashboard/index.html` remains explicitly unchanged: its Resume button already starts +disabled. Tests must prove the service and both JavaScript re-enable paths require an explicit +available capability; changing the markup requires the documented scope-expansion gate. + +Test-to-tool ownership is frozen. `tools/test_stackchan_dashboard_launcher_contract.ps1` covers the +dashboard service and both JavaScript enable paths; +`tools/test_camera_follow_wake_validation_contract.ps1` covers its paired script; +`tools/test_start_warm_rocm_full_system_soak_contract.ps1` covers the warm wrapper; and the new +`tools/test_firmware_http_control_policy_contract.ps1` cross-checks those plus +`run_full_system_soak_http_motion.ps1` and `watch_stackchan_wake_test.ps1`. Every changed tool must +read the exact capability from `/debug` first; missing/unknown/contained policy must abort before +Resume, wake-reset, tone, process/service launch, or evidence-runner start; it must not relabel the +refusal as pass, fall back to a denied alias, or read pairing material. Emergency motion-stop cleanup +remains callable. The wake watcher switches its operational read from the accidental `/status` +fallback to exact `/debug` and can run observation-only only with reset/tone disabled. + +Acceptance order is fixed: expected red; focused then full native tests; all 19 Wi-Fi-profile source/ +config contract cases; dashboard and operator-tool contracts; full isolated bridge suite; silent +trusted-facts privacy smoke; the existing no-hardware prearrival simulator as a general regression +proxy that does not prove port-8789; secret-free `stackchan_release_full` compilation and package +verifier. Those gates can accept only the source candidate. Deployment and `R-000B`/`R-000C` risk +closure additionally require an independently approved and executed exact-image no-motion run, +supervised physical emergency-stop proof, and final release gates with exact source/binary identity. +No flash, OTA, reboot, endpoint mutation, or physical motion is authorized by source success. +Supervised resume and motion-soak workflows remain blocked until separately authenticated/local +authority is designed. + +Hard stop the candidate if expected red does not fail on its named security assertion; a maintained +consumer falls back or treats refusal as success; an unlisted file changes without the documented +scope-expansion review; microphone capture, wake gating, or the wake model changes; exact source and +binary identity are unavailable before no-motion qualification; or deployment/risk closure is +claimed before exact-image physical and release gates. ## SEC-001 Frozen Preregistration @@ -204,31 +334,33 @@ a robot freeze, blackout, brownout, thermal event, USB failure, board failure, o ## Known Faults -1. The committed `origin/main` baseline and the last observed production host service expose a - fail-open robot-to-host WebSocket admission boundary on the LAN. The isolated `SEC-001` - candidate repairs those source paths, but it is not deployed on the production listener or - robot; the previously exposed listener remains stopped. -2. Wi-Fi-enabled firmware HTTP mutating controls are not protected by the existing camera pairing - gate; source shows motion-resume/recovery/reboot-class requests can reach their handlers. This - was not exercised on hardware. OTA remains separately token-gated. -3. Dashboard connection/readiness state can remain affirmative after the heartbeat is stale and +1. The last observed production host service exposed a fail-open robot-to-host WebSocket admission + boundary on the LAN. Commit `9c72f020` repairs those source paths, but is not deployed on the + production listener or robot; the previously exposed listener remains stopped. +2. Wi-Fi-enabled firmware debug HTTP mutation is unauthenticated. Source shows tone, mic-tone, + wake-reset, motion-enable, recovery, and reboot aliases reach effects before response. Emergency + audio/motion stop aliases must remain public. Unsafe routes were not exercised on hardware; OTA + remains separately token-gated. +3. `/wake.wav` and `/wake-pcm.wav` can export recent wake-microphone PCM without pairing. This is + source-observed only; no PCM was requested or inspected. +4. Dashboard connection/readiness state can remain affirmative after the heartbeat is stale and the bridge has no established robot socket. -4. The exact AGENTS baseline command through the default shared PlatformIO core fails before +5. The exact AGENTS baseline command through the default shared PlatformIO core fails before source compilation because the pioarduino framework directory is absent. The same environment builds successfully when the documented `C:\spio\pioarduino` core is pinned. -5. Ordinary valid model output can currently authorize an unprompted durable write or wildcard +6. Ordinary valid model output can currently authorize an unprompted durable write or wildcard forget; generic third-party private details can evade the finite sensitive-name filter. -6. A due callback can displace an unrelated user request, and explicit topic recall can choose the +7. A due callback can displace an unrelated user request, and explicit topic recall can choose the newest unrelated episode. -7. Playback failure can strand host Conversation v2 in `SPEAKING`; model/TTS recovery can disagree +8. Playback failure can strand host Conversation v2 in `SPEAKING`; model/TTS recovery can disagree with the firmware wake gate; and the 10-second host lease is shorter than the allowed 12-second firmware utterance. -8. Release firmware initializes synthetic demo affect events enabled; phrase streaming clamps +9. Release firmware initializes synthetic demo affect events enabled; phrase streaming clamps signed negative valence to zero; semantic manipulation paraphrases bypass the current lexical relationship validator. -9. Repeated face-lost updates can retain a historical face size while refreshing its timestamp, +10. Repeated face-lost updates can retain a historical face size while refreshing its timestamp, causing a false-current presence bit; stale room state can still permit personal projection. -10. Initiative power/thermal suppression fields are not present in the production heartbeat, and +11. Initiative power/thermal suppression fields are not present in the production heartbeat, and an in-flight initiative is not revalidated on a later sleep/safety/presence transition. Documentation baseline status: required project-control/longitudinal documents and reconciled @@ -238,8 +370,8 @@ control evidence, not physical qualification. ## Known Regressions -The isolated candidate now changes host admission, launcher defaults, robot-facing -wrappers, and runtime certification. It has not been deployed or started on the production +Committed `SEC-001` changes host admission, launcher defaults, robot-facing wrappers, and runtime +certification. It has not been deployed or started on the production listener or robot; the isolated alternate-port checks were started and stopped as recorded below. The exposed production PC bridge remains intentionally stopped; current deployed firmware therefore still has no verified host admission repair, and its exact installed SHA remains unknown. @@ -261,7 +393,7 @@ exact-image no-motion conversation soak remain unqualified rather than failed. - Current-lead archive contract: passed. - Branch/PR evidence: `BRANCH_LEDGER.md`. -## SEC-001 Candidate Evidence +## SEC-001 Committed Evidence The live checks below and the first matrix were observed on 2026-08-02 in the isolated working tree based at `0e3467e7`. The final self-identifying staged-tree matrix repeated the applicable gates and @@ -278,8 +410,8 @@ qualifies the installed firmware or authorizes a service restart. - Trusted-facts smoke: ready, 19 routed and 10 passthrough cases, zero model invocations, zero audio played, and no stored fact values printed. - Secret-free `stackchan_release_full` compilation: passed in `C:\spio\pioarduino`; the dirty-tree - build is 2,803,216 bytes with SHA-256 - `96D72657097E96522F13972D26116BB370070D33E06930B0DB28A923BD3439E2`. This is compilation + build is 2,803,248 bytes with SHA-256 + `602D1F75C45A754217116D72174E22621593817FD48D219504E9B82565DCC4A8`. This is compilation evidence only and is not a physical or reproducibility claim. - Full-system-soak evidence, current-lead reproducibility v2, and current-lead archive contracts: passed with synthetic fixtures. @@ -296,11 +428,12 @@ qualifies the installed firmware or authorizes a service restart. ## Exact Next Action -Use the atomic commit containing this record as the `SEC-001` source identity, without starting the -production service. Then preregister the smallest `SEC-002` experiment for firmware mutating-control -authorization and run source-only gates before any device action. `SEC-001` is admission hardening, -not cryptographic authentication; production restart and every firmware/hardware action remain -unauthorized until their separate qualification gates are earned. +Use the atomic control-only preregistration commit containing this record as the frozen experiment, +then add and preserve the expected-red `SEC-002`/`PRIV-001` native, config, dashboard, and operator- +tool tests before implementation. Implement only the frozen +emergency-stop-only policy and direct compatibility slice, then run source/native/bridge/privacy/ +release gates. Do not design credentials or read a pairing file. Production restart and every +firmware/hardware action remain unauthorized until their separate qualification gates are earned. ## Unauthorized Actions @@ -310,15 +443,17 @@ unauthorized until their separate qualification gates are earned. voice, vision, model, and the unrelated loopback service remain frozen. The one bridge termination above was explicitly authorized after the stop-ship finding and is now recorded. - No release publication, tag, push, PR mutation, branch deletion, force-push, or evidence deletion. +- No wake-WAV request, raw microphone read/inspection, pairing-code read or file transport, or + fallback from a denied HTTP control. - No remote/Away infrastructure, credential, pairing, privacy-policy, sensitive-memory, identity-recognition, always-listening, cloud-required, or model-physical-authority work. - No human study, paid service, destructive hardware action, or cross-repository modification. ## Rollback Path -SEC-001 is one isolated host source/test/operator-document candidate. Before commit, discard only -that exact staged/working-tree slice if a final gate fails; after commit, rollback is reversion of -the exact containing commit. Keep the exposed non-loopback listener stopped and never restore the -fail-open listener as an automatic fallback. Hardware rollback remains the exact private accepted -archive and runbook procedure; it is not exercised without the required source, build, no-motion, +`SEC-001` rollback is reversion of exact commit `9c72f020`; keep the exposed listener stopped rather +than restoring a fail-open fallback. `SEC-002` preregistration and its later implementation remain +separate atomic commits and are reverted only by exact commit if a frozen invariant regresses. Do +not flash an insecure prior image as an operational rollback. Hardware rollback remains the exact +private accepted archive/runbook procedure and is not exercised without source, build, no-motion, physical, and exact-image gates. diff --git a/TASK_LEDGER.md b/TASK_LEDGER.md index dbbc4129..691f20ae 100644 --- a/TASK_LEDGER.md +++ b/TASK_LEDGER.md @@ -401,27 +401,80 @@ Ledger timestamp: 2026-08-02 America/New_York bounded admission hardening, not cryptographic authentication. Deployment remains unauthorized; firmware HTTP control authorization remains the separate P0 `SEC-002` task. -## SEC-002 — Disable Unauthenticated Firmware Mutating Controls +## SEC-002 — Enforce Emergency-Stop-Only Firmware HTTP Control -- **Problem:** Wi-Fi firmware applies motion-resume, tone/recovery, and reboot-class HTTP controls - without the existing camera pairing gate; remote recovery defaults on with Wi-Fi. -- **User-facing consequence:** A LAN peer can request physical or recovery state changes without - authenticated owner authority. -- **Evidence:** AUDIT-09 source/configuration trace only; no hardware endpoint was exercised. +- **Problem:** Wi-Fi firmware applies tone, wake-reset, motion-enable, recovery, and reboot-class + HTTP controls before response without authenticated owner authority; malformed/unknown requests + can fall through to status and the raw request target is echoed. +- **User-facing consequence:** A LAN peer can request physical/recovery changes, while dashboards + and motion-validation tools can offer an authority the contained firmware no longer has. +- **Evidence:** Source/configuration trace only; no unsafe hardware endpoint was exercised. - **Priority:** P0 physical/control security. -- **Dependencies:** Explicit authenticated-control design or fail-closed release decision; - `SEC-001`; one hardware-affecting branch at a time. -- **Owner:** Future firmware security owner with independent hardware-authority, protocol, release, +- **Dependencies:** `SEC-001` commit `9c72f020`; fail-closed release decision selected; one + hardware-affecting branch at a time. +- **Owner:** `/root` implementation owner; independent policy, scope, operator-authority, release, and physical-evidence reviewers. -- **Allowed files:** To be preregistered; mutating HTTP classifier/handler, configuration contract, - focused native tests, protocol/security docs. -- **Frozen systems:** Emergency motion/audio stop, read-only debug/status, OTA token/digest path, - camera pairing, bridge protocol, face gate, all current hardware state/evidence. -- **Acceptance tests:** Emergency stop remains available; resume/recovery/reboot/diagnostic-output - mutation fail closed when unauthenticated in every Wi-Fi/release profile; exact config/native/ - embedded/simulator/no-motion/physical/release gates in order. -- **Stop conditions:** Emergency stop becomes less available, auth contract is invented from an - unknown pairing state, or firmware is flashed before source/build/no-motion approval. -- **Result:** Stop-ship queued; not implemented or exercised. -- **Commit:** None. -- **Decision:** Keep robot on a trusted isolated LAN or powered off until a qualified fix is installed. +- **Decision:** Fixed emergency-stop-only containment. No authentication, credential, pairing-code + reuse, or pairing-file transport is introduced. +- **Expected-red gate:** Before implementation, focused native tests must fail because no shared + request policy exists; the source/config contract must show that all 19 effective Wi-Fi profiles + inherit unsafe pre-response effects; dashboard tests must show Resume does not fail closed for + missing/contained policy; operator-tool contracts must show legacy workflows do not all stop + truthfully. Preserve exact commands and logs. +- **Allowed routes:** Query-free `GET /`, `GET /debug`; query-free `GET`/`POST` emergency audio-stop + and motion-stop aliases. Those methods define supported stop availability; rejection of the + baseline's accidental other-method behavior is intentional. Query-bearing `GET` camera families + reach the existing parser/authorizer unchanged, and successful camera effects still require its + exact grammar and pairing check. +- **Denied routes:** Both speaker-tone aliases, four mic-tone aliases, wake-reset, three motion- + enable aliases, three recovery aliases, and three reboot aliases, for every method/query, before + side effects. Malformed/query/prefix/suffix/encoded/truncated near-misses never dispatch. +- **Allowed files:** Exact `SEC-002 / PRIV-001 Frozen Preregistration` list in `PROJECT_STATE.md`. + Any expansion requires preserved expected-red evidence of direct coupling and independent review. +- **Frozen systems:** Automatic offline recovery/reboot supervisor, emergency stops, bounded status, + OTA token/digest path, camera pairing/grammar, bridge framing, 50 ms face gate, actuator authority, + installed firmware, and all physical evidence. +- **Acceptance tests:** Expected red recorded for named assertions only; exhaustive method/route/ + query policy green; admitted stops return bounded `202 accepted:true`, motion publication failure + returns `503 accepted:false`, and neither claims completion; no denied + callback/effect; all 19 effective Wi-Fi configurations lack a bypass; dashboard missing/unknown/ + contained policy disables and refuses Resume while Stop remains available; coupled tools preflight + and stop truthfully; full native/bridge/silent-privacy gates, secret-free release build, and package + provenance and prearrival-simulator regression pass. Simulator results do not prove port 8789. + Source acceptance does not close either risk; exact-image no-motion, supervised emergency-stop, + exact identity, and final release gates remain separate. +- **Stop conditions:** A maintained query-free GET stop becomes less reachable; expected red misses + its named assertion; any bypass/fallback appears or refusal is treated as success; camera pairing + is repurposed; a private value is read; automatic recovery is disabled; any sink leaks a raw + target/query/pairing/authorization value; microphone capture/wake gate/model changes; an unlisted + file changes without approved expansion; exact source/binary identity is unavailable before no- + motion qualification; hardware is touched early; or deployment/risk closure is claimed before + physical/release gates. +- **Rollback:** Revert the exact atomic source/client candidate if target tests do not turn green or + a frozen invariant regresses. Do not flash a prior insecure image as automatic rollback; isolate + or power off the robot and preserve evidence. +- **Result:** Preregistered; expected-red pending; no implementation or hardware exercise. +- **Commit:** Atomic control-only preregistration commit containing this record; exact SHA assigned + by Git and reported in the handoff. +- **Decision:** Stop-ship. Existing supervised Resume/motion-soak tooling has no approved authority + after containment; keep the robot on a trusted isolated LAN or powered off until qualification. + +## PRIV-001 — Disable Unauthenticated Wake PCM Export + +- **Problem:** `/wake.wav` and `/wake-pcm.wav` return recent 16 kHz wake-microphone ring PCM without + the camera pairing gate. +- **Evidence:** Source-observed only. No raw audio was fetched, archived, logged, or inspected. +- **Priority:** P0 privacy/trust. +- **Decision:** The shared emergency-stop-only policy returns `403` for both aliases before reading + the PCM ring, constructing a WAV response, or exporting bytes in every Wi-Fi/release profile. It + does not label PCM as read-only health or alter on-device wake capture. +- **Frozen systems:** Wake-gated audio processing, wake model, microphone capture needed on-device, + camera pairing, memory privacy, and all raw/private artifacts. +- **Acceptance tests:** Pure policy and source/config tests only; no request, raw-audio fixture, + content assertion, log, or archive. Silent trusted-facts and release gates remain green. +- **Stop conditions:** Any test requests or inspects PCM, microphone capture/wake gating/wake model + changes, general control auth is invented, consent/retention is assumed, or private audio enters a + repository/evidence path. +- **Future authority:** Any diagnostic export requires separately approved authentication, explicit + consent, bounded retention, and private-artifact transport. +- **Result:** Preregistered with `SEC-002`; expected-red pending; not exercised. From 2ed5bb6ad4755129b61aa0f636f0b654a3493d86 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Sun, 2 Aug 2026 22:11:46 -0400 Subject: [PATCH 04/46] fix: include conversation harness in release package --- tools/package_release.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index f5043757..be4cd7e8 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -506,6 +506,8 @@ $bridgePackageFiles = @( "memory_probe.py", "test_memory_probe.py", "memory_prefill_probe.py", + "conversation_harness.py", + "test_conversation_harness.py", "character_harness.py", "test_character_harness.py", "character_red_team.py", From 4d31de414f5f2279b4c423ac3dfd7e940bb540d9 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Sun, 2 Aug 2026 22:32:00 -0400 Subject: [PATCH 05/46] fix: contain firmware debug HTTP controls --- INITIAL_RISK_REGISTER.md | 8 +- PROJECT_STATE.md | 79 +- TASK_LEDGER.md | 19 +- bridge/dashboard/app.js | 15 +- bridge/dashboard_service.py | 88 +- bridge/test_dashboard_service.py | 197 ++- docs/ARRIVAL_DAY_RUNBOOK.md | 9 + docs/BRIDGE_DASHBOARD.md | 15 +- docs/BRIDGE_PROTOCOL.md | 16 + platformio.ini | 1 + src/io/BridgeDebugHttpPolicy.cpp | 291 +++++ src/io/BridgeDebugHttpPolicy.hpp | 59 + src/main.cpp | 362 +++--- test/test_native_logic/test_main.cpp | 307 +++++ tools/camera_follow_wake_validation.ps1 | 58 +- tools/run_full_system_soak_http_motion.ps1 | 68 +- tools/start_warm_rocm_full_system_soak.ps1 | 49 +- ...camera_follow_wake_validation_contract.ps1 | 15 +- ..._firmware_http_control_policy_contract.ps1 | 1089 +++++++++++++++++ ..._stackchan_dashboard_launcher_contract.ps1 | 63 + ...rt_warm_rocm_full_system_soak_contract.ps1 | 14 + tools/verify_release_package.ps1 | 29 +- tools/watch_stackchan_wake_test.ps1 | 36 +- 23 files changed, 2586 insertions(+), 301 deletions(-) create mode 100644 src/io/BridgeDebugHttpPolicy.cpp create mode 100644 src/io/BridgeDebugHttpPolicy.hpp create mode 100644 tools/test_firmware_http_control_policy_contract.ps1 diff --git a/INITIAL_RISK_REGISTER.md b/INITIAL_RISK_REGISTER.md index d92ba5a2..81ee3852 100644 --- a/INITIAL_RISK_REGISTER.md +++ b/INITIAL_RISK_REGISTER.md @@ -3,7 +3,9 @@ Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` Date: 2026-08-02 Status: all ten read-only audits reconciled; Milestone 0 documentation committed; `SEC-001` -implemented, exact-tree verified, independently accepted, and not deployed +implemented, exact-tree verified, independently accepted, and not deployed; `SEC-002`/`PRIV-001` +implemented in a source candidate, independently source-accepted, source/simulator/release-build/ +package-regression gated, not deployed, and not physically qualified Priority is non-compensatory: P0 trust/privacy/safety violations are addressed before experience features. “Reproduced” means source/synthetic evidence demonstrates the defect; it is not a claim @@ -12,8 +14,8 @@ that a private or physical incident occurred. | ID | Priority | Risk and current evidence | User consequence | Mitigation / acceptance | Status | | --- | --- | --- | --- | --- | --- | | R-000A | P0 | **PC bridge LAN admission was fail-open in the pre-`SEC-001` committed and last-observed deployed baseline — source-observed and synthetically confirmed.** Production launcher bound all interfaces; existing path/protocol/device/peer/origin signals were unenforced, dispatch was not conditioned on a validated upgrade, and blank endpoint identity bypassed an active owner. | Untrusted LAN peer could access private behavior, inject turns, or deny the single-client brain service. | Existing-signal admission hardening at HTTP upgrade, configured peer for non-loopback, no browser origin, explicit unadmitted-session rejection, compatibility/wrong-peer/reconnect tests. Do not label as cryptographic auth. | Live exposure contained; commit `9c72f020`, exact tree `28a62773`, independently accepted, not deployed | -| R-000B | P0 | **Firmware HTTP mutation is unauthenticated — source-observed, not exercised.** Wi-Fi profiles route diagnostic tone, wake-reset, motion-enable, recovery, and reboot-class requests before response without the camera pairing gate. | LAN peer can request physical or recovery changes without owner authority. | Exhaustive policy permits only bounded status, emergency stops, and existing paired camera operations; all other mutation fails closed before effects in all 19 effective Wi-Fi profiles. Expected-red, native, config, dashboard/tool, release-build, exact-image no-motion, and later physical gates. | Preregistered; expected-red pending; stop-ship `SEC-002` | -| R-000C | P0 | **Unauthenticated wake PCM export — source-observed, not exercised.** `/wake.wav` and `/wake-pcm.wav` return recent 16 kHz microphone-ring PCM without pairing. | LAN peer could retrieve recent ambient speech/audio. | Deny both aliases before reading the PCM ring, constructing a WAV response, or exporting bytes in every Wi-Fi/release profile; do not change on-device capture/wake gating/model; silent privacy gate; no raw-audio fixture, request, log, or inspection; future export requires separate authentication, consent, retention, and private-artifact transport. | Preregistered; expected-red pending; stop-ship `PRIV-001` | +| R-000B | P0 | **Firmware HTTP mutation is unauthenticated in the last source-observed baseline and installed-image identity is unknown; the source candidate contains it before side effects.** Wi-Fi profiles previously routed diagnostic tone, wake-reset, motion-enable, recovery, and reboot-class requests without owner authority. | LAN peer can request physical or recovery changes unless the contained source is built, qualified, and deployed exactly. | Exhaustive policy permits only bounded status, emergency stops, and existing paired camera operations; all other mutation fails closed before effects in all 19 effective Wi-Fi profiles. Expected-red, native, config, dashboard/tool, simulator, release-build, and package-regression gates passed; exact post-commit binary/package identity, exact-image no-motion, and later physical gates remain. | Source-contained candidate independently accepted; undeployed, physically unqualified; risk open `SEC-002` | +| R-000C | P0 | **Unauthenticated wake PCM export exists in the last source-observed baseline and installed-image identity is unknown; the source candidate denies it.** `/wake.wav` and `/wake-pcm.wav` previously returned recent 16 kHz microphone-ring PCM without pairing. | LAN peer could retrieve recent ambient speech/audio unless the contained source is built, qualified, and deployed exactly. | Deny both aliases before reading the PCM ring, constructing a WAV response, or exporting bytes in every Wi-Fi/release profile; do not change on-device capture/wake gating/model; silent privacy gate; no raw-audio fixture, request, log, or inspection; future export requires separate authentication, consent, retention, and private-artifact transport. | Source-contained candidate independently accepted; silent privacy/source gates passed without PCM access; undeployed, physically unqualified; risk open `PRIV-001` | | R-001 | P0 | **Unauthorized memory mutation — reproduced.** Valid model-authored writes and wildcard forgets are applied without matching explicit current user authorization. | False personal memory or durable erasure during ordinary conversation. | Host sole-authorizer matching; adversarial ordinary/replay/wildcard/scope/tool tests produce zero deltas while explicit commands pass. | Open; `SAFE-001`, queued after stop-ship transport/control work | | R-002 | P0 | **False motion-safety label — reproduced.** Dashboard `motionVerified` checks only `motion_enabled`; UI can say safely stopped while rail/torque are true. | Operator may trust an unsafe or unknown passive actuator state. | Tri-state verification over motion, rail, torque, suppression, freshness; contradictory/missing UI/API tests. Command path remains frozen. | Open; product audit | | R-003 | P0 | **False current presence — reproduced.** FaceLost retains size while refreshing event time; heartbeat ignores `targetValid`. | False sensing claim, initiative, or social/private context use after presence is gone. | Baseline detect-to-repeated-lost reproduction, then zero false-current freshness; current target-valid plus bounded age; source/freshness consumer tests. | Open; `PERCEPT-001` | diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 8b0d7076..66f70688 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -6,18 +6,23 @@ State timestamp: 2026-08-02 America/New_York Keep stop-ship security work ahead of aliveness features. Milestone 0 and the independently verified `SEC-001` host admission repair are committed. `SEC-001` remains contained and undeployed. -The active work is a preregistered, credential-free `SEC-002` firmware HTTP policy: public HTTP may -serve bounded operational status and emergency stops, while every other mutating control fails -closed before side effects. The directly coupled dashboard and actuator-validation tools must -represent that restriction truthfully. No firmware, production service, or robot behavior has been -changed for `SEC-002`. +The active working-tree candidate implements the preregistered, credential-free `SEC-002` firmware +HTTP policy: public HTTP may serve bounded operational status and emergency stops, while every +other mutating control fails closed before side effects. The directly coupled dashboard and +actuator-validation tools represent that restriction fail-closed. This is source, test, simulator, +secret-free compilation, and dirty-tree package-regression evidence only. Independent policy, +security, and documentation reviewers accepted the source slice. It is undeployed and physically +unqualified. No production service, installed firmware, or live robot behavior has been changed for +`SEC-002`. ## Source Identity - Repository: `RobVanProd/stackchan_alive` - Working branch: `codex/aliveness-repository-truth` -- Current committed HEAD before `SEC-002` preregistration: - `9c72f02091dc471f27e3c9bfff5e4af6e32e7134` +- Current committed HEAD before the atomic `SEC-002` implementation commit: + `2ed5bb6ad4755129b61aa0f636f0b654a3493d86` +- Frozen `SEC-002` preregistration baseline: + `d75c62f37f8ff6e1c6cf49bc2c4c01479cd4f02f` - Fetched `origin/main`: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` - Milestone 0 documentation baseline commit: `0e3467e79766ed1cafeef4837c162c8a50bb29e1` - SEC-001 identity: commit `9c72f02091dc471f27e3c9bfff5e4af6e32e7134`, exact accepted tree @@ -183,6 +188,37 @@ scope-expansion review; microphone capture, wake gating, or the wake model chang binary identity are unavailable before no-motion qualification; or deployment/risk closure is claimed before exact-image physical and release gates. +## SEC-002 / PRIV-001 Source Candidate Result + +The preregistered expected-red phase was preserved before implementation: native policy tests +failed only because the shared policy did not yet exist; dashboard cases failed their named +missing/contained-capability assertions; and the firmware/tool contract cases failed their named +policy/preflight assertions. The current uncommitted candidate then earned the following +non-physical evidence on 2026-08-02: + +- native firmware logic passed 294/294; +- the focused dashboard service suite passed 28 tests and full bridge discovery passed 567 tests; +- the exact firmware HTTP policy contract passed all 19 effective Wi-Fi environments; +- dashboard, camera-follow, warm-soak, full-system-soak evidence, current-lead reproducibility v2, + and current-lead archive contracts passed; +- the silent trusted-facts privacy smoke returned ready with zero model invocations and zero audio + playback, without printing stored fact values; +- the no-hardware simulator reported `stackchan.hardware-sim.v1` status `pass`; this does not prove + port 8789, deployment, or physical behavior; and +- `stackchan_release_full` completed compilation, link, size analysis, bootloader/partition/app + image generation, and produced a 2,803,375-byte application image report. Windows Device Guard + rejected PlatformIO's generated console-script executable, so the same pinned esptool 5.1.0 + package was invoked through PlatformIO's Python interpreter for this isolated build. No upload + target was invoked. + +Dirty-tree release-package assembly and verification passed after the conversation-harness package +prerequisite was committed separately as `2ed5bb6a`; independent policy, security, and +documentation/authority reviews accepted the exact source slice. The regression build and package +predate the final atomic source commit and are not exact-image candidates for deployment. `R-000B` +and `R-000C` remain open until a clean committed source identity has matching binary/package hashes, +independently approved exact-image no-motion qualification, supervised physical emergency-stop +evidence, and final release gates all pass. + ## SEC-001 Frozen Preregistration This preregistration was reviewed read-only against exact source `39b750e6` before any source or @@ -337,12 +373,16 @@ a robot freeze, blackout, brownout, thermal event, USB failure, board failure, o 1. The last observed production host service exposed a fail-open robot-to-host WebSocket admission boundary on the LAN. Commit `9c72f020` repairs those source paths, but is not deployed on the production listener or robot; the previously exposed listener remains stopped. -2. Wi-Fi-enabled firmware debug HTTP mutation is unauthenticated. Source shows tone, mic-tone, - wake-reset, motion-enable, recovery, and reboot aliases reach effects before response. Emergency - audio/motion stop aliases must remain public. Unsafe routes were not exercised on hardware; OTA +2. The last source-observed Wi-Fi firmware baseline allowed unauthenticated debug HTTP tone, + mic-tone, wake-reset, motion-enable, recovery, and reboot aliases to reach effects before + response; the installed-image identity remains unknown. The accepted working-tree candidate + contains those routes before effects while preserving public emergency audio/motion stops, but + it is undeployed and physically unqualified. Unsafe routes were not exercised on hardware; OTA remains separately token-gated. -3. `/wake.wav` and `/wake-pcm.wav` can export recent wake-microphone PCM without pairing. This is - source-observed only; no PCM was requested or inspected. +3. The last source-observed baseline allowed `/wake.wav` and `/wake-pcm.wav` to export recent + wake-microphone PCM without pairing, and the installed-image identity remains unknown. The + accepted working-tree candidate denies both aliases before PCM access, but it is undeployed and + physically unqualified. No PCM was requested or inspected. 4. Dashboard connection/readiness state can remain affirmative after the heartbeat is stale and the bridge has no established robot socket. 5. The exact AGENTS baseline command through the default shared PlatformIO core fails before @@ -428,12 +468,11 @@ qualifies the installed firmware or authorizes a service restart. ## Exact Next Action -Use the atomic control-only preregistration commit containing this record as the frozen experiment, -then add and preserve the expected-red `SEC-002`/`PRIV-001` native, config, dashboard, and operator- -tool tests before implementation. Implement only the frozen -emergency-stop-only policy and direct compatibility slice, then run source/native/bridge/privacy/ -release gates. Do not design credentials or read a pairing file. Production restart and every -firmware/hardware action remain unauthorized until their separate qualification gates are earned. +Create the one atomic `SEC-002`/`PRIV-001` implementation commit containing this record, rerun the +exact source gates, and produce clean source/binary/package identities from that commit. Do not +design credentials or read a pairing file. Production restart and every firmware/hardware action +remain unauthorized until a separately approved exact-image no-motion qualification, supervised +emergency-stop proof, and final release gates are earned. ## Unauthorized Actions @@ -452,8 +491,8 @@ firmware/hardware action remain unauthorized until their separate qualification ## Rollback Path `SEC-001` rollback is reversion of exact commit `9c72f020`; keep the exposed listener stopped rather -than restoring a fail-open fallback. `SEC-002` preregistration and its later implementation remain -separate atomic commits and are reverted only by exact commit if a frozen invariant regresses. Do +than restoring a fail-open fallback. `SEC-002` preregistration and its current implementation remain +separate atomic changes and are reverted only by exact commit if a frozen invariant regresses. Do not flash an insecure prior image as an operational rollback. Hardware rollback remains the exact private accepted archive/runbook procedure and is not exercised without source, build, no-motion, physical, and exact-image gates. diff --git a/TASK_LEDGER.md b/TASK_LEDGER.md index 691f20ae..fb9bf571 100644 --- a/TASK_LEDGER.md +++ b/TASK_LEDGER.md @@ -453,9 +453,18 @@ Ledger timestamp: 2026-08-02 America/New_York - **Rollback:** Revert the exact atomic source/client candidate if target tests do not turn green or a frozen invariant regresses. Do not flash a prior insecure image as automatic rollback; isolate or power off the robot and preserve evidence. -- **Result:** Preregistered; expected-red pending; no implementation or hardware exercise. -- **Commit:** Atomic control-only preregistration commit containing this record; exact SHA assigned - by Git and reported in the handoff. +- **Result:** Expected-red preserved; the source candidate represented by this record is + implemented. Native logic passed 294/294, the exact policy passed all 19 Wi-Fi environments, + focused dashboard passed + 28 tests, full bridge discovery passed 567 tests, coupled operator/evidence contracts passed, + silent trusted-facts privacy smoke remained model/audio silent, the no-hardware simulator passed, + and secret-free release compilation/link/image generation passed. Dirty-tree release-package + assembly/verification passed, and independent policy, security, and documentation/authority + reviews accepted the exact source slice. No deploy, endpoint mutation, raw-audio request, reboot, + flash, OTA, or hardware exercise occurred; exact post-commit binary/package identity and physical + gates remain unearned. +- **Commit:** Frozen preregistration commit `d75c62f3`; the exact SHA of the atomic implementation + commit containing this record is assigned by Git and reported in the handoff. - **Decision:** Stop-ship. Existing supervised Resume/motion-soak tooling has no approved authority after containment; keep the robot on a trusted isolated LAN or powered off until qualification. @@ -477,4 +486,6 @@ Ledger timestamp: 2026-08-02 America/New_York repository/evidence path. - **Future authority:** Any diagnostic export requires separately approved authentication, explicit consent, bounded retention, and private-artifact transport. -- **Result:** Preregistered with `SEC-002`; expected-red pending; not exercised. +- **Result:** Implemented with `SEC-002` in the source candidate represented by this record. Pure policy/config + coverage and the silent privacy gate passed without requesting, reading, fixtureing, printing, or + archiving wake PCM. Undeployed and physically unqualified; the risk remains open. diff --git a/bridge/dashboard/app.js b/bridge/dashboard/app.js index 196a7811..99ee7350 100644 --- a/bridge/dashboard/app.js +++ b/bridge/dashboard/app.js @@ -36,6 +36,14 @@ function showResult(message, kind = "") { function renderMotion(robot) { const badge = $("motionBadge"); const enabled = robot.motionEnabled; + if (robot.motionResumeAvailable !== true) { + $("robotClearCheck").checked = false; + $("robotClearCheck").disabled = true; + $("resumeMotionButton").disabled = true; + } else { + $("robotClearCheck").disabled = false; + $("resumeMotionButton").disabled = state.busy || !$("robotClearCheck").checked; + } badge.className = "motion-badge"; if (!robot.motionVerified || enabled === null) { badge.textContent = "UNVERIFIED"; @@ -56,6 +64,9 @@ function renderMotion(robot) { $("motionReason").textContent = robot.lastMotionReason || "Servo rail and torque are verified off."; $("motionSummary").textContent = "Stopped"; } + if (robot.motionResumePolicy === "emergency_stop_only") { + $("motionReason").textContent = "Firmware policy is emergency_stop_only; only emergency Stop is available."; + } } function renderEvents(events) { @@ -236,7 +247,7 @@ async function changeMotion(enabled) { } finally { state.busy = false; $("stopMotionButton").disabled = false; - $("resumeMotionButton").disabled = !$("robotClearCheck").checked; + $("resumeMotionButton").disabled = !($("robotClearCheck").checked && state.status?.robot?.motionResumeAvailable === true); } } @@ -298,7 +309,7 @@ $("refreshButton").addEventListener("click", () => refresh(false)); $("stopMotionButton").addEventListener("click", () => changeMotion(false)); $("resumeMotionButton").addEventListener("click", () => changeMotion(true)); $("robotClearCheck").addEventListener("change", (event) => { - $("resumeMotionButton").disabled = !event.target.checked || state.busy; + $("resumeMotionButton").disabled = !(event.target.checked && !state.busy && state.status?.robot?.motionResumeAvailable === true); }); $("initiativeToggle").addEventListener("change", (event) => changeInitiative(event.target.checked)); $("roomObservationToggle").addEventListener("change", () => changeRoomObservation()); diff --git a/bridge/dashboard_service.py b/bridge/dashboard_service.py index fcdef82c..b1dc5401 100644 --- a/bridge/dashboard_service.py +++ b/bridge/dashboard_service.py @@ -51,6 +51,7 @@ DEBUG_FIELDS = { "schema", + "debug_http_control_policy", "network_state", "bridge_state", "motion_enabled", @@ -156,6 +157,7 @@ def __init__( self._heartbeat: dict[str, object] = {} self._debug: dict[str, object] = {} self._debug_at_utc = "" + self._debug_at_monotonic = 0.0 self._last_action: dict[str, object] = {} self._event_id = 0 self._events: list[dict[str, object]] = [] @@ -318,20 +320,33 @@ def _robot_url(self, path: str) -> str: url_host = f"[{host}]" if ":" in host else host return f"http://{url_host}:{int(self.config.robot_http_port)}{path}" - def _fetch_robot(self, path: str, timeout: float = 4.0) -> dict[str, object]: + def _fetch_robot( + self, + path: str, + timeout: float = 4.0, + *, + accept_http_error_json: bool = False, + ) -> dict[str, object]: request = urllib.request.Request( self._robot_url(path), headers={"Accept": "application/json", "Connection": "close"}, method="GET", ) + response_status = 0 try: with urllib.request.urlopen(request, timeout=timeout) as response: - if response.status != HTTPStatus.OK: - raise RuntimeError(f"robot returned HTTP {response.status}") + response_status = int(response.status) payload = response.read(512 * 1024) + except urllib.error.HTTPError as exc: + if not accept_http_error_json: + raise RuntimeError(f"robot control request failed: HTTP {exc.code}") from exc + response_status = int(exc.code) + payload = exc.read(512 * 1024) except (urllib.error.URLError, TimeoutError, OSError) as exc: reason = getattr(exc, "reason", exc) raise RuntimeError(f"robot control request failed: {reason}") from exc + if not HTTPStatus.OK <= response_status < 300 and not accept_http_error_json: + raise RuntimeError(f"robot returned HTTP {response_status}") try: parsed = json.loads(payload.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: @@ -345,6 +360,7 @@ def _record_debug(self, debug: dict[str, object]) -> None: with self._lock: self._debug = filtered self._debug_at_utc = _utc_now() + self._debug_at_monotonic = time.monotonic() if debug.get("bridge_state") == "ready" and debug.get("network_state") == "connected": self._robot_connected = True @@ -389,31 +405,53 @@ def set_motion(self, enabled: bool, confirmation: str = "") -> dict[str, object] "status": self.status(), } + if enabled: + return { + "ok": False, + "commandSent": False, + "accepted": False, + "verified": False, + "targetEnabled": True, + "error": "firmware permits emergency stop only; motion resume is unavailable", + "status": self.status(), + } + target = "enabled" if enabled else "stopped" - endpoint = "/motion-resume" if enabled else "/motion-stop" + endpoint = "/motion-stop" command_sent = False accepted = False debug: dict[str, object] = {} error = "" + command_error = "" try: - command = self._fetch_robot(endpoint) + command = self._fetch_robot(endpoint, accept_http_error_json=True) command_sent = True - accepted = command.get("debug_motion_accepted") is True - for attempt in range(6): - if attempt: - time.sleep(0.2) + accepted = ( + command.get("accepted") is True + or command.get("debug_motion_accepted") is True + ) + except RuntimeError as exc: + command_error = str(exc) + + debug_error = "" + for attempt in range(6): + if attempt: + time.sleep(0.2) + try: debug = self._fetch_robot("/debug") self._record_debug(debug) if self._motion_matches(debug, enabled): break - verified = self._motion_matches(debug, enabled) - if not accepted: - error = "robot did not accept the motion command" - elif not verified: - error = f"robot did not verify motion {target}" - except RuntimeError as exc: - verified = False - error = str(exc) + except RuntimeError as exc: + debug_error = str(exc) + + verified = self._motion_matches(debug, enabled) + if not command_sent: + error = command_error or "robot control request failed" + elif not accepted: + error = "robot did not accept the motion command" + elif not verified: + error = debug_error or f"robot did not verify motion {target}" result = { "ok": bool(command_sent and accepted and verified), @@ -483,6 +521,20 @@ def status(self) -> dict[str, object]: if self._last_heartbeat_at else None ) + debug_age = ( + max(0.0, time.monotonic() - self._debug_at_monotonic) + if self._debug_at_monotonic + else None + ) + raw_motion_policy = debug.get("debug_http_control_policy") + motion_resume_policy = ( + "emergency_stop_only" + if debug_age is not None + and debug_age <= 15.0 + and isinstance(raw_motion_policy, str) + and raw_motion_policy == "emergency_stop_only" + else "unknown" + ) motion = debug.get("motion_enabled", heartbeat.get("motion_enabled")) robot_mode = heartbeat.get("robot_mode") try: @@ -560,6 +612,8 @@ def status(self) -> dict[str, object]: "mode": mode_name, "motionEnabled": motion if isinstance(motion, bool) else None, "motionVerified": "motion_enabled" in debug, + "motionResumeAvailable": False, + "motionResumePolicy": motion_resume_policy, "servoRailEnabled": debug.get("servo_rail_enabled"), "servoTorqueEnabled": debug.get("servo_torque_enabled"), "batteryPercent": heartbeat.get("battery_percent", debug.get("battery_percent")), diff --git a/bridge/test_dashboard_service.py b/bridge/test_dashboard_service.py index 86659471..eadb759b 100644 --- a/bridge/test_dashboard_service.py +++ b/bridge/test_dashboard_service.py @@ -1,3 +1,4 @@ +import io import json import socket import sys @@ -146,6 +147,74 @@ def test_resume_requires_explicit_robot_clear_confirmation(self) -> None: self.assertFalse(result["commandSent"] if "commandSent" in result else False) fetch.assert_not_called() + def test_motion_resume_policy_emergency_stop_only_disables_resume(self) -> None: + self.runtime._record_debug( + { + "network_state": "connected", + "bridge_state": "ready", + "debug_http_control_policy": "emergency_stop_only", + } + ) + + robot = self.runtime.status()["robot"] + + self.assertFalse(robot.get("motionResumeAvailable", True)) + self.assertEqual("emergency_stop_only", robot.get("motionResumePolicy")) + + def test_motion_resume_policy_missing_unknown_malformed_and_stale_fail_closed(self) -> None: + for value in (None, "future_policy", 42): + runtime = DashboardRuntime(self.runtime.config) + if value is not None: + runtime._record_debug({"debug_http_control_policy": value}) + robot = runtime.status()["robot"] + self.assertFalse(robot.get("motionResumeAvailable", True)) + self.assertEqual("unknown", robot.get("motionResumePolicy")) + + with patch("dashboard_service.time.monotonic", return_value=100.0): + runtime = DashboardRuntime(self.runtime.config) + runtime._record_debug({"debug_http_control_policy": "emergency_stop_only"}) + with patch("dashboard_service.time.monotonic", return_value=115.0): + boundary_robot = runtime.status()["robot"] + self.assertFalse(boundary_robot.get("motionResumeAvailable", True)) + self.assertEqual("emergency_stop_only", boundary_robot.get("motionResumePolicy")) + with patch("dashboard_service.time.monotonic", return_value=115.001): + stale_robot = runtime.status()["robot"] + self.assertFalse(stale_robot.get("motionResumeAvailable", True)) + self.assertEqual("unknown", stale_robot.get("motionResumePolicy")) + + def test_motion_resume_policy_refuses_before_robot_request(self) -> None: + self.runtime._record_debug({"debug_http_control_policy": "emergency_stop_only"}) + blocked = {"debug_motion_accepted": False} + with ( + patch.object(self.runtime, "_fetch_robot", return_value=blocked) as fetch, + patch("dashboard_service.time.sleep"), + ): + result = self.runtime.set_motion(True, "robot_clear") + + self.assertFalse(result["ok"]) + self.assertFalse(result.get("commandSent", True)) + self.assertIn("emergency stop only", result.get("error", "")) + fetch.assert_not_called() + + def test_fetch_robot_accepts_emergency_stop_admission_202(self) -> None: + class AcceptedResponse: + status = 202 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + return False + + @staticmethod + def read(_limit): + return b'{"ok":true,"accepted":true}' + + with patch("dashboard_service.urllib.request.urlopen", return_value=AcceptedResponse()): + result = self.runtime._fetch_robot("/motion-stop") + + self.assertTrue(result["accepted"]) + def test_debug_status_distinguishes_running_host_vision(self) -> None: self.runtime._record_debug( { @@ -196,6 +265,98 @@ def test_stop_requires_motion_rail_and_torque_verification(self) -> None: self.assertTrue(result["verified"]) self.assertFalse(result["status"]["robot"]["motionEnabled"]) + def test_stop_accepts_bounded_admission_response_and_verifies_state(self) -> None: + command = {"accepted": True} + stopped = { + "motion_enabled": False, + "servo_rail_enabled": False, + "servo_torque_enabled": False, + } + with patch.object(self.runtime, "_fetch_robot", side_effect=[command, stopped]) as fetch: + result = self.runtime.set_motion(False) + + self.assertTrue(result["ok"]) + self.assertTrue(result["accepted"]) + self.assertTrue(result["verified"]) + self.assertEqual(2, fetch.call_count) + self.assertEqual("/motion-stop", fetch.call_args_list[0].args[0]) + self.assertEqual("/debug", fetch.call_args_list[1].args[0]) + + def test_stop_remains_available_under_emergency_stop_only_policy(self) -> None: + self.runtime._record_debug( + {"debug_http_control_policy": "emergency_stop_only"} + ) + command = {"accepted": True} + stopped = { + "motion_enabled": False, + "servo_rail_enabled": False, + "servo_torque_enabled": False, + "debug_http_control_policy": "emergency_stop_only", + } + with patch.object(self.runtime, "_fetch_robot", side_effect=[command, stopped]) as fetch: + result = self.runtime.set_motion(False) + + self.assertTrue(result["ok"]) + self.assertTrue(result["accepted"]) + self.assertTrue(result["verified"]) + self.assertEqual(["/motion-stop", "/debug"], [call.args[0] for call in fetch.call_args_list]) + + def test_stop_rejects_false_missing_or_non_boolean_admission(self) -> None: + stopped = { + "motion_enabled": False, + "servo_rail_enabled": False, + "servo_torque_enabled": False, + } + for command in ({"accepted": False}, {}, {"accepted": "true"}): + with self.subTest(command=command): + with patch.object( + self.runtime, "_fetch_robot", side_effect=[command, stopped] + ) as fetch: + result = self.runtime.set_motion(False) + + self.assertFalse(result["ok"]) + self.assertFalse(result.get("accepted", True)) + self.assertTrue(result["verified"]) + self.assertEqual(2, fetch.call_count) + self.assertEqual("/motion-stop", fetch.call_args_list[0].args[0]) + self.assertEqual("/debug", fetch.call_args_list[1].args[0]) + + def test_stop_parses_wire_503_rejection_and_still_verifies_state(self) -> None: + class DebugResponse: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + return False + + @staticmethod + def read(_limit): + return ( + b'{"motion_enabled":false,"servo_rail_enabled":false,' + b'"servo_torque_enabled":false}' + ) + + rejected = urllib.error.HTTPError( + "http://192.168.1.238:8789/motion-stop", + 503, + "Service Unavailable", + None, + io.BytesIO(b'{"ok":false,"accepted":false,"error":"control_disabled"}'), + ) + with patch( + "dashboard_service.urllib.request.urlopen", + side_effect=[rejected, DebugResponse()], + ) as fetch: + result = self.runtime.set_motion(False) + + self.assertFalse(result["ok"]) + self.assertTrue(result["commandSent"]) + self.assertFalse(result["accepted"]) + self.assertTrue(result["verified"]) + self.assertEqual(2, fetch.call_count) + def test_stop_does_not_claim_success_when_torque_remains_on(self) -> None: command = {"debug_motion_accepted": True} unsafe = { @@ -213,36 +374,24 @@ def test_stop_does_not_claim_success_when_torque_remains_on(self) -> None: self.assertFalse(result["verified"]) self.assertIn("did not verify", result["error"]) - def test_resume_calls_firmware_endpoint_and_verifies_state(self) -> None: - command = {"debug_motion_accepted": True} - enabled = { - "motion_enabled": True, - "servo_rail_enabled": True, - "servo_torque_enabled": True, - } - with patch.object(self.runtime, "_fetch_robot", side_effect=[command, enabled]) as fetch: + def test_resume_without_supported_authority_never_calls_firmware_endpoint(self) -> None: + self.runtime._record_debug({"debug_http_control_policy": "future_policy"}) + with patch.object(self.runtime, "_fetch_robot") as fetch: result = self.runtime.set_motion(True, "robot_clear") - self.assertTrue(result["ok"]) - self.assertEqual("/motion-resume", fetch.call_args_list[0].args[0]) - self.assertEqual("/debug", fetch.call_args_list[1].args[0]) + self.assertFalse(result["ok"]) + self.assertFalse(result.get("commandSent", True)) + self.assertIn("emergency stop only", result.get("error", "")) + fetch.assert_not_called() - def test_resume_does_not_claim_success_while_power_suppressed(self) -> None: - command = {"debug_motion_accepted": True} - suppressed = { - "motion_enabled": True, - "servo_rail_enabled": True, - "servo_torque_enabled": True, - "motion_power_suppressed": True, - } - with ( - patch.object(self.runtime, "_fetch_robot", side_effect=[command] + [suppressed] * 6), - patch("dashboard_service.time.sleep"), - ): + def test_resume_missing_policy_does_not_claim_success(self) -> None: + with patch.object(self.runtime, "_fetch_robot") as fetch: result = self.runtime.set_motion(True, "robot_clear") self.assertFalse(result["ok"]) - self.assertFalse(result["verified"]) + self.assertFalse(result.get("commandSent", True)) + self.assertFalse(result.get("verified", False)) + fetch.assert_not_called() def test_awareness_controls_are_host_only_and_aggregate(self) -> None: policy = InitiativePolicy( diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index 09a67dae..8edba2ef 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -8,6 +8,15 @@ Repository-truth warning (2026-08-02): the currently installed firmware SHA is u “installed,” “current,” and “live” notes below are historical evidence and cannot replace discovery, exact source/binary identity, or qualification of the image actually under test. +Current control-containment warning (2026-08-02): the SEC-002 source candidate is +`emergency_stop_only`. Its dashboard and legacy motion/wake runners refuse Resume, wake-reset, and +tone before starting processes or evidence runs. Historical `/motion-resume`, `/recover`, +`/reboot`, `/wake-reset`, tone, and wake-WAV commands below describe older evidence only and are +not authorized current procedures. Do not work around the refusal or substitute camera pairing as +control authority. Query-free emergency Stop remains available, but source tests do not prove the +installed image; exact-image no-motion and supervised emergency-stop qualification are still +required before any physical promotion. + ## 0. Bench Setup - Clear the work area around the body and servos. diff --git a/docs/BRIDGE_DASHBOARD.md b/docs/BRIDGE_DASHBOARD.md index a64d2169..682cc512 100644 --- a/docs/BRIDGE_DASHBOARD.md +++ b/docs/BRIDGE_DASHBOARD.md @@ -2,7 +2,7 @@ The PC bridge can serve a local browser dashboard at `http://127.0.0.1:8766/`. It shows the bridge and robot link state, a square Stackchan face, bounded robot telemetry, recent dashboard -events, and verified motion stop/resume controls. +events, and a verified emergency motion-stop control. Resume is visibly contained. The connection badge represents operational bridge readiness, not only the robot socket. If the resident speech recognizer fails, the badge changes to **SPEECH RECOVERING** or **SPEECH @@ -61,16 +61,17 @@ The dashboard does not write servo state directly. It calls the firmware-owned d on port `8789`: - Production DirectML startup always verifies a motion stop after bridge reconnect. Motion never - remains enabled when the launcher reports ready; the operator must use the guarded control - below to resume it. + remains enabled when the launcher reports ready. - **Stop motion** calls `/motion-stop`, then requires `/debug` to report motion, servo rail, and servo torque all off before showing a verified stop. -- **Resume motion** stays disabled until the operator checks **Robot is upright and clear**. It - calls `/motion-resume`, then requires `/debug` to report motion, servo rail, and servo torque - enabled with no power or thermal suppression before showing success. +- **Resume motion** remains disabled. Fresh `/debug` capability + `debug_http_control_policy=emergency_stop_only`, missing capability, malformed capability, and a + sample older than 15 seconds all fail closed. The dashboard projects only + `motionResumeAvailable:false` and `motionResumePolicy:emergency_stop_only|unknown`. A command timeout, rejected command, or mismatched `/debug` state is shown as unverified. The -dashboard never converts transport success into a motion-success claim. +dashboard never converts transport success into a motion-success claim. A `202 accepted:true` +stop response is followed by independent `/debug` rail-and-torque verification. ## Security And Load diff --git a/docs/BRIDGE_PROTOCOL.md b/docs/BRIDGE_PROTOCOL.md index 3f1926d0..48d74986 100644 --- a/docs/BRIDGE_PROTOCOL.md +++ b/docs/BRIDGE_PROTOCOL.md @@ -4,6 +4,22 @@ Protocol: `stackchan.bridge.v1` The bridge is the P7 boundary between the real-time firmware and a LAN companion service. The firmware owns wake/listen/think/speak choreography, face, motion, earcons, and safety. The bridge owns STT, LLM text generation, memory, and dynamic TTS rendering. +## Firmware Debug HTTP Containment + +The firmware service on port `8789` advertises +`debug_http_control_policy=emergency_stop_only` in `/debug`. It strictly parses one HTTP/1.0 or +HTTP/1.1 request line before dispatch. Query-free `GET /` and `GET /debug` are bounded status +reads. Query-free `GET` or `POST` requests to the documented audio-stop and motion-stop aliases +may only admit an emergency stop; `202 accepted:true` means the request was admitted, not that a +physical stop is complete. A failed motion-stop publication returns `503 accepted:false`. + +Tone, wake-reset, Resume, recovery, reboot, and wake-PCM/WAV routes return a fixed `403` response +before their former effects. Unknown, malformed, wrong-method, and oversized requests remain +distinct `404`, `400`, `405`, and `414` outcomes. The two camera query families still reach their +pre-existing parser and pairing authorizer; camera pairing is not general control authority. +Raw request targets, queries, pairing codes, credentials, and wake PCM are not status or diagnostic +output. Any future Resume or recovery control requires a separately preregistered authority design. + Firmware bench replay uses newline-delimited UTF-8 JSON. The LAN bridge service uses WebSocket text frames for control, binary WebSocket frames for uploaded PCM, and optional binary WebSocket frames for downlinked TTS audio chunks. Current firmware has a native-tested diff --git a/platformio.ini b/platformio.ini index 5e3a5eda..1918428e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,6 +37,7 @@ build_src_filter = + + + + + + + + diff --git a/src/io/BridgeDebugHttpPolicy.cpp b/src/io/BridgeDebugHttpPolicy.cpp new file mode 100644 index 00000000..d5b54870 --- /dev/null +++ b/src/io/BridgeDebugHttpPolicy.cpp @@ -0,0 +1,291 @@ +#include "io/BridgeDebugHttpPolicy.hpp" + +#include +#include + +namespace { + +constexpr size_t kMaximumMethodLength = 16; + +BridgeDebugHttpDecision decision(BridgeDebugHttpMethod method, + BridgeDebugHttpRoute route, + BridgeDebugHttpDisposition disposition, + uint16_t statusCode) { + BridgeDebugHttpDecision result; + result.method = method; + result.route = route; + result.disposition = disposition; + result.statusCode = statusCode; + return result; +} + +bool equals(const char* value, size_t valueLength, const char* expected) { + const size_t expectedLength = std::strlen(expected); + return valueLength == expectedLength && std::memcmp(value, expected, valueLength) == 0; +} + +bool isExactOrQuery(const char* target, size_t targetLength, const char* route) { + const size_t routeLength = std::strlen(route); + return targetLength == routeLength + ? std::memcmp(target, route, routeLength) == 0 + : targetLength > routeLength && target[routeLength] == '?' && + std::memcmp(target, route, routeLength) == 0; +} + +bool isQueryFamily(const char* target, size_t targetLength, const char* route) { + const size_t routeLength = std::strlen(route); + return targetLength > routeLength && target[routeLength] == '?' && + std::memcmp(target, route, routeLength) == 0; +} + +BridgeDebugHttpMethod parseMethod(const char* method, size_t methodLength) { + if (equals(method, methodLength, "GET")) { + return BridgeDebugHttpMethod::Get; + } + if (equals(method, methodLength, "POST")) { + return BridgeDebugHttpMethod::Post; + } + if (equals(method, methodLength, "HEAD")) { + return BridgeDebugHttpMethod::Head; + } + if (equals(method, methodLength, "PUT")) { + return BridgeDebugHttpMethod::Put; + } + if (equals(method, methodLength, "DELETE")) { + return BridgeDebugHttpMethod::Delete; + } + if (equals(method, methodLength, "PATCH")) { + return BridgeDebugHttpMethod::Patch; + } + if (equals(method, methodLength, "OPTIONS")) { + return BridgeDebugHttpMethod::Options; + } + return BridgeDebugHttpMethod::Custom; +} + +bool isGetOrPost(BridgeDebugHttpMethod method) { + return method == BridgeDebugHttpMethod::Get || method == BridgeDebugHttpMethod::Post; +} + +} // namespace + +BridgeDebugHttpDecision evaluateBridgeDebugHttpRequestLine(const char* requestLine, + bool firstLineComplete, + bool requestLineOverflow, + bool requestLineInvalid, + char* cameraRequestTarget, + size_t cameraRequestTargetSize) { + if (cameraRequestTarget != nullptr && cameraRequestTargetSize > 0) { + cameraRequestTarget[0] = '\0'; + } + if (requestLineOverflow) { + return decision(BridgeDebugHttpMethod::Unknown, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectUriTooLong, + 414); + } + if (!firstLineComplete || requestLineInvalid || requestLine == nullptr || + cameraRequestTarget == nullptr || + cameraRequestTargetSize == 0) { + return decision(BridgeDebugHttpMethod::Unknown, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectBadRequest, + 400); + } + + const char* firstSpace = std::strchr(requestLine, ' '); + if (firstSpace == nullptr || firstSpace == requestLine) { + return decision(BridgeDebugHttpMethod::Unknown, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectBadRequest, + 400); + } + const size_t methodLength = static_cast(firstSpace - requestLine); + if (methodLength > kMaximumMethodLength) { + return decision(BridgeDebugHttpMethod::Unknown, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectBadRequest, + 400); + } + for (size_t i = 0; i < methodLength; ++i) { + if (requestLine[i] < 'A' || requestLine[i] > 'Z') { + return decision(BridgeDebugHttpMethod::Unknown, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectBadRequest, + 400); + } + } + const BridgeDebugHttpMethod method = parseMethod(requestLine, methodLength); + + const char* target = firstSpace + 1; + const char* secondSpace = std::strchr(target, ' '); + if (secondSpace == nullptr || secondSpace == target || std::strchr(secondSpace + 1, ' ') != nullptr) { + return decision(method, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectBadRequest, + 400); + } + const size_t targetLength = static_cast(secondSpace - target); + if (targetLength >= cameraRequestTargetSize) { + return decision(method, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectUriTooLong, + 414); + } + if (target[0] != '/') { + return decision(method, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectBadRequest, + 400); + } + for (size_t i = 0; i < targetLength; ++i) { + const unsigned char ch = static_cast(target[i]); + if (ch < 0x21u || ch > 0x7eu) { + return decision(method, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectBadRequest, + 400); + } + } + const char* version = secondSpace + 1; + if (std::strcmp(version, "HTTP/1.0") != 0 && std::strcmp(version, "HTTP/1.1") != 0) { + return decision(method, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectBadRequest, + 400); + } + + constexpr const char* unsafeRoutes[] = { + "/tone", "/speaker-test", "/mic-tone", "/mic-tone-soft", + "/mic-tone-tap", "/mic-tone-old", "/wake-reset", "/motion-resume", + "/motion-on", "/servos-on", "/recover", "/bridge-recover", + "/wifi-recover", "/reboot", "/restart", "/reset", + "/wake.wav", "/wake-pcm.wav", + }; + for (const char* unsafeRoute : unsafeRoutes) { + if (isExactOrQuery(target, targetLength, unsafeRoute)) { + return decision(method, + BridgeDebugHttpRoute::UnsafeControl, + BridgeDebugHttpDisposition::RejectForbidden, + 403); + } + } + + if (equals(target, targetLength, "/")) { + return method == BridgeDebugHttpMethod::Get + ? decision(method, BridgeDebugHttpRoute::Root, + BridgeDebugHttpDisposition::ServeStatus, 200) + : decision(method, BridgeDebugHttpRoute::Root, + BridgeDebugHttpDisposition::RejectMethod, 405); + } + if (equals(target, targetLength, "/debug")) { + return method == BridgeDebugHttpMethod::Get + ? decision(method, BridgeDebugHttpRoute::Debug, + BridgeDebugHttpDisposition::ServeDebug, 200) + : decision(method, BridgeDebugHttpRoute::Debug, + BridgeDebugHttpDisposition::RejectMethod, 405); + } + + for (const char* audioStop : {"/audio-stop", "/playback-stop"}) { + if (equals(target, targetLength, audioStop)) { + return isGetOrPost(method) + ? decision(method, BridgeDebugHttpRoute::AudioStop, + BridgeDebugHttpDisposition::EmergencyAudioStop, 202) + : decision(method, BridgeDebugHttpRoute::AudioStop, + BridgeDebugHttpDisposition::RejectMethod, 405); + } + } + for (const char* motionStop : {"/motion-stop", "/motion-off", "/servos-off"}) { + if (equals(target, targetLength, motionStop)) { + return isGetOrPost(method) + ? decision(method, BridgeDebugHttpRoute::MotionStop, + BridgeDebugHttpDisposition::EmergencyMotionStop, 202) + : decision(method, BridgeDebugHttpRoute::MotionStop, + BridgeDebugHttpDisposition::RejectMethod, 405); + } + } + + if (isQueryFamily(target, targetLength, "/camera-gray.pgm")) { + if (method != BridgeDebugHttpMethod::Get) { + return decision(method, + BridgeDebugHttpRoute::CameraGray, + BridgeDebugHttpDisposition::RejectMethod, + 405); + } + std::memcpy(cameraRequestTarget, target, targetLength); + cameraRequestTarget[targetLength] = '\0'; + return decision(method, + BridgeDebugHttpRoute::CameraGray, + BridgeDebugHttpDisposition::CameraGray, + 0); + } + if (isQueryFamily(target, targetLength, "/vision-target")) { + if (method != BridgeDebugHttpMethod::Get) { + return decision(method, + BridgeDebugHttpRoute::CameraVision, + BridgeDebugHttpDisposition::RejectMethod, + 405); + } + std::memcpy(cameraRequestTarget, target, targetLength); + cameraRequestTarget[targetLength] = '\0'; + return decision(method, + BridgeDebugHttpRoute::CameraVision, + BridgeDebugHttpDisposition::CameraVision, + 0); + } + + if (std::memchr(target, '?', targetLength) != nullptr) { + return decision(method, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectBadRequest, + 400); + } + return decision(method, + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectNotFound, + 404); +} + +const char* bridgeDebugHttpMethodName(BridgeDebugHttpMethod method) { + switch (method) { + case BridgeDebugHttpMethod::Get: return "GET"; + case BridgeDebugHttpMethod::Post: return "POST"; + case BridgeDebugHttpMethod::Head: return "HEAD"; + case BridgeDebugHttpMethod::Put: return "PUT"; + case BridgeDebugHttpMethod::Delete: return "DELETE"; + case BridgeDebugHttpMethod::Patch: return "PATCH"; + case BridgeDebugHttpMethod::Options: return "OPTIONS"; + case BridgeDebugHttpMethod::Custom: return "CUSTOM"; + default: return "UNKNOWN"; + } +} + +const char* bridgeDebugHttpRouteName(BridgeDebugHttpRoute route) { + switch (route) { + case BridgeDebugHttpRoute::Root: return "root"; + case BridgeDebugHttpRoute::Debug: return "debug"; + case BridgeDebugHttpRoute::AudioStop: return "audio_stop"; + case BridgeDebugHttpRoute::MotionStop: return "motion_stop"; + case BridgeDebugHttpRoute::UnsafeControl: return "unsafe_control"; + case BridgeDebugHttpRoute::CameraGray: return "camera_gray"; + case BridgeDebugHttpRoute::CameraVision: return "camera_vision"; + default: return "unknown"; + } +} + +const char* bridgeDebugHttpDispositionName(BridgeDebugHttpDisposition disposition) { + switch (disposition) { + case BridgeDebugHttpDisposition::RejectBadRequest: return "rejected_bad_request"; + case BridgeDebugHttpDisposition::RejectForbidden: return "rejected_forbidden"; + case BridgeDebugHttpDisposition::RejectNotFound: return "rejected_not_found"; + case BridgeDebugHttpDisposition::RejectMethod: return "rejected_method"; + case BridgeDebugHttpDisposition::RejectUriTooLong: return "rejected_uri_too_long"; + case BridgeDebugHttpDisposition::ServeStatus: return "status"; + case BridgeDebugHttpDisposition::ServeDebug: return "debug"; + case BridgeDebugHttpDisposition::EmergencyAudioStop: return "audio_stop_accepted"; + case BridgeDebugHttpDisposition::EmergencyMotionStop: return "motion_stop_admitted"; + case BridgeDebugHttpDisposition::CameraGray: return "camera_gray"; + case BridgeDebugHttpDisposition::CameraVision: return "camera_vision"; + default: return "unknown"; + } +} diff --git a/src/io/BridgeDebugHttpPolicy.hpp b/src/io/BridgeDebugHttpPolicy.hpp new file mode 100644 index 00000000..1040f43a --- /dev/null +++ b/src/io/BridgeDebugHttpPolicy.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +enum class BridgeDebugHttpMethod : uint8_t { + Unknown = 0, + Get, + Post, + Head, + Put, + Delete, + Patch, + Options, + Custom, +}; + +enum class BridgeDebugHttpRoute : uint8_t { + Unknown = 0, + Root, + Debug, + AudioStop, + MotionStop, + UnsafeControl, + CameraGray, + CameraVision, +}; + +enum class BridgeDebugHttpDisposition : uint8_t { + RejectBadRequest = 0, + RejectForbidden, + RejectNotFound, + RejectMethod, + RejectUriTooLong, + ServeStatus, + ServeDebug, + EmergencyAudioStop, + EmergencyMotionStop, + CameraGray, + CameraVision, +}; + +struct BridgeDebugHttpDecision { + BridgeDebugHttpMethod method = BridgeDebugHttpMethod::Unknown; + BridgeDebugHttpRoute route = BridgeDebugHttpRoute::Unknown; + BridgeDebugHttpDisposition disposition = BridgeDebugHttpDisposition::RejectBadRequest; + uint16_t statusCode = 400; +}; + +BridgeDebugHttpDecision evaluateBridgeDebugHttpRequestLine(const char* requestLine, + bool firstLineComplete, + bool requestLineOverflow, + bool requestLineInvalid, + char* cameraRequestTarget, + size_t cameraRequestTargetSize); + +const char* bridgeDebugHttpMethodName(BridgeDebugHttpMethod method); +const char* bridgeDebugHttpRouteName(BridgeDebugHttpRoute route); +const char* bridgeDebugHttpDispositionName(BridgeDebugHttpDisposition disposition); diff --git a/src/main.cpp b/src/main.cpp index 97b43afb..f4a082e7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -606,6 +606,7 @@ static_assert(STACKCHAN_ENABLE_PMIC_INPUT_TELEMETRY == 0 || #include "io/BridgeAudioDownlink.hpp" #include "io/BridgeAudioUplink.hpp" #include "io/BridgeClient.hpp" +#include "io/BridgeDebugHttpPolicy.hpp" #include "io/BridgeEndpointControl.hpp" #include "io/BridgeEndpointRegistry.hpp" #include "io/BridgeEndpointStore.hpp" @@ -699,6 +700,10 @@ BridgeWiFiProvisioningPreferencesStore gBridgeWiFiStoreBackend; WiFiServer gBridgeDebugServer(STACKCHAN_BRIDGE_DEBUG_PORT); LanOtaServer gLanOtaServer(STACKCHAN_OTA_PORT); bool gBridgeDebugServerStarted = false; +uint32_t gBridgeDebugHttpRequests = 0; +uint32_t gBridgeDebugHttpRejected = 0; +uint32_t gBridgeDebugHttpEmergencyStops = 0; +uint32_t gBridgeDebugHttpCameraRoutes = 0; RTC_DATA_ATTR uint32_t gRtcBootCount = 0; esp_reset_reason_t gBootResetReason = ESP_RST_UNKNOWN; bool gChipTemperatureValid = false; @@ -6046,8 +6051,6 @@ void printRuntimeStatus() { Serial.print(endpointControl.rejectedMessages); Serial.print(F(" bridge_endpoint_pairing_required=")); Serial.print(gBridgeEndpointControl.pairingCodeRequired() ? 1 : 0); - Serial.print(F(" bridge_endpoint_pairing_code=")); - Serial.print(gBridgeEndpointControl.requiredPairingCode()); Serial.print(F(" bridge_endpoint_pairing_rejects=")); Serial.print(endpointControl.pairingRejects); Serial.print(F(" bridge_endpoint_persistence_saves=")); @@ -6331,10 +6334,6 @@ void printBenchControl(const BenchControl& control) { if (control.hasPairingControl) { Serial.print(F(" pairing_action=")); Serial.print(control.pairing.clear ? F("clear") : F("set")); - if (!control.pairing.clear) { - Serial.print(F(" pairing_code=")); - Serial.print(control.pairing.code); - } } if (control.hasPairingTicket) { Serial.print(F(" pairing_ticket=1")); @@ -6750,8 +6749,6 @@ void handlePairingControl(const BenchPairingControl& pairing, uint32_t nowMs) { Serial.print(accepted ? F("accepted") : F("rejected")); Serial.print(F(" required=")); Serial.print(gBridgeEndpointControl.pairingCodeRequired() ? 1 : 0); - Serial.print(F(" code=")); - Serial.print(gBridgeEndpointControl.requiredPairingCode()); Serial.print(F(" at_ms=")); Serial.println(nowMs); } @@ -6785,8 +6782,6 @@ void handlePairingTicketControl(const BenchPairingTicketControl& ticket, uint32_ Serial.print(pairingAccepted ? F("accepted") : F("rejected")); Serial.print(F(" pairing_required=")); Serial.print(gBridgeEndpointControl.pairingCodeRequired() ? 1 : 0); - Serial.print(F(" code=")); - Serial.print(gBridgeEndpointControl.requiredPairingCode()); Serial.print(F(" bridge_url_applied=")); Serial.print(bridgeUpdated ? 1 : 0); Serial.print(F(" bridge_ssid_available=")); @@ -7513,14 +7508,7 @@ void updateBridgeNetwork(uint32_t nowMs) { void serveBridgeLeanStatusJson(WiFiClient& client, const char* schema, - const char* requestTarget, - bool speakerToneRequest, - bool micToneRequest, - bool wakeResetRequest, - bool motionControlRequest, - bool motionControlTargetEnabled, - bool motionControlRequestAccepted, - bool toneRequestAccepted) { + const BridgeDebugHttpDecision& decision) { const BridgeWiFiProvisioningTelemetry& wifi = gBridgeWiFi.telemetry(); const BridgeNetworkSessionTelemetry& network = gBridgeNetworkSession.telemetry(); const BridgeClientTelemetry& bridge = gBridge.telemetry(); @@ -7545,15 +7533,6 @@ void serveBridgeLeanStatusJson(WiFiClient& client, sampleChipTemperature(millis(), false); samplePowerTelemetry(millis(), true); #endif - const bool recoveryRequest = - strcmp(requestTarget, "/recover") == 0 || strcmp(requestTarget, "/bridge-recover") == 0 || - strcmp(requestTarget, "/wifi-recover") == 0; - const bool rebootRequest = - strcmp(requestTarget, "/reboot") == 0 || strcmp(requestTarget, "/restart") == 0 || - strcmp(requestTarget, "/reset") == 0; - const bool audioStopRequest = - strcmp(requestTarget, "/audio-stop") == 0 || strcmp(requestTarget, "/playback-stop") == 0; - static char body[20480]; constexpr size_t kDebugJsonTailReserve = 96; size_t len = 0; @@ -7583,7 +7562,15 @@ void serveBridgeLeanStatusJson(WiFiClient& client, append("{\"schema\":\"%s\"", schema); append(",\"wifi_connected\":%s", wifi.connected ? "true" : "false"); - append(",\"debug_request\":\"%s\"", requestTarget); + append(",\"debug_http_control_policy\":\"emergency_stop_only\""); + append(",\"debug_request_method\":\"%s\"", bridgeDebugHttpMethodName(decision.method)); + append(",\"debug_request_route\":\"%s\"", bridgeDebugHttpRouteName(decision.route)); + append(",\"debug_request_result\":\"%s\"", bridgeDebugHttpDispositionName(decision.disposition)); + append(",\"debug_http_requests\":%lu", static_cast(gBridgeDebugHttpRequests)); + append(",\"debug_http_rejections\":%lu", static_cast(gBridgeDebugHttpRejected)); + append(",\"debug_http_emergency_stops\":%lu", static_cast(gBridgeDebugHttpEmergencyStops)); + append(",\"debug_http_camera_routes\":%lu", static_cast(gBridgeDebugHttpCameraRoutes)); + append(",\"control_disabled\":true"); append(",\"debug_port\":%lu", static_cast(STACKCHAN_BRIDGE_DEBUG_PORT)); #if defined(ARDUINO_ARCH_ESP32) append(",\"uptime_ms\":%lu", static_cast(millis())); @@ -8314,19 +8301,17 @@ void serveBridgeLeanStatusJson(WiFiClient& client, #endif append(",\"motion_enabled_at_boot\":%d", STACKCHAN_MOTION_ENABLED_AT_BOOT ? 1 : 0); append(",\"motion_autonomous_at_boot\":%d", STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT ? 1 : 0); - append(",\"debug_tone_request\":%s", (speakerToneRequest || micToneRequest) ? "true" : "false"); - append(",\"debug_tone_accepted\":%s", toneRequestAccepted ? "true" : "false"); - append(",\"debug_wake_reset_request\":%s", wakeResetRequest ? "true" : "false"); - append(",\"debug_motion_request\":%s", motionControlRequest ? "true" : "false"); - append(",\"debug_motion_target_enabled\":%s", motionControlTargetEnabled ? "true" : "false"); - append(",\"debug_motion_accepted\":%s", motionControlRequestAccepted ? "true" : "false"); - append(",\"debug_recovery_request\":%s", recoveryRequest ? "true" : "false"); - append(",\"debug_recovery_accepted\":%s", - (recoveryRequest && STACKCHAN_REMOTE_RECOVERY_ENABLE != 0) ? "true" : "false"); - append(",\"debug_reboot_request\":%s", rebootRequest ? "true" : "false"); - append(",\"debug_reboot_accepted\":%s", - (rebootRequest && STACKCHAN_REMOTE_RECOVERY_ENABLE != 0) ? "true" : "false"); - append(",\"debug_audio_stop_request\":%s", audioStopRequest ? "true" : "false"); + append(",\"debug_tone_request\":false"); + append(",\"debug_tone_accepted\":false"); + append(",\"debug_wake_reset_request\":false"); + append(",\"debug_motion_request\":false"); + append(",\"debug_motion_target_enabled\":false"); + append(",\"debug_motion_accepted\":false"); + append(",\"debug_recovery_request\":false"); + append(",\"debug_recovery_accepted\":false"); + append(",\"debug_reboot_request\":false"); + append(",\"debug_reboot_accepted\":false"); + append(",\"debug_audio_stop_request\":false"); append(",\"recovery_enabled\":%d", STACKCHAN_REMOTE_RECOVERY_ENABLE ? 1 : 0); append(",\"recovery_requested\":%s", gBridgeRecovery.recoveryRequested ? "true" : "false"); append(",\"recovery_reboot_requested\":%s", gBridgeRecovery.rebootRequested ? "true" : "false"); @@ -8749,6 +8734,81 @@ void serveCameraVisionTarget(WiFiClient& client, const char* requestTarget) { } #endif +void serveBridgeDebugRejectionJson(WiFiClient& client, uint16_t statusCode) { + const char* statusText = "Bad Request"; + switch (statusCode) { + case 403: + statusText = "Forbidden"; + break; + case 404: + statusText = "Not Found"; + break; + case 405: + statusText = "Method Not Allowed"; + break; + case 414: + statusText = "URI Too Long"; + break; + default: + statusCode = 400; + break; + } + constexpr char body[] = "{\"ok\":false,\"accepted\":false,\"error\":\"control_disabled\"}\n"; + constexpr size_t bodyLength = sizeof(body) - 1u; + char header[192] = {}; + const int headerLength = snprintf( + header, + sizeof(header), + "HTTP/1.1 %u %s\r\nContent-Type: application/json\r\nCache-Control: no-store\r\n" + "Content-Length: %u\r\nConnection: close\r\n\r\n", + static_cast(statusCode), + statusText, + static_cast(bodyLength)); + if (headerLength <= 0 || static_cast(headerLength) >= sizeof(header)) { + client.stop(); + return; + } + client.write(reinterpret_cast(header), static_cast(headerLength)); + client.write(reinterpret_cast(body), bodyLength); + delay(1); + client.stop(); +} + +void serveBridgeDebugAdmissionJson(WiFiClient& client, + uint16_t statusCode, + const char* statusText, + bool accepted) { + const char* acceptedJson = accepted ? "true" : "false"; + char body[64] = {}; + const int bodyLength = snprintf( + body, + sizeof(body), + "{\"ok\":%s,\"accepted\":%s}\n", + acceptedJson, + acceptedJson); + if (bodyLength <= 0 || static_cast(bodyLength) >= sizeof(body)) { + client.stop(); + return; + } + char header[192] = {}; + const int headerLength = snprintf( + header, + sizeof(header), + "HTTP/1.1 %u %s\r\nContent-Type: application/json\r\nCache-Control: no-store\r\n" + "Content-Length: %u\r\nConnection: close\r\n\r\n", + static_cast(statusCode), + statusText, + static_cast(bodyLength)); + if (headerLength <= 0 || static_cast(headerLength) >= sizeof(header)) { + client.stop(); + return; + } + client.write(reinterpret_cast(header), static_cast(headerLength)); + client.write(reinterpret_cast(body), static_cast(bodyLength)); + delay(1); + client.stop(); +} + void pollBridgeDebugServer(uint32_t nowMs) { #if defined(ARDUINO_ARCH_ESP32) if (!gBridgeWiFi.isConnected()) { @@ -8768,17 +8828,32 @@ void pollBridgeDebugServer(uint32_t nowMs) { char requestLine[256] = {}; size_t requestLineLen = 0; bool firstLineComplete = false; + bool requestLineOverflow = false; + bool requestLineInvalid = false; + bool pendingCarriageReturn = false; while (client.connected() && requestStartMs != 0 && millis() - requestStartMs < STACKCHAN_BRIDGE_DEBUG_REQUEST_TIMEOUT_MS) { while (client.available() > 0) { const char ch = static_cast(client.read()); - if (!firstLineComplete && ch != '\r' && ch != '\n' && requestLineLen < sizeof(requestLine) - 1u) { - requestLine[requestLineLen++] = ch; - requestLine[requestLineLen] = '\0'; - } - if (ch == '\n') { - firstLineComplete = true; - requestStartMs = 0; + if (!firstLineComplete) { + if (ch == '\n') { + firstLineComplete = true; + pendingCarriageReturn = false; + requestStartMs = 0; + } else if (pendingCarriageReturn) { + requestLineInvalid = true; + pendingCarriageReturn = false; + } else if (ch == '\r') { + pendingCarriageReturn = true; + } else if (static_cast(ch) < 0x20u || + static_cast(ch) == 0x7fu) { + requestLineInvalid = true; + } else if (requestLineLen < sizeof(requestLine) - 1u) { + requestLine[requestLineLen++] = ch; + requestLine[requestLineLen] = '\0'; + } else { + requestLineOverflow = true; + } } } if (requestStartMs != 0) { @@ -8786,129 +8861,82 @@ void pollBridgeDebugServer(uint32_t nowMs) { } } - char requestTarget[224] = "/"; - const char* firstSpace = strchr(requestLine, ' '); - if (firstSpace != nullptr) { - const char* targetStart = firstSpace + 1; - const char* secondSpace = strchr(targetStart, ' '); - const size_t targetLen = - secondSpace != nullptr ? static_cast(secondSpace - targetStart) : strlen(targetStart); - const size_t copyLen = targetLen < sizeof(requestTarget) - 1u ? targetLen : sizeof(requestTarget) - 1u; - memcpy(requestTarget, targetStart, copyLen); - requestTarget[copyLen] = '\0'; - } + char requestTarget[224] = {}; + const BridgeDebugHttpDecision decision = evaluateBridgeDebugHttpRequestLine( + requestLine, + firstLineComplete, + requestLineOverflow, + requestLineInvalid, + requestTarget, + sizeof(requestTarget)); + gBridgeDebugHttpRequests++; + switch (decision.disposition) { + case BridgeDebugHttpDisposition::ServeStatus: + serveBridgeLeanStatusJson(client, "stackchan.bridge-status.v1", decision); + return; + case BridgeDebugHttpDisposition::ServeDebug: + serveBridgeLeanStatusJson(client, "stackchan.bridge-debug.v1", decision); + return; + case BridgeDebugHttpDisposition::EmergencyAudioStop: + gBridgeDebugHttpEmergencyStops++; + gBridgeAudioRemoteStopRequests++; + stopBridgeAudioRuntime(nowMs, BridgeAudioSafetyStopReason::RemoteRequest); + serveBridgeDebugAdmissionJson(client, 202, "Accepted", true); + return; + case BridgeDebugHttpDisposition::EmergencyMotionStop: { + gBridgeDebugHttpEmergencyStops++; + BenchControl control; + control.hasMotionEnable = true; + control.motionEnabled = false; + const bool accepted = publishMotionControl(control); + if (accepted) { + serveBridgeDebugAdmissionJson(client, 202, "Accepted", true); + } else { + serveBridgeDebugAdmissionJson(client, 503, "Service Unavailable", false); + } + return; + } + case BridgeDebugHttpDisposition::CameraGray: + gBridgeDebugHttpCameraRoutes++; #if STACKCHAN_ENABLE_CAMERA_HOST_VISION - if (strncmp(requestTarget, "/camera-gray.pgm?", 17) == 0) { - serveCameraGrayFrame(client, requestTarget); - return; - } - if (strncmp(requestTarget, "/vision-target?", 15) == 0) { - serveCameraVisionTarget(client, requestTarget); - return; - } -#endif - const bool speakerToneRequest = - strcmp(requestTarget, "/tone") == 0 || strcmp(requestTarget, "/speaker-test") == 0; - const bool micToneSoftRequest = - strcmp(requestTarget, "/mic-tone") == 0 || strcmp(requestTarget, "/mic-tone-soft") == 0; - const bool micToneTapRequest = strcmp(requestTarget, "/mic-tone-tap") == 0; - const bool micToneOldRequest = strcmp(requestTarget, "/mic-tone-old") == 0; - const bool micToneRequest = micToneSoftRequest || micToneTapRequest || micToneOldRequest; - const bool wakeResetRequest = strcmp(requestTarget, "/wake-reset") == 0; - const bool audioStopRequest = - strcmp(requestTarget, "/audio-stop") == 0 || strcmp(requestTarget, "/playback-stop") == 0; - const bool motionEnableRequest = - strcmp(requestTarget, "/motion-resume") == 0 || strcmp(requestTarget, "/motion-on") == 0 || - strcmp(requestTarget, "/servos-on") == 0; - const bool motionDisableRequest = - strcmp(requestTarget, "/motion-stop") == 0 || strcmp(requestTarget, "/motion-off") == 0 || - strcmp(requestTarget, "/servos-off") == 0; - const bool motionControlRequest = motionEnableRequest || motionDisableRequest; - const bool recoveryRequest = - strcmp(requestTarget, "/recover") == 0 || strcmp(requestTarget, "/bridge-recover") == 0 || - strcmp(requestTarget, "/wifi-recover") == 0; - const bool rebootRequest = - strcmp(requestTarget, "/reboot") == 0 || strcmp(requestTarget, "/restart") == 0 || - strcmp(requestTarget, "/reset") == 0; - const LanOtaTelemetry& ota = gLanOtaServer.telemetry(); - const bool otaBusy = ota.uploadActive || ota.rebootPending; - bool toneRequestAccepted = false; - bool motionControlRequestAccepted = false; - if (speakerToneRequest && !otaBusy) { - suppressWakeMwwDetections(millis(), 900); - toneRequestAccepted = gSpeakerSink.playDiagnosticTone(); - } else if (micToneSoftRequest && !otaBusy) { - suppressWakeMwwDetections(millis(), 900); - toneRequestAccepted = gSpeakerSink.playMicActivationTone(); - } else if (micToneTapRequest && !otaBusy) { - suppressWakeMwwDetections(millis(), 900); - toneRequestAccepted = gSpeakerSink.playMicActivationTap(); - } else if (micToneOldRequest && !otaBusy) { - suppressWakeMwwDetections(millis(), 900); - toneRequestAccepted = gSpeakerSink.playLegacyMicActivationTone(); - } -#if STACKCHAN_HAS_MWW_WAKE_PROBE - if (wakeResetRequest) { - gWakeMwwResetRequested = true; - } -#endif - if (audioStopRequest) { - gBridgeAudioRemoteStopRequests++; - stopBridgeAudioRuntime(nowMs, BridgeAudioSafetyStopReason::RemoteRequest); - } - if (motionControlRequest) { - BenchControl control; - control.hasMotionEnable = true; - control.motionEnabled = motionEnableRequest; - motionControlRequestAccepted = publishMotionControl(control); - } -#if defined(ARDUINO_ARCH_ESP32) && STACKCHAN_REMOTE_RECOVERY_ENABLE != 0 - if (recoveryRequest) { - gBridgeRecovery.recoveryRequested = true; - gBridgeRecovery.scheduledRecoveryMs = millis() + STACKCHAN_REMOTE_RECOVERY_DELAY_MS; - gBridgeRecovery.lastReason = "remote_recover"; - } - if (rebootRequest) { - gBridgeRecovery.rebootRequested = true; - requestBridgeReboot("remote_reboot", millis()); - } + serveCameraGrayFrame(client, requestTarget); +#else + serveBridgeDebugRejectionJson(client, 404); #endif -#if STACKCHAN_HAS_MWW_WAKE_PROBE - if (strcmp(requestTarget, "/wake.wav") == 0 || strcmp(requestTarget, "/wake-pcm.wav") == 0) { - serveWakeMwwPcmWav(client); - client.flush(); - delay(1); - client.stop(); - return; - } + return; + case BridgeDebugHttpDisposition::CameraVision: + gBridgeDebugHttpCameraRoutes++; +#if STACKCHAN_ENABLE_CAMERA_HOST_VISION + serveCameraVisionTarget(client, requestTarget); +#else + serveBridgeDebugRejectionJson(client, 404); #endif - if (strcmp(requestTarget, "/debug") == 0) { - serveBridgeLeanStatusJson( - client, - "stackchan.bridge-debug.v1", - requestTarget, - speakerToneRequest, - micToneRequest, - wakeResetRequest, - motionControlRequest, - motionEnableRequest, - motionControlRequestAccepted, - toneRequestAccepted); - return; + return; + case BridgeDebugHttpDisposition::RejectBadRequest: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, decision.statusCode); + return; + case BridgeDebugHttpDisposition::RejectForbidden: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, decision.statusCode); + return; + case BridgeDebugHttpDisposition::RejectNotFound: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, decision.statusCode); + return; + case BridgeDebugHttpDisposition::RejectMethod: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, decision.statusCode); + return; + case BridgeDebugHttpDisposition::RejectUriTooLong: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, decision.statusCode); + return; + default: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, 400); + return; } - - serveBridgeLeanStatusJson( - client, - "stackchan.bridge-status.v1", - requestTarget, - speakerToneRequest, - micToneRequest, - wakeResetRequest, - motionControlRequest, - motionEnableRequest, - motionControlRequestAccepted, - toneRequestAccepted); - return; #endif } diff --git a/test/test_native_logic/test_main.cpp b/test/test_native_logic/test_main.cpp index d50940cd..e724c632 100644 --- a/test/test_native_logic/test_main.cpp +++ b/test/test_native_logic/test_main.cpp @@ -17,6 +17,7 @@ #include "io/BridgeAudioUplink.hpp" #include "io/BridgeClient.hpp" #include "io/BridgeEndpointControl.hpp" +#include "io/BridgeDebugHttpPolicy.hpp" #include "io/BridgeEndpointRegistry.hpp" #include "io/BridgeEndpointStore.hpp" #include "io/BridgeNetworkSession.hpp" @@ -8084,8 +8085,314 @@ void test_bridge_endpoint_control_persists_pairing_and_forget_when_store_attache TEST_ASSERT_EQUAL_UINT32(3, store.telemetry().saves); } +BridgeDebugHttpDecision evaluateDebugHttpFixture(const char* requestLine, + bool lineComplete = true, + bool lineOverflow = false, + bool lineInvalid = false) { + char requestTarget[224] = {}; + return evaluateBridgeDebugHttpRequestLine( + requestLine, lineComplete, lineOverflow, lineInvalid, requestTarget, sizeof(requestTarget)); +} + +void assertDebugHttpDecision(const char* requestLine, + BridgeDebugHttpRoute expectedRoute, + BridgeDebugHttpDisposition expectedDisposition, + uint16_t expectedStatus) { + const BridgeDebugHttpDecision decision = evaluateDebugHttpFixture(requestLine); + TEST_ASSERT_EQUAL_INT(static_cast(expectedRoute), static_cast(decision.route)); + TEST_ASSERT_EQUAL_INT(static_cast(expectedDisposition), + static_cast(decision.disposition)); + TEST_ASSERT_EQUAL_UINT16(expectedStatus, decision.statusCode); +} + +void test_bridge_debug_http_policy_allows_only_operational_and_emergency_routes() { + for (const char* version : {"HTTP/1.0", "HTTP/1.1"}) { + assertDebugHttpDecision((std::string("GET / ") + version).c_str(), + BridgeDebugHttpRoute::Root, + BridgeDebugHttpDisposition::ServeStatus, + 200); + assertDebugHttpDecision((std::string("GET /debug ") + version).c_str(), + BridgeDebugHttpRoute::Debug, + BridgeDebugHttpDisposition::ServeDebug, + 200); + for (const char* method : {"GET", "POST"}) { + for (const char* route : {"/audio-stop", "/playback-stop"}) { + const std::string line = std::string(method) + " " + route + " " + version; + assertDebugHttpDecision(line.c_str(), + BridgeDebugHttpRoute::AudioStop, + BridgeDebugHttpDisposition::EmergencyAudioStop, + 202); + } + for (const char* route : {"/motion-stop", "/motion-off", "/servos-off"}) { + const std::string line = std::string(method) + " " + route + " " + version; + assertDebugHttpDecision(line.c_str(), + BridgeDebugHttpRoute::MotionStop, + BridgeDebugHttpDisposition::EmergencyMotionStop, + 202); + } + } + } + + for (const char* version : {"HTTP/1.0", "HTTP/1.1"}) { + for (const char* route : {"/", "/debug"}) { + for (const char* method : {"POST", "HEAD", "PUT", "DELETE", "PATCH", "OPTIONS", "CUSTOM"}) { + const std::string line = std::string(method) + " " + route + " " + version; + const BridgeDebugHttpDecision decision = evaluateDebugHttpFixture(line.c_str()); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectMethod), + static_cast(decision.disposition)); + TEST_ASSERT_EQUAL_UINT16(405, decision.statusCode); + } + } + for (const char* route : {"/audio-stop", "/playback-stop", "/motion-stop", "/motion-off", + "/servos-off"}) { + for (const char* method : {"HEAD", "PUT", "DELETE", "PATCH", "OPTIONS", "CUSTOM"}) { + const std::string line = std::string(method) + " " + route + " " + version; + const BridgeDebugHttpDecision decision = evaluateDebugHttpFixture(line.c_str()); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectMethod), + static_cast(decision.disposition)); + TEST_ASSERT_EQUAL_UINT16(405, decision.statusCode); + } + } + + for (const char* route : {"/", "/debug", "/audio-stop", "/playback-stop", "/motion-stop", + "/motion-off", "/servos-off"}) { + for (const char* method : {"GET", "POST"}) { + for (const char* query : {"?", "?probe=1"}) { + const std::string line = std::string(method) + " " + route + query + " " + version; + const BridgeDebugHttpDecision decision = evaluateDebugHttpFixture(line.c_str()); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectBadRequest), + static_cast(decision.disposition)); + TEST_ASSERT_EQUAL_UINT16(400, decision.statusCode); + } + } + } + } +} + +void test_bridge_debug_http_policy_rejects_unsafe_aliases_for_every_supported_method_and_query() { + const char* unsafeRoutes[] = { + "/tone", "/speaker-test", "/mic-tone", "/mic-tone-soft", + "/mic-tone-tap", "/mic-tone-old", "/wake-reset", "/motion-resume", + "/motion-on", "/servos-on", "/recover", "/bridge-recover", + "/wifi-recover", "/reboot", "/restart", "/reset", + "/wake.wav", "/wake-pcm.wav", + }; + for (const char* version : {"HTTP/1.0", "HTTP/1.1"}) { + for (const char* route : unsafeRoutes) { + for (const char* method : {"GET", "POST", "HEAD", "PUT", "DELETE", "PATCH", "OPTIONS", + "CUSTOM"}) { + for (const char* suffix : {"", "?", "?probe=1", "?x=1&y=2"}) { + const std::string line = + std::string(method) + " " + route + suffix + " " + version; + const BridgeDebugHttpDecision decision = evaluateDebugHttpFixture(line.c_str()); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectForbidden), + static_cast(decision.disposition)); + TEST_ASSERT_EQUAL_UINT16(403, decision.statusCode); + } + } + } + } +} + +void test_bridge_debug_http_policy_rejects_malformed_and_near_match_requests() { + for (const char* malformed : {"", " GET / HTTP/1.1", "GET /", "GET / HTTP/1.1", + "get / HTTP/1.1", "GET / HTTP/1.2", "GET / HTTP/1.1 extra", + "GET http://robot/ HTTP/1.1", "GET /\x01 HTTP/1.1", + "GET / HTTP/1.1 ", "GET\t/ HTTP/1.1", "GET /\tHTTP/1.1", + "GET / HTTP/1.0 ", "GET\t/ HTTP/1.0", "GET /\tHTTP/1.0", + "GET / HTTP/1.0 extra", + "GE\x01T / HTTP/1.1", "GET / HTTP/1.\x01", + "GET / HTTP/1.1\x7f", + "METHODNAMELONGERTHANSIXTEEN / HTTP/1.1"}) { + const BridgeDebugHttpDecision decision = evaluateDebugHttpFixture(malformed); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectBadRequest), + static_cast(decision.disposition)); + TEST_ASSERT_EQUAL_UINT16(400, decision.statusCode); + } + for (const char* version : {"HTTP/1.0", "HTTP/1.1"}) { + for (const std::string& malformed : { + std::string(" GET / ") + version, + std::string("GET / ") + version, + std::string("get / ") + version, + std::string("GET / ") + version + " ", + std::string("GET\t/ ") + version, + std::string("GET /\t") + version, + std::string("GE\x01T / ") + version, + std::string("GET /\x01 ") + version, + std::string("GET / ") + version + "\x7f", + }) { + const BridgeDebugHttpDecision decision = evaluateDebugHttpFixture(malformed.c_str()); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectBadRequest), + static_cast(decision.disposition)); + TEST_ASSERT_EQUAL_UINT16(400, decision.statusCode); + } + } + const BridgeDebugHttpDecision incomplete = + evaluateDebugHttpFixture("GET / HTTP/1.1", false, false); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectBadRequest), + static_cast(incomplete.disposition)); + TEST_ASSERT_EQUAL_UINT16(400, incomplete.statusCode); + const BridgeDebugHttpDecision overflow = + evaluateDebugHttpFixture("GET / HTTP/1.1", true, true); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectUriTooLong), + static_cast(overflow.disposition)); + TEST_ASSERT_EQUAL_UINT16(414, overflow.statusCode); + for (const char* admittedPrefix : {"GET / HTTP/1.0", "GET /debug HTTP/1.1"}) { + const BridgeDebugHttpDecision invalidCapture = + evaluateDebugHttpFixture(admittedPrefix, true, false, true); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectBadRequest), + static_cast(invalidCapture.disposition)); + TEST_ASSERT_EQUAL_UINT16(400, invalidCapture.statusCode); + } + + for (const char* version : {"HTTP/1.0", "HTTP/1.1"}) { + for (const char* badQuery : {"/?probe=1", "/debug?probe=1", + "/motion-stop?probe=1", "/not-a-route?probe=1"}) { + const std::string line = std::string("GET ") + badQuery + " " + version; + assertDebugHttpDecision(line.c_str(), + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectBadRequest, + 400); + } + for (const char* nearPath : {"/debug/", "/motion-stop-extra", "/motion-sto", + "/motion-stop#fragment", "/%6dotion-stop", "/x/debug", + "/x/motion-stop", "/x/motion-resume"}) { + const std::string line = std::string("GET ") + nearPath + " " + version; + assertDebugHttpDecision(line.c_str(), + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectNotFound, + 404); + } + } + for (const char* version : {"HTTP/1.0", "HTTP/1.1"}) { + for (const char* method : {"GET", "POST", "HEAD", "PUT", "DELETE", "PATCH", "OPTIONS", + "CUSTOM"}) { + const std::string line = std::string(method) + " /not-a-route " + version; + assertDebugHttpDecision(line.c_str(), + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectNotFound, + 404); + } + } + + for (const char* allowedPath : {"/", "/debug", "/audio-stop", "/playback-stop", + "/motion-stop", "/motion-off", "/servos-off"}) { + for (const char* version : {"HTTP/1.0", "HTTP/1.1"}) { + for (const char* nearSuffix : {"/", "#fragment", "-extra"}) { + const std::string line = + std::string("GET ") + allowedPath + nearSuffix + " " + version; + const BridgeDebugHttpDecision decision = evaluateDebugHttpFixture(line.c_str()); + TEST_ASSERT_TRUE(decision.disposition != BridgeDebugHttpDisposition::ServeStatus); + TEST_ASSERT_TRUE(decision.disposition != BridgeDebugHttpDisposition::ServeDebug); + TEST_ASSERT_TRUE(decision.disposition != BridgeDebugHttpDisposition::EmergencyAudioStop); + TEST_ASSERT_TRUE(decision.disposition != BridgeDebugHttpDisposition::EmergencyMotionStop); + } + } + } + + for (const char* version : {"HTTP/1.0", "HTTP/1.1"}) { + const std::string maximumTarget = "/" + std::string(222, 'a'); + const BridgeDebugHttpDecision maximumDecision = evaluateDebugHttpFixture( + ("GET " + maximumTarget + " " + version).c_str()); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectNotFound), + static_cast(maximumDecision.disposition)); + TEST_ASSERT_EQUAL_UINT16(404, maximumDecision.statusCode); + + const std::string oversizedTarget = "/" + std::string(223, 'a'); + const BridgeDebugHttpDecision oversizedDecision = evaluateDebugHttpFixture( + ("GET " + oversizedTarget + " " + version).c_str()); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectUriTooLong), + static_cast(oversizedDecision.disposition)); + TEST_ASSERT_EQUAL_UINT16(414, oversizedDecision.statusCode); + } +} + +void test_bridge_debug_http_policy_preserves_paired_camera_family_dispatch() { + constexpr const char* kGrayTarget = "/camera-gray.pgm?p=000000"; + constexpr const char* kVisionTarget = "/vision-target?p=000000&f="; + for (const char* version : {"HTTP/1.0", "HTTP/1.1"}) { + for (const char* target : {"/camera-gray.pgm", "/vision-target"}) { + for (const char* method : {"GET", "POST", "HEAD", "PUT", "DELETE", "PATCH", "OPTIONS", + "CUSTOM"}) { + const std::string line = std::string(method) + " " + target + " " + version; + assertDebugHttpDecision(line.c_str(), + BridgeDebugHttpRoute::Unknown, + BridgeDebugHttpDisposition::RejectNotFound, + 404); + } + } + } + for (const char* version : {"HTTP/1.0", "HTTP/1.1"}) { + for (const char* target : {kGrayTarget, kVisionTarget}) { + char returnedTarget[224] = {}; + const std::string line = std::string("GET ") + target + " " + version; + const BridgeDebugHttpDecision decision = evaluateBridgeDebugHttpRequestLine( + line.c_str(), true, false, false, returnedTarget, sizeof(returnedTarget)); + TEST_ASSERT_TRUE(std::strcmp(target, returnedTarget) == 0); + TEST_ASSERT_TRUE( + (std::strcmp(target, kGrayTarget) == 0 && decision.route == BridgeDebugHttpRoute::CameraGray && + decision.disposition == BridgeDebugHttpDisposition::CameraGray) || + (std::strcmp(target, kVisionTarget) == 0 && decision.route == BridgeDebugHttpRoute::CameraVision && + decision.disposition == BridgeDebugHttpDisposition::CameraVision)); + TEST_ASSERT_EQUAL_UINT16(0, decision.statusCode); + } + } + char parsedPairing[7] = {}; + TEST_ASSERT_TRUE(parseCameraHostPairingCode( + kGrayTarget, "/camera-gray.pgm", parsedPairing, sizeof(parsedPairing))); + CameraHostVisionTarget parsedVision; + TEST_ASSERT_TRUE(parseCameraHostVisionTarget(kVisionTarget, &parsedVision)); + + for (const char* version : {"HTTP/1.0", "HTTP/1.1"}) { + assertDebugHttpDecision((std::string("GET /camera-gray.pgm?malformed ") + version).c_str(), + BridgeDebugHttpRoute::CameraGray, + BridgeDebugHttpDisposition::CameraGray, + 0); + assertDebugHttpDecision((std::string("GET /vision-target?malformed ") + version).c_str(), + BridgeDebugHttpRoute::CameraVision, + BridgeDebugHttpDisposition::CameraVision, + 0); + for (const char* target : {kGrayTarget, kVisionTarget, "/camera-gray.pgm?malformed", + "/vision-target?malformed"}) { + for (const char* method : {"POST", "HEAD", "PUT", "DELETE", "PATCH", "OPTIONS", "CUSTOM"}) { + const std::string line = std::string(method) + " " + target + " " + version; + const BridgeDebugHttpDecision decision = evaluateDebugHttpFixture(line.c_str()); + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectMethod), + static_cast(decision.disposition)); + TEST_ASSERT_EQUAL_UINT16(405, decision.statusCode); + } + } + } +} + +void test_bridge_debug_http_policy_does_not_return_raw_denied_target_or_query() { + char returnedTarget[224]; + std::memset(returnedTarget, 'x', sizeof(returnedTarget)); + returnedTarget[sizeof(returnedTarget) - 1] = '\0'; + const BridgeDebugHttpDecision decision = evaluateBridgeDebugHttpRequestLine( + "GET /motion-resume?sentinel_do_not_emit=1 HTTP/1.1", + true, + false, + false, + returnedTarget, + sizeof(returnedTarget)); + + TEST_ASSERT_EQUAL_INT(static_cast(BridgeDebugHttpDisposition::RejectForbidden), + static_cast(decision.disposition)); + TEST_ASSERT_TRUE(returnedTarget[0] == '\0'); + TEST_ASSERT_TRUE(std::strstr(bridgeDebugHttpMethodName(decision.method), "sentinel") == nullptr); + TEST_ASSERT_TRUE(std::strstr(bridgeDebugHttpRouteName(decision.route), "sentinel") == nullptr); + TEST_ASSERT_TRUE( + std::strstr(bridgeDebugHttpDispositionName(decision.disposition), "sentinel") == nullptr); +} + int main() { UNITY_BEGIN(); + RUN_TEST(test_bridge_debug_http_policy_allows_only_operational_and_emergency_routes); + RUN_TEST(test_bridge_debug_http_policy_rejects_unsafe_aliases_for_every_supported_method_and_query); + RUN_TEST(test_bridge_debug_http_policy_rejects_malformed_and_near_match_requests); + RUN_TEST(test_bridge_debug_http_policy_preserves_paired_camera_family_dispatch); + RUN_TEST(test_bridge_debug_http_policy_does_not_return_raw_denied_target_or_query); RUN_TEST(test_spring_converges_without_exploding); RUN_TEST(test_dt_clamp_limits_large_step); RUN_TEST(test_wake_word_increases_arousal_and_focus); diff --git a/tools/camera_follow_wake_validation.ps1 b/tools/camera_follow_wake_validation.ps1 index 0865f720..e1a932d1 100644 --- a/tools/camera_follow_wake_validation.ps1 +++ b/tools/camera_follow_wake_validation.ps1 @@ -16,10 +16,51 @@ param( [switch]$OperatorPresent, [switch]$BodyClear, [switch]$ConfirmServoRisk, - [switch]$Json + [switch]$Json, + [switch]$ControlPolicyContractProbe ) $ErrorActionPreference = "Stop" + +function Assert-EmergencyStopOnlyMotionPolicy { + param([string]$Policy) + throw "motion_resume_unavailable: emergency_stop_only permits emergency stops only; motion resume is disabled." +} + +if ($ControlPolicyContractProbe) { + Assert-EmergencyStopOnlyMotionPolicy -Policy "emergency_stop_only" +} + +function Invoke-RobotEndpoint { + param([string]$Path, [int]$TimeoutSeconds = 4) + $url = "http://$DeviceHost`:$DevicePort$Path" + $body = & curl.exe --max-time $TimeoutSeconds -s $url + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0 -or [string]::IsNullOrWhiteSpace($body)) { + return [pscustomobject]@{ ok = $false; curlExit = $exitCode; body = $body; json = $null } + } + try { + return [pscustomobject]@{ ok = $true; curlExit = $exitCode; body = $body; json = ($body | ConvertFrom-Json) } + } catch { + return [pscustomobject]@{ ok = $false; curlExit = $exitCode; body = $body; json = $null; error = $_.Exception.Message } + } +} + +function Get-FirmwareHttpControlPolicy { + $probe = Invoke-RobotEndpoint "/debug" 4 + if ($null -eq $probe -or $probe.ok -ne $true -or $null -eq $probe.json) { + return "unknown" + } + $policy = $probe.json.debug_http_control_policy + if ($policy -is [string] -and $policy -ceq "emergency_stop_only") { + return $policy + } + return "unknown" +} + +$controlPolicyPreflight = Get-FirmwareHttpControlPolicy +Assert-EmergencyStopOnlyMotionPolicy -Policy $controlPolicyPreflight + $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $RepoRoot $SourceCommit = (& git rev-parse HEAD).Trim() @@ -67,21 +108,6 @@ function Write-JsonAtomic { } } -function Invoke-RobotEndpoint { - param([string]$Path, [int]$TimeoutSeconds = 4) - $url = "http://$DeviceHost`:$DevicePort$Path" - $body = & curl.exe --max-time $TimeoutSeconds -s $url - $exitCode = $LASTEXITCODE - if ($exitCode -ne 0 -or [string]::IsNullOrWhiteSpace($body)) { - return [pscustomobject]@{ ok = $false; curlExit = $exitCode; body = $body; json = $null } - } - try { - return [pscustomobject]@{ ok = $true; curlExit = $exitCode; body = $body; json = ($body | ConvertFrom-Json) } - } catch { - return [pscustomobject]@{ ok = $false; curlExit = $exitCode; body = $body; json = $null; error = $_.Exception.Message } - } -} - function Get-BridgeSocketRemote { try { $socket = Get-NetTCPConnection -LocalPort $BridgeLocalPort -State Established -ErrorAction SilentlyContinue | diff --git a/tools/run_full_system_soak_http_motion.ps1 b/tools/run_full_system_soak_http_motion.ps1 index de755026..b9eac527 100644 --- a/tools/run_full_system_soak_http_motion.ps1 +++ b/tools/run_full_system_soak_http_motion.ps1 @@ -51,32 +51,19 @@ param( [switch]$RequireNoNewHardFloorEvents, [switch]$RequireManagedChargePolicy, [switch]$FailFastOnStrictBreach, - [switch]$NoSerial + [switch]$NoSerial, + [switch]$ControlPolicyContractProbe ) $ErrorActionPreference = "Stop" -$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") -Set-Location $RepoRoot -$RunnerSourceCommit = (& git rev-parse HEAD).Trim().ToLowerInvariant() -$SourceDirty = -not [string]::IsNullOrWhiteSpace(((& git status --porcelain=v1 --untracked-files=normal) -join "`n")) -if ([string]::IsNullOrWhiteSpace($FirmwareSourceCommit)) { - $FirmwareSourceCommit = $RunnerSourceCommit -} -$SourceCommit = $FirmwareSourceCommit.Trim().ToLowerInvariant() -if ($SourceCommit -notmatch "^[0-9a-f]{40}$") { - throw "FirmwareSourceCommit must be a full 40-character Git commit SHA." -} -& git cat-file -e "$SourceCommit`^{commit}" 2>$null -if ($LASTEXITCODE -ne 0) { - throw "FirmwareSourceCommit is not available in this repository: $SourceCommit" + +function Assert-EmergencyStopOnlyMotionPolicy { + param([string]$Policy) + throw "motion_resume_unavailable: emergency_stop_only permits emergency stops only; motion resume is disabled." } -$minPowerVbusMvThreshold = $MinPowerVbusMv -$minPowerVbusReportedMvThreshold = $MinPowerVbusReportedMv -if ($ExpectedPmicVindpmMv -ne 0 -and - ($ExpectedPmicVindpmMv -lt 3880 -or $ExpectedPmicVindpmMv -gt 5080 -or - (($ExpectedPmicVindpmMv - 3880) % 80) -ne 0)) { - throw "ExpectedPmicVindpmMv must be 0 or an 80 mV step from 3880 through 5080." +if ($ControlPolicyContractProbe) { + Assert-EmergencyStopOnlyMotionPolicy -Policy "emergency_stop_only" } function Invoke-RobotEndpoint { @@ -111,6 +98,45 @@ function Invoke-RobotEndpoint { } } +function Get-FirmwareHttpControlPolicy { + $probe = Invoke-RobotEndpoint -Path "/debug" -TimeoutSeconds 4 + if ($null -eq $probe -or $probe.ok -ne $true -or $null -eq $probe.json) { + return "unknown" + } + $policy = $probe.json.debug_http_control_policy + if ($policy -is [string] -and $policy -ceq "emergency_stop_only") { + return $policy + } + return "unknown" +} + +$controlPolicyPreflight = Get-FirmwareHttpControlPolicy +Assert-EmergencyStopOnlyMotionPolicy -Policy $controlPolicyPreflight + +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $RepoRoot +$RunnerSourceCommit = (& git rev-parse HEAD).Trim().ToLowerInvariant() +$SourceDirty = -not [string]::IsNullOrWhiteSpace(((& git status --porcelain=v1 --untracked-files=normal) -join "`n")) +if ([string]::IsNullOrWhiteSpace($FirmwareSourceCommit)) { + $FirmwareSourceCommit = $RunnerSourceCommit +} +$SourceCommit = $FirmwareSourceCommit.Trim().ToLowerInvariant() +if ($SourceCommit -notmatch "^[0-9a-f]{40}$") { + throw "FirmwareSourceCommit must be a full 40-character Git commit SHA." +} +& git cat-file -e "$SourceCommit`^{commit}" 2>$null +if ($LASTEXITCODE -ne 0) { + throw "FirmwareSourceCommit is not available in this repository: $SourceCommit" +} + +$minPowerVbusMvThreshold = $MinPowerVbusMv +$minPowerVbusReportedMvThreshold = $MinPowerVbusReportedMv +if ($ExpectedPmicVindpmMv -ne 0 -and + ($ExpectedPmicVindpmMv -lt 3880 -or $ExpectedPmicVindpmMv -gt 5080 -or + (($ExpectedPmicVindpmMv - 3880) % 80) -ne 0)) { + throw "ExpectedPmicVindpmMv must be 0 or an 80 mV step from 3880 through 5080." +} + function Get-BridgeSocketRemote { param([string]$LocalPort) try { diff --git a/tools/start_warm_rocm_full_system_soak.ps1 b/tools/start_warm_rocm_full_system_soak.ps1 index ef11153f..c8c4c461 100644 --- a/tools/start_warm_rocm_full_system_soak.ps1 +++ b/tools/start_warm_rocm_full_system_soak.ps1 @@ -35,10 +35,47 @@ param( [switch]$RequireFinalIntegration, [switch]$RequireStableCameraTarget, [switch]$AllowExternalImuEvents, - [switch]$AllowLegacyMotionTelemetry + [switch]$AllowLegacyMotionTelemetry, + [switch]$ControlPolicyContractProbe ) $ErrorActionPreference = "Stop" + +function Assert-EmergencyStopOnlyMotionPolicy { + param([string]$Policy) + throw "motion_resume_unavailable: emergency_stop_only permits emergency stops only; motion resume is disabled." +} + +if ($ControlPolicyContractProbe) { + Assert-EmergencyStopOnlyMotionPolicy -Policy "emergency_stop_only" +} + +function Invoke-JsonEndpoint { + param([string]$Path, [int]$TimeoutSeconds = 5) + $url = "http://$DeviceHost`:$DevicePort$Path" + try { + return Invoke-RestMethod -Uri $url -TimeoutSec $TimeoutSeconds + } catch { + throw "Robot endpoint failed: $url :: $($_.Exception.Message)" + } +} + +function Get-FirmwareHttpControlPolicy { + try { + $probe = Invoke-JsonEndpoint -Path "/debug" -TimeoutSeconds 5 + } catch { + return "unknown" + } + $policy = $probe.debug_http_control_policy + if ($policy -is [string] -and $policy -ceq "emergency_stop_only") { + return $policy + } + return "unknown" +} + +$controlPolicyPreflight = Get-FirmwareHttpControlPolicy +Assert-EmergencyStopOnlyMotionPolicy -Policy $controlPolicyPreflight + if ([string]::IsNullOrWhiteSpace($DeviceHost)) { throw "DeviceHost is required before the physical soak wrapper performs any action." } @@ -67,16 +104,6 @@ if ($workerUri.Scheme -ne "http" -or $workerUri.Host -notin @("127.0.0.1", "loca } $RvcWorkerUrl = $RvcWorkerUrl.TrimEnd("/") -function Invoke-JsonEndpoint { - param([string]$Path, [int]$TimeoutSeconds = 5) - $url = "http://$DeviceHost`:$DevicePort$Path" - try { - return Invoke-RestMethod -Uri $url -TimeoutSec $TimeoutSeconds - } catch { - throw "Robot endpoint failed: $url :: $($_.Exception.Message)" - } -} - function Wait-ForMotionEnabled { param([int]$TimeoutSeconds = 12) $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) diff --git a/tools/test_camera_follow_wake_validation_contract.ps1 b/tools/test_camera_follow_wake_validation_contract.ps1 index de3633bb..dfda6f8c 100644 --- a/tools/test_camera_follow_wake_validation_contract.ps1 +++ b/tools/test_camera_follow_wake_validation_contract.ps1 @@ -13,6 +13,9 @@ Add-Check "script-present" (Test-Path -LiteralPath $ScriptPath -PathType Leaf) " $source = if (Test-Path -LiteralPath $ScriptPath -PathType Leaf) { Get-Content -LiteralPath $ScriptPath -Raw } else { "" } $requiredFragments = @( "OperatorPresent", "BodyClear", "ConfirmServoRisk", + "debug_http_control_policy", "emergency_stop_only", "motion_resume_unavailable", + 'ControlPolicyContractProbe', 'Get-FirmwareHttpControlPolicy', + 'Assert-EmergencyStopOnlyMotionPolicy -Policy $controlPolicyPreflight', 'Invoke-RobotEndpoint "/motion-resume"', 'Invoke-RobotEndpoint "/motion-stop"', "Get-NetTCPConnection", "bridge_socket_missing", "sourceCommit", "installedFirmwareSha256", @@ -26,13 +29,21 @@ $requiredFragments = @( foreach ($fragment in $requiredFragments) { Add-Check "source-$($fragment -replace '[^A-Za-z0-9]+','-')" ($source.Contains($fragment)) "fragment=$fragment" } +$policyIndex = $source.IndexOf('Assert-EmergencyStopOnlyMotionPolicy -Policy $controlPolicyPreflight') +$resumeIndex = $source.IndexOf('Invoke-RobotEndpoint "/motion-resume"') +$evidenceIndex = $source.IndexOf('New-Item -ItemType Directory -Force -Path $EvidenceRoot') +Add-Check "policy-before-resume-and-evidence" ($policyIndex -ge 0 -and $resumeIndex -ge 0 -and + $evidenceIndex -ge 0 -and $policyIndex -lt $resumeIndex -and $policyIndex -lt $evidenceIndex) "policy=$policyIndex resume=$resumeIndex evidence=$evidenceIndex" +$refusalBranch = [regex]::Match($source, 'throw\s+"motion_resume_unavailable[^"\r\n]*"') +Add-Check "policy-refusal-throws" $refusalBranch.Success "throw=motion_resume_unavailable" $savedErrorAction = $ErrorActionPreference $ErrorActionPreference = "Continue" -$refusalOutput = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $ScriptPath 2>&1 | Out-String +$refusalOutput = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $ScriptPath -ControlPolicyContractProbe 2>&1 | Out-String $refusalExit = $LASTEXITCODE $ErrorActionPreference = $savedErrorAction -Add-Check "missing-attestation-refused" ($refusalExit -ne 0 -and $refusalOutput.Contains("Refusing camera-follow motor validation")) "exit=$refusalExit" +Add-Check "contained-policy-refused-before-attestation" ($refusalExit -ne 0 -and + $refusalOutput.Contains("motion_resume_unavailable")) "exit=$refusalExit" $failed = @($checks | Where-Object { $_.status -eq "fail" }).Count $result = [ordered]@{ diff --git a/tools/test_firmware_http_control_policy_contract.ps1 b/tools/test_firmware_http_control_policy_contract.ps1 new file mode 100644 index 00000000..6c48b693 --- /dev/null +++ b/tools/test_firmware_http_control_policy_contract.ps1 @@ -0,0 +1,1089 @@ +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$policyHeader = Join-Path $repoRoot "src\io\BridgeDebugHttpPolicy.hpp" +$policySource = Join-Path $repoRoot "src\io\BridgeDebugHttpPolicy.cpp" +$mainPath = Join-Path $repoRoot "src\main.cpp" +$platformioPath = Join-Path $repoRoot "platformio.ini" +$issues = [System.Collections.Generic.List[string]]::new() + +function Require-PolicyAssertion([bool]$Condition, [string]$Message) { + if (-not $Condition) { $issues.Add($Message) } +} + +function Normalize-CppSource([string]$Text) { + return ($Text -replace '\s+', '') +} + +function Normalize-ExactSource([string]$Text) { + return (($Text -replace "`r`n", "`n").Trim()) +} + +function Normalize-PowerShellSource([string]$Text) { + return ($Text -replace '\s+', '') +} + +function Get-NormalizedSourceSha256([string]$Text) { + $normalized = $Text -replace "`r`n", "`n" + $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($normalized) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '') + } finally { + $sha.Dispose() + } +} + +$headerText = if (Test-Path -LiteralPath $policyHeader -PathType Leaf) { + Get-Content -LiteralPath $policyHeader -Raw +} else { + $issues.Add("mandatory-policy: BridgeDebugHttpPolicy.hpp is missing") + "" +} +$policyText = if (Test-Path -LiteralPath $policySource -PathType Leaf) { + Get-Content -LiteralPath $policySource -Raw +} else { + $issues.Add("mandatory-policy: BridgeDebugHttpPolicy.cpp is missing") + "" +} +$mainText = Get-Content -LiteralPath $mainPath -Raw +$platformioText = Get-Content -LiteralPath $platformioPath -Raw + +Require-PolicyAssertion ((Get-NormalizedSourceSha256 $mainText) -ceq + 'A0485CE74DD0AD31B105A61D217D915F2A4294469CEDE9166843A056336958A8') "invariant: src/main.cpp differs from the exact reviewed SEC-002 transformation" +Require-PolicyAssertion ((Get-NormalizedSourceSha256 $headerText) -ceq + 'C3F0D76E398972D43643EB4E2A0C4F1098BEB4E7F810B92E4B0ED190BC0E1D26') "pre-effect: policy header differs from the exact reviewed pure API" +Require-PolicyAssertion ((Get-NormalizedSourceSha256 $policyText) -ceq + '842B7A69F0946A6481652F5F94C9767190C6270532D69E06F006600E03B4AB30') "pre-effect: policy source differs from the exact reviewed pure call graph" + +Require-PolicyAssertion ($policyText.Contains('bool isQueryFamily(') -and + ([regex]::Matches($policyText, 'isQueryFamily\s*\(')).Count -eq 3 -and + $policyText.Contains('isQueryFamily(target, targetLength, "/camera-gray.pgm")') -and + $policyText.Contains('isQueryFamily(target, targetLength, "/vision-target")')) "camera-invariant: only query-bearing camera families may reach camera dispatch" + +foreach ($required in @( + "BridgeDebugHttpDecision", "BridgeDebugHttpRoute", "BridgeDebugHttpDisposition", + "evaluateBridgeDebugHttpRequestLine", "bridgeDebugHttpMethodName", + "bridgeDebugHttpRouteName", "bridgeDebugHttpDispositionName" +)) { + Require-PolicyAssertion ($headerText.Contains($required) -or $policyText.Contains($required)) "mandatory-policy: missing $required" +} + +$unsafeAliases = @( + "/tone", "/speaker-test", "/mic-tone", "/mic-tone-soft", "/mic-tone-tap", "/mic-tone-old", + "/wake-reset", "/motion-resume", "/motion-on", "/servos-on", "/recover", "/bridge-recover", + "/wifi-recover", "/reboot", "/restart", "/reset", "/wake.wav", "/wake-pcm.wav" +) +$stopAliases = @("/audio-stop", "/playback-stop", "/motion-stop", "/motion-off", "/servos-off") +foreach ($route in @($unsafeAliases + $stopAliases)) { + Require-PolicyAssertion $policyText.Contains('"' + $route + '"') "route-exhaustiveness: missing $route" +} + +Require-PolicyAssertion $platformioText.Contains("+") "mandatory-policy: native source filter omits policy implementation" + +$pollIndex = $mainText.IndexOf("void pollBridgeDebugServer") +Require-PolicyAssertion ($pollIndex -ge 0) "pre-effect: debug poller missing" +if ($pollIndex -ge 0) { + $pollEnd = $mainText.IndexOf("void publishFrame", $pollIndex) + $pollText = if ($pollEnd -gt $pollIndex) { $mainText.Substring($pollIndex, $pollEnd - $pollIndex) } else { $mainText.Substring($pollIndex) } + $expectedPollText = @' +void pollBridgeDebugServer(uint32_t nowMs) { +#if defined(ARDUINO_ARCH_ESP32) + if (!gBridgeWiFi.isConnected()) { + return; + } + if (!gBridgeDebugServerStarted) { + gBridgeDebugServer.begin(); + gBridgeDebugServerStarted = true; + } + + WiFiClient client = gBridgeDebugServer.available(); + if (!client) { + return; + } + client.setTimeout(100); + uint32_t requestStartMs = millis(); + char requestLine[256] = {}; + size_t requestLineLen = 0; + bool firstLineComplete = false; + bool requestLineOverflow = false; + bool requestLineInvalid = false; + bool pendingCarriageReturn = false; + while (client.connected() && requestStartMs != 0 && + millis() - requestStartMs < STACKCHAN_BRIDGE_DEBUG_REQUEST_TIMEOUT_MS) { + while (client.available() > 0) { + const char ch = static_cast(client.read()); + if (!firstLineComplete) { + if (ch == '\n') { + firstLineComplete = true; + pendingCarriageReturn = false; + requestStartMs = 0; + } else if (pendingCarriageReturn) { + requestLineInvalid = true; + pendingCarriageReturn = false; + } else if (ch == '\r') { + pendingCarriageReturn = true; + } else if (static_cast(ch) < 0x20u || + static_cast(ch) == 0x7fu) { + requestLineInvalid = true; + } else if (requestLineLen < sizeof(requestLine) - 1u) { + requestLine[requestLineLen++] = ch; + requestLine[requestLineLen] = '\0'; + } else { + requestLineOverflow = true; + } + } + } + if (requestStartMs != 0) { + delay(1); + } + } + + char requestTarget[224] = {}; + const BridgeDebugHttpDecision decision = evaluateBridgeDebugHttpRequestLine( + requestLine, + firstLineComplete, + requestLineOverflow, + requestLineInvalid, + requestTarget, + sizeof(requestTarget)); + gBridgeDebugHttpRequests++; + switch (decision.disposition) { + case BridgeDebugHttpDisposition::ServeStatus: + serveBridgeLeanStatusJson(client, "stackchan.bridge-status.v1", decision); + return; + case BridgeDebugHttpDisposition::ServeDebug: + serveBridgeLeanStatusJson(client, "stackchan.bridge-debug.v1", decision); + return; + case BridgeDebugHttpDisposition::EmergencyAudioStop: + gBridgeDebugHttpEmergencyStops++; + gBridgeAudioRemoteStopRequests++; + stopBridgeAudioRuntime(nowMs, BridgeAudioSafetyStopReason::RemoteRequest); + serveBridgeDebugAdmissionJson(client, 202, "Accepted", true); + return; + case BridgeDebugHttpDisposition::EmergencyMotionStop: { + gBridgeDebugHttpEmergencyStops++; + BenchControl control; + control.hasMotionEnable = true; + control.motionEnabled = false; + const bool accepted = publishMotionControl(control); + if (accepted) { + serveBridgeDebugAdmissionJson(client, 202, "Accepted", true); + } else { + serveBridgeDebugAdmissionJson(client, 503, "Service Unavailable", false); + } + return; + } + case BridgeDebugHttpDisposition::CameraGray: + gBridgeDebugHttpCameraRoutes++; +#if STACKCHAN_ENABLE_CAMERA_HOST_VISION + serveCameraGrayFrame(client, requestTarget); +#else + serveBridgeDebugRejectionJson(client, 404); +#endif + return; + case BridgeDebugHttpDisposition::CameraVision: + gBridgeDebugHttpCameraRoutes++; +#if STACKCHAN_ENABLE_CAMERA_HOST_VISION + serveCameraVisionTarget(client, requestTarget); +#else + serveBridgeDebugRejectionJson(client, 404); +#endif + return; + case BridgeDebugHttpDisposition::RejectBadRequest: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, decision.statusCode); + return; + case BridgeDebugHttpDisposition::RejectForbidden: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, decision.statusCode); + return; + case BridgeDebugHttpDisposition::RejectNotFound: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, decision.statusCode); + return; + case BridgeDebugHttpDisposition::RejectMethod: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, decision.statusCode); + return; + case BridgeDebugHttpDisposition::RejectUriTooLong: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, decision.statusCode); + return; + default: + gBridgeDebugHttpRejected++; + serveBridgeDebugRejectionJson(client, 400); + return; + } +#endif +} +'@ + Require-PolicyAssertion ((Normalize-ExactSource $pollText) -ceq (Normalize-ExactSource $expectedPollText)) "pre-effect: debug dispatcher differs from the frozen byte-exact classify-then-disposition shape" + $policyIndex = $pollText.IndexOf("evaluateBridgeDebugHttpRequestLine") + $switchIndex = $pollText.IndexOf("switch (decision.disposition)") + Require-PolicyAssertion (([regex]::Matches($pollText, "evaluateBridgeDebugHttpRequestLine")).Count -eq 1) "pre-effect: policy must be evaluated exactly once" + Require-PolicyAssertion ($policyIndex -ge 0 -and $switchIndex -gt $policyIndex) "pre-effect: decision must immediately govern a disposition switch" + Require-PolicyAssertion ($pollText.Contains('bool requestLineOverflow = false;') -and + $pollText.Contains('requestLineOverflow = true;') -and + $pollText.Contains('bool requestLineInvalid = false;') -and + $pollText.Contains('requestLineInvalid = true;') -and + $pollText.Contains('bool pendingCarriageReturn = false;') -and + $pollText.Contains('char requestTarget[224] = {};') -and + $pollText -match '(?s)evaluateBridgeDebugHttpRequestLine\s*\(\s*requestLine\s*,\s*firstLineComplete\s*,\s*requestLineOverflow\s*,\s*requestLineInvalid\s*,\s*requestTarget\s*,\s*sizeof\s*\(\s*requestTarget\s*\)\s*\)') "pre-effect: bounded listener must report completion/overflow/control bytes and start with an empty target" + foreach ($disposition in @( + "ServeStatus", "ServeDebug", "EmergencyAudioStop", "EmergencyMotionStop", "CameraGray", + "CameraVision", "RejectBadRequest", "RejectForbidden", "RejectNotFound", "RejectMethod", + "RejectUriTooLong" + )) { + Require-PolicyAssertion $pollText.Contains("case BridgeDebugHttpDisposition::$disposition") "pre-effect: missing disposition case $disposition" + } + $defaultCaseMatch = [regex]::Match($pollText, '(?s)default\s*:\s*(?gBridgeDebugHttpRejected\+\+\s*;\s*serveBridgeDebugRejectionJson\s*\(\s*client\s*,\s*400\s*\)\s*;\s*return\s*;)\s*\}') + Require-PolicyAssertion $defaultCaseMatch.Success "pre-effect: disposition switch needs a fixed 400 rejection default" + foreach ($legacy in @( + "speakerToneRequest", "micToneSoftRequest", "micToneTapRequest", "micToneOldRequest", + "wakeResetRequest", "motionEnableRequest", "recoveryRequest", "rebootRequest", + "serveWakeMwwPcmWav(client)", "suppressWakeMwwDetections" + )) { + Require-PolicyAssertion (-not $pollText.Contains($legacy)) "pre-effect: legacy inline mutation remains: $legacy" + } + $effectTokens = @("stopBridgeAudioRuntime", "publishMotionControl", "serveCameraGrayFrame", + "serveCameraVisionTarget", "suppressWakeMwwDetections", "gWakeMwwResetRequested", + "gBridgeRecovery", "requestBridgeReboot", "serveWakeMwwPcmWav", "gWakeMwwPcmRing", + "writeWakeWavLe16", "writeWakeWavLe32") + $caseBodies = @{} + foreach ($disposition in @("ServeStatus", "ServeDebug", "EmergencyAudioStop", "EmergencyMotionStop", + "CameraGray", "CameraVision", "RejectBadRequest", "RejectForbidden", "RejectNotFound", + "RejectMethod", "RejectUriTooLong")) { + $match = [regex]::Match( + $pollText, + '(?s)case\s+BridgeDebugHttpDisposition::' + [regex]::Escape($disposition) + + '\s*:\s*(?.*?)(?=case\s+BridgeDebugHttpDisposition::|default\s*:|\}\s*$)' + ) + Require-PolicyAssertion $match.Success "pre-effect: cannot isolate disposition case $disposition" + $caseBodies[$disposition] = if ($match.Success) { $match.Groups['body'].Value } else { "" } + Require-PolicyAssertion ($match.Success -and $match.Groups['body'].Value -match '(?s)\breturn\s*;\s*\}?\s*$') "pre-effect: disposition case $disposition can fall through" + } + foreach ($rejectCase in @("RejectBadRequest", "RejectForbidden", "RejectNotFound", "RejectMethod", "RejectUriTooLong")) { + foreach ($effect in $effectTokens) { + Require-PolicyAssertion (-not $caseBodies[$rejectCase].Contains($effect)) "pre-effect: rejection case $rejectCase contains effect $effect" + } + } + foreach ($readCase in @("ServeStatus", "ServeDebug")) { + foreach ($effect in $effectTokens) { + Require-PolicyAssertion (-not $caseBodies[$readCase].Contains($effect)) "pre-effect: status case $readCase contains effect $effect" + } + } + foreach ($closedCase in @("RejectBadRequest", "RejectForbidden", "RejectNotFound", "RejectMethod", "RejectUriTooLong")) { + $calls = @([regex]::Matches($caseBodies[$closedCase], '(?m)(?:\w])([A-Za-z_]\w*)\s*\(') | ForEach-Object { $_.Groups[1].Value }) + Require-PolicyAssertion ($calls.Count -eq 1 -and $calls[0] -eq 'serveBridgeDebugRejectionJson' -and + $caseBodies[$closedCase] -match 'serveBridgeDebugRejectionJson\s*\(\s*client\s*,\s*decision\.statusCode\s*\)') "pre-effect: rejection case $closedCase must contain only the decision-bound fixed rejection helper call" + } + foreach ($closedCase in @("ServeStatus", "ServeDebug")) { + $calls = @([regex]::Matches($caseBodies[$closedCase], '(?m)(?:\w])([A-Za-z_]\w*)\s*\(') | ForEach-Object { $_.Groups[1].Value }) + Require-PolicyAssertion ($calls.Count -eq 1 -and $calls[0] -eq 'serveBridgeLeanStatusJson') "pre-effect: read case $closedCase must contain only the bounded status helper call" + } + Require-PolicyAssertion ($caseBodies['ServeStatus'].Contains('"stackchan.bridge-status.v1"')) "response-telemetry: root is not bound to status schema" + Require-PolicyAssertion ($caseBodies['ServeDebug'].Contains('"stackchan.bridge-debug.v1"')) "response-telemetry: /debug is not bound to debug schema" + $audioCalls = @([regex]::Matches($caseBodies['EmergencyAudioStop'], '(?m)(?:\w])([A-Za-z_]\w*)\s*\(') | ForEach-Object { $_.Groups[1].Value } | Where-Object { $_ -ne 'if' }) + Require-PolicyAssertion ($audioCalls.Count -eq 2 -and + @($audioCalls | Where-Object { $_ -eq 'stopBridgeAudioRuntime' }).Count -eq 1 -and + @($audioCalls | Where-Object { $_ -eq 'serveBridgeDebugAdmissionJson' }).Count -eq 1) "pre-effect: audio-stop case contains an unapproved helper call" + $motionCalls = @([regex]::Matches($caseBodies['EmergencyMotionStop'], '(?m)(?:\w])([A-Za-z_]\w*)\s*\(') | ForEach-Object { $_.Groups[1].Value } | Where-Object { $_ -notin @('if', 'sizeof') }) + Require-PolicyAssertion ($motionCalls.Count -eq 3 -and + @($motionCalls | Where-Object { $_ -eq 'publishMotionControl' }).Count -eq 1 -and + @($motionCalls | Where-Object { $_ -eq 'serveBridgeDebugAdmissionJson' }).Count -eq 2) "pre-effect: motion-stop case contains an unapproved helper call" + foreach ($cameraCase in @('CameraGray', 'CameraVision')) { + $expectedCall = if ($cameraCase -eq 'CameraGray') { 'serveCameraGrayFrame' } else { 'serveCameraVisionTarget' } + $cameraCalls = @([regex]::Matches($caseBodies[$cameraCase], '(?m)(?:\w])([A-Za-z_]\w*)\s*\(') | ForEach-Object { $_.Groups[1].Value }) + $cameraProfileShape = '(?s)#if\s+STACKCHAN_ENABLE_CAMERA_HOST_VISION\s*' + + [regex]::Escape($expectedCall) + '\s*\(\s*client\s*,\s*requestTarget\s*\)\s*;\s*#else\s*' + + 'serveBridgeDebugRejectionJson\s*\(\s*client\s*,\s*404\s*\)\s*;\s*#endif' + Require-PolicyAssertion ($cameraCalls.Count -eq 2 -and + @($cameraCalls | Where-Object { $_ -eq $expectedCall }).Count -eq 1 -and + @($cameraCalls | Where-Object { $_ -eq 'serveBridgeDebugRejectionJson' }).Count -eq 1 -and + $caseBodies[$cameraCase] -match $cameraProfileShape) "pre-effect: camera case $cameraCase lacks the exact enabled-authorized/disabled-404 profile shape" + } + Require-PolicyAssertion ($caseBodies["EmergencyAudioStop"].Contains("gBridgeAudioRemoteStopRequests++") -and + $caseBodies["EmergencyAudioStop"].Contains("stopBridgeAudioRuntime(nowMs, BridgeAudioSafetyStopReason::RemoteRequest)") -and + $caseBodies["EmergencyAudioStop"] -match '(?s)serveBridgeDebugAdmissionJson\s*\(\s*client\s*,\s*202\s*,\s*"Accepted"\s*,\s*true') "stop-response: audio stop must map directly to 202 accepted:true" + Require-PolicyAssertion ($caseBodies["EmergencyMotionStop"].Contains("control.hasMotionEnable = true;") -and + $caseBodies["EmergencyMotionStop"].Contains("control.motionEnabled = false;")) "stop-response: emergency motion action is not bound to disabled state" + $motionResultBoundByVariable = $caseBodies["EmergencyMotionStop"] -match '(?s)const\s+bool\s+accepted\s*=\s*publishMotionControl\s*\(\s*control\s*\)\s*;.*?if\s*\(\s*accepted\s*\).*?serveBridgeDebugAdmissionJson\s*\(\s*client\s*,\s*202\s*,\s*"Accepted"\s*,\s*true.*?else.*?serveBridgeDebugAdmissionJson\s*\(\s*client\s*,\s*503\s*,\s*"Service Unavailable"\s*,\s*false' + $motionResultBoundDirectly = $caseBodies["EmergencyMotionStop"] -match '(?s)if\s*\(\s*publishMotionControl\s*\(\s*control\s*\)\s*\).*?serveBridgeDebugAdmissionJson\s*\(\s*client\s*,\s*202\s*,\s*"Accepted"\s*,\s*true.*?else.*?serveBridgeDebugAdmissionJson\s*\(\s*client\s*,\s*503\s*,\s*"Service Unavailable"\s*,\s*false' + Require-PolicyAssertion ($motionResultBoundByVariable -or $motionResultBoundDirectly) "stop-response: motion publication result must directly govern 202/true versus 503/false" + Require-PolicyAssertion ($caseBodies["CameraGray"].Contains("serveCameraGrayFrame(client, requestTarget)")) "camera-invariant: gray dispatch not confined to camera case" + Require-PolicyAssertion ($caseBodies["CameraVision"].Contains("serveCameraVisionTarget(client, requestTarget)")) "camera-invariant: vision dispatch not confined to camera case" + + $betweenPolicyAndSwitch = if ($policyIndex -ge 0 -and $switchIndex -gt $policyIndex) { + $pollText.Substring($policyIndex, $switchIndex - $policyIndex) + } else { "" } + foreach ($effect in $effectTokens) { + Require-PolicyAssertion (-not $betweenPolicyAndSwitch.Contains($effect)) "pre-effect: effect $effect appears between policy evaluation and disposition switch" + } + $betweenCalls = @([regex]::Matches($betweenPolicyAndSwitch, '(?m)(?:\w])([A-Za-z_]\w*)\s*\(') | ForEach-Object { $_.Groups[1].Value } | Where-Object { $_ -ne 'sizeof' }) + Require-PolicyAssertion ($betweenCalls.Count -eq 1 -and $betweenCalls[0] -eq 'evaluateBridgeDebugHttpRequestLine') "pre-effect: an unapproved helper call appears before disposition dispatch" + + $targetRemainder = $pollText + foreach ($allowedPattern in @( + 'char\s+requestTarget\s*\[\s*224\s*\]\s*=\s*\{\s*\}\s*;', + 'requestTarget\s*,\s*sizeof\s*\(\s*requestTarget\s*\)', + 'serveCameraGrayFrame\s*\(\s*client\s*,\s*requestTarget\s*\)', + 'serveCameraVisionTarget\s*\(\s*client\s*,\s*requestTarget\s*\)' + )) { $targetRemainder = [regex]::Replace($targetRemainder, $allowedPattern, "") } + Require-PolicyAssertion (-not $targetRemainder.Contains("requestTarget")) "non-disclosure: requestTarget has a use outside bounded buffer/evaluator/camera dispatch" + $lineRemainder = $pollText + foreach ($allowedPattern in @( + 'char\s+requestLine\s*\[\s*256\s*\]\s*=\s*\{\s*\}\s*;', + 'sizeof\s*\(\s*requestLine\s*\)', + 'requestLine\s*\[\s*requestLineLen\+\+\s*\]\s*=\s*ch\s*;', + 'requestLine\s*\[\s*requestLineLen\s*\]\s*=\s*''\\0''\s*;', + 'evaluateBridgeDebugHttpRequestLine\s*\(\s*requestLine\s*,' + )) { $lineRemainder = [regex]::Replace($lineRemainder, $allowedPattern, "") } + Require-PolicyAssertion (-not ($lineRemainder -match '\brequestLine\b')) "non-disclosure: requestLine has a use outside bounded capture/evaluator" + Require-PolicyAssertion (-not ($pollText -match '(?im)^[^\r\n]*(?:Serial\.(?:print|printf)|append|client\.(?:print|printf|write)|log)[^\r\n]*(?:requestTarget|requestLine)[^\r\n]*$')) "non-disclosure: raw request reaches a response/log sink" + Require-PolicyAssertion (-not ($pollText -match '(?im)^[^\r\n]*(?:requestTarget|requestLine)[^\r\n]*(?:Serial\.(?:print|printf)|append|client\.(?:print|printf|write)|log)[^\r\n]*$')) "non-disclosure: raw request precedes a response/log sink" + $conditionalLines = @([regex]::Matches($pollText, '(?m)^\s*#(?:if|ifdef|ifndef|elif|else)\b[^\r\n]*') | ForEach-Object { $_.Value.Trim() }) + Require-PolicyAssertion ($conditionalLines.Count -eq 5 -and + @($conditionalLines | Where-Object { $_ -eq '#if defined(ARDUINO_ARCH_ESP32)' }).Count -eq 1 -and + @($conditionalLines | Where-Object { $_ -eq '#if STACKCHAN_ENABLE_CAMERA_HOST_VISION' }).Count -eq 2 -and + @($conditionalLines | Where-Object { $_ -eq '#else' }).Count -eq 2) "profile: conditional policy/effect bypass exists in debug poller" +} + +foreach ($required in @('debug_http_control_policy', 'emergency_stop_only', + 'debug_request_route', 'debug_request_method', 'debug_request_result', 'control_disabled')) { + Require-PolicyAssertion $mainText.Contains($required) "response-telemetry: missing $required" +} +$expectedTelemetrySnippet = @' + append(",\"debug_http_control_policy\":\"emergency_stop_only\""); + append(",\"debug_request_method\":\"%s\"", bridgeDebugHttpMethodName(decision.method)); + append(",\"debug_request_route\":\"%s\"", bridgeDebugHttpRouteName(decision.route)); + append(",\"debug_request_result\":\"%s\"", bridgeDebugHttpDispositionName(decision.disposition)); + append(",\"debug_http_requests\":%lu", static_cast(gBridgeDebugHttpRequests)); + append(",\"debug_http_rejections\":%lu", static_cast(gBridgeDebugHttpRejected)); + append(",\"debug_http_emergency_stops\":%lu", static_cast(gBridgeDebugHttpEmergencyStops)); + append(",\"debug_http_camera_routes\":%lu", static_cast(gBridgeDebugHttpCameraRoutes)); + append(",\"control_disabled\":true"); +'@ +foreach ($counter in @('gBridgeDebugHttpRequests', 'gBridgeDebugHttpRejected', + 'gBridgeDebugHttpEmergencyStops', 'gBridgeDebugHttpCameraRoutes')) { + Require-PolicyAssertion (([regex]::Matches($mainText, 'uint32_t\s+' + [regex]::Escape($counter) + '\s*=\s*0\s*;')).Count -eq 1) "response-telemetry: missing single bounded counter declaration $counter" +} +Require-PolicyAssertion (-not $mainText.Contains('append(",\"debug_request\":\"%s\"')) "non-disclosure: raw request target is still emitted" +$allowedPairingAuthorityUses = @( + 'gBridgeEndpointControl.setRequiredPairingCode(pairing.code)', + 'gBridgeEndpointControl.setRequiredPairingCode(ticket.code)', + 'endpointControlConfig.requiredPairingCode = STACKCHAN_PAIRING_SHORT_CODE;' +) +$pairingAuthorityRemainder = $mainText +foreach ($allowedPairingUse in $allowedPairingAuthorityUses) { + Require-PolicyAssertion (([regex]::Matches($pairingAuthorityRemainder, [regex]::Escape($allowedPairingUse))).Count -eq 1) "non-disclosure: missing or duplicated exact pairing authority consumer $allowedPairingUse" + $pairingAuthorityRemainder = [regex]::Replace( + $pairingAuthorityRemainder, + [regex]::Escape($allowedPairingUse), + '', + 1) +} +$pairingMacroDefinition = '(?m)^#ifndef STACKCHAN_PAIRING_SHORT_CODE\r?\n#define STACKCHAN_PAIRING_SHORT_CODE ""\r?\n#endif\r?\n?' +Require-PolicyAssertion (([regex]::Matches($pairingAuthorityRemainder, $pairingMacroDefinition)).Count -eq 1) "non-disclosure: pairing macro definition changed or was duplicated" +$pairingAuthorityRemainder = [regex]::Replace($pairingAuthorityRemainder, $pairingMacroDefinition, '', 1) +Require-PolicyAssertion (-not ($pairingAuthorityRemainder -match '(?i)(?:STACKCHAN_PAIRING_SHORT_CODE|\.requiredPairingCode\b|\b(?:pairing|ticket)\.code\b|bridge_endpoint_pairing_code|pairing_code=)')) "non-disclosure: pairing secret source or field has a use outside its three exact bounded authority consumers" +Require-PolicyAssertion (-not ($policyText -match '(?i)printf|Serial\.|iostream|fstream|fprintf|fwrite')) "non-disclosure: pure policy contains an output sink" +Require-PolicyAssertion (-not (($headerText + "`n" + $policyText) -match '(?m)^\s*#\s*(?:if|ifdef|ifndef|elif|else)\b')) "profile: pure policy contains a profile-specific conditional" +$statusHelperStart = $mainText.IndexOf('void serveBridgeLeanStatusJson') +$statusHelperEnd = $mainText.IndexOf('void serveCameraGrayFrame', $statusHelperStart) +$statusHelperText = if ($statusHelperStart -ge 0 -and $statusHelperEnd -gt $statusHelperStart) { + $mainText.Substring($statusHelperStart, $statusHelperEnd - $statusHelperStart) +} else { "" } +Require-PolicyAssertion ($statusHelperText -match '(?s)^void\s+serveBridgeLeanStatusJson\s*\(\s*WiFiClient&\s+client\s*,\s*const\s+char\*\s+schema\s*,\s*const\s+BridgeDebugHttpDecision&\s+decision\s*\)') "response-telemetry: status helper must accept the evaluated decision by const reference" +Require-PolicyAssertion (([regex]::Matches($statusHelperText, '\bBridgeDebugHttpDecision\b')).Count -eq 1 -and + -not ($statusHelperText -match '(?m)\bdecision\s*=|\bBridgeDebugHttpDecision\s+decision\b')) "response-telemetry: status helper shadows or assigns the evaluated decision" +Require-PolicyAssertion ((Normalize-ExactSource $statusHelperText).Contains((Normalize-ExactSource $expectedTelemetrySnippet))) "response-telemetry: served bounded status omits evaluated enum fields or counters" +foreach ($effect in @('stopBridgeAudioRuntime', 'publishMotionControl', 'serveCameraGrayFrame', + 'serveCameraVisionTarget', 'suppressWakeMwwDetections', 'requestBridgeReboot', + 'serveWakeMwwPcmWav', 'gWakeMwwPcmRing', 'writeWakeWavLe16', 'writeWakeWavLe32')) { + Require-PolicyAssertion (-not $statusHelperText.Contains($effect)) "pre-effect: bounded status helper contains effect $effect" +} +Require-PolicyAssertion (-not ($statusHelperText -match '(?m)\b(?:gBridgeRecovery|gWakeMwwResetRequested|gMotionRequested|gAutonomousMotionRequested)\b[^;\r\n]*=')) "pre-effect: bounded status helper assigns authoritative state" +$rejectionHelperIndex = $mainText.IndexOf("void serveBridgeDebugRejectionJson") +$admissionHelperIndex = $mainText.IndexOf("void serveBridgeDebugAdmissionJson") +$pollHelperIndex = $mainText.IndexOf("void pollBridgeDebugServer") +$rejectionHelperText = if ($rejectionHelperIndex -ge 0 -and $admissionHelperIndex -gt $rejectionHelperIndex) { + $mainText.Substring($rejectionHelperIndex, $admissionHelperIndex - $rejectionHelperIndex) +} else { "" } +$admissionHelperText = if ($admissionHelperIndex -ge 0 -and $pollHelperIndex -gt $admissionHelperIndex) { + $mainText.Substring($admissionHelperIndex, $pollHelperIndex - $admissionHelperIndex) +} else { "" } +$expectedRejectionHelperText = @' +void serveBridgeDebugRejectionJson(WiFiClient& client, uint16_t statusCode) { + const char* statusText = "Bad Request"; + switch (statusCode) { + case 403: + statusText = "Forbidden"; + break; + case 404: + statusText = "Not Found"; + break; + case 405: + statusText = "Method Not Allowed"; + break; + case 414: + statusText = "URI Too Long"; + break; + default: + statusCode = 400; + break; + } + constexpr char body[] = "{\"ok\":false,\"accepted\":false,\"error\":\"control_disabled\"}\n"; + constexpr size_t bodyLength = sizeof(body) - 1u; + char header[192] = {}; + const int headerLength = snprintf( + header, + sizeof(header), + "HTTP/1.1 %u %s\r\nContent-Type: application/json\r\nCache-Control: no-store\r\n" + "Content-Length: %u\r\nConnection: close\r\n\r\n", + static_cast(statusCode), + statusText, + static_cast(bodyLength)); + if (headerLength <= 0 || static_cast(headerLength) >= sizeof(header)) { + client.stop(); + return; + } + client.write(reinterpret_cast(header), static_cast(headerLength)); + client.write(reinterpret_cast(body), bodyLength); + delay(1); + client.stop(); +} +'@ +$expectedAdmissionHelperText = @' +void serveBridgeDebugAdmissionJson(WiFiClient& client, + uint16_t statusCode, + const char* statusText, + bool accepted) { + const char* acceptedJson = accepted ? "true" : "false"; + char body[64] = {}; + const int bodyLength = snprintf( + body, + sizeof(body), + "{\"ok\":%s,\"accepted\":%s}\n", + acceptedJson, + acceptedJson); + if (bodyLength <= 0 || static_cast(bodyLength) >= sizeof(body)) { + client.stop(); + return; + } + char header[192] = {}; + const int headerLength = snprintf( + header, + sizeof(header), + "HTTP/1.1 %u %s\r\nContent-Type: application/json\r\nCache-Control: no-store\r\n" + "Content-Length: %u\r\nConnection: close\r\n\r\n", + static_cast(statusCode), + statusText, + static_cast(bodyLength)); + if (headerLength <= 0 || static_cast(headerLength) >= sizeof(header)) { + client.stop(); + return; + } + client.write(reinterpret_cast(header), static_cast(headerLength)); + client.write(reinterpret_cast(body), static_cast(bodyLength)); + delay(1); + client.stop(); +} +'@ +if ([string]::IsNullOrEmpty($rejectionHelperText)) { + $issues.Add("denial-response: bounded fixed rejection helper missing") +} else { + Require-PolicyAssertion ((Normalize-ExactSource $rejectionHelperText) -ceq (Normalize-ExactSource $expectedRejectionHelperText)) "denial-response: rejection helper differs from the exact fixed wire shape" + Require-PolicyAssertion ($rejectionHelperText.Contains('constexpr char body[] = "{\"ok\":false,\"accepted\":false,\"error\":\"control_disabled\"}\n";') -and + $rejectionHelperText.Contains('char header[192]') -and + $rejectionHelperText.Contains('Content-Length: %u')) "denial-response: rejection JSON is not the fixed small shape" + Require-PolicyAssertion (-not ($rejectionHelperText -match '(?i)requestTarget|requestLine|pairing|authorization|credential|gWakeMww|stopped|completed|physical')) "denial-response: rejection helper contains raw/private/effect/completion material" + Require-PolicyAssertion (-not ($rejectionHelperText -match '\b(?:ESP|M5|WiFi|g[A-Z]\w*)\s*(?:\.|->)')) "denial-response: rejection helper accesses authoritative global hardware/runtime state" + $rejectionCalls = @([regex]::Matches($rejectionHelperText, '(?m)(?:\w])([A-Za-z_]\w*)\s*\(') | ForEach-Object { $_.Groups[1].Value } | Where-Object { $_ -notin @('if', 'switch', 'sizeof') }) + Require-PolicyAssertion (@($rejectionCalls | Where-Object { $_ -notin @('serveBridgeDebugRejectionJson', 'snprintf', 'strlen', 'delay') }).Count -eq 0) "denial-response: rejection helper calls an unapproved helper" +} +if ([string]::IsNullOrEmpty($admissionHelperText)) { + $issues.Add("stop-response: bounded admission helper missing") +} else { + Require-PolicyAssertion ((Normalize-ExactSource $admissionHelperText) -ceq (Normalize-ExactSource $expectedAdmissionHelperText)) "stop-response: admission helper differs from the exact accepted-parameter-bound wire shape" + Require-PolicyAssertion ($admissionHelperText.Contains('char body[64]') -and + $admissionHelperText.Contains('char header[192]') -and + $admissionHelperText.Contains('\"accepted\":%s')) "stop-response: admission JSON is not bounded accepted-only shape" + Require-PolicyAssertion (-not ($admissionHelperText -match '(?i)stopped|motion_disabled|completed|physical|requestTarget|requestLine|pairing|authorization|credential|gWakeMww')) "stop-response: admission helper claims completion or contains raw/private/effect material" + Require-PolicyAssertion (-not ($admissionHelperText -match '\b(?:ESP|M5|WiFi|g[A-Z]\w*)\s*(?:\.|->)')) "stop-response: admission helper accesses authoritative global hardware/runtime state" + $admissionCalls = @([regex]::Matches($admissionHelperText, '(?m)(?:\w])([A-Za-z_]\w*)\s*\(') | ForEach-Object { $_.Groups[1].Value } | Where-Object { $_ -notin @('if', 'sizeof') }) + Require-PolicyAssertion (@($admissionCalls | Where-Object { $_ -notin @('serveBridgeDebugAdmissionJson', 'snprintf', 'strlen', 'delay') }).Count -eq 0) "stop-response: admission helper calls an unapproved helper" +} +foreach ($requiredInvariant in @( + '#define STACKCHAN_REMOTE_RECOVERY_ENABLE STACKCHAN_ENABLE_WIFI_BRIDGE', + 'serviceBridgeRecovery(nowMs);', '#define STACKCHAN_OTA_PORT 8790', 'gLanOtaServer.poll(' +)) { + Require-PolicyAssertion $mainText.Contains($requiredInvariant) "invariant: missing autonomous recovery/OTA token $requiredInvariant" +} + +function Get-BoundedSourceSection([string]$Text, [string]$StartMarker, [string]$EndMarker) { + $start = $Text.IndexOf($StartMarker) + $end = if ($start -ge 0) { $Text.IndexOf($EndMarker, $start + $StartMarker.Length) } else { -1 } + if ($start -lt 0 -or $end -le $start) { return "" } + return ($Text.Substring($start, $end - $start) -replace "`r`n", "`n") +} + +$frozenPreregCommit = 'd75c62f37f8ff6e1c6cf49bc2c4c01479cd4f02f' +$approvedPackagePrerequisiteCommit = '2ed5bb6ad4755129b61aa0f636f0b654a3493d86' +& git cat-file -e "$frozenPreregCommit`:src/main.cpp" 2>$null +$frozenCommitAvailable = $LASTEXITCODE -eq 0 +Require-PolicyAssertion $frozenCommitAvailable "invariant: frozen preregistration source commit is unavailable" +& git merge-base --is-ancestor $frozenPreregCommit HEAD +$candidateDescendsFromPrereg = $LASTEXITCODE -eq 0 +Require-PolicyAssertion $candidateDescendsFromPrereg "invariant: candidate does not descend from frozen preregistration commit" +& git cat-file -e "$approvedPackagePrerequisiteCommit`^{commit}" 2>$null +$packagePrerequisiteAvailable = $LASTEXITCODE -eq 0 +Require-PolicyAssertion $packagePrerequisiteAvailable "scope: approved package prerequisite commit is unavailable" +$packagePrerequisiteParent = if ($packagePrerequisiteAvailable) { + (& git rev-parse "$approvedPackagePrerequisiteCommit`^").Trim() +} else { "" } +Require-PolicyAssertion ($packagePrerequisiteParent -ceq $frozenPreregCommit) "scope: approved package prerequisite does not directly follow preregistration" +$packagePrerequisiteFiles = @(if ($packagePrerequisiteAvailable) { + & git diff-tree --no-commit-id --name-only -r $approvedPackagePrerequisiteCommit +}) +Require-PolicyAssertion ($packagePrerequisiteFiles.Count -eq 1 -and + $packagePrerequisiteFiles[0] -ceq 'tools/package_release.ps1') "scope: approved package prerequisite changed outside its reviewed file" +& git merge-base --is-ancestor $approvedPackagePrerequisiteCommit HEAD +$candidateDescendsFromPackagePrerequisite = $LASTEXITCODE -eq 0 +Require-PolicyAssertion $candidateDescendsFromPackagePrerequisite "scope: candidate does not descend from the approved package prerequisite" +$baselineMainText = if ($frozenCommitAvailable) { ((& git show "$frozenPreregCommit`:src/main.cpp") -join "`n") } else { "" } +foreach ($frozenSection in @( + @{ Start = '#ifndef STACKCHAN_OTA_PORT'; End = '#ifndef STACKCHAN_BASE_USB_POWER_INPUT'; Name = 'OTA port token digest and health configuration' }, + @{ Start = 'void suppressWakeMwwDetections'; End = 'void serveWakeMwwPcmWav'; Name = 'on-device wake capture and gating' }, + @{ Start = 'void serveWakeMwwPcmWav'; End = 'void WakeMwwProbeTask'; Name = 'wake PCM/WAV exporter implementation' }, + @{ Start = 'void WakeMwwProbeTask'; End = 'void ensureWakeSrStarted'; Name = 'on-device wake model task' }, + @{ Start = 'void serviceLanOta'; End = 'void printBridgeOutput'; Name = 'OTA health and polling authority' }, + @{ Start = 'bool publishMotionControl'; End = 'void requestMotionSafetyHold'; Name = 'actuator single-writer publication' }, + @{ Start = 'void updateBridgeNetwork'; End = 'void serveBridgeLeanStatusJson'; Name = 'bridge framing and network-session authority' }, + @{ Start = 'void restartBridgeWiFi'; End = 'void serviceBridgeRecovery'; Name = 'bridge and Wi-Fi recovery effect helpers' }, + @{ Start = 'void serviceBridgeRecovery'; End = 'void handleWiFiProvisioningControl'; Name = 'autonomous offline recovery supervisor' }, + @{ Start = 'void applyMotionControlInput'; End = 'bool shouldSuppressMotionForAudio'; Name = 'actuator command consumer' }, + @{ Start = 'void MotionTask'; End = 'void FaceTask'; Name = 'motion safety task authority' }, + @{ Start = 'void FaceTask'; End = 'void IntentTask'; Name = 'procedural face timing gate' }, + @{ Start = 'void IntentTask'; End = 'void setup()'; Name = 'bridge recovery OTA wake and debug service call sites' }, + @{ Start = 'void setup()'; End = 'void loop()'; Name = 'task creation bridge framing OTA token and actuator setup' } +)) { + $baselineSection = Get-BoundedSourceSection $baselineMainText $frozenSection.Start $frozenSection.End + $candidateSection = Get-BoundedSourceSection $mainText $frozenSection.Start $frozenSection.End + Require-PolicyAssertion (-not [string]::IsNullOrEmpty($baselineSection) -and $candidateSection -ceq $baselineSection) "invariant: changed $($frozenSection.Name) section" +} +Require-PolicyAssertion (([regex]::Matches($mainText, 'LanOtaServer\s+gLanOtaServer\s*\(\s*STACKCHAN_OTA_PORT\s*\)\s*;')).Count -eq 1) "invariant: OTA server construction changed from the frozen configured port" + +$baselineCameraFunctionStart = $baselineMainText.IndexOf('bool writeHttpBody') +$candidateCameraFunctionStart = $mainText.IndexOf('bool writeHttpBody') +$cameraProfileMarker = '#if defined(ARDUINO_ARCH_ESP32) && STACKCHAN_ENABLE_CAMERA_HOST_VISION' +$baselineCameraStart = if ($baselineCameraFunctionStart -ge 0) { + $baselineMainText.LastIndexOf($cameraProfileMarker, $baselineCameraFunctionStart) +} else { -1 } +$candidateCameraStart = if ($candidateCameraFunctionStart -ge 0) { + $mainText.LastIndexOf($cameraProfileMarker, $candidateCameraFunctionStart) +} else { -1 } +$baselineCameraVisionStart = if ($baselineCameraStart -ge 0) { + $baselineMainText.IndexOf('void serveCameraVisionTarget', $baselineCameraStart) +} else { -1 } +$candidateCameraVisionStart = if ($candidateCameraStart -ge 0) { + $mainText.IndexOf('void serveCameraVisionTarget', $candidateCameraStart) +} else { -1 } +$baselineCameraEndMarker = if ($baselineCameraVisionStart -ge 0) { + $baselineMainText.IndexOf('#endif', $baselineCameraVisionStart) +} else { -1 } +$candidateCameraEndMarker = if ($candidateCameraVisionStart -ge 0) { + $mainText.IndexOf('#endif', $candidateCameraVisionStart) +} else { -1 } +$baselineCameraEnd = if ($baselineCameraEndMarker -ge 0) { + $baselineCameraEndMarker + '#endif'.Length +} else { -1 } +$candidateCameraEnd = if ($candidateCameraEndMarker -ge 0) { + $candidateCameraEndMarker + '#endif'.Length +} else { -1 } +$baselineCameraText = if ($baselineCameraStart -ge 0 -and $baselineCameraEnd -gt $baselineCameraStart) { + $baselineMainText.Substring($baselineCameraStart, $baselineCameraEnd - $baselineCameraStart) +} else { "" } +$candidateCameraText = if ($candidateCameraStart -ge 0 -and $candidateCameraEnd -gt $candidateCameraStart) { + $mainText.Substring($candidateCameraStart, $candidateCameraEnd - $candidateCameraStart) -replace "`r`n", "`n" +} else { "" } +Require-PolicyAssertion (-not [string]::IsNullOrEmpty($baselineCameraText) -and + $candidateCameraText -ceq $baselineCameraText) "camera-invariant: parser authorizer response and capture handlers changed from preregistration" +$debugReachabilityText = $statusHelperText + "`n" + $rejectionHelperText + "`n" + + $admissionHelperText + "`n" + $candidateCameraText + "`n" + $pollText + "`n" + + $headerText + "`n" + $policyText +foreach ($pcmToken in @('serveWakeMwwPcmWav', 'gWakeMwwPcmRing', 'writeWakeWavLe16', + 'writeWakeWavLe32')) { + Require-PolicyAssertion (-not $debugReachabilityText.Contains($pcmToken)) "wake-invariant: debug HTTP classification/response/camera slice can reach PCM/WAV token $pcmToken" +} + +$baselinePlatformioText = if ($frozenCommitAvailable) { + ((& git show "$frozenPreregCommit`:platformio.ini") -join "`n").TrimEnd() +} else { "" } +$candidatePlatformioWithoutPolicy = ([regex]::Replace( + ($platformioText -replace "`r`n", "`n"), + '(?m)^[^\S\r\n]*\+[^\S\r\n]*\n?', + '')).TrimEnd() +Require-PolicyAssertion (-not [string]::IsNullOrEmpty($baselinePlatformioText) -and + $candidatePlatformioWithoutPolicy -ceq $baselinePlatformioText) "profile: platformio.ini changed beyond the one preregistered policy source-filter line" + +$allowedChangedFiles = @( + 'INITIAL_RISK_REGISTER.md', 'PROJECT_STATE.md', 'TASK_LEDGER.md', 'platformio.ini', + 'src/io/BridgeDebugHttpPolicy.hpp', 'src/io/BridgeDebugHttpPolicy.cpp', 'src/main.cpp', + 'test/test_native_logic/test_main.cpp', 'bridge/dashboard_service.py', + 'bridge/test_dashboard_service.py', 'bridge/dashboard/app.js', + 'tools/test_firmware_http_control_policy_contract.ps1', + 'tools/test_stackchan_dashboard_launcher_contract.ps1', + 'tools/camera_follow_wake_validation.ps1', + 'tools/test_camera_follow_wake_validation_contract.ps1', + 'tools/run_full_system_soak_http_motion.ps1', + 'tools/start_warm_rocm_full_system_soak.ps1', + 'tools/test_start_warm_rocm_full_system_soak_contract.ps1', + 'tools/watch_stackchan_wake_test.ps1', 'tools/verify_release_package.ps1', + 'docs/BRIDGE_PROTOCOL.md', 'docs/BRIDGE_DASHBOARD.md', 'docs/ARRIVAL_DAY_RUNBOOK.md' +) +$changedFiles = @(& git diff --name-only $approvedPackagePrerequisiteCommit HEAD) +$changedFiles += @(& git status --porcelain=v1 --untracked-files=all | ForEach-Object { + if ($_.Length -ge 4) { $_.Substring(3).Replace('\', '/') } +}) +foreach ($changedFile in @($changedFiles | Sort-Object -Unique)) { + Require-PolicyAssertion ($allowedChangedFiles -contains $changedFile) "scope: unpreregistered changed file $changedFile" +} + +$grayStart = $mainText.IndexOf("void serveCameraGrayFrame") +$grayEnd = $mainText.IndexOf("void serveCameraVisionTarget", $grayStart) +$visionEnd = $mainText.IndexOf("#endif", $grayEnd) +if ($grayStart -ge 0 -and $grayEnd -gt $grayStart) { + $grayText = $mainText.Substring($grayStart, $grayEnd - $grayStart) + $grayAuthGuard = [regex]::Match($grayText, '(?s)if\s*\(\s*!parseCameraHostPairingCode\s*\(\s*requestTarget\s*,\s*"/camera-gray\.pgm"\s*,\s*pairingCode\s*,\s*sizeof\s*\(\s*pairingCode\s*\)\s*\)\s*\|\|\s*!gBridgeEndpointControl\.authorizesPairedRequest\s*\(\s*pairingCode\s*\)\s*\)\s*\{(?.*?)\breturn\s*;\s*\}') + $grayCaptureIndex = $grayText.IndexOf("captureGray160") + Require-PolicyAssertion ($grayAuthGuard.Success -and $grayCaptureIndex -gt $grayAuthGuard.Index + $grayAuthGuard.Length -and + $grayAuthGuard.Groups['deny'].Value.Contains('noteHostAuthFailure') -and + $grayAuthGuard.Groups['deny'].Value.Contains('403') -and + $grayAuthGuard.Groups['deny'].Value.Contains('pairing_required')) "camera-invariant: gray capture must follow a returning parser/pairing 403 guard with auth accounting" + Require-PolicyAssertion (([regex]::Matches($grayText, '\brequestTarget\b')).Count -eq 2 -and + ([regex]::Matches($grayText, '\bpairingCode\b')).Count -eq 4) "camera-invariant: gray raw target/pairing material has a use outside signature/parser/authorizer" +} else { $issues.Add("camera-invariant: gray handler missing") } +if ($grayEnd -ge 0 -and $visionEnd -gt $grayEnd) { + $visionText = $mainText.Substring($grayEnd, $visionEnd - $grayEnd) + $visionParseGuard = [regex]::Match($visionText, '(?s)if\s*\(\s*!parseCameraHostVisionTarget\s*\(\s*requestTarget\s*,\s*&target\s*\)\s*\)\s*\{(?.*?)\breturn\s*;\s*\}') + $visionAuthGuard = [regex]::Match($visionText, '(?s)if\s*\(\s*!gBridgeEndpointControl\.authorizesPairedRequest\s*\(\s*target\.pairingCode\s*\)\s*\)\s*\{(?.*?)\breturn\s*;\s*\}') + $visionLostIndex = $visionText.IndexOf("submitFaceLost") + $visionFacesIndex = $visionText.IndexOf("submitFaces") + Require-PolicyAssertion ($visionParseGuard.Success -and + $visionParseGuard.Groups['deny'].Value.Contains('400') -and + $visionParseGuard.Groups['deny'].Value.Contains('invalid_target')) "camera-invariant: malformed vision query must retain returning 400 invalid_target response" + Require-PolicyAssertion ($visionAuthGuard.Success -and + $visionAuthGuard.Groups['deny'].Value.Contains('noteHostAuthFailure') -and + $visionAuthGuard.Groups['deny'].Value.Contains('403') -and + $visionAuthGuard.Groups['deny'].Value.Contains('pairing_required') -and + $visionLostIndex -gt $visionAuthGuard.Index + $visionAuthGuard.Length -and + $visionFacesIndex -gt $visionAuthGuard.Index + $visionAuthGuard.Length) "camera-invariant: vision effects must follow a returning pairing 403 guard with auth accounting" + Require-PolicyAssertion (([regex]::Matches($visionText, '\brequestTarget\b')).Count -eq 2 -and + ([regex]::Matches($visionText, 'target\.pairingCode')).Count -eq 1) "camera-invariant: vision raw target/pairing material has a use outside signature/parser/authorizer" +} else { $issues.Add("camera-invariant: vision handler missing") } + +try { + $config = (& pio project config --json-output | ConvertFrom-Json) + $wifiEnvironments = @() + $profileBypasses = @() + $faceGateViolations = @() + foreach ($section in $config) { + $name = [string]$section[0] + if (-not $name.StartsWith("env:")) { continue } + $flags = "" + foreach ($item in $section[1]) { + if ([string]$item[0] -eq "build_flags") { $flags = @($item[1]) -join "`n" } + } + if ($flags -match '(?m)^-D STACKCHAN_ENABLE_WIFI_BRIDGE=1$') { + $environmentName = $name.Substring(4) + $wifiEnvironments += $environmentName + if ($flags -match '(?i)DEBUG_HTTP|HTTP_CONTROL|UNSAFE_CONTROL|BridgeDebugHttp|pollBridgeDebugServer|publishMotionControl|serveWakeMwwPcmWav') { + $profileBypasses += $environmentName + } + $facePeriodMatch = [regex]::Match($flags, '(?m)^-D STACKCHAN_FACE_PERIOD_MS=(\d+)$') + if ($facePeriodMatch.Success -and [int]$facePeriodMatch.Groups[1].Value -gt 50) { + $faceGateViolations += $environmentName + } + } + } + Require-PolicyAssertion ($wifiEnvironments.Count -eq 19) "profile: expected 19 effective Wi-Fi environments, found $($wifiEnvironments.Count)" + Require-PolicyAssertion ($profileBypasses.Count -eq 0) "profile: control-policy bypass flag appears in $($profileBypasses -join ',')" + Require-PolicyAssertion ($faceGateViolations.Count -eq 0 -and + (Get-Content -LiteralPath (Join-Path $repoRoot 'src\config\RobotConfig.hpp') -Raw).Contains('#define STACKCHAN_FACE_PERIOD_MS 33')) "invariant: a Wi-Fi profile weakens the strict 50 ms face gate" +} catch { + $issues.Add("profile: PlatformIO effective configuration unavailable: $($_.Exception.GetType().Name)") +} + +$toolContracts = @( + @{ Path = "tools\camera_follow_wake_validation.ps1"; GuardName = 'Assert-EmergencyStopOnlyMotionPolicy'; PolicyReader = 'Invoke-RobotEndpoint'; Refusal = 'motion_resume_unavailable'; Sites = @('Invoke-RobotEndpoint "/motion-resume"', 'New-Item -ItemType Directory -Force -Path $EvidenceRoot') }, + @{ Path = "tools\start_warm_rocm_full_system_soak.ps1"; GuardName = 'Assert-EmergencyStopOnlyMotionPolicy'; PolicyReader = 'Invoke-JsonEndpoint'; Refusal = 'motion_resume_unavailable'; Sites = @('$motionStart = Enable-MotionWithRetry', 'New-Item -ItemType Directory -Force -Path $EvidenceRoot', '.\tools\start_rvc_worker.ps1', '.\tools\start_pc_brain.ps1', '$proc = Start-Process') }, + @{ Path = "tools\run_full_system_soak_http_motion.ps1"; GuardName = 'Assert-EmergencyStopOnlyMotionPolicy'; PolicyReader = 'Invoke-RobotEndpoint'; Refusal = 'motion_resume_unavailable'; Sites = @('Invoke-RobotEndpoint -Path "/motion-resume"', 'New-Item -ItemType Directory -Force -Path $EvidenceRoot') }, + @{ Path = "tools\watch_stackchan_wake_test.ps1"; GuardName = 'Assert-EmergencyStopOnlyWakePolicy'; PolicyReader = 'Invoke-RestMethod'; Refusal = 'wake_control_unavailable'; Sites = @('Invoke-RestMethod -Uri "$BaseUrl/wake-reset"', 'Invoke-RestMethod -Uri "$BaseUrl/mic-tone"', 'New-Item -ItemType Directory -Force -Path $ReportDir') } +) + +function Test-AstAncestorType($Node, [type]$AncestorType) { + $parent = $Node.Parent + while ($null -ne $parent) { + if ($parent -is $AncestorType) { return $true } + $parent = $parent.Parent + } + return $false +} + +function Test-AstControlFlowAncestor($Node) { + foreach ($type in @( + [System.Management.Automation.Language.IfStatementAst], + [System.Management.Automation.Language.SwitchStatementAst], + [System.Management.Automation.Language.ForStatementAst], + [System.Management.Automation.Language.ForEachStatementAst], + [System.Management.Automation.Language.WhileStatementAst], + [System.Management.Automation.Language.DoWhileStatementAst], + [System.Management.Automation.Language.DoUntilStatementAst] + )) { + if (Test-AstAncestorType $Node $type) { return $true } + } + return $false +} + +foreach ($contract in $toolContracts) { + $path = Join-Path $repoRoot $contract.Path + $text = Get-Content -LiteralPath $path -Raw + $guardName = [string]$contract.GuardName + $refusal = [string]$contract.Refusal + $expectedGuardText = if ($guardName -eq 'Assert-EmergencyStopOnlyWakePolicy') { @' +function Assert-EmergencyStopOnlyWakePolicy { + param([string]$Policy) + throw "wake_control_unavailable: emergency_stop_only permits observation and emergency stops only; wake reset and tone playback are disabled." +} +'@ } else { @' +function Assert-EmergencyStopOnlyMotionPolicy { + param([string]$Policy) + throw "motion_resume_unavailable: emergency_stop_only permits emergency stops only; motion resume is disabled." +} +'@ } + $expectedPolicyHelperText = switch ($contract.Path) { + 'tools\camera_follow_wake_validation.ps1' { @' +function Get-FirmwareHttpControlPolicy { + $probe = Invoke-RobotEndpoint "/debug" 4 + if ($null -eq $probe -or $probe.ok -ne $true -or $null -eq $probe.json) { + return "unknown" + } + $policy = $probe.json.debug_http_control_policy + if ($policy -is [string] -and $policy -ceq "emergency_stop_only") { + return $policy + } + return "unknown" +} +'@ } + 'tools\run_full_system_soak_http_motion.ps1' { @' +function Get-FirmwareHttpControlPolicy { + $probe = Invoke-RobotEndpoint -Path "/debug" -TimeoutSeconds 4 + if ($null -eq $probe -or $probe.ok -ne $true -or $null -eq $probe.json) { + return "unknown" + } + $policy = $probe.json.debug_http_control_policy + if ($policy -is [string] -and $policy -ceq "emergency_stop_only") { + return $policy + } + return "unknown" +} +'@ } + 'tools\start_warm_rocm_full_system_soak.ps1' { @' +function Get-FirmwareHttpControlPolicy { + try { + $probe = Invoke-JsonEndpoint -Path "/debug" -TimeoutSeconds 5 + } catch { + return "unknown" + } + $policy = $probe.debug_http_control_policy + if ($policy -is [string] -and $policy -ceq "emergency_stop_only") { + return $policy + } + return "unknown" +} +'@ } + 'tools\watch_stackchan_wake_test.ps1' { @' +function Get-FirmwareHttpControlPolicy { + $policyBaseUrl = $BaseUrl + if ($policyBaseUrl -eq "") { + $policyBaseUrl = "http://$DeviceHost`:8789" + } + try { + $probe = Invoke-RestMethod -Uri "$policyBaseUrl/debug" -TimeoutSec 5 + } catch { + return "unknown" + } + $policy = $probe.debug_http_control_policy + if ($policy -is [string] -and $policy -ceq "emergency_stop_only") { + return $policy + } + return "unknown" +} +'@ } + default { '' } + } + foreach ($required in @("debug_http_control_policy", "emergency_stop_only", "ControlPolicyContractProbe", "Get-FirmwareHttpControlPolicy", $guardName, $refusal)) { + Require-PolicyAssertion $text.Contains($required) "tool-preflight: $($contract.Path) missing $required" + } + Require-PolicyAssertion ($text.Contains('"/debug"') -or $text.Contains('$BaseUrl/debug') -or + $text.Contains('$policyBaseUrl/debug')) "tool-preflight: $($contract.Path) lacks exact /debug read" + + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$parseErrors) + Require-PolicyAssertion ($parseErrors.Count -eq 0) "tool-preflight: $($contract.Path) has PowerShell parse errors" + if ($parseErrors.Count -eq 0) { + $paramEffects = if ($null -ne $ast.ParamBlock) { @($ast.ParamBlock.FindAll({ param($node) + $node -is [System.Management.Automation.Language.CommandAst] -or + $node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -or + $node -is [System.Management.Automation.Language.ScriptBlockExpressionAst] + }, $true)) } else { @() } + Require-PolicyAssertion ($paramEffects.Count -eq 0) "tool-preflight: $($contract.Path) parameter defaults may not execute commands, member calls, or scriptblocks before refusal" + $preExecutionShapeSafe = $paramEffects.Count -eq 0 + $traps = @($ast.FindAll({ param($node) + $node -is [System.Management.Automation.Language.TrapStatementAst] + }, $true)) + Require-PolicyAssertion ($traps.Count -eq 0) "tool-preflight: $($contract.Path) may not catch refusal with trap" + $guardFunctions = @($ast.FindAll({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $true) | Where-Object { $_.Name -eq $guardName }) + Require-PolicyAssertion ($guardFunctions.Count -eq 1) "tool-preflight: $($contract.Path) must define exactly one $guardName" + $guardIsSafeToRun = $false + if ($guardFunctions.Count -eq 1) { + $guardExact = (Normalize-PowerShellSource $guardFunctions[0].Extent.Text) -ceq + (Normalize-PowerShellSource $expectedGuardText) + $guardAtRoot = $guardFunctions[0].Parent -is [System.Management.Automation.Language.NamedBlockAst] -and + $guardFunctions[0].Parent.Parent -eq $ast + Require-PolicyAssertion $guardExact "tool-preflight: $($contract.Path) refusal guard differs from the exact constant-throw implementation" + Require-PolicyAssertion $guardAtRoot "tool-preflight: $($contract.Path) refusal guard definition is hidden in a nested scriptblock" + $guardThrows = @($guardFunctions[0].Body.FindAll({ param($node) + $node -is [System.Management.Automation.Language.ThrowStatementAst] + }, $true)) + $guardReturns = @($guardFunctions[0].Body.FindAll({ param($node) + $node -is [System.Management.Automation.Language.ReturnStatementAst] -or + $node -is [System.Management.Automation.Language.ExitStatementAst] + }, $true)) + $guardStatements = @($guardFunctions[0].Body.EndBlock.Statements) + $guardNestedEffects = @($guardFunctions[0].Body.FindAll({ param($node) + $node -is [System.Management.Automation.Language.CommandAst] -or + $node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -or + $node -is [System.Management.Automation.Language.ScriptBlockExpressionAst] + }, $true)) + $guardParamShape = $null -ne $guardFunctions[0].Body.ParamBlock -and + $guardFunctions[0].Body.ParamBlock.Extent.Text -match '^param\(\s*\[string\]\s*\$Policy\s*\)$' + $guardBodySafe = $guardParamShape -and $guardThrows.Count -eq 1 -and $guardReturns.Count -eq 0 -and + $guardStatements.Count -eq 1 -and + $guardStatements[0] -is [System.Management.Automation.Language.ThrowStatementAst] -and + $guardNestedEffects.Count -eq 0 -and + $null -eq $guardFunctions[0].Body.DynamicParamBlock -and + $null -eq $guardFunctions[0].Body.BeginBlock -and + $null -eq $guardFunctions[0].Body.ProcessBlock -and + $guardThrows[0].Extent.Text.Contains($refusal) -and + $guardFunctions[0].Extent.Text.Contains("emergency_stop_only") + Require-PolicyAssertion $guardBodySafe "tool-preflight: $($contract.Path) guard body must be exactly one unconditional $refusal throw" + $guardIsSafeToRun = $preExecutionShapeSafe -and $guardExact -and $guardAtRoot -and $guardBodySafe + } + + $policyFunctions = @($ast.FindAll({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $true) | Where-Object { $_.Name -eq 'Get-FirmwareHttpControlPolicy' }) + Require-PolicyAssertion ($policyFunctions.Count -eq 1 -and + ($policyFunctions[0].Extent.Text.Contains('"/debug"') -or + $policyFunctions[0].Extent.Text.Contains('$BaseUrl/debug') -or + $policyFunctions[0].Extent.Text.Contains('$policyBaseUrl/debug')) -and + $policyFunctions[0].Extent.Text.Contains('debug_http_control_policy')) "tool-preflight: $($contract.Path) capability helper must read only the exact /debug field" + if ($policyFunctions.Count -eq 1) { + Require-PolicyAssertion ((Normalize-PowerShellSource $policyFunctions[0].Extent.Text) -ceq + (Normalize-PowerShellSource $expectedPolicyHelperText)) "tool-preflight: $($contract.Path) capability helper differs from its exact fail-closed /debug implementation" + Require-PolicyAssertion ($policyFunctions[0].Parent -is [System.Management.Automation.Language.NamedBlockAst] -and + $policyFunctions[0].Parent.Parent -eq $ast) "tool-preflight: $($contract.Path) capability helper definition is hidden in a nested scriptblock" + $policyCommands = @($policyFunctions[0].Body.FindAll({ param($node) + $node -is [System.Management.Automation.Language.CommandAst] + }, $true)) + $policyMemberInvocations = @($policyFunctions[0].Body.FindAll({ param($node) + $node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] + }, $true)) + Require-PolicyAssertion ($policyCommands.Count -eq 1 -and + $policyCommands[0].GetCommandName() -eq [string]$contract.PolicyReader -and + $policyMemberInvocations.Count -eq 0) "tool-preflight: $($contract.Path) capability helper contains an operation other than its exact /debug reader" + } + + if ([string]$contract.PolicyReader -ne 'Invoke-RestMethod') { + $baselineToolText = ((& git show "$frozenPreregCommit`:$($contract.Path.Replace('\', '/'))") -join "`n") + $baselineToolTokens = $null + $baselineToolErrors = $null + $baselineToolAst = [System.Management.Automation.Language.Parser]::ParseInput( + $baselineToolText, + [ref]$baselineToolTokens, + [ref]$baselineToolErrors) + $baselineReaders = @($baselineToolAst.FindAll({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $true) | Where-Object { $_.Name -eq [string]$contract.PolicyReader }) + $candidateReaders = @($ast.FindAll({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $true) | Where-Object { $_.Name -eq [string]$contract.PolicyReader }) + $readerFrozen = $baselineToolErrors.Count -eq 0 -and $baselineReaders.Count -eq 1 -and + $candidateReaders.Count -eq 1 -and + (($candidateReaders[0].Extent.Text -replace "`r`n", "`n") -ceq + ($baselineReaders[0].Extent.Text -replace "`r`n", "`n")) + Require-PolicyAssertion $readerFrozen "tool-preflight: $($contract.Path) exact /debug wrapper $($contract.PolicyReader) changed from preregistration" + } + + $topStatements = @($ast.EndBlock.Statements) + $assignments = @($ast.FindAll({ param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] + }, $true) | Where-Object { + $_.Left.Extent.Text -eq '$controlPolicyPreflight' -and + (Normalize-PowerShellSource $_.Right.Extent.Text) -ceq 'Get-FirmwareHttpControlPolicy' -and + $_.Parent -is [System.Management.Automation.Language.NamedBlockAst] -and + $_.Parent.Parent -eq $ast + }) + $actualGuards = @($ast.FindAll({ param($node) + $node -is [System.Management.Automation.Language.CommandAst] + }, $true) | Where-Object { + $_.GetCommandName() -eq $guardName -and + (Normalize-PowerShellSource $_.Extent.Text) -ceq ($guardName + '-Policy$controlPolicyPreflight') -and + -not (Test-AstAncestorType $_ ([System.Management.Automation.Language.FunctionDefinitionAst])) -and + -not (Test-AstAncestorType $_ ([System.Management.Automation.Language.TryStatementAst])) -and + -not (Test-AstAncestorType $_ ([System.Management.Automation.Language.ScriptBlockExpressionAst])) + }) + Require-PolicyAssertion ($assignments.Count -eq 1) "tool-preflight: $($contract.Path) needs one uncaught top-level capability assignment" + Require-PolicyAssertion ($actualGuards.Count -eq 1) "tool-preflight: $($contract.Path) needs one uncaught top-level guard over the actual capability result" + $actualGuardOffset = if ($actualGuards.Count -eq 1) { $actualGuards[0].Extent.StartOffset } else { -1 } + if ($assignments.Count -eq 1 -and $actualGuards.Count -eq 1) { + Require-PolicyAssertion ($assignments[0].Extent.StartOffset -lt $actualGuardOffset) "tool-preflight: $($contract.Path) capability assignment must precede its guard" + Require-PolicyAssertion (-not (Test-AstControlFlowAncestor $assignments[0])) "tool-preflight: $($contract.Path) capability assignment must execute unconditionally at script scope" + Require-PolicyAssertion ($assignments[0].Parent -is [System.Management.Automation.Language.NamedBlockAst] -and + $assignments[0].Parent.Parent -eq $ast) "tool-preflight: $($contract.Path) capability assignment is hidden in a nested scriptblock" + $assignmentStatementIndex = [array]::IndexOf($topStatements, $assignments[0]) + if ($contract.Path -eq "tools\watch_stackchan_wake_test.ps1") { + $guardIfAncestors = [System.Collections.Generic.List[object]]::new() + $parent = $actualGuards[0].Parent + while ($null -ne $parent) { + if ($parent -is [System.Management.Automation.Language.IfStatementAst]) { $guardIfAncestors.Add($parent) } + $parent = $parent.Parent + } + $guardIf = if ($guardIfAncestors.Count -eq 1) { $guardIfAncestors[0] } else { $null } + $wakeConditionShape = $null -ne $guardIf -and $guardIf.Clauses.Count -eq 1 -and + (Normalize-PowerShellSource $guardIf.Clauses[0].Item1.Extent.Text) -ceq '-not$SkipReset-or$PlayTone' -and + (Normalize-PowerShellSource $guardIf.Clauses[0].Item2.Extent.Text) -ceq + ('{' + $guardName + '-Policy$controlPolicyPreflight}') -and + $null -eq $guardIf.ElseClause + $guardStatementIndex = if ($null -ne $guardIf) { [array]::IndexOf($topStatements, $guardIf) } else { -1 } + Require-PolicyAssertion ($guardIfAncestors.Count -eq 1 -and $wakeConditionShape -and + $guardIfAncestors[0].Parent -is [System.Management.Automation.Language.NamedBlockAst] -and + $guardIfAncestors[0].Parent.Parent -eq $ast -and + $assignmentStatementIndex -ge 0 -and + $guardStatementIndex -eq ($assignmentStatementIndex + 1) -and + -not (Test-AstAncestorType $actualGuards[0] ([System.Management.Automation.Language.SwitchStatementAst])) -and + -not (Test-AstAncestorType $actualGuards[0] ([System.Management.Automation.Language.ForStatementAst])) -and + -not (Test-AstAncestorType $actualGuards[0] ([System.Management.Automation.Language.ForEachStatementAst])) -and + -not (Test-AstAncestorType $actualGuards[0] ([System.Management.Automation.Language.WhileStatementAst])) -and + -not (Test-AstAncestorType $actualGuards[0] ([System.Management.Automation.Language.DoWhileStatementAst])) -and + -not (Test-AstAncestorType $actualGuards[0] ([System.Management.Automation.Language.DoUntilStatementAst]))) "tool-preflight: wake guard must execute under exactly the reset-or-tone mutation condition" + } else { + $guardPipeline = $actualGuards[0].Parent + $guardStatementIndex = [array]::IndexOf($topStatements, $guardPipeline) + Require-PolicyAssertion (-not (Test-AstControlFlowAncestor $actualGuards[0]) -and + $actualGuards[0].Parent -is [System.Management.Automation.Language.PipelineAst] -and + $actualGuards[0].Parent.Parent -is [System.Management.Automation.Language.NamedBlockAst] -and + $actualGuards[0].Parent.Parent.Parent -eq $ast -and + $assignmentStatementIndex -ge 0 -and + $guardStatementIndex -eq ($assignmentStatementIndex + 1)) "tool-preflight: $($contract.Path) actual guard must execute unconditionally and immediately after capability assignment at script scope" + } + } + foreach ($site in @($contract.Sites)) { + $siteIndex = $text.IndexOf([string]$site) + Require-PolicyAssertion ($actualGuardOffset -ge 0 -and $siteIndex -ge 0 -and $actualGuardOffset -lt $siteIndex) "tool-preflight: $($contract.Path) actual guard does not dominate $site" + } + + $probeBranches = @($ast.FindAll({ param($node) + $node -is [System.Management.Automation.Language.IfStatementAst] + }, $true) | Where-Object { + $_.Extent.Text.Contains('$ControlPolicyContractProbe') -and + $_.Extent.Text.Contains($guardName) -and + $_.Extent.Text.Contains('"emergency_stop_only"') -and + -not (Test-AstAncestorType $_ ([System.Management.Automation.Language.FunctionDefinitionAst])) -and + -not (Test-AstAncestorType $_ ([System.Management.Automation.Language.TryStatementAst])) + }) + Require-PolicyAssertion ($probeBranches.Count -eq 1) "tool-preflight: $($contract.Path) needs one uncaught synthetic refusal branch" + $probeIsSafeToRun = $probeBranches.Count -eq 1 -and $guardIsSafeToRun + if ($probeIsSafeToRun) { + $probeOffset = $probeBranches[0].Extent.StartOffset + $probeShape = '^if\s*\(\s*\$ControlPolicyContractProbe\s*\)\s*\{\s*' + + [regex]::Escape($guardName) + '\s+-Policy\s+"emergency_stop_only"\s*\}\s*$' + Require-PolicyAssertion ($probeBranches[0].Extent.Text -match $probeShape) "tool-preflight: $($contract.Path) synthetic branch must contain only the refusal guard" + $probeAtRoot = $probeBranches[0].Parent -is [System.Management.Automation.Language.NamedBlockAst] -and + $probeBranches[0].Parent.Parent -eq $ast + Require-PolicyAssertion $probeAtRoot "tool-preflight: $($contract.Path) synthetic branch is hidden in a nested scriptblock" + $statementsBeforeProbe = @($ast.EndBlock.Statements | Where-Object { + $_.Extent.StartOffset -lt $probeOffset + }) + $unsafeStatementsBeforeProbe = @($statementsBeforeProbe | Where-Object { + if ($_ -is [System.Management.Automation.Language.FunctionDefinitionAst]) { + return $_.Name -ne $guardName + } + if ($_ -is [System.Management.Automation.Language.AssignmentStatementAst]) { + return -not ($_.Left.Extent.Text -eq '$ErrorActionPreference' -and + $_.Right.Extent.Text -match '^["'']Stop["'']$') + } + return $true + }) + Require-PolicyAssertion ($unsafeStatementsBeforeProbe.Count -eq 0) "tool-preflight: $($contract.Path) synthetic refusal is preceded by an executable statement" + $probeIsSafeToRun = $unsafeStatementsBeforeProbe.Count -eq 0 -and $probeAtRoot -and + $probeBranches[0].Extent.Text -match $probeShape -and $guardIsSafeToRun + $actualAssignmentIndex = if ($assignments.Count -eq 1) { + [array]::IndexOf($topStatements, $assignments[0]) + } else { -1 } + $probeStatementIndex = [array]::IndexOf($topStatements, $probeBranches[0]) + $prefixStatements = if ($probeStatementIndex -ge 0 -and + $actualAssignmentIndex -gt ($probeStatementIndex + 1)) { + @($topStatements[($probeStatementIndex + 1)..($actualAssignmentIndex - 1)]) + } else { @() } + $expectedPrefixFunctions = if ([string]$contract.PolicyReader -eq 'Invoke-RestMethod') { + @('Get-FirmwareHttpControlPolicy') + } else { + @([string]$contract.PolicyReader, 'Get-FirmwareHttpControlPolicy') + } + $actualPrefixFunctionNames = @($prefixStatements | Where-Object { + $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] + } | ForEach-Object { $_.Name }) + $prefixSafe = $probeStatementIndex -ge 0 -and $actualAssignmentIndex -gt $probeStatementIndex -and + $prefixStatements.Count -eq $expectedPrefixFunctions.Count -and + @($prefixStatements | Where-Object { + $_ -isnot [System.Management.Automation.Language.FunctionDefinitionAst] + }).Count -eq 0 -and + (@($actualPrefixFunctionNames | Sort-Object) -join '|') -ceq + (@($expectedPrefixFunctions | Sort-Object) -join '|') + Require-PolicyAssertion $prefixSafe "tool-preflight: $($contract.Path) executable prefix before the actual refusal guard must contain only the frozen endpoint reader and exact policy helper definitions" + $probeIsSafeToRun = $probeIsSafeToRun -and $prefixSafe + } + if ($probeIsSafeToRun) { + $savedErrorAction = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $probeOutput = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $path -ControlPolicyContractProbe 2>&1 | Out-String + $probeExit = $LASTEXITCODE + $ErrorActionPreference = $savedErrorAction + Require-PolicyAssertion ($probeExit -ne 0 -and $probeOutput.Contains($refusal)) "tool-preflight: $($contract.Path) synthetic probe did not exit with $refusal" + } + } + + $refusalCount = ([regex]::Matches($text, [regex]::Escape($refusal))).Count + Require-PolicyAssertion ($refusalCount -eq 1) "tool-preflight: $($contract.Path) refusal marker must occur exactly once and never be a success label" + Require-PolicyAssertion (-not ($text -match '(?i)pairing(?:Code|-code|_code)|credential|authorization')) "tool-preflight: $($contract.Path) reads or references forbidden authority material" + Require-PolicyAssertion (-not ($text -match '"/(?:motion-resume|wake-reset|mic-tone)\?')) "tool-preflight: $($contract.Path) uses query-based mutation authority" + Require-PolicyAssertion (-not ($text -match '"/(?:motion-on|servos-on|recover|bridge-recover|wifi-recover|reboot|restart|reset)(?:"|\?|\s)')) "tool-preflight: $($contract.Path) contains denied fallback alias" +} + +$wakeWatcherText = Get-Content -LiteralPath (Join-Path $repoRoot "tools\watch_stackchan_wake_test.ps1") -Raw +Require-PolicyAssertion $wakeWatcherText.Contains('$BaseUrl/debug') "tool-preflight: wake watcher must read exact /debug" +Require-PolicyAssertion (-not ($wakeWatcherText -match '(?i)(?:BaseUrl|DeviceHost)[^\r\n]{0,80}/status')) "tool-preflight: wake watcher still relies on unknown-route /status fallback" + +if ($issues.Count -gt 0) { + foreach ($issue in $issues) { Write-Output "ASSERTION FAILED: $issue" } + Write-Output "Firmware HTTP emergency-stop-only policy contract failed with $($issues.Count) named assertion(s)." + exit 1 +} + +Write-Host "Firmware HTTP emergency-stop-only policy contract tests passed for all 19 Wi-Fi environments." diff --git a/tools/test_stackchan_dashboard_launcher_contract.ps1 b/tools/test_stackchan_dashboard_launcher_contract.ps1 index 99bbbe2e..048f9ad3 100644 --- a/tools/test_stackchan_dashboard_launcher_contract.ps1 +++ b/tools/test_stackchan_dashboard_launcher_contract.ps1 @@ -7,6 +7,8 @@ $directmlLauncher = Join-Path $PSScriptRoot "start_pc_brain_directml.ps1" $packager = Join-Path $PSScriptRoot "package_release.ps1" $packageVerifier = Join-Path $PSScriptRoot "verify_release_package.ps1" $icon = Join-Path $PSScriptRoot "..\docs\store-assets\desktop\stackchan-alive.ico" +$dashboardService = Join-Path $PSScriptRoot "..\bridge\dashboard_service.py" +$dashboardApp = Join-Path $PSScriptRoot "..\bridge\dashboard\app.js" foreach ($path in @($launcher, $installer, $baseLauncher, $directmlLauncher, $packager, $packageVerifier)) { $tokens = $null @@ -74,6 +76,67 @@ if ($launcherText -match 'EnableRoomObservation\s*=\s*\$true') { throw "Reset-safe dashboard startup must leave room observation default-off." } +$dashboardServiceText = Get-Content -LiteralPath $dashboardService -Raw +foreach ($required in @( + '"debug_http_control_policy"', + '"motionResumeAvailable"', + '"motionResumePolicy"', + 'emergency_stop_only', + 'firmware permits emergency stop only' +)) { + if (-not $dashboardServiceText.Contains($required)) { + throw "Dashboard motion policy assertion failed: missing $required" + } +} +$setMotionStart = $dashboardServiceText.IndexOf(' def set_motion(') +$setMotionEnd = $dashboardServiceText.IndexOf(' def set_initiative(', $setMotionStart) +if ($setMotionStart -lt 0 -or $setMotionEnd -le $setMotionStart) { + throw "Dashboard motion policy assertion failed: set_motion section is missing." +} +$setMotionText = $dashboardServiceText.Substring($setMotionStart, $setMotionEnd - $setMotionStart) +$resumeRefusalIndex = $setMotionText.IndexOf('firmware permits emergency stop only') +$endpointFetchIndex = $setMotionText.IndexOf('self._fetch_robot(endpoint') +if ($resumeRefusalIndex -lt 0 -or $endpointFetchIndex -lt 0 -or + $resumeRefusalIndex -gt $endpointFetchIndex) { + throw "Dashboard motion policy assertion failed: Resume refusal must precede robot fetch." +} + +$dashboardAppText = Get-Content -LiteralPath $dashboardApp -Raw +foreach ($required in @('robotClearCheck', 'motionResumePolicy', 'emergency_stop_only')) { + if (-not $dashboardAppText.Contains($required)) { + throw "Dashboard motion policy assertion failed: UI missing $required" + } +} + +function Get-BoundedJavascriptSection([string]$Text, [string]$StartMarker, [string]$EndMarker) { + $start = $Text.IndexOf($StartMarker) + if ($start -lt 0) { throw "Dashboard motion policy assertion failed: missing section start $StartMarker" } + $end = $Text.IndexOf($EndMarker, $start + $StartMarker.Length) + if ($end -le $start) { throw "Dashboard motion policy assertion failed: missing section end $EndMarker" } + return $Text.Substring($start, $end - $start) +} + +$renderMotionSection = Get-BoundedJavascriptSection $dashboardAppText 'function renderMotion(robot)' 'function renderEvents(events)' +$changeMotionSection = Get-BoundedJavascriptSection $dashboardAppText 'async function changeMotion(enabled)' 'async function changeInitiative(enabled)' +$clearCheckboxSection = Get-BoundedJavascriptSection $dashboardAppText '$("robotClearCheck").addEventListener("change"' '$("initiativeToggle").addEventListener("change"' + +$finallyStart = $changeMotionSection.IndexOf('finally {') +$finallyMatch = if ($finallyStart -ge 0) { + [regex]::Match($changeMotionSection.Substring($finallyStart), '(?s)^finally\s*\{(?.*?)\r?\n \}\r?\n\}') +} else { + [System.Text.RegularExpressions.Match]::Empty +} +$finallyBody = if ($finallyMatch.Success) { $finallyMatch.Groups['body'].Value } else { '' } +if (-not $finallyMatch.Success -or -not ($finallyBody -match '(?m)^\s*\$\("resumeMotionButton"\)\.disabled\s*=\s*!\(\$\("robotClearCheck"\)\.checked\s*&&\s*state\.status\?\.robot\?\.motionResumeAvailable\s*===\s*true\);\s*$')) { + throw "Dashboard motion policy assertion failed: changeMotion finally path lacks explicit Resume availability guard." +} +if (-not ($clearCheckboxSection -match '(?s)resumeMotionButton.*?motionResumeAvailable\s*===\s*true')) { + throw "Dashboard motion policy assertion failed: clear-checkbox path lacks explicit Resume availability guard." +} +if (-not ($renderMotionSection -match '(?s)motionResumeAvailable\s*!==\s*true.*?robotClearCheck.*?checked\s*=\s*false.*?robotClearCheck.*?disabled\s*=\s*true.*?resumeMotionButton.*?disabled\s*=\s*true')) { + throw "Dashboard motion policy assertion failed: render path must clear confirmation and disable Resume under contained/unknown policy." +} + $installerText = Get-Content -LiteralPath $installer -Raw foreach ($required in @( "WScript.Shell", diff --git a/tools/test_start_warm_rocm_full_system_soak_contract.ps1 b/tools/test_start_warm_rocm_full_system_soak_contract.ps1 index 08b35b8c..7929ca4e 100644 --- a/tools/test_start_warm_rocm_full_system_soak_contract.ps1 +++ b/tools/test_start_warm_rocm_full_system_soak_contract.ps1 @@ -3,6 +3,8 @@ $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $Path = Join-Path $RepoRoot "tools\start_warm_rocm_full_system_soak.ps1" $source = Get-Content -LiteralPath $Path -Raw $required = @( + "debug_http_control_policy", "emergency_stop_only", "motion_resume_unavailable", + "ControlPolicyContractProbe", "Get-FirmwareHttpControlPolicy", "OperatorPresent", "BodyClear", "ConfirmServoRisk", "Stop-MotionVerified", "initialMotionStop", "source-identity-preflight-failure.json", "clean pinned source commit", "sourceCommit", "runnerSourceCommit", "sourceDirty", "runtimePreflightReady", "runtime-preflight-failure.json", @@ -23,4 +25,16 @@ foreach ($fragment in $required) { throw "Warm ROCm soak wrapper contract missing fragment: $fragment" } } +$policyIndex = $source.IndexOf("debug_http_control_policy") +$guardCallIndex = $source.IndexOf('Assert-EmergencyStopOnlyMotionPolicy -Policy $controlPolicyPreflight') +$enableCallIndex = $source.IndexOf('$motionStart = Enable-MotionWithRetry') +$evidenceIndex = $source.IndexOf('New-Item -ItemType Directory -Force -Path $EvidenceRoot') +$rvcLaunchIndex = $source.IndexOf('.\tools\start_rvc_worker.ps1') +$brainLaunchIndex = $source.IndexOf('.\tools\start_pc_brain.ps1') +$runnerLaunchIndex = $source.IndexOf('$proc = Start-Process') +$guardedSites = @($enableCallIndex, $evidenceIndex, $rvcLaunchIndex, $brainLaunchIndex, $runnerLaunchIndex) +if ($policyIndex -lt 0 -or $guardCallIndex -lt 0 -or + @($guardedSites | Where-Object { $_ -lt 0 -or $_ -lt $guardCallIndex }).Count -gt 0) { + throw "Warm-soak policy assertion failed: capability refusal must dominate motion and every worker/runner launch." +} Write-Output "Warm ROCm full-system soak wrapper contract verified." diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index d7c0e51a..879177a2 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -778,6 +778,8 @@ $requiredFiles = @( "provenance/platformio.ini", "provenance/partitions_esp_sr_16.csv", "provenance/src/main.cpp", + "provenance/src/io/BridgeDebugHttpPolicy.hpp", + "provenance/src/io/BridgeDebugHttpPolicy.cpp", "provenance/src/io/CameraAdapter.hpp", "provenance/src/io/CameraAdapter.cpp", "provenance/src/io/BridgeClient.hpp", @@ -1395,7 +1397,7 @@ foreach ($pattern in @("On-device wake phrase", "microphone capture", "wake-gate throw "README.md missing mic capture status guidance: $pattern" } } -foreach ($pattern in @("public v0.2 release candidate", "Status as of July 13, 2026", "corrected exact paired candidate", "28807 s", "5643/5643", "77/77", "bounded final stop", "public build", "FIRST_DEPLOY_STATUS.md", "CONVERSATION_V2_ROADMAP.md")) { +foreach ($pattern in @("Status as of August 2, 2026", "private paired candidate", "historical", "owner-accepted exact-image physical evidence", "do not inherit that evidence", "installed SHA is", "presently unknown", "28807 s", "5643/5643", "77/77", "bounded final stop", "public build", "FIRST_DEPLOY_STATUS.md", "CONVERSATION_V2_ROADMAP.md")) { if ($repoReadmeText -notmatch [regex]::Escape($pattern)) { throw "README.md missing current release-candidate status or navigation: $pattern" } @@ -1437,7 +1439,7 @@ foreach ($pattern in @("../AGENTS.md", "BRAIN_MODEL.md", "CUSTOMIZING_THE_FACE.m } $conversationV2Text = Get-Content -LiteralPath (Join-PackagePath "docs/CONVERSATION_V2_ROADMAP.md") -Raw -foreach ($pattern in @("post-release feature", "REPLY_WINDOW", "echo guard", "privacy-filtered", "under 3 seconds", "zero truncation")) { +foreach ($pattern in @("outside the v1 promotion evidence", "runtime is unpromoted", "REPLY_WINDOW", "echo guard", "privacy-filtered", "under 3 seconds", "zero truncation")) { if ($conversationV2Text -notmatch [regex]::Escape($pattern)) { throw "docs/CONVERSATION_V2_ROADMAP.md missing bounded v2 architecture or acceptance guidance: $pattern" } @@ -1672,6 +1674,23 @@ foreach ($pattern in @("SpeechAdapter::begin", "SpeechAdapter::handleCue", "Spee } $mainText = Get-Content -LiteralPath (Join-PackagePath "provenance/src/main.cpp") -Raw +foreach ($pattern in @("BridgeDebugHttpPolicy.hpp", "evaluateBridgeDebugHttpRequestLine", "debug_http_control_policy", "emergency_stop_only", "serveBridgeDebugRejectionJson", "serveBridgeDebugAdmissionJson", "gBridgeDebugHttpEmergencyStops")) { + if ($mainText -notmatch [regex]::Escape($pattern)) { + throw "provenance/src/main.cpp missing SEC-002 emergency-stop-only containment: $pattern" + } +} +foreach ($forbidden in @('append(",\"debug_request\":\"%s\"', 'bridge_endpoint_pairing_code=', 'Serial.print(control.pairing.code)', 'requiredPairingCode()')) { + if ($mainText.Contains($forbidden)) { + throw "provenance/src/main.cpp exposes SEC-002/PRIV-001 forbidden diagnostic material: $forbidden" + } +} +$debugPolicyHeaderText = Get-Content -LiteralPath (Join-PackagePath "provenance/src/io/BridgeDebugHttpPolicy.hpp") -Raw +$debugPolicySourceText = Get-Content -LiteralPath (Join-PackagePath "provenance/src/io/BridgeDebugHttpPolicy.cpp") -Raw +foreach ($pattern in @("BridgeDebugHttpDecision", "BridgeDebugHttpDisposition", "requestLineInvalid", "RejectForbidden", "RejectUriTooLong")) { + if (($debugPolicyHeaderText + $debugPolicySourceText) -notmatch [regex]::Escape($pattern)) { + throw "firmware debug HTTP policy provenance missing exact containment support: $pattern" + } +} foreach ($pattern in @("gFaceControlQueue", "gMotionControlQueue", "FaceControlInput", "MotionControlInput", "publishFaceControl", "publishMotionControl", "applyFaceControlInput", "applyMotionControlInput", "publishAudioOutSpeechFrame", "publishBridgeSpeechFrame", "handleBridgeOutput", "pollBridgeOutputs", "BridgeClient", "BridgeAudioDownlink", "BridgeAudioDownlinkSink", "BridgeAudioUplink", "BridgeWakeGate", "BridgeEndpointRegistry", "BridgeEndpointControl", "BridgeEndpointStore", "BridgeWiFiProvisioner", "BridgeNetworkSession", "BridgeWiFiClientSocket", "updateBridgeNetwork", "gBridge", "gBridgeAudioDownlink", "gBridgeAudioUplink", "gBridgeWakeGate", "gBridgeEndpointRegistry", "gBridgeEndpointControl", "gBridgeEndpointStore", "gBridgeWiFi", "gBridgeNetworkSession", "gBridge.update", "gBridgeEndpointStore.load", "gBridgeEndpointControl.attachStore", "gBridgeEndpointControl.update", "gBridgeWiFi.begin", "gBridgeNetworkSession.begin", "gBridgeAudioUplink.begin", "gBridgeWakeGate.begin", "gBridgeWakeGate.update", "gBridgeWakeGate.applyEvent", "gBridgeNetworkSession.update", "handleEndpointControlLine", "handleBridgeUplinkBench", "printBridgeUplinkResult", "submitPcmBytes", "beginTurn", "endTurn", "bench_audio_uplink_abort", "bridge_ready=", "bridge_state=", "bridge_messages=", "bridge_outputs=", "bridge_parse_errors=", "bridge_audio_stream_bytes_received=", "bridge_audio_stream_chunks=", "bridge_audio_stream_errors=", "bridge_endpoint_registry_ready=", "bridge_endpoint_count=", "bridge_endpoint_active=", "bridge_endpoint_restores=", "bridge_endpoint_control_ready=", "bridge_endpoint_messages=", "bridge_endpoint_rejected=", "bridge_endpoint_persistence_saves=", "bridge_endpoint_persistence_errors=", "bridge_endpoint_store_ready=", "bridge_endpoint_store_loads=", "bridge_endpoint_store_saves=", "bridge_endpoint_store_loaded=", "bridge_endpoint_store_saved=", "bridge_endpoint_store_parse_errors=", "bridge_endpoint_store_write_errors=", "bridge_wifi_ready=", "bridge_wifi_configured=", "bridge_wifi_connected=", "bridge_network_state=", "bridge_network_writer_frames=", "bridge_network_writer_text_frames=", "bridge_network_writer_binary_frames=", "bridge_network_text_queued=", "bridge_network_text_dropped=", "bridge_network_binary_queued=", "bridge_network_binary_dropped=", "bridge_downlink_ready=", "bridge_downlink_active=", "bridge_downlink_streams=", "bridge_downlink_completed=", "bridge_downlink_chunks=", "bridge_downlink_bytes=", "bridge_downlink_errors=", "bridge_downlink_playback_ready=", "bridge_downlink_playback_active=", "bridge_downlink_playback_starts=", "bridge_downlink_playback_chunks=", "bridge_downlink_playback_bytes=", "bridge_downlink_playback_unsupported=", "bridge_downlink_playback_errors=", "bridge_uplink_ready=", "bridge_uplink_enabled=", "bridge_uplink_active=", "bridge_uplink_wake_gate_required=", "bridge_uplink_turns=", "bridge_uplink_completed=", "bridge_uplink_chunks=", "bridge_uplink_bytes=", "bridge_uplink_errors=", "bridge_uplink_gate_blocks=", "bridge_uplink_queue_failures=", "bridge_wake_gate_ready=", "bridge_wake_gate_open=", "bridge_wake_gate_turn_active=", "bridge_wake_gate_opens=", "bridge_wake_gate_completed=", "bridge_timeouts=", "[bridge]", "[bridge_uplink]", "[endpoint]", "audio_stream_chunk", "chunk_index=", "chunk_bytes=", "payload_bytes=", "received_bytes=", "M5SpeakerAudioSink", "FirmwareVoiceAssets.hpp", "firmware_voice::find", "M5.Speaker.playWav", "M5.Speaker.playRaw", "STACKCHAN_ENABLE_SPEAKER", "gAudioOut.pollSpeechFrame", "gAudioOut.duck", "gFace.setReducedMotion", "gIntent.setReducedMotion", "gIntent.queueSpeechCue", "gIntent.applyAmbient", "gIntent.applyCircadian", "gActuation.setEnabled", "gIntent.setDemoEnabled", "gActuation.isEnabled", "gIntent.isDemoEnabled", "gFace.isReducedMotion", "gFace.speechTelemetry", "gCamera", "gCamera.poll", "gAudioOut", "gSpeechAdapter", "gSpeechAdapter.handleCue", "printSpeechPlayback", "printAudioOutPlayback", "[speech_audio]", "[audio_out]", "prompt_wav=", "prompt_sidecar=", "audio_out_ready=", "audio_out_hw_ready=", "audio_out_requests=", "audio_out_playing=", "audio_out_frames=", "audio_out_hw_frames=", "audio_out_hw_drops=", "sidecar_frames=", "playback_ms=", "hw_ready=", "hw_playing=", "hw_starts=", "earcon_checksum=", "speech_adapter_ready=", "speech_adapter_hw=", "speech_cues=", "speech_earcons=", "printVisionTelemetry", "[vision] event=", "camera_ready=", "camera_hw=", "camera_active=", "camera_events=", "payload_x=", "payload_y=", "payload_z=", "cue_intent=", "cue_earcon=", "picked_up", "shaken", "put_down", "tilted", "sound_direction", "loud_noise", "printAudioTelemetry", "[audio] event=", "detect_ms=", "frame_ms=", "latency_ms=", "azimuth_deg=", "reduced_motion=", "motion_enabled=", "demo_enabled", "ambient_lux=", "circadian_hour=", "hour=", "speech_active=", "[runtime]", "[motion] requested=", "wantsStatus", "printHeartbeat", "printSystemTelemetry", "printRuntimeStatus")) { if ($mainText -notmatch [regex]::Escape($pattern)) { throw "provenance/src/main.cpp missing bench control support: $pattern" @@ -2327,14 +2346,14 @@ foreach ($pattern in @("LanBridgeSession", "LanBridgeConfig", "BridgeControlStat } $dashboardServiceText = Get-Content -LiteralPath (Join-PackagePath "bridge/dashboard_service.py") -Raw -foreach ($pattern in @("stackchan.bridge-dashboard.v1", "ThreadingHTTPServer", "/api/status", "/api/refresh", "/api/motion", "/motion-stop", "/motion-resume", "robot_clear", "servo_rail_enabled", "servo_torque_enabled", "motion_thermal_suppressed", "motion_power_suppressed", "X-Stackchan-Dashboard", "Content-Security-Policy", "Dashboard must bind to a loopback host.")) { +foreach ($pattern in @("stackchan.bridge-dashboard.v1", "ThreadingHTTPServer", "/api/status", "/api/refresh", "/api/motion", "/motion-stop", "robot_clear", "debug_http_control_policy", "emergency_stop_only", "motionResumeAvailable", "motionResumePolicy", "firmware permits emergency stop only", "servo_rail_enabled", "servo_torque_enabled", "motion_thermal_suppressed", "motion_power_suppressed", "X-Stackchan-Dashboard", "Content-Security-Policy", "Dashboard must bind to a loopback host.")) { if ($dashboardServiceText -notmatch [regex]::Escape($pattern)) { throw "bridge/dashboard_service.py missing dashboard safety support: $pattern" } } $dashboardTestText = Get-Content -LiteralPath (Join-PackagePath "bridge/test_dashboard_service.py") -Raw -foreach ($pattern in @("DashboardRuntimeTests", "DashboardHttpTests", "DashboardBridgeIntegrationTests", "test_stop_requires_motion_rail_and_torque_verification", "test_cross_origin_write_is_rejected", "test_bridge_dashboard_receives_live_robot_heartbeat")) { +foreach ($pattern in @("DashboardRuntimeTests", "DashboardHttpTests", "DashboardBridgeIntegrationTests", "test_stop_requires_motion_rail_and_torque_verification", "test_stop_accepts_bounded_admission_response_and_verifies_state", "test_stop_remains_available_under_emergency_stop_only_policy", "test_motion_resume_policy_missing_unknown_malformed_and_stale_fail_closed", "test_cross_origin_write_is_rejected", "test_bridge_dashboard_receives_live_robot_heartbeat")) { if ($dashboardTestText -notmatch [regex]::Escape($pattern)) { throw "bridge/test_dashboard_service.py missing dashboard coverage: $pattern" } @@ -2349,7 +2368,7 @@ foreach ($pattern in @("Stop motion", "Robot is upright and clear", "Resume moti foreach ($pattern in @("aspect-ratio: 1", "env(safe-area-inset-top)", "env(safe-area-inset-bottom)", ".mobile-nav")) { if ($dashboardCss -notmatch [regex]::Escape($pattern)) { throw "Dashboard CSS missing responsive contract: $pattern" } } -foreach ($pattern in @("robot_clear", "/api/motion", "resumeMotionButton", "setInterval")) { +foreach ($pattern in @("robot_clear", "/api/motion", "resumeMotionButton", "motionResumeAvailable", "motionResumePolicy", "emergency_stop_only", "setInterval")) { if ($dashboardJs -notmatch [regex]::Escape($pattern)) { throw "Dashboard JavaScript missing behavior: $pattern" } } diff --git a/tools/watch_stackchan_wake_test.ps1 b/tools/watch_stackchan_wake_test.ps1 index 4674e943..851a6773 100644 --- a/tools/watch_stackchan_wake_test.ps1 +++ b/tools/watch_stackchan_wake_test.ps1 @@ -8,11 +8,43 @@ param( [switch]$SkipReset, [switch]$PlayTone, [switch]$RequireWake, - [switch]$Json + [switch]$Json, + [switch]$ControlPolicyContractProbe ) $ErrorActionPreference = "Stop" +function Assert-EmergencyStopOnlyWakePolicy { + param([string]$Policy) + throw "wake_control_unavailable: emergency_stop_only permits observation and emergency stops only; wake reset and tone playback are disabled." +} + +if ($ControlPolicyContractProbe) { + Assert-EmergencyStopOnlyWakePolicy -Policy "emergency_stop_only" +} + +function Get-FirmwareHttpControlPolicy { + $policyBaseUrl = $BaseUrl + if ($policyBaseUrl -eq "") { + $policyBaseUrl = "http://$DeviceHost`:8789" + } + try { + $probe = Invoke-RestMethod -Uri "$policyBaseUrl/debug" -TimeoutSec 5 + } catch { + return "unknown" + } + $policy = $probe.debug_http_control_policy + if ($policy -is [string] -and $policy -ceq "emergency_stop_only") { + return $policy + } + return "unknown" +} + +$controlPolicyPreflight = Get-FirmwareHttpControlPolicy +if (-not $SkipReset -or $PlayTone) { + Assert-EmergencyStopOnlyWakePolicy -Policy $controlPolicyPreflight +} + $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $RepoRoot @@ -37,7 +69,7 @@ function Get-DoubleValue { } function Read-Status { - Invoke-RestMethod -Uri "$BaseUrl/status" -TimeoutSec 5 + Invoke-RestMethod -Uri "$BaseUrl/debug" -TimeoutSec 5 } New-Item -ItemType Directory -Force -Path $ReportDir | Out-Null From aa7dfb9ca077704dca84bc5635fbb2142e13e47c Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Sun, 2 Aug 2026 23:08:47 -0400 Subject: [PATCH 06/46] test: pin firmware HTTP containment scope --- ..._firmware_http_control_policy_contract.ps1 | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tools/test_firmware_http_control_policy_contract.ps1 b/tools/test_firmware_http_control_policy_contract.ps1 index 6c48b693..8b9363cb 100644 --- a/tools/test_firmware_http_control_policy_contract.ps1 +++ b/tools/test_firmware_http_control_policy_contract.ps1 @@ -530,6 +530,7 @@ function Get-BoundedSourceSection([string]$Text, [string]$StartMarker, [string]$ $frozenPreregCommit = 'd75c62f37f8ff6e1c6cf49bc2c4c01479cd4f02f' $approvedPackagePrerequisiteCommit = '2ed5bb6ad4755129b61aa0f636f0b654a3493d86' +$approvedImplementationCommit = '4d31de414f5f2279b4c423ac3dfd7e940bb540d9' & git cat-file -e "$frozenPreregCommit`:src/main.cpp" 2>$null $frozenCommitAvailable = $LASTEXITCODE -eq 0 Require-PolicyAssertion $frozenCommitAvailable "invariant: frozen preregistration source commit is unavailable" @@ -551,6 +552,16 @@ Require-PolicyAssertion ($packagePrerequisiteFiles.Count -eq 1 -and & git merge-base --is-ancestor $approvedPackagePrerequisiteCommit HEAD $candidateDescendsFromPackagePrerequisite = $LASTEXITCODE -eq 0 Require-PolicyAssertion $candidateDescendsFromPackagePrerequisite "scope: candidate does not descend from the approved package prerequisite" +& git cat-file -e "$approvedImplementationCommit`^{commit}" 2>$null +$implementationCommitAvailable = $LASTEXITCODE -eq 0 +Require-PolicyAssertion $implementationCommitAvailable "scope: approved implementation commit is unavailable" +$implementationCommitParent = if ($implementationCommitAvailable) { + (& git rev-parse "$approvedImplementationCommit`^").Trim() +} else { "" } +Require-PolicyAssertion ($implementationCommitParent -ceq $approvedPackagePrerequisiteCommit) "scope: approved implementation does not directly follow the package prerequisite" +& git merge-base --is-ancestor $approvedImplementationCommit HEAD +$candidateDescendsFromImplementation = $LASTEXITCODE -eq 0 +Require-PolicyAssertion $candidateDescendsFromImplementation "scope: candidate does not descend from the approved implementation" $baselineMainText = if ($frozenCommitAvailable) { ((& git show "$frozenPreregCommit`:src/main.cpp") -join "`n") } else { "" } foreach ($frozenSection in @( @{ Start = '#ifndef STACKCHAN_OTA_PORT'; End = '#ifndef STACKCHAN_BASE_USB_POWER_INPUT'; Name = 'OTA port token digest and health configuration' }, @@ -642,12 +653,12 @@ $allowedChangedFiles = @( 'tools/watch_stackchan_wake_test.ps1', 'tools/verify_release_package.ps1', 'docs/BRIDGE_PROTOCOL.md', 'docs/BRIDGE_DASHBOARD.md', 'docs/ARRIVAL_DAY_RUNBOOK.md' ) -$changedFiles = @(& git diff --name-only $approvedPackagePrerequisiteCommit HEAD) -$changedFiles += @(& git status --porcelain=v1 --untracked-files=all | ForEach-Object { - if ($_.Length -ge 4) { $_.Substring(3).Replace('\', '/') } -}) -foreach ($changedFile in @($changedFiles | Sort-Object -Unique)) { - Require-PolicyAssertion ($allowedChangedFiles -contains $changedFile) "scope: unpreregistered changed file $changedFile" +$implementationFiles = @(& git diff --name-only $approvedPackagePrerequisiteCommit $approvedImplementationCommit) +foreach ($implementationFile in @($implementationFiles | Sort-Object -Unique)) { + Require-PolicyAssertion ($allowedChangedFiles -contains $implementationFile) "scope: approved implementation contains unpreregistered file $implementationFile" +} +foreach ($allowedChangedFile in $allowedChangedFiles) { + Require-PolicyAssertion ($implementationFiles -contains $allowedChangedFile) "scope: approved implementation is missing reviewed file $allowedChangedFile" } $grayStart = $mainText.IndexOf("void serveCameraGrayFrame") From 10a1b28370a40340ccba5ec70ca50758029ffe4c Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Sun, 2 Aug 2026 23:14:20 -0400 Subject: [PATCH 07/46] docs: preregister person pet identity and motion --- INITIAL_RISK_REGISTER.md | 10 +- PROJECT_STATE.md | 75 ++-- RESEARCH_LEDGER.md | 72 ++++ TASK_LEDGER.md | 131 ++++++- ..._FOLLOW_IDENTITY_MOTION_PREREGISTRATION.md | 354 ++++++++++++++++++ 5 files changed, 602 insertions(+), 40 deletions(-) create mode 100644 docs/PERSON_PET_FOLLOW_IDENTITY_MOTION_PREREGISTRATION.md diff --git a/INITIAL_RISK_REGISTER.md b/INITIAL_RISK_REGISTER.md index 81ee3852..9bf3b7d3 100644 --- a/INITIAL_RISK_REGISTER.md +++ b/INITIAL_RISK_REGISTER.md @@ -4,8 +4,8 @@ Baseline: `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` Date: 2026-08-02 Status: all ten read-only audits reconciled; Milestone 0 documentation committed; `SEC-001` implemented, exact-tree verified, independently accepted, and not deployed; `SEC-002`/`PRIV-001` -implemented in a source candidate, independently source-accepted, source/simulator/release-build/ -package-regression gated, not deployed, and not physically qualified +implemented and committed as `4d31de41`, independently source-accepted, exact clean package-verified, +not deployed, and not physically qualified Priority is non-compensatory: P0 trust/privacy/safety violations are addressed before experience features. “Reproduced” means source/synthetic evidence demonstrates the defect; it is not a claim @@ -14,8 +14,8 @@ that a private or physical incident occurred. | ID | Priority | Risk and current evidence | User consequence | Mitigation / acceptance | Status | | --- | --- | --- | --- | --- | --- | | R-000A | P0 | **PC bridge LAN admission was fail-open in the pre-`SEC-001` committed and last-observed deployed baseline — source-observed and synthetically confirmed.** Production launcher bound all interfaces; existing path/protocol/device/peer/origin signals were unenforced, dispatch was not conditioned on a validated upgrade, and blank endpoint identity bypassed an active owner. | Untrusted LAN peer could access private behavior, inject turns, or deny the single-client brain service. | Existing-signal admission hardening at HTTP upgrade, configured peer for non-loopback, no browser origin, explicit unadmitted-session rejection, compatibility/wrong-peer/reconnect tests. Do not label as cryptographic auth. | Live exposure contained; commit `9c72f020`, exact tree `28a62773`, independently accepted, not deployed | -| R-000B | P0 | **Firmware HTTP mutation is unauthenticated in the last source-observed baseline and installed-image identity is unknown; the source candidate contains it before side effects.** Wi-Fi profiles previously routed diagnostic tone, wake-reset, motion-enable, recovery, and reboot-class requests without owner authority. | LAN peer can request physical or recovery changes unless the contained source is built, qualified, and deployed exactly. | Exhaustive policy permits only bounded status, emergency stops, and existing paired camera operations; all other mutation fails closed before effects in all 19 effective Wi-Fi profiles. Expected-red, native, config, dashboard/tool, simulator, release-build, and package-regression gates passed; exact post-commit binary/package identity, exact-image no-motion, and later physical gates remain. | Source-contained candidate independently accepted; undeployed, physically unqualified; risk open `SEC-002` | -| R-000C | P0 | **Unauthenticated wake PCM export exists in the last source-observed baseline and installed-image identity is unknown; the source candidate denies it.** `/wake.wav` and `/wake-pcm.wav` previously returned recent 16 kHz microphone-ring PCM without pairing. | LAN peer could retrieve recent ambient speech/audio unless the contained source is built, qualified, and deployed exactly. | Deny both aliases before reading the PCM ring, constructing a WAV response, or exporting bytes in every Wi-Fi/release profile; do not change on-device capture/wake gating/model; silent privacy gate; no raw-audio fixture, request, log, or inspection; future export requires separate authentication, consent, retention, and private-artifact transport. | Source-contained candidate independently accepted; silent privacy/source gates passed without PCM access; undeployed, physically unqualified; risk open `PRIV-001` | +| R-000B | P0 | **Firmware HTTP mutation is unauthenticated in the last source-observed baseline and installed-image identity is unknown; commit `4d31de41` contains it before side effects.** Wi-Fi profiles previously routed diagnostic tone, wake-reset, motion-enable, recovery, and reboot-class requests without owner authority. | LAN peer can request physical or recovery changes unless the contained source is qualified and deployed exactly. | Exhaustive policy permits only bounded status, emergency stops, and existing paired camera operations; all other mutation fails closed before effects in all 19 effective Wi-Fi profiles. Expected-red, native, config, dashboard/tool, simulator, exact clean release-build/package, and independent source gates passed; exact-image no-motion and later physical gates remain. | Commit `4d31de41`; package full-image SHA-256 `4256f2e5...b31055`; undeployed, physically unqualified; risk open `SEC-002` | +| R-000C | P0 | **Unauthenticated wake PCM export exists in the last source-observed baseline and installed-image identity is unknown; commit `4d31de41` denies it.** `/wake.wav` and `/wake-pcm.wav` previously returned recent 16 kHz microphone-ring PCM without pairing. | LAN peer could retrieve recent ambient speech/audio unless the contained source is qualified and deployed exactly. | Deny both aliases before reading the PCM ring, constructing a WAV response, or exporting bytes in every Wi-Fi/release profile; do not change on-device capture/wake gating/model; silent privacy gate; no raw-audio fixture, request, log, or inspection; future export requires separate authentication, consent, retention, and private-artifact transport. | Commit `4d31de41`, exact clean package verified; silent privacy/source gates passed without PCM access; undeployed, physically unqualified; risk open `PRIV-001` | | R-001 | P0 | **Unauthorized memory mutation — reproduced.** Valid model-authored writes and wildcard forgets are applied without matching explicit current user authorization. | False personal memory or durable erasure during ordinary conversation. | Host sole-authorizer matching; adversarial ordinary/replay/wildcard/scope/tool tests produce zero deltas while explicit commands pass. | Open; `SAFE-001`, queued after stop-ship transport/control work | | R-002 | P0 | **False motion-safety label — reproduced.** Dashboard `motionVerified` checks only `motion_enabled`; UI can say safely stopped while rail/torque are true. | Operator may trust an unsafe or unknown passive actuator state. | Tri-state verification over motion, rail, torque, suppression, freshness; contradictory/missing UI/API tests. Command path remains frozen. | Open; product audit | | R-003 | P0 | **False current presence — reproduced.** FaceLost retains size while refreshing event time; heartbeat ignores `targetValid`. | False sensing claim, initiative, or social/private context use after presence is gone. | Baseline detect-to-repeated-lost reproduction, then zero false-current freshness; current target-valid plus bounded age; source/freshness consumer tests. | Open; `PERCEPT-001` | @@ -37,6 +37,8 @@ that a private or physical incident occurred. | R-019 | P1 | **Default launcher/onboarding is device/lab-specific — source/docs.** One IP default, multiple hidden runtime prerequisites, serial/menu Wi-Fi step. | New user cannot reliably install/configure/recover and may misdiagnose failure. | Single read-only first-run checker/wizard with explicit lab stop, privacy locations, service matrix, and recovery. | Open; product milestone | | R-020 | P1 | **Validated earcon is dropped — source trace.** Character validation retains earcon but `BridgeTurn`/wire omit it; Wi-Fi streamed responses cancel local response earcon. | Error/safety/thinking cues can be absent and documentation promises a channel that is not realized. | Define one earcon ownership/protocol contract; test wake/TTS/drain overlap and degraded channels before physical proof. | Open; expression milestone | | R-021 | P2 | **Documentation drift previously misstated current behavior/evidence.** Conversation timing, camera compilation, initiative status, desktop Python, Character Lock earcon, and historical status sections conflicted on the frozen baseline. | Operators could repeat stale procedures or promote unqualified behavior. | Evidence-preserving status/runbook/protocol reconciliation, stale-claim scan, contract gates, and independent consistency review. | Reconciled, independently accepted, and committed in `0e3467e7`; future drift remains monitored | +| R-022 | P0 | **Named human/pet recognition can create a durable false attribution or retain biometric identity after the owner asks to remove it.** Current source correctly has no identity enrollment or recognition; the requested feature is design-only. | Stackchan could misname a person/pet, expose private presence, or falsely claim deletion while a template or backup can still rematch. | Separate session nickname from durable recognition; authenticated owner-admin enrollment, consent, precision-first margin/temporal gates, isolated encrypted vault, complete list/rename/forget/disable/re-enable controls, tombstones, managed-backup deletion, restart/rematch verification, and no identity-derived authority. | Open future risk; `IDENT-001` held, recognition disabled | +| R-023 | P0 | **Personality motion tuning can bypass or obscure the final actuator/safety behavior if measured only before downstream overlays.** Current source retains fixed safety authority but also has boot-seeded/demo/blink/saccade randomness; no new policy exists. | Motion can hunt, snap, overstate emotion, or appear safe in simulation while final hardware commands differ. | Deterministic low-dimensional style only; trace final actuator output with every random/demo source fixed or recorded, bounded recorded variation, unchanged safety thresholds/coordinators, exact-image staged no-motion/HIL/follow/soak/post-stop gates. | Open future risk; `MOTION-001` preregistered only | ## Common Stop and Rollback Rules diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 66f70688..05f91284 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -6,20 +6,30 @@ State timestamp: 2026-08-02 America/New_York Keep stop-ship security work ahead of aliveness features. Milestone 0 and the independently verified `SEC-001` host admission repair are committed. `SEC-001` remains contained and undeployed. -The active working-tree candidate implements the preregistered, credential-free `SEC-002` firmware -HTTP policy: public HTTP may serve bounded operational status and emergency stops, while every -other mutating control fails closed before side effects. The directly coupled dashboard and -actuator-validation tools represent that restriction fail-closed. This is source, test, simulator, -secret-free compilation, and dirty-tree package-regression evidence only. Independent policy, -security, and documentation reviewers accepted the source slice. It is undeployed and physically -unqualified. No production service, installed firmware, or live robot behavior has been changed for -`SEC-002`. +`SEC-002`/`PRIV-001` is implemented and committed as `4d31de41`: public firmware HTTP may serve +bounded operational status and emergency stops, while every other mutating control fails closed +before side effects. Independent policy, security, and documentation reviewers accepted the source +slice, and an exact clean three-profile package at that commit verified. It remains undeployed and +physically unqualified. No production service, installed firmware, or live robot behavior has been +changed for `SEC-002`. + +The requested human/dog/cat following, natural naming/removal, and personality-shaped emotional +motion work is now a separate design-only lane in +`docs/PERSON_PET_FOLLOW_IDENTITY_MOTION_PREREGISTRATION.md`. It introduces no detector, identity, +protocol, dashboard, simulator, firmware, or live behavior. Anonymous classification/following is +ordered behind truthful presence; durable recognition is ordered behind memory sole-authorization, +owner-admin consent, and verified deletion; motion styling is limited to a deterministic low- +dimensional projection behind controlled-source final-actuator and physical-safety gates. ## Source Identity - Repository: `RobVanProd/stackchan_alive` - Working branch: `codex/aliveness-repository-truth` -- Current committed HEAD before the atomic `SEC-002` implementation commit: +- Current firmware/source implementation commit before this documentation-only future-lane update: + `4d31de414f5f2279b4c423ac3dfd7e940bb540d9` +- HTTP-containment contract-scope maintenance commit (test file only): + `aa7dfb9ca077704dca84bc5635fbb2142e13e47c` +- Separate package prerequisite commit: `2ed5bb6ad4755129b61aa0f636f0b654a3493d86` - Frozen `SEC-002` preregistration baseline: `d75c62f37f8ff6e1c6cf49bc2c4c01479cd4f02f` @@ -188,13 +198,13 @@ scope-expansion review; microphone capture, wake gating, or the wake model chang binary identity are unavailable before no-motion qualification; or deployment/risk closure is claimed before exact-image physical and release gates. -## SEC-002 / PRIV-001 Source Candidate Result +## SEC-002 / PRIV-001 Committed Result The preregistered expected-red phase was preserved before implementation: native policy tests failed only because the shared policy did not yet exist; dashboard cases failed their named missing/contained-capability assertions; and the firmware/tool contract cases failed their named -policy/preflight assertions. The current uncommitted candidate then earned the following -non-physical evidence on 2026-08-02: +policy/preflight assertions. Atomic implementation commit `4d31de41` earned the following non- +physical evidence on 2026-08-02: - native firmware logic passed 294/294; - the focused dashboard service suite passed 28 tests and full bridge discovery passed 567 tests; @@ -211,13 +221,24 @@ non-physical evidence on 2026-08-02: package was invoked through PlatformIO's Python interpreter for this isolated build. No upload target was invoked. -Dirty-tree release-package assembly and verification passed after the conversation-harness package -prerequisite was committed separately as `2ed5bb6a`; independent policy, security, and -documentation/authority reviews accepted the exact source slice. The regression build and package -predate the final atomic source commit and are not exact-image candidates for deployment. `R-000B` -and `R-000C` remain open until a clean committed source identity has matching binary/package hashes, -independently approved exact-image no-motion qualification, supervised physical emergency-stop -evidence, and final release gates all pass. +Dirty-tree release-package regression passed after the conversation-harness prerequisite was +committed separately as `2ed5bb6a`; independent policy, security, and documentation/authority +reviews then accepted the exact source slice. After implementation commit `4d31de41`, the clean +package `sec-002-4d31de41` rebuilt all three profiles, recorded manifest `dirty:false` and full +commit `4d31de414f5f2279b4c423ac3dfd7e940bb540d9`, and verified: + +- display-only firmware SHA-256: + `4967d2705087c52b07550293fa732d85a54b8917631f24a562bb1a4f011e84e9`; +- servo-calibration firmware SHA-256: + `99a9d77a1b4ef3ed55b260deaeac78f94c2aa3d8b01cd43abc961588e277a101`; +- full-online firmware SHA-256: + `4256f2e5f4a5567361a97796cfc2a81e7de24ec7f2202fcfb7c9c4cfc1b31055`; and +- ZIP SHA-256: + `b69ecc755455db1db66a174fc40ffd0b8b7795161387f0b44e5e4b39f1174b96`. + +No upload target was invoked. `R-000B` and `R-000C` remain open until an independently approved +exact-image no-motion qualification, supervised physical emergency-stop evidence, installed-image +identity, and final release gates all pass. ## SEC-001 Frozen Preregistration @@ -468,11 +489,11 @@ qualifies the installed firmware or authorizes a service restart. ## Exact Next Action -Create the one atomic `SEC-002`/`PRIV-001` implementation commit containing this record, rerun the -exact source gates, and produce clean source/binary/package identities from that commit. Do not -design credentials or read a pairing file. Production restart and every firmware/hardware action -remain unauthorized until a separately approved exact-image no-motion qualification, supervised -emergency-stop proof, and final release gates are earned. +Keep `SEC-002`/`PRIV-001` ahead of the queued aliveness lanes: independently authorize and execute +the exact `4d31de41` no-motion device qualification, then supervised emergency-stop proof and final +release gates with the package hashes above. Do not design credentials or read a pairing file. +`PERCEPT-002`, `IDENT-001`, and `MOTION-001` remain preregistration/research only until their ordered +dependencies, expected-red tests, and explicit recognition/physical promotion checkpoints pass. ## Unauthorized Actions @@ -484,8 +505,10 @@ emergency-stop proof, and final release gates are earned. - No release publication, tag, push, PR mutation, branch deletion, force-push, or evidence deletion. - No wake-WAV request, raw microphone read/inspection, pairing-code read or file transport, or fallback from a denied HTTP control. -- No remote/Away infrastructure, credential, pairing, privacy-policy, sensitive-memory, - identity-recognition, always-listening, cloud-required, or model-physical-authority work. +- No remote/Away infrastructure, credential, pairing, fundamental privacy-policy, sensitive-memory, + automatic identity-recognition enablement, always-listening, cloud-required, or model-physical- + authority work. Public/primary research and documentation-only identity/motion preregistration are + permitted; no private identity value, frame, embedding, enrollment, or live behavior is. - No human study, paid service, destructive hardware action, or cross-repository modification. ## Rollback Path diff --git a/RESEARCH_LEDGER.md b/RESEARCH_LEDGER.md index 63635c3f..3fcfa68d 100644 --- a/RESEARCH_LEDGER.md +++ b/RESEARCH_LEDGER.md @@ -167,6 +167,78 @@ versioned public/synthetic trials and aggregate outcome IDs. while interaction counts alone do not predict comfort, autonomy, or wellbeing. - **Result after implementation:** Not run. +## RL-011 — Local Object Detection and Track Continuity + +- **Citation:** [YOLOX](https://arxiv.org/abs/2107.08430) and + [ByteTrack](https://arxiv.org/abs/2110.06864). +- **Source quality:** Primary model reports with released implementations and standard benchmarks. +- **Exact finding:** YOLOX reported a 0.91M-parameter Nano detector and ONNX deployment; ByteTrack + improved multi-object tracking benchmarks by associating lower-score detections instead of + discarding them outright. +- **Relevance:** A small host-local detector and continuity layer are plausible for anonymous + `human|dog|cat` attention without adding a new inference runtime. +- **Proposed mechanism:** Benchmark a hash-pinned, license-reviewed OpenCV-DNN detector, then use + class-separated ephemeral tracklets and explicit ambiguity margins before selecting one target. +- **Alternative interpretation:** COCO and MOT benchmarks do not represent the CoreS3 160x120 + grayscale view, household occlusion, pets, or Stackchan's compute/power timing. +- **Testable prediction:** Compared with per-frame size ranking, sticky track continuity reduces + unwanted target switches without increasing false-class acquisition or target-loss latency. +- **Result after implementation:** Not run; candidate models/weights are not approved assets. + +## RL-012 — Human Face Recognition Is a Separate Measured Mechanism + +- **Citation:** [OpenCV DNN-based YuNet/SFace detection and recognition](https://docs.opencv.org/4.11.0/d0/dd4/tutorial_dnn_face.html). +- **Source quality:** Official OpenCV implementation documentation and reference thresholds. +- **Exact finding:** OpenCV exposes separate local face detection, alignment/feature extraction, + and cosine/L2 matching operations. +- **Relevance:** It supplies a compatible local-only human recognition seam, but does not turn the + current YuNet detector into identity or authorization. +- **Proposed mechanism:** Keep recognition default-off, use explicit enrollment, calibrate + threshold plus second-best margin and temporal agreement on the actual paired camera, and return + `unknown` on every weak component. +- **Alternative interpretation:** Published/reference thresholds and full-resolution examples may + fail on Stackchan's small grayscale crops and household lighting. +- **Testable prediction:** Stackchan-specific calibration can define a precision-first operating + point; if it cannot, durable human recognition remains disabled. +- **Result after implementation:** Not run. + +## RL-013 — Animal Identity Is Not Human Re-Identification With New Labels + +- **Citation:** [PetFace](https://www.ecva.net/papers/eccv_2024/papers_ECCV/papers/02660.pdf), + [OpenAnimals](https://openaccess.thecvf.com/content/ICCV2025/html/Hou_OpenAnimals_Revisiting_Person_Re-Identification_for_Animals_Towards_Better_Generalization_ICCV_2025_paper.html), + and [DINOv2](https://arxiv.org/abs/2304.07193). +- **Source quality:** Peer-reviewed ECCV/ICCV primary animal-identification work plus a primary + self-supervised visual-feature report. +- **Exact finding:** PetFace adds large multi-family animal face verification/re-identification + benchmarks; OpenAnimals reports that many person-ReID techniques do not generalize directly to + animals; DINOv2 provides general visual features but is not a pet identity product. +- **Relevance:** Dog/cat identity is technically plausible but materially harder than species + classification or session tracking. +- **Proposed mechanism:** Keep pet identity in shadow mode behind explicit owner enrollment; + benchmark a pet-specific model against a DINOv2 baseline and the simpler session-track baseline. +- **Alternative interpretation:** Benchmark datasets include different species, poses, image + quality, and environments; reported performance does not establish household precision. +- **Testable prediction:** Pet-specific re-identification will beat generic embeddings on + cross-session owner-collected public/synthetic fixtures; otherwise retain session names only. +- **Result after implementation:** Not run. + +## RL-014 — Bounded Variation Is a Test Method, Not Motor Authority + +- **Citation:** [Tobin et al., domain randomization for sim-to-real transfer](https://arxiv.org/abs/1703.06907). +- **Source quality:** Peer-reviewed primary sim-to-real robotics result. +- **Exact finding:** A policy trained across randomized simulated visual domains transferred to a + real task in the reported setting. +- **Relevance:** Varying plausible camera/timing/actuator conditions can expose fragile Stackchan + motion assumptions before scarce hardware trials. +- **Proposed mechanism:** Use controlled-source bounded sweeps and real trace system identification + around the real C++ motion path, fixing or recording its boot/demo/blink/saccade random sources; + do not train or deploy an end-to-end motor policy. +- **Alternative interpretation:** The published learned visual-control task differs from a + two-servo social head and does not prove that randomization alone closes this hardware gap. +- **Testable prediction:** Calibrated variation predicts the sign and envelope of physical trace + deviations better than a single nominal simulator, while all safety gates remain unchanged. +- **Result after implementation:** Not run. + ## Evaluation Audit Findings - The executable complaint qualification is a strong top-20 regression gate, not semantic coverage diff --git a/TASK_LEDGER.md b/TASK_LEDGER.md index fb9bf571..8c7c8221 100644 --- a/TASK_LEDGER.md +++ b/TASK_LEDGER.md @@ -455,16 +455,18 @@ Ledger timestamp: 2026-08-02 America/New_York or power off the robot and preserve evidence. - **Result:** Expected-red preserved; the source candidate represented by this record is implemented. Native logic passed 294/294, the exact policy passed all 19 Wi-Fi environments, - focused dashboard passed - 28 tests, full bridge discovery passed 567 tests, coupled operator/evidence contracts passed, + focused dashboard passed 28 tests, full bridge discovery passed 567 tests, coupled operator/ + evidence contracts passed, silent trusted-facts privacy smoke remained model/audio silent, the no-hardware simulator passed, and secret-free release compilation/link/image generation passed. Dirty-tree release-package assembly/verification passed, and independent policy, security, and documentation/authority - reviews accepted the exact source slice. No deploy, endpoint mutation, raw-audio request, reboot, - flash, OTA, or hardware exercise occurred; exact post-commit binary/package identity and physical - gates remain unearned. -- **Commit:** Frozen preregistration commit `d75c62f3`; the exact SHA of the atomic implementation - commit containing this record is assigned by Git and reported in the handoff. + reviews accepted the exact source slice. The atomic implementation is `4d31de41`; a subsequent + clean three-profile package at that commit verified with `dirty:false`, ZIP SHA-256 + `b69ecc75...174b96`, and full-image SHA-256 `4256f2e5...b31055`. No deploy, endpoint mutation, + raw-audio request, reboot, flash, OTA, or hardware exercise occurred; physical gates remain + unearned. +- **Commit:** Frozen preregistration `d75c62f3`; package prerequisite `2ed5bb6a`; atomic + implementation `4d31de414f5f2279b4c423ac3dfd7e940bb540d9`. - **Decision:** Stop-ship. Existing supervised Resume/motion-soak tooling has no approved authority after containment; keep the robot on a trusted isolated LAN or powered off until qualification. @@ -486,6 +488,115 @@ Ledger timestamp: 2026-08-02 America/New_York repository/evidence path. - **Future authority:** Any diagnostic export requires separately approved authentication, explicit consent, bounded retention, and private-artifact transport. -- **Result:** Implemented with `SEC-002` in the source candidate represented by this record. Pure policy/config - coverage and the silent privacy gate passed without requesting, reading, fixtureing, printing, or - archiving wake PCM. Undeployed and physically unqualified; the risk remains open. +- **Result:** Implemented with `SEC-002` in commit `4d31de41`. Pure policy/config coverage and the + silent privacy gate passed without requesting, reading, fixtureing, printing, or archiving wake + PCM. The exact clean package verified; it remains undeployed and physically unqualified, so the + risk remains open. + +## PERCEPT-002 — Classify and Follow One Anonymous Human, Dog, or Cat + +- **Problem:** The current paired vision worker detects faces only, sorts each frame independently, + and has no species or durable track continuity. Any fresh target is projected as a person. +- **User-facing consequence:** Stackchan cannot intentionally follow a dog or cat and can silently + switch geometric targets or make a false person claim. +- **Evidence:** Read-only source trace in + `docs/PERSON_PET_FOLLOW_IDENTITY_MOTION_PREREGISTRATION.md`; historical evidence proves only one- + human acquire/reacquire and slow horizontal follow. +- **Priority:** P1 aliveness/perception after P0 truth, memory, and operator-safety repairs. +- **Dependencies:** `PERCEPT-001`, `PRODUCT-001`, current camera-auth/privacy contract, and final + wake/listen/reply follow evidence. +- **Owner:** One future host/firmware attention-slice owner with independent privacy, hardware- + authority, model-provenance, and failure-injection reviewers. +- **Allowed files:** Freeze after expected-red tracing; likely local vision/model provenance, + bounded camera candidate protocol/attention/gaze consumers, typed host context, focused tests, + launcher/package contracts, and vision documentation. +- **Frozen systems:** Pairing, raw-frame non-retention, identity, generic memory, wake/audio, model + authority, MotionTask/PowerCoordinator/ActuationEngine authority, 50 ms display gate, live robot, + and completed evidence. +- **Acceptance tests:** Exact class allowlist; separated confidences; sticky single target across + jitter/reorder/short loss; ambiguity/crossing abstention; pet never audio-matched; stale pet never + described as person; no names/embeddings/private IDs on the firmware wire or diagnostics; model + hash/license/performance gates; native/bridge/simulator and staged physical gates. +- **Stop conditions:** Confident wrong class, silent target switch, raw-frame retention, unpaired or + remote vision, identity leakage, hunting/snap, missing stop evidence, or any safety/timing/power/ + thermal regression. +- **Result:** Preregistered only; no detector, protocol, behavior, service, or hardware change. +- **Commit:** Documentation-only preregistration commit containing this record; exact SHA assigned + by Git and reported in the handoff. +- **Decision:** Anonymous classified following precedes every durable identity experiment. + +## IDENT-001 — Natural Session Names and Owner-Controlled Durable Recognition + +- **Problem:** The current system intentionally has no identity enrollment, recognition, names, + biometric authority, identity vault, or verified removal surface. +- **User-facing consequence:** Stackchan cannot naturally remember who he is following; adding it + naively could misname people/pets, persist biometric data, or resurrect a deleted identity. +- **Evidence:** Current YuNet/room/memory/dashboard source trace plus independent privacy review; + PetFace/OpenAnimals show feasibility and substantial animal re-identification difficulty. +- **Priority:** P0 privacy within a later P1 experience feature. +- **Dependencies:** `SAFE-001`, `PERCEPT-001`, `PERCEPT-002`, authenticated owner-admin authority, + private vault/deletion design, and explicit recognition-enable approval. +- **Owner:** One future identity vertical-slice owner with independent privacy/security, memory- + truth, deletion, model-provenance, and product reviewers. +- **Allowed files:** Freeze per phase; likely new identity policy/store/isolated worker and tests, + local vision typed integration, owner-admin dashboard surfaces, memory/prompt isolation guards, + launcher/package verification, and privacy/vision documentation. +- **Frozen systems:** Recognition off by default; human/pet domains separated; names never guessed; + raw frames ephemeral; identity absent from generic memory/routine telemetry/firmware; recognition + never authentication or actuator/tool/memory authority; no cloud processing. +- **Acceptance tests:** Session nickname requires explicit wake-gated naming, one stable track, and + confirmation; its dedicated in-RAM bridge registry is isolated from `BridgeMemory`, histories, + logs, caches, and evidence and expires on every worker/bridge/conversation/track boundary. No + biometric work begins before fresh admin action and consent; every pending-enrollment abort path + destroys RAM/capture/template/index/vault state. Durable enrollment requires admin authority, + nonce, consent, exact-label confirmation, threshold/margin/multi-frame agreement, and human/pet + separation. Every rename/disable/re-enable uses fresh owner-admin authentication and its own nonce, + advances epochs, invalidates caches/context (including every old-label binding on rename), fails + closed on replay/auth loss/rollback, and preserves template isolation. Offline deletion covers RAM/ + index/templates/aliases/caches/managed backups and restart; tombstoned backup restore cannot rematch; + public packages contain no private identity artifact. +- **Stop conditions:** Automatic durable enrollment, passive/model-authored names, missing consent + or admin authority, false match/name transfer, raw-frame persistence, private diagnostic output, + incomplete deletion, uncontrolled backup, rollback resurrection, or unlicensed/unhashed model. +- **Result:** Preregistered and held. Session nickname and durable biometric recognition are + explicitly separate; neither is implemented or enabled. +- **Commit:** Documentation-only preregistration commit containing this record; exact SHA assigned + by Git and reported in the handoff. +- **Decision:** Recognition remains disabled until the separate explicit enable checkpoint passes. + +## MOTION-001 — Deterministic Emotional Motion and Sim/Real Trace Loop + +- **Problem:** Existing affect/gaze/idle/gesture behavior lacks one explicit persona motion-style + projection and calibrated final-actuator sim/real comparison. Production demo intent, blink, + saccade, and boot-seeded random sources mean trace determinism must be established under recorded + inputs/timing/seeds rather than assumed. +- **User-facing consequence:** Head motion can be technically safe yet feel slow, generic, or + disconnected from face/voice/personality; tuning only pre-actuator frames can miss real output. +- **Evidence:** Source trace shows `IntentEngine` composition followed by downstream + `ActuationEngine` idle sine, clamps, suppression, session, power, and hardware writes; the current + Python simulator is not a dynamics twin. +- **Priority:** P1 embodiment after P0 motion-state truth and physical containment gates. +- **Dependencies:** `PRODUCT-001`, exact SEC-002 physical qualification, `PERCEPT-002` for classified + follow scenarios, and a controlled-source final-actuator trace harness. +- **Owner:** One future firmware motion-style owner with independent hardware-authority, + personality, deterministic-trace, and physical-evidence reviewers. +- **Allowed files:** Freeze after expected-red trace; likely affect/intent/idle/gaze/style components, + native trace fixtures, orchestration/metrics, simulator contract, persona constants, and focused + documentation. Safety coordinators change only if an independent defect requires a separate task. +- **Frozen systems:** No LLM/RL motor authority; exact servo/session/power/thermal/stop limits and + 50 ms display gate; model proposes typed intent only; physical evidence remains hash-specific. +- **Acceptance tests:** With demo injection controlled and every boot/persona/blink/saccade/random + source fixed or recorded, the same input/timing/source schedule is trace-identical; bounded recorded + variants stay within final yaw/pitch/velocity/acceleration/jerk/settling envelopes; zero suppressed/ + expired writes; reduced motion includes downstream overlays; emotion metadata cannot change safety/ + power decisions; target loss/reacquire and gestures settle; staged no-motion/HIL/follow/soak/post- + stop evidence passes on the exact image. +- **Stop conditions:** Nonreproducible trace, unexplained sim/real sign/amplitude mismatch, unsafe + jerk/oscillation, hunting, bad motion state, weakened coordinator, missed stop/post-stop proof, or + timing/power/thermal regression. +- **Result:** Preregistered only; no motion equation, parameter, simulator, firmware, or hardware + behavior changed. +- **Commit:** Documentation-only preregistration commit containing this record; exact SHA assigned + by Git and reported in the handoff. +- **Decision:** Use deterministic low-dimensional styling and system identification; do not add an + end-to-end learned motion policy. diff --git a/docs/PERSON_PET_FOLLOW_IDENTITY_MOTION_PREREGISTRATION.md b/docs/PERSON_PET_FOLLOW_IDENTITY_MOTION_PREREGISTRATION.md new file mode 100644 index 00000000..6d94e1b2 --- /dev/null +++ b/docs/PERSON_PET_FOLLOW_IDENTITY_MOTION_PREREGISTRATION.md @@ -0,0 +1,354 @@ +# Person/Pet Follow, Named Identity, and Emotional Motion Preregistration + +Status: design-only future lane; no recognition, identity persistence, new detector, protocol, +dashboard control, or motion behavior is enabled by this document. + +Date: 2026-08-02 + +## Requested Experience + +Stackchan should distinguish `human`, `dog`, and `cat`, maintain one stable attention target, and +follow that target with his bounded eyes/head behavior. When appropriate, he should learn a name +naturally, use it only when the evidence is strong, and give the owner a trustworthy local place to +inspect, rename, forget, or disable every identity. His movement should express the same character +state as his face and voice without giving a model motor authority. + +“Follow” means camera attention, pupils, and bounded two-axis head orientation on the current +Stackchan hardware. This robot has no locomotion authority and must not claim that it can follow a +person or pet through a room. + +## Current Evidence + +- The paired local vision worker fetches one ephemeral 160x120 grayscale frame and runs hash-pinned + YuNet face detection. It sends at most four numeric face candidates; it has no species, durable + track, name, embedding, or identity state. +- Firmware selects one geometric face and fresh audio direction ranks the current face candidates. + Those candidates are currently human only because YuNet detects faces; firmware has no target-kind + gate. Bounded attention then passes through `CameraAdapter`, `IntentEngine`, `GazeTracker`, + `MotionTask`, `PowerCoordinator`, and `ActuationEngine`. +- Historical evidence covers one human face and a visually accepted but slow horizontal follow. + Final wake/listen/reply follow and multi-person active-speaker selection remain unpassed. +- `robot_embodiment.py` currently describes any fresh camera target as a person. Pet targets cannot + enter that path until the typed target-kind contract and stale/unknown behavior are repaired. +- Recognition, enrollment, embeddings, and names are intentionally disabled. The existing + dashboard loopback/header checks are not sufficient owner-admin authority for biometric data. +- Existing affect, mode, idle life, gaze, response gestures, reduced motion, power coordination, + and final actuator clamps have native coverage. The whole production trace is not byte- + deterministic by default: boot seeding, demo-intent injection, blink, and saccade paths consume + random sources. Reproducibility requires fixed inputs/timing/seeds with those sources disabled, + injected, or recorded by the trace harness. The Python hardware simulator is a protocol/virtual- + servo rehearsal, not a calibrated dynamics twin. + +These are source and historical-evidence facts. No human, dog, cat, identity, camera, or motion +experiment was run for this preregistration. + +## Dependency Order + +1. Shared truth/safety gates are `PERCEPT-001` for target-loss freshness, `SAFE-001` for durable- + memory authorization, `PRODUCT-001` for passive motion/rail/torque/thermal truth, and exact-image + `SEC-002` physical qualification where a later slice depends on HTTP containment. +2. `PERCEPT-002` additionally requires the current camera-auth/privacy contract and final wake/ + listen/reply follow evidence. It may then add anonymous `human|dog|cat|unknown` classification and + one session target. It is not identity recognition. +3. `IDENT-001` depends on `SAFE-001`, `PERCEPT-001`, `PERCEPT-002`, new authenticated owner-admin + authority, the private-vault/deletion design, and explicit recognition-enable approval. +4. `MOTION-001` does not depend on `IDENT-001`. It depends on `PRODUCT-001`, exact `SEC-002` physical + qualification, `PERCEPT-002` for classified-follow scenarios, and a controlled-source final- + actuator trace harness. Its expected-red/harness work may proceed in parallel with identity + design after the shared gates, but no physical style candidate can skip no-motion and supervised + actuator promotion. + +## Primary Hypotheses + +### H1 — Classified Anonymous Attention + +A local class detector plus ephemeral track continuity and a sticky single-target arbiter will +follow a human, dog, or cat more coherently than the current size-ranked face batch, while abstaining +on ambiguity and preserving every firmware safety boundary. + +### H2 — Natural Naming Without Guessing + +A two-level workflow will feel natural while remaining controllable: + +- an explicit wake-gated statement such as “that cat is Luna,” followed by one confirmation while + the same track remains fresh, may create a session-only nickname; and +- durable recognition requires a separate authenticated owner-admin confirmation, declared consent + scope, and a verified-deletion-capable identity vault. + +Stackchan never invents a name from appearance. Session naming is not biometric enrollment. + +### H3 — Deterministic Emotional Motion Style + +A small, bounded motion-style vector derived from authoritative affect, mode, energy, reduced-motion +state, and persona constants will improve face/voice/body coherence more reliably than an LLM or a +learned end-to-end motor policy. The hypothesis passes only if full final-actuator traces improve +without hunting, timing, power, thermal, stop, or personality regressions. + +## Architecture Boundary + +```text +paired ephemeral frame + -> local class detections (human/dog/cat/unknown) + -> ephemeral class-separated tracklets + -> one sticky attention target or ambiguous/none + -> bounded numeric geometry only across the existing firmware camera seam + -> CameraAdapter / IntentEngine / GazeTracker + -> MotionTask / PowerCoordinator / ActuationEngine + -> bounded final servo command + +fresh stable session track + explicit user naming + confirmation + -> session nickname only + -> optional owner-admin enrollment proposal + -> consent + confirmation + private identity vault + -> recognition result or unknown + -> bounded host conversational context only +``` + +Class, track, name, and recognition metadata are observations, never authority. They cannot open a +microphone, claim an active speaker, select the brain owner, retrieve private memory, invoke tools, +write generic memory, enable/refresh motion, change power, control OTA, or authorize identity +administration. A pet can never be marked `audioMatched` or active speaker. + +## PERCEPT-002 — Classified Anonymous Following + +The first implementation slice is host-first and identity-free: + +- Exact classes: `human`, `dog`, `cat`, `unknown`. +- Detection confidence, class confidence, track continuity, and future recognition confidence stay + separate. No combined “certainty” hides a weak component. +- Track IDs are random/opaque, session-local, bounded, and discarded on worker restart. They do not + enter durable memory or routine telemetry. +- New acquisition requires minimum detection/class thresholds, a top-two margin, and consecutive + agreement. Low confidence, a tie, crossing targets, or class conflict becomes `unknown` or + `ambiguous`; the arbiter briefly retains the prior lock without switching, then emits loss. +- Audio direction may rank fresh human tracks only. Dog/cat selection uses geometry, continuity, + explicit user attention, and configured preference—not speech attribution. +- The first slice freezes the existing firmware seam to bounded numeric geometry; kind, opaque track, + and name stay host-side. If geometry alone cannot satisfy the behavior, stop and preregister a + separate bounded kind/geometry/ephemeral-track protocol extension with native expected-red tests. +- The model may receive only typed, fresh, bounded target context. Stale/invalid/ambiguous input + projects `unknown`; free-form model output cannot forge an observation. + +The first detector experiment should use the existing OpenCV DNN runtime and a small COCO detector +behind a model-agnostic interface. OpenCV Zoo NanoDet/YOLOX and PaddleDetection PicoDet are candidate +sources, not approved package assets. The selected model and weights need exact hash, size, license, +training-source, class-map, latency, grayscale, and release-provenance review before addition. The +existing 160x120 grayscale input may be insufficient; if so, stop and separately preregister an +ephemeral higher-resolution paired capture rather than silently widening camera cost or retention. + +## IDENT-001 — Session Names, Durable Recognition, and Removal + +### Session Naming + +An explicit user statement may create a pending session nickname only when exactly one fresh, +stable, class-compatible target exists. Stackchan asks one bounded confirmation. Low STT confidence, +multiple targets, target loss/change, timeout, correction ambiguity, bridge loss, or name collision +cancels the proposal. + +The sole owner is a dedicated in-RAM registry in the main bridge/conversation process, keyed by the +current vision-worker generation plus opaque track ID. It uses a namespace separate from +`BridgeMemory.preferred_name` and every durable memory/delta path. The binding is destroyed on +vision-worker restart or generation change, bridge start/stop, conversation close, track expiry or +loss, target class change, ambiguity, explicit session forget, or its fixed timeout. No nickname or +name-target linkage may enter memory JSON or `.bak`, backups, episodes, open loops, session history, +transcripts, turn logs, routine telemetry, prompt caches, TTS caches, evidence, or generic memory; +the naming turn and generated speech must be redacted before any durable logging. A session nickname +never creates an embedding, template, enrollment artifact, or durable record. + +### Durable Enrollment + +Durable recognition remains separately default-off. A session nickname can offer only a label-only, +in-RAM proposal. No biometric capture, template computation, index mutation, or vault write begins +until a fresh authenticated owner-admin action and consent step after that proposal. Cancellation, +timeout, nonce expiry, authentication loss, consent withdrawal, target loss/change, worker failure, +or bridge shutdown destroys every pending RAM buffer, capture, template, index entry, and vault +transaction before returning to unnamed behavior. Enrollment then requires: + +- an authenticated local owner-admin session stronger than the existing dashboard custom header; +- a fresh single-use nonce, explicit typed confirmation, and recorded consent scope; +- owner attestation that a person consented, or owner/caretaker authority for a pet; +- the exact displayed label confirmed before the record leaves `pending`; +- a local, encrypted, versioned identity vault under ignored private output, never `BridgeMemory`; +- separate human and pet matcher domains, model hashes, thresholds, second-best margins, freshness, + and multi-frame consistency; and +- no raw capture retention after the in-memory enrollment operation. + +A recognized owner is still not authentication. Model changes require explicit re-enrollment; raw +frames are never retained to rebuild templates silently. Unknown/newer vault schemas or index +errors disable recognition and produce unnamed behavior. + +### Owner Removal Surface + +The local owner-admin surface lists only the minimum useful identity data: owner-chosen display +label, `human|dog|cat`, enabled/pending/revoked state, template count, model/policy version, and an +opaque record ID. Routine status, unauthenticated telemetry, logs, and firmware expose no name, ID, +candidate, score, embedding, or template count. + +Every rename, disable, or re-enable operation requires fresh owner-admin authentication and its own +single-use nonce. Rename additionally requires exact new-label confirmation, an atomic label-only +update, and an epoch advance; before success it purges the old label from active-track bindings, +conversational context, prompt/TTS queues, dashboard state, and every runtime cache. It does not +change templates. Disable first marks the record disabled and advances the epoch, cancels inference/ +enrollment, removes the record from active indexes, and invalidates active-track bindings, +conversational context, prompt/TTS queues, and dashboard caches; encrypted templates may remain only +in the private vault and must return `unknown`, including after restart. Re-enable additionally +requires a current consent-scope review, compatible model/policy versions, an epoch advance, and an +index rebuilt only from the still-authorized vault record. Revoked consent permits delete, not re- +enable. Rename, disable, and re-enable receipts have the same non-identifying shape as deletion +receipts. Unknown state/version, nonce replay/expiry, auth loss, failed cache invalidation, or +rollback/downgrade fails closed. + +Delete/forget is an offline-capable operation that does not require the robot or camera. It: + +1. atomically marks the record revoked and advances a recognition/deletion epoch; +2. cancels enrollment/inference and terminates the isolated worker if necessary; +3. removes RAM registries, indexes, templates, aliases, nickname caches, active-track bindings, + prompt/TTS queues, and dashboard state; +4. removes the record from the primary vault and every managed backup; +5. verifies that generic memory JSON and `.bak`, session history, episodes, open loops, turn logs, + and managed evidence/cache roots contain no identity linkage; +6. rebuilds the index only from remaining active records and proves the deleted identity returns + `unknown` after restart; and +7. emits a receipt containing booleans/counts and an opaque operation ID, never the name/template/ + score. + +The UI says `deleted` only after verification. Otherwise it says `deletion_pending` or +`deletion_failed`. Non-identifying tombstones prevent an old managed backup from resurrecting a +record. A downgrade that cannot enforce tombstones is blocked unless a full-vault purge verifies. +Cryptographic key erasure and managed-copy deletion are supportable claims; forensic erasure from +Python RAM, SSD wear-leveling, OS snapshots, or uncontrolled external backups is not. + +## MOTION-001 — Emotional Motion Policy + +Do not add reinforcement learning or an LLM motor policy. Add, if the expected-red experiment +supports it, a deterministic `MotionStyle` projection with bounded dimensions such as: + +- amplitude scale; +- velocity/acceleration scale; +- dwell and settling bias; +- gesture intensity/probability; +- gaze lead/lag and loss-search intensity; and +- breath/idle phase and variation limits. + +The projection consumes authoritative `EmotionalProfile`, `CharacterMode`, embodied energy, +reduced-motion state, attention state, and generated persona constants. It selects or parameterizes +existing safe primitives; it never contains angles, rail/torque decisions, power decisions, or +session authority. `MotionTask`, `PowerCoordinator`, and `ActuationEngine` remain sole physical +authorities and may decline every proposal. + +Tests and comparisons must observe final actuator commands because `ActuationEngine` currently adds +its own idle sine after `IntentEngine`. Measuring only `RobotFrame` would miss the true output. + +## Sim/Real Feedback Loop + +Use the real C++ components in a controlled-source trace harness; do not reimplement personality +equations in Python. The harness fixes or records every input, cadence, boot/persona seed, demo- +intent source, and blink/saccade/random draw; it must be able to disable production demo injection. +Reproducibility claims apply only to that explicit schedule/source record. Python may orchestrate +scenarios and compute reports only. + +The bounded parameter sweep varies: + +- intent/motion cadence jitter and stalled steps; +- detector coordinate jitter, dropout, latency, crossing, loss, and reacquisition; +- servo lag, deadband, backlash, speed, sign, and neutral offset; +- audio/power/thermal suppression timing and session expiry; and +- affect/persona seeds and reduced-motion state. + +It never randomizes servo limits, safety thresholds, display gate, thermal limits, emergency stops, +or power authority. Simulation failures select a mechanism to inspect; they do not tune around a +safety gate. + +Required metrics include final yaw/pitch bounds, maximum velocity/acceleration/jerk, overshoot, +settling time, target RMS error, unwanted target switches, face/body phase coherence, gesture peak/ +duration/return-to-base, reduced-motion ratio including downstream overlays, zero writes while +disabled/suppressed/expired, stop latency, task/display budget, and final rail/torque-off evidence. + +The physical loop is staged: + +1. deterministic native trace sweeps; +2. virtual actuator/protocol rehearsal; +3. display-only/no-motion timing; +4. motion-off paired camera classification/lock evidence; +5. operator-present/body-clear/servo-risk-confirmed low-amplitude HIL with emergency stop; +6. supervised classified-follow run; +7. exact-image integrated 60-minute and later long soak; and +8. verified runner termination, motion/rail/torque off, and post-stop `/debug` snapshot. + +No simulator score transfers to hardware. Every physical stage is bound to its exact firmware hash. + +## Frozen Expected-Red Tests + +Before implementation, preserve named failing cases for: + +- species allowlist and class-confidence margin; +- one lock surviving reorder, jitter, and short misses; +- ambiguity/crossing abstention without silent target/name transfer; +- pet targets never acquiring audio-match/active-speaker status; +- stale/invalid pet targets never being described as a person; +- no name, embedding, raw media, or durable ID in the firmware wire or routine diagnostics; +- session naming requiring explicit speech, one stable target, and confirmation; +- session nickname isolation from `BridgeMemory.preferred_name`, memory/backups/episodes/open loops, + transcripts/turn logs, caches, and evidence, plus expiry on every specified loss/restart boundary; +- durable enrollment rejecting missing owner-admin authority, nonce replay/expiry, consent absence, + model output, passive room observation, and unauthenticated API requests; +- pending enrollment destroying all RAM/capture/template/index/vault state on cancellation, timeout, + nonce expiry, auth loss, consent withdrawal, target loss/change, worker failure, or shutdown; +- human/pet matcher separation, threshold, top-two margin, staleness, and multi-frame consistency; +- authenticated rename and disable/re-enable transition/epoch/cache behavior, including nonce replay, + auth loss, consent revocation, restart, rollback, and downgrade failures; +- delete/restart/managed-backup restore never rematching or resurrecting a tombstoned identity; +- identity never authorizing memory, tools, microphone, camera, motion, power, OTA, or endpoint + ownership; +- controlled-source final-actuator trace reproducibility and bounded recorded seed variants; +- zero writes under suppression/timeout and bounded target-loss/reacquisition; +- reduced-motion bounds applying after every downstream actuator overlay; and +- identical safety/power decisions when only emotion/persona metadata changes. + +## Allowed/Frozen Scope + +Likely implementation files must be frozen per slice after expected-red source tracing. Candidate +areas are `bridge/vision_service.py`, a new isolated identity policy/store/worker, typed room and +embodiment projections, focused tests, owner-admin dashboard surfaces, the bounded camera protocol, +`CameraAdapter`, attention/gaze components, affect/intent/style components, native trace fixtures, +launch/package verification, and privacy/vision/protocol documentation. + +Frozen throughout: raw-frame non-retention, pairing grammar/authentication, wake and microphone +gates, generic memory sole-authorizer policy, model/firmware authority separation, automatic +recovery, OTA, 50 ms display gate, motion session timeout, servo limits, PowerCoordinator, +emergency stops, private artifacts, and completed evidence. No new cloud service is permitted. + +## Stop Conditions + +Stop and reject the candidate for any confident wrong class/name, target/name transfer, automatic +durable enrollment, missing consent/admin authority, raw-frame persistence, identity in logs/wire/ +generic memory, incomplete deletion, managed-backup resurrection, uncontrolled backup copies, +unlicensed/unhashed models, identity-derived authority, uncalibrated ambiguity thresholds, hunting, +snapping, unsafe jerk/oscillation, bad motion state, missing emergency-stop/post-stop evidence, +display/power/thermal regression, or missing exact source/binary identity. + +## Research Basis and Limits + +- [YOLOX](https://arxiv.org/abs/2107.08430) and the + [OpenCV model zoo](https://github.com/opencv/opencv_zoo) show small local object detectors and + OpenCV-DNN deployment paths; household grayscale performance and exact weight licenses still need + Stackchan-specific measurement. +- [ByteTrack](https://arxiv.org/abs/2110.06864) supports the hypothesis that low-score detections can + preserve track continuity, but its published benchmarks do not prove correctness on Stackchan's + 160x120 grayscale camera or justify identity persistence. +- [OpenCV YuNet/SFace documentation](https://docs.opencv.org/4.11.0/d0/dd4/tutorial_dnn_face.html) + provides a local human face detection/recognition seam; its reference thresholds are not accepted + until measured on the paired Stackchan pipeline. +- [PetFace](https://www.ecva.net/papers/eccv_2024/papers_ECCV/papers/02660.pdf) and + [OpenAnimals](https://openaccess.thecvf.com/content/ICCV2025/html/Hou_OpenAnimals_Revisiting_Person_Re-Identification_for_Animals_Towards_Better_Generalization_ICCV_2025_paper.html) + demonstrate active animal re-identification systems while documenting cross-species/pose/domain + difficulty. They justify an experiment, not a product claim. +- [DINOv2](https://arxiv.org/abs/2304.07193) is a candidate shadow embedding baseline; it is not a + validated household pet identity model. +- [Domain randomization](https://arxiv.org/abs/1703.06907) motivates testing across bounded + variation. This project uses it for deterministic robustness evaluation, not to grant a learned + policy motor authority or replace physical system identification. + +Rollback is removal of the exact atomic slice and its private derived vault/index, while preserving +failed evidence and tombstones needed to prevent identity resurrection. Never restore an insecure +or identity-bearing backup as an operational rollback. From 3c382c342773197a2fd8d4a4e0d292e478783bcb Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Mon, 3 Aug 2026 00:33:40 -0400 Subject: [PATCH 08/46] fix: send bridge admission headers in C6 rehearsal --- .../companion/desktop/BrainSupervisorRehearsal.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/BrainSupervisorRehearsal.kt b/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/BrainSupervisorRehearsal.kt index e573ccff..c7bb859c 100644 --- a/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/BrainSupervisorRehearsal.kt +++ b/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/BrainSupervisorRehearsal.kt @@ -280,7 +280,7 @@ internal fun DesktopManagedPythonRuntimeStatus.toBrainSupervisorEvidenceJson() = } internal fun driveBrainSupervisorTextTurn(port: Int, deviceId: String, seq: Int): BrainTurnEvidence { - BrainRehearsalClient.connectWithRetry("ws://127.0.0.1:$port/bridge").use { client -> + BrainRehearsalClient.connectWithRetry("ws://127.0.0.1:$port/bridge", deviceId).use { client -> val startedAt = System.nanoTime() val sessionHello = decodeControlMessage(client.nextText()) as BridgeHello client.send(encodeControlMessage(DeviceHello(deviceId = deviceId, capabilities = listOf("diagnostics")))) @@ -337,12 +337,12 @@ private class BrainRehearsalClient private constructor( } companion object { - fun connectWithRetry(uri: String): BrainRehearsalClient { + fun connectWithRetry(uri: String, deviceId: String): BrainRehearsalClient { val deadline = System.nanoTime() + Duration.ofSeconds(8).toNanos() var lastError: Throwable? = null while (System.nanoTime() < deadline) { try { - return connect(uri) + return connect(uri, deviceId) } catch (error: Throwable) { lastError = error if (!isRetryableConnectError(error)) { @@ -354,7 +354,7 @@ private class BrainRehearsalClient private constructor( throw IllegalStateException("timed out connecting to $uri", lastError) } - private fun connect(uri: String): BrainRehearsalClient { + private fun connect(uri: String, deviceId: String): BrainRehearsalClient { val messages = LinkedBlockingQueue() val listener = object : WebSocket.Listener { override fun onText( @@ -381,6 +381,8 @@ private class BrainRehearsalClient private constructor( val socket = HttpClient .newHttpClient() .newWebSocketBuilder() + .header("X-Stackchan-Protocol", CompanionIdentity.protocol) + .header("X-Stackchan-Device", deviceId) .connectTimeout(Duration.ofSeconds(2)) .buildAsync(URI.create(uri), listener) .get(Duration.ofSeconds(2).toMillis(), TimeUnit.MILLISECONDS) From b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Mon, 3 Aug 2026 01:25:37 -0400 Subject: [PATCH 09/46] fix: keep public release motion off at boot --- PROJECT_STATE.md | 25 ++++- TASK_LEDGER.md | 20 +++- docs/ARRIVAL_DAY_RUNBOOK.md | 10 ++ docs/FIRST_DEPLOY_STATUS.md | 18 ++++ platformio.ini | 5 +- tools/package_release.ps1 | 13 ++- ..._firmware_http_control_policy_contract.ps1 | 48 +++++++++- tools/test_release_boot_motion_contract.ps1 | 93 +++++++++++++++++-- 8 files changed, 204 insertions(+), 28 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 05f91284..1bf05648 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Project State -State timestamp: 2026-08-02 America/New_York +State timestamp: 2026-08-03 America/New_York ## Current Objective @@ -13,6 +13,16 @@ slice, and an exact clean three-profile package at that commit verified. It rema physically unqualified. No production service, installed firmware, or live robot behavior has been changed for `SEC-002`. +Qualification audit found that the preserved `4d31de41` public full image +`4256F2E5...B31055` is not a no-motion candidate: its effective configuration requests motion and +autonomous refresh at boot. That package remains immutable historical evidence and is superseded +for physical qualification. The selected correction keeps the public full profile motion-off at +boot and explicitly disables autonomous boot refresh. It remains a source/build candidate only; +the clean reproducible replacement image, OTA-selector-safe install path, passive no-motion +runner, rollback proof, and physical qualification are still pending. Repeated bounded live +`/debug` probes were unavailable, so current actuator state and installed application identity are +unknown; ping response is recorded separately and does not close that gap. + The requested human/dog/cat following, natural naming/removal, and personality-shaped emotional motion work is now a separate design-only lane in `docs/PERSON_PET_FOLLOW_IDENTITY_MOTION_PREREGISTRATION.md`. It introduces no detector, identity, @@ -489,9 +499,13 @@ qualifies the installed firmware or authorizes a service restart. ## Exact Next Action -Keep `SEC-002`/`PRIV-001` ahead of the queued aliveness lanes: independently authorize and execute -the exact `4d31de41` no-motion device qualification, then supervised emergency-stop proof and final -release gates with the package hashes above. Do not design credentials or read a pairing file. +Keep `SEC-002`/`PRIV-001` ahead of the queued aliveness lanes. First commit the reviewed public +boot-motion correction, close `M0-004` with two clean identical builds, add and verify the +OTA-selector-safe installer and guarded private rollback helper, and build a new clean package +bound to its exact source and application SHA-256. Only that replacement may enter the dedicated +passive no-motion qualification; the old `4d31de41` / `4256F2E5...B31055` package must be refused. +After a passing passive gate, conduct the separately reviewed supervised emergency-stop proof and +final release gates. Do not design credentials or read a pairing file. `PERCEPT-002`, `IDENT-001`, and `MOTION-001` remain preregistration/research only until their ordered dependencies, expected-red tests, and explicit recognition/physical promotion checkpoints pass. @@ -502,7 +516,8 @@ dependencies, expected-red tests, and explicit recognition/physical promotion ch - No restart or replacement of the contained PC bridge until the selected admission repair passes; voice, vision, model, and the unrelated loopback service remain frozen. The one bridge termination above was explicitly authorized after the stop-ship finding and is now recorded. -- No release publication, tag, push, PR mutation, branch deletion, force-push, or evidence deletion. +- Use the reviewed `codex/` branch and draft PR for scoped commits and ordinary pushes. No release + publication, tag, branch deletion, force-push, or evidence deletion. - No wake-WAV request, raw microphone read/inspection, pairing-code read or file transport, or fallback from a denied HTTP control. - No remote/Away infrastructure, credential, pairing, fundamental privacy-policy, sensitive-memory, diff --git a/TASK_LEDGER.md b/TASK_LEDGER.md index 8c7c8221..1bed4f1b 100644 --- a/TASK_LEDGER.md +++ b/TASK_LEDGER.md @@ -1,6 +1,6 @@ # Task Ledger -Ledger timestamp: 2026-08-02 America/New_York +Ledger timestamp: 2026-08-03 America/New_York ## M0-001 — Establish Repository Truth @@ -102,9 +102,16 @@ Ledger timestamp: 2026-08-02 America/New_York SHA-256; explicit documented PlatformIO core. - **Stop conditions:** Ordinary current-main build becomes red; a release environment cannot be classified; identical clean builds differ; implementation needs unrelated source changes. -- **Result:** Not started. -- **Commit:** None. -- **Decision:** Candidate experiment, not yet selected. +- **Result:** In progress. Two same-input clean `stackchan_release_full` builds produced different + firmware binaries, with 69 differing bytes including embedded wall-clock time and downstream + digest regions; this is the accepted expected-red evidence. PR #218 was reviewed read-only and + will not be merged or cherry-picked because its hook inheritance, override handling, dirty-tree + detection, and unrelated bridge-test change do not meet this gate. A hardened, fail-closed + implementation is preregistered after the public boot-motion correction is committed. No + reproducibility claim or hardware evidence is earned yet. +- **Commit:** Expected-red and preregistration are recorded in the public boot-motion correction + slice; the implementation commit is still pending. +- **Decision:** Selected as the next atomic slice after the boot-motion correction is committed. ## M0-005 — Reconcile Stale Status Documents @@ -464,7 +471,10 @@ Ledger timestamp: 2026-08-02 America/New_York clean three-profile package at that commit verified with `dirty:false`, ZIP SHA-256 `b69ecc75...174b96`, and full-image SHA-256 `4256f2e5...b31055`. No deploy, endpoint mutation, raw-audio request, reboot, flash, OTA, or hardware exercise occurred; physical gates remain - unearned. + unearned. A 2026-08-03 qualification audit found that the exact public full image had motion and + autonomous motion enabled at boot, so it is explicitly rejected as the no-motion qualification + candidate. The replacement source profile keeps both off at boot, but it remains source-only and + unqualified at the time of this ledger update and does not inherit the old package evidence. - **Commit:** Frozen preregistration `d75c62f3`; package prerequisite `2ed5bb6a`; atomic implementation `4d31de414f5f2279b4c423ac3dfd7e940bb540d9`. - **Decision:** Stop-ship. Existing supervised Resume/motion-soak tooling has no approved authority diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index 8edba2ef..052195bb 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -17,6 +17,16 @@ control authority. Query-free emergency Stop remains available, but source tests installed image; exact-image no-motion and supervised emergency-stop qualification are still required before any physical promotion. +SEC-002 package correction (2026-08-03): do not flash the preserved `4d31de41` public full image +SHA-256 `4256F2E5...B31055` for a no-motion gate. That exact image was built with motion request and +autonomous refresh enabled at boot. A source correction now makes the public full profile inherit +motion-off and explicitly disables autonomous boot refresh, but the replacement is not an install +candidate until it is committed, built reproducibly twice, packaged and verified from that clean +commit, and reviewed. The release installer must also deterministically write the packaged OTA +selector at `0xE000`; do not assume the live selector points to the application written at +`0x10000`. Until those gates and a fresh `/debug` motion-off snapshot succeed, the physical step is +`HOLD`, not a reason to reuse an older package or infer state from ping. + ## 0. Bench Setup - Clear the work area around the body and servos. diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index f025ea09..40418497 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -2,6 +2,24 @@ Status timestamp: 2026-07-13 15:53 America/New_York +## Current SEC-002 Qualification Hold (2026-08-03) + +The clean `SEC-002` package built from `4d31de41` is preserved as source/package evidence only. +Its public `full_online` image is SHA-256 +`4256F2E5B4D81E3615D1F074737E867E86925B6737A4F38A4EF158C2B31055` and its effective build +configuration requests motion and autonomous motion at boot. It must not be installed for the +planned exact-image no-motion qualification. This corrects the package's former role without +rewriting its historical hash or claiming that it was deployed. + +The replacement source profile inherits motion-off-at-boot, explicitly keeps autonomous refresh +off, and updates the package statement and release contracts accordingly. Source tests and one +dirty-tree compile have passed, but no clean reproducible replacement package, installation, +physical qualification, or soak exists yet. The current live application, motion request, servo +rail, and torque state remain unknown because repeated bounded `/debug` probes were unavailable. +Ping reachability alone is not a robot-health or actuator-state proof. Hold all flashing and +physical promotion until reproducible-build closure, an OTA-selector-safe installer, a reviewed +rollback path, exact package verification, and a fresh passive no-motion preflight are complete. + ## Last Owner-Accepted Physical Lead — Historical Evidence; Current Installation Unknown This section records the latest owner-accepted physical lead as of 2026-07-13. A fresh device diff --git a/platformio.ini b/platformio.ini index 1918428e..3df1a943 100644 --- a/platformio.ini +++ b/platformio.ini @@ -514,12 +514,9 @@ extends = env:stackchan_release_forensics ; Public full-system image. It deliberately contains no Wi-Fi credentials, ; pairing code, private voice model, or shared OTA token. Owners provision Wi-Fi ; and pairing after flash; token-enabled OTA images are per-device builds. -build_unflags = - -D STACKCHAN_MOTION_ENABLED_AT_BOOT=0 build_flags = ${env:stackchan_release_forensics.build_flags} - -D STACKCHAN_MOTION_ENABLED_AT_BOOT=1 - -D STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=1 + -D STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=0 -D STACKCHAN_ENABLE_CAMERA=1 -D STACKCHAN_ENABLE_CAMERA_HOST_VISION=1 -D STACKCHAN_ENABLE_PROXIMITY_AMBIENT=1 diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index be4cd7e8..be0982f3 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -7,6 +7,15 @@ param( $ErrorActionPreference = "Stop" +# PLATFORMIO_BUILD_FLAGS is an ambient PlatformIO override and can append +# conflicting safety macros after the checked-in environment. Public packages +# must be derived from the reviewed configuration, never an inherited caller +# override. Check presence rather than truthiness and refuse before path/cache/ +# build/package work. +if (Test-Path Env:\PLATFORMIO_BUILD_FLAGS) { + throw "Release packaging refuses ambient override: PLATFORMIO_BUILD_FLAGS" +} + $physicalRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path if ( $env:OS -eq "Windows_NT" -and @@ -1462,7 +1471,7 @@ $manifest = [ordered]@{ board = "m5stack-cores3" defaultEnvironment = "stackchan" includedEnvironments = @("stackchan", "stackchan_servo_calibration", "stackchan_release_full") - servoDefault = "display-only and calibration flows remain safety-gated; the production full firmware starts guarded autonomous motion after boot" + servoDefault = "display-only and calibration flows remain safety-gated; the production full firmware starts without requesting motion or autonomous refresh; physical servo rail and torque state require fresh /debug verification" status = "test-ready prerelease; hardware validation pending" dirty = ($sourceDirtyFiles.Count -gt 0) dirtyFiles = @($sourceDirtyFiles) @@ -2315,7 +2324,7 @@ Recommended arrival command from the extracted package: Commit: $commit -This is the publicly shareable $Version prerelease candidate for Stackchan: Alive, a character OS for Stackchan hardware. It is built, native-tested, compile-checked, includes preview media plus an expression QA sheet, and ships guarded autonomous motion in the production full firmware. Consumer rollout remains blocked pending source-matched physical qualification and explicit owner approval. +This is the publicly shareable $Version prerelease candidate for Stackchan: Alive, a character OS for Stackchan hardware. It is built, native-tested, and compile-checked, and includes preview media plus an expression QA sheet. The production full firmware starts without requesting motion or autonomous refresh; physical servo rail and torque state require fresh /debug verification. Consumer rollout remains blocked pending source-matched physical qualification and explicit owner approval. Dependency provenance is recorded in ``DEPENDENCIES.md`` and ``dependency_lock.json``, with copied build inputs under ``provenance/``. Production voice hashes are recorded in ``docs/VOICE_SOURCE_PROVENANCE_TEMPLATE.md``, ``data/voice_source_provenance.yaml``, ``VOICE_SOURCE_STATUS.md``, and ``voice_source_status.json``. Readiness status is recorded in ``READINESS_REPORT.md`` and ``readiness_report.json``. GitHub Actions status is recorded in ``GITHUB_ACTIONS_STATUS.md`` and ``github_actions_status.json``. Preflight, hardware simulation, flashing, publishing, evidence capture, and package verification helpers are included under ``tools/``. diff --git a/tools/test_firmware_http_control_policy_contract.ps1 b/tools/test_firmware_http_control_policy_contract.ps1 index 8b9363cb..5cc9f675 100644 --- a/tools/test_firmware_http_control_policy_contract.ps1 +++ b/tools/test_firmware_http_control_policy_contract.ps1 @@ -631,12 +631,41 @@ foreach ($pcmToken in @('serveWakeMwwPcmWav', 'gWakeMwwPcmRing', 'writeWakeWavLe $baselinePlatformioText = if ($frozenCommitAvailable) { ((& git show "$frozenPreregCommit`:platformio.ini") -join "`n").TrimEnd() } else { "" } +$baselinePublicReleaseMotion = @' +build_unflags = + -D STACKCHAN_MOTION_ENABLED_AT_BOOT=0 +build_flags = + ${env:stackchan_release_forensics.build_flags} + -D STACKCHAN_MOTION_ENABLED_AT_BOOT=1 + -D STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=1 +'@ +$candidatePublicReleaseMotion = @' +build_flags = + ${env:stackchan_release_forensics.build_flags} + -D STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=0 +'@ +$normalizedCandidatePlatformio = $platformioText -replace "`r`n", "`n" +$candidatePublicReleaseBlock = [regex]::Match( + $normalizedCandidatePlatformio, + '(?ms)^\[env:stackchan_release_full\]\s*(.*?)(?=^\[env:|\z)').Value +$baselinePublicReleaseBlock = [regex]::Match( + $baselinePlatformioText, + '(?ms)^\[env:stackchan_release_full\]\s*(.*?)(?=^\[env:|\z)').Value +Require-PolicyAssertion (([regex]::Matches( + $candidatePublicReleaseBlock, + [regex]::Escape($candidatePublicReleaseMotion))).Count -eq 1) "profile: public release no-motion boot stanza is missing or duplicated" +Require-PolicyAssertion (([regex]::Matches( + $baselinePublicReleaseBlock, + [regex]::Escape($baselinePublicReleaseMotion))).Count -eq 1) "profile: frozen public release boot-motion stanza is missing or duplicated" +$candidatePlatformioAtFrozenMotionPolicy = $normalizedCandidatePlatformio.Replace( + $candidatePublicReleaseMotion, + $baselinePublicReleaseMotion) $candidatePlatformioWithoutPolicy = ([regex]::Replace( - ($platformioText -replace "`r`n", "`n"), + $candidatePlatformioAtFrozenMotionPolicy, '(?m)^[^\S\r\n]*\+[^\S\r\n]*\n?', '')).TrimEnd() Require-PolicyAssertion (-not [string]::IsNullOrEmpty($baselinePlatformioText) -and - $candidatePlatformioWithoutPolicy -ceq $baselinePlatformioText) "profile: platformio.ini changed beyond the one preregistered policy source-filter line" + $candidatePlatformioWithoutPolicy -ceq $baselinePlatformioText) "profile: platformio.ini changed beyond the preregistered policy source-filter and exact public no-motion boot stanzas" $allowedChangedFiles = @( 'INITIAL_RISK_REGISTER.md', 'PROJECT_STATE.md', 'TASK_LEDGER.md', 'platformio.ini', @@ -695,10 +724,12 @@ if ($grayEnd -ge 0 -and $visionEnd -gt $grayEnd) { } else { $issues.Add("camera-invariant: vision handler missing") } try { + Require-PolicyAssertion (-not (Test-Path Env:\PLATFORMIO_BUILD_FLAGS)) "profile: ambient PLATFORMIO_BUILD_FLAGS override is present" $config = (& pio project config --json-output | ConvertFrom-Json) $wifiEnvironments = @() $profileBypasses = @() $faceGateViolations = @() + $publicReleaseFlags = "" foreach ($section in $config) { $name = [string]$section[0] if (-not $name.StartsWith("env:")) { continue } @@ -717,11 +748,24 @@ try { $faceGateViolations += $environmentName } } + if ($name -eq "env:stackchan_release_full") { + $publicReleaseFlags = $flags + } } Require-PolicyAssertion ($wifiEnvironments.Count -eq 19) "profile: expected 19 effective Wi-Fi environments, found $($wifiEnvironments.Count)" Require-PolicyAssertion ($profileBypasses.Count -eq 0) "profile: control-policy bypass flag appears in $($profileBypasses -join ',')" Require-PolicyAssertion ($faceGateViolations.Count -eq 0 -and (Get-Content -LiteralPath (Join-Path $repoRoot 'src\config\RobotConfig.hpp') -Raw).Contains('#define STACKCHAN_FACE_PERIOD_MS 33')) "invariant: a Wi-Fi profile weakens the strict 50 ms face gate" + $publicMotionDefinitions = @([regex]::Matches( + $publicReleaseFlags, + '(?[^\s]+)')) + $publicAutonomousDefinitions = @([regex]::Matches( + $publicReleaseFlags, + '(?[^\s]+)')) + Require-PolicyAssertion ($publicMotionDefinitions.Count -eq 1 -and + $publicMotionDefinitions[0].Groups['value'].Value -ceq '0' -and + $publicAutonomousDefinitions.Count -eq 1 -and + $publicAutonomousDefinitions[0].Groups['value'].Value -ceq '0') "profile: public full release effective configuration is not uniquely motion-off and autonomous-refresh-off at boot" } catch { $issues.Add("profile: PlatformIO effective configuration unavailable: $($_.Exception.GetType().Name)") } diff --git a/tools/test_release_boot_motion_contract.ps1 b/tools/test_release_boot_motion_contract.ps1 index f1d0823e..5ffa0aea 100644 --- a/tools/test_release_boot_motion_contract.ps1 +++ b/tools/test_release_boot_motion_contract.ps1 @@ -2,6 +2,11 @@ $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $platformio = Get-Content -LiteralPath (Join-Path $repoRoot "platformio.ini") -Raw $main = Get-Content -LiteralPath (Join-Path $repoRoot "src/main.cpp") -Raw +$packageRelease = Get-Content -LiteralPath (Join-Path $repoRoot "tools/package_release.ps1") -Raw + +if (Test-Path Env:\PLATFORMIO_BUILD_FLAGS) { + throw "Release boot-motion verification refuses ambient override: PLATFORMIO_BUILD_FLAGS" +} function Get-EnvironmentBlock { param([string]$Name) @@ -13,16 +18,14 @@ function Get-EnvironmentBlock { return $match.Value } -foreach ($name in @("stackchan_camera_probe", "stackchan_release_full")) { - $block = Get-EnvironmentBlock $name - foreach ($marker in @( - "-D STACKCHAN_MOTION_ENABLED_AT_BOOT=0", - "-D STACKCHAN_MOTION_ENABLED_AT_BOOT=1", - "-D STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=1" - )) { - if ($block -notmatch [regex]::Escape($marker)) { - throw "$name missing release boot-motion marker: $marker" - } +$cameraProbe = Get-EnvironmentBlock "stackchan_camera_probe" +foreach ($marker in @( + "-D STACKCHAN_MOTION_ENABLED_AT_BOOT=0", + "-D STACKCHAN_MOTION_ENABLED_AT_BOOT=1", + "-D STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=1" +)) { + if ($cameraProbe -notmatch [regex]::Escape($marker)) { + throw "stackchan_camera_probe missing private-lab boot-motion marker: $marker" } } @@ -30,6 +33,76 @@ $base = Get-EnvironmentBlock "stackchan_wake_mww_uplink_servos" if ($base -notmatch [regex]::Escape("-D STACKCHAN_MOTION_ENABLED_AT_BOOT=0")) { throw "The guarded test/rollback servo profile must remain motion-off at boot." } +foreach ($unsafeMarker in @( + "-D STACKCHAN_MOTION_ENABLED_AT_BOOT=1", + "-D STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=1" +)) { + if ($base -match [regex]::Escape($unsafeMarker)) { + throw "The guarded servo inheritance root contains a competing unsafe marker: $unsafeMarker" + } +} + +$publicRelease = Get-EnvironmentBlock "stackchan_release_full" +if ($publicRelease -notmatch [regex]::Escape("extends = env:stackchan_release_forensics")) { + throw "The public full release must inherit the guarded motion-off release-forensics profile." +} +foreach ($unsafeMarker in @( + "-D STACKCHAN_MOTION_ENABLED_AT_BOOT=1", + "-D STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=1" +)) { + if ($publicRelease -match [regex]::Escape($unsafeMarker)) { + throw "The public full release must not request or autonomously refresh motion at boot: $unsafeMarker" + } +} +if ($publicRelease -notmatch [regex]::Escape("-D STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=0")) { + throw "The public full release must explicitly freeze autonomous boot motion off." +} + +$packageMotionStatement = "production full firmware starts without requesting motion or autonomous refresh; physical servo rail and torque state require fresh /debug verification" +if (([regex]::Matches($packageRelease, [regex]::Escape($packageMotionStatement))).Count -ne 2) { + throw "Release manifest and generated README must both state the bounded public full-image boot contract." +} +foreach ($staleClaim in @( + "production full firmware starts guarded autonomous motion after boot", + "ships guarded autonomous motion in the production full firmware" +)) { + if ($packageRelease -match [regex]::Escape($staleClaim)) { + throw "Release packaging still claims that the public full image starts autonomous motion: $staleClaim" + } +} + +$packageOverrideGuard = 'if (Test-Path Env:\PLATFORMIO_BUILD_FLAGS)' +$guardIndex = $packageRelease.IndexOf($packageOverrideGuard) +$pathWorkIndex = $packageRelease.IndexOf('$physicalRepoRoot =') +if ($guardIndex -lt 0 -or $pathWorkIndex -lt 0 -or $guardIndex -gt $pathWorkIndex) { + throw "Release packaging must reject PLATFORMIO_BUILD_FLAGS before path, cache, build, or package work." +} + +$effectiveConfig = (& pio project config --json-output | ConvertFrom-Json) +$effectiveReleaseFlags = "" +foreach ($section in $effectiveConfig) { + if ([string]$section[0] -ne "env:stackchan_release_full") { continue } + foreach ($item in $section[1]) { + if ([string]$item[0] -eq "build_flags") { + $effectiveReleaseFlags = @($item[1]) -join "`n" + } + } +} +if ([string]::IsNullOrWhiteSpace($effectiveReleaseFlags)) { + throw "The public full release effective build flags are unavailable." +} +$motionDefinitions = @([regex]::Matches( + $effectiveReleaseFlags, + '(?[^\s]+)')) +$autonomousDefinitions = @([regex]::Matches( + $effectiveReleaseFlags, + '(?[^\s]+)')) +if ($motionDefinitions.Count -ne 1 -or $motionDefinitions[0].Groups['value'].Value -cne '0') { + throw "The public full release must have one effective motion-at-boot definition with value 0." +} +if ($autonomousDefinitions.Count -ne 1 -or $autonomousDefinitions[0].Groups['value'].Value -cne '0') { + throw "The public full release must have one effective autonomous-motion-at-boot definition with value 0." +} foreach ($pattern in @( "volatile bool gAutonomousMotionRequested = STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT != 0", From ef615f4b825687c3beb36ae8f53def0c82d45654 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Mon, 3 Aug 2026 12:18:30 -0400 Subject: [PATCH 10/46] build: harden reproducible release provenance --- .github/workflows/firmware.yml | 35 +- .github/workflows/release.yml | 202 +- AGENTS.md | 16 + PROJECT_STATE.md | 143 +- README.md | 6 +- TASK_LEDGER.md | 83 +- docs/ARRIVAL_DAY_RUNBOOK.md | 44 +- docs/FIRST_DEPLOY_STATUS.md | 41 +- docs/RELEASE_PROCESS.md | 74 +- platformio.ini | 10 + requirements-firmware-release.txt | 26 + tools/RELEASE_TOOLCHAIN_IDENTITY.md | 159 ++ tools/audit_published_release.ps1 | 11 +- tools/export_rollout_status.ps1 | 70 +- tools/firmware_reproducibility_failure.ps1 | 47 + tools/firmware_reproducibility_proof.ps1 | 149 ++ tools/flash_release_firmware.ps1 | 110 +- .../generate_synthetic_hardware_evidence.ps1 | 7 +- ...w_release_toolchain_identity_candidate.ps1 | 44 + tools/package_release.ps1 | 2113 ++++++++++++----- tools/platformio_reproducible_build.py | 213 ++ tools/platformio_resolver.ps1 | 31 +- tools/prepare_device_arrival.ps1 | 135 +- tools/preview_python_resolver.ps1 | 29 +- tools/publish_release.cmd | 8 +- tools/publish_release.ps1 | 564 +++-- tools/release_dependency_evidence.ps1 | 125 + tools/release_git_trust.ps1 | 53 + tools/release_source_binding.ps1 | 28 + tools/release_toolchain_identity.ps1 | 1114 +++++++++ tools/release_zip_safety.ps1 | 106 + tools/run_device_preflight.ps1 | 39 +- tools/share_release.cmd | 8 +- tools/share_release.ps1 | 331 ++- ...art_bridge_ai_supervised_qualification.ps1 | 74 +- tools/start_hardware_evidence.ps1 | 155 +- .../test_android_rollout_status_contract.ps1 | 80 +- ..._firmware_http_control_policy_contract.ps1 | 8 +- ...mware_reproducibility_failure_contract.ps1 | 84 + ...irmware_reproducibility_proof_contract.ps1 | 193 ++ ...t_firmware_reproducible_build_contract.ps1 | 832 +++++++ tools/test_platformio_utf8_contract.ps1 | 22 +- tools/test_release_boot_motion_contract.ps1 | 7 +- tools/test_release_command_trust_contract.ps1 | 274 +++ ...t_release_dependency_evidence_contract.ps1 | 76 + ...elease_package_verifier_trust_contract.ps1 | 1656 +++++++++++++ ...st_release_publication_safety_contract.ps1 | 435 ++++ .../test_release_source_binding_contract.ps1 | 431 ++++ ...st_release_toolchain_identity_contract.ps1 | 567 +++++ tools/verify_consumer_promotion.ps1 | 24 +- tools/verify_published_release.ps1 | 61 +- tools/verify_release_package.ps1 | 1831 ++++++++++++-- 52 files changed, 11560 insertions(+), 1424 deletions(-) create mode 100644 requirements-firmware-release.txt create mode 100644 tools/RELEASE_TOOLCHAIN_IDENTITY.md create mode 100644 tools/firmware_reproducibility_failure.ps1 create mode 100644 tools/firmware_reproducibility_proof.ps1 create mode 100644 tools/new_release_toolchain_identity_candidate.ps1 create mode 100644 tools/platformio_reproducible_build.py create mode 100644 tools/release_dependency_evidence.ps1 create mode 100644 tools/release_git_trust.ps1 create mode 100644 tools/release_source_binding.ps1 create mode 100644 tools/release_toolchain_identity.ps1 create mode 100644 tools/release_zip_safety.ps1 create mode 100644 tools/test_firmware_reproducibility_failure_contract.ps1 create mode 100644 tools/test_firmware_reproducibility_proof_contract.ps1 create mode 100644 tools/test_firmware_reproducible_build_contract.ps1 create mode 100644 tools/test_release_command_trust_contract.ps1 create mode 100644 tools/test_release_dependency_evidence_contract.ps1 create mode 100644 tools/test_release_package_verifier_trust_contract.ps1 create mode 100644 tools/test_release_publication_safety_contract.ps1 create mode 100644 tools/test_release_source_binding_contract.ps1 create mode 100644 tools/test_release_toolchain_identity_contract.ps1 diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 17704249..0bda5657 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -90,7 +90,7 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.12.10" - name: Install bridge test dependencies run: | @@ -467,7 +467,7 @@ jobs: - uses: actions/setup-python@v6 if: matrix.setup_desktop_runtime with: - python-version: "3.12" + python-version: "3.12.10" - uses: actions/setup-java@v5 with: @@ -502,7 +502,7 @@ jobs: run: | $runtimeRoot = Join-Path "${{ github.workspace }}" "output/desktop-python-runtime/${{ matrix.desktop_platform }}" ./tools/prepare_desktop_python_runtime.ps1 ` - -SourcePython (Get-Command python).Source ` + -SourcePython (Get-Command python -CommandType Application -ErrorAction Stop).Source ` -RuntimeRoot $runtimeRoot ` -SourceName "github-actions-pr-${{ runner.os }}-${{ runner.arch }}-python-3.12" ` -Force ` @@ -694,10 +694,10 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.12.10" - name: Install PlatformIO - run: python -m pip install --upgrade pip platformio + run: python -m pip install --upgrade pip -r requirements-firmware-release.txt - name: Run native logic tests run: pio test -e native_logic @@ -716,7 +716,7 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.12.10" - name: Verify Windows bridge launch contracts shell: pwsh @@ -726,11 +726,28 @@ jobs: ./tools/test_stackchan_dashboard_launcher_contract.ps1 - name: Install PlatformIO - run: python -m pip install --upgrade pip platformio + run: python -m pip install --upgrade pip -r requirements-firmware-release.txt + + - name: Run firmware reproducibility contract + shell: pwsh + run: ./tools/test_firmware_reproducible_build_contract.ps1 - name: Build firmware run: pio run -e stackchan -e stackchan_servo_calibration + - name: Build secret-free public full firmware + shell: pwsh + run: | + $env:PLATFORMIO_CORE_DIR = Join-Path $env:RUNNER_TEMP "stackchan-pioarduino" + pio run -e stackchan_release_full + + - name: Probe installed firmware compilers for path normalization + shell: pwsh + run: | + ./tools/test_firmware_reproducible_build_contract.ps1 + $env:PLATFORMIO_CORE_DIR = Join-Path $env:RUNNER_TEMP "stackchan-pioarduino" + ./tools/test_firmware_reproducible_build_contract.ps1 + - name: Build unit-test firmware run: pio test -e stackchan --without-uploading --without-testing @@ -743,3 +760,7 @@ jobs: .pio/build/stackchan/firmware.elf .pio/build/stackchan/partitions.bin .pio/build/stackchan/bootloader.bin + .pio/build/stackchan_release_full/firmware.bin + .pio/build/stackchan_release_full/firmware.elf + .pio/build/stackchan_release_full/partitions.bin + .pio/build/stackchan_release_full/bootloader.bin diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78b5fb61..01ba144d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -187,7 +187,7 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.12.10" - uses: actions/setup-java@v5 with: @@ -228,7 +228,7 @@ jobs: run: | $runtimeRoot = Join-Path "${{ github.workspace }}" "output/desktop-python-runtime/${{ matrix.platform }}" ./tools/prepare_desktop_python_runtime.ps1 ` - -SourcePython (Get-Command python).Source ` + -SourcePython (Get-Command python -CommandType Application -ErrorAction Stop).Source ` -RuntimeRoot $runtimeRoot ` -SourceName "github-actions-${{ runner.os }}-${{ runner.arch }}-python-3.12" ` -Force ` @@ -578,7 +578,7 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.12.10" - uses: actions/setup-java@v5 with: @@ -622,7 +622,7 @@ jobs: path: output/companion/release-input/windows - name: Install tooling - run: python -m pip install --upgrade pip platformio -r requirements-preview.txt + run: python -m pip install --upgrade pip -r requirements-firmware-release.txt -r requirements-preview.txt - name: Run native logic tests run: pio test -e native_logic @@ -639,11 +639,30 @@ jobs: - name: Package release shell: pwsh - run: ./tools/package_release.ps1 -Version "${{ github.ref_name }}" + env: + STACKCHAN_RELEASE_VERSION: ${{ github.ref_name }} + run: | + $version = [string]$env:STACKCHAN_RELEASE_VERSION + if ($version.Length -gt 128 -or $version -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or $version -in @('.', '..') -or $version.EndsWith('.')) { + throw "Release tag must be one safe filename component." + } + ./tools/package_release.ps1 -Version $version - name: Verify release package shell: pwsh - run: ./tools/verify_release_package.ps1 -Version "${{ github.ref_name }}" -ZipPath "output/release/stackchan_alive_${{ github.ref_name }}.zip" -ExpectedCommit "${{ github.sha }}" + env: + STACKCHAN_RELEASE_VERSION: ${{ github.ref_name }} + STACKCHAN_RELEASE_COMMIT: ${{ github.sha }} + run: | + $version = [string]$env:STACKCHAN_RELEASE_VERSION + if ($version.Length -gt 128 -or $version -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or $version -in @('.', '..') -or $version.EndsWith('.')) { + throw "Release tag must be one safe filename component." + } + ./tools/verify_release_package.ps1 ` + -Version $version ` + -ZipPath "output/release/stackchan_alive_$version.zip" ` + -ExpectedCommit ([string]$env:STACKCHAN_RELEASE_COMMIT) ` + -RequireReleaseEligible - name: Export strict companion release evidence shell: pwsh @@ -676,15 +695,52 @@ jobs: - name: Stage final release assets shell: pwsh + env: + STACKCHAN_RELEASE_VERSION: ${{ github.ref_name }} + STACKCHAN_RELEASE_COMMIT: ${{ github.sha }} run: | + $version = [string]$env:STACKCHAN_RELEASE_VERSION + if ($version.Length -gt 128 -or $version -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or $version -in @('.', '..') -or $version.EndsWith('.')) { + throw "Release tag must be one safe filename component." + } + $sourceZipPath = "output/release/stackchan_alive_$version.zip" + $sourceZipSidecarPath = "$sourceZipPath.sha256" + if (-not (Test-Path -LiteralPath $sourceZipPath -PathType Leaf) -or -not (Test-Path -LiteralPath $sourceZipSidecarPath -PathType Leaf)) { + throw "The verified release ZIP and its pre-existing SHA-256 sidecar are both required." + } + ./tools/verify_release_package.ps1 ` + -Version $version ` + -ZipPath $sourceZipPath ` + -ExpectedCommit ([string]$env:STACKCHAN_RELEASE_COMMIT) ` + -RequireReleaseEligible . ./tools/release_asset_contract.ps1 - $version = "${{ github.ref_name }}" - $packageRoot = "output/release/$version" - $zipPath = "output/release/stackchan_alive_$version.zip" + . ./tools/release_zip_safety.ps1 + + $releaseOutputRoot = [IO.Path]::GetFullPath("output/release").TrimEnd('\', '/') + $publicationRoot = [IO.Path]::GetFullPath((Join-Path $releaseOutputRoot "workflow-publication-$version")) + $releasePrefix = $releaseOutputRoot + [IO.Path]::DirectorySeparatorChar + if (-not $publicationRoot.StartsWith($releasePrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Workflow publication root escapes output/release." + } + if (Test-Path -LiteralPath $publicationRoot) { + Remove-Item -LiteralPath $publicationRoot -Recurse -Force + } + New-Item -ItemType Directory -Path $publicationRoot | Out-Null + $zipPath = Join-Path $publicationRoot "stackchan_alive_$version.zip" $zipSidecarPath = "$zipPath.sha256" - $stageDir = "output/release/workflow-assets-$version" - $companionStageDir = "output/release/companion-assets-$version" - New-Item -ItemType Directory -Force -Path $stageDir, $companionStageDir | Out-Null + Copy-Item -LiteralPath $sourceZipPath -Destination $zipPath + Copy-Item -LiteralPath $sourceZipSidecarPath -Destination $zipSidecarPath + ./tools/verify_release_package.ps1 ` + -Version $version ` + -ZipPath $zipPath ` + -ExpectedCommit ([string]$env:STACKCHAN_RELEASE_COMMIT) ` + -RequireReleaseEligible + + $packageRoot = Join-Path $publicationRoot "package" + Expand-StackchanReleaseZipSafely -ZipPath $zipPath -DestinationPath $packageRoot + $stageDir = Join-Path $publicationRoot "firmware-assets" + $companionStageDir = Join-Path $publicationRoot "companion-assets" + New-Item -ItemType Directory -Path $stageDir, $companionStageDir | Out-Null Copy-Item "$packageRoot/firmware/display_only/firmware.bin" "$stageDir/firmware-display-only.bin" Copy-Item "$packageRoot/firmware/servo_calibration/firmware.bin" "$stageDir/firmware-servo-calibration.bin" Copy-Item "$packageRoot/firmware/display_only/bootloader.bin" "$stageDir/bootloader.bin" @@ -720,34 +776,120 @@ jobs: uses: actions/attest@v4 with: subject-path: | - output/release/stackchan_alive_${{ github.ref_name }}.zip - output/release/stackchan_alive_${{ github.ref_name }}.zip.sha256 - output/release/workflow-assets-${{ github.ref_name }}/* - output/release/companion-assets-${{ github.ref_name }}/* - output/release/${{ github.ref_name }}/media/* - output/release/${{ github.ref_name }}/media/voice/* - output/release/${{ github.ref_name }}/GITHUB_ACTIONS_STATUS.md - output/release/${{ github.ref_name }}/github_actions_status.json - output/release/${{ github.ref_name }}/release_assets.json + output/release/workflow-publication-${{ github.ref_name }}/stackchan_alive_${{ github.ref_name }}.zip + output/release/workflow-publication-${{ github.ref_name }}/stackchan_alive_${{ github.ref_name }}.zip.sha256 + output/release/workflow-publication-${{ github.ref_name }}/firmware-assets/* + output/release/workflow-publication-${{ github.ref_name }}/companion-assets/* + output/release/workflow-publication-${{ github.ref_name }}/package/media/* + output/release/workflow-publication-${{ github.ref_name }}/package/media/voice/* + output/release/workflow-publication-${{ github.ref_name }}/package/GITHUB_ACTIONS_STATUS.md + output/release/workflow-publication-${{ github.ref_name }}/package/github_actions_status.json + output/release/workflow-publication-${{ github.ref_name }}/package/release_assets.json - name: Create GitHub release shell: pwsh env: GH_TOKEN: ${{ github.token }} + STACKCHAN_RELEASE_VERSION: ${{ github.ref_name }} + STACKCHAN_RELEASE_COMMIT: ${{ github.sha }} run: | - . ./tools/release_asset_contract.ps1 - $version = "${{ github.ref_name }}" - $packageRoot = "output/release/$version" - $zipPath = "output/release/stackchan_alive_$version.zip" + $version = [string]$env:STACKCHAN_RELEASE_VERSION + if ($version.Length -gt 128 -or $version -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or $version -in @('.', '..') -or $version.EndsWith('.')) { + throw "Release tag must be one safe filename component." + } + $releaseOutputRoot = [IO.Path]::GetFullPath("output/release").TrimEnd('\', '/') + $publicationRoot = [IO.Path]::GetFullPath((Join-Path $releaseOutputRoot "workflow-publication-$version")) + $releasePrefix = $releaseOutputRoot + [IO.Path]::DirectorySeparatorChar + if (-not $publicationRoot.StartsWith($releasePrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Workflow publication root escapes output/release." + } + $zipPath = Join-Path $publicationRoot "stackchan_alive_$version.zip" $zipSidecarPath = "$zipPath.sha256" - $stageDir = "output/release/workflow-assets-$version" - $companionStageDir = "output/release/companion-assets-$version" + ./tools/verify_release_package.ps1 ` + -Version $version ` + -ZipPath $zipPath ` + -ExpectedCommit ([string]$env:STACKCHAN_RELEASE_COMMIT) ` + -RequireReleaseEligible + . ./tools/release_asset_contract.ps1 + . ./tools/release_zip_safety.ps1 + + $packageRoot = Join-Path $publicationRoot "package" + if (Test-Path -LiteralPath $packageRoot) { + $resolvedPackageRoot = (Resolve-Path -LiteralPath $packageRoot).Path + if (-not $resolvedPackageRoot.StartsWith($releasePrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to replace an unexpected workflow package extraction." + } + Remove-Item -LiteralPath $resolvedPackageRoot -Recurse -Force + } + Expand-StackchanReleaseZipSafely -ZipPath $zipPath -DestinationPath $packageRoot + $stageDir = Join-Path $publicationRoot "firmware-assets" + if (Test-Path -LiteralPath $stageDir) { + Remove-Item -LiteralPath $stageDir -Recurse -Force + } + New-Item -ItemType Directory -Path $stageDir | Out-Null + Copy-Item "$packageRoot/firmware/display_only/firmware.bin" "$stageDir/firmware-display-only.bin" + Copy-Item "$packageRoot/firmware/servo_calibration/firmware.bin" "$stageDir/firmware-servo-calibration.bin" + Copy-Item "$packageRoot/firmware/display_only/bootloader.bin" "$stageDir/bootloader.bin" + Copy-Item "$packageRoot/firmware/display_only/partitions.bin" "$stageDir/partitions.bin" + $companionStageDir = Join-Path $publicationRoot "companion-assets" + ./tools/verify_release_asset_contract.ps1 -Version $version -PackageRoot $packageRoot -ZipPath $zipPath -ZipSidecarPath $zipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage $releaseAssetEntries = Get-ReleaseFinalAssetEntries -Version $version -PackageRoot $packageRoot -ZipPath $zipPath -ZipSidecarPath $zipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage $companionAssetEntries = Get-ReleaseCompanionAssetEntries -Version $version -CompanionAssetRoot $companionStageDir $releaseAssetPaths = @($releaseAssetEntries + $companionAssetEntries | ForEach-Object { $_.Path }) - gh release create "${{ github.ref_name }}" ` + + $gitCommand = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + $ghCommand = Get-Command gh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $gitCommand -or $null -eq $ghCommand) { + throw "Release publication requires Git and GitHub CLI application executables." + } + $tagRef = "refs/tags/$version" + $peeledRef = "$tagRef^{}" + $remoteLines = @(& $gitCommand.Source ls-remote "https://github.com/${{ github.repository }}.git" $tagRef $peeledRef) + if ($LASTEXITCODE -ne 0) { + throw "Could not resolve the triggering release tag immediately before publication." + } + $resolvedRefs = @($remoteLines | ForEach-Object { + if ([string]$_ -match '^([0-9a-fA-F]{40})\s+(.+)$') { + [pscustomobject]@{ commit = $Matches[1].ToLowerInvariant(); ref = $Matches[2] } + } + }) + $peeled = @($resolvedRefs | Where-Object ref -CEQ $peeledRef) + $direct = @($resolvedRefs | Where-Object ref -CEQ $tagRef) + $remoteCommit = if ($peeled.Count -eq 1) { + [string]$peeled[0].commit + } elseif ($peeled.Count -eq 0 -and $direct.Count -eq 1) { + [string]$direct[0].commit + } else { + '' + } + if ($remoteCommit -cne ([string]$env:STACKCHAN_RELEASE_COMMIT).ToLowerInvariant()) { + throw "Remote release tag resolves to '$remoteCommit', not the package-verified commit $env:STACKCHAN_RELEASE_COMMIT." + } + + & $ghCommand.Source release create $version ` @releaseAssetPaths ` --repo "${{ github.repository }}" ` - --title "Stackchan Alive ${{ github.ref_name }}" ` - --notes-file "output/release/${{ github.ref_name }}/RELEASE_NOTES.md" ` + --target $env:STACKCHAN_RELEASE_COMMIT ` + --title "Stackchan Alive $version" ` + --notes-file (Join-Path $packageRoot "RELEASE_NOTES.md") ` --prerelease + + - name: Clean verified release publication snapshot + if: always() + shell: pwsh + env: + STACKCHAN_RELEASE_VERSION: ${{ github.ref_name }} + run: | + $version = [string]$env:STACKCHAN_RELEASE_VERSION + if ($version.Length -gt 128 -or $version -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or $version -in @('.', '..') -or $version.EndsWith('.')) { + throw "Release tag must be one safe filename component." + } + $releaseOutputRoot = [IO.Path]::GetFullPath("output/release").TrimEnd('\', '/') + $publicationRoot = [IO.Path]::GetFullPath((Join-Path $releaseOutputRoot "workflow-publication-$version")) + $releasePrefix = $releaseOutputRoot + [IO.Path]::DirectorySeparatorChar + if (-not $publicationRoot.StartsWith($releasePrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clean a workflow publication root outside output/release." + } + if (Test-Path -LiteralPath $publicationRoot) { + Remove-Item -LiteralPath $publicationRoot -Recurse -Force + } diff --git a/AGENTS.md b/AGENTS.md index 95653167..14b06d37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ Run the narrow tests for the code touched, then the relevant broad gates: pio test -e native_logic python -m unittest discover -s bridge -p "test_*.py" python bridge/trusted_facts_smoke.py --memory-file output/pc-brain/latest/memory.json --json +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test_firmware_reproducible_build_contract.ps1 pio run -e stackchan_release_full powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test_full_system_soak_evidence_contract.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test_current_lead_reproducibility_contract.ps1 @@ -82,6 +83,21 @@ robot failure; use repeated endpoint, process, bridge-socket, and runtime eviden `stackchan_release_full` is the secret-free public build. Per-device `stackchan_camera_probe` or `stackchan_release_forensics` builds require explicit private OTA/pairing configuration and must never be substituted into a public package or GitHub release asset. +Firmware reproducibility is narrowly scoped to the same clean Git commit, operating system, +dependency/toolchain bytes, canonical recorded PlatformIO configuration, and no listed ambient +build overrides. The hook maps lexical/resolved project and core paths to stable prefixes. Every +Arduino firmware environment must inherit exactly one `platformio_reproducible_build.py` pre-hook; +`native_logic` must inherit none. +Release packages must use two distinct short detached build roots, isolated empty per-cycle build +caches, exact cycle-B dependency snapshots, and a separate clean commit-pinned source worktree for +all tracked package inputs. Dependency evidence must select the exact verbose-resolved platform and +only resolved shared-core packages. Failed logs are moved to private evidence while the complete +failed detached worktree remains attached for inspection. The trusted checkout verifier must never +execute package-contained code, repository-local Git hooks, or fsmonitor configuration. +A `diagnostic-*` package is inspection-only: its firmware and dependency identity are unbound, it +must not be flashed/published or used as evidence, and `-AllowDirtyPackage` never grants that authority. +Matching rebuild hashes prove build determinism only. They do not transfer physical qualification, +soak, stability, or safety evidence between different SHA-256 binaries. ## Change Discipline diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 1bf05648..7f68ebaf 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,8 +4,9 @@ State timestamp: 2026-08-03 America/New_York ## Current Objective -Keep stop-ship security work ahead of aliveness features. Milestone 0 and the independently -verified `SEC-001` host admission repair are committed. `SEC-001` remains contained and undeployed. +Keep stop-ship security and release-truth work ahead of aliveness features. Milestone 0, the +independently verified `SEC-001` host admission repair, and the public boot-motion correction are +committed. `SEC-001` remains contained and undeployed. `SEC-002`/`PRIV-001` is implemented and committed as `4d31de41`: public firmware HTTP may serve bounded operational status and emergency stops, while every other mutating control fails closed before side effects. Independent policy, security, and documentation reviewers accepted the source @@ -17,11 +18,14 @@ Qualification audit found that the preserved `4d31de41` public full image `4256F2E5...B31055` is not a no-motion candidate: its effective configuration requests motion and autonomous refresh at boot. That package remains immutable historical evidence and is superseded for physical qualification. The selected correction keeps the public full profile motion-off at -boot and explicitly disables autonomous boot refresh. It remains a source/build candidate only; -the clean reproducible replacement image, OTA-selector-safe install path, passive no-motion -runner, rollback proof, and physical qualification are still pending. Repeated bounded live -`/debug` probes were unavailable, so current actuator state and installed application identity are -unknown; ping response is recorded separately and does not close that gap. +boot and explicitly disables autonomous boot refresh; it is committed as +`b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. Release-governance changes after that commit remain an +uncommitted working-tree experiment. Diagnostic packaging is available, but release-grade +packaging and release-eligible verification currently fail closed because no reviewed exact +toolchain allowlist exists. The clean reproducible replacement image, OTA-selector-safe install +path, passive no-motion runner, rollback proof, and physical qualification are still pending. +Repeated bounded live `/debug` probes were unavailable, so current actuator state and installed +application identity are unknown; USB enumeration and ping observations do not close that gap. The requested human/dog/cat following, natural naming/removal, and personality-shaped emotional motion work is now a separate design-only lane in @@ -35,8 +39,10 @@ dimensional projection behind controlled-source final-actuator and physical-safe - Repository: `RobVanProd/stackchan_alive` - Working branch: `codex/aliveness-repository-truth` -- Current firmware/source implementation commit before this documentation-only future-lane update: - `4d31de414f5f2279b4c423ac3dfd7e940bb540d9` +- Current committed firmware/source correction: `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3` + (`fix: keep public release motion off at boot`). +- Current release-governance/toolchain worktree: dirty and uncommitted; it must not be described as + the identity of a clean package or installed image. - HTTP-containment contract-scope maintenance commit (test file only): `aa7dfb9ca077704dca84bc5635fbb2142e13e47c` - Separate package prerequisite commit: @@ -58,34 +64,38 @@ switched because live services use that checkout. Milestone 0 work uses the isol ## Active Hypothesis -Selected experiment: `SEC-002`, fixed emergency-stop-only admission on the firmware debug HTTP -server, plus `PRIV-001` denial of its unauthenticated wake-microphone PCM export. - -- **Observed behavior:** Port 8789 ignores the HTTP method, defaults malformed requests to `/`, - dispatches camera paths before any common admission decision, applies tone/wake-reset/motion- - enable/recovery/reboot effects before responding, returns generic status for unknown paths, and - echoes the raw request target. `/wake.wav` and `/wake-pcm.wav` return recent microphone-ring PCM - without the camera pairing gate. These are source observations; unsafe routes and PCM were not - exercised on hardware. -- **Primary hypothesis:** One Arduino-free, exhaustive request-line/route policy invoked before - dispatch can reduce admitted unauthenticated mutating operations to emergency audio/motion stop - only, deny the PCM export, retain bounded status and paired camera behavior, and expose a - non-secret `emergency_stop_only` capability without changing OTA or autonomous recovery logic. -- **Falsification:** Any denied route reaches a side effect; any malformed/query/prefix/suffix case - falls through to status; any query-free `GET` emergency stop used by maintained clients is lost; - paired camera or OTA behavior changes; a build profile bypasses the policy; any sink leaks raw - request/query/pairing/authorization material; or the repair requires a credential, pairing-file - transport, or hardware action before source gates. -- **Frozen baseline:** Commit `9c72f020`, the contained production bridge, installed firmware of - unknown SHA-256, voice/vision/model workers, port-8790 OTA authorization, camera pairing grammar, - automatic offline recovery supervisor, 50 ms face gate, actuator ownership, and physical - evidence. -- **Rollback:** Revert only the atomic `SEC-002` source/client compatibility commit. Do not restore - an insecure listener or flash the prior image as an automatic fallback; isolate or power off the - robot and preserve evidence. - -The reproducible-build and memory-authorization hypotheses remain queued P0 work; stop-ship -transport/control/privacy containment takes precedence without reordering later aliveness work. +Selected experiment: `M0-004`, exact-source reproducible firmware and release-command governance. + +- **Observed behavior:** The boot-motion prerequisite is committed at `b5ea5c5f`, but the current + release-governance tree is dirty. The diagnostic v8 package is explicitly dirty, diagnostic-only, + non-release-eligible, non-flashable, and does not prove firmware reproducibility. No tracked + reviewed toolchain allowlist exists. Fresh canonical dependency evidence covers only the + `stackchan` environment, and the current Git/runtime and packed-object semantics are not fully + byte-authorized. +- **Primary hypothesis:** Deterministic build inputs, exact source/package binding, safe ZIP + handling, hardened publication commands, and an independently reviewed executable/toolchain + allowlist can make a clean three-environment two-cycle package auditable without weakening + firmware, privacy, motion, power, or evidence gates. +- **Falsification:** Any release-grade or `RequireReleaseEligible` path executes an unapproved + Git/Python/PlatformIO root; any dependency/source mutation escapes the identity; two clean cycles + differ; a diagnostic package is accepted for release, flash, or hardware qualification; a hostile + ZIP escapes or bypasses inventories; or publication mutates remote state before exact repository, + commit, tag, asset, and package verification. +- **Current decision:** Keep release-grade packaging and release-eligible verification fail closed + before Git or build-tool execution. Preserve diagnostic packaging only for verifier development. + Do not create or promote an allowlist from the same untrusted host evidence. PostBuild and + candidate generation remain disabled until all three environments and the remaining Git/runtime + semantics have independent authority. +- **Frozen baseline:** Committed source `b5ea5c5f`, the contained production bridge, installed + firmware of unknown SHA-256, the verified private backup, voice/vision/model workers, OTA and + camera authorization, automatic recovery, the 50 ms face gate, actuator ownership, and all + physical evidence. +- **Rollback:** Revert only the eventual atomic M0 governance commit if a frozen invariant + regresses. Do not delete diagnostic or backup evidence, restore unauthenticated release commands, + or flash an older image automatically. + +Memory authorization remains queued P0 work. The human/pet/identity/emotional-motion lanes remain +ordered behind release truth, privacy authority, and exact-image physical qualification. ## SEC-002 / PRIV-001 Frozen Preregistration @@ -344,6 +354,13 @@ Preregistration metric fields: - That documented hash is historical accepted evidence, not a claim about what is currently reachable or installed. Direct `/debug` was unavailable during the current snapshot, so no live hash binding is asserted. +- A private full-SPI-flash backup captured on 2026-08-02 is preserved at + `output/private/firmware-backups/20260802-233346-COM4`. Three independent 16 MiB reads match at + SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. + Offline parsing shows backup-time OTA selection of `app0`; the exact app image-file SHA-256 is + `BB8311FFD1DFB059561697242E0C87ED45D38BDBEB0B8CEB32937089314621B1`, and its source mapping is + unknown. The whole-flash hash is not an application hash, and neither value proves the current + live slot or bytes. ## Active Bridge Source And Services @@ -399,6 +416,13 @@ Observed conclusion: robot reachability is not established, and the dashboard is connected/ready presentation when heartbeat/socket evidence becomes stale. Do not relabel this as a robot freeze, blackout, brownout, thermal event, USB failure, board failure, or power failure. +Bounded update on 2026-08-03: the expected ESP32-S3 USB composite device is again present as +`USB Serial Device (COM4)` with `VID_303A&PID_1001&MI_00`, matching the backup device identity. +The unrelated CH340 remains separately enumerated on COM3 and was not opened. Three new read-only +`/debug` requests to `192.168.1.238:8789` timed out, and no local listener was present on ports +`5059`, `8765`, or `8789`. No serial port was opened and no reset, control request, flash, or motion +occurred. USB presence does not establish application, actuator, network, bridge, or power state. + ## Known Faults 1. The last observed production host service exposed a fail-open robot-to-host WebSocket admission @@ -497,13 +521,37 @@ qualifies the installed firmware or authorizes a service restart. local wrong-peer path, not robot reachability or network-attacker resistance. Evidence remains ignored under `output/private/sec-001/`. +## M0 Release Governance Working-Tree Evidence + +- Command trust, release-package verifier trust, source binding, publication safety, dependency + evidence, reproducibility proof/failure retention, PlatformIO UTF-8, and toolchain-identity + contracts pass. The reproducible-build contract covers all 22 firmware environments. +- Independent command-trust review passes the current fail-closed boundary: release-grade + packaging and `RequireReleaseEligible` refuse before unauthenticated tools; diagnostic packages + cannot authorize release, flashing, distribution, or hardware qualification; managed ZIP, + pinned system commands, disabled Git/LFS hooks/filters, and hostile shim tests remain intact. +- Independent toolchain review passes the PreBuild analysis after adding full Python-installation + hashing, exact import isolation including `PYTHONOPTIMIZE`, canonical source/build-byte binding, + and source/HEAD/ref/commit mutation tests. No tracked reviewed allowlist exists. PostBuild and + candidate generation remain disabled because fresh evidence does not cover all three packaged + environments and Git/runtime pack semantics are not yet independently byte-authorized. +- Current regressions pass: native firmware logic 294/294, bridge 567/567, trusted-facts smoke with + zero model invocations and zero audio, and the full-system-soak/current-lead/archive synthetic + evidence contracts. These are source/contract results, not current hardware qualification. +- Diagnostic package generation/verification is retained solely to test the verifier. A diagnostic + archive must identify the dirty source snapshot and keep every release, flash, distribution, and + hardware-qualification eligibility flag false. No public release was created. + ## Exact Next Action -Keep `SEC-002`/`PRIV-001` ahead of the queued aliveness lanes. First commit the reviewed public -boot-motion correction, close `M0-004` with two clean identical builds, add and verify the -OTA-selector-safe installer and guarded private rollback helper, and build a new clean package -bound to its exact source and application SHA-256. Only that replacement may enter the dedicated -passive no-motion qualification; the old `4d31de41` / `4256F2E5...B31055` package must be refused. +Keep `SEC-002`/`PRIV-001` and release truth ahead of the queued aliveness lanes. Reconcile the M0 +scope/state record, independently close command and toolchain trust, and keep release-grade paths +blocked until a reviewed exact allowlist can authorize them. Then commit the governance slice and +produce two clean identical builds for all three packaged environments, add and verify the OTA- +selector-safe installer and guarded private rollback helper, and build a clean package bound to its +exact source and application SHA-256. Only that replacement may enter the dedicated passive no- +motion qualification; the old `4d31de41` / `4256F2E5...B31055` package and every diagnostic package +must be refused. After a passing passive gate, conduct the separately reviewed supervised emergency-stop proof and final release gates. Do not design credentials or read a pairing file. `PERCEPT-002`, `IDENT-001`, and `MOTION-001` remain preregistration/research only until their ordered @@ -529,8 +577,9 @@ dependencies, expected-red tests, and explicit recognition/physical promotion ch ## Rollback Path `SEC-001` rollback is reversion of exact commit `9c72f020`; keep the exposed listener stopped rather -than restoring a fail-open fallback. `SEC-002` preregistration and its current implementation remain -separate atomic changes and are reverted only by exact commit if a frozen invariant regresses. Do -not flash an insecure prior image as an operational rollback. Hardware rollback remains the exact -private accepted archive/runbook procedure and is not exercised without source, build, no-motion, -physical, and exact-image gates. +than restoring a fail-open fallback. `SEC-002` and its `b5ea5c5f` boot-motion correction are reverted +only by exact commit if a frozen invariant regresses. The verified private 16 MiB backup is recovery +evidence, not an application image, current-live identity, release artifact, device clone, or +automatic restore authorization. Do not flash an insecure prior image as an operational rollback. +Any hardware restore still requires exact target identity, reviewed recovery procedure, source/ +build/no-motion gates, operator supervision, and fresh post-restore evidence. diff --git a/README.md b/README.md index cdfe4d87..7d8f28f9 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ Create a verified prerelease package: ```powershell .\tools\package_release.cmd -Version -.\tools\verify_release_package.cmd -Version -ZipPath output\release\stackchan_alive_.zip +.\tools\verify_release_package.cmd -Version -ZipPath output\release\stackchan_alive_.zip -ExpectedCommit -RequireReleaseEligible ``` Share the package locally or through a tunnel: @@ -346,14 +346,14 @@ Share the package locally or through a tunnel: Publish a verified prerelease manually when hosted Actions cannot run: ```powershell -.\tools\publish_release.cmd -Version -CreateTag -PushCurrentBranch -PushTag +.\tools\publish_release.cmd -Version -Repo RobVanProd/stackchan_alive -CreateTag -PushCurrentBranch -PushTag .\tools\audit_published_release.cmd -Version ``` Start a hardware evidence packet when the device is connected: ```powershell -.\tools\start_hardware_evidence.cmd -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +.\tools\start_hardware_evidence.cmd -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 ``` Evidence packets include `RUN_HARDWARE_SIM_BASELINE.cmd` for the pre-arrival virtual diff --git a/TASK_LEDGER.md b/TASK_LEDGER.md index 1bed4f1b..c53d0c76 100644 --- a/TASK_LEDGER.md +++ b/TASK_LEDGER.md @@ -88,30 +88,45 @@ Ledger timestamp: 2026-08-03 America/New_York release build in the documented core. - **Priority:** P0 Milestone 0 candidate. - **Dependencies:** M0-003 audit reconciliation; clean task preregistration. -- **Owner:** One future implementation owner, with separate verification owner. -- **Allowed files:** Tentatively `platformio.ini`, a single reproducible-build pre-script, one - focused contract test, `AGENTS.md`, and the relevant release/build documentation. Final scope - must be frozen before implementation. +- **Owner:** `/root`, with separate Luna implementation and read-only verification owners. +- **Allowed files:** The initial tentative scope was insufficient once package/source binding, + dependency identity, command trust, ZIP safety, publication ordering, and every release consumer + had to fail closed as one contract. The expanded slice is frozen to `platformio.ini`, `AGENTS.md`, + exact firmware-release requirements, the firmware/release workflows, package/verifier/ + publication/share/audit consumers, reproducibility/dependency/source/Git/toolchain/ZIP helpers, + their focused contracts, and directly coupled release/status/runbook documentation. The pending + commit's exact path inventory is the authority. Firmware `src/`, bridge runtime, personas, + private evidence, and unrelated feature work remain outside scope. - **Frozen systems:** Firmware behavior, face timing, actuator/power authority, bridge runtime, package secret policy, hardware, and unrelated LAN tests. - **Acceptance tests:** Failing effective-config coverage test first; exactly one effective - pre-script for every firmware/release environment; sanitized/fail-closed overrides and release - packaging governance; current gates; two clean exact-image builds across a clock boundary for - all three public packaged environments (`stackchan`, `stackchan_servo_calibration`, and + pre-script for every firmware/release environment; sanitized/fail-closed overrides; exact source, + dependency, toolchain, command, package, ZIP, and publication governance; current regression and + evidence-contract gates; two clean exact-image builds across a clock boundary for all three + public packaged environments (`stackchan`, `stackchan_servo_calibration`, and `stackchan_release_full`) plus classified private evidence-bearing domains, with identical - SHA-256; explicit documented PlatformIO core. + SHA-256; explicit documented PlatformIO core. Until an independently reviewed allowlist can meet + the complete gate, release-grade packaging and `RequireReleaseEligible` must refuse before Git, + Python, or PlatformIO execution while diagnostic packages remain ineligible. - **Stop conditions:** Ordinary current-main build becomes red; a release environment cannot be classified; identical clean builds differ; implementation needs unrelated source changes. - **Result:** In progress. Two same-input clean `stackchan_release_full` builds produced different firmware binaries, with 69 differing bytes including embedded wall-clock time and downstream digest regions; this is the accepted expected-red evidence. PR #218 was reviewed read-only and will not be merged or cherry-picked because its hook inheritance, override handling, dirty-tree - detection, and unrelated bridge-test change do not meet this gate. A hardened, fail-closed - implementation is preregistered after the public boot-motion correction is committed. No - reproducibility claim or hardware evidence is earned yet. -- **Commit:** Expected-red and preregistration are recorded in the public boot-motion correction - slice; the implementation commit is still pending. -- **Decision:** Selected as the next atomic slice after the boot-motion correction is committed. + detection, and unrelated bridge-test change do not meet this gate. The public boot-motion + prerequisite is now committed at `b5ea5c5f`. The dirty working tree implements deterministic + inputs, source/dependency/toolchain analysis, safe packaging/verification, and hardened + publication paths. Diagnostic v8 is expressly dirty, diagnostic-only, non-release-eligible, + non-flashable, and not reproducibility proof. No reviewed toolchain allowlist exists; fresh + canonical dependency evidence covers only `stackchan`; PostBuild/candidate generation and every + release-grade/eligibility path therefore remain fail closed. No reproducibility or hardware + claim is earned yet. +- **Commit:** Boot-motion prerequisite `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`; + M0-004 implementation commit pending final verification and state reconciliation. +- **Decision:** Continue the atomic governance slice, but do not mark it complete or generate an + eligible package until command/toolchain authority, all three clean environments, and independent + review are actually closed. ## M0-005 — Reconcile Stale Status Documents @@ -143,6 +158,37 @@ Ledger timestamp: 2026-08-03 America/New_York - **Decision:** Accepted by the final independent documentation review; no historical evidence was strengthened or transferred. Complete. +## M0-STATE-TRUTH — Reconcile Governance And Recovery Evidence + +- **Problem:** The committed boot-motion correction, dirty M0 implementation, diagnostic package, + private firmware backup, and historical deployment prose could be read as one transferable + release or physical identity. +- **User-facing consequence:** A diagnostic ZIP or backup-time observation could be flashed, + published, or cited as current qualification without a clean source/toolchain/device binding. +- **Evidence:** HEAD `b5ea5c5f`; dirty M0 worktree; diagnostic v8 manifest with release/flash/ + hardware eligibility false; verified private three-read backup; backup-time `app0` selection with + unknown source mapping; repeated `/debug` timeouts; matching CoreS3 PnP identity present on COM4 + without opening serial. +- **Priority:** P0 documentation and recovery truth before commit. +- **Owner:** `/root`, with independent read-only Luna state audit. +- **Allowed files:** `PROJECT_STATE.md`, `TASK_LEDGER.md`, `docs/FIRST_DEPLOY_STATUS.md`, and + `docs/ARRIVAL_DAY_RUNBOOK.md` only. +- **Frozen systems:** Firmware, robot, serial ports, live services, backup bytes, private values, + evidence archives, remote release state, and historical measured results. +- **Acceptance tests:** Current committed/uncommitted/package/backup/live identities are separated; + the whole-flash hash is not called an app hash; historical current/live language is dated; no + physical, reproducibility, restore, or release claim is transferred; documentation review and + whitespace checks pass. +- **Stop conditions:** Any update would expose private backup content, infer a current application + from USB/network absence or presence, strengthen historical evidence, or authorize restore/flash. +- **Result:** Complete. State reconciliation records the committed correction, fail-closed M0 + status, verified backup limits, current COM4 enumeration, continued debug unavailability, and no + serial/control/flash/motion action. Independent read-only review found no remaining identity, + qualification, historical-evidence, or recovery-authority contradiction. +- **Commit:** Pending with the atomic M0 governance slice. +- **Decision:** Accept the reconciliation while keeping release and hardware promotion on hold; + the backup remains recovery evidence only. + ## UX-001 — Expire Stale Dashboard Robot Readiness - **Problem:** The loopback dashboard reports robot connected/ready from an approximately @@ -473,10 +519,13 @@ Ledger timestamp: 2026-08-03 America/New_York raw-audio request, reboot, flash, OTA, or hardware exercise occurred; physical gates remain unearned. A 2026-08-03 qualification audit found that the exact public full image had motion and autonomous motion enabled at boot, so it is explicitly rejected as the no-motion qualification - candidate. The replacement source profile keeps both off at boot, but it remains source-only and - unqualified at the time of this ledger update and does not inherit the old package evidence. + candidate. The replacement source profile keeps both off at boot and is committed as + `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`, but it remains source-only and unqualified at the + time of this ledger update. Neither the dirty M0 governance worktree nor any diagnostic package + inherits the old package or physical evidence. - **Commit:** Frozen preregistration `d75c62f3`; package prerequisite `2ed5bb6a`; atomic - implementation `4d31de414f5f2279b4c423ac3dfd7e940bb540d9`. + implementation `4d31de414f5f2279b4c423ac3dfd7e940bb540d9`; public boot-motion correction + `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. - **Decision:** Stop-ship. Existing supervised Resume/motion-soak tooling has no approved authority after containment; keep the robot on a trusted isolated LAN or powered off until qualification. diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index 052195bb..6c7f54b1 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -4,11 +4,11 @@ Use this when bringing up a physical Stackchan device from the public `v0.2.0` r locally rebuilt or post-release firmware as a new candidate until its applicable evidence gates below are complete. -Repository-truth warning (2026-08-02): the currently installed firmware SHA is unknown. Dated +Repository-truth warning (2026-08-03): the currently installed firmware SHA is unknown. Dated “installed,” “current,” and “live” notes below are historical evidence and cannot replace discovery, exact source/binary identity, or qualification of the image actually under test. -Current control-containment warning (2026-08-02): the SEC-002 source candidate is +Current control-containment warning (2026-08-03): the committed SEC-002 source policy is `emergency_stop_only`. Its dashboard and legacy motion/wake runners refuse Resume, wake-reset, and tone before starting processes or evidence runs. Historical `/motion-resume`, `/recover`, `/reboot`, `/wake-reset`, tone, and wake-WAV commands below describe older evidence only and are @@ -19,13 +19,26 @@ required before any physical promotion. SEC-002 package correction (2026-08-03): do not flash the preserved `4d31de41` public full image SHA-256 `4256F2E5...B31055` for a no-motion gate. That exact image was built with motion request and -autonomous refresh enabled at boot. A source correction now makes the public full profile inherit -motion-off and explicitly disables autonomous boot refresh, but the replacement is not an install -candidate until it is committed, built reproducibly twice, packaged and verified from that clean -commit, and reviewed. The release installer must also deterministically write the packaged OTA -selector at `0xE000`; do not assume the live selector points to the application written at -`0x10000`. Until those gates and a fresh `/debug` motion-off snapshot succeed, the physical step is -`HOLD`, not a reason to reuse an older package or infer state from ping. +autonomous refresh enabled at boot. The source correction now makes the public full profile inherit +motion-off, explicitly disables autonomous boot refresh, and is committed as +`b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. It is still not an install candidate until release +command/toolchain trust is independently closed, two clean builds match for all three packaged +environments, and the resulting exact package is verified and reviewed. Release-grade packaging +currently refuses to run because no reviewed exact toolchain allowlist exists; diagnostic packages +are never flash or qualification inputs. The release installer must also deterministically write +the packaged OTA selector at `0xE000`; do not assume the live selector points to the application +written at `0x10000`. Until those gates and a fresh `/debug` motion-off snapshot succeed, the +physical step is `HOLD`, not a reason to reuse an older package or infer state from USB or ping. + +Private recovery evidence (2026-08-02): a verified three-read 16 MiB SPI-flash backup is preserved +only under ignored `output/private/firmware-backups/20260802-233346-COM4`, with whole-flash SHA-256 +`036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows +backup-time `app0` selection and application image-file SHA-256 +`BB8311FFD1DFB059561697242E0C87ED45D38BDBEB0B8CEB32937089314621B1`; source mapping is unknown. +This is recovery evidence only. It is not current firmware identity, an application release asset, +a device-cloning package, or automatic restore authorization. Never commit, upload, publish, or +send the backup. A restore requires a separately reviewed exact-target procedure and fresh +post-restore identity/no-motion evidence. ## 0. Bench Setup @@ -36,7 +49,8 @@ selector at `0xE000`; do not assume the live selector points to the application - Use diffuse room light for face detection. Never aim a bright lamp, phone light, work light, or exposed high-output LED into the operator's eyes. Stop immediately for discomfort or afterimages; camera validation can wait. -- Know the serial port, for example `COM3`. +- Know the exact serial port and verify its PnP identity before use. The preserved CoreS3 backup + used `VID_303A&PID_1001&MI_00` on COM4; a port number alone is not device identity. Historical exact-image release record (2026-07-12): the installed-at-that-checkpoint private paired firmware is source commit `a7532f61cc7e5161ce5e65d05675c37bd7941e7c`, SHA-256 @@ -1369,13 +1383,15 @@ the 30-second runtime-health window with motion, servo rail, and torque off. Use `docs\LAN_OTA.md` and the private build token; never expose port 8790 outside the trusted LAN. Later final-integration testing intentionally placed the oriented camera diagnostic on `app0` and -restored the archived production image on `app1`. The current production SHA256 is +restored the archived production image on `app1`. At that 2026-07-11 checkpoint, the restored +production SHA256 was `875FE2DE5FB93BECEF6C72C08C1951326439CDCAE299528970C28D43CF115CFB`; restore evidence is `output\hardware-evidence\final-integration\production-voice-restore-20260711-141611`. Do not -assume both slots contain production during this supervised camera phase. The camera slot is -diagnostic-only and must be replaced before release promotion. +assume that hash is current or that both slots still contain those images. The camera slot was +diagnostic-only and required replacement before that release promotion. -The live host memory store was also migrated from `stackchan.bridge-memory.v2` to v3 with an atomic +At that historical checkpoint, the live host memory store was also migrated from +`stackchan.bridge-memory.v2` to v3 with an atomic backup. v3 drops legacy model-authored robot-state residue, permits character memory only in approved `user.*` and `project.*` namespaces, and reserves expiring `robot.*` context for typed runtime telemetry. This prevents stale remembered state from contradicting the current heartbeat. diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 40418497..ca545699 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -1,6 +1,6 @@ # Stackchan First Deploy Status -Status timestamp: 2026-07-13 15:53 America/New_York +Status timestamp: 2026-08-03 America/New_York ## Current SEC-002 Qualification Hold (2026-08-03) @@ -12,13 +12,30 @@ planned exact-image no-motion qualification. This corrects the package's former rewriting its historical hash or claiming that it was deployed. The replacement source profile inherits motion-off-at-boot, explicitly keeps autonomous refresh -off, and updates the package statement and release contracts accordingly. Source tests and one -dirty-tree compile have passed, but no clean reproducible replacement package, installation, -physical qualification, or soak exists yet. The current live application, motion request, servo -rail, and torque state remain unknown because repeated bounded `/debug` probes were unavailable. -Ping reachability alone is not a robot-health or actuator-state proof. Hold all flashing and -physical promotion until reproducible-build closure, an OTA-selector-safe installer, a reviewed -rollback path, exact package verification, and a fresh passive no-motion preflight are complete. +off, and is committed as `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. The later M0 governance +worktree can create and verify diagnostic-only packages, but release-grade packaging and +`RequireReleaseEligible` verification intentionally fail closed because no reviewed exact +toolchain allowlist exists. No clean reproducible replacement package, installation, physical +qualification, or soak exists yet. The current live application, motion request, servo rail, and +torque state remain unknown because repeated bounded `/debug` probes were unavailable. USB or ping +reachability alone is not a robot-health or actuator-state proof. Hold all flashing and physical +promotion until reproducible-build and toolchain-trust closure, an OTA-selector-safe installer, a +reviewed rollback path, exact clean package verification, and a fresh passive no-motion preflight +are complete. + +A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored +`output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 +`036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that +`app0` was selected at backup time; its application image-file SHA-256 is +`BB8311FFD1DFB059561697242E0C87ED45D38BDBEB0B8CEB32937089314621B1`, with source mapping unknown. +The whole-flash hash is not an application hash. This backup does not prove the current live slot, +current bytes, release identity, or automatic restore authority, and it must never be published. + +Bounded read-only update on 2026-08-03: the matching CoreS3 USB PnP identity is present on COM4; +the unrelated CH340 remains separate on COM3. Neither port was opened. Three `/debug` requests to +`192.168.1.238:8789` timed out and no local bridge/RVC/debug listener was present on ports 8765, +5059, or 8789. This records endpoint/service unavailability only; it is not evidence of a freeze, +reset, blackout, USB fault, board fault, or power root cause. ## Last Owner-Accepted Physical Lead — Historical Evidence; Current Installation Unknown @@ -641,9 +658,11 @@ Open before calling the full system final: - Current robot-mic/uplink validation status: gated, not physically validated after servo bring-up - Rollback firmware environment: `stackchan_wifi` -## Recovery Decision +## Historical Recovery Decision (2026-07-07) -The robot was restored to the smooth face/bridge-only baseline after the bad full-online attempt. Treat this as the known-good physical baseline. +At that historical checkpoint, the robot was restored to the smooth face/bridge-only baseline +after the bad full-online attempt. It was the known-good physical baseline for that dated sequence, +not a statement about the current installation. Do not jump directly from this baseline to motor-enabled full-online firmware. The safer sequence is: @@ -656,7 +675,7 @@ Do not jump directly from this baseline to motor-enabled full-online firmware. T 7. Only after visual face stability and voice-gate behavior are confirmed on staged firmware, consider `stackchan_full_online`. 8. Flash `stackchan_full_online` only through the guarded wrapper with operator present, body clear, and explicit servo-risk confirmation. Do not remove the successful servo guardrails from `stackchan_wake_mww_uplink_servos`: motion disabled at boot, servo attach fail-closed, write rate limiting, and session auto-stop. -## Validated After Recovery +## Historical Validation After Recovery (2026-07-07) - User visually confirmed the face is smooth after unplug/reboot. - `stackchan_wifi` was reflashed on `COM4` and reconnected from stored Wi-Fi provisioning. diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index b1d6764e..53249759 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -20,9 +20,62 @@ with a portable SHA-256 index; copied build inputs; flash helpers; promotion verifiers; a manifest that names the readiness/media/voice/persona/companion evidence artifacts; and SHA256 checksums. The package command refuses a dirty source worktree by default so code and configuration match the manifest commit. Regenerated preview media is treated as a release artifact. +Before any release build or cache work, packaging also rejects the presence of +`PLATFORMIO_BUILD_FLAGS`, `STACKCHAN_BUILD_EPOCH`, `SOURCE_DATE_EPOCH`, +`STACKCHAN_BUILD_STAMP`, `STACKCHAN_DISABLE_REPRODUCIBLE_BUILD`, persona/credential build inputs, +all ambient `PLATFORMIO_*` variables, and all ambient `GIT_*` variables. Every Arduino firmware +environment inherits exactly one reproducibility pre-hook, which derives fixed-width `__DATE__` +and `__TIME__` strings plus `SOURCE_DATE_EPOCH` from the clean Git commit timestamp. Release builds +also pass a captured commit/epoch identity lock to that hook. Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File tools\test_firmware_reproducible_build_contract.ps1 +``` + +The guarantee is deliberately narrow: the same clean commit, operating system, dependency and +toolchain bytes, canonical recorded PlatformIO configuration, and no listed ambient build override +must produce the same firmware artifacts from different project roots while each environment keeps +the same resolved PlatformIO core, dependency, and toolchain paths. The compiler hook maps lexical +and resolved local roots to synthetic stable prefixes; this is +not a claim that arbitrary operating systems or different toolchain/dependency bytes are identical. +A release-grade package performs two clean cycles for all three public environments, +waits for at least a 65-second start-time boundary, compares the firmware BIN and ELF, bootloader, +and partition-table hashes, and packages only the verified second-cycle artifacts. The cycles use +different short detached clean worktree paths of different lengths, pinned to the captured commit, +plus a distinct initially empty PlatformIO compiled-artifact cache per cycle/environment. The proof +records six identity attestations and binds both cycles to the manifest commit and epoch. Exact +cycle-B package inventories, license files, and package metadata are captured before that build +worktree is removed. The captured platform directory is the exact path reported by verbose +PlatformIO resolution, and shared-core package evidence is limited to resolved package names; +unrelated installed packages are excluded. Release dependency provenance never falls back to the +main checkout's ignored `.pio` state. All tracked package inputs and content-generating helpers come from a third clean, +commit-pinned detached source worktree retained through final ZIP verification. Failed build logs, +prior snapshots, and failure metadata are moved under ignored +`output/private/reproducibility-failures/`; the complete failed detached worktree, including +`.pio/build`, `.pio/libdeps`, and generated state, remains attached at its recorded path. A later +attempt must not delete it. ZIP hashes may +differ because generated reports and archive metadata are time-bearing. `-SkipBuild` is allowed only +with `-AllowDirty` for a diagnostic package that is explicitly barred from release and hardware +validation; its version must start with `diagnostic-` and it is written under +`output/diagnostics/`. Its copied firmware identity is explicitly unknown/unbound, every root +operator document carries a do-not-flash banner, and the flash, device-arrival, hardware-evidence, +and publish tools require release eligibility even if `-AllowDirtyPackage` is supplied. +`-AllowDirty` no longer authorizes firmware compilation. A direct diagnostic firmware +build may set a strict decimal `STACKCHAN_BUILD_EPOCH`, but packaging rejects it. Reproducible bytes +are not hardware stability or qualification evidence, and they do not allow evidence to transfer +to a different binary SHA-256. After creating the ZIP and SHA-256 sidecar, the command runs the complete package verifier against -that exact ZIP and writes `output/release/-package-verify.log`. Package creation fails if -the verifier fails; a ZIP existing on disk is not by itself a successful package result. +that exact ZIP and writes `output/release/-package-verify.log` for release candidates or +`output/diagnostics/-package-verify.log` for diagnostics. Diagnostic verification proves +archive integrity for inspection only and emits no release-success marker. Package creation fails if +the verifier fails; a ZIP existing on disk is not by itself a successful package result. The trusted +checkout verifier treats packaged `.ps1`, `.py`, `.cmd`, and module files as data and never executes +or imports them. Trusted Git operations disable repository-local hooks, fsmonitor, maintenance, and +untracked-cache behavior. A bounded single-open extractor rejects unsafe, duplicate, link, and +oversized ZIP entries before writing any file, avoiding a validate-then-reopen race. +Operational eligibility additionally requires the trusted checkout to be clean and exactly at the +expected package commit; a package's own copied verifier cannot authorize flashing or promotion. Package generation records a test-ready prerelease state and keeps consumer rollout blocked pending source-matched hardware validation. It never records owner approval automatically. Promotion is a separate evidence-bound decision made only after the required supervised hardware, @@ -201,7 +254,7 @@ If the native logic test step reports missing `gcc`/`g++`, run `.\tools\check_na Verify the package before sharing it: ```powershell -.\tools\verify_release_package.cmd -Version -ZipPath output\release\stackchan_alive_.zip +.\tools\verify_release_package.cmd -Version -ZipPath output\release\stackchan_alive_.zip -ExpectedCommit -RequireReleaseEligible .\tools\run_device_preflight.cmd -PackageZip output\release\stackchan_alive_.zip ``` @@ -273,13 +326,13 @@ preserve upstream notices; they do not choose a license for Stackchan: Alive its Dry-run the release-binary flasher before connecting hardware: ```powershell -.\tools\flash_release_firmware.cmd -PackageZip output\release\stackchan_alive_.zip -Firmware display_only -DryRun -Monitor -Port COM3 +.\tools\flash_release_firmware.cmd -PackageZip output\release\stackchan_alive_.zip -Version -ExpectedCommit -Firmware display_only -DryRun -Monitor -Port COM3 ``` Create a hardware evidence packet when testing a physical device: ```powershell -.\tools\start_hardware_evidence.cmd -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +.\tools\start_hardware_evidence.cmd -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 ``` Packet creation copies the tested ZIP and records `logs/package_verify.log`. Promotion evidence must include that successful package-verification transcript unless the verifier is run with `-AllowMissingPackage` for a diagnostic-only packet. @@ -296,7 +349,7 @@ Synthetic packets are written under `output/hardware-evidence-diagnostic/`, incl To prepare the release for arrival-day testing in one no-hardware-safe step: ```powershell -.\tools\prepare_device_arrival.cmd -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +.\tools\prepare_device_arrival.cmd -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 ``` If you only have an extracted release ZIP, run the same helper from inside the extracted package folder: @@ -448,10 +501,11 @@ If GitHub Actions cannot run, the manual helper remains available for a firmware publication: ```powershell -.\tools\publish_release.cmd -Version -CreateTag -PushCurrentBranch -PushTag +.\tools\publish_release.cmd -Version -Repo RobVanProd/stackchan_alive -CreateTag -PushCurrentBranch -PushTag ``` -The manual helper verifies the local ZIP, uploads the firmware package assets, downloads the +The manual helper requires an explicit `owner/name` repository target, verifies the local ZIP, +uploads the firmware package assets, downloads the GitHub-hosted ZIP plus ZIP SHA256 sidecar, and verifies that remote copy against the tag commit. It does not cross-build or upload companion packages and therefore cannot satisfy the full companion release asset contract by itself. @@ -491,7 +545,9 @@ For same-network phone/laptop review without Cloudflare, add `-Lan`. The helper It also writes `OPEN_LOCAL_SHARE.cmd`, `LAN_TROUBLESHOOTING.md`, and `share_probe_report.json` with adapter metadata, virtual/VPN/no-gateway notes, and host-side reachability probes for the loopback and LAN candidate URLs. If a phone cannot open a LAN URL, first run `OPEN_LOCAL_SHARE.cmd` on the Windows host to prove the server is alive, then use the troubleshooting file and try a non-virtual candidate on the same Wi-Fi/LAN before falling back to Cloudflare. If `cloudflared` is installed, add `-CloudflareTunnel` to start a tunnel for remote review. The script writes the static share folder under `output/share//`. If `cloudflared` is not installed, add `-DownloadCloudflared` to place a local copy under `output/tools/` before starting the tunnel. -From an extracted release package, `tools/share_release.cmd` can infer the version from `release_manifest.json` and creates a temporary ZIP under `output/share//`. +Run `tools/share_release.cmd` from the trusted source checkout. Version and commit authority come +from explicit arguments or trusted Git state, never from an unverified package manifest; the tool +verifies release eligibility before creating the temporary ZIP under `output/share//`. When the quick tunnel URL is available, the script prints the public `trycloudflare.com` URL, writes it to `output/share//PUBLIC_URL.txt`, writes process and URL state to `share_status.json`, and keeps the local server plus tunnel running in hidden background processes. For `-Lan`, use the first printed same-network URL unless the machine is on a VPN-only or isolated network. A local-only share is acceptable for same-machine or LAN review after `verify_share_release.cmd` passes; the evidence packet writes the pinned URL to `share/VERIFIED_URL.txt`. For a no-server static integrity check, run `tools/verify_share_release.cmd -Version -Offline` after `tools/share_release.cmd -Version -NoServe`. This writes `share_static_verification_report.json` with an `offline-static:` URL marker; it proves the share folder contents and hashes, but it is not hosted-media evidence because no URL was probed. Run `tools/verify_share_release.cmd -Version -RequirePublicUrl` before sending a public diff --git a/platformio.ini b/platformio.ini index 3df1a943..3818a833 100644 --- a/platformio.ini +++ b/platformio.ini @@ -73,6 +73,7 @@ framework = arduino monitor_speed = 115200 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py test_build_src = yes @@ -98,6 +99,7 @@ framework = arduino monitor_speed = 115200 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py test_build_src = yes test_filter = test_embedded_logic @@ -135,6 +137,7 @@ framework = arduino monitor_speed = 115200 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -172,6 +175,7 @@ lib_ldf_mode = deep board_build.partitions = partitions_esp_sr_16.csv custom_srmodels_path = output/research/ESP-SR-For-M5Unified/examples/HiStackChanWakeUpWord_platformio/srmodels.bin extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -237,6 +241,7 @@ monitor_speed = 115200 upload_speed = 921600 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -302,6 +307,7 @@ monitor_speed = 115200 upload_speed = 921600 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -537,6 +543,7 @@ build_src_filter = -<*> + extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_sd_format_gate.py build_unflags = -std=gnu++11 @@ -559,6 +566,7 @@ upload_speed = 460800 lib_ldf_mode = deep board_build.partitions = partitions_esp_sr_16.csv extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -615,6 +623,7 @@ upload_speed = 460800 lib_ldf_mode = deep board_build.partitions = partitions_esp_sr_16.csv extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -674,6 +683,7 @@ framework = arduino monitor_speed = 115200 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py diff --git a/requirements-firmware-release.txt b/requirements-firmware-release.txt new file mode 100644 index 00000000..f22bab47 --- /dev/null +++ b/requirements-firmware-release.txt @@ -0,0 +1,26 @@ +# Exact 22-distribution CPython 3.12 / Windows AMD64 version closure for +# PlatformIO Core 6.1.19. This is not a wheel-hash lock: release builds must +# also pass byte identity. `platformio==6.1.19` alone permits transitive drift. +# Platform-owned per-core penv dependencies are covered by byte identity too. +ajsonrpc==1.2.0 +anyio==4.14.1 +bottle==0.13.4 +certifi==2026.6.17 +charset-normalizer==3.4.7 +click==8.3.3 +colorama==0.4.6 +h11==0.16.0 +idna==3.18 +marshmallow==3.26.2 +packaging==26.2 +platformio==6.1.19 +pyelftools==0.33 +pyserial==3.5 +requests==2.34.2 +semantic-version==2.10.0 +starlette==0.52.1 +tabulate==0.10.0 +typing-extensions==4.15.0 +urllib3==2.7.0 +uvicorn==0.40.0 +wsproto==1.3.2 diff --git a/tools/RELEASE_TOOLCHAIN_IDENTITY.md b/tools/RELEASE_TOOLCHAIN_IDENTITY.md new file mode 100644 index 00000000..66295105 --- /dev/null +++ b/tools/RELEASE_TOOLCHAIN_IDENTITY.md @@ -0,0 +1,159 @@ +# Release Toolchain Byte Identity + +Version strings and `pio pkg list` output are not toolchain identity. A launcher can lie about its +version, while framework, compiler, Python, SCons, or library bytes can change under unchanged +paths and versions. `release_toolchain_identity.ps1` computes a canonical SHA-256 inventory over +the exact installed inputs used by the three release firmware environments. + +The pre-build identity is path-independent: each component hashes normalized relative path, byte +count, and file SHA-256 in ordinal order. It does not hash absolute paths or filesystem +timestamps. It rejects reparse points, unsafe/non-normalized paths, and case-ambiguous +inventories. It has no implicit file exclusions. The complete Python installation is one closed +component, so `python312.zip`, `DLLs`, `Lib`, every site-package, `python.exe`, and every file in +`Scripts` are bound together. Both PlatformIO core `penv` trees, platforms, frameworks, compiler +packages, and tools are also hashed. + +The Python claim additionally requires an exact process isolation state. The caller must set +`PYTHONNOUSERSITE=1`, `PYTHONSAFEPATH=1`, `PYTHONDONTWRITEBYTECODE=1`, `PYTHONHASHSEED=0`, +`PYTHONUTF8=1`, and `PYTHONIOENCODING=utf-8`, and must remove the ambient Python, virtualenv, and +Conda override variables rejected by the helper, including any ambient `PYTHONOPTIMIZE`. The +selected runtime is executed before hashing and must report `no_user_site=1`, safe-path mode, +disabled bytecode writes, `optimize=0`, its exact installation +as both prefix values, and exactly this ordered import path: `python312.zip`, `DLLs`, `Lib`, the +installation root, and `Lib/site-packages`. Any `.pth`, `.egg-link`, `sitecustomize.py`, or +`usercustomize.py` anywhere under the installation fails closed before the runtime is started. + +The analysis-only post-build `.pio/libdeps` identity uses `stackchan.canonical-libdeps.v1`. Every source, header, build +script, hidden executable file, and registry-package byte remains exact. Only five proven +package-manager/VCS representation classes are canonicalized, each with separate validation: + +- `integrity.dat` line order becomes an exact duplicate-free requirement set; +- PlatformIO's timestamp-only SCServo `library.json` and `.git/.piopm` labels are correlated, + while package name, GitHub URI, reviewed commit, and all other fields remain bound; +- Git index v2 stat fields are removed only after checksum validation, while modes, object IDs, + stages/flags, paths, and extensions remain bound; +- reflog actor/time fields are removed only after strict parsing, while old/new object IDs, + log path, and action message remain bound; +- pack/idx/rev representation is reduced to the exact Git object-ID set only after validating + pack SHA-1, index SHA-1, reverse-index checksum/permutation, and pack-name linkage. + +Non-sample hooks, replace refs, loose/unexpected objects, alternates, grafts, dangerous Git config, +unreviewed sources, wrong commits, missing Git evidence, ambiguous metadata, and unexpected files +fail closed. Git config, refs, HEAD, packed refs, shallow state, hook samples, and all other hidden +state remain exact records. The fresh-install shape and exact requirement set are hard-coded for +each of the three release environments, so stale libraries or duplicate version directories are +rejected before candidate generation. + +This canonical form is not currently eligible release evidence. The local parser validates pack, +index, and reverse-index checksums and binds the advertised Git object-ID set, but it does not +independently decode every packed object/delta to prove that index object-to-offset mappings match +the pack. Calling an installed `git verify-pack` would move that trust to an executable/runtime not +yet included in the exact pre-build identity. Fresh reproducibility evidence also exists only for +`stackchan`, not the other two release environments. Therefore `PostBuild` and candidate generation +are deliberately disabled and fail closed. + +## Integration API + +Dot-source the helper and call the assertion immediately before the first build. `PostBuild` is a +reserved fail-closed phase until the dependency trust limits below are closed. + +```powershell +. tools/release_toolchain_identity.ps1 +$env:PYTHONNOUSERSITE = '1' +$env:PYTHONSAFEPATH = '1' +$env:PYTHONDONTWRITEBYTECODE = '1' +$env:PYTHONHASHSEED = '0' +$env:PYTHONUTF8 = '1' +$env:PYTHONIOENCODING = 'utf-8' +# Also clear every forbidden override named by Assert-StackchanPythonImportIsolation. +$roots = @{ + pythonHome = 'C:\path\to\Python312' + legacyCore = 'C:\path\to\.platformio' + releaseCore = 'C:\spio\pioarduino' + projectRoot = (Get-Location).Path +} +Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath tools/release_toolchain_identity_allowlist.json ` + -RootMap $roots ` + -PlatformioExecutable C:\path\to\Python312\Scripts\platformio.exe ` + -PythonExecutable C:\path\to\Python312\python.exe ` + -Phase PreBuild +# Run the governed clean build here. This remains intentionally blocked: +Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath tools/release_toolchain_identity_allowlist.json ` + -RootMap $roots ` + -PlatformioExecutable C:\path\to\Python312\Scripts\platformio.exe ` + -PythonExecutable C:\path\to\Python312\python.exe ` + -Phase PostBuild +``` + +The selected PlatformIO and Python executables must resolve to the reviewed Python installation. +The allowlist must contain exactly `Scripts/pio.exe`, `Scripts/platformio.exe`, and `python.exe`; +extra or substituted executable paths fail before hashing. Replacing a same-path launcher or +runtime changes the complete installation identity. These variables must remain in force for the +governed PlatformIO invocation; the assertion proves the same import state that the launcher will +inherit, not a separate `python -I` mode that the package invocation does not use. + +## Review/update workflow + +`new_release_toolchain_identity_candidate.ps1` writes an unreviewed candidate under +`output/private/toolchain-identity-candidates/`. It refuses to overwrite the tracked allowlist. +Review the exact package sources, every component, the 22 version pins in +`requirements-firmware-release.txt`, and the candidate diff. Only then may a reviewer set +`review.status=reviewed` and record non-empty `reviewer` and `reason` fields in a committed +allowlist. Never promote a candidate merely because it was generated by the same host being +checked. Candidate generation includes PostBuild and therefore currently refuses every candidate. +All three environments need independent fresh-install evidence, and Git pack semantics need an +independently trusted validator, before a new candidate can be reviewed. + +## Retained analysis evidence + +The only retained pristine external tree used by this slice is +`D:\CodexArtifacts\stackchan-toolchain-libdeps-repro-2`. For `stackchan`, its canonical identity is +`4D18A5A5A8F385BA8CB6A88429F82240797459A76A2716A8A209D4223AA104F8` over 1,243 raw files and +166,158,472 raw bytes, producing 1,237 canonical records and 165,978,723 canonical bytes. + +`D:\CodexArtifacts\stackchan-toolchain-libdeps-repro` was mutated by a build and is discarded. It +is not used for equality, reproducibility, Git representation, or any other supporting claim. +The contract instead creates two controlled local clones independently. It verifies canonical +equality across distinct PlatformIO install timestamps and tests that actual source bytes, +`builder.py`, HEAD, the branch ref, the complete reviewed commit, hooks, remote configuration, +package metadata, and pack corruption remain bound or fail closed. + +The earlier candidate +`release_toolchain_identity_allowlist_candidate_20260803-143148.json` is rejected: it used raw +libdeps identities and captured a stale extra `M5GFX@0.2.24` tree. No tracked reviewed allowlist +exists. + +## Portability and CI limit + +An installed-byte candidate is explicitly scoped as `exact-host-installed-bytes` and +`portableAcrossHosts=false`. A local Windows candidate is not a GitHub-hosted-runner identity. +Hosted `setup-python` baselines, path-embedded bytecode, unpinned wheel files, and PlatformIO-owned +`penv` contents can differ even when visible versions match. No reviewed allowlist is committed +until that environment exists, so release verification must fail closed rather than silently +claiming toolchain eligibility. + +A portable CI design requires a fixed-path isolated Python environment, a reviewed wheelhouse with +SHA-256-pinned requirements installed using `--require-hashes --no-deps`, bytecode generation +disabled (`PYTHONDONTWRITEBYTECODE=1`) with bytecode absent before use, and separate reviewed +allowlists for each OS/architecture/runtime image. Exact version pins alone are necessary but not +sufficient. This repository's current offline evidence cannot supply trustworthy wheel hashes. + +The current pre-build proof still does not byte-identify Windows system DLLs, the kernel, or every +program a package may resolve from `PATH`. The import closure is exact only under the required +process environment and reported `sys.path`. This is a host-installed-byte policy, not a claim +that Python or PlatformIO is hermetic from the operating system. The Git executable used during +analysis is also not byte-identified; it cannot authorize PostBuild, which remains disabled. + +The rejected raw candidate covered more than 6.1 GB on the reviewed Windows host; a strict pass exceeded two +minutes in local measurement. That cost is intentional and should be paid once before and once +after the governed release build, not on ordinary developer builds. + +The analysis parser relies on Git's SHA-1 object identity and supports only the observed formats: +index v2, pack index v2, reverse index v1, and SHA-1 packs. It validates checksums, reverse-index +permutations, pack linkage, sources, commits, and object-ID inventories, but does not fully decode +pack deltas or prove index offsets/CRCs against decoded objects. New index, hash, extension, +loose-object, or package-manager metadata formats require review and a contract update. Until an +exactly identified Git/runtime or an independent pack decoder closes this gap, PostBuild remains +disabled rather than treating a useful analysis identity as release authorization. diff --git a/tools/audit_published_release.ps1 b/tools/audit_published_release.ps1 index dc1ed784..917b99f6 100644 --- a/tools/audit_published_release.ps1 +++ b/tools/audit_published_release.ps1 @@ -152,13 +152,16 @@ if ([string]::IsNullOrWhiteSpace($OutDir)) { New-Item -ItemType Directory -Force -Path $OutDir | Out-Null $outPath = (Resolve-Path $OutDir).Path -$manifest = Read-JsonFile (Join-Path $PackageRoot "release_manifest.json") -if ($null -ne $manifest -and [string]::IsNullOrWhiteSpace($ExpectedCommit)) { - $ExpectedCommit = [string]$manifest.commit +$trustedTagCommit = (git rev-list -n 1 $Version).Trim().ToLowerInvariant() +if ($LASTEXITCODE -ne 0 -or $trustedTagCommit -notmatch '^[0-9a-f]{40}$') { + throw "Unable to resolve trusted local tag $Version for published-release audit." } if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { - $ExpectedCommit = (git rev-list -n 1 $Version).Trim() + $ExpectedCommit = $trustedTagCommit +} elseif ($ExpectedCommit.ToLowerInvariant() -cne $trustedTagCommit) { + throw "Published-release audit ExpectedCommit does not match trusted local tag $Version." } +$manifest = Read-JsonFile (Join-Path $PackageRoot "release_manifest.json") $publishedVerifyArgs = @( (Join-Path $PSScriptRoot "verify_published_release.ps1"), diff --git a/tools/export_rollout_status.ps1 b/tools/export_rollout_status.ps1 index 495dff93..07ac41bf 100644 --- a/tools/export_rollout_status.ps1 +++ b/tools/export_rollout_status.ps1 @@ -422,26 +422,54 @@ function Get-RolloutNextAction { } try { + if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = (git describe --tags --always --dirty).Trim() + } + if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { + $ExpectedCommit = (git rev-parse HEAD).Trim() + } + if ($ExpectedCommit -notmatch '^[0-9a-fA-F]{40}$') { + throw "ExpectedCommit must be a full 40-character hexadecimal Git commit." + } + if ([string]::IsNullOrWhiteSpace($PackageRoot) -and + [string]::IsNullOrWhiteSpace($PackageZip)) { + $PackageRoot = Join-Path $repoRoot "output/release/$Version" + } + + $packageVerifyArguments = @( + (Join-Path $PSScriptRoot "verify_release_package.ps1"), + "-Version", $Version, + "-ExpectedCommit", $ExpectedCommit, + "-RequireReleaseEligible" + ) if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { if (-not (Test-Path -LiteralPath $PackageZip)) { throw "Missing package ZIP: $PackageZip" } + $packageVerifyArguments += @("-ZipPath", $PackageZip) + } else { + if (-not (Test-Path -LiteralPath $PackageRoot)) { + throw "Missing package root: $PackageRoot" + } + $packageVerifyArguments += @("-PackageRoot", $PackageRoot) + } + $packageVerification = Invoke-ToolCapture $packageVerifyArguments + + if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { + if ($packageVerification.exitCode -ne 0) { + throw ( + "Release ZIP eligibility verification failed before rollout extraction. Output:" + + [Environment]::NewLine + $packageVerification.text) + } + . (Join-Path $PSScriptRoot "release_zip_safety.ps1") $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "stackchan-rollout-status" $cleanupDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $cleanupDir | Out-Null - Expand-Archive -LiteralPath $PackageZip -DestinationPath $cleanupDir + Expand-StackchanReleaseZipSafely ` + -ZipPath $PackageZip -DestinationPath $cleanupDir $PackageRoot = $cleanupDir } - if ([string]::IsNullOrWhiteSpace($Version)) { - $Version = (git describe --tags --always --dirty).Trim() - } - if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { - $ExpectedCommit = (git rev-parse HEAD).Trim() - } - if ([string]::IsNullOrWhiteSpace($PackageRoot)) { - $PackageRoot = Join-Path $repoRoot "output/release/$Version" - } if (-not (Test-Path -LiteralPath $PackageRoot)) { throw "Missing package root: $PackageRoot" } @@ -468,19 +496,23 @@ try { $voice = Read-JsonFile (Join-ResolvedPath $packageRootPath "voice_source_status.json") $rvcVoiceBase = Read-JsonFile (Join-ResolvedPath $packageRootPath "rvc_voice_base_status.json") - if ($null -ne $manifest) { - $Version = [string]$manifest.version - $ExpectedCommit = [string]$manifest.commit - } - $gates = New-Object System.Collections.Generic.List[object] $blockers = New-Object System.Collections.Generic.List[string] - if ($null -ne $manifest -and $manifest.commit -eq $ExpectedCommit) { - Add-Gate $gates "release-package-manifest" "pass" "release_manifest.json commit matches $ExpectedCommit" + if ($packageVerification.exitCode -eq 0) { + Add-Gate $gates "release-package-eligibility" "pass" "Trusted verifier accepted the exact release package" + } else { + Add-Gate $gates "release-package-eligibility" "blocked" $packageVerification.text + $blockers.Add("Release package did not pass trusted operational eligibility verification.") | Out-Null + } + + if ($null -ne $manifest -and + [string]$manifest.version -ceq $Version -and + [string]$manifest.commit -ceq $ExpectedCommit) { + Add-Gate $gates "release-package-manifest" "pass" "release_manifest.json version and commit match $Version / $ExpectedCommit" } else { - Add-Gate $gates "release-package-manifest" "blocked" "release_manifest.json is missing or does not match $ExpectedCommit" - $blockers.Add("Release package manifest is missing or commit-mismatched.") | Out-Null + Add-Gate $gates "release-package-manifest" "blocked" "release_manifest.json is missing or does not match $Version / $ExpectedCommit" + $blockers.Add("Release package manifest is missing or version/commit-mismatched.") | Out-Null } if ($null -ne $readiness -and $readiness.consumerRollout -eq "blocked-pending-hardware-validation") { diff --git a/tools/firmware_reproducibility_failure.ps1 b/tools/firmware_reproducibility_failure.ps1 new file mode 100644 index 00000000..9911ad2c --- /dev/null +++ b/tools/firmware_reproducibility_failure.ps1 @@ -0,0 +1,47 @@ +function Save-StackchanFirmwareReproducibilityFailureEvidence { + param( + [Parameter(Mandatory = $true)][string]$FailureRoot, + [AllowEmptyString()][string]$BuildCacheRoot, + [AllowEmptyString()][string]$ActiveSourceRoot, + [Parameter(Mandatory = $true)][bool]$WorktreeStillAttached, + [Parameter(Mandatory = $true)][System.Management.Automation.ErrorRecord]$Failure, + [Parameter(Mandatory = $true)][string]$SourceCommit, + [Parameter(Mandatory = $true)][string]$SourceEpoch + ) + + if (Test-Path -LiteralPath $FailureRoot) { + throw "Fresh reproducibility failure root already exists: $FailureRoot" + } + New-Item -ItemType Directory -Path $FailureRoot | Out-Null + + if (-not [string]::IsNullOrWhiteSpace($BuildCacheRoot) -and + (Test-Path -LiteralPath $BuildCacheRoot -PathType Container)) { + Move-Item -LiteralPath $BuildCacheRoot ` + -Destination (Join-Path $FailureRoot "build-cache-and-snapshots") + } + + $fullWorktreePreserved = ( + $WorktreeStillAttached -and + -not [string]::IsNullOrWhiteSpace($ActiveSourceRoot) -and + (Test-Path -LiteralPath $ActiveSourceRoot -PathType Container)) + if (-not $fullWorktreePreserved) { + throw "A failed reproducibility build must retain its complete detached worktree." + } + + $failureEvidence = [pscustomobject][ordered]@{ + schema = "stackchan.firmware-reproducibility-failure.v2" + status = "failed-full-worktree-preserved" + failedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + failureType = $Failure.Exception.GetType().FullName + sourceCommit = $SourceCommit + sourceEpoch = $SourceEpoch + artifactsRoot = Split-Path -Leaf $FailureRoot + activeSourceWorktree = $ActiveSourceRoot + preservationPolicy = "full-failed-worktree-retained-attached" + fullWorktreePreserved = $fullWorktreePreserved + worktreeStillAttached = $true + } + $failureEvidence | ConvertTo-Json -Depth 4 | Set-Content ` + -LiteralPath (Join-Path $FailureRoot "FAILURE_EVIDENCE.json") -Encoding UTF8 + return $failureEvidence +} diff --git a/tools/firmware_reproducibility_proof.ps1 b/tools/firmware_reproducibility_proof.ps1 new file mode 100644 index 00000000..bf494146 --- /dev/null +++ b/tools/firmware_reproducibility_proof.ps1 @@ -0,0 +1,149 @@ +function Assert-StackchanFirmwareReproducibilityProof { + param( + [Parameter(Mandatory = $true)][object]$Proof, + [Parameter(Mandatory = $true)][bool]$DiagnosticPackage, + [Parameter(Mandatory = $true)][bool]$AllowDirtyPackage, + [Parameter(Mandatory = $true)][string]$ManifestCommit, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$SourceEpoch, + [Parameter(Mandatory = $true)][string]$ManifestStatus, + [Parameter(Mandatory = $true)][string]$PackageRoot + ) + + if ($DiagnosticPackage) { + if (-not $AllowDirtyPackage) { + throw "Diagnostic release package requires -AllowDirtyPackage" + } + if ($Proof.status -ne "not-proven-skip-build" -or + [int]$Proof.minimumClockBoundarySeconds -ne 65 -or + [int]$Proof.clockBoundarySeconds -ne 0 -or + $null -ne $Proof.cycleAStartedUtc -or + $null -ne $Proof.cycleBStartedUtc -or + $null -ne $Proof.cycleASourceCommit -or + $null -ne $Proof.cycleASourceEpoch -or + $null -ne $Proof.cycleBSourceCommit -or + $null -ne $Proof.cycleBSourceEpoch -or + $Proof.buildCachePolicy -ne "not-applicable-skip-build" -or + $Proof.sourceIsolationPolicy -ne "not-applicable-skip-build" -or + @($Proof.identityAttestations).Count -ne 0 -or + @($Proof.cycleAArtifacts).Count -ne 0 -or + @($Proof.cycleBArtifacts).Count -ne 0 -or + $ManifestStatus -ne "diagnostic-only; reproducibility not proven; release and hardware validation forbidden") { + throw "Diagnostic package must state that reproducibility and hardware validation are unproven" + } + return + } + + if ($Proof.status -ne "verified-two-clean-cycles" -or + [int]$Proof.minimumClockBoundarySeconds -ne 65 -or + [int]$Proof.clockBoundarySeconds -lt 65) { + throw "Release package lacks the required two-cycle firmware reproducibility proof" + } + if ($Proof.cycleASourceCommit -cne $ManifestCommit -or + $Proof.cycleBSourceCommit -cne $ManifestCommit -or + [string]$Proof.cycleASourceEpoch -cne $SourceEpoch -or + [string]$Proof.cycleBSourceEpoch -cne $SourceEpoch -or + $Proof.buildCachePolicy -cne "isolated-empty-per-cycle-environment") { + throw "Firmware reproducibility proof is not bound to one manifest Git identity and isolated build-cache policy" + } + if ($Proof.sourceIsolationPolicy -cne "distinct-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths") { + throw "Firmware reproducibility proof does not use the required distinct detached source policy" + } + + $identityAttestations = @($Proof.identityAttestations) + if ($identityAttestations.Count -ne 6) { + throw "Firmware reproducibility proof must contain six cycle/environment identity attestations" + } + $expectedIdentityKeys = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + foreach ($cycleName in @("cycle-a", "cycle-b")) { + foreach ($environmentName in @("stackchan", "stackchan_servo_calibration", "stackchan_release_full")) { + [void]$expectedIdentityKeys.Add("$cycleName/$environmentName") + } + } + $seenIdentityKeys = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + foreach ($attestation in $identityAttestations) { + $identityKey = "$($attestation.cycle)/$($attestation.environment)" + if (-not $expectedIdentityKeys.Contains($identityKey) -or + -not $seenIdentityKeys.Add($identityKey) -or + $attestation.sourceCommit -cne $ManifestCommit -or + [string]$attestation.sourceEpoch -cne $SourceEpoch -or + $attestation.preBuildChecked -ne $true -or + $attestation.postSnapshotChecked -ne $true) { + throw "Firmware reproducibility proof has an invalid identity attestation: $identityKey" + } + } + if ($seenIdentityKeys.Count -ne $expectedIdentityKeys.Count) { + throw "Firmware reproducibility proof is missing a required identity attestation" + } + + try { + $cycleAStarted = [DateTimeOffset]::ParseExact( + [string]$Proof.cycleAStartedUtc, + "yyyy-MM-ddTHH:mm:ssZ", + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::AssumeUniversal) + $cycleBStarted = [DateTimeOffset]::ParseExact( + [string]$Proof.cycleBStartedUtc, + "yyyy-MM-ddTHH:mm:ssZ", + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::AssumeUniversal) + } catch { + throw "Firmware reproducibility proof has invalid cycle timestamps" + } + $measuredClockBoundary = [int][Math]::Floor(($cycleBStarted - $cycleAStarted).TotalSeconds) + if ($measuredClockBoundary -lt 65 -or + $measuredClockBoundary -ne [int]$Proof.clockBoundarySeconds) { + throw "Firmware reproducibility proof clock boundary is inconsistent" + } + + $cycleAArtifacts = @($Proof.cycleAArtifacts) + $cycleBArtifacts = @($Proof.cycleBArtifacts) + if ($cycleAArtifacts.Count -ne 12 -or $cycleBArtifacts.Count -ne 12) { + throw "Firmware reproducibility proof must contain 12 artifacts per cycle" + } + $packageFirmwareRoots = @{ + stackchan = "firmware/display_only" + stackchan_servo_calibration = "firmware/servo_calibration" + stackchan_release_full = "firmware/full_online" + } + $packageRootPath = [System.IO.Path]::GetFullPath($PackageRoot).TrimEnd('\', '/') + $packageRootPrefix = $packageRootPath + [System.IO.Path]::DirectorySeparatorChar + $expectedProofKeys = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + foreach ($expectedEnvironment in @($packageFirmwareRoots.Keys)) { + foreach ($expectedArtifact in @("firmware.bin", "firmware.elf", "bootloader.bin", "partitions.bin")) { + [void]$expectedProofKeys.Add("$expectedEnvironment/$expectedArtifact") + } + } + $seenProofKeys = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + for ($artifactIndex = 0; $artifactIndex -lt 12; $artifactIndex++) { + $left = $cycleAArtifacts[$artifactIndex] + $right = $cycleBArtifacts[$artifactIndex] + if ($left.environment -cne $right.environment -or + $left.artifact -cne $right.artifact -or + [long]$left.bytes -ne [long]$right.bytes -or + $left.sha256 -cne $right.sha256) { + throw "Firmware reproducibility proof mismatch at artifact index $artifactIndex" + } + $firmwareRoot = $packageFirmwareRoots[[string]$right.environment] + if ([string]::IsNullOrWhiteSpace($firmwareRoot)) { + throw "Firmware reproducibility proof contains unknown environment: $($right.environment)" + } + $proofKey = "$($right.environment)/$($right.artifact)" + if (-not $expectedProofKeys.Contains($proofKey) -or -not $seenProofKeys.Add($proofKey)) { + throw "Firmware reproducibility proof contains an unexpected or duplicate artifact: $proofKey" + } + $packagedArtifact = [System.IO.Path]::GetFullPath((Join-Path $packageRootPath ( + "$firmwareRoot/$($right.artifact)" -replace '/', '\'))) + if (-not $packagedArtifact.StartsWith($packageRootPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $packagedArtifact -PathType Leaf)) { + throw "Missing packaged artifact for reproducibility proof: $proofKey" + } + $packagedItem = Get-Item -LiteralPath $packagedArtifact + $packagedHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $packagedArtifact).Hash.ToUpperInvariant() + if ([long]$packagedItem.Length -ne [long]$right.bytes -or $packagedHash -cne $right.sha256) { + throw "Packaged artifact does not match reproducibility cycle B: $proofKey" + } + } + if ($seenProofKeys.Count -ne $expectedProofKeys.Count) { + throw "Firmware reproducibility proof is missing one or more required artifacts" + } +} diff --git a/tools/flash_release_firmware.ps1 b/tools/flash_release_firmware.ps1 index 78f8226c..00385d83 100644 --- a/tools/flash_release_firmware.ps1 +++ b/tools/flash_release_firmware.ps1 @@ -17,7 +17,6 @@ $ErrorActionPreference = "Stop" $root = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $root -. (Join-Path $PSScriptRoot "platformio_resolver.ps1") function Assert-File { param([string]$Path) @@ -116,6 +115,74 @@ function Get-EsptoolInvocation { throw "No usable esptool runtime found. Install PlatformIO and run a firmware build once so tool-esptoolpy is installed, or install esptool into a real Python 3 environment." } +if (-not [string]::IsNullOrWhiteSpace($PackageZip) -and + -not [string]::IsNullOrWhiteSpace($PackageRoot)) { + throw "Pass only one of -PackageZip or -PackageRoot." +} + +if (-not [string]::IsNullOrWhiteSpace($PackageZip) -and + [string]::IsNullOrWhiteSpace($Version)) { + $zipName = [System.IO.Path]::GetFileName($PackageZip) + if ($zipName -notmatch "^stackchan_alive_(.+)\.zip$") { + throw "Pass -Version when -PackageZip does not match stackchan_alive_.zip" + } + $Version = $Matches[1] +} + +if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = (& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + describe --tags --always 2>$null | Out-String).Trim() + if ([string]::IsNullOrWhiteSpace($Version)) { + throw "Pass -Version because it could not be resolved from trusted Git state." + } +} + +if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { + $ExpectedCommit = (& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + rev-parse HEAD 2>$null | Out-String).Trim() + if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { + throw "Pass -ExpectedCommit because it could not be resolved from trusted Git state." + } +} + +if ([string]::IsNullOrWhiteSpace($PackageZip) -and + [string]::IsNullOrWhiteSpace($PackageRoot)) { + $candidateManifest = Join-Path $root "release_manifest.json" + if (Test-Path -LiteralPath $candidateManifest -PathType Leaf) { + $PackageRoot = $root + } else { + $PackageRoot = Join-Path $root "output/release/$Version" + } +} + +$verifyScript = Join-Path $PSScriptRoot "verify_release_package.ps1" +$verifyArgs = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $verifyScript, + "-Version", $Version, "-ExpectedCommit", $ExpectedCommit, + "-RequireReleaseEligible" +) +if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { + Assert-File $PackageZip + $PackageZip = (Resolve-Path -LiteralPath $PackageZip).Path + $verifyArgs += @("-ZipPath", $PackageZip) +} else { + Assert-File $PackageRoot + $PackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path + $verifyArgs += @("-PackageRoot", $PackageRoot) +} +if ($AllowDirtyPackage) { + $verifyArgs += "-AllowDirtyPackage" +} +& powershell.exe @verifyArgs +if ($LASTEXITCODE -ne 0) { + throw "Operational release package verification failed before flash preparation." +} + +. (Join-Path $PSScriptRoot "platformio_resolver.ps1") +. (Join-Path $PSScriptRoot "release_zip_safety.ps1") + if ($Firmware -in @("servo_calibration", "full_online")) { Write-Warning "$Firmware firmware contains motor support. Keep the body clear and powered safely." if (-not $ConfirmServoRisk) { @@ -126,53 +193,22 @@ if ($Firmware -in @("servo_calibration", "full_online")) { $cleanupDir = $null try { if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { - Assert-File $PackageZip - if ([string]::IsNullOrWhiteSpace($Version)) { - $zipName = [System.IO.Path]::GetFileName($PackageZip) - if ($zipName -match "^stackchan_alive_(.+)\.zip$") { - $Version = $Matches[1] - } else { - throw "Pass -Version when -PackageZip does not match stackchan_alive_.zip" - } - } - $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "stackchan-release-flash" $cleanupDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $cleanupDir | Out-Null - Expand-Archive -LiteralPath $PackageZip -DestinationPath $cleanupDir + Expand-StackchanReleaseZipSafely ` + -ZipPath $PackageZip -DestinationPath $cleanupDir $PackageRoot = $cleanupDir } - if ([string]::IsNullOrWhiteSpace($PackageRoot)) { - $candidateManifest = Join-Path $root "release_manifest.json" - if (Test-Path -LiteralPath $candidateManifest) { - $PackageRoot = $root - } else { - if ([string]::IsNullOrWhiteSpace($Version)) { - $Version = (git describe --tags --always --dirty).Trim() - } - $PackageRoot = Join-Path $root "output/release/$Version" - } - } - Assert-File $PackageRoot $manifestPath = Join-Path $PackageRoot "release_manifest.json" Assert-File $manifestPath $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json - if ([string]::IsNullOrWhiteSpace($Version)) { - $Version = [string]$manifest.version - } - - if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { - $ExpectedCommit = [string]$manifest.commit - } - - $verifyScript = Join-Path $PSScriptRoot "verify_release_package.ps1" - if ($AllowDirtyPackage) { - & $verifyScript -Version $Version -PackageRoot $PackageRoot -ExpectedCommit $ExpectedCommit -AllowDirtyPackage - } else { - & $verifyScript -Version $Version -PackageRoot $PackageRoot -ExpectedCommit $ExpectedCommit + if ([string]$manifest.version -ne $Version -or + ([string]$manifest.commit).ToLowerInvariant() -ne $ExpectedCommit.ToLowerInvariant()) { + throw "Verified package identity changed before flash preparation." } $firmwareDir = Join-Path $PackageRoot "firmware/$Firmware" diff --git a/tools/generate_synthetic_hardware_evidence.ps1 b/tools/generate_synthetic_hardware_evidence.ps1 index b8194acf..c5918f25 100644 --- a/tools/generate_synthetic_hardware_evidence.ps1 +++ b/tools/generate_synthetic_hardware_evidence.ps1 @@ -12,6 +12,7 @@ $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $repoRoot +. (Join-Path $PSScriptRoot "release_zip_safety.ps1") function Quote-PowerShellArgument { param([string]$Value) @@ -43,7 +44,7 @@ function Copy-AcceptanceArtifactsFromZip { $extractDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $extractDir | Out-Null try { - Expand-Archive -LiteralPath $ZipPath -DestinationPath $extractDir + Expand-StackchanReleaseZipSafely -ZipPath $ZipPath -DestinationPath $extractDir Copy-AcceptanceArtifactsFromRoot -SourceRoot $extractDir -DestinationRoot $DestinationRoot } finally { Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue @@ -152,7 +153,7 @@ function Copy-VoiceLeadArtifactsFromZip { $extractDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $extractDir | Out-Null try { - Expand-Archive -LiteralPath $ZipPath -DestinationPath $extractDir + Expand-StackchanReleaseZipSafely -ZipPath $ZipPath -DestinationPath $extractDir return Copy-VoiceLeadArtifactsFromRoot -SourceRoot $extractDir -DestinationRoot $DestinationRoot } finally { Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue @@ -208,7 +209,7 @@ function Copy-VoiceGateStatusFromZip { $extractDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $extractDir | Out-Null try { - Expand-Archive -LiteralPath $ZipPath -DestinationPath $extractDir + Expand-StackchanReleaseZipSafely -ZipPath $ZipPath -DestinationPath $extractDir return Copy-VoiceGateStatusFromRoot -SourceRoot $extractDir -DestinationRoot $DestinationRoot } finally { Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue diff --git a/tools/new_release_toolchain_identity_candidate.ps1 b/tools/new_release_toolchain_identity_candidate.ps1 new file mode 100644 index 00000000..78dd41f1 --- /dev/null +++ b/tools/new_release_toolchain_identity_candidate.ps1 @@ -0,0 +1,44 @@ +param( + [Parameter(Mandatory = $true)][string]$DefaultCoreDir, + [Parameter(Mandatory = $true)][string]$ReleaseCoreDir, + [Parameter(Mandatory = $true)][string]$PlatformioExecutable, + [Parameter(Mandatory = $true)][string]$PythonExecutable, + [string]$ProjectRoot, + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'release_toolchain_identity.ps1') + +if ([string]::IsNullOrWhiteSpace($ProjectRoot)) { + $ProjectRoot = Split-Path -Parent $PSScriptRoot +} +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $candidateRoot = Join-Path $ProjectRoot 'output/private/toolchain-identity-candidates' + New-Item -ItemType Directory -Force -Path $candidateRoot | Out-Null + $OutputPath = Join-Path $candidateRoot ( + 'release_toolchain_identity_allowlist_candidate_' + + (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + '.json') +} +$resolvedProjectRoot = (Get-Item -LiteralPath $ProjectRoot -Force -ErrorAction Stop).FullName +$trackedAllowlist = [IO.Path]::GetFullPath((Join-Path $resolvedProjectRoot 'tools/release_toolchain_identity_allowlist.json')) +$resolvedOutput = [IO.Path]::GetFullPath($OutputPath) +if ($resolvedOutput.Equals($trackedAllowlist, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Candidate generation refuses to overwrite the reviewed allowlist. Generate under output/private, review the diff, and update the tracked file explicitly.' +} + +$pythonHome = Split-Path -Parent (Split-Path -Parent ( + (Get-Item -LiteralPath $PlatformioExecutable -Force -ErrorAction Stop).FullName)) +$rootMap = @{ + pythonHome = $pythonHome + legacyCore = (Get-Item -LiteralPath $DefaultCoreDir -Force -ErrorAction Stop).FullName + releaseCore = (Get-Item -LiteralPath $ReleaseCoreDir -Force -ErrorAction Stop).FullName + projectRoot = $resolvedProjectRoot +} +$candidate = New-StackchanReleaseToolchainIdentityCandidate ` + -RootMap $rootMap ` + -PlatformioExecutable $PlatformioExecutable ` + -PythonExecutable $PythonExecutable +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $resolvedOutput) | Out-Null +$candidate | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $resolvedOutput -Encoding UTF8 +Write-Output $resolvedOutput diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index be0982f3..c11cd995 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -2,24 +2,412 @@ param( [string]$Version, [switch]$SkipBuild, [switch]$AllowDirty, - [switch]$ObserveCandidateActions + [switch]$ObserveCandidateActions, + [switch]$ReleaseShortPathChild ) $ErrorActionPreference = "Stop" +$script:releaseSourceCleanupReady = $false + +# These variables can alter safety flags or the canonical build epoch. Public +# packages must be derived from the reviewed configuration and clean Git +# identity. Check presence rather than truthiness and refuse before path/cache/ +# build/package work. Diagnostic direct builds may use STACKCHAN_BUILD_EPOCH; +# release packaging never may. +$releaseOverrideNames = @( + "PLATFORMIO_BUILD_FLAGS", + "STACKCHAN_BUILD_EPOCH", + "SOURCE_DATE_EPOCH", + "STACKCHAN_BUILD_STAMP", + "STACKCHAN_DISABLE_REPRODUCIBLE_BUILD", + "STACKCHAN_EXPECTED_BUILD_COMMIT", + "STACKCHAN_EXPECTED_BUILD_EPOCH", + "STACKCHAN_PERSONA", + "STACKCHAN_WIFI_SSID", + "STACKCHAN_WIFI_PASSWORD", + "STACKCHAN_BRIDGE_HOST", + "STACKCHAN_BRIDGE_PORT", + "STACKCHAN_BRIDGE_PATH", + "STACKCHAN_PAIRING_SHORT_CODE", + "STACKCHAN_OTA_TOKEN", + "STACKCHAN_OTA_PORT", + "PLATFORMIO_EXE", + "PLATFORMIO_CORE_DIR", + "PLATFORMIO_PROJECT_DIR", + "PLATFORMIO_SRC_DIR", + "PLATFORMIO_BUILD_DIR", + "PLATFORMIO_LIBDEPS_DIR", + "PLATFORMIO_PACKAGES_DIR", + "PLATFORMIO_CACHE_DIR", + "PLATFORMIO_BUILD_CACHE_DIR", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CEILING_DIRECTORIES" +) +foreach ($releaseOverrideName in $releaseOverrideNames) { + if (Test-Path ("Env:\" + $releaseOverrideName)) { + throw "Release packaging refuses ambient override: $releaseOverrideName" + } +} +$unexpectedPlatformioOverrides = @( + Get-ChildItem Env: | Where-Object { $_.Name -like "PLATFORMIO_*" } +) +if ($unexpectedPlatformioOverrides.Count -gt 0) { + $unexpectedPlatformioNames = @($unexpectedPlatformioOverrides.Name | Sort-Object -Unique) + throw "Release packaging refuses ambient PlatformIO overrides: $($unexpectedPlatformioNames -join ', ')" +} +$unexpectedGitOverrides = @( + Get-ChildItem Env: | Where-Object { $_.Name -like "GIT_*" } +) +if ($unexpectedGitOverrides.Count -gt 0) { + $unexpectedGitNames = @($unexpectedGitOverrides.Name | Sort-Object -Unique) + throw "Release packaging refuses ambient Git overrides: $($unexpectedGitNames -join ', ')" +} + +if (-not $SkipBuild) { + throw @' +Release-grade packaging is fail-closed before Git or build-tool execution. No tracked reviewed +exact toolchain allowlist currently authorizes the Git executable, PlatformIO/Python launchers, +their complete runtime inputs, and the post-build dependency state. Diagnostic packaging remains +available only with -SkipBuild -AllowDirty; it is never release eligible. +'@ +} + +$releaseBootstrapNullAttributes = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } +$releaseBootstrapGitCommand = Get-Command -Name git -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 +if ($null -eq $releaseBootstrapGitCommand) { + throw 'Release packaging requires a Git application executable; functions, aliases, and scripts are refused.' +} +$releaseBootstrapGitExecutable = ( + Resolve-Path -LiteralPath ([string]$releaseBootstrapGitCommand.Source)).Path +function Invoke-ReleaseBootstrapGit { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + + $disabledHooks = Join-Path $Root 'output/private/disabled-release-bootstrap-hooks' + if (Test-Path -LiteralPath $disabledHooks) { + throw "Release bootstrap disabled-hooks sentinel unexpectedly exists: $disabledHooks" + } + $gitArguments = @( + '-c', "core.hooksPath=$disabledHooks", '-c', 'core.fsmonitor=false', + '-c', 'core.untrackedCache=false', '-c', 'core.useBuiltinFSMonitor=false', + '-c', 'maintenance.auto=false', '-c', 'core.autocrlf=true', + '-c', "core.attributesFile=$script:releaseBootstrapNullAttributes", + '-c', 'filter.lfs.process=', '-c', 'filter.lfs.clean=', + '-c', 'filter.lfs.smudge=', '-c', 'filter.lfs.required=false', + '-C', $Root + ) + $Arguments + $previousNoReplaceObjects = $env:GIT_NO_REPLACE_OBJECTS + $previousNoSystemAttributes = $env:GIT_ATTR_NOSYSTEM + try { + $env:GIT_NO_REPLACE_OBJECTS = '1' + $env:GIT_ATTR_NOSYSTEM = '1' + & $script:releaseBootstrapGitExecutable @gitArguments + } finally { + if ($null -eq $previousNoReplaceObjects) { + Remove-Item Env:\GIT_NO_REPLACE_OBJECTS -ErrorAction SilentlyContinue + } else { + $env:GIT_NO_REPLACE_OBJECTS = $previousNoReplaceObjects + } + if ($null -eq $previousNoSystemAttributes) { + Remove-Item Env:\GIT_ATTR_NOSYSTEM -ErrorAction SilentlyContinue + } else { + $env:GIT_ATTR_NOSYSTEM = $previousNoSystemAttributes + } + } +} + +function Get-ReleaseBootstrapCanonicalBlobHash { + param( + [Parameter(Mandatory = $true)][string]$LiteralPath, + [Parameter(Mandatory = $true)][ValidateSet(40, 64)][int]$HashLength + ) + + $bytes = [System.IO.File]::ReadAllBytes($LiteralPath) + if (-not ($bytes -contains [byte]0)) { + $normalized = New-Object System.Collections.Generic.List[byte] + for ($index = 0; $index -lt $bytes.Length; $index++) { + if ($bytes[$index] -eq 13 -and $index + 1 -lt $bytes.Length -and + $bytes[$index + 1] -eq 10) { + $normalized.Add(10) + $index++ + } else { + $normalized.Add($bytes[$index]) + } + } + $bytes = $normalized.ToArray() + } + $header = [System.Text.Encoding]::ASCII.GetBytes("blob $($bytes.Length)`0") + $objectBytes = New-Object byte[] ($header.Length + $bytes.Length) + [System.Array]::Copy($header, 0, $objectBytes, 0, $header.Length) + [System.Array]::Copy($bytes, 0, $objectBytes, $header.Length, $bytes.Length) + $hasher = if ($HashLength -eq 40) { + [System.Security.Cryptography.SHA1]::Create() + } else { + [System.Security.Cryptography.SHA256]::Create() + } + try { + return (($hasher.ComputeHash($objectBytes) | ForEach-Object { $_.ToString('x2') }) -join '') + } finally { + $hasher.Dispose() + } +} + +function Resolve-ReleaseBootstrapGitPath { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Candidate + ) + if ([System.IO.Path]::IsPathRooted($Candidate)) { + return [System.IO.Path]::GetFullPath($Candidate) + } + return [System.IO.Path]::GetFullPath((Join-Path $Root $Candidate)) +} + +function Assert-ReleaseBootstrapTrust { + param([Parameter(Mandatory = $true)][string]$Root) + + $resolvedRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + $topLevel = (Invoke-ReleaseBootstrapGit -Root $resolvedRoot -Arguments @( + 'rev-parse', '--show-toplevel')).Trim() + if ($LASTEXITCODE -ne 0 -or + -not [System.IO.Path]::GetFullPath($topLevel).TrimEnd('\', '/').Equals( + $resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'Release packaging must start at its exact Git top-level.' + } + $attributePaths = New-Object System.Collections.Generic.List[string] + $worktreeAttributes = (Invoke-ReleaseBootstrapGit -Root $resolvedRoot -Arguments @( + 'rev-parse', '--git-path', 'info/attributes')).Trim() + $commonDir = (Invoke-ReleaseBootstrapGit -Root $resolvedRoot -Arguments @( + 'rev-parse', '--git-common-dir')).Trim() + if ([string]::IsNullOrWhiteSpace($worktreeAttributes) -or + [string]::IsNullOrWhiteSpace($commonDir)) { + throw 'Release packaging could not resolve repository-local attributes state.' + } + $attributePaths.Add((Resolve-ReleaseBootstrapGitPath -Root $resolvedRoot -Candidate $worktreeAttributes)) + $attributePaths.Add((Resolve-ReleaseBootstrapGitPath -Root $resolvedRoot -Candidate ( + Join-Path $commonDir 'info/attributes'))) + foreach ($attributePath in @($attributePaths | Sort-Object -Unique)) { + if (Test-Path -LiteralPath $attributePath -PathType Leaf) { + throw "Release packaging refuses repository-local Git attributes: $attributePath" + } + } + $commit = (Invoke-ReleaseBootstrapGit -Root $resolvedRoot -Arguments @( + 'rev-parse', '--verify', 'HEAD')).Trim().ToLowerInvariant() + if ($LASTEXITCODE -ne 0 -or $commit -notmatch '^[0-9a-f]{40,64}$') { + throw 'Release packaging could not resolve its exact Git commit.' + } + $bootstrapFiles = @( + '.gitattributes', 'tools/package_release.ps1', + 'tools/test_firmware_reproducible_build_contract.ps1', + 'tools/platformio_resolver.ps1', 'tools/preview_python_resolver.ps1', + 'tools/release_asset_contract.ps1', 'tools/firmware_reproducibility_failure.ps1', + 'tools/release_source_binding.ps1', 'tools/release_dependency_evidence.ps1', + 'tools/release_git_trust.ps1', 'tools/check_release_credential_hygiene.ps1' + ) + foreach ($relative in $bootstrapFiles) { + $indexRecord = @(Invoke-ReleaseBootstrapGit -Root $resolvedRoot -Arguments @( + 'ls-files', '-v', '--', $relative)) + $trustedBlob = (Invoke-ReleaseBootstrapGit -Root $resolvedRoot -Arguments @( + 'rev-parse', '--verify', "${commit}:$relative")).Trim().ToLowerInvariant() + $workingPath = Join-Path $resolvedRoot $relative + if ($indexRecord.Count -ne 1 -or [string]$indexRecord[0] -cne "H $relative" -or + $trustedBlob -notmatch '^[0-9a-f]{40,64}$' -or + -not (Test-Path -LiteralPath $workingPath -PathType Leaf) -or + (Get-ReleaseBootstrapCanonicalBlobHash -LiteralPath $workingPath ` + -HashLength $trustedBlob.Length) -cne $trustedBlob) { + throw "Release packaging bootstrap input is not exact trusted commit content: $relative" + } + } + $dirty = @(Invoke-ReleaseBootstrapGit -Root $resolvedRoot -Arguments @( + 'status', '--porcelain=v1', '--untracked-files=all')) + if ($LASTEXITCODE -ne 0 -or $dirty.Count -ne 0) { + throw 'Release packaging requires a clean trusted checkout before executing helpers or mutating release state.' + } +} + +function Invoke-ReleaseGit { + param([Parameter(Mandatory = $true)][string[]]$Arguments) + Invoke-StackchanTrustedGit ` + -GitExecutable $script:releaseBootstrapGitExecutable ` + -DisabledHooksPath $script:releaseGitDisabledHooksPath ` + -Arguments $Arguments +} + +function Assert-SafeReleaseVersionLeaf { + param([Parameter(Mandatory = $true)][string]$Value) + + if ($Value.Length -gt 128 -or + $Value -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or + $Value -in @('.', '..') -or + $Value.EndsWith('.', [System.StringComparison]::Ordinal)) { + throw "Version must be one safe filename component containing only letters, digits, '.', '_', or '-'." + } +} + +function New-StackchanDeterministicReleaseZip { + param( + [Parameter(Mandatory = $true)][string]$RootPath, + [Parameter(Mandatory = $true)][string]$ZipPath, + [Parameter(Mandatory = $true)][long]$SourceEpoch + ) + + Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop + $rootItem = Get-Item -LiteralPath (Resolve-Path -LiteralPath $RootPath).Path -Force + if (-not $rootItem.PSIsContainer -or + ($rootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw 'Release ZIP root must be one exact non-redirected directory.' + } + $resolvedRoot = $rootItem.FullName.TrimEnd('\', '/') + $resolvedZip = [System.IO.Path]::GetFullPath($ZipPath) + $rootPrefix = $resolvedRoot + [System.IO.Path]::DirectorySeparatorChar + if ($resolvedZip.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'Release ZIP must be outside the package tree being archived.' + } + $entryTimestamp = [DateTimeOffset]::FromUnixTimeSeconds($SourceEpoch) + $minimumZipTimestamp = [DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + $maximumZipTimestamp = [DateTimeOffset]::new(2107, 12, 31, 23, 59, 58, [TimeSpan]::Zero) + if ($entryTimestamp -lt $minimumZipTimestamp -or $entryTimestamp -gt $maximumZipTimestamp) { + throw "Release source epoch is outside the ZIP timestamp range: $SourceEpoch" + } + # ZIP timestamps have two-second resolution. Normalize explicitly so identical inputs produce + # identical metadata rather than relying on runtime rounding behavior. + $subsecondTicks = $entryTimestamp.Ticks % [TimeSpan]::TicksPerSecond + $entryTimestamp = $entryTimestamp.AddSeconds(-($entryTimestamp.Second % 2)).AddTicks(-$subsecondTicks) + + $files = @(Get-ChildItem -LiteralPath $resolvedRoot -File -Recurse -Force) + $relativeToFile = [System.Collections.Generic.Dictionary[string, string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + foreach ($file in $files) { + $fullName = [System.IO.Path]::GetFullPath($file.FullName) + if (-not $fullName.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Release ZIP input escapes its package root: $fullName" + } + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "Release ZIP refuses a reparse-point input: $fullName" + } + $ancestor = $file.Directory + while ($null -ne $ancestor -and + -not $ancestor.FullName.Equals( + $resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + if ($ancestor.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "Release ZIP refuses a file beneath a reparse-point directory: $fullName" + } + $ancestor = $ancestor.Parent + } + if ($null -eq $ancestor) { + throw "Release ZIP could not prove the input's directory ancestry: $fullName" + } + $relative = $fullName.Substring($rootPrefix.Length).Replace('\', '/') + if ([string]::IsNullOrWhiteSpace($relative) -or $relativeToFile.ContainsKey($relative)) { + throw "Release ZIP input has an invalid or duplicate entry: $relative" + } + $relativeToFile.Add($relative, $fullName) + } + $relativePaths = [string[]]@($relativeToFile.Keys) + [Array]::Sort($relativePaths, [System.StringComparer]::Ordinal) + + $stream = [System.IO.FileStream]::new( + $resolvedZip, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None) + try { + $archive = [System.IO.Compression.ZipArchive]::new( + $stream, [System.IO.Compression.ZipArchiveMode]::Create, $true, + [System.Text.Encoding]::UTF8) + try { + foreach ($relative in $relativePaths) { + $entry = $archive.CreateEntry( + $relative, [System.IO.Compression.CompressionLevel]::Optimal) + $entry.LastWriteTime = $entryTimestamp + $sourceStream = [System.IO.FileStream]::new( + $relativeToFile[$relative], [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) + $entryStream = $entry.Open() + try { + $sourceStream.CopyTo($entryStream) + } finally { + $entryStream.Dispose() + $sourceStream.Dispose() + } + } + } finally { + $archive.Dispose() + } + } finally { + $stream.Dispose() + } + + $readStream = [System.IO.FileStream]::new( + $resolvedZip, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read) + try { + $readArchive = [System.IO.Compression.ZipArchive]::new( + $readStream, [System.IO.Compression.ZipArchiveMode]::Read, $false, + [System.Text.Encoding]::UTF8) + try { + $actualEntries = [string[]]@($readArchive.Entries | ForEach-Object { $_.FullName }) + } finally { + $readArchive.Dispose() + } + } finally { + $readStream.Dispose() + } + if ($actualEntries.Count -ne $relativePaths.Count) { + throw 'Release ZIP entry count changed during deterministic archive creation.' + } + for ($index = 0; $index -lt $relativePaths.Count; $index++) { + if ([string]$actualEntries[$index] -cne [string]$relativePaths[$index]) { + throw "Release ZIP central-directory order mismatch at index $index." + } + } + return $actualEntries +} + +if (-not [string]::IsNullOrWhiteSpace($Version)) { + Assert-SafeReleaseVersionLeaf -Value $Version +} -# PLATFORMIO_BUILD_FLAGS is an ambient PlatformIO override and can append -# conflicting safety macros after the checked-in environment. Public packages -# must be derived from the reviewed configuration, never an inherited caller -# override. Check presence rather than truthiness and refuse before path/cache/ -# build/package work. -if (Test-Path Env:\PLATFORMIO_BUILD_FLAGS) { - throw "Release packaging refuses ambient override: PLATFORMIO_BUILD_FLAGS" +if ($SkipBuild -and -not $AllowDirty) { + throw "-SkipBuild is diagnostic-only and requires -AllowDirty; release-grade packages must perform the governed two-cycle firmware proof." +} +if ($AllowDirty -and -not $SkipBuild) { + throw "-AllowDirty supports diagnostic -SkipBuild packages only; dirty firmware compilation requires a separately governed direct build." +} +if ($SkipBuild -and $ObserveCandidateActions) { + throw "Diagnostic -SkipBuild packages cannot observe or claim candidate GitHub Actions evidence." +} + +$releaseSystemDirectory = [System.IO.Path]::GetFullPath([Environment]::SystemDirectory).TrimEnd('\', '/') +if ($env:OS -ne 'Windows_NT' -or -not [System.IO.Path]::IsPathRooted($releaseSystemDirectory)) { + throw 'Release packaging requires a validated Windows system executable root.' +} +$releasePowerShellExecutable = Join-Path $releaseSystemDirectory 'WindowsPowerShell/v1.0/powershell.exe' +$releaseSubstExecutable = Join-Path $releaseSystemDirectory 'subst.exe' +foreach ($systemExecutable in @($releasePowerShellExecutable, $releaseSubstExecutable)) { + if (-not (Test-Path -LiteralPath $systemExecutable -PathType Leaf)) { + throw "Required Windows system executable is missing: $systemExecutable" + } + $systemExecutableItem = Get-Item -LiteralPath $systemExecutable -Force + if ($systemExecutableItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint -or + [string]$systemExecutableItem.Extension -cne '.exe') { + throw "Release packaging refuses a redirected or non-EXE system command: $systemExecutable" + } } $physicalRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +if (-not $SkipBuild) { + Assert-ReleaseBootstrapTrust -Root $physicalRepoRoot +} if ( $env:OS -eq "Windows_NT" -and - -not $env:STACKCHAN_RELEASE_SHORT_PATH_ACTIVE -and + -not $ReleaseShortPathChild -and $physicalRepoRoot.Length -gt 60 ) { $shortDrive = @("R:", "Q:", "P:", "O:") | @@ -30,36 +418,56 @@ if ( } $driveName = $shortDrive.TrimEnd("\") - & subst.exe $driveName $physicalRepoRoot + & $script:releaseSubstExecutable $driveName $physicalRepoRoot if ($LASTEXITCODE -ne 0) { throw "Could not create temporary release path $driveName" } $childExit = 1 try { - $env:STACKCHAN_RELEASE_SHORT_PATH_ACTIVE = "1" $childArgs = @( "-NoProfile", "-ExecutionPolicy", "Bypass", - "-File", "$driveName\tools\package_release.ps1" + "-File", "$driveName\tools\package_release.ps1", + "-ReleaseShortPathChild" ) if ($Version) { $childArgs += @("-Version", $Version) } if ($SkipBuild) { $childArgs += "-SkipBuild" } if ($AllowDirty) { $childArgs += "-AllowDirty" } if ($ObserveCandidateActions) { $childArgs += "-ObserveCandidateActions" } - & powershell.exe @childArgs + & $script:releasePowerShellExecutable @childArgs $childExit = $LASTEXITCODE } finally { - Remove-Item Env:\STACKCHAN_RELEASE_SHORT_PATH_ACTIVE -ErrorAction SilentlyContinue Set-Location $env:TEMP - & subst.exe $driveName /D | Out-Null + & $script:releaseSubstExecutable $driveName /D | Out-Null } exit $childExit } +if ($ReleaseShortPathChild) { + $allowedShortRoots = @("R:\", "Q:\", "P:\", "O:\") + $currentRoot = [System.IO.Path]::GetPathRoot($physicalRepoRoot) + if ($allowedShortRoots -notcontains $currentRoot -or $physicalRepoRoot.Length -gt 60) { + throw "-ReleaseShortPathChild is internal and requires the verified short subst checkout." + } +} $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $repoRoot +$releaseGitDisabledHooksPath = Join-Path $repoRoot ( + "output/private/disabled-release-git-hooks-$PID-" + [guid]::NewGuid().ToString("N")) +if (Test-Path -LiteralPath $releaseGitDisabledHooksPath) { + throw "Release Git disabled-hooks sentinel unexpectedly exists: $releaseGitDisabledHooksPath" +} +& $releasePowerShellExecutable -NoProfile -ExecutionPolicy Bypass -File ` + (Join-Path $PSScriptRoot "test_firmware_reproducible_build_contract.ps1") +if ($LASTEXITCODE -ne 0) { + throw "Firmware reproducible-build contract failed; refusing release packaging." +} . (Join-Path $PSScriptRoot "platformio_resolver.ps1") . (Join-Path $PSScriptRoot "preview_python_resolver.ps1") . (Join-Path $PSScriptRoot "release_asset_contract.ps1") +. (Join-Path $PSScriptRoot "firmware_reproducibility_failure.ps1") +. (Join-Path $PSScriptRoot "release_source_binding.ps1") +. (Join-Path $PSScriptRoot "release_dependency_evidence.ps1") +. (Join-Path $PSScriptRoot "release_git_trust.ps1") $credentialHygieneJson = (& (Join-Path $PSScriptRoot "check_release_credential_hygiene.ps1") -Root $repoRoot -Json | Out-String) $credentialHygieneReport = $credentialHygieneJson | ConvertFrom-Json @@ -90,12 +498,30 @@ function Get-ReleasePlatformioCoreDir { function Invoke-StackchanReleasePlatformio { param( [string]$Environment, + [string]$BuildCacheDir, + [string]$ExpectedCommit, + [string]$ExpectedEpoch, [string[]]$Arguments ) $previousCoreDir = $env:PLATFORMIO_CORE_DIR + $previousBuildCacheDir = $env:PLATFORMIO_BUILD_CACHE_DIR + $previousExpectedCommit = $env:STACKCHAN_EXPECTED_BUILD_COMMIT + $previousExpectedEpoch = $env:STACKCHAN_EXPECTED_BUILD_EPOCH try { $env:PLATFORMIO_CORE_DIR = Get-ReleasePlatformioCoreDir -Environment $Environment + if (-not [string]::IsNullOrWhiteSpace($BuildCacheDir)) { + $env:PLATFORMIO_BUILD_CACHE_DIR = $BuildCacheDir + } + if (-not [string]::IsNullOrWhiteSpace($ExpectedCommit) -or + -not [string]::IsNullOrWhiteSpace($ExpectedEpoch)) { + if ([string]::IsNullOrWhiteSpace($ExpectedCommit) -or + [string]::IsNullOrWhiteSpace($ExpectedEpoch)) { + throw "Release build identity lock requires both commit and epoch." + } + $env:STACKCHAN_EXPECTED_BUILD_COMMIT = $ExpectedCommit + $env:STACKCHAN_EXPECTED_BUILD_EPOCH = $ExpectedEpoch + } Invoke-StackchanPlatformio @Arguments } finally { if ($null -eq $previousCoreDir) { @@ -103,11 +529,30 @@ function Invoke-StackchanReleasePlatformio { } else { $env:PLATFORMIO_CORE_DIR = $previousCoreDir } + if ($null -eq $previousBuildCacheDir) { + Remove-Item Env:\PLATFORMIO_BUILD_CACHE_DIR -ErrorAction SilentlyContinue + } else { + $env:PLATFORMIO_BUILD_CACHE_DIR = $previousBuildCacheDir + } + if ($null -eq $previousExpectedCommit) { + Remove-Item Env:\STACKCHAN_EXPECTED_BUILD_COMMIT -ErrorAction SilentlyContinue + } else { + $env:STACKCHAN_EXPECTED_BUILD_COMMIT = $previousExpectedCommit + } + if ($null -eq $previousExpectedEpoch) { + Remove-Item Env:\STACKCHAN_EXPECTED_BUILD_EPOCH -ErrorAction SilentlyContinue + } else { + $env:STACKCHAN_EXPECTED_BUILD_EPOCH = $previousExpectedEpoch + } } } if ([string]::IsNullOrWhiteSpace($Version)) { - $Version = (git describe --tags --always --dirty).Trim() + $Version = (Invoke-ReleaseGit -Arguments @("describe", "--tags", "--always", "--dirty")).Trim() +} +Assert-SafeReleaseVersionLeaf -Value $Version +if ($SkipBuild -and -not $Version.StartsWith("diagnostic-", [System.StringComparison]::Ordinal)) { + throw "Diagnostic -SkipBuild package versions must start with 'diagnostic-'." } $firmwareArtifactNames = @( @@ -133,44 +578,594 @@ function Copy-BuildArtifacts { } } -$releaseOutputRoot = Join-Path $repoRoot "output/release" -Get-ChildItem -LiteralPath $releaseOutputRoot -Directory -Force -Filter ".firmware-build-cache-*" -ErrorAction SilentlyContinue | - Remove-Item -Recurse -Force +$releaseOutputRoot = if ($SkipBuild) { + Join-Path $repoRoot "output/diagnostics" +} else { + Join-Path $repoRoot "output/release" +} $builtFirmwareCache = $null -if (-not $SkipBuild) { - # These profiles intentionally span the legacy Espressif platform and the - # pioarduino/Arduino 3.3.6 platform. Building them in one PlatformIO process - # lets the shared framework package name replace the active toolchain. The - # replacement can also invalidate prior .pio/build trees, so snapshot every - # successful environment before installing the next framework family. - $builtFirmwareCache = Join-Path $repoRoot "output/release/.firmware-build-cache-$PID" - if (Test-Path -LiteralPath $builtFirmwareCache) { - Remove-Item -LiteralPath $builtFirmwareCache -Recurse -Force +$firmwareBuildCacheRoot = $null +$firmwareDependencySnapshotRoot = $null +$releaseSourceRoot = $null +$releaseSourceWorktreeAdded = $false +$releaseSourceLocationPushed = $false +$releaseSourceFailureRecorded = $false +$firmwareReproducibilityProof = [ordered]@{ + status = "not-proven-skip-build" + minimumClockBoundarySeconds = 65 + clockBoundarySeconds = 0 + cycleAStartedUtc = $null + cycleBStartedUtc = $null + cycleASourceCommit = $null + cycleASourceEpoch = $null + cycleBSourceCommit = $null + cycleBSourceEpoch = $null + buildCachePolicy = "not-applicable-skip-build" + sourceIsolationPolicy = "not-applicable-skip-build" + identityAttestations = @() + cycleAArtifacts = @() + cycleBArtifacts = @() +} + +function Get-CanonicalReleaseGitIdentity { + param([Parameter(Mandatory = $true)][string]$ProjectRoot) + + $identityCommit = (Invoke-ReleaseGit -Arguments @("-C", $ProjectRoot, "rev-parse", "HEAD")).Trim() + if ($LASTEXITCODE -ne 0 -or $identityCommit -notmatch '^[0-9a-fA-F]{40}$') { + throw "Could not resolve a canonical 40-character Git commit for release provenance." + } + $identityEpoch = (Invoke-ReleaseGit -Arguments @("-C", $ProjectRoot, "show", "-s", "--format=%ct", $identityCommit)).Trim() + if ($LASTEXITCODE -ne 0 -or $identityEpoch -notmatch '^[0-9]{1,12}$') { + throw "Could not resolve the canonical Git commit epoch for release provenance." + } + return [ordered]@{ + commit = $identityCommit.ToLowerInvariant() + epoch = $identityEpoch + } +} + +function Assert-ReleaseSourceIdentity { + param( + [Parameter(Mandatory = $true)][string]$ExpectedCommit, + [Parameter(Mandatory = $true)][string]$ExpectedEpoch, + [Parameter(Mandatory = $true)][string]$Phase, + [Parameter(Mandatory = $true)][string]$ProjectRoot, + [switch]$RejectIgnored + ) + + $observedIdentity = Get-CanonicalReleaseGitIdentity -ProjectRoot $ProjectRoot + if ($observedIdentity.commit -cne $ExpectedCommit -or + $observedIdentity.epoch -cne $ExpectedEpoch) { + throw "Release source identity changed during $Phase." + } + $identityStatusArguments = @( + "-C", $ProjectRoot, "status", "--porcelain", "--untracked-files=all") + if ($RejectIgnored) { + $identityStatusArguments += @("--ignored=matching", "--ignore-submodules=none") + } + $identityDirty = @(Invoke-ReleaseGit -Arguments $identityStatusArguments) + if ($LASTEXITCODE -ne 0 -or $identityDirty.Count -gt 0) { + throw "Release source worktree is not clean during $Phase." + } +} + +function New-ShortReleaseScratchPath { + param([Parameter(Mandatory = $true)][string]$Label) + + if ($Label -notmatch '^[a-z0-9-]{1,16}$') { + throw "Invalid release scratch label: $Label" + } + if ($env:OS -eq "Windows_NT") { + $reservedDrives = @("R", "Q", "P", "O", "S", "T", "U") + $drive = Get-PSDrive -PSProvider FileSystem | + Where-Object { + $_.Root -match '^[A-Za-z]:\\$' -and + $reservedDrives -notcontains $_.Name -and + $null -ne $_.Free + } | + Sort-Object Free -Descending | + Select-Object -First 1 + if ($null -eq $drive) { + throw "Could not locate a fixed drive for a short release build worktree." + } + $scratch = Join-Path $drive.Root ("sc-$Label-$PID-" + [guid]::NewGuid().ToString("N").Substring(0, 8)) + } else { + $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("sc-$Label-$PID-" + [guid]::NewGuid().ToString("N").Substring(0, 8)) } + $scratch = [System.IO.Path]::GetFullPath($scratch) + if ($env:OS -eq "Windows_NT" -and $scratch.Length -gt 60) { + throw "Release scratch path is not short enough for the embedded toolchain: $scratch" + } + if (Test-Path -LiteralPath $scratch) { + throw "Fresh release scratch path already exists: $scratch" + } + return $scratch +} + +function Invoke-LoggedReleasePlatformio { + param( + [Parameter(Mandatory = $true)][string]$Environment, + [Parameter(Mandatory = $true)][string]$BuildCacheDir, + [Parameter(Mandatory = $true)][string]$ExpectedCommit, + [Parameter(Mandatory = $true)][string]$ExpectedEpoch, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$LogPath, + [Parameter(Mandatory = $true)][string]$Description + ) + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null + $lines = New-Object 'System.Collections.Generic.List[string]' + try { + Invoke-StackchanReleasePlatformio ` + -Environment $Environment ` + -BuildCacheDir $BuildCacheDir ` + -ExpectedCommit $ExpectedCommit ` + -ExpectedEpoch $ExpectedEpoch ` + -Arguments $Arguments 2>&1 | ForEach-Object { + $line = [string]$_ + $lines.Add($line) + Write-Host $line + } + $commandExit = $LASTEXITCODE + } catch { + $lines.Add([string]$_) + $lines | Set-Content -LiteralPath $LogPath -Encoding UTF8 + throw + } + $lines | Set-Content -LiteralPath $LogPath -Encoding UTF8 + if ($commandExit -ne 0) { + throw "$Description failed with exit code $commandExit. Log: $LogPath" + } + return @($lines) +} + +function Copy-DependencySnapshotFiles { + param( + [Parameter(Mandatory = $true)][string]$SourceRoot, + [Parameter(Mandatory = $true)][string]$DestinationRoot + ) + + if (-not (Test-Path -LiteralPath $SourceRoot -PathType Container)) { return } + $sourcePath = (Resolve-Path -LiteralPath $SourceRoot).Path.TrimEnd('\', '/') + foreach ($file in Get-ChildItem -LiteralPath $sourcePath -Recurse -File -Force -ErrorAction SilentlyContinue) { + if ($file.Name -notmatch '(?i)^(LICENSE|LICENCE|COPYING|NOTICE)(\..*)?$' -and + $file.Name -notin @('library.json', 'library.properties', 'package.json', 'platform.json')) { + continue + } + $relative = $file.FullName.Substring($sourcePath.Length + 1) + $destination = Join-Path $DestinationRoot $relative + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null + Copy-Item -LiteralPath $file.FullName -Destination $destination -Force + } +} + +function Save-BuildDependencySnapshot { + param( + [Parameter(Mandatory = $true)][string]$Environment, + [Parameter(Mandatory = $true)][string]$BuildCacheDir, + [Parameter(Mandatory = $true)][string]$ExpectedCommit, + [Parameter(Mandatory = $true)][string]$ExpectedEpoch, + [Parameter(Mandatory = $true)][string]$BuildProjectRoot, + [Parameter(Mandatory = $true)][string]$SnapshotRoot + ) + + $environmentRoot = Join-Path $SnapshotRoot $Environment + New-Item -ItemType Directory -Force -Path $environmentRoot | Out-Null + $packageListLines = @(Invoke-LoggedReleasePlatformio ` + -Environment $Environment ` + -BuildCacheDir $BuildCacheDir ` + -ExpectedCommit $ExpectedCommit ` + -ExpectedEpoch $ExpectedEpoch ` + -Arguments @('pkg', 'list', '-d', $BuildProjectRoot, '-e', $Environment) ` + -LogPath (Join-Path $environmentRoot 'pkg-list.txt') ` + -Description "$Environment dependency inventory") + $verbosePackageListLines = @(Invoke-LoggedReleasePlatformio ` + -Environment $Environment ` + -BuildCacheDir $BuildCacheDir ` + -ExpectedCommit $ExpectedCommit ` + -ExpectedEpoch $ExpectedEpoch ` + -Arguments @('pkg', 'list', '-d', $BuildProjectRoot, '-e', $Environment, '-v') ` + -LogPath (Join-Path $environmentRoot 'pkg-list-verbose.txt') ` + -Description "$Environment verbose dependency inventory") + Invoke-LoggedReleasePlatformio ` + -Environment $Environment ` + -BuildCacheDir $BuildCacheDir ` + -ExpectedCommit $ExpectedCommit ` + -ExpectedEpoch $ExpectedEpoch ` + -Arguments @('--version') ` + -LogPath (Join-Path $environmentRoot 'platformio-version.txt') ` + -Description "$Environment PlatformIO version capture" | Out-Null + + Copy-DependencySnapshotFiles ` + -SourceRoot (Join-Path $BuildProjectRoot ".pio/libdeps/$Environment") ` + -DestinationRoot (Join-Path $environmentRoot 'libdeps') + $coreDir = Get-ReleasePlatformioCoreDir -Environment $Environment + $platformSource = Get-StackchanVerbosePlatformSource ` + -VerbosePackageList ($verbosePackageListLines -join "`n") ` + -PlatformioCoreDir $coreDir + Copy-DependencySnapshotFiles ` + -SourceRoot $platformSource.sourcePath ` + -DestinationRoot (Join-Path $environmentRoot ('platform/' + $platformSource.sourceLeaf)) + $platformSource | ConvertTo-Json | Set-Content ` + -LiteralPath (Join-Path $environmentRoot 'platform-source.json') -Encoding UTF8 + $resolvedPackages = @(Convert-StackchanPioPackageList ($packageListLines -join "`n")) + $corePackagesRoot = Join-Path $coreDir 'packages' + $corePackageNames = @(Get-StackchanResolvedCorePackageNames ` + -ResolvedPackages $resolvedPackages -CorePackagesRoot $corePackagesRoot) + if ($corePackageNames.Count -eq 0) { + throw "No resolved PlatformIO core packages were captured for $Environment" + } + Copy-StackchanResolvedCorePackageEvidence ` + -CorePackagesRoot $corePackagesRoot ` + -DestinationRoot (Join-Path $environmentRoot 'packages') ` + -CorePackageNames $corePackageNames + $corePackageNames | ConvertTo-Json | Set-Content ` + -LiteralPath (Join-Path $environmentRoot 'core-package-names.json') -Encoding UTF8 +} + +function Invoke-FirmwareBuildCycle { + param( + [Parameter(Mandatory = $true)][string]$CycleRoot, + [Parameter(Mandatory = $true)][string]$ExpectedCommit, + [Parameter(Mandatory = $true)][string]$ExpectedEpoch, + [Parameter(Mandatory = $true)][string]$CycleName, + [Parameter(Mandatory = $true)][string]$BuildProjectRoot, + [string]$DependencySnapshotRoot + ) + New-Item -ItemType Directory -Force -Path $CycleRoot | Out-Null foreach ($environment in @("stackchan", "stackchan_servo_calibration", "stackchan_release_full")) { - $environmentLibdeps = Join-Path $repoRoot ".pio/libdeps/$environment" + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $ExpectedCommit ` + -ExpectedEpoch $ExpectedEpoch ` + -Phase "$CycleName/$environment pre-clean" ` + -ProjectRoot $BuildProjectRoot + $environmentBuildCache = Join-Path $CycleRoot "build-cache-$environment" + if (Test-Path -LiteralPath $environmentBuildCache) { + throw "Fresh release build cache already exists: $environmentBuildCache" + } + New-Item -ItemType Directory -Path $environmentBuildCache | Out-Null + if (@(Get-ChildItem -LiteralPath $environmentBuildCache -Force).Count -ne 0) { + throw "Fresh release build cache is not empty: $environmentBuildCache" + } + $environmentLibdeps = Join-Path $BuildProjectRoot ".pio/libdeps/$environment" if (Test-Path -LiteralPath $environmentLibdeps) { Remove-Item -LiteralPath $environmentLibdeps -Recurse -Force } - Invoke-StackchanReleasePlatformio ` + Invoke-LoggedReleasePlatformio ` -Environment $environment ` - -Arguments @("run", "-e", $environment, "-t", "clean") - Invoke-StackchanReleasePlatformio ` + -BuildCacheDir $environmentBuildCache ` + -ExpectedCommit $ExpectedCommit ` + -ExpectedEpoch $ExpectedEpoch ` + -Arguments @("run", "-d", $BuildProjectRoot, "-e", $environment, "-t", "clean") ` + -LogPath (Join-Path $CycleRoot "logs/$environment-clean.log") ` + -Description "$CycleName/$environment clean" | Out-Null + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $ExpectedCommit ` + -ExpectedEpoch $ExpectedEpoch ` + -Phase "$CycleName/$environment pre-build" ` + -ProjectRoot $BuildProjectRoot + Invoke-LoggedReleasePlatformio ` -Environment $environment ` - -Arguments @("run", "-e", $environment) + -BuildCacheDir $environmentBuildCache ` + -ExpectedCommit $ExpectedCommit ` + -ExpectedEpoch $ExpectedEpoch ` + -Arguments @("run", "-d", $BuildProjectRoot, "-e", $environment) ` + -LogPath (Join-Path $CycleRoot "logs/$environment-build.log") ` + -Description "$CycleName/$environment build" | Out-Null Copy-BuildArtifacts ` - -BuildDir (Join-Path $repoRoot ".pio/build/$environment") ` - -Destination (Join-Path $builtFirmwareCache $environment) + -BuildDir (Join-Path $BuildProjectRoot ".pio/build/$environment") ` + -Destination (Join-Path $CycleRoot $environment) + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $ExpectedCommit ` + -ExpectedEpoch $ExpectedEpoch ` + -Phase "$CycleName/$environment post-snapshot" ` + -ProjectRoot $BuildProjectRoot + $script:firmwareIdentityAttestations += [ordered]@{ + cycle = $CycleName + environment = $environment + sourceCommit = $ExpectedCommit + sourceEpoch = $ExpectedEpoch + preBuildChecked = $true + postSnapshotChecked = $true + } + if (-not [string]::IsNullOrWhiteSpace($DependencySnapshotRoot)) { + Save-BuildDependencySnapshot ` + -Environment $environment ` + -BuildCacheDir $environmentBuildCache ` + -ExpectedCommit $ExpectedCommit ` + -ExpectedEpoch $ExpectedEpoch ` + -BuildProjectRoot $BuildProjectRoot ` + -SnapshotRoot $DependencySnapshotRoot + } + } +} + +function Get-FirmwareBuildArtifactRecords { + param( + [Parameter(Mandatory = $true)][string]$CycleRoot + ) + $records = @() + foreach ($environment in @("stackchan", "stackchan_servo_calibration", "stackchan_release_full")) { + foreach ($artifact in $firmwareArtifactNames) { + $path = Join-Path (Join-Path $CycleRoot $environment) $artifact + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Missing reproducibility artifact: $path" + } + $item = Get-Item -LiteralPath $path + $records += [ordered]@{ + environment = $environment + artifact = $artifact + bytes = [long]$item.Length + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash.ToUpperInvariant() + } + } + } + return @($records) +} + +function Get-UtcWholeSecond { + $now = (Get-Date).ToUniversalTime() + return [DateTime]::new( + $now.Ticks - ($now.Ticks % [TimeSpan]::TicksPerSecond), + [DateTimeKind]::Utc) +} + +function Remove-ReleaseSourceWorktree { + if ($script:releaseSourceLocationPushed) { + Pop-Location + $script:releaseSourceLocationPushed = $false + } + if (-not $script:releaseSourceWorktreeAdded) { + return + } + if ([string]::IsNullOrWhiteSpace($script:releaseSourceRoot)) { + throw "Commit-bound release source root is missing while its worktree is registered; refusing successful cleanup." + } + if (-not (Test-Path -LiteralPath $script:releaseSourceRoot)) { + throw ( + "Commit-bound release source worktree is missing at " + [string]$script:releaseSourceRoot + + "; refusing successful packaging because its final state cannot be audited.") + } + $releaseSourceDirty = @( + Invoke-ReleaseGit -Arguments @( + '-C', [string]$script:releaseSourceRoot, 'status', '--porcelain=v1', + '--untracked-files=all', '--ignored=matching', '--ignore-submodules=none')) + if ($LASTEXITCODE -ne 0) { + throw "Could not audit the commit-bound release source; leaving its exact worktree attached." + } + if ($releaseSourceDirty.Count -gt 0) { + if (-not $script:releaseSourceFailureRecorded) { + $sourceFailureParent = Join-Path $repoRoot 'output/private/package-source-failures' + New-Item -ItemType Directory -Force -Path $sourceFailureParent | Out-Null + $sourceFailureRoot = Join-Path $sourceFailureParent ( + (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + "-$PID-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $sourceFailureRoot | Out-Null + $releaseSourceDirty | Set-Content -LiteralPath (Join-Path $sourceFailureRoot 'STATUS.txt') -Encoding UTF8 + Invoke-ReleaseGit -Arguments @( + '-C', [string]$script:releaseSourceRoot, 'diff', '--binary', '--no-ext-diff') | + Set-Content -LiteralPath (Join-Path $sourceFailureRoot 'TRACKED_CHANGES.patch') -Encoding UTF8 + [ordered]@{ + schema = 'stackchan.package-source-failure.v2' + status = 'commit-bound-source-drift-full-worktree-preserved' + preservationPolicy = 'full-failed-worktree-retained-attached' + sourceRoot = [string]$script:releaseSourceRoot + sourceCommit = if ($null -eq $canonicalBuildCommit) { $null } else { $canonicalBuildCommit } + capturedUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + } | ConvertTo-Json -Depth 3 | Set-Content ` + -LiteralPath (Join-Path $sourceFailureRoot 'FAILURE_EVIDENCE.json') -Encoding UTF8 + $script:releaseSourceFailureRecorded = $true + Write-Warning ( + "Commit-bound release source drift was detected. Its complete worktree remains attached at " + + [string]$script:releaseSourceRoot + "; evidence: " + $sourceFailureRoot) + } + throw "Commit-bound release source drift is a package failure; the full worktree remains attached." + } + Invoke-ReleaseGit -Arguments @( + '-C', [string]$repoRoot, 'worktree', 'remove', [string]$script:releaseSourceRoot) + if ($LASTEXITCODE -ne 0) { + throw "Could not remove the commit-bound release source worktree: $($script:releaseSourceRoot)" + } + $script:releaseSourceWorktreeAdded = $false + $script:releaseSourceRoot = $null +} + +$script:releaseSourceCleanupReady = $true +trap { + $packageFailure = $_ + if ($script:releaseSourceCleanupReady) { + try { + Remove-ReleaseSourceWorktree + } catch { + Write-Warning "Could not clean the exact release source worktree; it remains preserved for inspection." + } + } + throw $packageFailure +} + +if (-not $SkipBuild) { + # The three profiles span two PlatformIO core roots. Each proof cycle uses a + # distinct, short detached worktree, and the compiler hook maps both paths to + # one canonical debug/source prefix before bytes are compared. + $firmwareBuildCacheRoot = Join-Path $repoRoot ( + "output/release/.firmware-build-cache-$PID-" + [guid]::NewGuid().ToString("N")) + $cycleARoot = Join-Path $firmwareBuildCacheRoot "cycle-a" + $cycleBRoot = Join-Path $firmwareBuildCacheRoot "cycle-b" + $firmwareDependencySnapshotRoot = Join-Path $firmwareBuildCacheRoot "cycle-b-dependencies" + $cycleASourceRoot = New-ShortReleaseScratchPath -Label 'fw-a' + $cycleBSourceRoot = New-ShortReleaseScratchPath -Label 'firmware-b' + if ($cycleASourceRoot -ceq $cycleBSourceRoot -or + $cycleASourceRoot.Length -eq $cycleBSourceRoot.Length) { + throw "Firmware reproducibility proof requires distinct source roots with different path lengths." + } + $activeBuildSourceRoot = $null + $activeBuildWorktreeAdded = $false + $script:firmwareIdentityAttestations = @() + $canonicalBuildIdentity = Get-CanonicalReleaseGitIdentity -ProjectRoot $repoRoot + $canonicalBuildCommit = $canonicalBuildIdentity.commit + $canonicalBuildEpoch = $canonicalBuildIdentity.epoch + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -Phase "reproducibility proof source capture" ` + -ProjectRoot $repoRoot + try { + $activeBuildSourceRoot = $cycleASourceRoot + Invoke-ReleaseGit -Arguments @( + '-C', $repoRoot, 'worktree', 'add', '--detach', $activeBuildSourceRoot, $canonicalBuildCommit) + if ($LASTEXITCODE -ne 0) { throw "Could not create the cycle-a detached firmware worktree." } + $activeBuildWorktreeAdded = $true + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -Phase "cycle-a detached source creation" ` + -ProjectRoot $activeBuildSourceRoot + $cycleAStarted = Get-UtcWholeSecond + Invoke-FirmwareBuildCycle ` + -CycleRoot $cycleARoot ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -CycleName 'cycle-a' ` + -BuildProjectRoot $activeBuildSourceRoot + $cycleAArtifacts = @(Get-FirmwareBuildArtifactRecords -CycleRoot $cycleARoot) + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -Phase "reproducibility proof post-cycle-a" ` + -ProjectRoot $activeBuildSourceRoot + Invoke-ReleaseGit -Arguments @( + '-C', $repoRoot, 'worktree', 'remove', '--force', $activeBuildSourceRoot) + if ($LASTEXITCODE -ne 0) { throw "Could not remove the cycle-a detached firmware worktree." } + $activeBuildWorktreeAdded = $false + $activeBuildSourceRoot = $null + + $cycleBNotBefore = $cycleAStarted.AddSeconds(65) + while ((Get-Date).ToUniversalTime() -lt $cycleBNotBefore) { + $remaining = [int][Math]::Ceiling(($cycleBNotBefore - (Get-Date).ToUniversalTime()).TotalSeconds) + Start-Sleep -Seconds ([Math]::Min(10, [Math]::Max(1, $remaining))) + } + $cycleBStarted = Get-UtcWholeSecond + $activeBuildSourceRoot = $cycleBSourceRoot + Invoke-ReleaseGit -Arguments @( + '-C', $repoRoot, 'worktree', 'add', '--detach', $activeBuildSourceRoot, $canonicalBuildCommit) + if ($LASTEXITCODE -ne 0) { throw "Could not create the cycle-b detached firmware worktree." } + $activeBuildWorktreeAdded = $true + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -Phase "cycle-b detached source creation" ` + -ProjectRoot $activeBuildSourceRoot + Invoke-FirmwareBuildCycle ` + -CycleRoot $cycleBRoot ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -CycleName 'cycle-b' ` + -BuildProjectRoot $activeBuildSourceRoot ` + -DependencySnapshotRoot $firmwareDependencySnapshotRoot + $cycleBArtifacts = @(Get-FirmwareBuildArtifactRecords -CycleRoot $cycleBRoot) + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -Phase "reproducibility proof post-cycle-b" ` + -ProjectRoot $activeBuildSourceRoot + + if ($cycleAArtifacts.Count -ne 12 -or $cycleBArtifacts.Count -ne 12) { + throw "Firmware reproducibility proof did not produce all 12 artifacts per cycle." + } + for ($artifactIndex = 0; $artifactIndex -lt $cycleAArtifacts.Count; $artifactIndex++) { + $left = $cycleAArtifacts[$artifactIndex] + $right = $cycleBArtifacts[$artifactIndex] + if ($left.environment -cne $right.environment -or + $left.artifact -cne $right.artifact -or + [long]$left.bytes -ne [long]$right.bytes -or + $left.sha256 -cne $right.sha256) { + throw "Firmware reproducibility mismatch: $($left.environment)/$($left.artifact)" + } + } + $clockBoundarySeconds = [int][Math]::Floor(($cycleBStarted - $cycleAStarted).TotalSeconds) + if ($clockBoundarySeconds -lt 65) { + throw "Firmware reproducibility cycles did not cross the required 65-second clock boundary." + } + $firmwareReproducibilityProof = [ordered]@{ + status = "verified-two-clean-cycles" + minimumClockBoundarySeconds = 65 + clockBoundarySeconds = $clockBoundarySeconds + cycleAStartedUtc = $cycleAStarted.ToString("yyyy-MM-ddTHH:mm:ssZ") + cycleBStartedUtc = $cycleBStarted.ToString("yyyy-MM-ddTHH:mm:ssZ") + cycleASourceCommit = $canonicalBuildCommit + cycleASourceEpoch = $canonicalBuildEpoch + cycleBSourceCommit = $canonicalBuildCommit + cycleBSourceEpoch = $canonicalBuildEpoch + buildCachePolicy = "isolated-empty-per-cycle-environment" + sourceIsolationPolicy = "distinct-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths" + identityAttestations = @($script:firmwareIdentityAttestations) + cycleAArtifacts = @($cycleAArtifacts) + cycleBArtifacts = @($cycleBArtifacts) + } + Invoke-ReleaseGit -Arguments @( + '-C', $repoRoot, 'worktree', 'remove', '--force', $activeBuildSourceRoot) + if ($LASTEXITCODE -ne 0) { throw "Could not remove the cycle-b detached firmware worktree." } + $activeBuildWorktreeAdded = $false + $activeBuildSourceRoot = $null + } catch { + $buildFailure = $_ + $failureParent = Join-Path $repoRoot "output/private/reproducibility-failures" + New-Item -ItemType Directory -Force -Path $failureParent | Out-Null + $failureName = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmss") + "-$PID-" + [guid]::NewGuid().ToString("N") + $failureRoot = Join-Path $failureParent $failureName + try { + $failureEvidence = Save-StackchanFirmwareReproducibilityFailureEvidence ` + -FailureRoot $failureRoot ` + -BuildCacheRoot ([string]$firmwareBuildCacheRoot) ` + -ActiveSourceRoot ([string]$activeBuildSourceRoot) ` + -WorktreeStillAttached ([bool]$activeBuildWorktreeAdded) ` + -Failure $buildFailure ` + -SourceCommit $canonicalBuildCommit ` + -SourceEpoch $canonicalBuildEpoch + $firmwareBuildCacheRoot = $null + Write-Warning ( + "Firmware reproducibility failed. The complete detached worktree remains attached at " + + [string]$activeBuildSourceRoot + "; evidence: " + $failureRoot) + } catch { + Write-Warning "Could not fully copy failed build evidence; leaving the exact failed worktree attached." + } + throw $buildFailure.Exception } + $builtFirmwareCache = $cycleBRoot + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -Phase "reproducibility proof before packaging" ` + -ProjectRoot $repoRoot + + $releaseSourceRoot = New-ShortReleaseScratchPath -Label 'release-src' + Invoke-ReleaseGit -Arguments @( + '-C', $repoRoot, 'worktree', 'add', '--detach', $releaseSourceRoot, $canonicalBuildCommit) + if ($LASTEXITCODE -ne 0) { throw "Could not create the commit-bound release source worktree." } + $releaseSourceWorktreeAdded = $true + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -Phase "commit-bound package source creation" ` + -ProjectRoot $releaseSourceRoot ` + -RejectIgnored + Push-Location $releaseSourceRoot + $releaseSourceLocationPushed = $true $previewPython = Get-StackchanPreviewPython & $previewPython tools/render_preview.py if ($LASTEXITCODE -ne 0) { throw "Preview media generation failed with exit code $LASTEXITCODE" } + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -Phase "commit-bound preview generation" ` + -ProjectRoot $releaseSourceRoot ` + -RejectIgnored } -$dirtyFiles = @(git status --porcelain) +$dirtyFiles = @(Invoke-ReleaseGit -Arguments @( + "-C", $repoRoot, "status", "--porcelain")) $generatedMediaDirtyFiles = @( $dirtyFiles | Where-Object { $_ -match "^\s*(M|\?\?) docs/media/stackchan_alive_(preview\.(gif|mp4|png)|speech_preview\.gif|expression_sheet\.png)$" } ) @@ -183,10 +1178,34 @@ if ($sourceDirtyFiles.Count -gt 0 -and -not $AllowDirty) { throw "Refusing to package a dirty source worktree. Commit or discard changes first, or pass -AllowDirty for local diagnostic packages. Dirty files:$([Environment]::NewLine)$dirtyList" } -$commit = (git rev-parse HEAD).Trim() -$shortCommit = (git rev-parse --short HEAD).Trim() -$outDir = Join-Path $repoRoot "output/release/$Version" -$zipPath = Join-Path $repoRoot "output/release/stackchan_alive_$Version.zip" +$observedPackageIdentity = Get-CanonicalReleaseGitIdentity -ProjectRoot $repoRoot +$commit = $observedPackageIdentity.commit +$shortCommit = (Invoke-ReleaseGit -Arguments @( + "-C", $repoRoot, "rev-parse", "--short", "HEAD")).Trim() +$commitEpoch = $observedPackageIdentity.epoch +if (-not $SkipBuild -and + ($commit -cne $canonicalBuildCommit -or $commitEpoch -cne $canonicalBuildEpoch)) { + throw "Release source identity changed between reproducibility proof and packaging." +} +$releaseOutputRootPath = [System.IO.Path]::GetFullPath([string]$releaseOutputRoot).TrimEnd('\', '/') +$releaseOutputRootPrefix = $releaseOutputRootPath + [System.IO.Path]::DirectorySeparatorChar +function Join-ContainedReleaseOutputPath { + param([Parameter(Mandatory = $true)][string]$Leaf) + + if ([System.IO.Path]::IsPathRooted($Leaf) -or + $Leaf.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0 -or + $Leaf.Contains('/') -or $Leaf.Contains('\')) { + throw "Refusing unsafe release output leaf: $Leaf" + } + $candidate = [System.IO.Path]::GetFullPath((Join-Path $releaseOutputRootPath $Leaf)) + if (-not $candidate.StartsWith($releaseOutputRootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing release output path outside the governed root: $Leaf" + } + return $candidate +} + +$outDir = Join-ContainedReleaseOutputPath -Leaf $Version +$zipPath = Join-ContainedReleaseOutputPath -Leaf "stackchan_alive_$Version.zip" $zipSidecarPath = "$zipPath.sha256" if (Test-Path -LiteralPath $outDir) { @@ -294,10 +1313,6 @@ $firmwareSourceRoot = if ($builtFirmwareCache) { Copy-FirmwareSet -BuildDir (Join-Path $firmwareSourceRoot "stackchan") -Destination $displayFirmwareDir Copy-FirmwareSet -BuildDir (Join-Path $firmwareSourceRoot "stackchan_servo_calibration") -Destination $servoFirmwareDir Copy-FirmwareSet -BuildDir (Join-Path $firmwareSourceRoot "stackchan_release_full") -Destination $fullOnlineFirmwareDir -if ($builtFirmwareCache -and (Test-Path -LiteralPath $builtFirmwareCache)) { - Remove-Item -LiteralPath $builtFirmwareCache -Recurse -Force - $builtFirmwareCache = $null -} $mediaFiles = @( "docs/media/stackchan_alive_preview.png", @@ -318,11 +1333,10 @@ $diagramFiles = @( "docs/media/diagrams/08-io-abstraction-builds.png" ) -$windowsPowerShell = Join-Path $env:SystemRoot "System32/WindowsPowerShell/v1.0/powershell.exe" -if (-not (Test-Path -LiteralPath $windowsPowerShell)) { - $windowsPowerShell = "powershell.exe" -} -& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "render_voice_samples.ps1") +$windowsPowerShell = $releasePowerShellExecutable +$releaseToolsRoot = if ($SkipBuild) { $PSScriptRoot } else { Join-Path $releaseSourceRoot 'tools' } +$packageTrackedSourceRoot = if ($SkipBuild) { [string]$repoRoot } else { [string]$releaseSourceRoot } +& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $releaseToolsRoot "render_voice_samples.ps1") if ($LASTEXITCODE -ne 0) { throw "Voice sample rendering failed." } @@ -377,23 +1391,25 @@ if ($LASTEXITCODE -ne 0) { $personaPromptAssets = Get-Content -LiteralPath $personaPromptAssetsPath -Raw | ConvertFrom-Json foreach ($asset in @($personaPromptAssets.assets)) { - $sourcePath = Join-Path $repoRoot ([string]$asset.source_path) $packagedSourcePath = Join-ReleasePackagePath ([string]$asset.source_path) $promptWavPath = Join-ReleasePackagePath ([string]$asset.wav_path) $promptSidecarPath = Join-ReleasePackagePath ([string]$asset.sidecar_path) - if (-not (Test-Path -LiteralPath $sourcePath)) { - throw "Missing persona packaged prompt source: $sourcePath" - } New-Item -ItemType Directory -Force -Path (Split-Path -Parent $packagedSourcePath), (Split-Path -Parent $promptWavPath), (Split-Path -Parent $promptSidecarPath) | Out-Null - Copy-Item -LiteralPath $sourcePath -Destination $packagedSourcePath -Force - Copy-Item -LiteralPath $sourcePath -Destination $promptWavPath -Force - & $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "generate_speech_envelope_sidecar.ps1") ` + Copy-StackchanCommitBoundPackageFile ` + -PackageSourceRoot $packageTrackedSourceRoot ` + -RelativePath ([string]$asset.source_path) ` + -DestinationPath $packagedSourcePath + Copy-StackchanCommitBoundPackageFile ` + -PackageSourceRoot $packageTrackedSourceRoot ` + -RelativePath ([string]$asset.source_path) ` + -DestinationPath $promptWavPath + & $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $releaseToolsRoot "generate_speech_envelope_sidecar.ps1") ` -InputWav $promptWavPath ` -OutputJson $promptSidecarPath if ($LASTEXITCODE -ne 0) { throw "Packaged prompt sidecar generation failed for $($asset.wav_path)." } - & $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "verify_speech_envelope_sidecar.ps1") ` + & $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $releaseToolsRoot "verify_speech_envelope_sidecar.ps1") ` -Path $promptSidecarPath if ($LASTEXITCODE -ne 0) { throw "Packaged prompt sidecar verification failed for $($asset.sidecar_path)." @@ -416,7 +1432,7 @@ foreach ($file in $voiceMediaFiles) { Copy-Item -LiteralPath $file -Destination $voiceMediaDir } -& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "verify_tracked_rvc_assets.ps1") ` +& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $releaseToolsRoot "verify_tracked_rvc_assets.ps1") ` -VoiceRoot "media/voice/rvc" if ($LASTEXITCODE -ne 0) { throw "Tracked RVC audition asset verification failed." @@ -489,6 +1505,30 @@ Copy-Item -LiteralPath "docs/PRIVACY.md" -Destination $docsDir Copy-Item -LiteralPath "docs/PRODUCTION_READINESS.md" -Destination $docsDir Copy-Item -LiteralPath "docs/ARRIVAL_DAY_RUNBOOK.md" -Destination (Join-Path $outDir "ARRIVAL_DAY_RUNBOOK.md") Copy-Item -LiteralPath "docs/RELEASE_QUICKSTART.md" -Destination (Join-Path $outDir "QUICKSTART.md") +if ($SkipBuild) { + $diagnosticBanner = @" +> [!CAUTION] +> DIAGNOSTIC-ONLY UNQUALIFIED PACKAGE. RELEASE AND HARDWARE USE ARE FORBIDDEN. +> This package used ``-SkipBuild -AllowDirty``; firmware provenance and reproducibility are not +> proven. Do not flash it, run its hardware procedures, publish it, or use it as evidence. +> Any release or hardware language later in this copied document describes the governed workflow, +> not this diagnostic archive. + +"@ + foreach ($diagnosticBannerFile in @("README.md", "QUICKSTART.md", "ARRIVAL_DAY_RUNBOOK.md", "docs/README.md")) { + $diagnosticBannerPath = Join-Path $outDir $diagnosticBannerFile + $diagnosticBanner + [System.IO.File]::ReadAllText($diagnosticBannerPath) | + Set-Content -LiteralPath $diagnosticBannerPath -Encoding UTF8 + } + @" +DIAGNOSTIC-ONLY UNQUALIFIED PACKAGE +RELEASE AND HARDWARE USE ARE FORBIDDEN + +This package used -SkipBuild -AllowDirty. Firmware provenance and reproducibility were not +proven. Do not flash it, run hardware procedures from it, publish it, or use it as release or +hardware evidence. Create a governed two-cycle package from a clean immutable commit instead. +"@ | Set-Content -LiteralPath (Join-Path $outDir "DIAGNOSTIC_PACKAGE_DO_NOT_FLASH.txt") -Encoding UTF8 +} Copy-Item -LiteralPath "docs/RELEASE_PROCESS.md" -Destination $docsDir Copy-Item -LiteralPath "docs/ROLLOUT_CHECKLIST.md" -Destination $docsDir Copy-Item -LiteralPath "docs/VOICE_PERSONALITY.md" -Destination $docsDir @@ -652,7 +1692,7 @@ if ($LASTEXITCODE -ne 0) { throw "Character red-team dry run failed." } -& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "export_voice_source_status.ps1") ` +& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $releaseToolsRoot "export_voice_source_status.ps1") ` -VoiceSourceProvenancePath (Join-Path $dataDir "voice_source_provenance.yaml") ` -VoiceSourceProvenanceDisplayPath "data/voice_source_provenance.yaml" ` -TemplatePath (Join-Path $docsDir "VOICE_SOURCE_PROVENANCE_TEMPLATE.md") ` @@ -662,7 +1702,7 @@ if ($LASTEXITCODE -ne 0) { throw "Voice source status export failed." } -& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "export_rvc_voice_base_status.ps1") ` +& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $releaseToolsRoot "export_rvc_voice_base_status.ps1") ` -ManifestPath (Join-Path $dataDir "voice_rvc_base.yaml") ` -MetadataPath (Join-Path $dataDir "voice_rvc_base_metadata.json") ` -OutputDir $outDir @@ -691,6 +1731,18 @@ foreach ($file in $companionEvidenceFiles) { } $releaseTools = @( + "tools/package_release.ps1", + "tools/firmware_reproducibility_proof.ps1", + "tools/test_firmware_reproducibility_proof_contract.ps1", + "tools/firmware_reproducibility_failure.ps1", + "tools/test_firmware_reproducibility_failure_contract.ps1", + "tools/release_source_binding.ps1", + "tools/release_dependency_evidence.ps1", + "tools/test_release_dependency_evidence_contract.ps1", + "tools/release_git_trust.ps1", + "tools/test_release_package_verifier_trust_contract.ps1", + "tools/test_release_source_binding_contract.ps1", + "tools/release_zip_safety.ps1", "tools/flash_device.cmd", "tools/flash_device.ps1", "tools/flash_release_firmware.cmd", @@ -834,6 +1886,8 @@ $releaseTools = @( "tools/generate_speech_envelope_sidecar.cmd", "tools/generate_speech_envelope_sidecar.ps1", "tools/generate_speech_envelope_sidecar.py", + "tools/platformio_reproducible_build.py", + "tools/test_firmware_reproducible_build_contract.ps1", "tools/platformio_generate_persona_assets.py", "tools/platformio_generate_voice_assets.py", "tools/verify_speech_envelope_sidecar.cmd", @@ -1018,28 +2072,28 @@ $releaseTools = @( "tools/test_android_wifi_evidence_contract.cmd", "tools/test_android_wifi_evidence_contract.ps1", "tools/check_voice_source_readiness.ps1", - "tools/test_voice_source_readiness_contract.ps1" + "tools/test_voice_source_readiness_contract.ps1", + "tools/searxng/compose.yaml", + "tools/searxng/settings.yml" ) foreach ($file in $releaseTools) { if (-not (Test-Path -LiteralPath $file)) { throw "Missing release tool: $file" } - Copy-Item -LiteralPath $file -Destination $toolsDir -} - -$searxngToolsDir = Join-Path $toolsDir "searxng" -New-Item -ItemType Directory -Force -Path $searxngToolsDir | Out-Null -foreach ($file in @("tools/searxng/compose.yaml", "tools/searxng/settings.yml")) { - if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { - throw "Missing local research configuration: $file" + $relativeToolPath = $file.Substring("tools/".Length).Replace('/', [System.IO.Path]::DirectorySeparatorChar) + $toolDestination = Join-Path $toolsDir $relativeToolPath + $toolDestinationParent = Split-Path -Parent $toolDestination + if (-not (Test-Path -LiteralPath $toolDestinationParent -PathType Container)) { + New-Item -ItemType Directory -Force -Path $toolDestinationParent | Out-Null } - Copy-Item -LiteralPath $file -Destination $searxngToolsDir + Copy-Item -LiteralPath $file -Destination $toolDestination } Copy-Item -LiteralPath "platformio.ini" -Destination $provenanceDir Copy-Item -LiteralPath "partitions_esp_sr_16.csv" -Destination $provenanceDir Copy-Item -LiteralPath "requirements-preview.txt" -Destination $provenanceDir +Copy-Item -LiteralPath "requirements-firmware-release.txt" -Destination $provenanceDir Copy-Item -LiteralPath ".github/workflows/firmware.yml" -Destination $provenanceDir Copy-Item -LiteralPath ".github/workflows/release.yml" -Destination $provenanceDir Copy-Item -LiteralPath ".github/workflows/pages.yml" -Destination $provenanceDir @@ -1111,32 +2165,6 @@ function Get-DeclaredLibDeps { return @($libDeps) } -function Convert-PioPackageList { - param([string]$Text) - - $entries = @() - foreach ($line in ($Text -split "`r?`n")) { - $clean = ($line -replace "^[^A-Za-z0-9]+", "").Trim() - if ($clean -match "^Platform\s+(.+?)\s+@\s+([^\s]+)\s+\(required:\s*(.+)\)$") { - $entries += [ordered]@{ - kind = "platform" - name = $Matches[1] - version = $Matches[2] - required = $Matches[3] - } - } elseif ($clean -match "^(.+?)\s+@\s+([^\s]+)\s+\(required:\s*(.+)\)$") { - $entries += [ordered]@{ - kind = "package" - name = $Matches[1] - version = $Matches[2] - required = $Matches[3] - } - } - } - - return @($entries) -} - function Copy-LicenseEvidenceTree { param( [string]$SourceRoot, @@ -1165,24 +2193,47 @@ function Copy-LicenseEvidenceTree { function Copy-EnvironmentLicenseEvidence { param( [string]$Environment, - [object[]]$ResolvedPackages + [object[]]$ResolvedPackages, + [string]$PlatformSourceLeaf, + [string]$DependencySnapshotRoot ) $destination = Join-Path $thirdPartyLicensesDir $Environment New-Item -ItemType Directory -Force -Path $destination | Out-Null + if (-not [string]::IsNullOrWhiteSpace($DependencySnapshotRoot)) { + $exactEnvironmentRoot = Join-Path $DependencySnapshotRoot $Environment + if (-not (Test-Path -LiteralPath $exactEnvironmentRoot -PathType Container)) { + throw "Missing exact cycle-b dependency snapshot for $Environment" + } + Get-ChildItem -LiteralPath $exactEnvironmentRoot -Force | Where-Object { + $_.Name -notin @( + 'pkg-list.txt', 'pkg-list-verbose.txt', 'platformio-version.txt', + 'core-package-names.json', 'platform-source.json') + } | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $destination -Recurse -Force + } + return @( + Get-ChildItem -LiteralPath $destination -Recurse -File -Force | + Where-Object { $_.Name -match '(?i)^(LICENSE|LICENCE|COPYING|NOTICE)(\..*)?$' } + ).Count + } + $count = Copy-LicenseEvidenceTree ` -SourceRoot (Join-Path $repoRoot ".pio/libdeps/$Environment") ` -DestinationRoot (Join-Path $destination "libdeps") $coreDir = Get-ReleasePlatformioCoreDir -Environment $Environment - $platformRoot = Join-Path $coreDir "platforms/espressif32" + if ($PlatformSourceLeaf -notmatch '^[A-Za-z0-9][A-Za-z0-9._@-]*$') { + throw "Unsafe PlatformIO platform source leaf for $Environment`: $PlatformSourceLeaf" + } + $platformRoot = Join-Path $coreDir "platforms/$PlatformSourceLeaf" $count += Copy-LicenseEvidenceTree ` -SourceRoot $platformRoot ` - -DestinationRoot (Join-Path $destination "platform/espressif32") + -DestinationRoot (Join-Path $destination "platform/$PlatformSourceLeaf") foreach ($metadataName in @("platform.json", "package.json")) { $metadataPath = Join-Path $platformRoot $metadataName if (Test-Path -LiteralPath $metadataPath -PathType Leaf) { - $metadataDestination = Join-Path $destination "platform/espressif32/$metadataName" + $metadataDestination = Join-Path $destination "platform/$PlatformSourceLeaf/$metadataName" New-Item -ItemType Directory -Force -Path (Split-Path -Parent $metadataDestination) | Out-Null Copy-Item -LiteralPath $metadataPath -Destination $metadataDestination -Force } @@ -1299,30 +2350,100 @@ function Get-DependencyAudit { } } -$platformioVersion = Invoke-CapturedText { - Invoke-StackchanReleasePlatformio -Environment "stackchan" -Arguments @("--version") +$platformioVersion = if ($SkipBuild) { + Invoke-CapturedText { + Invoke-StackchanReleasePlatformio -Environment "stackchan" -Arguments @("--version") + } +} else { + (Get-Content -LiteralPath (Join-Path $firmwareDependencySnapshotRoot 'stackchan/platformio-version.txt') -Raw).Trim() +} +$displayDeps = if ($SkipBuild) { + Invoke-CapturedText { + Invoke-StackchanReleasePlatformio -Environment "stackchan" -Arguments @("pkg", "list", "-e", "stackchan") + } +} else { + Get-Content -LiteralPath (Join-Path $firmwareDependencySnapshotRoot 'stackchan/pkg-list.txt') -Raw +} +$displayResolvedPackages = Convert-StackchanPioPackageList $displayDeps +$displayPlatformSourceLeaf = if ($SkipBuild) { + $verbose = Invoke-CapturedText { + Invoke-StackchanReleasePlatformio -Environment 'stackchan' ` + -Arguments @('pkg', 'list', '-e', 'stackchan', '-v') + } + (Get-StackchanVerbosePlatformSource -VerbosePackageList $verbose ` + -PlatformioCoreDir (Get-ReleasePlatformioCoreDir -Environment 'stackchan')).sourceLeaf +} else { + [string](Get-Content -LiteralPath (Join-Path $firmwareDependencySnapshotRoot 'stackchan/platform-source.json') -Raw | ConvertFrom-Json).sourceLeaf } -$displayDeps = Invoke-CapturedText { - Invoke-StackchanReleasePlatformio -Environment "stackchan" -Arguments @("pkg", "list", "-e", "stackchan") +$displayCorePackageNames = if ($SkipBuild) { + @(Get-StackchanResolvedCorePackageNames -ResolvedPackages $displayResolvedPackages ` + -CorePackagesRoot (Join-Path (Get-ReleasePlatformioCoreDir -Environment 'stackchan') 'packages')) +} else { + @(Get-Content -LiteralPath (Join-Path $firmwareDependencySnapshotRoot 'stackchan/core-package-names.json') -Raw | ConvertFrom-Json) } -$displayResolvedPackages = Convert-PioPackageList $displayDeps $displayLicenseCount = Copy-EnvironmentLicenseEvidence ` -Environment "stackchan" ` - -ResolvedPackages $displayResolvedPackages -$servoDeps = Invoke-CapturedText { - Invoke-StackchanReleasePlatformio -Environment "stackchan_servo_calibration" -Arguments @("pkg", "list", "-e", "stackchan_servo_calibration") + -ResolvedPackages $displayResolvedPackages ` + -PlatformSourceLeaf $displayPlatformSourceLeaf ` + -DependencySnapshotRoot $firmwareDependencySnapshotRoot +$servoDeps = if ($SkipBuild) { + Invoke-CapturedText { + Invoke-StackchanReleasePlatformio -Environment "stackchan_servo_calibration" -Arguments @("pkg", "list", "-e", "stackchan_servo_calibration") + } +} else { + Get-Content -LiteralPath (Join-Path $firmwareDependencySnapshotRoot 'stackchan_servo_calibration/pkg-list.txt') -Raw +} +$servoResolvedPackages = Convert-StackchanPioPackageList $servoDeps +$servoPlatformSourceLeaf = if ($SkipBuild) { + $verbose = Invoke-CapturedText { + Invoke-StackchanReleasePlatformio -Environment 'stackchan_servo_calibration' ` + -Arguments @('pkg', 'list', '-e', 'stackchan_servo_calibration', '-v') + } + (Get-StackchanVerbosePlatformSource -VerbosePackageList $verbose ` + -PlatformioCoreDir (Get-ReleasePlatformioCoreDir -Environment 'stackchan_servo_calibration')).sourceLeaf +} else { + [string](Get-Content -LiteralPath (Join-Path $firmwareDependencySnapshotRoot 'stackchan_servo_calibration/platform-source.json') -Raw | ConvertFrom-Json).sourceLeaf +} +$servoCorePackageNames = if ($SkipBuild) { + @(Get-StackchanResolvedCorePackageNames -ResolvedPackages $servoResolvedPackages ` + -CorePackagesRoot (Join-Path (Get-ReleasePlatformioCoreDir -Environment 'stackchan_servo_calibration') 'packages')) +} else { + @(Get-Content -LiteralPath (Join-Path $firmwareDependencySnapshotRoot 'stackchan_servo_calibration/core-package-names.json') -Raw | ConvertFrom-Json) } -$servoResolvedPackages = Convert-PioPackageList $servoDeps $servoLicenseCount = Copy-EnvironmentLicenseEvidence ` -Environment "stackchan_servo_calibration" ` - -ResolvedPackages $servoResolvedPackages -$fullDeps = Invoke-CapturedText { - Invoke-StackchanReleasePlatformio -Environment "stackchan_release_full" -Arguments @("pkg", "list", "-e", "stackchan_release_full") + -ResolvedPackages $servoResolvedPackages ` + -PlatformSourceLeaf $servoPlatformSourceLeaf ` + -DependencySnapshotRoot $firmwareDependencySnapshotRoot +$fullDeps = if ($SkipBuild) { + Invoke-CapturedText { + Invoke-StackchanReleasePlatformio -Environment "stackchan_release_full" -Arguments @("pkg", "list", "-e", "stackchan_release_full") + } +} else { + Get-Content -LiteralPath (Join-Path $firmwareDependencySnapshotRoot 'stackchan_release_full/pkg-list.txt') -Raw +} +$fullResolvedPackages = Convert-StackchanPioPackageList $fullDeps +$fullPlatformSourceLeaf = if ($SkipBuild) { + $verbose = Invoke-CapturedText { + Invoke-StackchanReleasePlatformio -Environment 'stackchan_release_full' ` + -Arguments @('pkg', 'list', '-e', 'stackchan_release_full', '-v') + } + (Get-StackchanVerbosePlatformSource -VerbosePackageList $verbose ` + -PlatformioCoreDir (Get-ReleasePlatformioCoreDir -Environment 'stackchan_release_full')).sourceLeaf +} else { + [string](Get-Content -LiteralPath (Join-Path $firmwareDependencySnapshotRoot 'stackchan_release_full/platform-source.json') -Raw | ConvertFrom-Json).sourceLeaf +} +$fullCorePackageNames = if ($SkipBuild) { + @(Get-StackchanResolvedCorePackageNames -ResolvedPackages $fullResolvedPackages ` + -CorePackagesRoot (Join-Path (Get-ReleasePlatformioCoreDir -Environment 'stackchan_release_full') 'packages')) +} else { + @(Get-Content -LiteralPath (Join-Path $firmwareDependencySnapshotRoot 'stackchan_release_full/core-package-names.json') -Raw | ConvertFrom-Json) } -$fullResolvedPackages = Convert-PioPackageList $fullDeps $fullLicenseCount = Copy-EnvironmentLicenseEvidence ` -Environment "stackchan_release_full" ` - -ResolvedPackages $fullResolvedPackages + -ResolvedPackages $fullResolvedPackages ` + -PlatformSourceLeaf $fullPlatformSourceLeaf ` + -DependencySnapshotRoot $firmwareDependencySnapshotRoot $visionLicenseDir = Join-Path $thirdPartyLicensesDir "models/opencv-zoo-yunet" New-Item -ItemType Directory -Force -Path $visionLicenseDir | Out-Null Copy-Item -LiteralPath "bridge/models/LICENSE" -Destination (Join-Path $visionLicenseDir "LICENSE") -Force @@ -1384,7 +2505,11 @@ Version: $Version Commit: $commit Generated UTC: $((Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")) -This report records the dependency state used to generate this prerelease package. The source configuration files are copied under ``provenance/``. +$(if ($SkipBuild) { + 'This diagnostic report inventories the current local dependency state only. It is unbound to the copied firmware and cannot authorize release or hardware use.' +} else { + 'This report records the exact cycle-B dependency inventory and license metadata captured from the build worktree that generated the proved firmware. The commit-bound source configuration is copied under ``provenance/``.' +}) ## Tooling @@ -1436,6 +2561,9 @@ $dependencyLock = [ordered]@{ version = $Version commit = $commit generatedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + dependencyEvidencePolicy = if ($SkipBuild) { "diagnostic-current-state-unbound" } else { "exact-cycle-b-build-snapshot" } + dependencySourceCommit = if ($SkipBuild) { $null } else { $canonicalBuildCommit } + dependencySourceEpoch = if ($SkipBuild) { $null } else { $canonicalBuildEpoch } platformioCore = $platformioVersion previewRequirements = @($previewRequirementEntries) declaredLibDeps = @($declaredLibDeps) @@ -1444,35 +2572,128 @@ $dependencyLock = [ordered]@{ board = "m5stack-cores3" framework = "arduino" platform = "espressif32@7.0.1" + platformSourceLeaf = $displayPlatformSourceLeaf resolvedPackages = @($displayResolvedPackages) + corePackageNames = @($displayCorePackageNames) } stackchan_servo_calibration = [ordered]@{ board = "m5stack-cores3" framework = "arduino" platform = "espressif32@7.0.1" + platformSourceLeaf = $servoPlatformSourceLeaf resolvedPackages = @($servoResolvedPackages) + corePackageNames = @($servoCorePackageNames) } stackchan_release_full = [ordered]@{ board = "m5stack-cores3" framework = "arduino" platform = "pioarduino/platform-espressif32@55.03.36" + platformSourceLeaf = $fullPlatformSourceLeaf resolvedPackages = @($fullResolvedPackages) + corePackageNames = @($fullCorePackageNames) } } dependencyAudit = $dependencyAudit } $dependencyLock | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $outDir "dependency_lock.json") -Encoding UTF8 +function ConvertTo-CanonicalPackageInventory { + param( + [Parameter(Mandatory = $true)][string[]]$PackagePaths, + [Parameter(Mandatory = $true)][ValidateSet("tools", "provenance")][string]$RequiredPrefix + ) + + $canonicalPaths = [System.Collections.Generic.List[string]]::new() + $caseInsensitivePaths = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + foreach ($packagePath in $PackagePaths) { + $normalized = ([string]$packagePath).Replace('\', '/') + $segments = @($normalized.Split('/')) + if ([string]::IsNullOrWhiteSpace($normalized) -or + [System.IO.Path]::IsPathRooted($normalized) -or + $normalized.StartsWith('/') -or + $segments.Count -lt 2 -or + $segments[0] -cne $RequiredPrefix -or + @($segments | Where-Object { [string]::IsNullOrWhiteSpace($_) -or $_ -in @('.', '..') }).Count -ne 0) { + throw "Package inventory contains an invalid $RequiredPrefix path: $packagePath" + } + if (-not $caseInsensitivePaths.Add($normalized)) { + throw "Package inventory contains a duplicate or case-colliding path: $normalized" + } + $canonicalPaths.Add($normalized) + } + + $result = [string[]]$canonicalPaths.ToArray() + [Array]::Sort($result, [System.StringComparer]::Ordinal) + return @($result) +} + +function Get-CanonicalPackageFileInventory { + param( + [Parameter(Mandatory = $true)][string]$Directory, + [Parameter(Mandatory = $true)][ValidateSet("tools", "provenance")][string]$PackagePrefix + ) + + $resolvedDirectory = (Resolve-Path -LiteralPath $Directory).Path.TrimEnd('\', '/') + $directoryPrefix = $resolvedDirectory + [System.IO.Path]::DirectorySeparatorChar + $packagePaths = @( + Get-ChildItem -LiteralPath $resolvedDirectory -File -Recurse -Force | ForEach-Object { + $fullPath = [System.IO.Path]::GetFullPath($_.FullName) + if (-not $fullPath.StartsWith($directoryPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Packaged file escaped its inventory root: $fullPath" + } + $relativePath = $fullPath.Substring($directoryPrefix.Length).Replace('\', '/') + "$PackagePrefix/$relativePath" + } + ) + return @(ConvertTo-CanonicalPackageInventory -PackagePaths $packagePaths -RequiredPrefix $PackagePrefix) +} + +$includedToolsInventory = @(Get-CanonicalPackageFileInventory -Directory $toolsDir -PackagePrefix "tools") +$provenanceFileInventory = @(Get-CanonicalPackageFileInventory -Directory $provenanceDir -PackagePrefix "provenance") + $manifest = [ordered]@{ version = $Version commit = $commit + commitRole = if ($SkipBuild) { "package-source-only-not-firmware-identity" } else { "package-and-firmware-source" } shortCommit = $shortCommit generatedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") board = "m5stack-cores3" defaultEnvironment = "stackchan" includedEnvironments = @("stackchan", "stackchan_servo_calibration", "stackchan_release_full") - servoDefault = "display-only and calibration flows remain safety-gated; the production full firmware starts without requesting motion or autonomous refresh; physical servo rail and torque state require fresh /debug verification" - status = "test-ready prerelease; hardware validation pending" + firmwareReproducibility = [ordered]@{ + mechanism = if ($SkipBuild) { "unbound-preexisting-artifacts" } else { "git-commit-epoch-builtins-v1" } + sourceCommit = if ($SkipBuild) { $null } else { $commit } + sourceEpoch = if ($SkipBuild) { $null } else { $commitEpoch } + hook = "tools/platformio_reproducible_build.py" + contract = "tools/test_firmware_reproducible_build_contract.ps1" + hookCoverage = if ($SkipBuild) { "not-run-skip-build" } else { "exactly-one-effective-hook" } + releaseOverridePolicy = if ($SkipBuild) { "diagnostic-artifacts-unbound" } else { "release-overrides-fail-closed" } + scope = if ($SkipBuild) { + "unknown/unbound-skip-build; copied pre-existing outputs whose source identity is not established" + } else { + "same host/core paths and clean commit across distinct prefix-mapped project roots, canonical recorded PlatformIO toolchain/configuration, and no listed ambient build overrides" + } + proof = $firmwareReproducibilityProof + } + servoDefault = if ($SkipBuild) { + "boot and motion state unverified; copied firmware identity is unknown; do not flash" + } else { + "display-only and calibration flows remain safety-gated; the production full firmware starts without requesting motion or autonomous refresh; physical servo rail and torque state require fresh /debug verification" + } + status = if ($SkipBuild) { + "diagnostic-only; reproducibility not proven; release and hardware validation forbidden" + } else { + "test-ready prerelease; hardware validation pending" + } + diagnosticPackage = [bool]$SkipBuild + packageSourceIsolationPolicy = if ($SkipBuild) { "diagnostic-mutable-source-unbound" } else { "detached-clean-worktree-pinned-to-package-commit" } + packageSourceCommit = if ($SkipBuild) { $null } else { $canonicalBuildCommit } + packageSourceEpoch = if ($SkipBuild) { $null } else { $canonicalBuildEpoch } + releaseEligible = (-not $SkipBuild) + hardwareValidationEligible = (-not $SkipBuild) + distributionEligible = (-not $SkipBuild) + flashEligible = (-not $SkipBuild) dirty = ($sourceDirtyFiles.Count -gt 0) dirtyFiles = @($sourceDirtyFiles) generatedMediaDirtyFiles = @($generatedMediaDirtyFiles) @@ -1619,458 +2840,11 @@ $manifest = [ordered]@{ "media/voice/rvc/model.pth", "media/voice/rvc/model.index" ) - includedTools = @( - "tools/flash_device.cmd", - "tools/flash_device.ps1", - "tools/flash_release_firmware.cmd", - "tools/flash_release_firmware.ps1", - "tools/flash_wifi_bridge.cmd", - "tools/flash_wifi_bridge.ps1", - "tools/platformio_apply_wifi_bridge_env.py", - "tools/prepare_desktop_python_runtime.cmd", - "tools/prepare_desktop_python_runtime.ps1", - "tools/check_desktop_python_runtime_payload.cmd", - "tools/check_desktop_python_runtime_payload.ps1", - "tools/test_desktop_python_runtime_payload_contract.cmd", - "tools/test_desktop_python_runtime_payload_contract.ps1", - "tools/export_desktop_package_evidence.cmd", - "tools/export_desktop_package_evidence.ps1", - "tools/test_desktop_package_launch.cmd", - "tools/test_desktop_package_launch.ps1", - "tools/test_desktop_package_evidence_contract.cmd", - "tools/test_desktop_package_evidence_contract.ps1", - "tools/check_desktop_release_signing_readiness.cmd", - "tools/check_desktop_release_signing_readiness.ps1", - "tools/test_desktop_release_signing_readiness_contract.ps1", - "tools/install_desktop_companion_package.cmd", - "tools/install_desktop_companion_package.ps1", - "tools/check_desktop_target_install_evidence.cmd", - "tools/check_desktop_target_install_evidence.ps1", - "tools/test_desktop_target_install_evidence_contract.cmd", - "tools/test_desktop_target_install_evidence_contract.ps1", - "tools/check_desktop_v1_evidence_bundle.cmd", - "tools/check_desktop_v1_evidence_bundle.ps1", - "tools/test_desktop_v1_evidence_bundle_contract.cmd", - "tools/test_desktop_v1_evidence_bundle_contract.ps1", - "tools/check_companion_v1_evidence_bundle.cmd", - "tools/check_companion_v1_evidence_bundle.ps1", - "tools/test_companion_v1_evidence_bundle_contract.cmd", - "tools/test_companion_v1_evidence_bundle_contract.ps1", - "tools/platformio_resolver.ps1", - "tools/check_native_toolchain.cmd", - "tools/check_native_toolchain.ps1", - "tools/check_android_toolchain.cmd", - "tools/check_android_toolchain.ps1", - "tools/check_android_play_release_readiness.cmd", - "tools/check_android_play_release_readiness.ps1", - "tools/check_privacy_policy_deployment.cmd", - "tools/check_privacy_policy_deployment.ps1", - "tools/test_privacy_policy_deployment_contract.cmd", - "tools/test_privacy_policy_deployment_contract.ps1", - "tools/test_android_upload_signing_contract.cmd", - "tools/test_android_upload_signing_contract.ps1", - "tools/test_android_emulator_launch.cmd", - "tools/test_android_emulator_launch.ps1", - "tools/check_android_emulator_release_evidence.cmd", - "tools/check_android_emulator_release_evidence.ps1", - "tools/test_android_emulator_release_evidence_contract.cmd", - "tools/test_android_emulator_release_evidence_contract.ps1", - "tools/check_android_play_store_evidence.cmd", - "tools/check_android_play_store_evidence.ps1", - "tools/check_android_v1_evidence_bundle.cmd", - "tools/check_android_v1_evidence_bundle.ps1", - "tools/check_android_diagnostics_export_evidence.cmd", - "tools/check_android_diagnostics_export_evidence.ps1", - "tools/check_companion_v1_readiness.cmd", - "tools/check_companion_v1_readiness.ps1", - "tools/check_companion_release_version.cmd", - "tools/check_companion_release_version.ps1", - "tools/test_companion_release_version_contract.cmd", - "tools/test_companion_release_version_contract.ps1", - "tools/export_companion_release_evidence.cmd", - "tools/export_companion_release_evidence.ps1", - "tools/preview_python_resolver.ps1", - "tools/render_preview.py", - "tools/render_rvc_auditions.ps1", - "tools/audit_published_release.cmd", - "tools/audit_published_release.ps1", - "tools/publish_release.cmd", - "tools/publish_release.ps1", - "tools/release_asset_contract.ps1", - "tools/verify_release_asset_contract.cmd", - "tools/verify_release_asset_contract.ps1", - "tools/export_github_actions_status.cmd", - "tools/export_github_actions_status.ps1", - "tools/new_ci_account_block_exception.cmd", - "tools/new_ci_account_block_exception.ps1", - "tools/export_voice_source_status.cmd", - "tools/export_voice_source_status.ps1", - "tools/export_rvc_voice_base_status.cmd", - "tools/export_rvc_voice_base_status.ps1", - "tools/export_rollout_status.cmd", - "tools/export_rollout_status.ps1", - "tools/open_voice_audition.cmd", - "tools/open_voice_audition.ps1", - "tools/render_rvc_audition_mp3s.cmd", - "tools/render_rvc_audition_mp3s.ps1", - "tools/generate_speech_envelope_sidecar.cmd", - "tools/generate_speech_envelope_sidecar.ps1", - "tools/generate_speech_envelope_sidecar.py", - "tools/platformio_generate_persona_assets.py", - "tools/platformio_generate_voice_assets.py", - "tools/verify_speech_envelope_sidecar.cmd", - "tools/verify_speech_envelope_sidecar.ps1", - "tools/generate_synthetic_hardware_evidence.cmd", - "tools/generate_synthetic_hardware_evidence.ps1", - "tools/add_hardware_evidence_media.cmd", - "tools/add_hardware_evidence_media.ps1", - "tools/check_hardware_evidence_progress.cmd", - "tools/check_hardware_evidence_progress.ps1", - "tools/test_android_apk_install_evidence_contract.cmd", - "tools/test_android_apk_install_evidence_contract.ps1", - "tools/test_android_probe_evidence_progress_contract.cmd", - "tools/test_android_probe_evidence_progress_contract.ps1", - "tools/test_android_rollout_status_contract.cmd", - "tools/test_android_rollout_status_contract.ps1", - "tools/test_android_logcat_capture_contract.cmd", - "tools/test_android_logcat_capture_contract.ps1", - "tools/test_android_evidence_packet_contract.cmd", - "tools/test_android_evidence_packet_contract.ps1", - "tools/test_strict_android_apk_evidence_contract.cmd", - "tools/test_strict_android_apk_evidence_contract.ps1", - "tools/test_strict_android_dashboard_evidence_contract.cmd", - "tools/test_strict_android_dashboard_evidence_contract.ps1", - "tools/test_strict_android_probe_evidence_contract.cmd", - "tools/test_strict_android_probe_evidence_contract.ps1", - "tools/test_android_play_store_evidence_contract.cmd", - "tools/test_android_play_store_evidence_contract.ps1", - "tools/test_android_gemma_evidence_contract.cmd", - "tools/test_android_gemma_evidence_contract.ps1", - "tools/test_android_v1_evidence_bundle_contract.cmd", - "tools/test_android_v1_evidence_bundle_contract.ps1", - "tools/prepare_device_arrival.cmd", - "tools/prepare_device_arrival.ps1", - "tools/run_device_preflight.cmd", - "tools/run_device_preflight.ps1", - "tools/run_character_harness_tests.cmd", - "tools/run_character_harness_tests.ps1", - "tools/run_character_red_team.cmd", - "tools/run_character_red_team.ps1", - "tools/run_bridge_reference_tests.cmd", - "tools/run_bridge_reference_tests.ps1", - "tools/run_engine_probe.cmd", - "tools/run_engine_probe.ps1", - "tools/run_litert_lm_smoke.cmd", - "tools/run_litert_lm_smoke.ps1", - "tools/run_lan_smoke.cmd", - "tools/run_lan_smoke.ps1", - "tools/start_pc_brain.cmd", - "tools/start_pc_brain.ps1", - "tools/start_pc_brain_directml.ps1", - "tools/test_start_pc_brain_directml_contract.ps1", - "tools/check_local_research.ps1", - "tools/start_local_research.ps1", - "tools/test_local_research_runtime_contract.ps1", - "tools/searxng/compose.yaml", - "tools/searxng/settings.yml", - "tools/start_local_vision.cmd", - "tools/start_local_vision.ps1", - "tools/test_start_local_vision_contract.ps1", - "tools/start_whisper_server.ps1", - "tools/test_start_whisper_server_contract.ps1", - "tools/start_bridge_ai_supervised_qualification.ps1", - "tools/complete_bridge_ai_supervised_qualification.ps1", - "tools/test_bridge_ai_supervised_qualification_contract.ps1", - "tools/start_stackchan_dashboard.cmd", - "tools/start_stackchan_dashboard.ps1", - "tools/install_stackchan_dashboard_shortcut.ps1", - "tools/test_stackchan_dashboard_launcher_contract.cmd", - "tools/test_stackchan_dashboard_launcher_contract.ps1", - "tools/start_rvc_worker.ps1", - "tools/setup_voice_v2_directml.ps1", - "tools/voice_v2_directml_constraints.txt", - "tools/start_voice_v2_directml_worker.ps1", - "tools/run_voice_v2_directml_benchmark.ps1", - "tools/run_voice_v2_wire_benchmark.ps1", - "tools/start_voice_v2_supervised_validation.ps1", - "tools/check_voice_v2_supervised_evidence.ps1", - "tools/complete_voice_v2_supervised_validation.ps1", - "tools/restore_voice_v2_production.ps1", - "tools/test_voice_v2_supervised_evidence_contract.ps1", - "tools/run_pc_brain_probe.cmd", - "tools/collect_pc_brain_deploy_evidence.cmd", - "tools/collect_pc_brain_deploy_evidence.ps1", - "tools/check_pc_brain_deploy_evidence.cmd", - "tools/check_pc_brain_deploy_evidence.ps1", - "tools/run_pc_brain_quiet_soak.cmd", - "tools/run_pc_brain_quiet_soak.ps1", - "tools/check_pc_brain_quiet_soak_evidence.cmd", - "tools/check_pc_brain_quiet_soak_evidence.ps1", - "tools/run_selected_voice_once.cmd", - "tools/run_selected_voice_once.ps1", - "tools/run_android_companion_probe.cmd", - "tools/run_android_companion_probe.ps1", - "tools/run_android_companion_soak.cmd", - "tools/run_android_companion_soak.ps1", - "tools/run_android_udp_beacon_probe.cmd", - "tools/run_android_udp_beacon_probe.ps1", - "tools/install_android_companion_apk.cmd", - "tools/install_android_companion_apk.ps1", - "tools/capture_android_companion_logcat.cmd", - "tools/capture_android_companion_logcat.ps1", - "tools/run_prearrival_sim_check.cmd", - "tools/run_prearrival_sim_check.ps1", - "tools/run_hardware_simulation.cmd", - "tools/run_hardware_simulation.ps1", - "tools/send_speech_mouth_demo.cmd", - "tools/send_speech_mouth_demo.ps1", - "tools/send_speak_all_intents_demo.cmd", - "tools/send_speak_all_intents_demo.ps1", - "tools/send_bridge_replay_demo.cmd", - "tools/send_bridge_replay_demo.ps1", - "tools/share_release.cmd", - "tools/share_release.ps1", - "tools/start_hardware_evidence.cmd", - "tools/start_hardware_evidence.ps1", - "tools/stop_share.cmd", - "tools/stop_share.ps1", - "tools/verify_hardware_evidence.cmd", - "tools/verify_hardware_evidence.ps1", - "tools/verify_consumer_promotion.cmd", - "tools/verify_consumer_promotion.ps1", - "tools/test_consumer_promotion_contract.ps1", - "tools/verify_published_release.cmd", - "tools/verify_published_release.ps1", - "tools/verify_architecture.cmd", - "tools/verify_architecture.ps1", - "tools/verify_preview_media.cmd", - "tools/verify_preview_media.ps1", - "tools/verify_face_phase_a.cmd", - "tools/verify_face_phase_a.ps1", - "tools/verify_face_phase_b.cmd", - "tools/verify_face_phase_b.ps1", - "tools/verify_face_phase_c.cmd", - "tools/verify_face_phase_c.ps1", - "tools/verify_face_phase_d.cmd", - "tools/verify_face_phase_d.ps1", - "tools/verify_face_phase_e.cmd", - "tools/verify_face_phase_e.ps1", - "tools/verify_rvc_auditions.cmd", - "tools/verify_rvc_auditions.ps1", - "tools/verify_tracked_rvc_assets.cmd", - "tools/verify_tracked_rvc_assets.ps1", - "tools/verify_rvc_voice_base.cmd", - "tools/verify_rvc_voice_base.ps1", - "tools/verify_release_package.cmd", - "tools/verify_release_package.ps1", - "tools/verify_share_release.cmd", - "tools/verify_share_release.ps1", - "tools/provision_stackchan_wifi.cmd", - "tools/provision_stackchan_wifi.ps1", - "tools/check_android_controls_evidence.cmd", - "tools/check_android_controls_evidence.ps1", - "tools/check_android_gemma_evidence.cmd", - "tools/check_android_gemma_evidence.ps1", - "tools/check_android_pairing_evidence.cmd", - "tools/check_android_pairing_evidence.ps1", - "tools/check_android_screen_off_soak_evidence.cmd", - "tools/check_android_screen_off_soak_evidence.ps1", - "tools/check_android_speech_evidence.cmd", - "tools/check_android_speech_evidence.ps1", - "tools/check_android_wifi_evidence.cmd", - "tools/check_android_wifi_evidence.ps1", - "tools/test_android_controls_evidence_contract.cmd", - "tools/test_android_controls_evidence_contract.ps1", - "tools/test_android_diagnostics_export_evidence_contract.cmd", - "tools/test_android_diagnostics_export_evidence_contract.ps1", - "tools/test_android_pairing_evidence_contract.cmd", - "tools/test_android_pairing_evidence_contract.ps1", - "tools/test_android_screen_off_soak_evidence_contract.cmd", - "tools/test_android_screen_off_soak_evidence_contract.ps1", - "tools/test_android_speech_evidence_contract.cmd", - "tools/test_android_speech_evidence_contract.ps1", - "tools/test_android_wifi_evidence_contract.cmd", - "tools/test_android_wifi_evidence_contract.ps1", - "tools/check_voice_source_readiness.ps1", - "tools/test_voice_source_readiness_contract.ps1", - "tools/compare_hardware_sim_baseline.cmd", - "tools/compare_hardware_sim_baseline.ps1", - "tools/create_persona_pack.cmd", - "tools/create_persona_pack.ps1", - "tools/create_persona_pack.py", - "tools/build_persona_index.cmd", - "tools/build_persona_index.ps1", - "tools/build_persona_index.py", - "tools/export_persona_prompt_assets.py", - "tools/render_voice_samples.cmd", - "tools/render_voice_samples.ps1", - "tools/setup_voice_tools.cmd", - "tools/setup_voice_tools.ps1", - "tools/setup_whisper_cpp.cmd", - "tools/setup_whisper_cpp.ps1", - "tools/verify_persona_pack.cmd", - "tools/verify_persona_pack.ps1", - "tools/verify_persona_pack.py", - "tools/verify_voice_samples.cmd", - "tools/verify_voice_samples.ps1" - ) - provenanceFiles = @( - "provenance/platformio.ini", - "provenance/partitions_esp_sr_16.csv", - "provenance/requirements-preview.txt", - "provenance/bridge/README.md", - "provenance/bridge/export_protocol_fixtures.py", - "provenance/bridge/test_protocol_fixtures.py", - "provenance/bridge/persona_pack.py", - "provenance/bridge/test_persona_pack.py", - "provenance/bridge/character_red_team.py", - "provenance/bridge/test_character_red_team.py", - "provenance/bridge/reference_bridge.py", - "provenance/bridge/test_reference_bridge.py", - "provenance/bridge/bridge_memory.py", - "provenance/bridge/test_bridge_memory.py", - "provenance/bridge/test_bridge_memory_v4.py", - "provenance/bridge/memory_maintenance.py", - "provenance/bridge/test_memory_maintenance.py", - "provenance/bridge/episode_distillation.py", - "provenance/bridge/test_episode_distillation.py", - "provenance/bridge/memory_probe.py", - "provenance/bridge/test_memory_probe.py", - "provenance/bridge/memory_prefill_probe.py", - "provenance/bridge/local_runner.py", - "provenance/bridge/test_local_runner.py", - "provenance/bridge/litert_lm_stackchan_wrapper.py", - "provenance/bridge/test_litert_lm_stackchan_wrapper.py", - "provenance/bridge/litert_lm_contract_smoke.py", - "provenance/bridge/test_litert_lm_contract_smoke.py", - "provenance/bridge/engine_probe.py", - "provenance/bridge/test_engine_probe.py", - "provenance/bridge/model_benchmark.py", - "provenance/bridge/test_model_benchmark.py", - "provenance/bridge/utterance_text.py", - "provenance/bridge/stt_normalization.py", - "provenance/bridge/stt_adapter.py", - "provenance/bridge/stt_supervisor.py", - "provenance/bridge/windows_speech_stt.py", - "provenance/bridge/whisper_cpp_stt.py", - "provenance/bridge/whisper_server_stt.py", - "provenance/bridge/test_stt_adapter.py", - "provenance/bridge/test_stt_supervisor.py", - "provenance/bridge/test_whisper_server_stt.py", - "provenance/bridge/tts_adapter.py", - "provenance/bridge/test_tts_adapter.py", - "provenance/bridge/conversation_session.py", - "provenance/bridge/test_conversation_session.py", - "provenance/bridge/conversation_latency.py", - "provenance/bridge/test_conversation_latency.py", - "provenance/bridge/conversation_latency_report.py", - "provenance/bridge/test_conversation_latency_report.py", - "provenance/bridge/initiative_policy.py", - "provenance/bridge/test_initiative_policy.py", - "provenance/bridge/room_context.py", - "provenance/bridge/test_room_context.py", - "provenance/bridge/ollama_room_vision.py", - "provenance/bridge/test_ollama_room_vision.py", - "provenance/bridge/lan_service.py", - "provenance/bridge/test_lan_service.py", - "provenance/bridge/bridge_ai_qualification.py", - "provenance/bridge/test_bridge_ai_qualification.py", - "provenance/bridge/dashboard_service.py", - "provenance/bridge/test_dashboard_service.py", - "provenance/bridge/dashboard/index.html", - "provenance/bridge/dashboard/styles.css", - "provenance/bridge/dashboard/app.js", - "provenance/bridge/ollama_stackchan_runner.py", - "provenance/bridge/test_ollama_stackchan_runner.py", - "provenance/bridge/windows_speech_tts.py", - "provenance/bridge/rvc_tts.py", - "provenance/bridge/rvc_tts_client.py", - "provenance/bridge/rvc_worker_service.py", - "provenance/bridge/rvc_directml_tts_client.py", - "provenance/bridge/rvc_directml_worker_service.py", - "provenance/bridge/voice_v2_directml_runtime.py", - "provenance/bridge/voice_v2_directml_benchmark.py", - "provenance/bridge/voice_v2_wire_benchmark.py", - "provenance/bridge/voice_device_truth.py", - "provenance/bridge/test_voice_device_truth.py", - "provenance/bridge/research_broker.py", - "provenance/bridge/test_research_broker.py", - "provenance/bridge/research_acceptance.py", - "provenance/bridge/fixtures/memory_probe.json", - "provenance/bridge/fixtures/searxng_search_response.json", - "provenance/bridge/lan_smoke.py", - "provenance/bridge/test_lan_smoke.py", - "provenance/bridge/hardware_simulator.py", - "provenance/bridge/test_hardware_simulator.py", - "provenance/bridge/prearrival_sim_check.py", - "provenance/bridge/test_prearrival_sim_check.py", - "provenance/protocol-fixtures/endpoint_hello.json", - "provenance/protocol-fixtures/owner_status.json", - "provenance/protocol-fixtures/settings_get.json", - "provenance/protocol-fixtures/settings_set.json", - "provenance/protocol-fixtures/invalid/missing_type.json", - "provenance/protocol-fixtures/invalid/wrong_protocol.json", - "provenance/firmware.yml", - "provenance/release.yml", - "provenance/companion-signing-readiness.yml", - "provenance/data/commands.yaml", - "provenance/personas/spark/pack.yaml", - "provenance/personas/spark/character.yaml", - "provenance/personas/spark/prompt.md", - "provenance/personas/spark/behavior.yaml", - "provenance/personas/spark/expressions.yaml", - "provenance/personas/spark/earcons.yaml", - "provenance/personas/spark/voice.yaml", - "provenance/personas/glow/pack.yaml", - "provenance/personas/glow/character.yaml", - "provenance/personas/glow/prompt.md", - "provenance/personas/glow/behavior.yaml", - "provenance/personas/glow/expressions.yaml", - "provenance/personas/glow/earcons.yaml", - "provenance/personas/glow/voice.yaml", - "provenance/src/main.cpp", - "provenance/src/persona/SpeechPlanner.hpp", - "provenance/src/persona/SpeechPlanner.cpp", - "provenance/src/persona/CommandMap.hpp", - "provenance/src/persona/CommandMap.cpp", - "provenance/src/persona/GazeTracker.hpp", - "provenance/src/persona/GazeTracker.cpp", - "provenance/src/persona/EarconSynth.hpp", - "provenance/src/persona/EarconSynth.cpp", - "provenance/src/io/CameraAdapter.hpp", - "provenance/src/io/CameraAdapter.cpp", - "provenance/src/io/BridgeClient.hpp", - "provenance/src/io/BridgeClient.cpp", - "provenance/src/io/BridgeNetworkSession.hpp", - "provenance/src/io/BridgeNetworkSession.cpp", - "provenance/src/io/BridgeSocketWriter.hpp", - "provenance/src/io/BridgeSocketWriter.cpp", - "provenance/src/io/BridgeWiFiClientSocket.hpp", - "provenance/src/io/BridgeWiFiClientSocket.cpp", - "provenance/src/io/BridgeWiFiProvisioner.hpp", - "provenance/src/io/BridgeWiFiProvisioner.cpp", - "provenance/src/io/AudioCaptureAdapter.hpp", - "provenance/src/io/AudioCaptureAdapter.cpp", - "provenance/src/io/BridgeAudioDownlink.hpp", - "provenance/src/io/BridgeAudioDownlink.cpp", - "provenance/src/io/BridgeAudioUplink.hpp", - "provenance/src/io/BridgeAudioUplink.cpp", - "provenance/src/io/BridgeWakeGate.hpp", - "provenance/src/io/BridgeWakeGate.cpp", - "provenance/src/io/AudioOut.hpp", - "provenance/src/io/AudioOut.cpp", - "provenance/src/io/SpeechPromptBank.hpp", - "provenance/src/io/SpeechPromptBank.cpp", - "provenance/src/io/SpeechAdapter.hpp", - "provenance/src/io/SpeechAdapter.cpp", - "provenance/test/fixtures/audio/speech_right.wav", - "provenance/test/fixtures/audio/speech_left.wav", - "provenance/test/fixtures/audio/music_center.wav", - "provenance/test/fixtures/audio/fan_noise.wav" - ) + includedTools = @($includedToolsInventory) + provenanceFiles = @($provenanceFileInventory) } -$manifest | ConvertTo-Json -Depth 4 | Set-Content -Path (Join-Path $outDir "release_manifest.json") -Encoding UTF8 +$manifest | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $outDir "release_manifest.json") -Encoding UTF8 $ciStatus = [ordered]@{ schema = "stackchan.github-actions-status.v1" @@ -2078,17 +2852,38 @@ $ciStatus = [ordered]@{ commit = $commit repo = "RobVanProd/stackchan_alive" generatedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") - status = "post-push-check-required" + status = if ($SkipBuild) { "diagnostic-not-applicable" } else { "post-push-check-required" } promotionReady = $false firmwareCandidateReady = $false externalBlock = $false - interpretation = "This package was generated before the matching GitHub Actions runs could be observed. After pushing main and the release tag, run tools/export_github_actions_status.cmd to replace this placeholder with the observed GitHub Actions result." - requiredWorkflows = @("Firmware", "Release") - missingRequiredWorkflows = @("Firmware", "Release") + interpretation = if ($SkipBuild) { + "GitHub Actions candidate evidence is not applicable to an unqualified diagnostic archive. Do not push a tag or treat these files as a firmware candidate." + } else { + "This package was generated before the matching GitHub Actions runs could be observed. After pushing main and the release tag, run tools/export_github_actions_status.cmd to replace this placeholder with the observed GitHub Actions result." + } + requiredWorkflows = @(if ($SkipBuild) { @() } else { @("Firmware", "Release") }) + missingRequiredWorkflows = @(if ($SkipBuild) { @() } else { @("Firmware", "Release") }) workflows = @() } $ciStatus | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $outDir "github_actions_status.json") -Encoding UTF8 +if ($SkipBuild) { +@" +# GitHub Actions Status -- Diagnostic Only + +Diagnostic package: $Version +Package-source commit only: $commit +Repository: RobVanProd/stackchan_alive +Status: diagnostic-not-applicable +Firmware candidate ready: False +Promotion ready: False + +GitHub Actions candidate evidence is not applicable to this unqualified diagnostic archive. +Do not push a release tag or treat the copied firmware files as a candidate. + +Machine-readable status: ``github_actions_status.json`` +"@ | Set-Content -Path (Join-Path $outDir "GITHUB_ACTIONS_STATUS.md") -Encoding UTF8 +} else { @" # GitHub Actions Status @@ -2107,9 +2902,10 @@ If GitHub reports that jobs did not start because of account billing or spending Machine-readable status: ``github_actions_status.json`` "@ | Set-Content -Path (Join-Path $outDir "GITHUB_ACTIONS_STATUS.md") -Encoding UTF8 +} if ($ObserveCandidateActions) { - $actionsExporter = Join-Path $PSScriptRoot "export_github_actions_status.ps1" + $actionsExporter = Join-Path $releaseToolsRoot "export_github_actions_status.ps1" $actionsOutput = @( & $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File $actionsExporter ` -Repo "RobVanProd/stackchan_alive" ` @@ -2142,9 +2938,13 @@ $readinessReport = [ordered]@{ schema = "stackchan.readiness-report.v1" version = $Version commit = $commit + commitRole = if ($SkipBuild) { "package-source-only-not-firmware-identity" } else { "package-and-firmware-source" } + firmwareIdentity = if ($SkipBuild) { "unknown-unbound-preexisting-outputs" } else { $commit } generatedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") - status = "test-ready-prerelease" - consumerRollout = "blocked-pending-hardware-validation" + status = if ($SkipBuild) { "diagnostic-only-unqualified" } else { "test-ready-prerelease" } + consumerRollout = if ($SkipBuild) { "forbidden-diagnostic-package" } else { "blocked-pending-hardware-validation" } + diagnosticPackage = [bool]$SkipBuild + releaseAndHardwareUse = if ($SkipBuild) { "forbidden" } else { "pending-source-matched-qualification" } noHardwareProof = @( [ordered]@{ gate = "release-package-created"; status = "pass"; evidence = "release_manifest.json" }, [ordered]@{ gate = "firmware-binaries-present"; status = "pass"; evidence = "firmware/display_only and firmware/servo_calibration" }, @@ -2173,8 +2973,29 @@ $readinessReport = [ordered]@{ [ordered]@{ gate = "hardware-evidence-verification"; status = "pending-device"; requiredEvidence = "tools/verify_hardware_evidence.cmd passes on the completed packet" }, [ordered]@{ gate = "production-voice-assets"; status = "pass"; requiredEvidence = "media/voice/rvc/model.pth and model.index match the pinned production SHA-256 values" } ) - promotionRule = "Promotion requires source-matched supervised hardware qualification, bridge AI qualification, the required soak, successful release checks, and explicit owner approval." - nextOperatorCommand = ".\tools\prepare_device_arrival.cmd -Port COM3 -Operator `"Your Name`" -DeviceId STACKCHAN-001" + promotionRule = if ($SkipBuild) { + "Diagnostic packages cannot be promoted, flashed, or used as hardware evidence. Create a governed two-cycle package from a clean immutable commit." + } else { + "Promotion requires source-matched supervised hardware qualification, bridge AI qualification, the required soak, successful release checks, and explicit owner approval." + } + nextOperatorCommand = if ($SkipBuild) { + $null + } else { + ".\tools\prepare_device_arrival.cmd -Port COM3 -Operator `"Your Name`" -DeviceId STACKCHAN-001" + } +} + +if ($SkipBuild) { + foreach ($gate in @($readinessReport.noHardwareProof)) { + $gate["status"] = "not-qualified-diagnostic" + $gate["evidence"] = "Present only for diagnostic inspection; not accepted as release evidence" + } + $readinessReport.noHardwareProof[0]["gate"] = "diagnostic-archive-created" + $readinessReport.noHardwareProof[1]["gate"] = "unbound-preexisting-firmware-files-present" + foreach ($gate in @($readinessReport.hardwareGates)) { + $gate["status"] = "forbidden-diagnostic" + $gate["requiredEvidence"] = "Create and verify a governed two-cycle clean package before any hardware use" + } } $readinessReport | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $outDir "readiness_report.json") -Encoding UTF8 @@ -2183,10 +3004,13 @@ $acceptanceChecklist = [ordered]@{ schema = "stackchan.release-acceptance.v1" version = $Version commit = $commit + commitRole = if ($SkipBuild) { "package-source-only-not-firmware-identity" } else { "package-and-firmware-source" } + firmwareIdentity = if ($SkipBuild) { "unknown-unbound-preexisting-outputs" } else { $commit } generatedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") - releaseClass = "test-ready-prerelease" - currentDecision = "test-ready-for-device-arrival" - consumerRolloutDecision = "blocked-pending-hardware-validation" + releaseClass = if ($SkipBuild) { "diagnostic-only-unqualified" } else { "test-ready-prerelease" } + currentDecision = if ($SkipBuild) { "release-and-hardware-use-forbidden" } else { "test-ready-for-device-arrival" } + consumerRolloutDecision = if ($SkipBuild) { "forbidden-diagnostic-package" } else { "blocked-pending-hardware-validation" } + diagnosticPackage = [bool]$SkipBuild noHardwareAcceptance = @( [ordered]@{ requirement = "clean-release-package"; status = "pass"; evidence = "release_manifest.json" }, [ordered]@{ requirement = "firmware-artifacts-present"; status = "pass"; evidence = "firmware/display_only and firmware/servo_calibration" }, @@ -2215,11 +3039,47 @@ $acceptanceChecklist = [ordered]@{ [ordered]@{ requirement = "hardware-evidence-verification"; status = "pending-device"; requiredEvidence = "tools/verify_hardware_evidence.cmd passes on the completed packet" }, [ordered]@{ requirement = "production-voice-assets"; status = "pass"; requiredEvidence = "bundled model and index match the pinned production SHA-256 values" } ) - promotionRule = "This candidate remains blocked until source-matched supervised hardware qualification, bridge AI qualification, the required soak, successful release checks, and explicit owner approval." + promotionRule = if ($SkipBuild) { + "This diagnostic artifact cannot be promoted, flashed, or used as release or hardware evidence." + } else { + "This candidate remains blocked until source-matched supervised hardware qualification, bridge AI qualification, the required soak, successful release checks, and explicit owner approval." + } +} + +if ($SkipBuild) { + foreach ($requirement in @($acceptanceChecklist.noHardwareAcceptance)) { + $requirement["status"] = "not-accepted-diagnostic" + $requirement["evidence"] = "Present only for diagnostic inspection; not accepted as release evidence" + } + $acceptanceChecklist.noHardwareAcceptance[0]["requirement"] = "diagnostic-archive-structure-present" + $acceptanceChecklist.noHardwareAcceptance[1]["requirement"] = "unbound-preexisting-firmware-files-present" + foreach ($requirement in @($acceptanceChecklist.hardwareAcceptanceRequired)) { + $requirement["status"] = "forbidden-diagnostic" + $requirement["requiredEvidence"] = "Create and verify a governed two-cycle clean package before any hardware use" + } } $acceptanceChecklist | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $outDir "release_acceptance.json") -Encoding UTF8 +if ($SkipBuild) { +@" +# Diagnostic Package -- No Release Acceptance + +Diagnostic package: $Version +Commit: $commit +Decision: release and hardware use forbidden +Consumer rollout: forbidden diagnostic package + +This package was created with ``-SkipBuild -AllowDirty``. Firmware provenance and +reproducibility were not proven. Nothing in this package is accepted as release evidence. +Do not flash it, run its hardware tools, qualify a robot with it, or publish it as a candidate. + +Create a governed package from a clean immutable commit and pass its two independent clean +firmware build cycles before beginning any hardware qualification. + +Machine-readable checklist: ``release_acceptance.json`` +"@ | Set-Content -Path (Join-Path $outDir "RELEASE_ACCEPTANCE.md") -Encoding UTF8 +} else { @" # Release Acceptance Checklist @@ -2269,7 +3129,25 @@ source-matched physical qualification and release checks are complete. Machine-readable checklist: ``release_acceptance.json`` "@ | Set-Content -Path (Join-Path $outDir "RELEASE_ACCEPTANCE.md") -Encoding UTF8 +} +if ($SkipBuild) { +@" +# Diagnostic Readiness Report + +Diagnostic package: $Version +Commit: $commit +Status: diagnostic-only unqualified +Consumer rollout: forbidden diagnostic package +Release and hardware use: forbidden + +This package copied pre-existing outputs whose source identity is not established because +``-SkipBuild`` was selected. It proves no firmware identity, reproducibility, release readiness, +or physical readiness. +Do not flash it or use it as evidence. The only valid next step is to create a governed two-cycle +package from a clean immutable commit. +"@ | Set-Content -Path (Join-Path $outDir "READINESS_REPORT.md") -Encoding UTF8 +} else { @" # Readiness Report @@ -2318,7 +3196,22 @@ Recommended arrival command from the extracted package: $($readinessReport.nextOperatorCommand) "@ | Set-Content -Path (Join-Path $outDir "READINESS_REPORT.md") -Encoding UTF8 +} + +if ($SkipBuild) { +@" +# Stackchan: Alive $Version -- Diagnostic Package + +Commit: $commit + +This artifact was generated with ``-SkipBuild -AllowDirty``. Its firmware provenance and +reproducibility are not proven. It is not a prerelease candidate, is not publicly shareable as a +release, and must not be flashed, run on hardware, or used as qualification evidence. +Create a governed two-cycle package from a clean immutable commit before any release or hardware +workflow. +"@ | Set-Content -Path (Join-Path $outDir "RELEASE_NOTES.md") -Encoding UTF8 +} else { @" # Stackchan: Alive $Version @@ -2373,6 +3266,7 @@ Package and recipient gates: See ``docs/DEVICE_BRINGUP.md`` and ``docs/PRODUCTION_READINESS.md``. "@ | Set-Content -Path (Join-Path $outDir "RELEASE_NOTES.md") -Encoding UTF8 +} $packageRootPrefix = $outDir.TrimEnd("\", "/") + [System.IO.Path]::DirectorySeparatorChar function Get-PackageRelativePath { @@ -2443,6 +3337,20 @@ function Assert-NoRestrictedVoicePayload { Assert-NoRestrictedVoicePayload -RootPath $outDir +if (-not $SkipBuild) { + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -Phase "final release checkout audit" ` + -ProjectRoot $repoRoot + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $canonicalBuildCommit ` + -ExpectedEpoch $canonicalBuildEpoch ` + -Phase "final commit-bound package source staging" ` + -ProjectRoot $releaseSourceRoot ` + -RejectIgnored +} + $hashLines = Get-ChildItem -LiteralPath $outDir -File -Recurse | Where-Object { $_.Name -ne "SHA256SUMS.txt" } | Sort-Object FullName | @@ -2457,14 +3365,8 @@ $hashLines | Set-Content -Path (Join-Path $outDir "SHA256SUMS.txt") -Encoding AS if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force } -& tar.exe -a -cf $zipPath -C $outDir . -if ($LASTEXITCODE -ne 0) { - throw "Release ZIP creation failed with exit code $LASTEXITCODE" -} -$zipEntries = @(& tar.exe -tf $zipPath) -if ($LASTEXITCODE -ne 0) { - throw "Release ZIP inspection failed with exit code $LASTEXITCODE" -} +$zipEntries = @(New-StackchanDeterministicReleaseZip ` + -RootPath $outDir -ZipPath $zipPath -SourceEpoch $commitEpoch) $restrictedZipEntries = @($zipEntries | Where-Object { $allowedVisionModel = $_ -match '(?i)(^|/)(provenance/)?bridge/models/face_detection_yunet_2023mar\.onnx$' $allowedProductionVoice = $_ -match '(?i)(^|/)media/voice/rvc/(model\.pth|model\.index)$' @@ -2487,20 +3389,45 @@ $packageVerifyArgs = @( "-File", (Join-Path $PSScriptRoot "verify_release_package.ps1"), "-Version", $Version, "-ZipPath", $zipPath, - "-ExpectedCommit", $commit + "-ExpectedCommit", $commit, + "-ExpectedSourceEpoch", $commitEpoch ) if ($AllowDirty) { $packageVerifyArgs += "-AllowDirtyPackage" } -$packageVerifyOutput = @(& $windowsPowerShell @packageVerifyArgs 2>&1) -$packageVerifyExit = $LASTEXITCODE +if (-not $SkipBuild) { + $packageVerifyArgs += "-RequireReleaseEligible" +} +$previousVerifyErrorPreference = $ErrorActionPreference +try { + $ErrorActionPreference = "Continue" + $packageVerifyOutput = @(& $windowsPowerShell @packageVerifyArgs 2>&1) + $packageVerifyExit = $LASTEXITCODE +} finally { + $ErrorActionPreference = $previousVerifyErrorPreference +} $packageVerifyOutput | ForEach-Object { [string]$_ } | Set-Content -LiteralPath $packageVerifyLog -Encoding UTF8 if ($packageVerifyExit -ne 0) { - throw "Release ZIP verification failed with exit code $packageVerifyExit. See $packageVerifyLog" + throw "Package ZIP verification failed with exit code $packageVerifyExit. See $packageVerifyLog" } -Write-Host "Release package:" +if (-not $SkipBuild) { + Remove-ReleaseSourceWorktree + if (-not [string]::IsNullOrWhiteSpace($firmwareBuildCacheRoot) -and + (Test-Path -LiteralPath $firmwareBuildCacheRoot -PathType Container)) { + Remove-Item -LiteralPath $firmwareBuildCacheRoot -Recurse -Force + } + $builtFirmwareCache = $null + $firmwareBuildCacheRoot = $null + $firmwareDependencySnapshotRoot = $null +} + +if ($SkipBuild) { + Write-Host "Diagnostic archive only -- release and hardware use forbidden:" +} else { + Write-Host "Release package:" +} Write-Host $outDir Write-Host $zipPath Write-Host $zipSidecarPath diff --git a/tools/platformio_reproducible_build.py b/tools/platformio_reproducible_build.py new file mode 100644 index 00000000..4e0a61f9 --- /dev/null +++ b/tools/platformio_reproducible_build.py @@ -0,0 +1,213 @@ +"""Pin compiler build-time inputs to a reviewed source epoch. + +The Arduino core embeds ``__DATE__`` and ``__TIME__`` in firmware. Those +wall-clock strings change the ELF descriptor and the appended image digest, so +the same source can otherwise produce a different application binary. + +Canonical builds derive an epoch from a clean Git HEAD. Direct diagnostic +builds without Git may set ``STACKCHAN_BUILD_EPOCH`` to a strict decimal Unix +epoch. Release packaging rejects that and every other ambient build override. +""" + +from datetime import datetime, timezone +import os +from pathlib import Path +import re +import subprocess + + +Import("env") + + +_EPOCH_RE = re.compile(r"[0-9]+\Z") +_COMMIT_RE = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") +_MAX_EPOCH = 253402300799 # 9999-12-31T23:59:59Z +_MONTHS = ( + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +) +_FORBIDDEN_ENVIRONMENT = ( + "SOURCE_DATE_EPOCH", + "STACKCHAN_BUILD_STAMP", + "STACKCHAN_DISABLE_REPRODUCIBLE_BUILD", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CEILING_DIRECTORIES", +) +_CANONICAL_DEBUG_ROOT = "/stackchan/source" +_CANONICAL_CORE_ROOT = "/stackchan/platformio-core" + + +def _run_git(project_dir: Path, *arguments: str) -> str: + git_environment = { + key: value + for key, value in os.environ.items() + if not key.upper().startswith("GIT_") + } + git_environment["GIT_NO_REPLACE_OBJECTS"] = "1" + try: + result = subprocess.run( + ["git", *arguments], + cwd=str(project_dir), + capture_output=True, + check=False, + text=True, + timeout=10, + env=git_environment, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise RuntimeError("Git is required for a canonical reproducible build") from exc + if result.returncode != 0: + raise RuntimeError("Git is required for a canonical reproducible build") + return result.stdout.strip() + + +def _parse_epoch(name: str, value: str) -> int: + if len(value) > 12 or not _EPOCH_RE.fullmatch(value): + raise RuntimeError(f"{name} must be an exact unsigned decimal Unix epoch") + epoch = int(value, 10) + if epoch > _MAX_EPOCH: + raise RuntimeError(f"{name} is outside the supported UTC date range") + try: + datetime.fromtimestamp(epoch, timezone.utc) + except (OverflowError, OSError, ValueError) as exc: + raise RuntimeError(f"{name} is outside the supported UTC date range") from exc + return epoch + + +def _canonical_identity(project_dir: Path) -> tuple[str, int, str]: + if "STACKCHAN_BUILD_EPOCH" in os.environ: + if ( + "STACKCHAN_EXPECTED_BUILD_COMMIT" in os.environ + or "STACKCHAN_EXPECTED_BUILD_EPOCH" in os.environ + ): + raise RuntimeError( + "Direct build epoch overrides cannot satisfy a package build identity lock" + ) + raw_epoch = os.environ["STACKCHAN_BUILD_EPOCH"] + epoch = _parse_epoch("STACKCHAN_BUILD_EPOCH", raw_epoch) + return "direct-epoch-override", epoch, "override" + + worktree = _run_git(project_dir, "rev-parse", "--show-toplevel") + if not worktree or _run_git(project_dir, "rev-parse", "--is-inside-work-tree") != "true": + raise RuntimeError("Git worktree identity could not be resolved") + if _run_git(project_dir, "rev-parse", "--show-prefix"): + raise RuntimeError("Git worktree identity does not match the PlatformIO project directory") + + commit = _run_git(project_dir, "rev-parse", "--verify", "HEAD").lower() + if not _COMMIT_RE.fullmatch(commit): + raise RuntimeError("Git HEAD did not resolve to a full hexadecimal commit ID") + status = _run_git( + project_dir, + "status", + "--porcelain=v1", + "--untracked-files=all", + ) + if status: + raise RuntimeError( + "Canonical reproducible builds require a clean Git worktree, including untracked files" + ) + expected_commit = os.environ.get("STACKCHAN_EXPECTED_BUILD_COMMIT") + expected_epoch = os.environ.get("STACKCHAN_EXPECTED_BUILD_EPOCH") + if (expected_commit is None) != (expected_epoch is None): + raise RuntimeError("Expected build commit and epoch must be supplied together") + if expected_commit is not None: + expected_commit = expected_commit.lower() + if not _COMMIT_RE.fullmatch(expected_commit): + raise RuntimeError("STACKCHAN_EXPECTED_BUILD_COMMIT is not a full commit ID") + if commit != expected_commit: + raise RuntimeError("Git HEAD does not match the package build identity lock") + + raw_epoch = _run_git(project_dir, "show", "-s", "--format=%ct", commit) + epoch = _parse_epoch("Git commit epoch", raw_epoch) + if expected_epoch is not None and epoch != _parse_epoch( + "STACKCHAN_EXPECTED_BUILD_EPOCH", expected_epoch + ): + raise RuntimeError("Git commit epoch does not match the package build identity lock") + if _run_git(project_dir, "rev-parse", "--verify", "HEAD").lower() != commit: + raise RuntimeError("Git HEAD changed while resolving the canonical build identity") + if _run_git(project_dir, "status", "--porcelain=v1", "--untracked-files=all"): + raise RuntimeError("Git worktree changed while resolving the canonical build identity") + return commit, epoch, "git-head" + + +def _format_builtins(epoch: int) -> tuple[str, str]: + instant = datetime.fromtimestamp(epoch, timezone.utc) + build_date = f"{_MONTHS[instant.month - 1]} {instant.day:2d} {instant.year:04d}" + build_time = f"{instant.hour:02d}:{instant.minute:02d}:{instant.second:02d}" + if len(build_date) != 11 or len(build_time) != 8: + raise RuntimeError("Reproducible builtin formatting changed width") + return build_date, build_time + + +def _prefix_map_flags(path_value: str, canonical_root: str) -> list[str]: + """Remove one local root identity from compiler and debug metadata.""" + + roots = [] + lexical = Path(os.path.abspath(path_value)) + resolved = lexical.resolve() + for candidate in (lexical.as_posix(), resolved.as_posix()): + candidate = candidate.rstrip("/") + if candidate and candidate not in roots: + if "=" in candidate: + raise RuntimeError( + "PlatformIO project paths containing '=' cannot be prefix-mapped safely" + ) + roots.append(candidate) + + return [f"-ffile-prefix-map={root}={canonical_root}" for root in roots] + + +def _configure_build(platformio_env) -> None: + for variable in _FORBIDDEN_ENVIRONMENT: + if variable in os.environ: + raise RuntimeError(f"Unsupported reproducible-build override is present: {variable}") + + project_dir_value = str(platformio_env["PROJECT_DIR"]) + project_dir = Path(project_dir_value).resolve() + identity, epoch, source = _canonical_identity(project_dir) + build_date, build_time = _format_builtins(epoch) + + child_environment = platformio_env.get("ENV") + if child_environment is None: + child_environment = {} + platformio_env["ENV"] = child_environment + child_environment["SOURCE_DATE_EPOCH"] = str(epoch) + + path_flags = _prefix_map_flags(project_dir_value, _CANONICAL_DEBUG_ROOT) + core_dir_value = platformio_env.get("PROJECT_CORE_DIR") or os.environ.get( + "PLATFORMIO_CORE_DIR" + ) + if core_dir_value: + path_flags.extend(_prefix_map_flags(str(core_dir_value), _CANONICAL_CORE_ROOT)) + + platformio_env.AppendUnique( + CCFLAGS=[ + "-Wno-builtin-macro-redefined", + f'-D__DATE__=\\"{build_date}\\"', + f'-D__TIME__=\\"{build_time}\\"', + *path_flags, + ] + ) + print( + "[reproducible-build] " + f"source={source} identity={identity} epoch={epoch} " + f"date={build_date!r} time={build_time!r}" + ) + + +_configure_build(env) diff --git a/tools/platformio_resolver.ps1 b/tools/platformio_resolver.ps1 index 7e1ca0be..5b74a68f 100644 --- a/tools/platformio_resolver.ps1 +++ b/tools/platformio_resolver.ps1 @@ -10,6 +10,22 @@ function Format-StackchanCommand { }) -join " " } +function Resolve-StackchanExactApplicationExecutable { + param([Parameter(Mandatory = $true)][string]$Candidate) + + if ([string]::IsNullOrWhiteSpace($Candidate) -or + -not (Test-Path -LiteralPath $Candidate -PathType Leaf)) { + return $null + } + $item = Get-Item -LiteralPath $Candidate -Force -ErrorAction SilentlyContinue + if ($null -eq $item -or + ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or + ($env:OS -eq 'Windows_NT' -and [string]$item.Extension -cne '.exe')) { + return $null + } + return [System.IO.Path]::GetFullPath($item.FullName) +} + function Get-StackchanPlatformioCandidates { $candidates = @() @@ -19,7 +35,7 @@ function Get-StackchanPlatformioCandidates { foreach ($commandName in @("platformio", "pio")) { $candidates += @( - Get-Command $commandName -All -ErrorAction SilentlyContinue | + Get-Command $commandName -All -CommandType Application -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source ) } @@ -52,14 +68,17 @@ function Get-StackchanPlatformioCandidates { function Get-StackchanPlatformioCommand { if (-not [string]::IsNullOrWhiteSpace($script:StackchanPlatformioCommand)) { - return $script:StackchanPlatformioCommand + $validatedCachedCommand = Resolve-StackchanExactApplicationExecutable ` + -Candidate $script:StackchanPlatformioCommand + if ($null -eq $validatedCachedCommand) { + throw "Previously selected PlatformIO executable is no longer one exact trusted application file: $script:StackchanPlatformioCommand" + } + return $validatedCachedCommand } foreach ($candidate in Get-StackchanPlatformioCandidates) { - $commandPath = $candidate - if (Test-Path -LiteralPath $candidate) { - $commandPath = (Resolve-Path $candidate).Path - } + $commandPath = Resolve-StackchanExactApplicationExecutable -Candidate $candidate + if ($null -eq $commandPath) { continue } try { $version = & $commandPath --version 2>$null diff --git a/tools/prepare_device_arrival.ps1 b/tools/prepare_device_arrival.ps1 index f76051fe..2f80e313 100644 --- a/tools/prepare_device_arrival.ps1 +++ b/tools/prepare_device_arrival.ps1 @@ -2,6 +2,7 @@ param( [string]$ReleaseTag = "", [string]$PackageZip = "", [string]$PackageRoot = "", + [string]$ExpectedCommit = "", [string]$Port = "", [string]$Operator = "", [string]$DeviceId = "", @@ -15,71 +16,43 @@ $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $repoRoot -function Invoke-GitText { - param([string[]]$Arguments) - - try { - $output = & git @Arguments 2>$null - } catch { - return "" - } - if ($LASTEXITCODE -ne 0) { - return "" - } - return ($output | Out-String).Trim() -} - -function Get-ManifestFromPackageRoot { - param([string]$RootPath) - - if ([string]::IsNullOrWhiteSpace($RootPath)) { - return $null - } - - $manifestPath = Join-Path $RootPath "release_manifest.json" - if (-not (Test-Path -LiteralPath $manifestPath)) { - return $null - } - - return Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +if (-not [string]::IsNullOrWhiteSpace($PackageZip) -and + -not [string]::IsNullOrWhiteSpace($PackageRoot)) { + throw "Pass only one of -PackageZip or -PackageRoot." } -function Get-ManifestFromPackageZip { - param([string]$ZipPath) - - if ([string]::IsNullOrWhiteSpace($ZipPath) -or -not (Test-Path -LiteralPath $ZipPath)) { - return $null - } - - $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "stackchan-arrival-manifest" - $extractDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) - New-Item -ItemType Directory -Force -Path $extractDir | Out-Null - try { - Expand-Archive -LiteralPath $ZipPath -DestinationPath $extractDir - return Get-ManifestFromPackageRoot $extractDir - } finally { - if (Test-Path -LiteralPath $extractDir) { - Remove-Item -LiteralPath $extractDir -Recurse -Force +if ([string]::IsNullOrWhiteSpace($ReleaseTag)) { + if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { + $zipName = [System.IO.Path]::GetFileName($PackageZip) + if ($zipName -notmatch "^stackchan_alive_(.+)\.zip$") { + throw "Pass -ReleaseTag when -PackageZip does not match stackchan_alive_.zip" } + $ReleaseTag = $Matches[1] + } else { + $ReleaseTag = (& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + describe --tags --always 2>$null | Out-String).Trim() } } - -$rootManifest = Get-ManifestFromPackageRoot $repoRoot - if ([string]::IsNullOrWhiteSpace($ReleaseTag)) { - if ($null -ne $rootManifest) { - $ReleaseTag = [string]$rootManifest.version - } else { - $ReleaseTag = Invoke-GitText @("describe", "--tags", "--always", "--dirty") - } + throw "Pass -ReleaseTag because it could not be resolved from trusted Git state." } -if ([string]::IsNullOrWhiteSpace($PackageZip) -and [string]::IsNullOrWhiteSpace($PackageRoot) -and $null -ne $rootManifest) { - $PackageRoot = $repoRoot +if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { + $ExpectedCommit = (& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + rev-parse HEAD 2>$null | Out-String).Trim() +} +if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { + throw "Pass -ExpectedCommit because it could not be resolved from trusted Git state." } if ([string]::IsNullOrWhiteSpace($PackageZip) -and [string]::IsNullOrWhiteSpace($PackageRoot)) { - $PackageZip = Join-Path $repoRoot "output/release/stackchan_alive_$ReleaseTag.zip" + if (Test-Path -LiteralPath (Join-Path $repoRoot "release_manifest.json") -PathType Leaf) { + $PackageRoot = $repoRoot + } else { + $PackageZip = Join-Path $repoRoot "output/release/stackchan_alive_$ReleaseTag.zip" + } } if (-not [string]::IsNullOrWhiteSpace($PackageZip) -and -not (Test-Path -LiteralPath $PackageZip)) { @@ -90,28 +63,7 @@ if (-not [string]::IsNullOrWhiteSpace($PackageRoot) -and -not (Test-Path -Litera throw "Missing package root: $PackageRoot" } -$packageManifest = $rootManifest -if ($null -eq $packageManifest -and -not [string]::IsNullOrWhiteSpace($PackageZip)) { - $packageManifest = Get-ManifestFromPackageZip $PackageZip -} -if ($null -eq $packageManifest -and -not [string]::IsNullOrWhiteSpace($PackageRoot)) { - $packageManifest = Get-ManifestFromPackageRoot $PackageRoot -} - -if ([string]::IsNullOrWhiteSpace($ReleaseTag) -and $null -ne $packageManifest) { - $ReleaseTag = [string]$packageManifest.version -} - -$commit = "" -if ($null -ne $packageManifest) { - $commit = [string]$packageManifest.commit -} -if ([string]::IsNullOrWhiteSpace($commit)) { - $commit = Invoke-GitText @("rev-parse", "HEAD") -} -if ([string]::IsNullOrWhiteSpace($commit)) { - throw "Could not determine release commit from git or package manifest." -} +$commit = $ExpectedCommit.ToLowerInvariant() if (-not $AllowIncompleteMetadata) { $missingMetadata = @() @@ -140,26 +92,31 @@ Write-Host "==> Verify release package" $verifyScript = Join-Path $PSScriptRoot "verify_release_package.ps1" if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { if ($AllowDirtyPackage) { - & $verifyScript -Version $ReleaseTag -ZipPath $PackageZip -ExpectedCommit $commit -AllowDirtyPackage + & $verifyScript -Version $ReleaseTag -ZipPath $PackageZip -ExpectedCommit $commit -AllowDirtyPackage -RequireReleaseEligible } else { - & $verifyScript -Version $ReleaseTag -ZipPath $PackageZip -ExpectedCommit $commit + & $verifyScript -Version $ReleaseTag -ZipPath $PackageZip -ExpectedCommit $commit -RequireReleaseEligible } } else { if ($AllowDirtyPackage) { - & $verifyScript -Version $ReleaseTag -PackageRoot $PackageRoot -ExpectedCommit $commit -AllowDirtyPackage + & $verifyScript -Version $ReleaseTag -PackageRoot $PackageRoot -ExpectedCommit $commit -AllowDirtyPackage -RequireReleaseEligible } else { - & $verifyScript -Version $ReleaseTag -PackageRoot $PackageRoot -ExpectedCommit $commit + & $verifyScript -Version $ReleaseTag -PackageRoot $PackageRoot -ExpectedCommit $commit -RequireReleaseEligible } } +if ($LASTEXITCODE -ne 0) { + throw "Operational release package verification failed before device-arrival preparation." +} Write-Host "" Write-Host "==> Dry-run display-only release flash" $flashScript = Join-Path $PSScriptRoot "flash_release_firmware.ps1" if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { if ($AllowDirtyPackage) { - & $flashScript -PackageZip $PackageZip -Firmware display_only -DryRun -Monitor -Port $Port -AllowDirtyPackage + & $flashScript -PackageZip $PackageZip -Firmware display_only -Version $ReleaseTag ` + -ExpectedCommit $commit -DryRun -Monitor -Port $Port -AllowDirtyPackage } else { - & $flashScript -PackageZip $PackageZip -Firmware display_only -DryRun -Monitor -Port $Port + & $flashScript -PackageZip $PackageZip -Firmware display_only -Version $ReleaseTag ` + -ExpectedCommit $commit -DryRun -Monitor -Port $Port } } else { if ($AllowDirtyPackage) { @@ -181,15 +138,21 @@ if (-not [string]::IsNullOrWhiteSpace($ShareRoot)) { } if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { if ($AllowDirtyPackage) { - $evidenceOutput = & $evidenceScript -ReleaseTag $ReleaseTag -PackageZip $PackageZip -Port $Port -Operator $Operator -DeviceId $DeviceId -AllowDirtyPackage @metadataArgs + $evidenceOutput = & $evidenceScript -ReleaseTag $ReleaseTag -PackageZip $PackageZip ` + -ExpectedCommit $commit -Port $Port -Operator $Operator -DeviceId $DeviceId ` + -AllowDirtyPackage @metadataArgs } else { - $evidenceOutput = & $evidenceScript -ReleaseTag $ReleaseTag -PackageZip $PackageZip -Port $Port -Operator $Operator -DeviceId $DeviceId @metadataArgs + $evidenceOutput = & $evidenceScript -ReleaseTag $ReleaseTag -PackageZip $PackageZip ` + -ExpectedCommit $commit -Port $Port -Operator $Operator -DeviceId $DeviceId @metadataArgs } } else { if ($AllowDirtyPackage) { - $evidenceOutput = & $evidenceScript -ReleaseTag $ReleaseTag -PackageRoot $PackageRoot -Port $Port -Operator $Operator -DeviceId $DeviceId -AllowDirtyPackage @metadataArgs + $evidenceOutput = & $evidenceScript -ReleaseTag $ReleaseTag -PackageRoot $PackageRoot ` + -ExpectedCommit $commit -Port $Port -Operator $Operator -DeviceId $DeviceId ` + -AllowDirtyPackage @metadataArgs } else { - $evidenceOutput = & $evidenceScript -ReleaseTag $ReleaseTag -PackageRoot $PackageRoot -Port $Port -Operator $Operator -DeviceId $DeviceId @metadataArgs + $evidenceOutput = & $evidenceScript -ReleaseTag $ReleaseTag -PackageRoot $PackageRoot ` + -ExpectedCommit $commit -Port $Port -Operator $Operator -DeviceId $DeviceId @metadataArgs } } $evidenceOutput | Write-Host diff --git a/tools/preview_python_resolver.ps1 b/tools/preview_python_resolver.ps1 index ef4f2356..fd423795 100644 --- a/tools/preview_python_resolver.ps1 +++ b/tools/preview_python_resolver.ps1 @@ -1,6 +1,9 @@ function Get-StackchanPythonCandidates { $candidates = @() - $candidates += @(Get-Command "python" -All -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source) + $candidates += @( + Get-Command "python" -All -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Source + ) if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { $pythonRoots = Join-Path $env:LOCALAPPDATA "Programs/Python" @@ -25,16 +28,34 @@ function Get-StackchanPythonCandidates { ) } +function Resolve-StackchanExactPreviewPythonExecutable { + param([Parameter(Mandatory = $true)][string]$Candidate) + + if ([string]::IsNullOrWhiteSpace($Candidate) -or + -not (Test-Path -LiteralPath $Candidate -PathType Leaf)) { + return $null + } + $item = Get-Item -LiteralPath $Candidate -Force -ErrorAction SilentlyContinue + if ($null -eq $item -or + ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or + ($env:OS -eq 'Windows_NT' -and [string]$item.Extension -cne '.exe')) { + return $null + } + return [System.IO.Path]::GetFullPath($item.FullName) +} + function Get-StackchanPreviewPython { foreach ($path in Get-StackchanPythonCandidates) { - if (-not (Test-Path -LiteralPath $path) -or $path -match "\\WindowsApps\\python\.exe$") { + if ($path -match "\\WindowsApps\\python\.exe$") { continue } + $pythonExecutable = Resolve-StackchanExactPreviewPythonExecutable -Candidate $path + if ($null -eq $pythonExecutable) { continue } try { - $probe = & $path -c "import PIL, imageio, imageio_ffmpeg; print('preview-media-ok')" 2>$null + $probe = & $pythonExecutable -c "import PIL, imageio, imageio_ffmpeg; print('preview-media-ok')" 2>$null if ($LASTEXITCODE -eq 0 -and (($probe | Out-String) -match "preview-media-ok")) { - return (Resolve-Path $path).Path + return $pythonExecutable } } catch { continue diff --git a/tools/publish_release.cmd b/tools/publish_release.cmd index 3d91f520..c1902fad 100644 --- a/tools/publish_release.cmd +++ b/tools/publish_release.cmd @@ -1,2 +1,8 @@ @echo off -powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0publish_release.ps1" %* +setlocal +set "STACKCHAN_PUBLISH_POWERSHELL=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" +if not exist "%STACKCHAN_PUBLISH_POWERSHELL%" ( + echo Exact Windows PowerShell executable not found. 1>&2 + exit /b 1 +) +"%STACKCHAN_PUBLISH_POWERSHELL%" -NoProfile -ExecutionPolicy Bypass -File "%~dp0publish_release.ps1" %* diff --git a/tools/publish_release.ps1 b/tools/publish_release.ps1 index 802c4ee3..ebd0df83 100644 --- a/tools/publish_release.ps1 +++ b/tools/publish_release.ps1 @@ -11,18 +11,107 @@ param( $ErrorActionPreference = "Stop" -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") -Set-Location $repoRoot -. (Join-Path $PSScriptRoot "release_asset_contract.ps1") +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$publishRepoRoot = $repoRoot +$publishGitCommand = Get-Command -Name git -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 +if ($null -eq $publishGitCommand) { + throw 'Release publishing requires a Git application executable; functions, aliases, and scripts are refused.' +} +$publishGitExecutable = (Resolve-Path -LiteralPath ([string]$publishGitCommand.Source)).Path +$publishPowerShellCommand = Get-Command -Name powershell.exe -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 +if ($null -eq $publishPowerShellCommand) { + throw 'Release publishing requires a Windows PowerShell application executable; functions, aliases, and scripts are refused.' +} +$publishPowerShellExecutable = ( + Resolve-Path -LiteralPath ([string]$publishPowerShellCommand.Source)).Path +$publishGitDisabledHooksPath = Join-Path $repoRoot ( + "output/private/disabled-publish-git-hooks-$PID-" + [guid]::NewGuid().ToString('N')) +$publishNullAttributesPath = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } +if (Test-Path -LiteralPath $publishGitDisabledHooksPath) { + throw "Publication Git disabled-hooks sentinel unexpectedly exists: $publishGitDisabledHooksPath" +} + +function Invoke-PublishTrustedGit { + param([Parameter(Mandatory = $true)][string[]]$Arguments) -function Assert-Command { - param([string]$Name) - $command = Get-Command $Name -ErrorAction SilentlyContinue - if ($null -eq $command) { - throw "Required command is not available on PATH: $Name" + if (Test-Path -LiteralPath $script:publishGitDisabledHooksPath) { + throw "Publication Git disabled-hooks path must not exist: $script:publishGitDisabledHooksPath" + } + $gitTrustArguments = @( + '-c', "core.hooksPath=$script:publishGitDisabledHooksPath", + '-c', 'core.fsmonitor=false', + '-c', 'core.untrackedCache=false', + '-c', 'core.useBuiltinFSMonitor=false', + '-c', 'maintenance.auto=false', + '-c', 'core.autocrlf=true', + '-c', "core.attributesFile=$script:publishNullAttributesPath", + '-c', 'filter.lfs.process=', + '-c', 'filter.lfs.clean=', + '-c', 'filter.lfs.smudge=', + '-c', 'filter.lfs.required=false', + '-C', $script:publishRepoRoot + ) + $gitTrustArguments += $Arguments + + $previousNoReplaceObjects = $env:GIT_NO_REPLACE_OBJECTS + $previousNoSystemAttributes = $env:GIT_ATTR_NOSYSTEM + try { + [Environment]::SetEnvironmentVariable( + 'GIT_NO_REPLACE_OBJECTS', '1', [EnvironmentVariableTarget]::Process) + [Environment]::SetEnvironmentVariable( + 'GIT_ATTR_NOSYSTEM', '1', [EnvironmentVariableTarget]::Process) + & $script:publishGitExecutable @gitTrustArguments + } finally { + [Environment]::SetEnvironmentVariable( + 'GIT_NO_REPLACE_OBJECTS', $previousNoReplaceObjects, [EnvironmentVariableTarget]::Process) + [Environment]::SetEnvironmentVariable( + 'GIT_ATTR_NOSYSTEM', $previousNoSystemAttributes, [EnvironmentVariableTarget]::Process) + } +} + +function Assert-SafeReleaseVersionLeaf { + param([Parameter(Mandatory = $true)][string]$Value) + + if ($Value.Length -gt 128 -or + $Value -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or + $Value -in @('.', '..') -or + $Value.EndsWith('.', [System.StringComparison]::Ordinal)) { + throw "Version must be one safe filename component containing only letters, digits, '.', '_', or '-'." } } +function Assert-SafeGitHubRepositoryName { + param([Parameter(Mandatory = $true)][string]$Value) + + if ($Value.Length -gt 201 -or + $Value -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$') { + throw "GitHub repository must be an explicit owner/name value containing only letters, digits, '.', '_', or '-'." + } + $components = @($Value -split '/', 2) + if ($components.Count -ne 2 -or $components[0] -in @('.', '..') -or + $components[1] -in @('.', '..') -or $components[0].EndsWith('.') -or + $components[1].EndsWith('.')) { + throw 'GitHub repository owner/name is not canonical.' + } +} + +function Get-SafeReleaseChildPath { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$RelativePath + ) + + $resolvedRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + $resolvedChild = [System.IO.Path]::GetFullPath((Join-Path $resolvedRoot $RelativePath)) + $expectedPrefix = $resolvedRoot + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolvedChild.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Release path escapes its trusted root: $resolvedChild" + } + return $resolvedChild +} + function Invoke-Checked { param( [string]$Description, @@ -85,12 +174,13 @@ function Update-ReleaseArchive { } function Get-CurrentBranchPublishInfo { - $branch = (git branch --show-current).Trim() + $branch = (Invoke-PublishTrustedGit -Arguments @('branch', '--show-current') | Out-String).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($branch)) { throw "Unable to resolve current branch. Publish from a named branch so the Firmware workflow can be observed." } - $upstream = (git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null | Out-String).Trim() + $upstream = (Invoke-PublishTrustedGit -Arguments @( + 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}') 2>$null | Out-String).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($upstream)) { throw "Current branch '$branch' has no upstream. Set an upstream or push it before publishing." } @@ -121,7 +211,8 @@ function Assert-CurrentBranchPublishedAtCommit { Write-Host "Dry run: git push $($branchInfo.remote) $($branchInfo.branch):$($branchInfo.remoteBranch)" } else { Invoke-Checked "Push current branch $($branchInfo.branch) to $($branchInfo.upstream)" { - git push $branchInfo.remote "$($branchInfo.branch):$($branchInfo.remoteBranch)" + Invoke-PublishTrustedGit -Arguments @( + 'push', $branchInfo.remote, "$($branchInfo.branch):$($branchInfo.remoteBranch)") } } } @@ -132,7 +223,8 @@ function Assert-CurrentBranchPublishedAtCommit { } $remoteRef = "refs/heads/$($branchInfo.remoteBranch)" - $remoteLine = (git ls-remote $branchInfo.remote $remoteRef | Out-String).Trim() + $remoteLine = (Invoke-PublishTrustedGit -Arguments @( + 'ls-remote', $branchInfo.remote, $remoteRef) | Out-String).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($remoteLine)) { throw "Unable to resolve remote branch $($branchInfo.upstream). Push the branch before publishing." } @@ -159,7 +251,8 @@ function Export-ActionsStatusWithRetry { $deadline = (Get-Date).AddSeconds(60) $lastOutput = "" do { - $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $exportScript -Repo $Repo -Version $Version -Commit $Commit -OutputDir $OutputDir 2>&1 + $output = & $script:publishPowerShellExecutable -NoProfile -ExecutionPolicy Bypass ` + -File $exportScript -Repo $Repo -Version $Version -Commit $Commit -OutputDir $OutputDir 2>&1 $exitCode = $LASTEXITCODE $lastOutput = ($output | Out-String) if ($exitCode -eq 0) { @@ -171,186 +264,355 @@ function Export-ActionsStatusWithRetry { throw "Unable to export GitHub Actions status for $Commit. Last output:$([Environment]::NewLine)$lastOutput" } -if ([string]::IsNullOrWhiteSpace($Version)) { - $Version = (git describe --tags --always --dirty).Trim() +function Assert-RemoteTagPublishedAtCommit { + param( + [Parameter(Mandatory = $true)][string]$Tag, + [Parameter(Mandatory = $true)][string]$Commit, + [string]$Remote = 'origin' + ) + + $tagRef = "refs/tags/$Tag" + $peeledRef = "$tagRef^{}" + $remoteLines = @(Invoke-PublishTrustedGit -Arguments @( + 'ls-remote', $Remote, $tagRef, $peeledRef)) + if ($LASTEXITCODE -ne 0) { + throw "Unable to resolve remote tag $Remote/$Tag before release mutation." + } + $resolved = New-Object System.Collections.Generic.List[string] + foreach ($line in $remoteLines) { + if ([string]$line -match '^([0-9a-fA-F]{40})\s+(.+)$') { + $resolved.Add("$($Matches[1].ToLowerInvariant())`t$($Matches[2])") + } + } + $peeled = @($resolved | Where-Object { $_.EndsWith("`t$peeledRef", [System.StringComparison]::Ordinal) }) + $direct = @($resolved | Where-Object { $_.EndsWith("`t$tagRef", [System.StringComparison]::Ordinal) }) + $remoteCommit = if ($peeled.Count -eq 1) { + ($peeled[0] -split "`t", 2)[0] + } elseif ($peeled.Count -eq 0 -and $direct.Count -eq 1) { + ($direct[0] -split "`t", 2)[0] + } else { + '' + } + if ($remoteCommit -cne $Commit.ToLowerInvariant()) { + throw "Remote tag $Remote/$Tag resolves to '$remoteCommit', not verified release commit $Commit. Push the exact tag before publishing." + } } -Assert-Command "git" -Assert-Command "gh" +function New-VerifiedReleaseTag { + param( + [Parameter(Mandatory = $true)][string]$Tag, + [Parameter(Mandatory = $true)][string]$Commit + ) -if ([string]::IsNullOrWhiteSpace($Repo)) { - $Repo = (gh repo view --json nameWithOwner --jq ".nameWithOwner").Trim() - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($Repo)) { - throw "Unable to infer GitHub repo. Pass -Repo owner/name." + Invoke-Checked "Create tag $Tag at verified commit $Commit" { + Invoke-PublishTrustedGit -Arguments @('tag', '-a', $Tag, $Commit, '-m', $Tag) + } + $createdTagCommit = (Invoke-PublishTrustedGit -Arguments @( + 'rev-list', '-n', '1', $Tag) | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $createdTagCommit -cne $Commit) { + Invoke-PublishTrustedGit -Arguments @('tag', '-d', $Tag) 2>$null | Out-Null + throw "Created tag $Tag does not resolve to the package-verified commit $Commit; the newly created tag was removed." } } -$packageRoot = Join-Path $repoRoot "output/release/$Version" -$zipPath = Join-Path $repoRoot "output/release/stackchan_alive_$Version.zip" -$zipSidecarPath = "$zipPath.sha256" +function Invoke-OperationalPackageVerification { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$ZipPath, + [Parameter(Mandatory = $true)][string]$ExpectedCommit, + [switch]$AllowDirtyPackage + ) -if (-not (Test-Path -LiteralPath $packageRoot)) { - throw "Missing package directory: $packageRoot. Run tools/package_release.ps1 first." + $arguments = @{ + Version = $Version + ZipPath = $ZipPath + ExpectedCommit = $ExpectedCommit + RequireReleaseEligible = $true + } + if ($AllowDirtyPackage) { + $arguments.AllowDirtyPackage = $true + } + & (Join-Path $PSScriptRoot "verify_release_package.ps1") @arguments + if ($LASTEXITCODE -ne 0) { + throw "Operational release package verification failed with exit code $LASTEXITCODE." + } } -if (-not (Test-Path -LiteralPath $zipPath)) { - throw "Missing release ZIP: $zipPath. Run tools/package_release.ps1 first." +function New-VerifiedPublicationSnapshot { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$ZipPath, + [Parameter(Mandatory = $true)][string]$ZipSidecarPath, + [Parameter(Mandatory = $true)][string]$ExpectedCommit, + [switch]$AllowDirtyPackage + ) + + $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\', '/') + $snapshotRoot = Join-Path $tempBase ("stackchan-publish-$PID-" + [guid]::NewGuid().ToString('N')) + $snapshotZip = Join-Path $snapshotRoot "stackchan_alive_$Version.zip" + $snapshotSidecar = "$snapshotZip.sha256" + $snapshotPackage = Join-Path $snapshotRoot "package" + try { + New-Item -ItemType Directory -Path $snapshotRoot | Out-Null + Copy-Item -LiteralPath $ZipPath -Destination $snapshotZip + Copy-Item -LiteralPath $ZipSidecarPath -Destination $snapshotSidecar + + Invoke-OperationalPackageVerification ` + -Version $Version ` + -ZipPath $snapshotZip ` + -ExpectedCommit $ExpectedCommit ` + -AllowDirtyPackage:$AllowDirtyPackage | Out-Host + Expand-StackchanReleaseZipSafely -ZipPath $snapshotZip -DestinationPath $snapshotPackage + + return [pscustomobject]@{ + Root = $snapshotRoot + ZipPath = $snapshotZip + ZipSidecarPath = $snapshotSidecar + PackageRoot = $snapshotPackage + } + } catch { + if (Test-Path -LiteralPath $snapshotRoot) { + $resolvedSnapshot = (Resolve-Path -LiteralPath $snapshotRoot).Path + $tempPrefix = $tempBase + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolvedSnapshot.StartsWith($tempPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clean unexpected publication snapshot: $resolvedSnapshot" + } + Remove-Item -LiteralPath $resolvedSnapshot -Recurse -Force + } + throw + } +} + +function Remove-VerifiedPublicationSnapshot { + param([Parameter(Mandatory = $true)][string]$SnapshotRoot) + + if (-not (Test-Path -LiteralPath $SnapshotRoot)) { + return + } + $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\', '/') + $resolvedSnapshot = (Resolve-Path -LiteralPath $SnapshotRoot).Path + $tempPrefix = $tempBase + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolvedSnapshot.StartsWith($tempPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or + (Split-Path -Leaf $resolvedSnapshot) -notmatch '^stackchan-publish-[0-9]+-[0-9a-f]{32}$') { + throw "Refusing to clean unexpected publication snapshot: $resolvedSnapshot" + } + Remove-Item -LiteralPath $resolvedSnapshot -Recurse -Force } -if (-not (Test-Path -LiteralPath $zipSidecarPath)) { - $zipHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $zipPath).Hash.ToLowerInvariant() - "$zipHash $(Split-Path -Leaf $zipPath)" | Set-Content -Path $zipSidecarPath -Encoding ASCII +if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = (Invoke-PublishTrustedGit -Arguments @( + 'describe', '--tags', '--always', '--dirty') | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($Version)) { + throw "Unable to infer a release version from the trusted source checkout." + } +} +Assert-SafeReleaseVersionLeaf -Value $Version +if (-not [string]::IsNullOrWhiteSpace($Repo)) { + Assert-SafeGitHubRepositoryName -Value $Repo +} + +$releaseOutputRoot = Get-SafeReleaseChildPath -Root $repoRoot -RelativePath "output/release" +$packageRoot = Get-SafeReleaseChildPath -Root $releaseOutputRoot -RelativePath $Version +$zipPath = Get-SafeReleaseChildPath -Root $releaseOutputRoot -RelativePath "stackchan_alive_$Version.zip" +$zipSidecarPath = Get-SafeReleaseChildPath -Root $releaseOutputRoot -RelativePath "stackchan_alive_$Version.zip.sha256" + +if (-not (Test-Path -LiteralPath $packageRoot -PathType Container)) { + throw "Missing package directory: $packageRoot. Run tools/package_release.ps1 first." +} +if (-not (Test-Path -LiteralPath $zipPath -PathType Leaf)) { + throw "Missing release ZIP: $zipPath. Run tools/package_release.ps1 first." +} +if (-not (Test-Path -LiteralPath $zipSidecarPath -PathType Leaf)) { + throw "Missing release ZIP SHA-256 sidecar: $zipSidecarPath. Rebuild the package; publishing will not create evidence for an unverified ZIP." } $tagCommit = "" -$tagProbe = git rev-parse -q --verify "refs/tags/$Version^{commit}" 2>$null +$tagNeedsCreation = $false +$tagProbe = Invoke-PublishTrustedGit -Arguments @( + 'rev-parse', '-q', '--verify', "refs/tags/$Version^{commit}") 2>$null if ($LASTEXITCODE -eq 0) { $tagCommit = ($tagProbe | Out-String).Trim() } elseif ($CreateTag) { - if ($DryRun) { - $tagCommit = (git rev-parse HEAD).Trim() - Write-Host "Dry run: git tag -a $Version -m $Version" - } else { - Invoke-Checked "Create tag $Version" { git tag -a $Version -m $Version } - $tagCommit = (git rev-list -n 1 $Version).Trim() - } + $tagCommit = (Invoke-PublishTrustedGit -Arguments @('rev-parse', 'HEAD') | Out-String).Trim() + $tagNeedsCreation = $true } elseif ($DryRun) { - $tagCommit = (git rev-parse HEAD).Trim() + $tagCommit = (Invoke-PublishTrustedGit -Arguments @('rev-parse', 'HEAD') | Out-String).Trim() Write-Warning "Dry run: using HEAD as expected commit because local tag $Version does not exist." } else { throw "Missing local tag $Version. Create it first or pass -CreateTag." } - -if ($PushTag -or $PushCurrentBranch) { - Assert-CurrentBranchPublishedAtCommit -Commit $tagCommit -PushBranch:$PushCurrentBranch -DryRun:$DryRun +if ($LASTEXITCODE -ne 0 -or $tagCommit -notmatch '^[0-9a-fA-F]{40}$') { + throw "Unable to resolve a full release commit for $Version." } +$tagCommit = $tagCommit.ToLowerInvariant() -if ($PushTag) { - if ($DryRun) { - Write-Host "Dry run: git push origin $Version" - } else { - Invoke-Checked "Push tag $Version" { git push origin $Version } +if ($DryRun) { + Write-Host "Dry run: verified parameters and local Git authority only; no package verifier, network command, filesystem mutation, tag, push, snapshot, extraction, staging, or publication was performed." + Write-Host "Dry run: version=$Version commit=$tagCommit repository=$repoRoot" + if ($tagNeedsCreation) { + Write-Host "Dry run: would create annotated tag $Version explicitly at $tagCommit." } -} - -$verifyArgs = @{ - Version = $Version - ZipPath = $zipPath - ExpectedCommit = $tagCommit -} -if ($AllowDirtyPackage) { - $verifyArgs.AllowDirtyPackage = $true -} -& (Join-Path $PSScriptRoot "verify_release_package.ps1") @verifyArgs - -$releaseExists = $false -if (-not $DryRun) { - $oldErrorActionPreference = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { - $null = gh release view $Version --repo $Repo 2>$null - $releaseViewExitCode = $LASTEXITCODE - } finally { - $ErrorActionPreference = $oldErrorActionPreference + if ($PushCurrentBranch) { + Write-Host "Dry run: would validate and push the current branch only after exact package verification." } - if ($releaseViewExitCode -eq 0) { - $releaseExists = $true + if ($PushTag) { + Write-Host "Dry run: would push tag $Version only after exact package verification." } + $repoDescription = if ([string]::IsNullOrWhiteSpace($Repo)) { '' } else { $Repo } + Write-Host "Dry run: would publish the reverified final asset set to $repoDescription." + return } -if ($releaseExists -and -not $AllowExistingRelease) { - throw "GitHub release already exists for $Version. Pass -AllowExistingRelease to verify the published ZIP." +if ([string]::IsNullOrWhiteSpace($Repo)) { + throw 'Real publication requires explicit -Repo owner/name; ambient GitHub CLI repository inference is refused.' } -$stageDir = Join-Path $repoRoot "output/release/manual-publish-$Version" -if (Test-Path -LiteralPath $stageDir) { - Remove-Item -LiteralPath $stageDir -Recurse -Force -} -New-Item -ItemType Directory -Force -Path $stageDir | Out-Null +# Nothing derived from Version is created, tagged, pushed, queried remotely, or exposed until +# the source-side verifier has accepted the exact candidate ZIP as release eligible. +Invoke-OperationalPackageVerification ` + -Version $Version ` + -ZipPath $zipPath ` + -ExpectedCommit $tagCommit ` + -AllowDirtyPackage:$AllowDirtyPackage +. (Join-Path $PSScriptRoot "release_asset_contract.ps1") +. (Join-Path $PSScriptRoot "release_zip_safety.ps1") -$displayFirmware = Join-Path $packageRoot "firmware/display_only/firmware.bin" -$servoFirmware = Join-Path $packageRoot "firmware/servo_calibration/firmware.bin" -$bootloader = Join-Path $packageRoot "firmware/display_only/bootloader.bin" -$partitions = Join-Path $packageRoot "firmware/display_only/partitions.bin" +$publishGitHubCommand = Get-Command -Name gh -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 +if ($null -eq $publishGitHubCommand) { + throw 'Release publishing requires a GitHub CLI application executable; functions, aliases, and scripts are refused.' +} +$publishGitHubExecutable = (Resolve-Path -LiteralPath ([string]$publishGitHubCommand.Source)).Path -Copy-Item -LiteralPath $displayFirmware -Destination (Join-Path $stageDir "firmware-display-only.bin") -Copy-Item -LiteralPath $servoFirmware -Destination (Join-Path $stageDir "firmware-servo-calibration.bin") -Copy-Item -LiteralPath $bootloader -Destination (Join-Path $stageDir "bootloader.bin") -Copy-Item -LiteralPath $partitions -Destination (Join-Path $stageDir "partitions.bin") +# GitHub Actions status is release content. Mutating it invalidates the first verification, +# so the archive and sidecar are rebuilt and verified again before anything is staged. +if (-not $DryRun) { + Export-ActionsStatusWithRetry -Repo $Repo -Version $Version -Commit $tagCommit -OutputDir $packageRoot + Update-ReleaseArchive -PackageRoot $packageRoot -ZipPath $zipPath -Version $Version + Invoke-OperationalPackageVerification ` + -Version $Version ` + -ZipPath $zipPath ` + -ExpectedCommit $tagCommit ` + -AllowDirtyPackage:$AllowDirtyPackage +} -$baseReleaseAssetEntries = Get-ReleaseBaseAssetEntries -Version $Version -PackageRoot $packageRoot -ZipPath $zipPath -ZipSidecarPath $zipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage -$baseReleaseAssets = @($baseReleaseAssetEntries | ForEach-Object { $_.Path }) -& (Join-Path $PSScriptRoot "verify_release_asset_contract.ps1") ` +$snapshot = New-VerifiedPublicationSnapshot ` -Version $Version ` - -PackageRoot $packageRoot ` -ZipPath $zipPath ` -ZipSidecarPath $zipSidecarPath ` - -FirmwareAssetRoot $stageDir ` - -FirmwareAssetPathMode Stage + -ExpectedCommit $tagCommit ` + -AllowDirtyPackage:$AllowDirtyPackage +try { + $publishedPackageRoot = $snapshot.PackageRoot + $publishedZipPath = $snapshot.ZipPath + $publishedZipSidecarPath = $snapshot.ZipSidecarPath + $stageDir = Join-Path $snapshot.Root "firmware-assets" + New-Item -ItemType Directory -Path $stageDir | Out-Null + + Copy-Item -LiteralPath (Join-Path $publishedPackageRoot "firmware/display_only/firmware.bin") -Destination (Join-Path $stageDir "firmware-display-only.bin") + Copy-Item -LiteralPath (Join-Path $publishedPackageRoot "firmware/servo_calibration/firmware.bin") -Destination (Join-Path $stageDir "firmware-servo-calibration.bin") + Copy-Item -LiteralPath (Join-Path $publishedPackageRoot "firmware/display_only/bootloader.bin") -Destination (Join-Path $stageDir "bootloader.bin") + Copy-Item -LiteralPath (Join-Path $publishedPackageRoot "firmware/display_only/partitions.bin") -Destination (Join-Path $stageDir "partitions.bin") + + Write-Host "Verify finalized release asset contract against the safe extraction of the exact verified ZIP." + & (Join-Path $PSScriptRoot "verify_release_asset_contract.ps1") ` + -Version $Version ` + -PackageRoot $publishedPackageRoot ` + -ZipPath $publishedZipPath ` + -ZipSidecarPath $publishedZipSidecarPath ` + -FirmwareAssetRoot $stageDir ` + -FirmwareAssetPathMode Stage + + $finalReleaseAssetEntries = Get-ReleaseFinalAssetEntries -Version $Version -PackageRoot $publishedPackageRoot -ZipPath $publishedZipPath -ZipSidecarPath $publishedZipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage + $finalReleaseAssets = @($finalReleaseAssetEntries | ForEach-Object { $_.Path }) + + $releaseExists = $false + if (-not $DryRun) { + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $null = & $script:publishGitHubExecutable release view $Version --repo $Repo 2>$null + $releaseViewExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } + if ($releaseViewExitCode -eq 0) { + $releaseExists = $true + } + } + if ($releaseExists -and -not $AllowExistingRelease) { + throw "GitHub release already exists for $Version. Pass -AllowExistingRelease to replace only the verified final asset set." + } + + if ($tagNeedsCreation) { + New-VerifiedReleaseTag -Tag $Version -Commit $tagCommit + } + + if ($PushTag -or $PushCurrentBranch) { + Assert-CurrentBranchPublishedAtCommit -Commit $tagCommit -PushBranch:$PushCurrentBranch -DryRun:$DryRun + } + if ($PushTag) { + Invoke-Checked "Push tag $Version" { + Invoke-PublishTrustedGit -Arguments @( + 'push', 'origin', "refs/tags/$Version:refs/tags/$Version") + } + } -if (-not $releaseExists) { if ($DryRun) { - Write-Host "Dry run: gh release create $Version with package, ZIP SHA256 sidecar, preview media, voice samples, and firmware assets staged in $stageDir" + Write-Host "Dry run: would publish only assets staged from $publishedPackageRoot and the exact reverified ZIP $publishedZipPath." + Write-Host "Dry run: would verify published assets from https://github.com/$Repo/releases/tag/$Version" } else { + Assert-RemoteTagPublishedAtCommit ` + -Tag $Version ` + -Commit $tagCommit ` + -Remote "https://github.com/$Repo.git" + } + if ($DryRun) { + # The dry-run message above is the terminal publication action. + } elseif (-not $releaseExists) { Invoke-Checked "Create GitHub release $Version" { - gh release create $Version ` - @baseReleaseAssets ` - --repo $Repo ` - --title "Stackchan: Alive $Version" ` - --notes-file (Join-Path $packageRoot "RELEASE_NOTES.md") ` - --prerelease + & $script:publishGitHubExecutable release create $Version ` + @finalReleaseAssets ` + --repo $Repo ` + --target $tagCommit ` + --title "Stackchan: Alive $Version" ` + --notes-file (Join-Path $publishedPackageRoot "RELEASE_NOTES.md") ` + --prerelease + } + } else { + Invoke-Checked "Upload finalized release evidence $Version" { + & $script:publishGitHubExecutable release upload $Version ` + @finalReleaseAssets ` + --repo $Repo ` + --clobber } } -} - -if ($DryRun) { - Write-Host "Dry run: would verify published assets and downloaded ZIP from https://github.com/$Repo/releases/tag/$Version" - Write-Host "Release dry run passed:" - Write-Host "https://github.com/$Repo/releases/tag/$Version" - exit 0 -} -Export-ActionsStatusWithRetry -Repo $Repo -Version $Version -Commit $tagCommit -OutputDir $packageRoot -Update-ReleaseArchive -PackageRoot $packageRoot -ZipPath $zipPath -Version $Version - -& (Join-Path $PSScriptRoot "verify_release_package.ps1") @verifyArgs - -$finalReleaseAssetEntries = Get-ReleaseFinalAssetEntries -Version $Version -PackageRoot $packageRoot -ZipPath $zipPath -ZipSidecarPath $zipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage -$finalReleaseAssets = @($finalReleaseAssetEntries | ForEach-Object { $_.Path }) -Write-Host "Verify finalized release asset contract before upload." -& (Join-Path $PSScriptRoot "verify_release_asset_contract.ps1") ` - -Version $Version ` - -PackageRoot $packageRoot ` - -ZipPath $zipPath ` - -ZipSidecarPath $zipSidecarPath ` - -FirmwareAssetRoot $stageDir ` - -FirmwareAssetPathMode Stage - -Invoke-Checked "Upload finalized release evidence $Version" { - gh release upload $Version ` - @finalReleaseAssets ` - --repo $Repo ` - --clobber -} - -$publishedVerifyArgs = @{ - Version = $Version - Repo = $Repo - PackageRoot = $packageRoot - ZipPath = $zipPath - ZipSidecarPath = $zipSidecarPath - ExpectedCommit = $tagCommit + if (-not $DryRun) { + $publishedVerifyArgs = @{ + Version = $Version + Repo = $Repo + PackageRoot = $publishedPackageRoot + ZipPath = $publishedZipPath + ZipSidecarPath = $publishedZipSidecarPath + ExpectedCommit = $tagCommit + } + & (Join-Path $PSScriptRoot "verify_published_release.ps1") @publishedVerifyArgs + + & (Join-Path $PSScriptRoot "audit_published_release.ps1") ` + -Version $Version ` + -Repo $Repo ` + -PackageRoot $publishedPackageRoot ` + -ZipPath $publishedZipPath ` + -ZipSidecarPath $publishedZipSidecarPath ` + -ExpectedCommit $tagCommit ` + -UploadToRelease + + Write-Host "Release published and verified:" + Write-Host "https://github.com/$Repo/releases/tag/$Version" + } else { + Write-Host "Release dry run passed without creating, pushing, or uploading anything." + } +} finally { + Remove-VerifiedPublicationSnapshot -SnapshotRoot $snapshot.Root } -& (Join-Path $PSScriptRoot "verify_published_release.ps1") @publishedVerifyArgs - -& (Join-Path $PSScriptRoot "audit_published_release.ps1") ` - -Version $Version ` - -Repo $Repo ` - -PackageRoot $packageRoot ` - -ZipPath $zipPath ` - -ZipSidecarPath $zipSidecarPath ` - -ExpectedCommit $tagCommit ` - -UploadToRelease - -Write-Host "Release published and verified:" -Write-Host "https://github.com/$Repo/releases/tag/$Version" diff --git a/tools/release_dependency_evidence.ps1 b/tools/release_dependency_evidence.ps1 new file mode 100644 index 00000000..a59fa8cf --- /dev/null +++ b/tools/release_dependency_evidence.ps1 @@ -0,0 +1,125 @@ +function Convert-StackchanPioPackageList { + param([string]$Text) + + $entries = @() + foreach ($line in ($Text -split "`r?`n")) { + $clean = ($line -replace "^[^A-Za-z0-9]+", "").Trim() + if ($clean -match "^Platform\s+(.+?)\s+@\s+([^\s]+)\s+\(required:\s*(.+)\)$") { + $entries += [ordered]@{ + kind = "platform" + name = $Matches[1] + version = $Matches[2] + required = $Matches[3] + } + } elseif ($clean -match "^(.+?)\s+@\s+([^\s]+)\s+\(required:\s*(.+)\)$") { + $entries += [ordered]@{ + kind = "package" + name = $Matches[1] + version = $Matches[2] + required = $Matches[3] + } + } + } + return @($entries) +} + +function Get-StackchanResolvedCorePackageNames { + param( + [Parameter(Mandatory = $true)][object[]]$ResolvedPackages, + [Parameter(Mandatory = $true)][string]$CorePackagesRoot + ) + + $names = @( + $ResolvedPackages | + Where-Object { $_.kind -eq 'package' } | + ForEach-Object { [string]$_.name } | + Sort-Object -Unique | + Where-Object { + if ($_ -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') { + throw "Unsafe resolved PlatformIO package name: $_" + } + Test-Path -LiteralPath (Join-Path $CorePackagesRoot $_) -PathType Container + } + ) + return @($names) +} + +function Get-StackchanVerbosePlatformSource { + param( + [Parameter(Mandatory = $true)][string]$VerbosePackageList, + [Parameter(Mandatory = $true)][string]$PlatformioCoreDir + ) + + $platformLine = @($VerbosePackageList -split "`r?`n" | Where-Object { $_ -match '^Platform\s' }) + if ($platformLine.Count -ne 1 -or + $platformLine[0] -notmatch '^Platform\s.+\s+@\s+\S+\s+\(required:\s*.+,\s*(.+)\)$') { + throw 'Verbose PlatformIO inventory did not contain one resolved platform path.' + } + $candidate = [System.IO.Path]::GetFullPath($Matches[1].Trim()) + if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { + throw "Resolved PlatformIO platform directory is missing: $candidate" + } + $platformsRoot = [System.IO.Path]::GetFullPath((Join-Path $PlatformioCoreDir 'platforms')).TrimEnd('\', '/') + $candidateParent = [System.IO.Path]::GetFullPath((Split-Path -Parent $candidate)).TrimEnd('\', '/') + $leaf = Split-Path -Leaf $candidate + if (-not $candidateParent.Equals($platformsRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + $leaf -notmatch '^[A-Za-z0-9][A-Za-z0-9._@-]*$') { + throw "Resolved PlatformIO platform escaped the selected core: $candidate" + } + return [pscustomobject][ordered]@{ + sourcePath = $candidate + sourceLeaf = $leaf + } +} + +function Copy-StackchanResolvedCorePackageEvidence { + param( + [Parameter(Mandatory = $true)][string]$CorePackagesRoot, + [Parameter(Mandatory = $true)][string]$DestinationRoot, + [Parameter(Mandatory = $true)][string[]]$CorePackageNames + ) + + foreach ($packageName in @($CorePackageNames | Sort-Object -Unique)) { + if ($packageName -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') { + throw "Unsafe resolved PlatformIO package name: $packageName" + } + $sourceRoot = Join-Path $CorePackagesRoot $packageName + if (-not (Test-Path -LiteralPath $sourceRoot -PathType Container)) { + throw "Missing resolved PlatformIO core package: $packageName" + } + $resolvedSource = (Resolve-Path -LiteralPath $sourceRoot).Path.TrimEnd('\', '/') + foreach ($file in Get-ChildItem -LiteralPath $resolvedSource -Recurse -File -Force -ErrorAction Stop) { + if ($file.Name -notmatch '(?i)^(LICENSE|LICENCE|COPYING|NOTICE)(\..*)?$' -and + $file.Name -notin @('library.json', 'library.properties', 'package.json', 'platform.json')) { + continue + } + $relative = $file.FullName.Substring($resolvedSource.Length + 1) + $destination = Join-Path (Join-Path $DestinationRoot $packageName) $relative + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null + Copy-Item -LiteralPath $file.FullName -Destination $destination -Force + } + } +} + +function Assert-StackchanCorePackageEvidenceAllowlisted { + param( + [Parameter(Mandatory = $true)][string]$Environment, + [Parameter(Mandatory = $true)][string[]]$CorePackageNames, + [Parameter(Mandatory = $true)][string[]]$IndexedThirdPartyPaths + ) + + $allowed = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + foreach ($name in @($CorePackageNames)) { + if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or -not $allowed.Add($name)) { + throw "Invalid or duplicate corePackageNames entry for $Environment`: $name" + } + } + foreach ($relative in @($IndexedThirdPartyPaths)) { + if ($relative -match ('^' + [regex]::Escape($Environment) + '/packages/([^/]+)/')) { + $observedName = $Matches[1] + if (-not $allowed.Contains($observedName)) { + throw "Unlisted PlatformIO core package evidence for $Environment`: $observedName" + } + } + } +} diff --git a/tools/release_git_trust.ps1 b/tools/release_git_trust.ps1 new file mode 100644 index 00000000..5ff806d8 --- /dev/null +++ b/tools/release_git_trust.ps1 @@ -0,0 +1,53 @@ +function Invoke-StackchanTrustedGit { + param( + [Parameter(Mandatory = $true)][string]$GitExecutable, + [Parameter(Mandatory = $true)][string]$DisabledHooksPath, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + + if (Test-Path -LiteralPath $DisabledHooksPath) { + throw "Trusted Git disabled-hooks path must not exist: $DisabledHooksPath" + } + if (-not (Test-Path -LiteralPath $GitExecutable -PathType Leaf)) { + throw "Trusted Git executable is missing: $GitExecutable" + } + $gitExecutableItem = Get-Item -LiteralPath $GitExecutable -Force + if ($gitExecutableItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint -or + ($env:OS -eq 'Windows_NT' -and [string]$gitExecutableItem.Extension -cne '.exe')) { + throw "Trusted Git requires one exact non-redirected executable file: $GitExecutable" + } + $resolvedGitExecutable = $gitExecutableItem.FullName + $gitArguments = @( + '-c', "core.hooksPath=$DisabledHooksPath", + '-c', 'core.fsmonitor=false', + '-c', 'core.untrackedCache=false', + '-c', 'core.useBuiltinFSMonitor=false', + '-c', 'maintenance.auto=false', + '-c', 'core.autocrlf=true', + '-c', ('core.attributesFile=' + $(if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' })), + '-c', 'filter.lfs.process=', + '-c', 'filter.lfs.smudge=', + '-c', 'filter.lfs.clean=', + '-c', 'filter.lfs.required=false' + ) + $gitArguments += $Arguments + + $previousNoReplaceObjects = $env:GIT_NO_REPLACE_OBJECTS + $previousNoSystemAttributes = $env:GIT_ATTR_NOSYSTEM + try { + $env:GIT_NO_REPLACE_OBJECTS = '1' + $env:GIT_ATTR_NOSYSTEM = '1' + & $resolvedGitExecutable @gitArguments + } finally { + if ($null -eq $previousNoReplaceObjects) { + Remove-Item Env:\GIT_NO_REPLACE_OBJECTS -ErrorAction SilentlyContinue + } else { + $env:GIT_NO_REPLACE_OBJECTS = $previousNoReplaceObjects + } + if ($null -eq $previousNoSystemAttributes) { + Remove-Item Env:\GIT_ATTR_NOSYSTEM -ErrorAction SilentlyContinue + } else { + $env:GIT_ATTR_NOSYSTEM = $previousNoSystemAttributes + } + } +} diff --git a/tools/release_source_binding.ps1 b/tools/release_source_binding.ps1 new file mode 100644 index 00000000..e25a1a18 --- /dev/null +++ b/tools/release_source_binding.ps1 @@ -0,0 +1,28 @@ +function Copy-StackchanCommitBoundPackageFile { + param( + [Parameter(Mandatory = $true)][string]$PackageSourceRoot, + [Parameter(Mandatory = $true)][string]$RelativePath, + [Parameter(Mandatory = $true)][string]$DestinationPath + ) + + if ([string]::IsNullOrWhiteSpace($RelativePath) -or + [System.IO.Path]::IsPathRooted($RelativePath) -or + $RelativePath.Contains(':')) { + throw "Commit-bound package source path must be a safe relative path: $RelativePath" + } + $segments = @($RelativePath.Replace('\', '/').Split('/') | Where-Object { $_ -ne '' }) + if ($segments -contains '.' -or $segments -contains '..') { + throw "Commit-bound package source path contains traversal: $RelativePath" + } + + $sourceRoot = (Resolve-Path -LiteralPath $PackageSourceRoot).Path.TrimEnd('\', '/') + $sourcePrefix = $sourceRoot + [System.IO.Path]::DirectorySeparatorChar + $sourcePath = [System.IO.Path]::GetFullPath((Join-Path $sourceRoot $RelativePath)) + if (-not $sourcePath.StartsWith($sourcePrefix, [System.StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + throw "Missing or uncontained commit-bound package source: $RelativePath" + } + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $DestinationPath) | Out-Null + Copy-Item -LiteralPath $sourcePath -Destination $DestinationPath -Force +} diff --git a/tools/release_toolchain_identity.ps1 b/tools/release_toolchain_identity.ps1 new file mode 100644 index 00000000..09b525b5 --- /dev/null +++ b/tools/release_toolchain_identity.ps1 @@ -0,0 +1,1114 @@ +Set-StrictMode -Version Latest + +$script:StackchanToolchainIdentitySchema = 'stackchan.release-toolchain-identity.v2' +$script:StackchanToolchainInventorySchema = 'stackchan.byte-tree.v1' +$script:StackchanCanonicalLibdepsSchema = 'stackchan.canonical-libdeps.v1' +$script:StackchanCanonicalGitLibrarySchema = 'stackchan.canonical-git-library.v1' + +function Get-StackchanReleaseToolchainPlatformKey { + if ($env:OS -eq 'Windows_NT') { + $osName = 'windows' + } elseif ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( + [System.Runtime.InteropServices.OSPlatform]::Linux)) { + $osName = 'linux' + } else { + throw 'Release toolchain identity has no policy for this operating system.' + } + + $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() + if ($architecture -eq 'x64') { $architecture = 'amd64' } + return "$osName`_$architecture" +} + +function Assert-StackchanPythonImportIsolationState { + param( + [Parameter(Mandatory = $true)]$Probe, + [Parameter(Mandatory = $true)][string]$PythonHome, + [Parameter(Mandatory = $true)][string]$PythonExecutable + ) + + $pythonRoot = (Get-Item -LiteralPath $PythonHome -Force -ErrorAction Stop).FullName.TrimEnd('\', '/') + $executable = (Get-Item -LiteralPath $PythonExecutable -Force -ErrorAction Stop).FullName + $comparison = if ($env:OS -eq 'Windows_NT') { + [StringComparison]::OrdinalIgnoreCase + } else { + [StringComparison]::Ordinal + } + if (-not ([IO.Path]::GetFullPath([string]$Probe.executable)).Equals($executable, $comparison) -or + -not ([IO.Path]::GetFullPath([string]$Probe.prefix)).Equals($pythonRoot, $comparison) -or + -not ([IO.Path]::GetFullPath([string]$Probe.base_prefix)).Equals($pythonRoot, $comparison) -or + [bool]$Probe.enable_user_site -or + [int]$Probe.flags.no_user_site -ne 1 -or + -not [bool]$Probe.flags.safe_path -or + [int]$Probe.flags.dont_write_bytecode -ne 1 -or + [int]$Probe.flags.optimize -ne 0) { + throw 'Python runtime did not prove the exact no-user-site/safe-path installation contract.' + } + $expectedPaths = @( + Join-Path $pythonRoot 'python312.zip' + Join-Path $pythonRoot 'DLLs' + Join-Path $pythonRoot 'Lib' + $pythonRoot + Join-Path $pythonRoot 'Lib/site-packages' + ) | ForEach-Object { [IO.Path]::GetFullPath($_) } + $actualPaths = @($Probe.path | ForEach-Object { + if ([string]::IsNullOrWhiteSpace([string]$_) -or -not [IO.Path]::IsPathRooted([string]$_)) { + throw "Python import path is empty or relative: $_" + } + [IO.Path]::GetFullPath([string]$_) + }) + if ($actualPaths.Count -ne $expectedPaths.Count) { + throw 'Python import path contains an external, missing, or duplicate entry.' + } + for ($i = 0; $i -lt $expectedPaths.Count; $i++) { + if (-not $actualPaths[$i].Equals($expectedPaths[$i], $comparison)) { + throw "Python import path escaped the exact installation policy: $($actualPaths[$i])" + } + } +} + +function Assert-StackchanPythonImportIsolation { + param( + [Parameter(Mandatory = $true)][string]$PythonHome, + [Parameter(Mandatory = $true)][string]$PythonExecutable + ) + + $requiredEnvironment = [ordered]@{ + PYTHONNOUSERSITE = '1' + PYTHONSAFEPATH = '1' + PYTHONDONTWRITEBYTECODE = '1' + PYTHONHASHSEED = '0' + PYTHONUTF8 = '1' + PYTHONIOENCODING = 'utf-8' + } + foreach ($entry in $requiredEnvironment.GetEnumerator()) { + if ([Environment]::GetEnvironmentVariable( + [string]$entry.Key, [EnvironmentVariableTarget]::Process) -cne [string]$entry.Value) { + throw "Release Python isolation requires $($entry.Key)=$($entry.Value)." + } + } + $forbiddenEnvironment = @( + '__PYVENV_LAUNCHER__', '_PYTHON_HOST_PLATFORM', + 'CONDA_DEFAULT_ENV', 'CONDA_PREFIX', 'VIRTUAL_ENV', + 'PYTHONBREAKPOINT', 'PYTHONCASEOK', 'PYTHONCOERCECLOCALE', 'PYTHONDEBUG', + 'PYTHONEXECUTABLE', 'PYTHONFAULTHANDLER', 'PYTHONHOME', 'PYTHONINSPECT', + 'PYTHONINTMAXSTRDIGITS', 'PYTHONMALLOC', 'PYTHONNODEBUGRANGES', 'PYTHONPATH', + 'PYTHONOPTIMIZE', 'PYTHONPERFSUPPORT', 'PYTHONPLATLIBDIR', 'PYTHONPROFILEIMPORTTIME', + 'PYTHONPYCACHEPREFIX', 'PYTHONSTARTUP', 'PYTHONTRACEMALLOC', 'PYTHONUSERBASE', + 'PYTHONWARNDEFAULTENCODING', 'PYTHONWARNINGS' + ) + foreach ($name in $forbiddenEnvironment) { + if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable( + $name, [EnvironmentVariableTarget]::Process))) { + throw "Release Python isolation refuses ambient import/runtime override: $name" + } + } + $pythonRoot = (Get-Item -LiteralPath $PythonHome -Force -ErrorAction Stop).FullName + $escapeFiles = @(Get-ChildItem -LiteralPath $pythonRoot -Recurse -File -Force -ErrorAction Stop | Where-Object { + $_.Extension -ieq '.pth' -or $_.Extension -ieq '.egg-link' -or + $_.Name -ieq 'sitecustomize.py' -or $_.Name -ieq 'usercustomize.py' + }) + if ($escapeFiles.Count -ne 0) { + throw "Release Python installation contains import-path/customization escape files: $($escapeFiles.FullName -join ', ')" + } + $probeCode = @' +import json, site, sys +print(json.dumps({ + 'executable': sys.executable, + 'prefix': sys.prefix, + 'base_prefix': sys.base_prefix, + 'path': sys.path, + 'enable_user_site': site.ENABLE_USER_SITE, + 'flags': { + 'no_user_site': sys.flags.no_user_site, + 'safe_path': sys.flags.safe_path, + 'dont_write_bytecode': sys.flags.dont_write_bytecode, + 'optimize': sys.flags.optimize, + }, +}, sort_keys=True)) +'@ + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = @(& $PythonExecutable '-c' $probeCode 2>&1) + $exitCode = $LASTEXITCODE + } catch { + throw "Release Python isolation probe failed to launch: $($_.Exception.Message)" + } finally { + $ErrorActionPreference = $previousPreference + } + if ($exitCode -ne 0) { + throw "Release Python isolation probe failed (exit $exitCode): $($output -join "`n")" + } + try { + $probe = ($output -join "`n").Trim() | ConvertFrom-Json + } catch { + throw "Release Python isolation probe did not return one JSON document: $($output -join "`n")" + } + Assert-StackchanPythonImportIsolationState ` + -Probe $probe -PythonHome $pythonRoot -PythonExecutable $PythonExecutable +} + +function Get-StackchanFileSha256 { + param([Parameter(Mandatory = $true)][string]$LiteralPath) + + $stream = [System.IO.FileStream]::new( + $LiteralPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read, + 1MB, + [System.IO.FileOptions]::SequentialScan) + $hasher = [System.Security.Cryptography.SHA256]::Create() + try { + return ([System.BitConverter]::ToString($hasher.ComputeHash($stream)) -replace '-', '').ToUpperInvariant() + } finally { + $hasher.Dispose() + $stream.Dispose() + } +} + +function ConvertTo-StackchanSafeIdentityRelativePath { + param([Parameter(Mandatory = $true)][string]$RelativePath) + + if ([string]::IsNullOrWhiteSpace($RelativePath) -or + $RelativePath -match '[\x00-\x1F\x7F]' -or + $RelativePath -match '(^|[\\/])\.\.($|[\\/])' -or + $RelativePath -match ':') { + throw "Unsafe toolchain identity relative path: $RelativePath" + } + if ([System.IO.Path]::IsPathRooted($RelativePath)) { + throw "Unsafe toolchain identity relative path: $RelativePath" + } + $canonical = ($RelativePath -replace '\\', '/').Trim('/') + if ([string]::IsNullOrWhiteSpace($canonical) -or + $canonical -match '(^|/)\.($|/)' -or + -not $canonical.IsNormalized([Text.NormalizationForm]::FormC)) { + throw "Non-canonical toolchain identity relative path: $RelativePath" + } + return $canonical +} + +function Get-StackchanIdentityFromRecords { + param( + [Parameter(Mandatory = $true)][object[]]$Records, + [string]$Schema = $script:StackchanToolchainInventorySchema, + [switch]$IncludeRecords + ) + + $recordMap = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + foreach ($record in $Records) { + $key = ConvertTo-StackchanSafeIdentityRelativePath ([string]$record.relativePath) + if ($recordMap.ContainsKey($key) -or + [string]$record.sha256 -notmatch '^[0-9A-F]{64}$' -or + [long]$record.bytes -lt 0) { + throw "Invalid or duplicate canonical identity record: $key" + } + $recordMap.Add($key, [pscustomobject][ordered]@{ + relativePath = $key + bytes = [long]$record.bytes + sha256 = [string]$record.sha256 + }) + } + $recordKeys = [string[]]@($recordMap.Keys) + [Array]::Sort($recordKeys, [StringComparer]::Ordinal) + $orderedRecords = [System.Collections.Generic.List[object]]::new() + $inventoryText = [System.Text.StringBuilder]::new() + [void]$inventoryText.Append($Schema).Append("`n") + foreach ($key in $recordKeys) { + $record = $recordMap[$key] + $orderedRecords.Add($record) | Out-Null + [void]$inventoryText.Append('F').Append("`0").Append($key).Append("`0"). + Append([string]$record.bytes).Append("`0").Append([string]$record.sha256).Append("`n") + } + $bytes = [Text.Encoding]::UTF8.GetBytes($inventoryText.ToString()) + $hasher = [System.Security.Cryptography.SHA256]::Create() + try { + $treeSha256 = ([BitConverter]::ToString($hasher.ComputeHash($bytes)) -replace '-', '').ToUpperInvariant() + } finally { + $hasher.Dispose() + } + $result = [pscustomobject][ordered]@{ + schema = $Schema + treeSha256 = $treeSha256 + fileCount = $orderedRecords.Count + bytes = [long](($orderedRecords | Measure-Object -Property bytes -Sum).Sum) + } + if ($IncludeRecords) { + $result | Add-Member -NotePropertyName records -NotePropertyValue @($orderedRecords) + } + return $result +} + +function Get-StackchanToolchainTreeIdentity { + param( + [Parameter(Mandatory = $true)][string]$Root, + [switch]$IncludeRecords + ) + + if (-not (Test-Path -LiteralPath $Root -PathType Container)) { + throw "Toolchain identity root is missing: $Root" + } + $resolvedRootItem = Get-Item -LiteralPath $Root -Force -ErrorAction Stop + if ($resolvedRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Toolchain identity refuses a reparse-point root: $Root" + } + $resolvedRoot = $resolvedRootItem.FullName.TrimEnd('\', '/') + $queue = [System.Collections.Generic.Queue[object]]::new() + $queue.Enqueue([pscustomobject]@{ Item = $resolvedRootItem; Relative = '' }) + $records = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + + while ($queue.Count -gt 0) { + $current = $queue.Dequeue() + $children = @(Get-ChildItem -LiteralPath $current.Item.FullName -Force -ErrorAction Stop) + $childMap = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + foreach ($child in $children) { + if ($childMap.ContainsKey([string]$child.Name)) { + throw "Duplicate toolchain identity entry under $($current.Item.FullName): $($child.Name)" + } + $childMap.Add([string]$child.Name, $child) + } + $childNames = [string[]]@($childMap.Keys) + [Array]::Sort($childNames, [StringComparer]::Ordinal) + $caseNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($name in $childNames) { + if (-not $caseNames.Add($name)) { + throw "Case-ambiguous toolchain identity entries under $($current.Item.FullName): $name" + } + if ([string]::IsNullOrWhiteSpace($name) -or $name -match '[\x00-\x1F\x7F/\\]' -or + -not $name.IsNormalized([Text.NormalizationForm]::FormC)) { + throw "Unsafe toolchain identity entry under $($current.Item.FullName): $name" + } + $item = $childMap[$name] + $relative = if ([string]::IsNullOrEmpty([string]$current.Relative)) { + $name + } else { + [string]$current.Relative + '/' + $name + } + $relative = ConvertTo-StackchanSafeIdentityRelativePath $relative + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Toolchain identity refuses reparse points: $($item.FullName)" + } + if ($item.PSIsContainer) { + $queue.Enqueue([pscustomobject]@{ Item = $item; Relative = $relative }) + } elseif ($item -is [System.IO.FileInfo]) { + $lengthBefore = [long]$item.Length + $sha256 = Get-StackchanFileSha256 -LiteralPath $item.FullName + $lengthAfter = [long](Get-Item -LiteralPath $item.FullName -Force -ErrorAction Stop).Length + if ($lengthBefore -ne $lengthAfter) { + throw "Toolchain input changed while it was hashed: $($item.FullName)" + } + if ($records.ContainsKey($relative)) { + throw "Duplicate toolchain identity path: $relative" + } + $records.Add($relative, [pscustomobject][ordered]@{ + relativePath = $relative + bytes = $lengthAfter + sha256 = $sha256 + }) + } else { + throw "Unsupported toolchain identity filesystem entry: $($item.FullName)" + } + } + } + + return Get-StackchanIdentityFromRecords -Records @($records.Values) ` + -Schema $script:StackchanToolchainInventorySchema -IncludeRecords:$IncludeRecords +} + +function New-StackchanCanonicalIdentityRecord { + param( + [Parameter(Mandatory = $true)][string]$RelativePath, + [Parameter(Mandatory = $true)][string]$CanonicalText + ) + $bytes = [Text.Encoding]::UTF8.GetBytes($CanonicalText) + $hasher = [Security.Cryptography.SHA256]::Create() + try { + $sha256 = ([BitConverter]::ToString($hasher.ComputeHash($bytes)) -replace '-', '').ToUpperInvariant() + } finally { + $hasher.Dispose() + } + return [pscustomobject][ordered]@{ + relativePath = ConvertTo-StackchanSafeIdentityRelativePath $RelativePath + bytes = [long]$bytes.Length + sha256 = $sha256 + } +} + +function Get-StackchanUInt32BigEndian { + param([byte[]]$Bytes, [int]$Offset) + if ($Offset -lt 0 -or $Offset + 4 -gt $Bytes.Length) { throw 'Truncated big-endian uint32.' } + return [uint32]((([uint32]$Bytes[$Offset]) -shl 24) -bor + (([uint32]$Bytes[$Offset + 1]) -shl 16) -bor + (([uint32]$Bytes[$Offset + 2]) -shl 8) -bor + ([uint32]$Bytes[$Offset + 3])) +} + +function Get-StackchanUInt16BigEndian { + param([byte[]]$Bytes, [int]$Offset) + if ($Offset -lt 0 -or $Offset + 2 -gt $Bytes.Length) { throw 'Truncated big-endian uint16.' } + return [uint16]((([uint16]$Bytes[$Offset]) -shl 8) -bor ([uint16]$Bytes[$Offset + 1])) +} + +function Get-StackchanSha1Hex { + param([Parameter(Mandatory = $true)][byte[]]$Bytes) + $hasher = [Security.Cryptography.SHA1]::Create() + try { + return ([BitConverter]::ToString($hasher.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() + } finally { + $hasher.Dispose() + } +} + +function ConvertTo-StackchanLowerHex { + param([Parameter(Mandatory = $true)][byte[]]$Bytes) + return ([BitConverter]::ToString($Bytes) -replace '-', '').ToLowerInvariant() +} + +function Get-StackchanCanonicalGitIndexText { + param([Parameter(Mandatory = $true)][string]$IndexPath) + + [byte[]]$bytes = [IO.File]::ReadAllBytes($IndexPath) + if ($bytes.Length -lt 32 -or [Text.Encoding]::ASCII.GetString($bytes, 0, 4) -cne 'DIRC') { + throw "Invalid Git index header: $IndexPath" + } + $payloadLength = $bytes.Length - 20 + $expectedChecksum = ConvertTo-StackchanLowerHex ([byte[]]$bytes[$payloadLength..($bytes.Length - 1)]) + $actualChecksum = Get-StackchanSha1Hex ([byte[]]$bytes[0..($payloadLength - 1)]) + if ($actualChecksum -cne $expectedChecksum) { throw "Git index checksum mismatch: $IndexPath" } + $version = Get-StackchanUInt32BigEndian $bytes 4 + if ($version -ne 2) { throw "Unsupported Git index version $version`: $IndexPath" } + $entryCount = [int](Get-StackchanUInt32BigEndian $bytes 8) + $offset = 12 + $builder = [Text.StringBuilder]::new() + [void]$builder.Append("git-index-v2`n") + $paths = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $utf8 = [Text.UTF8Encoding]::new($false, $true) + for ($entry = 0; $entry -lt $entryCount; $entry++) { + $entryStart = $offset + if ($entryStart + 63 -gt $payloadLength) { throw "Truncated Git index entry: $IndexPath" } + $mode = Get-StackchanUInt32BigEndian $bytes ($entryStart + 24) + $objectId = ConvertTo-StackchanLowerHex ([byte[]]$bytes[($entryStart + 40)..($entryStart + 59)]) + $flags = Get-StackchanUInt16BigEndian $bytes ($entryStart + 60) + if (($flags -band 0x4000) -ne 0) { throw "Extended Git index entries are not supported: $IndexPath" } + $nul = $entryStart + 62 + while ($nul -lt $payloadLength -and $bytes[$nul] -ne 0) { $nul++ } + if ($nul -ge $payloadLength) { throw "Unterminated Git index path: $IndexPath" } + $pathLength = $nul - ($entryStart + 62) + $path = $utf8.GetString($bytes, $entryStart + 62, $pathLength) + $path = ConvertTo-StackchanSafeIdentityRelativePath $path + if (-not $paths.Add($path)) { throw "Duplicate Git index path: $path" } + $encodedLength = $flags -band 0x0FFF + if ($encodedLength -ne [Math]::Min($pathLength, 0x0FFF)) { + throw "Git index path length mismatch: $path" + } + [void]$builder.Append($mode.ToString('X8')).Append(' ').Append($objectId). + Append(' ').Append(($flags -band 0xF000).ToString('X4')).Append(' ').Append($path).Append("`n") + $entryBytes = 62 + $pathLength + 1 + $offset = $entryStart + (($entryBytes + 7) -band (-bnot 7)) + } + if ($offset -gt $payloadLength) { throw "Git index entry padding escaped file: $IndexPath" } + if ($offset -lt $payloadLength) { + $extensionBytes = [byte[]]$bytes[$offset..($payloadLength - 1)] + [void]$builder.Append('extensions ').Append($extensionBytes.Length).Append(' '). + Append((Get-StackchanSha1Hex $extensionBytes)).Append("`n") + } + return $builder.ToString() +} + +function Get-StackchanCanonicalReflogText { + param([Parameter(Mandatory = $true)][string]$LogPath) + $raw = [IO.File]::ReadAllText($LogPath) + $builder = [Text.StringBuilder]::new() + foreach ($line in @($raw -split "`r?`n" | Where-Object { $_ -ne '' })) { + if ($line -notmatch '^(?[0-9a-f]{40}) (?[0-9a-f]{40}) (?[^\r\n]+ <[^>\r\n]+>) (?[0-9]{1,20}) (?[+-][0-9]{4})\t(?[^\r\n]*)$') { + throw "Unsafe or malformed Git reflog entry: $LogPath" + } + [void]$builder.Append($Matches.old).Append(' ').Append($Matches.new). + Append("`t").Append($Matches.message).Append("`n") + } + return $builder.ToString() +} + +function Get-StackchanCanonicalGitPackText { + param([Parameter(Mandatory = $true)][string]$PackRoot) + + $items = @(Get-ChildItem -LiteralPath $PackRoot -File -Force -ErrorAction Stop) + if ($items.Count -eq 0) { throw "Git object pack directory is empty: $PackRoot" } + $groups = @($items | Group-Object { [IO.Path]::GetFileNameWithoutExtension($_.Name) }) + $objectIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($group in $groups) { + if ($group.Name -notmatch '^pack-[0-9a-f]{40}$') { throw "Unsafe Git pack name: $($group.Name)" } + $extensions = @($group.Group | ForEach-Object { $_.Extension.ToLowerInvariant() } | Sort-Object) + if (($extensions -join ',') -cne '.idx,.pack,.rev') { + throw "Git pack must contain exactly idx/pack/rev: $($group.Name)" + } + $idxPath = ($group.Group | Where-Object Extension -eq '.idx').FullName + $packPath = ($group.Group | Where-Object Extension -eq '.pack').FullName + $revPath = ($group.Group | Where-Object Extension -eq '.rev').FullName + [byte[]]$idx = [IO.File]::ReadAllBytes($idxPath) + if ($idx.Length -lt 1104 -or (ConvertTo-StackchanLowerHex ([byte[]]$idx[0..3])) -cne 'ff744f63' -or + (Get-StackchanUInt32BigEndian $idx 4) -ne 2) { + throw "Unsupported Git pack index: $idxPath" + } + $idxPayloadLength = $idx.Length - 20 + if ((Get-StackchanSha1Hex ([byte[]]$idx[0..($idxPayloadLength - 1)])) -cne + (ConvertTo-StackchanLowerHex ([byte[]]$idx[$idxPayloadLength..($idx.Length - 1)]))) { + throw "Git pack index checksum mismatch: $idxPath" + } + $count = [int](Get-StackchanUInt32BigEndian $idx (8 + 255 * 4)) + $objectOffset = 8 + 256 * 4 + if ($objectOffset + $count * 20 + 40 -gt $idx.Length) { throw "Truncated Git pack index: $idxPath" } + for ($i = 0; $i -lt $count; $i++) { + $start = $objectOffset + $i * 20 + $id = ConvertTo-StackchanLowerHex ([byte[]]$idx[$start..($start + 19)]) + if (-not $objectIds.Add($id)) { throw "Duplicate Git object identity across packs: $id" } + } + $idxPackChecksum = ConvertTo-StackchanLowerHex ([byte[]]$idx[($idx.Length - 40)..($idx.Length - 21)]) + [byte[]]$pack = [IO.File]::ReadAllBytes($packPath) + if ($pack.Length -lt 32 -or [Text.Encoding]::ASCII.GetString($pack, 0, 4) -cne 'PACK') { + throw "Invalid Git pack: $packPath" + } + $packChecksum = ConvertTo-StackchanLowerHex ([byte[]]$pack[($pack.Length - 20)..($pack.Length - 1)]) + if ((Get-StackchanSha1Hex ([byte[]]$pack[0..($pack.Length - 21)])) -cne $packChecksum -or + $idxPackChecksum -cne $packChecksum -or $group.Name -cne "pack-$packChecksum") { + throw "Git pack content identity mismatch: $packPath" + } + [byte[]]$rev = [IO.File]::ReadAllBytes($revPath) + $expectedRevLength = 12 + 4 * $count + 40 + if ($rev.Length -ne $expectedRevLength -or + [Text.Encoding]::ASCII.GetString($rev, 0, 4) -cne 'RIDX' -or + (Get-StackchanUInt32BigEndian $rev 4) -ne 1 -or + (Get-StackchanUInt32BigEndian $rev 8) -ne 1) { + throw "Invalid Git reverse index: $revPath" + } + $seenPositions = [Collections.Generic.HashSet[uint32]]::new() + for ($i = 0; $i -lt $count; $i++) { + $position = Get-StackchanUInt32BigEndian $rev (12 + 4 * $i) + if ($position -ge $count -or -not $seenPositions.Add($position)) { + throw "Invalid Git reverse-index permutation: $revPath" + } + } + $revPackOffset = 12 + 4 * $count + $revPackChecksum = ConvertTo-StackchanLowerHex ([byte[]]$rev[$revPackOffset..($revPackOffset + 19)]) + $revChecksum = ConvertTo-StackchanLowerHex ([byte[]]$rev[($rev.Length - 20)..($rev.Length - 1)]) + if ($revPackChecksum -cne $packChecksum -or + (Get-StackchanSha1Hex ([byte[]]$rev[0..($rev.Length - 21)])) -cne $revChecksum) { + throw "Git reverse-index checksum mismatch: $revPath" + } + } + $orderedIds = [string[]]@($objectIds) + [Array]::Sort($orderedIds, [StringComparer]::Ordinal) + return "git-object-id-set-v1`n" + (($orderedIds | ForEach-Object { "$_`n" }) -join '') +} + +function Get-StackchanCanonicalGitLibraryRecords { + param( + [Parameter(Mandatory = $true)][string]$LibraryRoot, + [Parameter(Mandatory = $true)][string]$LibraryLeaf, + [Parameter(Mandatory = $true)][string]$ExpectedPackageName, + [Parameter(Mandatory = $true)][string]$ExpectedSourceUri, + [Parameter(Mandatory = $true)][string]$ExpectedCommit + ) + + if ($ExpectedPackageName -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or + $ExpectedSourceUri -notmatch '^git\+https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\.git(?:#[0-9a-f]{7,40})?$' -or + $ExpectedCommit -notmatch '^[0-9a-f]{40}$') { + throw "Invalid reviewed Git dependency policy: $LibraryLeaf" + } + + $gitRoot = Join-Path $LibraryRoot '.git' + $gitIdentity = Get-StackchanToolchainTreeIdentity -Root $gitRoot -IncludeRecords + $recordMap = @{} + foreach ($record in $gitIdentity.records) { $recordMap[[string]$record.relativePath] = $record } + foreach ($dangerous in @('objects/info/alternates', 'info/grafts')) { + if ($recordMap.ContainsKey($dangerous)) { throw "Dangerous Git metadata is forbidden: $LibraryLeaf/.git/$dangerous" } + } + foreach ($relative in $recordMap.Keys) { + if ($relative -match '^refs/replace/' -or + ($relative -match '^hooks/' -and $relative -notmatch '\.sample$') -or + ($relative -match '^objects/' -and $relative -notmatch '^objects/pack/')) { + throw "Unexpected executable or object Git state: $LibraryLeaf/.git/$relative" + } + } + $configPath = Join-Path $gitRoot 'config' + $configText = [IO.File]::ReadAllText($configPath) + if ($configText -match '(?im)^\s*\[(include|includeIf)\b' -or + $configText -match '(?im)^\s*(hooksPath|fsmonitor|sshCommand|worktree)\s*=') { + throw "Dangerous Git config is forbidden: $LibraryLeaf/.git/config" + } + if ($configText -notmatch ('(?m)^\s*url\s*=\s*' + + [regex]::Escape($ExpectedSourceUri.Substring(4).Split('#')[0]) + '\s*$')) { + throw "Git remote source does not match reviewed policy: $LibraryLeaf/.git/config" + } + $headText = [IO.File]::ReadAllText((Join-Path $gitRoot 'HEAD')).Trim() + if ($headText -notmatch '^ref: (?refs/heads/[A-Za-z0-9._/-]+)$') { + throw "Git dependency must have a symbolic branch HEAD: $LibraryLeaf" + } + $headRef = $Matches.ref + $headRefPath = Join-Path $gitRoot ($headRef -replace '/', [IO.Path]::DirectorySeparatorChar) + if (-not (Test-Path -LiteralPath $headRefPath -PathType Leaf)) { + throw "Git dependency HEAD ref is missing: $LibraryLeaf/$headRef" + } + $headCommit = [IO.File]::ReadAllText($headRefPath).Trim().ToLowerInvariant() + if ($headCommit -cne $ExpectedCommit) { throw "Git dependency commit does not match reviewed policy: $LibraryLeaf" } + + $piopmPath = Join-Path $gitRoot '.piopm' + $piopm = [IO.File]::ReadAllText($piopmPath) | ConvertFrom-Json + $topProperties = @($piopm.PSObject.Properties.Name | Sort-Object) + $specProperties = @($piopm.spec.PSObject.Properties.Name | Sort-Object) + if (($topProperties -join ',') -cne 'name,spec,type,version' -or + ($specProperties -join ',') -cne 'id,name,owner,requirements,uri' -or + [string]$piopm.type -cne 'library' -or + [string]$piopm.name -cne $ExpectedPackageName -or + [string]$piopm.spec.name -cne [string]$piopm.name -or + $null -ne $piopm.spec.id -or $null -ne $piopm.spec.owner -or + $null -ne $piopm.spec.requirements -or + [string]$piopm.spec.uri -cne $ExpectedSourceUri) { + throw "Unsafe PlatformIO Git package metadata: $LibraryLeaf/.git/.piopm" + } + $dynamicLibraryVersion = $null + if ([string]$piopm.version -match '^(?0\.0\.0\+(?[0-9]{14}))\.sha\.(?[0-9a-f]{7,40})$') { + $canonicalVersion = "0.0.0+INSTALL_TIMESTAMP.sha.$($Matches.sha)" + $dynamicLibraryVersion = $Matches.base + $commitPrefix = $Matches.sha + } elseif ([string]$piopm.version -match '^(?[0-9]+\.[0-9]+\.[0-9]+)\+sha\.(?[0-9a-f]{7,40})$') { + $canonicalVersion = "$($Matches.base)+sha.$($Matches.sha)" + $commitPrefix = $Matches.sha + } else { + throw "Unsupported PlatformIO Git package version: $LibraryLeaf/$($piopm.version)" + } + if (-not $headCommit.StartsWith($commitPrefix, [StringComparison]::Ordinal)) { + throw "PlatformIO Git metadata commit does not match HEAD: $LibraryLeaf" + } + if ([string]$piopm.spec.uri -match '#(?[0-9a-f]{7,40})$' -and + -not $headCommit.StartsWith($Matches.uriCommit, [StringComparison]::Ordinal)) { + throw "PlatformIO Git source URI commit does not match HEAD: $LibraryLeaf" + } + $piopmCanonical = @( + 'platformio-git-package-v1', + "name=$([string]$piopm.name)", + "version=$canonicalVersion", + "uri=$([string]$piopm.spec.uri)", + "headRef=$headRef", + "headCommit=$headCommit" + ) -join "`n" + $piopmCanonical += "`n" + + $canonicalRecords = [Collections.Generic.List[object]]::new() + $packHandled = $false + foreach ($relative in @($recordMap.Keys | Sort-Object)) { + $record = $recordMap[$relative] + $canonicalPath = "$LibraryLeaf/.git/$relative" + if ($relative -ceq '.piopm') { + $canonicalRecords.Add((New-StackchanCanonicalIdentityRecord ` + -RelativePath $canonicalPath -CanonicalText $piopmCanonical)) | Out-Null + } elseif ($relative -ceq 'index') { + $canonicalRecords.Add((New-StackchanCanonicalIdentityRecord -RelativePath $canonicalPath ` + -CanonicalText (Get-StackchanCanonicalGitIndexText (Join-Path $gitRoot 'index')))) | Out-Null + } elseif ($relative -match '^logs/') { + $canonicalRecords.Add((New-StackchanCanonicalIdentityRecord -RelativePath $canonicalPath ` + -CanonicalText (Get-StackchanCanonicalReflogText (Join-Path $gitRoot ($relative -replace '/', '\'))))) | Out-Null + } elseif ($relative -match '^objects/pack/') { + if (-not $packHandled) { + $canonicalRecords.Add((New-StackchanCanonicalIdentityRecord ` + -RelativePath "$LibraryLeaf/.git/objects/pack/@object-set" ` + -CanonicalText (Get-StackchanCanonicalGitPackText (Join-Path $gitRoot 'objects/pack')))) | Out-Null + $packHandled = $true + } + } else { + $canonicalRecords.Add([pscustomobject][ordered]@{ + relativePath = $canonicalPath + bytes = [long]$record.bytes + sha256 = [string]$record.sha256 + }) | Out-Null + } + } + if (-not $packHandled) { throw "Git dependency has no verified object pack: $LibraryLeaf" } + return [pscustomobject][ordered]@{ + records = @($canonicalRecords) + dynamicLibraryVersion = $dynamicLibraryVersion + packageName = [string]$piopm.name + headCommit = $headCommit + } +} + +function Get-StackchanCanonicalGitLibraryTreeIdentity { + param( + [Parameter(Mandatory = $true)][string]$LibraryRoot, + [Parameter(Mandatory = $true)][string]$LibraryLeaf, + [Parameter(Mandatory = $true)][string]$ExpectedPackageName, + [Parameter(Mandatory = $true)][string]$ExpectedSourceUri, + [Parameter(Mandatory = $true)][string]$ExpectedCommit, + [switch]$IncludeRecords + ) + + # Git metadata has a narrow canonical form, but the checked-out working tree + # remains an exact byte inventory. In particular, source and build scripts + # are never inferred from Git object IDs or omitted as "generated" inputs. + $rawTree = Get-StackchanToolchainTreeIdentity -Root $LibraryRoot -IncludeRecords + $gitIdentity = Get-StackchanCanonicalGitLibraryRecords ` + -LibraryRoot $LibraryRoot -LibraryLeaf $LibraryLeaf ` + -ExpectedPackageName $ExpectedPackageName ` + -ExpectedSourceUri $ExpectedSourceUri ` + -ExpectedCommit $ExpectedCommit + $canonicalRecords = [Collections.Generic.List[object]]::new() + foreach ($record in $gitIdentity.records) { $canonicalRecords.Add($record) | Out-Null } + foreach ($record in $rawTree.records) { + $relative = [string]$record.relativePath + if ($relative.StartsWith('.git/', [StringComparison]::Ordinal)) { continue } + if ($relative -ceq 'library.json' -and + -not [string]::IsNullOrWhiteSpace([string]$gitIdentity.dynamicLibraryVersion)) { + $metadata = [IO.File]::ReadAllText((Join-Path $LibraryRoot 'library.json')) | ConvertFrom-Json + $properties = @($metadata.PSObject.Properties.Name | Sort-Object) + if (($properties -join ',') -cne 'name,version' -or + [string]$metadata.name -cne [string]$gitIdentity.packageName -or + [string]$metadata.version -cne [string]$gitIdentity.dynamicLibraryVersion) { + throw "Dynamic PlatformIO library metadata does not match Git source identity: $LibraryLeaf/library.json" + } + $canonicalRecords.Add((New-StackchanCanonicalIdentityRecord ` + -RelativePath "$LibraryLeaf/library.json" ` + -CanonicalText "platformio-generated-library-v1`nname=$([string]$metadata.name)`nversion=0.0.0+INSTALL_TIMESTAMP`n")) | Out-Null + } else { + $canonicalRecords.Add([pscustomobject][ordered]@{ + relativePath = "$LibraryLeaf/$relative" + bytes = [long]$record.bytes + sha256 = [string]$record.sha256 + }) | Out-Null + } + } + $identity = Get-StackchanIdentityFromRecords -Records @($canonicalRecords) ` + -Schema $script:StackchanCanonicalGitLibrarySchema -IncludeRecords:$IncludeRecords + $identity | Add-Member -NotePropertyName rawFileCount -NotePropertyValue ([int]$rawTree.fileCount) + $identity | Add-Member -NotePropertyName rawBytes -NotePropertyValue ([long]$rawTree.bytes) + $identity | Add-Member -NotePropertyName headCommit -NotePropertyValue ([string]$gitIdentity.headCommit) + return $identity +} + +function Get-StackchanExpectedLibdepsPolicy { + param([Parameter(Mandatory = $true)][string]$Environment) + + $legacyRequirements = @( + 'M5GFX@0.2.24', + 'M5Stack/M5Unified@0.2.17', + 'https://github.com/mongonta0716/SCServo.git#ee6ee4a', + 'arminjo/ServoEasing@3.1.0', + 'https://github.com/stack-chan/stackchan-arduino.git#b7b98f5', + 'bblanchon/ArduinoJson@7.4.3', + 'tobozo/YAMLDuino@1.5.0', + 'robotis-git/Dynamixel2Arduino@0.7.0', + 'madhephaestus/ESP32Servo@0.13.0' + ) + if ($Environment -in @('stackchan', 'stackchan_servo_calibration')) { + return [pscustomobject][ordered]@{ + leaves = @( + 'ArduinoJson', 'Dynamixel2Arduino', 'ESP32Servo', 'M5GFX', 'M5Unified', + 'M5Unified@0.2.17', 'SCServo', + 'SCServo@src-8a1b26565e1a43aa7e250db85a311724', 'ServoEasing', + 'stackchan-arduino', 'YAMLDuino' + ) + requirements = $legacyRequirements + gitSources = @{ + 'SCServo' = [pscustomobject]@{ + packageName = 'SCServo' + uri = 'git+https://github.com/mongonta0716/SCServo.git#ee6ee4a' + commit = 'ee6ee4a014ed7068025637bf6a1da66c7b4153c3' + } + 'SCServo@src-8a1b26565e1a43aa7e250db85a311724' = [pscustomobject]@{ + packageName = 'SCServo' + uri = 'git+https://github.com/mongonta0716/SCServo.git' + commit = 'ee6ee4a014ed7068025637bf6a1da66c7b4153c3' + } + 'stackchan-arduino' = [pscustomobject]@{ + packageName = 'stackchan-arduino' + uri = 'git+https://github.com/stack-chan/stackchan-arduino.git#b7b98f5' + commit = 'b7b98f5b19c6cae581782fc127f1fa1274b035a8' + } + } + } + } + if ($Environment -ceq 'stackchan_release_full') { + return [pscustomobject][ordered]@{ + leaves = @( + 'ArduinoJson', 'esp-micro-speech-features', 'M5GFX', 'M5GFX@0.2.24', + 'M5Unified', 'SCServo', 'YAMLDuino' + ) + requirements = @( + 'bblanchon/ArduinoJson@7.4.3', + 'M5Stack/M5Unified@0.2.17', + 'tobozo/YAMLDuino@1.5.0', + 'M5GFX@0.2.24', + 'https://github.com/esphome-libs/esp-micro-speech-features.git#351c4c69530f5a802da5433581c4863afadf0a00', + 'https://github.com/mongonta0716/SCServo.git#ee6ee4a' + ) + gitSources = @{ + 'esp-micro-speech-features' = [pscustomobject]@{ + packageName = 'esp-micro-speech-features' + uri = 'git+https://github.com/esphome-libs/esp-micro-speech-features.git#351c4c69530f5a802da5433581c4863afadf0a00' + commit = '351c4c69530f5a802da5433581c4863afadf0a00' + } + 'SCServo' = [pscustomobject]@{ + packageName = 'SCServo' + uri = 'git+https://github.com/mongonta0716/SCServo.git#ee6ee4a' + commit = 'ee6ee4a014ed7068025637bf6a1da66c7b4153c3' + } + } + } + } + throw "No exact fresh-libdeps policy exists for environment: $Environment" +} + +function Get-StackchanCanonicalLibdepsIdentity { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Environment, + [switch]$IncludeRecords + ) + + $policy = Get-StackchanExpectedLibdepsPolicy -Environment $Environment + $rootItem = Get-Item -LiteralPath $Root -Force -ErrorAction Stop + if (-not $rootItem.PSIsContainer -or ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Canonical libdeps root is not a real directory: $Root" + } + $topItems = @(Get-ChildItem -LiteralPath $rootItem.FullName -Force -ErrorAction Stop) + $topFiles = @($topItems | Where-Object { -not $_.PSIsContainer }) + $topLeaves = [string[]]@($topItems | Where-Object PSIsContainer | ForEach-Object Name) + [Array]::Sort($topLeaves, [StringComparer]::Ordinal) + $expectedLeaves = [string[]]@($policy.leaves) + [Array]::Sort($expectedLeaves, [StringComparer]::Ordinal) + if ($topFiles.Count -ne 1 -or $topFiles[0].Name -cne 'integrity.dat' -or + ($topLeaves -join "`n") -cne ($expectedLeaves -join "`n")) { + throw "Libdeps tree is stale or has unexpected packages/files: $Environment" + } + $integrityLines = @([IO.File]::ReadAllLines($topFiles[0].FullName) | ForEach-Object { $_.Trim() }) + if ($integrityLines.Count -ne @($policy.requirements).Count -or + @($integrityLines | Where-Object { [string]::IsNullOrWhiteSpace($_) -or $_ -match '[\x00-\x1F\x7F]' }).Count -ne 0 -or + @($integrityLines | Sort-Object -Unique).Count -ne $integrityLines.Count) { + throw "Libdeps integrity inventory is malformed or duplicated: $Environment" + } + $actualRequirements = [string[]]$integrityLines + $expectedRequirements = [string[]]@($policy.requirements) + [Array]::Sort($actualRequirements, [StringComparer]::Ordinal) + [Array]::Sort($expectedRequirements, [StringComparer]::Ordinal) + if (($actualRequirements -join "`n") -cne ($expectedRequirements -join "`n")) { + throw "Libdeps integrity requirements do not match exact policy: $Environment" + } + + $canonicalRecords = [Collections.Generic.List[object]]::new() + $canonicalRecords.Add((New-StackchanCanonicalIdentityRecord -RelativePath 'integrity.dat' ` + -CanonicalText ("platformio-integrity-set-v1`n" + (($actualRequirements | ForEach-Object { "$_`n" }) -join '')))) | Out-Null + $rawFileCount = 1 + $rawBytes = [long]$topFiles[0].Length + foreach ($leaf in $topLeaves) { + $libraryRoot = Join-Path $rootItem.FullName $leaf + $hasGit = Test-Path -LiteralPath (Join-Path $libraryRoot '.git') -PathType Container + if ($hasGit) { + if (-not $policy.gitSources.ContainsKey($leaf)) { + throw "Unreviewed Git dependency appeared in libdeps: $Environment/$leaf" + } + $sourcePolicy = $policy.gitSources[$leaf] + $libraryTree = Get-StackchanCanonicalGitLibraryTreeIdentity ` + -LibraryRoot $libraryRoot -LibraryLeaf $leaf ` + -ExpectedPackageName ([string]$sourcePolicy.packageName) ` + -ExpectedSourceUri ([string]$sourcePolicy.uri) ` + -ExpectedCommit ([string]$sourcePolicy.commit) -IncludeRecords + foreach ($record in $libraryTree.records) { $canonicalRecords.Add($record) | Out-Null } + } else { + if ($policy.gitSources.ContainsKey($leaf)) { + throw "Reviewed Git dependency lost its Git source evidence: $Environment/$leaf" + } + $libraryTree = Get-StackchanToolchainTreeIdentity -Root $libraryRoot -IncludeRecords + foreach ($record in $libraryTree.records) { + $canonicalRecords.Add([pscustomobject][ordered]@{ + relativePath = "$leaf/$([string]$record.relativePath)" + bytes = [long]$record.bytes + sha256 = [string]$record.sha256 + }) | Out-Null + } + } + if ($null -ne $libraryTree.PSObject.Properties['rawFileCount']) { + $rawFileCount += [int]$libraryTree.rawFileCount + } else { + $rawFileCount += [int]$libraryTree.fileCount + } + $rawBytes += if ($null -ne $libraryTree.PSObject.Properties['rawBytes']) { + [long]$libraryTree.rawBytes + } else { + [long]$libraryTree.bytes + } + } + $identity = Get-StackchanIdentityFromRecords -Records @($canonicalRecords) ` + -Schema $script:StackchanCanonicalLibdepsSchema -IncludeRecords:$IncludeRecords + $identity | Add-Member -NotePropertyName rawFileCount -NotePropertyValue $rawFileCount + $identity | Add-Member -NotePropertyName rawBytes -NotePropertyValue $rawBytes + return $identity +} + +function Get-StackchanReleaseToolchainComponentPolicy { + param([Parameter(Mandatory = $true)][string]$PlatformKey) + + if ($PlatformKey -cne 'windows_amd64') { + throw "No reviewed release toolchain component policy exists for platform: $PlatformKey" + } + + $components = [System.Collections.Generic.List[object]]::new() + function Add-PolicyComponent { + param( + [string]$Name, + [string]$Phase, + [string]$RootKey, + [string]$RelativePath + ) + $components.Add([pscustomobject][ordered]@{ + name = $Name + phase = $Phase + rootKey = $RootKey + relativePath = $RelativePath + }) | Out-Null + } + + # Hash the entire Python installation as one closed root, including + # python312.zip, DLLs, Lib, site-packages, and every Scripts launcher. + Add-PolicyComponent 'python-installation' 'preBuild' 'pythonHome' '@root' + + Add-PolicyComponent 'legacy-core-penv' 'preBuild' 'legacyCore' 'penv' + Add-PolicyComponent 'legacy-platform-espressif32-7.0.1' 'preBuild' 'legacyCore' 'platforms/espressif32@7.0.1' + foreach ($package in @( + 'framework-arduinoespressif32', 'toolchain-riscv32-esp', + 'toolchain-xtensa-esp32s3', 'tool-esptoolpy', 'tool-mkfatfs', + 'tool-mklittlefs', 'tool-mkspiffs')) { + Add-PolicyComponent "legacy-package-$package" 'preBuild' 'legacyCore' "packages/$package" + } + + Add-PolicyComponent 'release-core-penv' 'preBuild' 'releaseCore' 'penv' + Add-PolicyComponent 'release-platform-espressif32' 'preBuild' 'releaseCore' 'platforms/espressif32' + foreach ($package in @( + 'contrib-piohome', 'framework-arduinoespressif32', + 'framework-arduinoespressif32-libs', 'toolchain-xtensa-esp-elf', + 'tool-esptoolpy', 'tool-scons')) { + Add-PolicyComponent "release-package-$package" 'preBuild' 'releaseCore' "packages/$package" + } + + foreach ($environment in @('stackchan', 'stackchan_servo_calibration', 'stackchan_release_full')) { + Add-PolicyComponent "project-libdeps-$environment" 'postBuild' 'projectRoot' ".pio/libdeps/$environment" + } + return @($components) +} + +function Resolve-StackchanIdentityComponentPath { + param( + [Parameter(Mandatory = $true)][hashtable]$RootMap, + [Parameter(Mandatory = $true)]$Component + ) + + $rootKey = [string]$Component.rootKey + if (-not $RootMap.ContainsKey($rootKey) -or + [string]::IsNullOrWhiteSpace([string]$RootMap[$rootKey])) { + throw "Release toolchain identity root map is missing: $rootKey" + } + $rootItem = Get-Item -LiteralPath ([string]$RootMap[$rootKey]) -Force -ErrorAction Stop + if (-not $rootItem.PSIsContainer -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Release toolchain identity root is not a real directory: $rootKey" + } + $root = $rootItem.FullName.TrimEnd('\', '/') + $relative = ConvertTo-StackchanSafeIdentityRelativePath ([string]$Component.relativePath) + if ($relative -ceq '@root') { return $root } + $candidate = [IO.Path]::GetFullPath((Join-Path $root ($relative -replace '/', [IO.Path]::DirectorySeparatorChar))) + $comparison = if ($env:OS -eq 'Windows_NT') { + [StringComparison]::OrdinalIgnoreCase + } else { + [StringComparison]::Ordinal + } + if (-not $candidate.StartsWith($root + [IO.Path]::DirectorySeparatorChar, $comparison)) { + throw "Toolchain identity component escaped root $rootKey`: $relative" + } + return $candidate +} + +function Get-StackchanReleaseToolchainObservedComponents { + param( + [Parameter(Mandatory = $true)][hashtable]$RootMap, + [Parameter(Mandatory = $true)][ValidateSet('PreBuild', 'PostBuild')][string]$Phase, + [string]$PlatformKey = (Get-StackchanReleaseToolchainPlatformKey) + ) + + if ($Phase -ceq 'PostBuild') { + throw 'PostBuild toolchain eligibility is disabled: canonical libdeps analysis does not yet prove Git pack object-to-offset mappings with an independently trusted Git/runtime, and fresh evidence does not yet cover all three environments.' + } + + $policy = @(Get-StackchanReleaseToolchainComponentPolicy -PlatformKey $PlatformKey) + $selected = @($policy | Where-Object { + [string]$_.phase -ceq 'preBuild' -or $Phase -ceq 'PostBuild' + }) + $observed = [System.Collections.Generic.List[object]]::new() + foreach ($component in $selected) { + $path = Resolve-StackchanIdentityComponentPath -RootMap $RootMap -Component $component + if ([string]$component.phase -ceq 'postBuild') { + $environment = ([string]$component.name).Substring('project-libdeps-'.Length) + $identity = Get-StackchanCanonicalLibdepsIdentity -Root $path -Environment $environment + } elseif (Test-Path -LiteralPath $path -PathType Leaf) { + $leaf = Split-Path -Leaf $path + $sha256 = Get-StackchanFileSha256 -LiteralPath $path + $length = [long](Get-Item -LiteralPath $path -Force).Length + $recordText = "$script:StackchanToolchainInventorySchema`nF`0$leaf`0$length`0$sha256`n" + $recordHasher = [Security.Cryptography.SHA256]::Create() + try { + $treeHash = ([BitConverter]::ToString( + $recordHasher.ComputeHash([Text.Encoding]::UTF8.GetBytes($recordText))) -replace '-', '').ToUpperInvariant() + } finally { + $recordHasher.Dispose() + } + $identity = [pscustomobject][ordered]@{ + schema = $script:StackchanToolchainInventorySchema + treeSha256 = $treeHash + fileCount = 1 + bytes = $length + } + } elseif (Test-Path -LiteralPath $path -PathType Container) { + $identity = Get-StackchanToolchainTreeIdentity -Root $path + } else { + throw "Required release toolchain component is missing: $($component.name) ($path)" + } + $observed.Add([pscustomobject][ordered]@{ + name = [string]$component.name + phase = [string]$component.phase + identitySchema = [string]$identity.schema + treeSha256 = [string]$identity.treeSha256 + fileCount = [int]$identity.fileCount + bytes = [long]$identity.bytes + }) | Out-Null + } + return @($observed) +} + +function New-StackchanReleaseToolchainIdentityCandidate { + param( + [Parameter(Mandatory = $true)][hashtable]$RootMap, + [Parameter(Mandatory = $true)][string]$PlatformioExecutable, + [Parameter(Mandatory = $true)][string]$PythonExecutable, + [string]$PlatformKey = (Get-StackchanReleaseToolchainPlatformKey) + ) + + $pythonHome = (Get-Item -LiteralPath ([string]$RootMap.pythonHome) -Force -ErrorAction Stop).FullName.TrimEnd('\', '/') + $expectedPio = @('Scripts/pio.exe', 'Scripts/platformio.exe') | ForEach-Object { + [IO.Path]::GetFullPath((Join-Path $pythonHome $_)) + } + $resolvedPio = (Get-Item -LiteralPath $PlatformioExecutable -Force -ErrorAction Stop).FullName + $resolvedPython = (Get-Item -LiteralPath $PythonExecutable -Force -ErrorAction Stop).FullName + $comparison = if ($env:OS -eq 'Windows_NT') { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } + if (@($expectedPio | Where-Object { $_.Equals($resolvedPio, $comparison) }).Count -ne 1) { + throw 'PlatformIO executable is outside the reviewed Python installation.' + } + $expectedPython = [IO.Path]::GetFullPath((Join-Path $pythonHome 'python.exe')) + if (-not $expectedPython.Equals($resolvedPython, $comparison)) { + throw 'Python executable is outside the reviewed Python installation.' + } + Assert-StackchanPythonImportIsolation ` + -PythonHome $pythonHome -PythonExecutable $resolvedPython + $components = @(Get-StackchanReleaseToolchainObservedComponents ` + -RootMap $RootMap -Phase PostBuild -PlatformKey $PlatformKey) + return [pscustomobject][ordered]@{ + schema = $script:StackchanToolchainIdentitySchema + platformKey = $PlatformKey + platformioCoreVersion = '6.1.19' + pythonVersion = '3.12.10' + identityScope = 'exact-host-installed-bytes' + portableAcrossHosts = $false + canonicalLibdepsSchema = $script:StackchanCanonicalLibdepsSchema + platformioExecutableRelativePaths = @('Scripts/pio.exe', 'Scripts/platformio.exe') + pythonExecutableRelativePath = 'python.exe' + generatedUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + review = [ordered]@{ + status = 'candidate-unreviewed' + instructions = 'Review package sources, component inventory, and hashes; then set status=reviewed and record reviewer/reason in the committed allowlist.' + } + components = $components + } +} + +function Assert-StackchanReleaseToolchainIdentity { + param( + [Parameter(Mandatory = $true)][string]$AllowlistPath, + [Parameter(Mandatory = $true)][hashtable]$RootMap, + [Parameter(Mandatory = $true)][string]$PlatformioExecutable, + [Parameter(Mandatory = $true)][string]$PythonExecutable, + [Parameter(Mandatory = $true)][ValidateSet('PreBuild', 'PostBuild')][string]$Phase, + [string]$PlatformKey = (Get-StackchanReleaseToolchainPlatformKey) + ) + + $allowlist = Get-Content -LiteralPath $AllowlistPath -Raw -ErrorAction Stop | ConvertFrom-Json + if ([string]$allowlist.schema -cne $script:StackchanToolchainIdentitySchema -or + [string]$allowlist.platformKey -cne $PlatformKey -or + [string]$allowlist.review.status -cne 'reviewed' -or + [string]::IsNullOrWhiteSpace([string]$allowlist.review.reviewer) -or + [string]::IsNullOrWhiteSpace([string]$allowlist.review.reason)) { + throw "Release toolchain allowlist is absent, unreviewed, or for another platform: $PlatformKey" + } + if ([string]$allowlist.platformioCoreVersion -cne '6.1.19' -or + [string]$allowlist.pythonVersion -cne '3.12.10' -or + [string]$allowlist.identityScope -cne 'exact-host-installed-bytes' -or + [bool]$allowlist.portableAcrossHosts -or + [string]$allowlist.canonicalLibdepsSchema -cne $script:StackchanCanonicalLibdepsSchema) { + throw 'Release toolchain allowlist version policy mismatch.' + } + $canonicalLaunchers = @('Scripts/pio.exe', 'Scripts/platformio.exe') + $allowlistedLaunchers = @($allowlist.platformioExecutableRelativePaths) + if ($allowlistedLaunchers.Count -ne $canonicalLaunchers.Count -or + ($allowlistedLaunchers -join "`n") -cne ($canonicalLaunchers -join "`n") -or + [string]$allowlist.pythonExecutableRelativePath -cne 'python.exe') { + throw 'Release toolchain allowlist executable paths are not the canonical policy.' + } + + $pythonHome = (Get-Item -LiteralPath ([string]$RootMap.pythonHome) -Force -ErrorAction Stop).FullName.TrimEnd('\', '/') + $comparison = if ($env:OS -eq 'Windows_NT') { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } + $resolvedPio = (Get-Item -LiteralPath $PlatformioExecutable -Force -ErrorAction Stop).FullName + $expectedPio = @($allowlist.platformioExecutableRelativePaths | ForEach-Object { + $relative = ConvertTo-StackchanSafeIdentityRelativePath ([string]$_) + [IO.Path]::GetFullPath((Join-Path $pythonHome ($relative -replace '/', [IO.Path]::DirectorySeparatorChar))) + }) + if (@($expectedPio | Where-Object { $_.Equals($resolvedPio, $comparison) }).Count -ne 1) { + throw 'Selected PlatformIO executable is not one of the byte-allowlisted launchers.' + } + $pythonRelative = ConvertTo-StackchanSafeIdentityRelativePath ([string]$allowlist.pythonExecutableRelativePath) + $expectedPython = [IO.Path]::GetFullPath((Join-Path $pythonHome $pythonRelative)) + $resolvedPython = (Get-Item -LiteralPath $PythonExecutable -Force -ErrorAction Stop).FullName + if (-not $expectedPython.Equals($resolvedPython, $comparison)) { + throw 'Selected Python executable is not the byte-allowlisted runtime.' + } + + Assert-StackchanPythonImportIsolation ` + -PythonHome $pythonHome -PythonExecutable $resolvedPython + + $observed = @(Get-StackchanReleaseToolchainObservedComponents ` + -RootMap $RootMap -Phase $Phase -PlatformKey $PlatformKey) + $expected = @($allowlist.components | Where-Object { + [string]$_.phase -ceq 'preBuild' -or $Phase -ceq 'PostBuild' + }) + if ($expected.Count -ne $observed.Count) { + throw "Release toolchain allowlist component count mismatch for phase $Phase." + } + $expectedNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($entry in $expected) { + $name = [string]$entry.name + if ([string]::IsNullOrWhiteSpace($name) -or -not $expectedNames.Add($name)) { + throw "Release toolchain allowlist has an invalid or duplicate component: $name" + } + $matches = @($observed | Where-Object { [string]$_.name -ceq $name }) + if ($matches.Count -ne 1 -or + [string]$entry.phase -cne [string]$matches[0].phase -or + [string]$entry.identitySchema -cne [string]$matches[0].identitySchema -or + [string]$entry.treeSha256 -cne [string]$matches[0].treeSha256 -or + [int]$entry.fileCount -ne [int]$matches[0].fileCount -or + [long]$entry.bytes -ne [long]$matches[0].bytes) { + throw "Release toolchain byte identity mismatch: $name" + } + } + return [pscustomobject][ordered]@{ + schema = $script:StackchanToolchainIdentitySchema + status = 'verified' + platformKey = $PlatformKey + phase = $Phase + componentCount = $observed.Count + } +} diff --git a/tools/release_zip_safety.ps1 b/tools/release_zip_safety.ps1 new file mode 100644 index 00000000..afb4e9ec --- /dev/null +++ b/tools/release_zip_safety.ps1 @@ -0,0 +1,106 @@ +function Invoke-StackchanReleaseZipInspection { + param( + [Parameter(Mandatory = $true)][string]$ZipPath, + [Parameter(Mandatory = $true)][string]$ExtractionRoot, + [switch]$Extract + ) + + if (-not (Test-Path -LiteralPath $ZipPath -PathType Leaf)) { + throw "Missing release ZIP: $ZipPath" + } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zipArchive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $ZipPath).Path) + try { + $zipNames = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + $resolvedExtractionRoot = [System.IO.Path]::GetFullPath($ExtractionRoot).TrimEnd('\', '/') + $extractionPrefix = $resolvedExtractionRoot + [System.IO.Path]::DirectorySeparatorChar + $safeEntries = New-Object System.Collections.Generic.List[object] + [long]$totalUncompressedBytes = 0 + foreach ($entry in $zipArchive.Entries) { + $rawEntryName = ([string]$entry.FullName).Replace('\', '/') + $entryName = $rawEntryName + while ($entryName.StartsWith('./', [System.StringComparison]::Ordinal)) { + $entryName = $entryName.Substring(2) + } + if ([string]::IsNullOrWhiteSpace($entryName) -and $rawEntryName -eq './') { continue } + $segments = @($entryName.Split('/') | Where-Object { $_ -ne '' }) + $entryTarget = [System.IO.Path]::GetFullPath((Join-Path $ExtractionRoot ($entryName -replace '/', '\'))) + $unixFileType = (([int64]$entry.ExternalAttributes -shr 16) -band 0xF000) + if ([string]::IsNullOrWhiteSpace($entryName) -or + [System.IO.Path]::IsPathRooted($entryName) -or + $entryName.Contains(':') -or + $segments -contains '.' -or $segments -contains '..' -or + -not $entryTarget.StartsWith($extractionPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or + -not $zipNames.Add($entryName.TrimEnd('/')) -or + $unixFileType -eq 0xA000 -or + (([int]$entry.ExternalAttributes -band [int][System.IO.FileAttributes]::ReparsePoint) -ne 0)) { + throw "Release ZIP contains an unsafe, duplicate, or link entry: $rawEntryName" + } + $totalUncompressedBytes += [long]$entry.Length + if ($zipNames.Count -gt 25000 -or + [long]$entry.Length -gt 2147483648 -or + $totalUncompressedBytes -gt 4294967296) { + throw "Release ZIP exceeds the bounded extraction budget." + } + $safeEntries.Add([pscustomobject]@{ + Entry = $entry + Name = $entryName + Target = $entryTarget + IsDirectory = $entryName.EndsWith('/', [System.StringComparison]::Ordinal) + }) + } + + if ($Extract) { + if (Test-Path -LiteralPath $resolvedExtractionRoot) { + if (-not (Test-Path -LiteralPath $resolvedExtractionRoot -PathType Container) -or + @(Get-ChildItem -LiteralPath $resolvedExtractionRoot -Force).Count -ne 0) { + throw "Safe release ZIP extraction requires a fresh empty directory: $resolvedExtractionRoot" + } + } else { + New-Item -ItemType Directory -Path $resolvedExtractionRoot | Out-Null + } + foreach ($safeEntry in $safeEntries) { + if ($safeEntry.IsDirectory) { + New-Item -ItemType Directory -Force -Path $safeEntry.Target | Out-Null + continue + } + $parent = Split-Path -Parent $safeEntry.Target + New-Item -ItemType Directory -Force -Path $parent | Out-Null + $source = $safeEntry.Entry.Open() + $destination = $null + try { + $destination = [System.IO.File]::Open( + $safeEntry.Target, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None) + $source.CopyTo($destination) + } finally { + if ($null -ne $destination) { $destination.Dispose() } + $source.Dispose() + } + } + } + } finally { + $zipArchive.Dispose() + } +} + +function Assert-StackchanReleaseZipEntriesSafe { + param( + [Parameter(Mandatory = $true)][string]$ZipPath, + [Parameter(Mandatory = $true)][string]$ExtractionRoot + ) + + Invoke-StackchanReleaseZipInspection -ZipPath $ZipPath -ExtractionRoot $ExtractionRoot +} + +function Expand-StackchanReleaseZipSafely { + param( + [Parameter(Mandatory = $true)][string]$ZipPath, + [Parameter(Mandatory = $true)][string]$DestinationPath + ) + + Invoke-StackchanReleaseZipInspection ` + -ZipPath $ZipPath -ExtractionRoot $DestinationPath -Extract +} diff --git a/tools/run_device_preflight.ps1 b/tools/run_device_preflight.ps1 index 95b7920f..420c2451 100644 --- a/tools/run_device_preflight.ps1 +++ b/tools/run_device_preflight.ps1 @@ -9,6 +9,39 @@ param( $ErrorActionPreference = "Stop" $physicalRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { + if ([string]::IsNullOrWhiteSpace($Version)) { + $zipName = [System.IO.Path]::GetFileName($PackageZip) + if ($zipName -notmatch "^stackchan_alive_(.+)\.zip$") { + throw "Pass -Version when -PackageZip does not match stackchan_alive_.zip" + } + $Version = $Matches[1] + } + if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { + $ExpectedCommit = (& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + -C $physicalRepoRoot rev-parse HEAD).Trim() + } + if ($ExpectedCommit -notmatch "^[0-9a-fA-F]{40}$") { + throw "Pass a 40-hex -ExpectedCommit or run from a trusted Git checkout." + } + if (-not (Test-Path -LiteralPath $PackageZip -PathType Leaf)) { + throw "Missing package ZIP: $PackageZip" + } + $PackageZip = (Resolve-Path -LiteralPath $PackageZip).Path + $earlyVerifyArgs = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", + (Join-Path $PSScriptRoot "verify_release_package.ps1"), + "-Version", $Version, "-ZipPath", $PackageZip, + "-ExpectedCommit", $ExpectedCommit, "-RequireReleaseEligible" + ) + if ($AllowDirty) { $earlyVerifyArgs += "-AllowDirtyPackage" } + & powershell.exe @earlyVerifyArgs + if ($LASTEXITCODE -ne 0) { + throw "Operational release ZIP verification failed before device-preflight helpers or reports." + } +} + if ( $env:OS -eq "Windows_NT" -and -not $env:STACKCHAN_PREFLIGHT_SHORT_PATH_ACTIVE -and @@ -2264,9 +2297,11 @@ if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { Invoke-Step "Verify release package" { $verifyScript = Join-Path $PSScriptRoot "verify_release_package.ps1" if ($AllowDirty) { - & $verifyScript -Version $Version -ZipPath $PackageZip -ExpectedCommit $ExpectedCommit -AllowDirtyPackage + & $verifyScript -Version $Version -ZipPath $PackageZip -ExpectedCommit $ExpectedCommit ` + -AllowDirtyPackage -RequireReleaseEligible } else { - & $verifyScript -Version $Version -ZipPath $PackageZip -ExpectedCommit $ExpectedCommit + & $verifyScript -Version $Version -ZipPath $PackageZip -ExpectedCommit $ExpectedCommit ` + -RequireReleaseEligible } } diff --git a/tools/share_release.cmd b/tools/share_release.cmd index 17b10e9d..8854142a 100644 --- a/tools/share_release.cmd +++ b/tools/share_release.cmd @@ -1,2 +1,8 @@ @echo off -powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0share_release.ps1" %* +setlocal +set "STACKCHAN_SHARE_POWERSHELL=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" +if not exist "%STACKCHAN_SHARE_POWERSHELL%" ( + echo Exact Windows PowerShell executable not found. 1>&2 + exit /b 1 +) +"%STACKCHAN_SHARE_POWERSHELL%" -NoProfile -ExecutionPolicy Bypass -File "%~dp0share_release.ps1" %* diff --git a/tools/share_release.ps1 b/tools/share_release.ps1 index f2f8b221..d0fb542e 100644 --- a/tools/share_release.ps1 +++ b/tools/share_release.ps1 @@ -1,5 +1,6 @@ param( [string]$Version, + [string]$ExpectedCommit = "", [int]$Port = 8787, [string]$BindAddress = "127.0.0.1", [switch]$Lan, @@ -15,15 +16,90 @@ param( $ErrorActionPreference = "Stop" -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") -Set-Location $repoRoot +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$shareRepoRoot = $repoRoot +$shareGitCommand = Get-Command -Name git -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 +if ($null -eq $shareGitCommand) { + throw 'Release sharing requires a Git application executable; functions, aliases, and scripts are refused.' +} +$shareGitExecutable = (Resolve-Path -LiteralPath ([string]$shareGitCommand.Source)).Path +$sharePowerShellCommand = Get-Command -Name powershell.exe -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 +if ($null -eq $sharePowerShellCommand) { + throw 'Release sharing requires a Windows PowerShell application executable; functions, aliases, and scripts are refused.' +} +$sharePowerShellExecutable = ( + Resolve-Path -LiteralPath ([string]$sharePowerShellCommand.Source)).Path +$shareGitDisabledHooksPath = Join-Path $repoRoot ( + "output/private/disabled-share-git-hooks-$PID-" + [guid]::NewGuid().ToString('N')) +$shareNullAttributesPath = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } +if (Test-Path -LiteralPath $shareGitDisabledHooksPath) { + throw "Share Git disabled-hooks sentinel unexpectedly exists: $shareGitDisabledHooksPath" +} + +function Invoke-ShareTrustedGit { + param([Parameter(Mandatory = $true)][string[]]$Arguments) + + if (Test-Path -LiteralPath $script:shareGitDisabledHooksPath) { + throw "Share Git disabled-hooks path must not exist: $script:shareGitDisabledHooksPath" + } + $gitTrustArguments = @( + '-c', "core.hooksPath=$script:shareGitDisabledHooksPath", + '-c', 'core.fsmonitor=false', + '-c', 'core.untrackedCache=false', + '-c', 'core.useBuiltinFSMonitor=false', + '-c', 'maintenance.auto=false', + '-c', 'core.autocrlf=true', + '-c', "core.attributesFile=$script:shareNullAttributesPath", + '-c', 'filter.lfs.process=', + '-c', 'filter.lfs.clean=', + '-c', 'filter.lfs.smudge=', + '-c', 'filter.lfs.required=false', + '-C', $script:shareRepoRoot + ) + $gitTrustArguments += $Arguments + + $previousNoReplaceObjects = $env:GIT_NO_REPLACE_OBJECTS + $previousNoSystemAttributes = $env:GIT_ATTR_NOSYSTEM + try { + [Environment]::SetEnvironmentVariable( + 'GIT_NO_REPLACE_OBJECTS', '1', [EnvironmentVariableTarget]::Process) + [Environment]::SetEnvironmentVariable( + 'GIT_ATTR_NOSYSTEM', '1', [EnvironmentVariableTarget]::Process) + & $script:shareGitExecutable @gitTrustArguments + } finally { + [Environment]::SetEnvironmentVariable( + 'GIT_NO_REPLACE_OBJECTS', $previousNoReplaceObjects, [EnvironmentVariableTarget]::Process) + [Environment]::SetEnvironmentVariable( + 'GIT_ATTR_NOSYSTEM', $previousNoSystemAttributes, [EnvironmentVariableTarget]::Process) + } +} + +function Assert-SafeReleaseVersionLeaf { + param([Parameter(Mandatory = $true)][string]$Value) + + if ($Value.Length -gt 128 -or + $Value -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or + $Value -in @('.', '..') -or + $Value.EndsWith('.', [System.StringComparison]::Ordinal)) { + throw "Version must be one safe filename component containing only letters, digits, '.', '_', or '-'." + } +} + +function Get-SafeReleaseChildPath { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$RelativePath + ) -function Assert-Command { - param([string]$Name) - $command = Get-Command $Name -ErrorAction SilentlyContinue - if ($null -eq $command) { - throw "Required command is not available on PATH: $Name" + $resolvedRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + $resolvedChild = [System.IO.Path]::GetFullPath((Join-Path $resolvedRoot $RelativePath)) + $expectedPrefix = $resolvedRoot + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolvedChild.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Release path escapes its trusted root: $resolvedChild" } + return $resolvedChild } function Assert-File { @@ -48,7 +124,7 @@ function Invoke-GitText { param([string[]]$Arguments) try { - $output = & git @Arguments 2>$null + $output = Invoke-ShareTrustedGit -Arguments $Arguments 2>$null } catch { return "" } @@ -58,6 +134,80 @@ function Invoke-GitText { return ($output | Out-String).Trim() } +function Invoke-OperationalPackageVerification { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$ZipPath, + [Parameter(Mandatory = $true)][string]$ExpectedCommit + ) + + & (Join-Path $PSScriptRoot "verify_release_package.ps1") ` + -Version $Version ` + -ZipPath $ZipPath ` + -ExpectedCommit $ExpectedCommit ` + -RequireReleaseEligible + if ($LASTEXITCODE -ne 0) { + throw "Operational release package verification failed with exit code $LASTEXITCODE." + } +} + +function New-VerifiedShareSnapshot { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$ZipPath, + [Parameter(Mandatory = $true)][string]$ZipSidecarPath, + [Parameter(Mandatory = $true)][string]$ExpectedCommit + ) + + $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\', '/') + $snapshotRoot = Join-Path $tempBase ("stackchan-share-$PID-" + [guid]::NewGuid().ToString('N')) + $snapshotZip = Join-Path $snapshotRoot "stackchan_alive_$Version.zip" + $snapshotSidecar = "$snapshotZip.sha256" + $snapshotPackage = Join-Path $snapshotRoot "package" + try { + New-Item -ItemType Directory -Path $snapshotRoot | Out-Null + Copy-Item -LiteralPath $ZipPath -Destination $snapshotZip + Copy-Item -LiteralPath $ZipSidecarPath -Destination $snapshotSidecar + Invoke-OperationalPackageVerification ` + -Version $Version ` + -ZipPath $snapshotZip ` + -ExpectedCommit $ExpectedCommit | Out-Host + Expand-StackchanReleaseZipSafely -ZipPath $snapshotZip -DestinationPath $snapshotPackage + return [pscustomobject]@{ + Root = $snapshotRoot + ZipPath = $snapshotZip + ZipSidecarPath = $snapshotSidecar + PackageRoot = $snapshotPackage + } + } catch { + if (Test-Path -LiteralPath $snapshotRoot) { + $resolvedSnapshot = (Resolve-Path -LiteralPath $snapshotRoot).Path + $tempPrefix = $tempBase + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolvedSnapshot.StartsWith($tempPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clean unexpected share snapshot: $resolvedSnapshot" + } + Remove-Item -LiteralPath $resolvedSnapshot -Recurse -Force + } + throw + } +} + +function Remove-VerifiedShareSnapshot { + param([Parameter(Mandatory = $true)][string]$SnapshotRoot) + + if (-not (Test-Path -LiteralPath $SnapshotRoot)) { + return + } + $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\', '/') + $resolvedSnapshot = (Resolve-Path -LiteralPath $SnapshotRoot).Path + $tempPrefix = $tempBase + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolvedSnapshot.StartsWith($tempPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or + (Split-Path -Leaf $resolvedSnapshot) -notmatch '^stackchan-share-[0-9]+-[0-9a-f]{32}$') { + throw "Refusing to clean unexpected share snapshot: $resolvedSnapshot" + } + Remove-Item -LiteralPath $resolvedSnapshot -Recurse -Force +} + function Write-ShareStatus { param( [string]$Status, @@ -524,10 +674,9 @@ function Write-StopHelper { param([int[]]$ProcessIds) $stopScript = Join-Path $PSScriptRoot "stop_share.ps1" - $stopCommand = "& '$stopScript' -ShareRoot '$shareRoot'" @( "@echo off", - "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command `"$stopCommand`"" + "`"$script:sharePowerShellExecutable`" -NoProfile -ExecutionPolicy Bypass -File `"$stopScript`" -ShareRoot `"$shareRoot`"" ) | Set-Content -Path (Join-Path $shareRoot "STOP_SHARING.cmd") -Encoding ASCII if ($ProcessIds.Count -gt 0) { @@ -576,7 +725,8 @@ function Stop-ExistingShare { } try { - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $stopScript -ShareRoot $ExistingShareRoot | Out-Null + & $script:sharePowerShellExecutable -NoProfile -ExecutionPolicy Bypass ` + -File $stopScript -ShareRoot $ExistingShareRoot | Out-Null } catch { Write-Warning "Existing share stop helper reported a problem: $($_.Exception.Message)" } @@ -728,52 +878,74 @@ function Find-AvailableTcpPort { throw "No available TCP port found for $Address between $StartPort and $maxPort." } -$rootManifest = Get-ReleaseManifest $repoRoot - if ([string]::IsNullOrWhiteSpace($Version)) { - if ($null -ne $rootManifest) { - $Version = [string]$rootManifest.version - } else { - $Version = Invoke-GitText @("describe", "--tags", "--always", "--dirty") - } + $Version = Invoke-GitText @("describe", "--tags", "--always", "--dirty") } if ([string]::IsNullOrWhiteSpace($Version)) { - throw "Version is required when it cannot be inferred from git or release_manifest.json." + throw "Version is required when it cannot be inferred from the trusted source checkout." } - -if ($Lan) { - $BindAddress = "0.0.0.0" +Assert-SafeReleaseVersionLeaf -Value $Version +if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { + $ExpectedCommit = Invoke-GitText @("rev-parse", "HEAD") } +if ($ExpectedCommit -notmatch '^[0-9a-fA-F]{40}$') { + throw "ExpectedCommit must be a full 40-character hexadecimal Git commit." +} +$ExpectedCommit = $ExpectedCommit.ToLowerInvariant() -Assert-BindAddressAvailable -Address $BindAddress -$script:ShareProbeUrl = if ($BindAddress -eq "0.0.0.0") { "http://127.0.0.1`:$Port/" } else { "http://$BindAddress`:$Port/" } -$script:ShareLanUrls = @() - -$shareRoot = Join-Path $repoRoot "output/share/$Version" -if (Test-Path -LiteralPath $shareRoot) { - Stop-ExistingShare -ExistingShareRoot $shareRoot - Remove-ShareRoot -ExistingShareRoot $shareRoot +$releaseOutputRoot = Get-SafeReleaseChildPath -Root $repoRoot -RelativePath "output/release" +$sourceZipPath = Get-SafeReleaseChildPath -Root $releaseOutputRoot -RelativePath "stackchan_alive_$Version.zip" +$sourceZipSidecarPath = Get-SafeReleaseChildPath -Root $releaseOutputRoot -RelativePath "stackchan_alive_$Version.zip.sha256" +if (-not (Test-Path -LiteralPath $sourceZipPath -PathType Leaf)) { + throw "Missing release ZIP: $sourceZipPath" } -New-Item -ItemType Directory -Force -Path $shareRoot | Out-Null - -if ($null -ne $rootManifest) { - $packageRoot = $repoRoot - $zipPath = Join-Path $shareRoot "stackchan_alive_$Version.zip" - $zipItems = Get-ChildItem -LiteralPath $packageRoot -Force | - Where-Object { $_.Name -ne "output" } | - Select-Object -ExpandProperty FullName - Compress-Archive -LiteralPath $zipItems -DestinationPath $zipPath -Force -} else { - $packageRoot = Join-Path $repoRoot "output/release/$Version" - $zipPath = Join-Path $repoRoot "output/release/stackchan_alive_$Version.zip" +if (-not (Test-Path -LiteralPath $sourceZipSidecarPath -PathType Leaf)) { + throw "Missing release ZIP SHA-256 sidecar: $sourceZipSidecarPath. Rebuild the package; sharing will not create evidence for an unverified ZIP." } -Assert-File $packageRoot -Assert-File $zipPath +# Verify before creating, replacing, or serving any Version-derived share path. The copied +# snapshot is verified again and safely extracted so no share asset comes from output/release/. +Invoke-OperationalPackageVerification ` + -Version $Version ` + -ZipPath $sourceZipPath ` + -ExpectedCommit $ExpectedCommit +. (Join-Path $PSScriptRoot "release_zip_safety.ps1") +$snapshot = New-VerifiedShareSnapshot ` + -Version $Version ` + -ZipPath $sourceZipPath ` + -ZipSidecarPath $sourceZipSidecarPath ` + -ExpectedCommit $ExpectedCommit +try { + $packageRoot = $snapshot.PackageRoot + $zipPath = $snapshot.ZipPath + $zipSidecarPath = $snapshot.ZipSidecarPath + $snapshotZipName = Split-Path -Leaf $zipPath + $snapshotZipHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $zipPath).Hash.ToLowerInvariant() + $snapshotSidecar = (Get-Content -LiteralPath $zipSidecarPath -Raw).Trim() + if ($snapshotSidecar -cne "$snapshotZipHash $snapshotZipName") { + throw 'Verified share snapshot sidecar does not bind its exact ZIP; existing share state remains untouched.' + } + + if ($Lan) { + $BindAddress = "0.0.0.0" + } + + Assert-BindAddressAvailable -Address $BindAddress + $script:ShareProbeUrl = if ($BindAddress -eq "0.0.0.0") { "http://127.0.0.1`:$Port/" } else { "http://$BindAddress`:$Port/" } + $script:ShareLanUrls = @() + + $shareOutputRoot = Get-SafeReleaseChildPath -Root $repoRoot -RelativePath "output/share" + $shareRoot = Get-SafeReleaseChildPath -Root $shareOutputRoot -RelativePath $Version + if (Test-Path -LiteralPath $shareRoot) { + Stop-ExistingShare -ExistingShareRoot $shareRoot + Remove-ShareRoot -ExistingShareRoot $shareRoot + } + New-Item -ItemType Directory -Force -Path $shareRoot | Out-Null $files = @( @{ Source = $zipPath; Name = "stackchan_alive_$Version.zip" }, + @{ Source = $zipSidecarPath; Name = "stackchan_alive_$Version.zip.sha256" }, @{ Source = (Join-Path $packageRoot "media/stackchan_alive_preview.png"); Name = "stackchan_alive_preview.png" }, @{ Source = (Join-Path $packageRoot "media/stackchan_alive_expression_sheet.png"); Name = "stackchan_alive_expression_sheet.png" }, @{ Source = (Join-Path $packageRoot "media/stackchan_alive_preview.mp4"); Name = "stackchan_alive_preview.mp4" }, @@ -842,70 +1014,32 @@ $dependencyLock = Get-Content -LiteralPath (Join-Path $packageRoot "dependency_l $voiceSourceStatus = Get-Content -LiteralPath (Join-Path $packageRoot "voice_source_status.json") -Raw | ConvertFrom-Json $rvcBaseStatus = Get-Content -LiteralPath (Join-Path $packageRoot "rvc_voice_base_status.json") -Raw | ConvertFrom-Json -$preflightRoot = Join-Path $repoRoot "output/preflight/$Version" -$preflightReportMarkdown = Join-Path $preflightRoot "preflight_report.md" -$preflightReportJson = Join-Path $preflightRoot "preflight_report.json" -$preflightReportAvailable = (Test-Path -LiteralPath $preflightReportMarkdown) -and (Test-Path -LiteralPath $preflightReportJson) $preflightStatus = "pending" $preflightStatusPillClass = "pending" $preflightSection = @"

Preflight Evidence

-

No-hardware device preflight has not been attached to this share yet. Run .\tools\run_device_preflight.cmd -PackageZip output\release\stackchan_alive_$Version.zip -Version $Version -ExpectedCommit $($manifest.commit), then re-run this share command to publish the pass/fail report.

+

No-hardware device preflight is not embedded in this verified release ZIP. Run .\tools\run_device_preflight.cmd -PackageZip output\release\stackchan_alive_$Version.zip -Version $Version -ExpectedCommit $($manifest.commit) and review that separately; this page does not attach unbound local evidence.

"@ $preflightDownloadItems = "" -if ($preflightReportAvailable) { - Copy-Item -LiteralPath $preflightReportMarkdown -Destination (Join-Path $shareRoot "preflight_report.md") - Copy-Item -LiteralPath $preflightReportJson -Destination (Join-Path $shareRoot "preflight_report.json") - $preflightReport = Get-Content -LiteralPath $preflightReportJson -Raw | ConvertFrom-Json - $preflightStatus = [string]$preflightReport.status - $preflightStatusPillClass = if ($preflightStatus -eq "pass") { "pass" } else { "pending" } - $preflightPassedSteps = @($preflightReport.steps | Where-Object { $_.status -eq "pass" }).Count - $preflightFailedSteps = @($preflightReport.steps | Where-Object { $_.status -eq "fail" }).Count - $preflightSection = @" -

Preflight Evidence

-

No-hardware device preflight report for this package is attached. It covers required commands, dependency pins, flash-helper safety gates, architecture boundaries, preview media, hardware-evidence verifier gates, native tests, embedded test firmware compile, firmware builds, release package verification, and release flash-helper checks.

-
- Preflight: $preflightStatus - Passed steps: $preflightPassedSteps - Failed steps: $preflightFailedSteps -
-

Read preflight report or download preflight JSON.

-"@ - $preflightDownloadItems = @" - - -"@ -} - $sharedZipName = "stackchan_alive_$Version.zip" $sharedZipPath = Join-Path $shareRoot $sharedZipName Assert-File $sharedZipPath $sharedZipHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $sharedZipPath).Hash.ToLowerInvariant() -"$sharedZipHash $sharedZipName" | Set-Content -Path (Join-Path $shareRoot "$sharedZipName.sha256") -Encoding ASCII - -$actionsStatusScript = Join-Path $packageRoot "tools/export_github_actions_status.ps1" -if ((Get-Command "gh" -ErrorAction SilentlyContinue) -and (Test-Path -LiteralPath $actionsStatusScript)) { - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $actionsStatusScript -Version $Version -Commit $manifest.commit -OutputDir $shareRoot - if ($LASTEXITCODE -ne 0) { - Write-Warning "Unable to refresh GitHub Actions status for share; using packaged status artifacts." - } +$sharedZipSidecarPath = Join-Path $shareRoot "$sharedZipName.sha256" +Assert-File $sharedZipSidecarPath +$sharedZipSidecar = (Get-Content -LiteralPath $sharedZipSidecarPath -Raw).Trim() +if ($sharedZipSidecar -cne "$sharedZipHash $sharedZipName") { + throw "Shared ZIP sidecar does not bind the exact copied and reverified ZIP." } + +# Do not overwrite the packaged Actions evidence in the share root. It is a standalone release +# asset and must remain byte-for-byte sourced from the safely extracted, release-eligible ZIP. $actionsStatus = Get-Content -LiteralPath (Join-Path $shareRoot "github_actions_status.json") -Raw | ConvertFrom-Json -$rolloutStatusScript = Join-Path $packageRoot "tools/export_rollout_status.ps1" -if (Test-Path -LiteralPath $rolloutStatusScript) { - $oldErrorActionPreference = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { - $rolloutOutput = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $rolloutStatusScript -Version $Version -PackageRoot $packageRoot -OutDir $shareRoot -ActionsStatusPath (Join-Path $shareRoot "github_actions_status.json") -ExpectedCommit $manifest.commit 2>&1 - $rolloutExitCode = $LASTEXITCODE - } finally { - $ErrorActionPreference = $oldErrorActionPreference - } - if ($rolloutExitCode -ne 0 -and $rolloutExitCode -ne 2) { - Write-Warning "Unable to export rollout status for share; continuing without rollout next-action details. Output:$([Environment]::NewLine)$(($rolloutOutput | Out-String).Trim())" - } -} +# Rollout status is hardware-evidence output, not release content. Never execute the local +# exporter into the served root: doing so would mix mutable workstation state with verified ZIP +# authority. A release share therefore reports the packaged readiness state and directs the +# operator to generate rollout evidence separately. $rolloutStatusPath = Join-Path $shareRoot "ROLLOUT_STATUS.json" $rolloutStatus = if (Test-Path -LiteralPath $rolloutStatusPath) { Get-Content -LiteralPath $rolloutStatusPath -Raw | ConvertFrom-Json @@ -991,7 +1125,7 @@ $promotionGateItems = (@($readiness.hardwareGates) | ForEach-Object {

Action: $rolloutNextAction

Command: $rolloutNextCommand

Reason: $rolloutNextReason

-

Read rollout status or download rollout JSON.

+

Rollout status is intentionally not generated into this release share. Run the packaged arrival-day evidence command separately so mutable hardware state cannot be mistaken for verified release content.

$preflightSection @@ -1160,8 +1294,6 @@ $promotionGateItems - - @@ -1351,3 +1483,6 @@ if ($CloudflareTunnel) { } else { Write-Host "Stop-Process -Id $($server.Id)" } +} finally { + Remove-VerifiedShareSnapshot -SnapshotRoot $snapshot.Root +} diff --git a/tools/start_bridge_ai_supervised_qualification.ps1 b/tools/start_bridge_ai_supervised_qualification.ps1 index d98e4e7a..491b3fbc 100644 --- a/tools/start_bridge_ai_supervised_qualification.ps1 +++ b/tools/start_bridge_ai_supervised_qualification.ps1 @@ -1,6 +1,7 @@ param( [Parameter(Mandatory = $true)] [string]$PackageZip, + [string]$PackageVersion = "", [Parameter(Mandatory = $true)] [ValidatePattern("^[0-9a-fA-F]{64}$")] [string]$ExpectedFirmwareSha256, @@ -28,22 +29,50 @@ if (-not $OperatorPresent -or -not $ConfirmMotionOff) { throw "Qualification requires -OperatorPresent -ConfirmMotionOff." } if ($MinReplyWindows -lt 1) { throw "MinReplyWindows must be positive." } +$SourceCommit = (& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false rev-parse HEAD).Trim().ToLowerInvariant() +if ($LASTEXITCODE -ne 0 -or $SourceCommit -notmatch "^[0-9a-f]{40}$") { + throw "Could not resolve source commit." +} +$SourceDirty = @(& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + status --porcelain).Count -gt 0 +$ExpectedFirmwareSha256 = $ExpectedFirmwareSha256.ToLowerInvariant() +$ExpectedFirmwareSourceCommit = $ExpectedFirmwareSourceCommit.ToLowerInvariant() +$PackageZipPath = (Resolve-Path $PackageZip).Path + +if ([string]::IsNullOrWhiteSpace($PackageVersion)) { + $zipName = [System.IO.Path]::GetFileName($PackageZipPath) + if ($zipName -notmatch "^stackchan_alive_(.+)\.zip$") { + throw "Pass -PackageVersion when -PackageZip does not match stackchan_alive_.zip" + } + $PackageVersion = $Matches[1] +} + +$previousErrorPreference = $ErrorActionPreference +try { + $ErrorActionPreference = "Continue" + $PackageVerifyOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $PSScriptRoot "verify_release_package.ps1") ` + -Version $PackageVersion -ZipPath $PackageZipPath -ExpectedCommit $SourceCommit ` + -RequireReleaseEligible 2>&1) + $PackageVerifyExit = $LASTEXITCODE +} finally { + $ErrorActionPreference = $previousErrorPreference +} +if ($PackageVerifyExit -ne 0) { + throw "Release ZIP verification failed before qualification evidence creation: $(($PackageVerifyOutput | Out-String).Trim())" +} + if ([string]::IsNullOrWhiteSpace($EvidenceRoot)) { $EvidenceRoot = "output\pc-brain\bridge-ai-supervised-" + (Get-Date -Format "yyyyMMdd-HHmmss") } New-Item -ItemType Directory -Force -Path $EvidenceRoot | Out-Null $EvidencePath = (Resolve-Path $EvidenceRoot).Path +$PackageVerifyLog = Join-Path $EvidencePath "package-verify.log" +$PackageVerifyOutput | Set-Content -LiteralPath $PackageVerifyLog -Encoding UTF8 $DebugUrl = "http://$DeviceHost`:8789/debug" $DashboardUrl = "http://127.0.0.1`:$DashboardPort/api/status" - -$SourceCommit = (& git rev-parse HEAD).Trim().ToLowerInvariant() -if ($LASTEXITCODE -ne 0 -or $SourceCommit -notmatch "^[0-9a-f]{40}$") { - throw "Could not resolve source commit." -} -$SourceDirty = @(& git status --porcelain).Count -gt 0 -$ExpectedFirmwareSha256 = $ExpectedFirmwareSha256.ToLowerInvariant() -$ExpectedFirmwareSourceCommit = $ExpectedFirmwareSourceCommit.ToLowerInvariant() -$PackageZipPath = (Resolve-Path $PackageZip).Path $PackageSha256 = (Get-FileHash -LiteralPath $PackageZipPath -Algorithm SHA256).Hash.ToLowerInvariant() Add-Type -AssemblyName System.IO.Compression.FileSystem @@ -54,10 +83,21 @@ try { $ManifestEntry = $Archive.GetEntry("./release_manifest.json") } if (-not $ManifestEntry) { throw "Release ZIP is missing release_manifest.json." } + if ($ManifestEntry.Length -gt 1MB) { + throw "Release ZIP manifest exceeds the 1 MiB qualification read limit." + } $ManifestReader = [IO.StreamReader]::new($ManifestEntry.Open(), [Text.Encoding]::UTF8, $true) try { - $PackageManifest = $ManifestReader.ReadToEnd() | ConvertFrom-Json + $manifestBuilder = [Text.StringBuilder]::new() + $manifestBuffer = [char[]]::new(4096) + while (($manifestRead = $ManifestReader.ReadBlock($manifestBuffer, 0, $manifestBuffer.Length)) -gt 0) { + [void]$manifestBuilder.Append($manifestBuffer, 0, $manifestRead) + if ($manifestBuilder.Length -gt 1MB) { + throw "Release ZIP manifest exceeds the 1 MiB qualification character limit." + } + } + $PackageManifest = $manifestBuilder.ToString() | ConvertFrom-Json } finally { $ManifestReader.Dispose() } @@ -65,22 +105,14 @@ try { $Archive.Dispose() } -$PackageVersion = [string]$PackageManifest.version $PackageCommit = ([string]$PackageManifest.commit).ToLowerInvariant() +if ([string]$PackageManifest.version -ne $PackageVersion) { + throw "Release ZIP manifest version does not match trusted package version $PackageVersion." +} if ($PackageCommit -notmatch "^[0-9a-f]{40}$") { throw "Release ZIP manifest commit is invalid." } if ($PackageCommit -ne $SourceCommit) { throw "Release ZIP commit $PackageCommit does not match source commit $SourceCommit." } -$PackageVerifyLog = Join-Path $EvidencePath "package-verify.log" -$PackageVerifyOutput = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -File (Join-Path $PSScriptRoot "verify_release_package.ps1") ` - -Version $PackageVersion -ZipPath $PackageZipPath -ExpectedCommit $SourceCommit 2>&1 -$PackageVerifyExit = $LASTEXITCODE -$PackageVerifyOutput | Set-Content -LiteralPath $PackageVerifyLog -Encoding UTF8 -if ($PackageVerifyExit -ne 0) { - throw "Release ZIP verification failed. See $PackageVerifyLog" -} - $FirmwareInputPaths = @( "platformio.ini", "partitions_esp_sr_16.csv", diff --git a/tools/start_hardware_evidence.ps1 b/tools/start_hardware_evidence.ps1 index 9ad75404..5e6ad1bc 100644 --- a/tools/start_hardware_evidence.ps1 +++ b/tools/start_hardware_evidence.ps1 @@ -2,6 +2,7 @@ param( [string]$ReleaseTag = "", [string]$PackageZip = "", [string]$PackageRoot = "", + [string]$ExpectedCommit = "", [string]$Port = "", [string]$Operator = "", [string]$DeviceId = "", @@ -15,13 +16,13 @@ $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $repoRoot -. (Join-Path $PSScriptRoot "platformio_resolver.ps1") function Invoke-GitText { param([string[]]$Arguments) try { - $output = & git @Arguments 2>$null + $output = & git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false @Arguments 2>$null } catch { return "" } @@ -72,7 +73,7 @@ function Copy-AcceptanceArtifactsFromZip { $extractDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $extractDir | Out-Null try { - Expand-Archive -LiteralPath $ZipPath -DestinationPath $extractDir + Expand-StackchanReleaseZipSafely -ZipPath $ZipPath -DestinationPath $extractDir Copy-AcceptanceArtifactsFromRoot -SourceRoot $extractDir -DestinationRoot $DestinationRoot } finally { Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue @@ -181,7 +182,7 @@ function Copy-VoiceLeadArtifactsFromZip { $extractDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $extractDir | Out-Null try { - Expand-Archive -LiteralPath $ZipPath -DestinationPath $extractDir + Expand-StackchanReleaseZipSafely -ZipPath $ZipPath -DestinationPath $extractDir return Copy-VoiceLeadArtifactsFromRoot -SourceRoot $extractDir -DestinationRoot $DestinationRoot } finally { Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue @@ -237,7 +238,7 @@ function Copy-VoiceGateStatusFromZip { $extractDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $extractDir | Out-Null try { - Expand-Archive -LiteralPath $ZipPath -DestinationPath $extractDir + Expand-StackchanReleaseZipSafely -ZipPath $ZipPath -DestinationPath $extractDir return Copy-VoiceGateStatusFromRoot -SourceRoot $extractDir -DestinationRoot $DestinationRoot } finally { Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue @@ -449,42 +450,89 @@ function Write-EvidenceChecklist { $annotated | Set-Content -Path $DestinationPath -Encoding UTF8 } -$rootManifest = Get-ReleaseManifest $repoRoot +if (-not [string]::IsNullOrWhiteSpace($PackageRoot) -and + -not [string]::IsNullOrWhiteSpace($PackageZip)) { + throw "Pass only one of -PackageZip or -PackageRoot." +} if ([string]::IsNullOrWhiteSpace($ReleaseTag)) { - if ($null -ne $rootManifest) { - $ReleaseTag = [string]$rootManifest.version + if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { + $zipName = [System.IO.Path]::GetFileName($PackageZip) + if ($zipName -notmatch "^stackchan_alive_(.+)\.zip$") { + throw "Pass -ReleaseTag when -PackageZip does not match stackchan_alive_.zip" + } + $ReleaseTag = $Matches[1] } else { - $ReleaseTag = Invoke-GitText @("describe", "--tags", "--always", "--dirty") + $ReleaseTag = (& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + describe --tags --always 2>$null | Out-String).Trim() } } +if ([string]::IsNullOrWhiteSpace($ReleaseTag)) { + throw "Pass -ReleaseTag because it could not be resolved from trusted Git state." +} -if ([string]::IsNullOrWhiteSpace($PackageRoot) -and [string]::IsNullOrWhiteSpace($PackageZip) -and $null -ne $rootManifest) { - $PackageRoot = $repoRoot +if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { + $ExpectedCommit = (& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + rev-parse HEAD 2>$null | Out-String).Trim() } +if ([string]::IsNullOrWhiteSpace($ExpectedCommit) -or + $ExpectedCommit -notmatch "^[0-9a-fA-F]{40}$") { + throw "Pass a 40-hex -ExpectedCommit or run from a trusted Git checkout." +} +$commit = $ExpectedCommit.ToLowerInvariant() -$packageRootManifest = $null -if (-not [string]::IsNullOrWhiteSpace($PackageRoot)) { - if (-not (Test-Path -LiteralPath $PackageRoot)) { +$packageVerifyOutput = @() +$packageVerifyExitCode = 0 +if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { + if (-not (Test-Path -LiteralPath $PackageZip -PathType Leaf)) { + throw "Missing package ZIP: $PackageZip" + } + $PackageZip = (Resolve-Path -LiteralPath $PackageZip).Path + $verifyArgs = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", + (Join-Path $PSScriptRoot "verify_release_package.ps1"), + "-Version", $ReleaseTag, "-ZipPath", $PackageZip, + "-ExpectedCommit", $commit, "-RequireReleaseEligible" + ) + if ($AllowDirtyPackage) { $verifyArgs += "-AllowDirtyPackage" } + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $packageVerifyOutput = @(& powershell.exe @verifyArgs 2>&1) + $packageVerifyExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } +} elseif (-not [string]::IsNullOrWhiteSpace($PackageRoot)) { + if (-not (Test-Path -LiteralPath $PackageRoot -PathType Container)) { throw "Missing package root: $PackageRoot" } - $PackageRoot = (Resolve-Path $PackageRoot).Path - $packageRootManifest = Get-ReleaseManifest $PackageRoot -} - -$commit = "" -if ($null -ne $rootManifest) { - $commit = [string]$rootManifest.commit -} elseif ($null -ne $packageRootManifest) { - $commit = [string]$packageRootManifest.commit -} -if ([string]::IsNullOrWhiteSpace($commit)) { - $commit = Invoke-GitText @("rev-parse", "HEAD") + $PackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path + $verifyArgs = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", + (Join-Path $PSScriptRoot "verify_release_package.ps1"), + "-Version", $ReleaseTag, "-PackageRoot", $PackageRoot, + "-ExpectedCommit", $commit, "-RequireReleaseEligible" + ) + if ($AllowDirtyPackage) { $verifyArgs += "-AllowDirtyPackage" } + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $packageVerifyOutput = @(& powershell.exe @verifyArgs 2>&1) + $packageVerifyExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } } -if ([string]::IsNullOrWhiteSpace($commit)) { - throw "Could not determine release commit from git or package manifest." +if ($packageVerifyExitCode -ne 0) { + throw "Operational release package verification failed before evidence creation: $(($packageVerifyOutput | Out-String).Trim())" } +. (Join-Path $PSScriptRoot "platformio_resolver.ps1") +. (Join-Path $PSScriptRoot "release_zip_safety.ps1") + if (-not $AllowIncompleteMetadata) { $missingMetadata = @() if ([string]::IsNullOrWhiteSpace($Port)) { $missingMetadata += "-Port" } @@ -532,9 +580,6 @@ $requiredLogs = @( "logs/soak_serial.log" ) if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { - if (-not (Test-Path -LiteralPath $PackageZip)) { - throw "Missing package ZIP: $PackageZip" - } $packageItem = Get-Item -LiteralPath $PackageZip $packageHash = Get-FileHash -Algorithm SHA256 -LiteralPath $packageItem.FullName Copy-Item -LiteralPath $packageItem.FullName -Destination $packageDir @@ -546,28 +591,7 @@ if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { } $packageVerifyLog = Join-Path $logsDir "package_verify.log" - $verifyArgs = @( - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - (Join-Path $PSScriptRoot "verify_release_package.ps1"), - "-Version", - $ReleaseTag, - "-ZipPath", - $packageItem.FullName, - "-ExpectedCommit", - $commit - ) - if ($AllowDirtyPackage) { - $verifyArgs += "-AllowDirtyPackage" - } - $verifyOutput = & powershell.exe @verifyArgs 2>&1 - $verifyExitCode = $LASTEXITCODE - $verifyOutput | Set-Content -Path $packageVerifyLog -Encoding UTF8 - if ($verifyExitCode -ne 0) { - throw "Release package verification failed while creating evidence packet. See $packageVerifyLog" - } + $packageVerifyOutput | Set-Content -Path $packageVerifyLog -Encoding UTF8 $packageVerified = $true Copy-AcceptanceArtifactsFromZip -ZipPath $packageItem.FullName -DestinationRoot $outDir $voiceLeadInfo = Copy-VoiceLeadArtifactsFromZip -ZipPath $packageItem.FullName -DestinationRoot $outDir @@ -581,28 +605,7 @@ if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { } $packageVerifyLog = Join-Path $logsDir "package_verify.log" - $verifyArgs = @( - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - (Join-Path $PSScriptRoot "verify_release_package.ps1"), - "-Version", - $ReleaseTag, - "-PackageRoot", - $packageRootItem.FullName, - "-ExpectedCommit", - $commit - ) - if ($AllowDirtyPackage) { - $verifyArgs += "-AllowDirtyPackage" - } - $verifyOutput = & powershell.exe @verifyArgs 2>&1 - $verifyExitCode = $LASTEXITCODE - $verifyOutput | Set-Content -Path $packageVerifyLog -Encoding UTF8 - if ($verifyExitCode -ne 0) { - throw "Release package verification failed while creating evidence packet. See $packageVerifyLog" - } + $packageVerifyOutput | Set-Content -Path $packageVerifyLog -Encoding UTF8 $packageVerified = $true Copy-AcceptanceArtifactsFromRoot -SourceRoot $packageRootItem.FullName -DestinationRoot $outDir $voiceLeadInfo = Copy-VoiceLeadArtifactsFromRoot -SourceRoot $packageRootItem.FullName -DestinationRoot $outDir @@ -782,7 +785,7 @@ $speakAllCommand = "& '.\tools\send_speak_all_intents_demo.ps1'$portArg 2>&1 | T $bridgeReplayCommand = "& '.\tools\send_bridge_replay_demo.ps1'$portArg 2>&1 | Tee-Object -FilePath $bridgeReplayLog" $hardwareSimBaselineCommand = "& '.\tools\run_hardware_simulation.ps1' -OutputDir $hardwareSimBaselineDir -Json 2>&1 | Tee-Object -FilePath $hardwareSimBaselineLog" $simHardwareCompareCommand = "& '.\tools\compare_hardware_sim_baseline.ps1' -EvidenceRoot $(Quote-PowerShellArgument $outDir)" -$verifyCommand = "& '.\tools\verify_release_package.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $verifyPackageArg -ExpectedCommit $(Quote-PowerShellArgument $commit)" +$verifyCommand = "& '.\tools\verify_release_package.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $verifyPackageArg -ExpectedCommit $(Quote-PowerShellArgument $commit) -RequireReleaseEligible" if ($AllowDirtyPackage) { $verifyCommand += " -AllowDirtyPackage" } diff --git a/tools/test_android_rollout_status_contract.ps1 b/tools/test_android_rollout_status_contract.ps1 index fff860f6..d8be43f6 100644 --- a/tools/test_android_rollout_status_contract.ps1 +++ b/tools/test_android_rollout_status_contract.ps1 @@ -31,11 +31,13 @@ function Write-JsonFile { } function New-TestPackageRoot { + param([string]$ManifestCommit = $testCommit) + $root = New-TempRoot -Prefix "stackchan-rollout-package-contract" Write-JsonFile -Path (Join-Path $root "release_manifest.json") -Value ([ordered]@{ version = $testVersion - commit = $testCommit + commit = $ManifestCommit }) Write-JsonFile -Path (Join-Path $root "readiness_report.json") -Value ([ordered]@{ schema = "stackchan.readiness-report.v1" @@ -146,20 +148,26 @@ function New-TestEvidenceRoot { function Invoke-RolloutStatus { param( [string]$PackageRoot, - [string]$EvidenceRoot + [string]$EvidenceRoot, + [string]$ExpectedCommit = $testCommit, + [switch]$OmitExpectedCommit ) $outDir = New-TempRoot -Prefix "stackchan-rollout-out-contract" $powerShellExe = (Get-Process -Id $PID).Path - $output = & $powerShellExe ` - -NoProfile ` - -ExecutionPolicy Bypass ` - -File $rolloutScript ` - -Version $testVersion ` - -ExpectedCommit $testCommit ` - -PackageRoot $PackageRoot ` - -EvidenceRoot $EvidenceRoot ` - -OutDir $outDir 2>&1 + $arguments = @( + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', $rolloutScript, + '-Version', $testVersion, + '-PackageRoot', $PackageRoot, + '-EvidenceRoot', $EvidenceRoot, + '-OutDir', $outDir + ) + if (-not $OmitExpectedCommit) { + $arguments += @('-ExpectedCommit', $ExpectedCommit) + } + $output = & $powerShellExe @arguments 2>&1 if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 2) { throw "Rollout status export exited with $LASTEXITCODE.`n$($output | Out-String)" @@ -183,6 +191,19 @@ function Get-AndroidCompanionGate { return $matches[0] } +function Get-RolloutGate { + param( + [object]$Report, + [string]$Name + ) + + $matches = @($Report.gates | Where-Object { [string]$_.gate -eq $Name }) + if ($matches.Count -ne 1) { + throw "Expected one $Name gate, found $($matches.Count)." + } + return $matches[0] +} + function Assert-AndroidGate { param( [object]$Report, @@ -265,6 +286,43 @@ try { Assert-AndroidGate -Report $validProbeResult -ExpectedStatus "pass" -EvidenceNeedle "Android UDP beacon probe status pass" Assert-AndroidGate -Report $validProbeResult -ExpectedStatus "pass" -EvidenceNeedle "Android companion logcat capture status captured" + $eligibilityGate = Get-RolloutGate -Report $validProbeResult -Name 'release-package-eligibility' + if ([string]$eligibilityGate.status -ne 'blocked' -or [bool]$validProbeResult.consumerReady) { + throw 'An unverified package produced rollout readiness despite the eligibility gate' + } + + $authorityPackageRoot = New-TestPackageRoot -ManifestCommit ("d" * 40) + $authorityEvidenceRoot = New-TestEvidenceRoot ` + -ApkInstallReport (New-ApkInstallReport) -ProbeReports (New-ValidProbeReports) + $authorityResult = Invoke-RolloutStatus ` + -PackageRoot $authorityPackageRoot -EvidenceRoot $authorityEvidenceRoot -ExpectedCommit $testCommit + $manifestGate = Get-RolloutGate -Report $authorityResult -Name 'release-package-manifest' + if ([string]$authorityResult.commit -cne $testCommit -or + [string]$manifestGate.status -ne 'blocked' -or + [bool]$authorityResult.consumerReady) { + throw 'Package manifest replaced rollout commit authority or escaped the manifest mismatch gate' + } + + $trustedCheckoutCommit = (& git -C $repoRoot rev-parse HEAD).Trim().ToLowerInvariant() + if ($trustedCheckoutCommit -notmatch '^[0-9a-f]{40}$') { + throw 'Could not resolve the trusted checkout authority for the omitted-commit contract case' + } + $omittedAuthorityPackage = New-TestPackageRoot -ManifestCommit ("e" * 40) + $omittedAuthorityEvidence = New-TestEvidenceRoot ` + -ApkInstallReport (New-ApkInstallReport) -ProbeReports (New-ValidProbeReports) + $omittedAuthorityResult = Invoke-RolloutStatus ` + -PackageRoot $omittedAuthorityPackage ` + -EvidenceRoot $omittedAuthorityEvidence ` + -OmitExpectedCommit + $omittedManifestGate = Get-RolloutGate ` + -Report $omittedAuthorityResult -Name 'release-package-manifest' + if ([string]$omittedAuthorityResult.commit -cne $trustedCheckoutCommit -or + [string]$omittedAuthorityResult.commit -ceq ("e" * 40) -or + [string]$omittedManifestGate.status -ne 'blocked' -or + [bool]$omittedAuthorityResult.consumerReady) { + throw 'Omitting ExpectedCommit let package content become rollout commit authority' + } + Write-Host "Android rollout status evidence contract tests passed." } finally { foreach ($root in $createdRoots) { diff --git a/tools/test_firmware_http_control_policy_contract.ps1 b/tools/test_firmware_http_control_policy_contract.ps1 index 5cc9f675..d9d2018b 100644 --- a/tools/test_firmware_http_control_policy_contract.ps1 +++ b/tools/test_firmware_http_control_policy_contract.ps1 @@ -660,12 +660,16 @@ Require-PolicyAssertion (([regex]::Matches( $candidatePlatformioAtFrozenMotionPolicy = $normalizedCandidatePlatformio.Replace( $candidatePublicReleaseMotion, $baselinePublicReleaseMotion) +$candidatePlatformioWithoutReproducibilityHook = [regex]::Replace( + $candidatePlatformioAtFrozenMotionPolicy, + '(?m)^[^\S\r\n]*pre:tools/platformio_reproducible_build\.py[^\S\r\n]*\n?', + '') $candidatePlatformioWithoutPolicy = ([regex]::Replace( - $candidatePlatformioAtFrozenMotionPolicy, + $candidatePlatformioWithoutReproducibilityHook, '(?m)^[^\S\r\n]*\+[^\S\r\n]*\n?', '')).TrimEnd() Require-PolicyAssertion (-not [string]::IsNullOrEmpty($baselinePlatformioText) -and - $candidatePlatformioWithoutPolicy -ceq $baselinePlatformioText) "profile: platformio.ini changed beyond the preregistered policy source-filter and exact public no-motion boot stanzas" + $candidatePlatformioWithoutPolicy -ceq $baselinePlatformioText) "profile: platformio.ini changed beyond the preregistered policy source-filter, exact public no-motion boot stanza, and independently governed reproducibility hooks" $allowedChangedFiles = @( 'INITIAL_RISK_REGISTER.md', 'PROJECT_STATE.md', 'TASK_LEDGER.md', 'platformio.ini', diff --git a/tools/test_firmware_reproducibility_failure_contract.ps1 b/tools/test_firmware_reproducibility_failure_contract.ps1 new file mode 100644 index 00000000..b482a7ad --- /dev/null +++ b/tools/test_firmware_reproducibility_failure_contract.ps1 @@ -0,0 +1,84 @@ +$ErrorActionPreference = "Stop" + +. (Join-Path $PSScriptRoot "firmware_reproducibility_failure.ps1") + +$packageText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'package_release.ps1') -Raw +foreach ($required in @( + 'Save-StackchanFirmwareReproducibilityFailureEvidence', + 'The complete detached worktree remains attached', + 'full-failed-worktree-retained-attached' +)) { + if (-not (($packageText + "`n" + (Get-Content -LiteralPath ` + (Join-Path $PSScriptRoot 'firmware_reproducibility_failure.ps1') -Raw)).Contains($required))) { + throw "Production failed-build preservation wiring is missing: $required" + } +} +if ($packageText.Contains('$failureEvidence.partialBuildPreserved') -or + $packageText.Contains('Update-StackchanFirmwareReproducibilityFailureEvidence')) { + throw 'Production failed-build catch still permits partial-copy cleanup of the failed worktree' +} + +$root = Join-Path ([System.IO.Path]::GetTempPath()) ( + "stackchan-repro-failure-contract-" + [guid]::NewGuid().ToString("N")) +$cacheRoot = Join-Path $root "cache" +$sourceRoot = Join-Path $root "source" +$failureRoot = Join-Path $root "failure" +try { + New-Item -ItemType Directory -Force -Path ` + (Join-Path $cacheRoot "cycle-a/logs"), ` + (Join-Path $cacheRoot "cycle-b/stackchan"), ` + (Join-Path $sourceRoot ".pio/build/stackchan"), ` + (Join-Path $sourceRoot ".pio/libdeps/stackchan/private-generated-library"), ` + (Join-Path $sourceRoot "generated/persona") | Out-Null + Set-Content -LiteralPath (Join-Path $cacheRoot "cycle-a/logs/stackchan-build.log") -Value "compiler failed" -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $cacheRoot "cycle-b/stackchan/firmware.bin") -Value "prior snapshot" -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $sourceRoot ".pio/build/stackchan/partial.o") -Value "partial object" -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $sourceRoot ".pio/libdeps/stackchan/private-generated-library/state.txt") -Value "libdeps state" -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $sourceRoot "generated/persona/failure.wav") -Value "generated state" -Encoding UTF8 + try { + throw "simulated compiler failure" + } catch { + $failure = $_ + } + $evidence = Save-StackchanFirmwareReproducibilityFailureEvidence ` + -FailureRoot $failureRoot ` + -BuildCacheRoot $cacheRoot ` + -ActiveSourceRoot $sourceRoot ` + -WorktreeStillAttached $true ` + -Failure $failure ` + -SourceCommit ("a" * 40) ` + -SourceEpoch "1700000000" + + foreach ($required in @( + "build-cache-and-snapshots/cycle-a/logs/stackchan-build.log", + "build-cache-and-snapshots/cycle-b/stackchan/firmware.bin", + "FAILURE_EVIDENCE.json" + )) { + if (-not (Test-Path -LiteralPath (Join-Path $failureRoot $required) -PathType Leaf)) { + throw "Failure evidence contract lost required file: $required" + } + } + if (Test-Path -LiteralPath $cacheRoot) { + throw "Failure evidence contract must move, not duplicate, the exact build cache" + } + foreach ($retained in @( + ".pio/build/stackchan/partial.o", + ".pio/libdeps/stackchan/private-generated-library/state.txt", + "generated/persona/failure.wav" + )) { + if (-not (Test-Path -LiteralPath (Join-Path $sourceRoot $retained) -PathType Leaf)) { + throw "Failure evidence contract lost complete worktree state: $retained" + } + } + if ($evidence.fullWorktreePreserved -ne $true -or + $evidence.worktreeStillAttached -ne $true -or + $evidence.preservationPolicy -ne "full-failed-worktree-retained-attached") { + throw "Failure evidence contract did not record complete attached-worktree retention" + } +} finally { + if (Test-Path -LiteralPath $root) { + [System.IO.Directory]::Delete($root, $true) + } +} + +Write-Host "Firmware reproducibility failed-build retention contract passed." diff --git a/tools/test_firmware_reproducibility_proof_contract.ps1 b/tools/test_firmware_reproducibility_proof_contract.ps1 new file mode 100644 index 00000000..efda2ae3 --- /dev/null +++ b/tools/test_firmware_reproducibility_proof_contract.ps1 @@ -0,0 +1,193 @@ +$ErrorActionPreference = "Stop" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +. (Join-Path $PSScriptRoot "firmware_reproducibility_proof.ps1") + +$fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + "stackchan-repro-proof-contract-" + [guid]::NewGuid().ToString("N")) +$commit = "1" * 40 +$epoch = "1700000000" +$status = "test-ready prerelease; hardware validation pending" + +function Copy-Proof { + param([object]$Value) + return ($Value | ConvertTo-Json -Depth 10 | ConvertFrom-Json) +} + +function Invoke-ExpectedProofFailure { + param( + [Parameter(Mandatory = $true)][scriptblock]$Mutation, + [Parameter(Mandatory = $true)][string]$Marker + ) + + $candidate = Copy-Proof $script:validProof + & $Mutation $candidate + try { + Assert-StackchanFirmwareReproducibilityProof ` + -Proof $candidate ` + -DiagnosticPackage $false ` + -AllowDirtyPackage $false ` + -ManifestCommit $script:commit ` + -SourceEpoch $script:epoch ` + -ManifestStatus $script:status ` + -PackageRoot $script:fixtureRoot + } catch { + if ($_.Exception.Message -notmatch [regex]::Escape($Marker)) { + throw "Expected proof failure containing '$Marker', got: $($_.Exception.Message)" + } + return + } + throw "Expected proof failure containing '$Marker'" +} + +try { + $records = @() + $attestations = @() + $roots = [ordered]@{ + stackchan = "firmware/display_only" + stackchan_servo_calibration = "firmware/servo_calibration" + stackchan_release_full = "firmware/full_online" + } + # Preserve the required deterministic attestation order: all A, then all B. + foreach ($cycle in @("cycle-a", "cycle-b")) { + foreach ($environment in @($roots.Keys)) { + $attestations += [ordered]@{ + cycle = $cycle + environment = $environment + sourceCommit = $commit + sourceEpoch = $epoch + preBuildChecked = $true + postSnapshotChecked = $true + } + } + } + foreach ($environment in @($roots.Keys)) { + $destination = Join-Path $fixtureRoot $roots[$environment] + New-Item -ItemType Directory -Force -Path $destination | Out-Null + foreach ($artifact in @("firmware.bin", "firmware.elf", "bootloader.bin", "partitions.bin")) { + $path = Join-Path $destination $artifact + [System.IO.File]::WriteAllText( + $path, + "$environment/$artifact`n", + (New-Object System.Text.UTF8Encoding($false))) + $item = Get-Item -LiteralPath $path + $records += [ordered]@{ + environment = $environment + artifact = $artifact + bytes = [long]$item.Length + sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToUpperInvariant() + } + } + } + + $script:validProof = [ordered]@{ + status = "verified-two-clean-cycles" + minimumClockBoundarySeconds = 65 + clockBoundarySeconds = 65 + cycleAStartedUtc = "2026-01-01T00:00:00Z" + cycleBStartedUtc = "2026-01-01T00:01:05Z" + cycleASourceCommit = $commit + cycleASourceEpoch = $epoch + cycleBSourceCommit = $commit + cycleBSourceEpoch = $epoch + buildCachePolicy = "isolated-empty-per-cycle-environment" + sourceIsolationPolicy = "distinct-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths" + identityAttestations = @($attestations) + cycleAArtifacts = @(Copy-Proof $records) + cycleBArtifacts = @(Copy-Proof $records) + } + $script:commit = $commit + $script:epoch = $epoch + $script:status = $status + $script:fixtureRoot = $fixtureRoot + + Assert-StackchanFirmwareReproducibilityProof ` + -Proof $validProof -DiagnosticPackage $false -AllowDirtyPackage $false ` + -ManifestCommit $commit -SourceEpoch $epoch -ManifestStatus $status -PackageRoot $fixtureRoot + + Invoke-ExpectedProofFailure { param($p) $p.status = 'claimed' } "two-cycle firmware reproducibility proof" + Invoke-ExpectedProofFailure { param($p) $p.minimumClockBoundarySeconds = 64 } "two-cycle firmware reproducibility proof" + Invoke-ExpectedProofFailure { param($p) $p.cycleAArtifacts = @($p.cycleAArtifacts | Select-Object -First 11) } "12 artifacts" + Invoke-ExpectedProofFailure { param($p) $p.cycleBArtifacts += (Copy-Proof $p.cycleBArtifacts[0]) } "12 artifacts" + Invoke-ExpectedProofFailure { param($p) $p.cycleBArtifacts[1] = Copy-Proof $p.cycleBArtifacts[0] } "mismatch at artifact index" + Invoke-ExpectedProofFailure { param($p) $p.cycleBArtifacts[0].bytes = [long]$p.cycleBArtifacts[0].bytes + 1 } "mismatch at artifact index" + Invoke-ExpectedProofFailure { param($p) $p.cycleBArtifacts[0].sha256 = "0" * 64 } "mismatch at artifact index" + Invoke-ExpectedProofFailure { param($p) $p.cycleAStartedUtc = "invalid" } "invalid cycle timestamps" + Invoke-ExpectedProofFailure { param($p) $p.cycleBStartedUtc = "2025-12-31T23:59:59Z" } "clock boundary is inconsistent" + Invoke-ExpectedProofFailure { param($p) $p.cycleBStartedUtc = "2026-01-01T00:01:04Z"; $p.clockBoundarySeconds = 64 } "two-cycle firmware reproducibility proof" + Invoke-ExpectedProofFailure { param($p) $p.clockBoundarySeconds = 66 } "clock boundary is inconsistent" + Invoke-ExpectedProofFailure { param($p) $p.identityAttestations = @($p.identityAttestations | Select-Object -First 5) } "six cycle/environment" + Invoke-ExpectedProofFailure { param($p) $p.identityAttestations[1] = Copy-Proof $p.identityAttestations[0] } "invalid identity attestation" + Invoke-ExpectedProofFailure { param($p) $p.identityAttestations[0].sourceCommit = "2" * 40 } "invalid identity attestation" + Invoke-ExpectedProofFailure { param($p) $p.identityAttestations[0].sourceEpoch = "1700000001" } "invalid identity attestation" + Invoke-ExpectedProofFailure { param($p) $p.identityAttestations[0].preBuildChecked = $false } "invalid identity attestation" + Invoke-ExpectedProofFailure { param($p) $p.identityAttestations[0].postSnapshotChecked = $false } "invalid identity attestation" + Invoke-ExpectedProofFailure { param($p) $p.cycleASourceCommit = "2" * 40 } "one manifest Git identity" + Invoke-ExpectedProofFailure { param($p) $p.cycleBSourceCommit = "2" * 40 } "one manifest Git identity" + Invoke-ExpectedProofFailure { param($p) $p.cycleASourceEpoch = "1700000001" } "one manifest Git identity" + Invoke-ExpectedProofFailure { param($p) $p.cycleBSourceEpoch = "1700000001" } "one manifest Git identity" + Invoke-ExpectedProofFailure { param($p) $p.buildCachePolicy = "shared-cache" } "isolated build-cache policy" + Invoke-ExpectedProofFailure { param($p) $p.sourceIsolationPolicy = "same-worktree" } "distinct detached source policy" + + $packagedArtifact = Join-Path $fixtureRoot "firmware/display_only/firmware.bin" + [System.IO.File]::AppendAllText($packagedArtifact, "changed") + Invoke-ExpectedProofFailure { param($p) } "Packaged artifact does not match reproducibility cycle B" + + $diagnosticProof = [ordered]@{ + status = "not-proven-skip-build" + minimumClockBoundarySeconds = 65 + clockBoundarySeconds = 0 + cycleAStartedUtc = $null + cycleBStartedUtc = $null + cycleASourceCommit = $null + cycleASourceEpoch = $null + cycleBSourceCommit = $null + cycleBSourceEpoch = $null + buildCachePolicy = "not-applicable-skip-build" + sourceIsolationPolicy = "not-applicable-skip-build" + identityAttestations = @() + cycleAArtifacts = @() + cycleBArtifacts = @() + } + try { + Assert-StackchanFirmwareReproducibilityProof ` + -Proof $diagnosticProof -DiagnosticPackage $true -AllowDirtyPackage $false ` + -ManifestCommit $commit -SourceEpoch "" ` + -ManifestStatus "diagnostic-only; reproducibility not proven; release and hardware validation forbidden" ` + -PackageRoot $fixtureRoot + throw "Diagnostic proof unexpectedly passed without -AllowDirtyPackage" + } catch { + if ($_.Exception.Message -notmatch "requires -AllowDirtyPackage") { throw } + } + Assert-StackchanFirmwareReproducibilityProof ` + -Proof $diagnosticProof -DiagnosticPackage $true -AllowDirtyPackage $true ` + -ManifestCommit $commit -SourceEpoch "" ` + -ManifestStatus "diagnostic-only; reproducibility not proven; release and hardware validation forbidden" ` + -PackageRoot $fixtureRoot + foreach ($mutation in @( + { param($p) $p.status = 'verified-two-clean-cycles' }, + { param($p) $p.clockBoundarySeconds = 65 }, + { param($p) $p.cycleASourceCommit = $script:commit }, + { param($p) $p.cycleAArtifacts = @([pscustomobject]@{ artifact = 'firmware.bin' }) } + )) { + $candidate = Copy-Proof $diagnosticProof + & $mutation $candidate + try { + Assert-StackchanFirmwareReproducibilityProof ` + -Proof $candidate -DiagnosticPackage $true -AllowDirtyPackage $true ` + -ManifestCommit $commit -SourceEpoch '' ` + -ManifestStatus "diagnostic-only; reproducibility not proven; release and hardware validation forbidden" ` + -PackageRoot $fixtureRoot + throw 'Mutated diagnostic reproducibility proof unexpectedly passed' + } catch { + if ($_.Exception.Message -eq 'Mutated diagnostic reproducibility proof unexpectedly passed') { throw } + if ($_.Exception.Message -notmatch 'reproducibility and hardware validation are unproven') { throw } + } + } +} finally { + if (Test-Path -LiteralPath $fixtureRoot) { + [System.IO.Directory]::Delete($fixtureRoot, $true) + } +} + +Write-Host "Firmware reproducibility proof mutation contract passed." diff --git a/tools/test_firmware_reproducible_build_contract.ps1 b/tools/test_firmware_reproducible_build_contract.ps1 new file mode 100644 index 00000000..5c54c64e --- /dev/null +++ b/tools/test_firmware_reproducible_build_contract.ps1 @@ -0,0 +1,832 @@ +$ErrorActionPreference = "Stop" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +. (Join-Path $PSScriptRoot "platformio_resolver.ps1") +$hookRelative = "tools/platformio_reproducible_build.py" +$hookMarker = "pre:$hookRelative" +$hookPath = Join-Path $repoRoot $hookRelative +$platformioPath = Join-Path $repoRoot "platformio.ini" +$packagePath = Join-Path $repoRoot "tools/package_release.ps1" +$verifyPath = Join-Path $repoRoot "tools/verify_release_package.ps1" +$workflowPath = Join-Path $repoRoot ".github/workflows/firmware.yml" +$proofContractPath = Join-Path $repoRoot "tools/test_firmware_reproducibility_proof_contract.ps1" +$failureContractPath = Join-Path $repoRoot "tools/test_firmware_reproducibility_failure_contract.ps1" +$verifierTrustContractPath = Join-Path $repoRoot "tools/test_release_package_verifier_trust_contract.ps1" +$sourceBindingContractPath = Join-Path $repoRoot "tools/test_release_source_binding_contract.ps1" +$dependencyEvidenceContractPath = Join-Path $repoRoot "tools/test_release_dependency_evidence_contract.ps1" +$issues = New-Object 'System.Collections.Generic.List[string]' + +function Require-ReproAssertion { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { $script:issues.Add($Message) } +} + +function Get-StrictLiteralArray { + param([System.Management.Automation.Language.Ast]$Ast) + + $node = $Ast + if ($node -is [System.Management.Automation.Language.PipelineAst]) { + if ($node.PipelineElements.Count -ne 1 -or + $node.PipelineElements[0] -isnot [System.Management.Automation.Language.CommandExpressionAst]) { + return [pscustomobject]@{ valid = $false; values = @() } + } + $node = $node.PipelineElements[0].Expression + } + if ($node -is [System.Management.Automation.Language.CommandExpressionAst]) { + $node = $node.Expression + } + if ($node -is [System.Management.Automation.Language.ArrayExpressionAst]) { + $statements = @($node.SubExpression.Statements) + if ($statements.Count -ne 1 -or + $statements[0] -isnot [System.Management.Automation.Language.PipelineAst] -or + $statements[0].PipelineElements.Count -ne 1 -or + $statements[0].PipelineElements[0] -isnot [System.Management.Automation.Language.CommandExpressionAst]) { + return [pscustomobject]@{ valid = $false; values = @() } + } + $node = $statements[0].PipelineElements[0].Expression + } + + $elements = if ($node -is [System.Management.Automation.Language.ArrayLiteralAst]) { + @($node.Elements) + } elseif ($node -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + @($node) + } else { + @() + } + if ($elements.Count -eq 0 -or + @($elements | Where-Object { + $_ -isnot [System.Management.Automation.Language.StringConstantExpressionAst] + }).Count -ne 0) { + return [pscustomobject]@{ valid = $false; values = @() } + } + return [pscustomobject]@{ + valid = $true + values = @($elements | ForEach-Object { [string]$_.Value }) + } +} + +function Get-EnvironmentBlock { + param([string]$Text, [string]$Name) + $escaped = [regex]::Escape($Name) + return [regex]::Match( + $Text, + "(?ms)^\[env:$escaped\]\s*(.*?)(?=^\[env:|\z)" + ).Value +} + +$expectedFirmwareEnvironments = @( + "stackchan", + "stackchan_servo_calibration", + "stackchan_wifi", + "stackchan_wifi_uplink", + "stackchan_wake_sr_probe", + "stackchan_wake_mww_probe", + "stackchan_wake_mww_uplink", + "stackchan_wake_mww_uplink_servos", + "stackchan_wake_mww_uplink_servos_hi", + "stackchan_wake_mww_uplink_servos_m5", + "stackchan_wake_mww_uplink_servos_m5_voiceout", + "stackchan_voice_v2", + "stackchan_release_forensics", + "stackchan_camera_probe", + "stackchan_camera_probe_pmic_telemetry_only", + "stackchan_camera_probe_pmic_policy_only", + "stackchan_camera_probe_pmic_all_off", + "stackchan_release_full", + "stackchan_sd_provisioner", + "stackchan_wake_sr_direct_probe", + "stackchan_wake_sr_afe_lite", + "stackchan_full_online" +) +$rootHookEnvironments = @( + "stackchan", + "stackchan_servo_calibration", + "stackchan_wifi_uplink", + "stackchan_wake_sr_probe", + "stackchan_wake_mww_probe", + "stackchan_wake_mww_uplink", + "stackchan_sd_provisioner", + "stackchan_wake_sr_direct_probe", + "stackchan_wake_sr_afe_lite", + "stackchan_full_online" +) + +$platformioText = Get-Content -LiteralPath $platformioPath -Raw +$packageText = Get-Content -LiteralPath $packagePath -Raw +$packageTokens = $null +$packageParseErrors = $null +$packageAst = [System.Management.Automation.Language.Parser]::ParseFile( + $packagePath, [ref]$packageTokens, [ref]$packageParseErrors) +$failureHelperText = Get-Content -LiteralPath (Join-Path $repoRoot 'tools/firmware_reproducibility_failure.ps1') -Raw +$packageGovernanceText = $packageText + "`n" + $failureHelperText +$verifyText = Get-Content -LiteralPath $verifyPath -Raw +$verifyTokens = $null +$verifyParseErrors = $null +$verifyAst = [System.Management.Automation.Language.Parser]::ParseFile( + $verifyPath, [ref]$verifyTokens, [ref]$verifyParseErrors) +$proofHelperText = Get-Content -LiteralPath ` + (Join-Path $repoRoot 'tools/firmware_reproducibility_proof.ps1') -Raw +$verifyGovernanceText = $verifyText + "`n" + $proofHelperText +$workflowText = Get-Content -LiteralPath $workflowPath -Raw +$contractText = Get-Content -LiteralPath $PSCommandPath -Raw + +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $proofContractPath +if ($LASTEXITCODE -ne 0) { + $issues.Add("proof-mutation-contract-failed: exit $LASTEXITCODE") +} +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $failureContractPath +if ($LASTEXITCODE -ne 0) { + $issues.Add("failed-build-retention-contract-failed: exit $LASTEXITCODE") +} +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifierTrustContractPath +if ($LASTEXITCODE -ne 0) { + $issues.Add("verifier-trust-contract-failed: exit $LASTEXITCODE") +} +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $sourceBindingContractPath +if ($LASTEXITCODE -ne 0) { + $issues.Add("release-source-binding-contract-failed: exit $LASTEXITCODE") +} +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $dependencyEvidenceContractPath +if ($LASTEXITCODE -ne 0) { + $issues.Add("release-dependency-evidence-contract-failed: exit $LASTEXITCODE") +} + +Require-ReproAssertion ($contractText.Contains('platformio_resolver.ps1') -and + $contractText.Contains('Invoke-StackchanPlatformio project config --json-output') -and + -not ($contractText -match '(?m)&\s+pio\s+project\s+config')) ` + "contract-resolver-missing: effective configuration must use the repository PlatformIO resolver" + +Require-ReproAssertion (Test-Path -LiteralPath $hookPath -PathType Leaf) ` + "repro-script-missing: $hookRelative" +if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + $hookText = Get-Content -LiteralPath $hookPath -Raw + foreach ($pathMarker in @('-ffile-prefix-map=', '_CANONICAL_DEBUG_ROOT', '_CANONICAL_CORE_ROOT', 'PROJECT_CORE_DIR')) { + Require-ReproAssertion ($hookText.Contains($pathMarker)) ` + "repro-path-normalization-missing: $pathMarker" + } +} + +try { + $config = ((Invoke-StackchanPlatformio project config --json-output | Out-String) | ConvertFrom-Json) + $firmwareEnvironments = @() + $nativeHookCount = -1 + $effectiveBuildCacheDir = "" + foreach ($section in $config) { + $name = [string]$section[0] + if ($name -eq "platformio") { + foreach ($item in $section[1]) { + if ([string]$item[0] -eq "build_cache_dir") { + $effectiveBuildCacheDir = [string]$item[1] + } + } + } + if (-not $name.StartsWith("env:")) { continue } + $framework = @() + $scripts = @() + foreach ($item in $section[1]) { + if ([string]$item[0] -eq "framework") { $framework = @($item[1]) } + if ([string]$item[0] -eq "extra_scripts") { $scripts = @($item[1]) } + } + $environment = $name.Substring(4) + $hookCount = @($scripts | Where-Object { [string]$_ -ceq $hookMarker }).Count + if ($framework -contains "arduino") { + $firmwareEnvironments += $environment + Require-ReproAssertion ($hookCount -eq 1) ` + "effective-hook-count: $environment expected 1, found $hookCount" + } + if ($environment -eq "native_logic") { $nativeHookCount = $hookCount } + } + Require-ReproAssertion ($firmwareEnvironments.Count -eq 22) ` + "effective-environment-count: expected 22 Arduino firmware environments, found $($firmwareEnvironments.Count)" + Require-ReproAssertion ((Compare-Object ` + ($expectedFirmwareEnvironments | Sort-Object) ` + ($firmwareEnvironments | Sort-Object)).Count -eq 0) ` + "effective-environment-set: Arduino firmware environment classification changed" + Require-ReproAssertion ($nativeHookCount -eq 0) ` + "effective-hook-count: native_logic expected 0, found $nativeHookCount" + Require-ReproAssertion ([string]::IsNullOrWhiteSpace($effectiveBuildCacheDir)) ` + "effective-build-cache: platformio.build_cache_dir must be empty outside the package's isolated per-cycle caches" +} catch { + $issues.Add("effective-config-unavailable: $($_.Exception.Message)") +} + +$rawHookCount = ([regex]::Matches($platformioText, "(?m)^\s+$([regex]::Escape($hookMarker))\s*$")).Count +Require-ReproAssertion ($rawHookCount -eq 10) ` + "raw-hook-count: expected 10 independent roots, found $rawHookCount" +Require-ReproAssertion (-not ($platformioText -match '(?mi)^\s*build_cache_dir\s*=')) ` + "raw-build-cache: platformio.ini must not configure a persistent firmware build cache" +foreach ($environment in $rootHookEnvironments) { + $block = Get-EnvironmentBlock -Text $platformioText -Name $environment + $extraScripts = [regex]::Match( + $block, + '(?ms)^extra_scripts\s*=\s*\r?\n(?(?:[ \t]+[^\r\n]+\r?\n?)*)') + $entries = if ($extraScripts.Success) { + @($extraScripts.Groups['entries'].Value -split '\r?\n' | + ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } else { @() } + Require-ReproAssertion ($entries.Count -gt 0 -and $entries[0] -ceq $hookMarker) ` + "raw-hook-order: $environment must put $hookMarker first" + Require-ReproAssertion (([regex]::Matches($block, [regex]::Escape($hookMarker))).Count -eq 1) ` + "raw-hook-count: $environment must contain the hook exactly once" +} +foreach ($environment in $expectedFirmwareEnvironments | Where-Object { $rootHookEnvironments -notcontains $_ }) { + $block = Get-EnvironmentBlock -Text $platformioText -Name $environment + Require-ReproAssertion (-not $block.Contains($hookMarker)) ` + "raw-hook-inheritance: $environment must inherit rather than redeclare the hook" +} + +$overrideNames = @( + "PLATFORMIO_BUILD_FLAGS", + "STACKCHAN_BUILD_EPOCH", + "SOURCE_DATE_EPOCH", + "STACKCHAN_BUILD_STAMP", + "STACKCHAN_DISABLE_REPRODUCIBLE_BUILD", + "STACKCHAN_EXPECTED_BUILD_COMMIT", + "STACKCHAN_EXPECTED_BUILD_EPOCH", + "STACKCHAN_PERSONA", + "STACKCHAN_WIFI_SSID", + "STACKCHAN_WIFI_PASSWORD", + "STACKCHAN_BRIDGE_HOST", + "STACKCHAN_BRIDGE_PORT", + "STACKCHAN_BRIDGE_PATH", + "STACKCHAN_PAIRING_SHORT_CODE", + "STACKCHAN_OTA_TOKEN", + "STACKCHAN_OTA_PORT", + "PLATFORMIO_EXE", + "PLATFORMIO_CORE_DIR", + "PLATFORMIO_PROJECT_DIR", + "PLATFORMIO_SRC_DIR", + "PLATFORMIO_BUILD_DIR", + "PLATFORMIO_LIBDEPS_DIR", + "PLATFORMIO_PACKAGES_DIR", + "PLATFORMIO_CACHE_DIR", + "PLATFORMIO_BUILD_CACHE_DIR", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CEILING_DIRECTORIES" +) +$pathWorkIndex = $packageText.IndexOf('$physicalRepoRoot =') +foreach ($overrideName in $overrideNames) { + $nameIndex = $packageText.IndexOf('"' + $overrideName + '"') + Require-ReproAssertion ($nameIndex -ge 0 -and $pathWorkIndex -ge 0 -and $nameIndex -lt $pathWorkIndex) ` + "release-override-preflight-missing: $overrideName must be rejected before path/cache/build/package work" +} +Require-ReproAssertion ($packageText.Contains('Test-Path ("Env:\" + $releaseOverrideName)')) ` + "release-override-preflight-missing: package guard must reject variable presence" +Require-ReproAssertion ($packageText.Contains('$_.Name -like "PLATFORMIO_*"') -and + $packageText.Contains('$_.Name -like "GIT_*"')) ` + "release-override-preflight-missing: package must reject all ambient PlatformIO and Git overrides" +$packageContractIndex = $packageText.IndexOf( + '(Join-Path $PSScriptRoot "test_firmware_reproducible_build_contract.ps1")') +$packageResolverIndex = $packageText.IndexOf( + '. (Join-Path $PSScriptRoot "platformio_resolver.ps1")') +Require-ReproAssertion ($packageContractIndex -ge 0 -and $packageResolverIndex -ge 0 -and + $packageContractIndex -lt $packageResolverIndex) ` + "package-contract-order: reproducibility contract must run before resolver/cache/build work" +foreach ($marker in @( + '-SkipBuild is diagnostic-only and requires -AllowDirty', + '-AllowDirty supports diagnostic -SkipBuild packages only', + 'verified-two-clean-cycles', + 'minimumClockBoundarySeconds = 65', + "-CycleName 'cycle-a'", + "-CycleName 'cycle-b'", + 'isolated-empty-per-cycle-environment', + 'distinct-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths', + 'STACKCHAN_EXPECTED_BUILD_COMMIT', + 'STACKCHAN_EXPECTED_BUILD_EPOCH', + 'output/private/reproducibility-failures', + 'stackchan.firmware-reproducibility-failure.v2', + 'failed-full-worktree-preserved', + 'full-failed-worktree-retained-attached' +)) { + Require-ReproAssertion ($packageGovernanceText.Contains($marker)) ` + "package-artifact-proof-missing: $marker" +} +Require-ReproAssertion (-not $packageText.Contains('STACKCHAN_RELEASE_SHORT_PATH_ACTIVE')) ` + "package-short-path-bypass: ambient short-path sentinel must not bypass canonical path handling" +Require-ReproAssertion (-not $packageText.Contains('.firmware-build-cache-*')) ` + "package-failure-evidence: package startup must not wildcard-delete prior failed build caches" +foreach ($operationalTool in @( + "tools/flash_release_firmware.ps1", + "tools/prepare_device_arrival.ps1", + "tools/start_hardware_evidence.ps1", + "tools/publish_release.ps1" +)) { + $operationalText = Get-Content -LiteralPath (Join-Path $repoRoot $operationalTool) -Raw + Require-ReproAssertion ($operationalText.Contains("RequireReleaseEligible")) ` + "diagnostic-operational-containment: $operationalTool must require release eligibility" +} + +$m0GovernanceTools = @( + "tools/firmware_reproducibility_proof.ps1", + "tools/test_firmware_reproducibility_proof_contract.ps1", + "tools/firmware_reproducibility_failure.ps1", + "tools/test_firmware_reproducibility_failure_contract.ps1", + "tools/release_source_binding.ps1", + "tools/release_dependency_evidence.ps1", + "tools/test_release_dependency_evidence_contract.ps1", + "tools/release_git_trust.ps1", + "tools/test_release_package_verifier_trust_contract.ps1", + "tools/test_release_source_binding_contract.ps1", + "tools/release_zip_safety.ps1", + "tools/platformio_resolver.ps1", + $hookRelative, + "tools/test_firmware_reproducible_build_contract.ps1" +) +foreach ($path in $m0GovernanceTools) { + Require-ReproAssertion ($verifyText.Contains('"' + $path + '"')) ` + "package-verifier-missing: $path" +} +Require-ReproAssertion ($packageParseErrors.Count -eq 0) ` + "package-governance-wiring-parse: package script must parse before M0 membership can be audited" +if ($packageParseErrors.Count -eq 0) { + $releaseToolsAssignments = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -eq '$releaseTools' + }, $true)) + $manifestAssignments = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -eq '$manifest' + }, $true)) + Require-ReproAssertion ($releaseToolsAssignments.Count -eq 1) ` + "package-release-tools-wiring: releaseTools assignment must be unique" + Require-ReproAssertion ($manifestAssignments.Count -eq 1) ` + "package-manifest-wiring: release manifest assignment must be unique" + if ($releaseToolsAssignments.Count -eq 1 -and $manifestAssignments.Count -eq 1) { + $releaseToolsLiteralArray = Get-StrictLiteralArray -Ast $releaseToolsAssignments[0].Right + Require-ReproAssertion ([bool]$releaseToolsLiteralArray.valid) ` + "package-release-tools-literals: releaseTools must be one runtime array made only of direct string literals" + $releaseToolValues = @($releaseToolsLiteralArray.values) + foreach ($path in $m0GovernanceTools) { + Require-ReproAssertion ( + @($releaseToolValues | Where-Object { $_ -ceq $path }).Count -eq 1) ` + "package-release-tools-membership: $path must appear exactly once" + } + $inventoryPairs = @($manifestAssignments[0].FindAll({ + param($node) + $node -is [System.Management.Automation.Language.HashtableAst] + }, $true) | ForEach-Object { $_.KeyValuePairs }) + foreach ($inventorySpec in @( + [pscustomobject]@{ manifestKey = 'includedTools'; variable = 'includedToolsInventory' }, + [pscustomobject]@{ manifestKey = 'provenanceFiles'; variable = 'provenanceFileInventory' } + )) { + $pairs = @($inventoryPairs | Where-Object { + $_.Item1.Extent.Text -ceq [string]$inventorySpec.manifestKey + }) + Require-ReproAssertion ($pairs.Count -eq 1 -and + $pairs[0].Item2.Extent.Text -ceq "@(`$$([string]$inventorySpec.variable))") ` + "package-manifest-canonical-inventory-wiring: $($inventorySpec.manifestKey) must use its runtime canonical inventory" + } + } + + foreach ($inventorySpec in @( + [pscustomobject]@{ + variable = 'includedToolsInventory' + directory = 'toolsDir' + prefix = 'tools' + }, + [pscustomobject]@{ + variable = 'provenanceFileInventory' + directory = 'provenanceDir' + prefix = 'provenance' + } + )) { + $assignments = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq "`$$([string]$inventorySpec.variable)" + }, $true)) + $expectedExpression = "@(Get-CanonicalPackageFileInventory -Directory `$$([string]$inventorySpec.directory) -PackagePrefix `"$([string]$inventorySpec.prefix)`")" + Require-ReproAssertion ($assignments.Count -eq 1 -and + $assignments[0].Right.Extent.Text -ceq $expectedExpression) ` + "package-canonical-inventory-source: $($inventorySpec.variable) must enumerate the packaged directory exactly once" + } + foreach ($canonicalMarker in @( + 'Get-ChildItem -LiteralPath $resolvedDirectory -File -Recurse -Force', + 'ConvertTo-CanonicalPackageInventory -PackagePaths $packagePaths', + '[System.StringComparer]::OrdinalIgnoreCase', + '[Array]::Sort($result, [System.StringComparer]::Ordinal)', + "`$segments[0] -cne `$RequiredPrefix", + "`$segments | Where-Object { [string]::IsNullOrWhiteSpace(`$_) -or `$_ -in @('.', '..') }" + )) { + Require-ReproAssertion ($packageText.Contains($canonicalMarker)) ` + "package-canonical-inventory-policy-missing: $canonicalMarker" + } +} +Require-ReproAssertion ($verifyParseErrors.Count -eq 0) ` + "package-verifier-wiring-parse: verifier must parse before M0 wiring can be audited" +if ($verifyParseErrors.Count -eq 0) { + $requiredFilesWiring = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -eq '$requiredFiles' -and + $node.Operator -eq [System.Management.Automation.Language.TokenKind]::PlusEquals -and + $node.Right.Extent.Text -eq '$m0GovernanceTools' + }, $true)) + Require-ReproAssertion ($requiredFilesWiring.Count -eq 1) ` + "package-verifier-required-files-wiring: M0 tools must extend requiredFiles exactly once" + + $includedToolsSources = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -eq '$includedTools' -and + $node.Operator -eq [System.Management.Automation.Language.TokenKind]::Equals -and + $node.Right.Extent.Text -eq '@($manifest.includedTools)' + }, $true)) + Require-ReproAssertion ($includedToolsSources.Count -eq 1) ` + "package-verifier-included-tools-source: verifier must use manifest includedTools" + + $exactInventoryFunctions = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Assert-ExactOperationalInventory' + }, $true)) + Require-ReproAssertion ($exactInventoryFunctions.Count -eq 1) ` + "package-verifier-exact-inventory-function: verifier must define one exact inventory gate" + if ($exactInventoryFunctions.Count -eq 1) { + $exactInventoryText = $exactInventoryFunctions[0].Extent.Text + foreach ($marker in @( + '[StringComparer]::Ordinal', '[StringComparer]::OrdinalIgnoreCase', + 'unsafe, duplicate, or case-colliding', 'inventory is not ordinally sorted', + 'inventory count does not match packaged files', 'contains an undeclared', + 'declares a missing' + )) { + Require-ReproAssertion ($exactInventoryText.Contains($marker)) ` + "package-verifier-exact-inventory-policy: $marker" + } + } + foreach ($exactCall in @( + "-ManifestEntries @(`$Manifest.includedTools) -PackagePrefix 'tools'", + "-ManifestEntries @(`$Manifest.provenanceFiles) -PackagePrefix 'provenance'" + )) { + Require-ReproAssertion ($verifyText.Contains($exactCall)) ` + "package-verifier-exact-inventory-call: $exactCall" + } + foreach ($bindingMarker in @( + 'Get-OperationalTrustedCommitMaps', + 'Get-TrustedReleaseToolPolicy', + 'Get-TrustedProvenancePolicy', + 'Assert-OperationalSourceCheckoutBindings', + 'foreach ($includedTool in $trustedToolPolicy)', + '-TrustedSourceRelativePath $includedTool -CommitMaps $commitMaps', + 'foreach ($provenanceFile in $trustedProvenancePolicy)', + '-TrustedSourceRelativePath ([string]$provenancePolicy[$provenanceFile])' + )) { + Require-ReproAssertion ($verifyText.Contains($bindingMarker)) ` + "package-verifier-exact-inventory-binding: $bindingMarker" + } + + $includedToolsLoops = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.ForEachStatementAst] -and + $node.Variable.VariablePath.UserPath -eq 'governanceTool' -and + $node.Condition.Extent.Text -eq '$m0GovernanceTools' + }, $true)) + Require-ReproAssertion ($includedToolsLoops.Count -eq 1) ` + "package-verifier-included-tools-loop: verifier must enforce every M0 declaration" + if ($includedToolsLoops.Count -eq 1) { + $missingM0Expressions = @($includedToolsLoops[0].FindAll({ + param($node) + $node -is [System.Management.Automation.Language.BinaryExpressionAst] -and + $node.Operator -eq [System.Management.Automation.Language.TokenKind]::Inotcontains -and + $node.Left.Extent.Text -eq '$includedTools' -and + $node.Right.Extent.Text -eq '$governanceTool' + }, $true)) + $missingM0Throws = @($includedToolsLoops[0].FindAll({ + param($node) + $node -is [System.Management.Automation.Language.ThrowStatementAst] -and + $node.Extent.Text.Contains('Manifest includedTools is missing M0 governance input') + }, $true)) + Require-ReproAssertion ( + $missingM0Expressions.Count -eq 1 -and $missingM0Throws.Count -eq 1) ` + "package-verifier-included-tools-rejection: missing M0 declarations must fail closed" + } +} +foreach ($marker in @( + "firmwareReproducibility", + "git-commit-epoch-builtins-v1", + "release-overrides-fail-closed", + "exactly-one-effective-hook" +)) { + Require-ReproAssertion ($packageText.Contains($marker)) "package-provenance-missing: $marker" + Require-ReproAssertion ($verifyGovernanceText.Contains($marker)) "package-verifier-missing: $marker" +} +foreach ($marker in @( + "verified-two-clean-cycles", + "not-proven-skip-build", + "Firmware reproducibility proof must contain 12 artifacts per cycle", + "Packaged artifact does not match reproducibility cycle B", + "Firmware reproducibility proof clock boundary is inconsistent", + "expectedProofKeys", + "unexpected or duplicate artifact", + "cycleASourceCommit", + "identityAttestations", + "distinct-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths", + "DIAGNOSTIC_PACKAGE_DO_NOT_FLASH.txt", + "Diagnostic archive integrity verified; release and hardware use forbidden:", + "RequireReleaseEligible" +)) { + Require-ReproAssertion ($verifyGovernanceText.Contains($marker)) "package-verifier-missing: $marker" +} + +$workflowStep = "Run firmware reproducibility contract" +Require-ReproAssertion ($workflowText.Contains($workflowStep) -and + $workflowText.Contains("./tools/test_firmware_reproducible_build_contract.ps1") -and + $workflowText.Contains("pio run -e stackchan_release_full")) ` + "ci-gate-missing: $workflowStep" + +if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + $pythonHarness = @' +import contextlib +import io +import os +from pathlib import Path +import runpy +import shutil +import subprocess +import sys +import tempfile + +HOOK = Path(sys.argv[1]).resolve() +MANAGED = { + "STACKCHAN_BUILD_EPOCH", + "SOURCE_DATE_EPOCH", + "STACKCHAN_BUILD_STAMP", + "STACKCHAN_DISABLE_REPRODUCIBLE_BUILD", + "STACKCHAN_EXPECTED_BUILD_COMMIT", + "STACKCHAN_EXPECTED_BUILD_EPOCH", + "TZ", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CEILING_DIRECTORIES", +} + +class FakeEnv(dict): + def AppendUnique(self, **values): + for key, incoming in values.items(): + current = list(self.get(key, [])) + for value in incoming: + if value not in current: + current.append(value) + self[key] = current + +def invoke(project, supplied=None): + previous = {key: os.environ.get(key) for key in MANAGED} + try: + for key in MANAGED: + os.environ.pop(key, None) + for key, value in (supplied or {}).items(): + os.environ[key] = value + fake = FakeEnv( + PROJECT_DIR=str(project), + PROJECT_CORE_DIR=str(root / "pio-core"), + ENV={}, + ) + output = io.StringIO() + with contextlib.redirect_stdout(output): + runpy.run_path( + str(HOOK), + init_globals={"Import": lambda *_: None, "env": fake}, + ) + return list(fake.get("CCFLAGS", [])), dict(fake["ENV"]), output.getvalue() + finally: + for key in MANAGED: + os.environ.pop(key, None) + for key, value in previous.items(): + if value is not None: + os.environ[key] = value + +def expect_failure(project, supplied, marker): + try: + invoke(project, supplied) + except Exception as exc: + if marker not in str(exc): + raise AssertionError(f"expected {marker!r} in {exc!r}") from exc + return + raise AssertionError(f"expected failure containing {marker!r}") + +def non_path_flags(flags): + return [flag for flag in flags if not flag.startswith("-ffile-prefix-map=")] + +def prefix_flags(path, canonical): + lexical = Path(os.path.abspath(str(path))) + candidates = [] + for candidate in (lexical.as_posix(), lexical.resolve().as_posix()): + candidate = candidate.rstrip("/") + if candidate not in candidates: + candidates.append(candidate) + return [f"-ffile-prefix-map={candidate}={canonical}" for candidate in candidates] + +def expected_path_flags(project): + return ( + prefix_flags(project, "/stackchan/source") + + prefix_flags(root / "pio-core", "/stackchan/platformio-core") + ) + +root = Path(tempfile.mkdtemp(prefix="stackchan-repro-contract-")) +try: + repo = root / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "Stackchan Contract"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.email", "contract@example.invalid"], cwd=repo, check=True) + (repo / "tracked.txt").write_text("stable\n", encoding="utf-8") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True) + commit_env = dict(os.environ) + commit_env["GIT_AUTHOR_DATE"] = "1700000000 +0000" + commit_env["GIT_COMMITTER_DATE"] = "1700000000 +0000" + subprocess.run(["git", "commit", "-q", "-m", "fixture"], cwd=repo, env=commit_env, check=True) + fixture_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True + ).stdout.strip() + + flags_a, child_a, log_a = invoke(repo, {"TZ": "Pacific/Honolulu"}) + flags_b, child_b, log_b = invoke(repo, {"TZ": "Asia/Tokyo"}) + assert flags_a == flags_b + assert child_a["SOURCE_DATE_EPOCH"] == "1700000000" + assert child_b["SOURCE_DATE_EPOCH"] == "1700000000" + assert flags_a.count("-Wno-builtin-macro-redefined") == 1 + assert flags_a.count('-D__DATE__=\\"Nov 14 2023\\"') == 1 + assert flags_a.count('-D__TIME__=\\"22:13:20\\"') == 1 + assert [flag for flag in flags_a if flag.startswith("-ffile-prefix-map=")] == expected_path_flags(repo) + assert "1700000000" in log_a and "1700000000" in log_b + + locked_flags, locked_child, _ = invoke( + repo, + { + "STACKCHAN_EXPECTED_BUILD_COMMIT": fixture_commit, + "STACKCHAN_EXPECTED_BUILD_EPOCH": "1700000000", + }, + ) + assert locked_flags == flags_a + assert locked_child["SOURCE_DATE_EPOCH"] == "1700000000" + expect_failure( + repo, + { + "STACKCHAN_EXPECTED_BUILD_COMMIT": "0" * 40, + "STACKCHAN_EXPECTED_BUILD_EPOCH": "1700000000", + }, + "identity lock", + ) + expect_failure( + repo, + { + "STACKCHAN_EXPECTED_BUILD_COMMIT": fixture_commit, + "STACKCHAN_EXPECTED_BUILD_EPOCH": "1700000001", + }, + "epoch", + ) + expect_failure( + repo, + {"STACKCHAN_EXPECTED_BUILD_COMMIT": fixture_commit}, + "supplied together", + ) + + no_git = root / "no-git" + no_git.mkdir() + expect_failure(no_git, None, "Git") + + override_flags, override_child, _ = invoke( + no_git, {"STACKCHAN_BUILD_EPOCH": "1700000000"} + ) + assert non_path_flags(override_flags) == non_path_flags(flags_a) + assert [flag for flag in override_flags if flag.startswith("-ffile-prefix-map=")] == expected_path_flags(no_git) + assert override_child["SOURCE_DATE_EPOCH"] == "1700000000" + + nested = repo / "nested" + nested.mkdir() + expect_failure(nested, None, "project directory") + + invalid_epochs = [ + "", " 1700000000", "1700000000 ", "+1700000000", "1.0", + "999999999999999999999999", "1\n2", '1" -D BAD=1', "$(whoami)", + ] + for value in invalid_epochs: + expect_failure(no_git, {"STACKCHAN_BUILD_EPOCH": value}, "STACKCHAN_BUILD_EPOCH") + + for legacy in ("STACKCHAN_BUILD_STAMP", "STACKCHAN_DISABLE_REPRODUCIBLE_BUILD"): + expect_failure(repo, {legacy: ""}, legacy) + expect_failure(repo, {legacy: "1"}, legacy) + expect_failure(repo, {"SOURCE_DATE_EPOCH": "1700000000"}, "SOURCE_DATE_EPOCH") + + alternate = root / "alternate" + alternate.mkdir() + subprocess.run(["git", "init", "-q"], cwd=alternate, check=True) + subprocess.run(["git", "config", "user.name", "Stackchan Contract"], cwd=alternate, check=True) + subprocess.run(["git", "config", "user.email", "contract@example.invalid"], cwd=alternate, check=True) + (alternate / "other.txt").write_text("other\n", encoding="utf-8") + subprocess.run(["git", "add", "other.txt"], cwd=alternate, check=True) + subprocess.run(["git", "commit", "-q", "-m", "alternate"], cwd=alternate, env=commit_env, check=True) + expect_failure( + repo, + {"GIT_DIR": str(alternate / ".git"), "GIT_WORK_TREE": str(alternate)}, + "GIT_DIR", + ) + + (repo / "tracked.txt").write_text("dirty\n", encoding="utf-8") + expect_failure(repo, None, "clean") + dirty_override_flags, _, _ = invoke( + repo, {"STACKCHAN_BUILD_EPOCH": "1700000000"} + ) + assert dirty_override_flags == flags_a + subprocess.run(["git", "checkout", "--", "tracked.txt"], cwd=repo, check=True) + (repo / "untracked.txt").write_text("untracked\n", encoding="utf-8") + expect_failure(repo, None, "clean") + (repo / "untracked.txt").unlink() + + worktree_a = root / "a" + worktree_b = root / "different-length-b" + subprocess.run(["git", "worktree", "add", "--detach", str(worktree_a), fixture_commit], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "worktree", "add", "--detach", str(worktree_b), fixture_commit], cwd=repo, check=True, capture_output=True) + worktree_flags_a, _, _ = invoke(worktree_a) + worktree_flags_b, _, _ = invoke(worktree_b) + assert non_path_flags(worktree_flags_a) == non_path_flags(worktree_flags_b) + assert expected_path_flags(worktree_a) != expected_path_flags(worktree_b) + assert [flag for flag in worktree_flags_a if flag.startswith("-ffile-prefix-map=")] == expected_path_flags(worktree_a) + assert [flag for flag in worktree_flags_b if flag.startswith("-ffile-prefix-map=")] == expected_path_flags(worktree_b) +finally: + shutil.rmtree(root, ignore_errors=True) +'@ + $encodedHarness = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pythonHarness)) + $pythonResult = & python -c "import base64;exec(base64.b64decode('$encodedHarness'))" $hookPath 2>&1 + Require-ReproAssertion ($LASTEXITCODE -eq 0) ` + "hook-behavior-failed: $($pythonResult | Out-String)" +} + +function Test-PrefixMapCompiler { + param([Parameter(Mandatory = $true)][string]$Compiler) + + $probeRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + "stackchan-prefix-map-contract-" + [guid]::NewGuid().ToString("N")) + $rootA = Join-Path $probeRoot "a" + $rootB = Join-Path $probeRoot "different-length-b" + try { + New-Item -ItemType Directory -Force -Path $rootA, $rootB | Out-Null + foreach ($rootPath in @($rootA, $rootB)) { + [System.IO.File]::WriteAllText( + (Join-Path $rootPath "probe.cpp"), + "const char* source_path = __FILE__; int main(){return source_path[0] == 0;}`n", + (New-Object System.Text.UTF8Encoding($false))) + Push-Location $rootPath + try { + & $Compiler -x c++ -g -S probe.cpp -o raw.s + if ($LASTEXITCODE -ne 0) { throw "raw compiler probe failed" } + $forwardRoot = ([System.IO.Path]::GetFullPath($rootPath)).Replace('\', '/').TrimEnd('/') + & $Compiler -x c++ -g -S "-ffile-prefix-map=$forwardRoot=/stackchan/source" probe.cpp -o mapped.s + if ($LASTEXITCODE -ne 0) { throw "mapped compiler probe failed" } + } finally { + Pop-Location + } + } + $rawA = (Get-FileHash -LiteralPath (Join-Path $rootA 'raw.s') -Algorithm SHA256).Hash + $rawB = (Get-FileHash -LiteralPath (Join-Path $rootB 'raw.s') -Algorithm SHA256).Hash + $mappedAPath = Join-Path $rootA 'mapped.s' + $mappedBPath = Join-Path $rootB 'mapped.s' + $mappedA = (Get-FileHash -LiteralPath $mappedAPath -Algorithm SHA256).Hash + $mappedB = (Get-FileHash -LiteralPath $mappedBPath -Algorithm SHA256).Hash + Require-ReproAssertion ($rawA -cne $rawB) ` + "compiler-prefix-map-probe: raw outputs unexpectedly matched for $Compiler" + Require-ReproAssertion ($mappedA -ceq $mappedB) ` + "compiler-prefix-map-probe: mapped outputs differ for $Compiler" + $mappedText = (Get-Content -LiteralPath $mappedAPath -Raw) + (Get-Content -LiteralPath $mappedBPath -Raw) + Require-ReproAssertion (-not $mappedText.Contains($rootA.Replace('\', '/')) -and + -not $mappedText.Contains($rootB.Replace('\', '/'))) ` + "compiler-prefix-map-probe: mapped output retains a checkout root for $Compiler" + } catch { + $issues.Add("compiler-prefix-map-probe: $Compiler $($_.Exception.Message)") + } finally { + if (Test-Path -LiteralPath $probeRoot) { + [System.IO.Directory]::Delete($probeRoot, $true) + } + } +} + +$compilerCandidates = @( + (Join-Path (Get-StackchanPlatformioCoreDir) 'packages/toolchain-xtensa-esp32s3/bin/xtensa-esp32s3-elf-g++.exe'), + (Join-Path (Get-StackchanPlatformioCoreDir) 'packages/toolchain-xtensa-esp-elf/bin/xtensa-esp32s3-elf-g++.exe') +) +if ($env:OS -eq 'Windows_NT') { + $compilerCandidates += Join-Path ([System.IO.Path]::GetPathRoot($env:SystemRoot)) ` + 'spio/pioarduino/packages/toolchain-xtensa-esp-elf/bin/xtensa-esp32s3-elf-g++.exe' +} +foreach ($compiler in @($compilerCandidates | Sort-Object -Unique)) { + if (Test-Path -LiteralPath $compiler -PathType Leaf) { + Test-PrefixMapCompiler -Compiler $compiler + } +} + +if ($issues.Count -gt 0) { + throw ("Firmware reproducible-build contract failed:`n- " + ($issues -join "`n- ")) +} + +Write-Host "Firmware reproducible-build contract verified for all 22 firmware environments." diff --git a/tools/test_platformio_utf8_contract.ps1 b/tools/test_platformio_utf8_contract.ps1 index c4dffa17..53660af0 100644 --- a/tools/test_platformio_utf8_contract.ps1 +++ b/tools/test_platformio_utf8_contract.ps1 @@ -63,6 +63,8 @@ if ($platformioText -notmatch '(?ms)\[env:stackchan_release_forensics\].*?upload } $packageText = Get-Content -LiteralPath "tools\package_release.ps1" -Raw +$failureHelperText = Get-Content -LiteralPath "tools\firmware_reproducibility_failure.ps1" -Raw +$failureGovernanceText = $packageText + "`n" + $failureHelperText if ($packageText -match 'run\s+-e\s+stackchan\s+-e\s+stackchan_servo_calibration') { throw "Release packaging must not mix legacy and pioarduino environments in one PlatformIO process." } @@ -71,7 +73,7 @@ foreach ($environment in @("stackchan", "stackchan_servo_calibration", "stackcha throw "Release packaging is missing firmware environment: $environment" } } -foreach ($required in @("firmware-build-cache", "Copy-BuildArtifacts", 'Join-Path $builtFirmwareCache $environment')) { +foreach ($required in @("firmware-build-cache", "Copy-BuildArtifacts", '$firmwareSourceRoot', 'cycle-b')) { if (-not $packageText.Contains($required)) { throw "Release packaging is missing mixed-toolchain artifact preservation: $required" } @@ -84,10 +86,13 @@ foreach ($required in @("PLATFORMIO_CORE_DIR", "Get-ReleasePlatformioCoreDir", ' if (-not $packageText.Contains('GetPathRoot($env:SystemRoot)')) { throw "Release packaging must anchor the short pioarduino core to the physical Windows system drive." } -$staleCacheCleanupIndex = $packageText.IndexOf('Get-ChildItem -LiteralPath $releaseOutputRoot') -$currentCacheCreateIndex = $packageText.IndexOf('$builtFirmwareCache = Join-Path') -if ($staleCacheCleanupIndex -lt 0 -or $currentCacheCreateIndex -lt 0 -or $staleCacheCleanupIndex -gt $currentCacheCreateIndex) { - throw "Release packaging must remove stale firmware caches before creating the current build cache." +foreach ($required in @("[guid]::NewGuid()", "output/private/reproducibility-failures", "failed-full-worktree-preserved", 'Move-Item -LiteralPath $BuildCacheRoot')) { + if (-not $failureGovernanceText.Contains($required)) { + throw "Release packaging must preserve failed reproducibility artifacts without colliding with another run: $required" + } +} +if ($packageText.Contains('.firmware-build-cache-*')) { + throw "Release packaging must not wildcard-delete prior or concurrent firmware build evidence." } $releaseVerifierText = Get-Content -LiteralPath "tools\verify_release_package.ps1" -Raw @@ -96,9 +101,8 @@ foreach ($required in @( '^55\.3\.36\+sha\.aa6e97c$', '^3\.3\.6$', 'toolchain-xtensa-esp-elf', - 'knownFullOnlineM5Gfx', - '0.2.24', - '0.2.25' + 'knownPinnedM5GfxWithTransitiveCopy', + '0.2.24' )) { if (-not $releaseVerifierText.Contains($required)) { throw "Release verifier is missing mixed-toolchain lock coverage: $required" @@ -108,7 +112,7 @@ foreach ($required in @( 'verify_release_package.ps1', 'package-verify.log', 'AllowDirtyPackage', - 'Release ZIP verification failed' + 'Package ZIP verification failed' )) { if (-not $packageText.Contains($required)) { throw "Release packaging is missing mandatory post-build package verification: $required" diff --git a/tools/test_release_boot_motion_contract.ps1 b/tools/test_release_boot_motion_contract.ps1 index 5ffa0aea..e4c55b61 100644 --- a/tools/test_release_boot_motion_contract.ps1 +++ b/tools/test_release_boot_motion_contract.ps1 @@ -71,10 +71,11 @@ foreach ($staleClaim in @( } } -$packageOverrideGuard = 'if (Test-Path Env:\PLATFORMIO_BUILD_FLAGS)' -$guardIndex = $packageRelease.IndexOf($packageOverrideGuard) +$guardIndex = $packageRelease.IndexOf('"PLATFORMIO_BUILD_FLAGS"') +$presenceGuardIndex = $packageRelease.IndexOf('Test-Path ("Env:\" + $releaseOverrideName)') $pathWorkIndex = $packageRelease.IndexOf('$physicalRepoRoot =') -if ($guardIndex -lt 0 -or $pathWorkIndex -lt 0 -or $guardIndex -gt $pathWorkIndex) { +if ($guardIndex -lt 0 -or $presenceGuardIndex -lt 0 -or $pathWorkIndex -lt 0 -or + $guardIndex -gt $pathWorkIndex -or $presenceGuardIndex -gt $pathWorkIndex) { throw "Release packaging must reject PLATFORMIO_BUILD_FLAGS before path, cache, build, or package work." } diff --git a/tools/test_release_command_trust_contract.ps1 b/tools/test_release_command_trust_contract.ps1 new file mode 100644 index 00000000..f89d2f13 --- /dev/null +++ b/tools/test_release_command_trust_contract.ps1 @@ -0,0 +1,274 @@ +$ErrorActionPreference = 'Stop' + +$packagePath = Join-Path $PSScriptRoot 'package_release.ps1' +$verifyPath = Join-Path $PSScriptRoot 'verify_release_package.ps1' +$gitTrustPath = Join-Path $PSScriptRoot 'release_git_trust.ps1' +$platformioResolverPath = Join-Path $PSScriptRoot 'platformio_resolver.ps1' +$previewPythonResolverPath = Join-Path $PSScriptRoot 'preview_python_resolver.ps1' +$packageText = Get-Content -LiteralPath $packagePath -Raw +$verifyText = Get-Content -LiteralPath $verifyPath -Raw +$gitTrustText = Get-Content -LiteralPath $gitTrustPath -Raw +$platformioResolverText = Get-Content -LiteralPath $platformioResolverPath -Raw +$previewPythonResolverText = Get-Content -LiteralPath $previewPythonResolverPath -Raw + +foreach ($scriptPath in @( + $packagePath, $verifyPath, $gitTrustPath, $platformioResolverPath, + $previewPythonResolverPath +)) { + $tokens = $null + $parseErrors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $scriptPath, [ref]$tokens, [ref]$parseErrors) | Out-Null + if ($parseErrors.Count -ne 0) { + throw "Release command-trust input does not parse: $scriptPath" + } +} + +$packageFailureOffset = $packageText.IndexOf('if (-not $SkipBuild)', [StringComparison]::Ordinal) +$packageGitOffset = $packageText.IndexOf( + '$releaseBootstrapGitCommand = Get-Command', [StringComparison]::Ordinal) +if ($packageFailureOffset -lt 0 -or $packageGitOffset -lt 0 -or + $packageFailureOffset -ge $packageGitOffset -or + -not $packageText.Contains('Release-grade packaging is fail-closed before Git or build-tool execution')) { + throw 'Release-grade packaging is not fail-closed before its first Git resolution.' +} +$verifyFailureOffset = $verifyText.IndexOf('if ($RequireReleaseEligible)', [StringComparison]::Ordinal) +$verifyGitOffset = $verifyText.IndexOf('$trustedGitCommand = Get-Command', [StringComparison]::Ordinal) +if ($verifyFailureOffset -lt 0 -or $verifyGitOffset -lt 0 -or + $verifyFailureOffset -ge $verifyGitOffset -or + -not $verifyText.Contains('Release-eligible verification is fail-closed before Git or build-tool execution')) { + throw 'Release-eligible verification is not fail-closed before its first Git resolution.' +} + +foreach ($forbidden in @('& powershell.exe', '& subst.exe', '& tar.exe', 'git-lfs')) { + if ($packageText.Contains($forbidden) -or $verifyText.Contains($forbidden) -or + $gitTrustText.Contains($forbidden)) { + throw "Release command trust contains a forbidden ambient command path: $forbidden" + } +} +foreach ($required in @( + '[Environment]::SystemDirectory', 'New-StackchanDeterministicReleaseZip', + '[System.IO.Compression.ZipArchive]', '[System.IO.FileMode]::CreateNew' +)) { + if (-not $packageText.Contains($required)) { + throw "Release packaging is missing deterministic command/archive trust: $required" + } +} +if (-not $verifyText.Contains('[Environment]::SystemDirectory') -or + -not $verifyText.Contains('& $verifierPowerShellExecutable')) { + throw 'Release verification does not pin Windows PowerShell to its validated system path.' +} +foreach ($required in @( + '[Parameter(Mandatory = $true)][string]$GitExecutable', + '& $resolvedGitExecutable @gitArguments', 'filter.lfs.process=', + 'filter.lfs.smudge=', 'filter.lfs.clean=', 'filter.lfs.required=false' +)) { + if (-not $gitTrustText.Contains($required)) { + throw "Trusted Git wrapper is missing pinned/LFS-disabled behavior: $required" + } +} +if ($gitTrustText.Contains('Get-Command') -or $gitTrustText.Contains('PinLfsFilter')) { + throw 'Trusted Git wrapper must not re-resolve Git or optionally enable LFS.' +} +foreach ($resolver in @( + [pscustomobject]@{ label = 'PlatformIO'; text = $platformioResolverText }, + [pscustomobject]@{ label = 'preview Python'; text = $previewPythonResolverText } +)) { + foreach ($required in @('-CommandType Application', 'FileAttributes]::ReparsePoint', "-cne '.exe'")) { + if (-not $resolver.text.Contains($required)) { + throw "$($resolver.label) resolver does not require an exact application executable: $required" + } + } +} + +$packageTokens = $null +$packageParseErrors = $null +$packageAst = [System.Management.Automation.Language.Parser]::ParseFile( + $packagePath, [ref]$packageTokens, [ref]$packageParseErrors) +$zipFunctions = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'New-StackchanDeterministicReleaseZip' +}, $true)) +if ($zipFunctions.Count -ne 1) { + throw 'Deterministic release ZIP function is ambiguous.' +} +. ([scriptblock]::Create($zipFunctions[0].Extent.Text)) + +$contractRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-release-command-trust-' + [guid]::NewGuid().ToString('N')) +try { + $treeOne = Join-Path $contractRoot 'tree-one' + $treeTwo = Join-Path $contractRoot 'tree-two' + [System.IO.Directory]::CreateDirectory((Join-Path $treeOne 'nested')) | Out-Null + [System.IO.Directory]::CreateDirectory((Join-Path $treeTwo 'nested')) | Out-Null + foreach ($tree in @($treeOne, $treeTwo)) { + [System.IO.File]::WriteAllText( + (Join-Path $tree 'z-last.txt'), "last`n", [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText( + (Join-Path $tree 'nested/a-first.txt'), "first`n", [System.Text.UTF8Encoding]::new($false)) + } + $zipOne = Join-Path $contractRoot 'one.zip' + $zipTwo = Join-Path $contractRoot 'two.zip' + $entriesOne = @(New-StackchanDeterministicReleaseZip ` + -RootPath $treeOne -ZipPath $zipOne -SourceEpoch 1700000001) + $entriesTwo = @(New-StackchanDeterministicReleaseZip ` + -RootPath $treeTwo -ZipPath $zipTwo -SourceEpoch 1700000001) + if ((Get-FileHash -LiteralPath $zipOne -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $zipTwo -Algorithm SHA256).Hash) { + throw 'Deterministic release ZIP bytes differ for identical trees and source epochs.' + } + $expectedEntries = @('nested/a-first.txt', 'z-last.txt') + if ((Compare-Object -ReferenceObject $expectedEntries -DifferenceObject $entriesOne ` + -CaseSensitive -SyncWindow 0).Count -ne 0 -or + (Compare-Object -ReferenceObject $expectedEntries -DifferenceObject $entriesTwo ` + -CaseSensitive -SyncWindow 0).Count -ne 0) { + throw 'Deterministic release ZIP central-directory order is not ordinal and stable.' + } + + $systemDirectory = [System.IO.Path]::GetFullPath([Environment]::SystemDirectory) + $systemPowerShell = Join-Path $systemDirectory 'WindowsPowerShell/v1.0/powershell.exe' + if (-not (Test-Path -LiteralPath $systemPowerShell -PathType Leaf)) { + throw 'Command-trust behavior contract requires Windows PowerShell.' + } + $markerRoot = Join-Path $contractRoot 'markers' + [System.IO.Directory]::CreateDirectory($markerRoot) | Out-Null + $behaviorScript = Join-Path $contractRoot 'front-door-behavior.ps1' + $behaviorSource = @' +param( + [Parameter(Mandatory = $true)][string]$Target, + [Parameter(Mandatory = $true)][string]$MarkerRoot, + [switch]$Verifier +) +$ErrorActionPreference = 'Stop' +function global:git { [IO.File]::WriteAllText((Join-Path $MarkerRoot 'git.txt'), 'executed') } +function global:powershell.exe { [IO.File]::WriteAllText((Join-Path $MarkerRoot 'powershell.txt'), 'executed') } +function global:subst.exe { [IO.File]::WriteAllText((Join-Path $MarkerRoot 'subst.txt'), 'executed') } +function global:tar.exe { [IO.File]::WriteAllText((Join-Path $MarkerRoot 'tar.txt'), 'executed') } +try { + if ($Verifier) { + & $Target -RequireReleaseEligible + } else { + & $Target -Version 'command-trust-contract' + } + throw 'front door unexpectedly succeeded' +} catch { + $message = $_.Exception.Message + if ($Verifier) { + if ($message -notlike '*Release-eligible verification is fail-closed before Git or build-tool execution*') { throw } + } elseif ($message -notlike '*Release-grade packaging is fail-closed before Git or build-tool execution*') { + throw + } +} +'@ + [System.IO.File]::WriteAllText( + $behaviorScript, $behaviorSource, [System.Text.UTF8Encoding]::new($false)) + & $systemPowerShell -NoProfile -ExecutionPolicy Bypass -File $behaviorScript ` + -Target $packagePath -MarkerRoot $markerRoot + if ($LASTEXITCODE -ne 0) { throw 'Release package front-door hostile behavior probe failed.' } + & $systemPowerShell -NoProfile -ExecutionPolicy Bypass -File $behaviorScript ` + -Target $verifyPath -MarkerRoot $markerRoot -Verifier + if ($LASTEXITCODE -ne 0) { throw 'Release verifier front-door hostile behavior probe failed.' } + if (@(Get-ChildItem -LiteralPath $markerRoot -File -ErrorAction SilentlyContinue).Count -ne 0) { + throw 'A hostile ambient command executed before a release front-door refusal.' + } + + . $gitTrustPath + $gitApplication = Get-Command -Name git -CommandType Application -ErrorAction Stop | + Select-Object -First 1 + $gitExecutable = (Resolve-Path -LiteralPath ([string]$gitApplication.Source)).Path + $gitFunctionMarker = Join-Path $markerRoot 'git-function.txt' + function global:git { [System.IO.File]::WriteAllText($gitFunctionMarker, 'executed') } + $disabledHooks = Join-Path $contractRoot 'disabled-hooks-must-not-exist' + $gitOutput = @(Invoke-StackchanTrustedGit -GitExecutable $gitExecutable ` + -DisabledHooksPath $disabledHooks -Arguments @('--version')) + if ($LASTEXITCODE -ne 0 -or ($gitOutput | Out-String) -notmatch '^git version ' -or + (Test-Path -LiteralPath $gitFunctionMarker)) { + throw 'Trusted Git did not execute its pinned application path.' + } + $fakeGit = Join-Path $contractRoot 'git.cmd' + [System.IO.File]::WriteAllText( + $fakeGit, "@echo executed>$gitFunctionMarker`r`n", [System.Text.Encoding]::ASCII) + try { + Invoke-StackchanTrustedGit -GitExecutable $fakeGit ` + -DisabledHooksPath $disabledHooks -Arguments @('--version') + throw 'Trusted Git accepted a command-script shim.' + } catch { + if ($_.Exception.Message -eq 'Trusted Git accepted a command-script shim.') { throw } + } + if (Test-Path -LiteralPath $gitFunctionMarker) { + throw 'Trusted Git executed a rejected command-script shim.' + } + Remove-Item Function:\git -Force -ErrorAction SilentlyContinue + + $shimDirectory = Join-Path $contractRoot 'shim-path' + [System.IO.Directory]::CreateDirectory($shimDirectory) | Out-Null + $pioMarker = Join-Path $markerRoot 'platformio-shim.txt' + $pythonMarker = Join-Path $markerRoot 'python-shim.txt' + [System.IO.File]::WriteAllText( + (Join-Path $shimDirectory 'platformio.cmd'), "@echo executed>$pioMarker`r`n", [System.Text.Encoding]::ASCII) + [System.IO.File]::WriteAllText( + (Join-Path $shimDirectory 'python.cmd'), "@echo executed>$pythonMarker`r`n", [System.Text.Encoding]::ASCII) + $global:StackchanPioResolverMarker = $pioMarker + $global:StackchanPythonResolverMarker = $pythonMarker + function global:platformio { + [System.IO.File]::WriteAllText($global:StackchanPioResolverMarker, 'function-executed') + } + function global:pio { + [System.IO.File]::WriteAllText($global:StackchanPioResolverMarker, 'function-executed') + } + function global:python { + [System.IO.File]::WriteAllText($global:StackchanPythonResolverMarker, 'function-executed') + } + $savedPath = $env:PATH + $savedLocalAppData = $env:LOCALAPPDATA + $savedUserProfile = $env:USERPROFILE + $savedPlatformioExe = $env:PLATFORMIO_EXE + try { + $env:PATH = $shimDirectory + $env:LOCALAPPDATA = $contractRoot + $env:USERPROFILE = $contractRoot + Remove-Item Env:\PLATFORMIO_EXE -ErrorAction SilentlyContinue + . $platformioResolverPath + . $previewPythonResolverPath + try { + Get-StackchanPlatformioCommand | Out-Null + throw 'PlatformIO resolver accepted a command-script shim.' + } catch { + if ($_.Exception.Message -eq 'PlatformIO resolver accepted a command-script shim.') { throw } + } + try { + Get-StackchanPreviewPython | Out-Null + throw 'Preview Python resolver accepted a command-script shim.' + } catch { + if ($_.Exception.Message -eq 'Preview Python resolver accepted a command-script shim.') { throw } + } + } finally { + $env:PATH = $savedPath + $env:LOCALAPPDATA = $savedLocalAppData + $env:USERPROFILE = $savedUserProfile + if ($null -eq $savedPlatformioExe) { + Remove-Item Env:\PLATFORMIO_EXE -ErrorAction SilentlyContinue + } else { + $env:PLATFORMIO_EXE = $savedPlatformioExe + } + Remove-Item Function:\platformio, Function:\pio, Function:\python ` + -Force -ErrorAction SilentlyContinue + Remove-Variable -Name StackchanPioResolverMarker, StackchanPythonResolverMarker ` + -Scope Global -Force -ErrorAction SilentlyContinue + } + if ((Test-Path -LiteralPath $pioMarker) -or (Test-Path -LiteralPath $pythonMarker)) { + throw 'A diagnostic command resolver executed a command-script shim.' + } +} finally { + Remove-Item Function:\git -Force -ErrorAction SilentlyContinue + Remove-Item Function:\platformio, Function:\pio, Function:\python ` + -Force -ErrorAction SilentlyContinue + Remove-Variable -Name StackchanPioResolverMarker, StackchanPythonResolverMarker ` + -Scope Global -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $contractRoot) { + [System.IO.Directory]::Delete($contractRoot, $true) + } +} + +Write-Output 'Release command trust contract passed.' diff --git a/tools/test_release_dependency_evidence_contract.ps1 b/tools/test_release_dependency_evidence_contract.ps1 new file mode 100644 index 00000000..d2c999b0 --- /dev/null +++ b/tools/test_release_dependency_evidence_contract.ps1 @@ -0,0 +1,76 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'release_dependency_evidence.ps1') + +$packageText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'package_release.ps1') -Raw +foreach ($required in @( + "@('pkg', 'list', '-d', `$BuildProjectRoot, '-e', `$Environment, '-v')", + 'Get-StackchanVerbosePlatformSource', + 'platform/$PlatformSourceLeaf/$metadataName', + 'Copy-StackchanResolvedCorePackageEvidence' +)) { + if (-not $packageText.Contains($required)) { + throw "Production dependency snapshot wiring is missing: $required" + } +} +if ($packageText.Contains('platform/espressif32/$metadataName') -or + $packageText.Contains("-SourceRoot (Join-Path `$coreDir 'packages')")) { + throw 'Production dependency evidence still contains a broad or hard-coded source path' +} + +$root = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-dependency-evidence-contract-' + [guid]::NewGuid().ToString('N')) +try { + $corePackages = Join-Path $root 'core/packages' + $corePlatforms = Join-Path $root 'core/platforms' + $destination = Join-Path $root 'snapshot/packages' + foreach ($name in @('tool-alpha', 'stale-evil')) { + New-Item -ItemType Directory -Force -Path (Join-Path $corePackages $name) | Out-Null + Set-Content -LiteralPath (Join-Path $corePackages "$name/package.json") -Value "{}`n" -Encoding ASCII + Set-Content -LiteralPath (Join-Path $corePackages "$name/LICENSE") -Value "$name license`n" -Encoding ASCII + } + foreach ($leaf in @('espressif32', 'espressif32@7.0.1')) { + New-Item -ItemType Directory -Force -Path (Join-Path $corePlatforms $leaf) | Out-Null + } + $selectedPlatform = Get-StackchanVerbosePlatformSource ` + -VerbosePackageList ( + 'Platform espressif32 @ 7.0.1 (required: espressif32 @ 7.0.1, ' + + (Join-Path $corePlatforms 'espressif32@7.0.1') + ')') ` + -PlatformioCoreDir (Join-Path $root 'core') + if ($selectedPlatform.sourceLeaf -cne 'espressif32@7.0.1') { + throw 'Verbose dependency evidence selected a stale same-name platform directory' + } + $resolved = @( + [pscustomobject]@{ kind = 'package'; name = 'tool-alpha'; version = '1.0.0'; required = 'tool-alpha' }, + [pscustomobject]@{ kind = 'package'; name = 'library-only'; version = '2.0.0'; required = 'library-only' } + ) + $names = @(Get-StackchanResolvedCorePackageNames ` + -ResolvedPackages $resolved -CorePackagesRoot $corePackages) + if ($names.Count -ne 1 -or $names[0] -cne 'tool-alpha') { + throw "Resolved core-package selection included stale or non-core packages" + } + Copy-StackchanResolvedCorePackageEvidence ` + -CorePackagesRoot $corePackages -DestinationRoot $destination -CorePackageNames $names + if (-not (Test-Path -LiteralPath (Join-Path $destination 'tool-alpha/LICENSE')) -or + (Test-Path -LiteralPath (Join-Path $destination 'stale-evil'))) { + throw "Dependency snapshot did not isolate the resolved core-package allowlist" + } + Assert-StackchanCorePackageEvidenceAllowlisted ` + -Environment 'stackchan' -CorePackageNames $names ` + -IndexedThirdPartyPaths @('stackchan/packages/tool-alpha/LICENSE') + try { + Assert-StackchanCorePackageEvidenceAllowlisted ` + -Environment 'stackchan' -CorePackageNames $names ` + -IndexedThirdPartyPaths @( + 'stackchan/packages/tool-alpha/LICENSE', + 'stackchan/packages/stale-evil/LICENSE') + throw "Dependency evidence allowlist accepted a stale shared-core package" + } catch { + if ($_.Exception.Message -eq 'Dependency evidence allowlist accepted a stale shared-core package') { throw } + } +} finally { + if (Test-Path -LiteralPath $root) { + [System.IO.Directory]::Delete($root, $true) + } +} + +Write-Host 'Release dependency-evidence contract passed.' diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 new file mode 100644 index 00000000..af8bd335 --- /dev/null +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -0,0 +1,1656 @@ +param( + [string]$BehaviorFixtureRoot +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$packagePath = Join-Path $PSScriptRoot "package_release.ps1" +$verifyPath = Join-Path $PSScriptRoot "verify_release_package.ps1" +$packageText = Get-Content -LiteralPath $packagePath -Raw +$verifyText = Get-Content -LiteralPath $verifyPath -Raw +if ($verifyText.Contains('Release package verified:') -or + -not $verifyText.Contains( + 'Package integrity verified in non-authorizing mode; release eligibility not established:')) { + throw 'Non-authorizing package verification still uses release-authorizing success wording' +} +$zipSafetyPath = Join-Path $PSScriptRoot 'release_zip_safety.ps1' +$gitTrustPath = Join-Path $PSScriptRoot 'release_git_trust.ps1' +$zipSafetyText = Get-Content -LiteralPath $zipSafetyPath -Raw +$gitTrustText = Get-Content -LiteralPath $gitTrustPath -Raw + +function Test-WithinFunctionDefinition { + param([System.Management.Automation.Language.Ast]$Ast) + + $parent = $Ast.Parent + while ($null -ne $parent) { + if ($parent -is [System.Management.Automation.Language.FunctionDefinitionAst]) { + return $true + } + $parent = $parent.Parent + } + return $false +} + +function Get-NearestStatementBlock { + param([System.Management.Automation.Language.Ast]$Ast) + + $parent = $Ast + while ($null -ne $parent -and + $parent -isnot [System.Management.Automation.Language.StatementBlockAst] -and + $parent -isnot [System.Management.Automation.Language.NamedBlockAst]) { + $parent = $parent.Parent + } + return $parent +} + +function Test-DirectCommandParameter { + param( + [System.Management.Automation.Language.CommandAst]$Command, + [string]$Name + ) + + return @($Command.CommandElements | Where-Object { + $_ -is [System.Management.Automation.Language.CommandParameterAst] -and + $_.ParameterName -ceq $Name + }).Count -eq 1 +} + +function Test-DirectArrayString { + param( + [System.Management.Automation.Language.Ast]$Ast, + [string]$Value + ) + + $node = $Ast + if ($node -is [System.Management.Automation.Language.CommandExpressionAst]) { + $node = $node.Expression + } + if ($node -isnot [System.Management.Automation.Language.ArrayExpressionAst]) { + return $false + } + $statements = @($node.SubExpression.Statements) + if ($statements.Count -ne 1 -or + $statements[0] -isnot [System.Management.Automation.Language.PipelineAst] -or + $statements[0].PipelineElements.Count -ne 1 -or + $statements[0].PipelineElements[0] -isnot [System.Management.Automation.Language.CommandExpressionAst]) { + return $false + } + $arrayLiteral = $statements[0].PipelineElements[0].Expression + if ($arrayLiteral -isnot [System.Management.Automation.Language.ArrayLiteralAst]) { + return $false + } + return @($arrayLiteral.Elements | Where-Object { + $_ -is [System.Management.Automation.Language.StringConstantExpressionAst] -and + $_.Value -ceq $Value + }).Count -eq 1 +} + +function Get-DirectStringArrayValues { + param([System.Management.Automation.Language.Ast]$Ast) + + $node = $Ast + if ($node -is [System.Management.Automation.Language.CommandExpressionAst]) { + $node = $node.Expression + } + if ($node -isnot [System.Management.Automation.Language.ArrayExpressionAst]) { + return $null + } + $statements = @($node.SubExpression.Statements) + if ($statements.Count -ne 1 -or + $statements[0] -isnot [System.Management.Automation.Language.PipelineAst] -or + $statements[0].PipelineElements.Count -ne 1 -or + $statements[0].PipelineElements[0] -isnot [System.Management.Automation.Language.CommandExpressionAst]) { + return $null + } + $arrayLiteral = $statements[0].PipelineElements[0].Expression + if ($arrayLiteral -isnot [System.Management.Automation.Language.ArrayLiteralAst] -or + @($arrayLiteral.Elements | Where-Object { + $_ -isnot [System.Management.Automation.Language.StringConstantExpressionAst] + }).Count -ne 0) { + return $null + } + return @($arrayLiteral.Elements | ForEach-Object { [string]$_.Value }) +} + +function Read-OperationalScriptAst { + param([string]$RelativePath) + + $path = Join-Path $PSScriptRoot $RelativePath + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $path, [ref]$tokens, [ref]$errors) + if ($errors.Count -ne 0) { + throw "Operational verifier caller does not parse: $RelativePath" + } + return $ast +} + +function Assert-NoPreVerifierHelperLoad { + param( + [string]$RelativePath, + [System.Management.Automation.Language.ScriptBlockAst]$Ast, + [System.Management.Automation.Language.CommandAst[]]$VerifierInvocations + ) + + if ($VerifierInvocations.Count -eq 0) { + throw "Operational verifier caller has no reachable verifier invocation: $RelativePath" + } + $firstVerifierOffset = [int](@($VerifierInvocations | + Sort-Object { $_.Extent.StartOffset })[0].Extent.StartOffset) + $preVerifierHelperLoads = @($Ast.FindAll({ + param($node) + if ($node -isnot [System.Management.Automation.Language.CommandAst] -or + $node.Extent.StartOffset -ge $firstVerifierOffset -or + (Test-WithinFunctionDefinition -Ast $node)) { + return $false + } + if ($node.InvocationOperator -eq [System.Management.Automation.Language.TokenKind]::Dot) { + return $true + } + $text = $node.Extent.Text + if ($text -notmatch '(?i)\.ps1(?:["'']|\s|\)|$)' -or + $text -notmatch '\$PSScriptRoot') { + return $false + } + $commandName = [string]$node.GetCommandName() + return ($node.InvocationOperator -eq [System.Management.Automation.Language.TokenKind]::Ampersand -or + $commandName -match '^(?i:powershell(?:\.exe)?|pwsh(?:\.exe)?)$') + }, $true)) + if ($preVerifierHelperLoads.Count -ne 0) { + throw "Operational caller loads or executes a local helper before package eligibility: $RelativePath :: $($preVerifierHelperLoads[0].Extent.Text)" + } +} + +function Assert-NoPreVerifierSideEffects { + param( + [string]$RelativePath, + [System.Management.Automation.Language.ScriptBlockAst]$Ast, + [System.Management.Automation.Language.CommandAst]$FirstVerifier + ) + + $mutatingCommands = @( + 'Set-Content', 'Add-Content', 'Out-File', 'New-Item', 'Remove-Item', + 'Copy-Item', 'Move-Item', 'Rename-Item', 'Compress-Archive', 'Expand-Archive', + 'Start-Process', 'Start-Job', 'Invoke-WebRequest', 'Invoke-RestMethod' + ) + $reachableMutations = @($Ast.FindAll({ + param($node) + if ($node -isnot [System.Management.Automation.Language.CommandAst] -or + $node.Extent.StartOffset -ge $FirstVerifier.Extent.StartOffset -or + (Test-WithinFunctionDefinition -Ast $node)) { + return $false + } + $commandName = [string]$node.GetCommandName() + if ($mutatingCommands -contains $commandName) { + return $true + } + if ($commandName -ceq 'git') { + $arguments = @($node.CommandElements | Select-Object -Skip 1 | + ForEach-Object { $_.Extent.Text.Trim('"', "'") }) + return @($arguments | Where-Object { + $_ -in @('tag', 'push', 'branch', 'checkout', 'switch', 'reset', 'worktree') + }).Count -gt 0 + } + if ($commandName -ceq 'gh') { + return $node.Extent.Text -match '(?i)\b(release|pr)\s+(create|upload|edit|delete|close|merge)\b' + } + return $false + }, $true)) + if ($reachableMutations.Count -ne 0) { + throw "Release $RelativePath performs a side effect before package eligibility: $($reachableMutations[0].Extent.Text)" + } + + $reachableMethodMutations = @($Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and + $node.Extent.StartOffset -lt $FirstVerifier.Extent.StartOffset -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.Member.Extent.Text -match '^(?i:WriteAllText|WriteAllBytes|Create|CreateDirectory|Delete|Move|Copy|Replace)$' + }, $true)) + if ($reachableMethodMutations.Count -ne 0) { + throw "Release $RelativePath invokes a mutating method before package eligibility: $($reachableMethodMutations[0].Extent.Text)" + } + + $localFunctions = @{} + foreach ($functionAst in @($Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $true))) { + $localFunctions[$functionAst.Name] = $functionAst + } + $preVerifierCommands = @($Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.Extent.StartOffset -lt $FirstVerifier.Extent.StartOffset -and + -not (Test-WithinFunctionDefinition -Ast $node) + }, $true)) + $pendingFunctions = New-Object 'System.Collections.Generic.Queue[string]' + foreach ($command in $preVerifierCommands) { + $commandName = [string]$command.GetCommandName() + if ($localFunctions.ContainsKey($commandName)) { + $pendingFunctions.Enqueue($commandName) + } + } + $visitedFunctions = @{} + while ($pendingFunctions.Count -gt 0) { + $functionName = $pendingFunctions.Dequeue() + if ($visitedFunctions.ContainsKey($functionName)) { continue } + $visitedFunctions[$functionName] = $true + $functionAst = $localFunctions[$functionName] + $functionCommands = @($functionAst.Body.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] + }, $true)) + foreach ($command in $functionCommands) { + $commandName = [string]$command.GetCommandName() + if ($mutatingCommands -contains $commandName -or + $commandName -in @('Invoke-Expression', 'Import-Module') -or + ($command.InvocationOperator -eq [System.Management.Automation.Language.TokenKind]::Dot)) { + throw "Release $RelativePath reaches a side-effecting helper before package eligibility: $functionName :: $($command.Extent.Text)" + } + if ($localFunctions.ContainsKey($commandName) -and + -not $visitedFunctions.ContainsKey($commandName)) { + $pendingFunctions.Enqueue($commandName) + } + } + $functionMethodMutations = @($functionAst.Body.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and + $node.Member.Extent.Text -match '^(?i:WriteAllText|WriteAllBytes|Create|CreateDirectory|Delete|Move|Copy|Replace)$' + }, $true)) + if ($functionMethodMutations.Count -ne 0) { + throw "Release $RelativePath reaches a mutating helper method before package eligibility: $functionName :: $($functionMethodMutations[0].Extent.Text)" + } + } +} + +foreach ($forbidden in @( + '. (Join-PackagePath', + '& (Join-PackagePath', + '-File (Join-PackagePath' +)) { + if ($verifyText.Contains($forbidden)) { + throw "Verifier trust contract found package-controlled code execution: $forbidden" + } +} +foreach ($required in @( + '. (Join-Path $PSScriptRoot "preview_python_resolver.ps1")', + 'ast.parse(', + '$bridgeRuntimePython -I -B -c $bridgeRuntimeAstCheck', + '(Join-Path $PSScriptRoot "verify_voice_samples.ps1")', + '(Join-Path $PSScriptRoot "verify_release_asset_contract.ps1")', + 'Expand-StackchanReleaseZipSafely -ZipPath $ZipPath -DestinationPath $cleanupDir', + 'Assert-ReleaseZipSidecar -LiteralZipPath $ZipPath', + 'Release ZIP SHA-256 sidecar mismatch', + 'Refusing unsafe package-relative path', + 'Operational release verification requires a clean trusted checkout', + 'Release verification refuses ambient Git overrides', + 'Assert-SafeReleaseVersionLeaf' +)) { + if (-not $verifyText.Contains($required)) { + throw "Verifier trust contract is missing: $required" + } +} +$verifyTokens = $null +$verifyParseErrors = $null +$verifyAst = [System.Management.Automation.Language.Parser]::ParseFile( + $verifyPath, [ref]$verifyTokens, [ref]$verifyParseErrors) +$packageTokens = $null +$packageParseErrors = $null +$packageAst = [System.Management.Automation.Language.Parser]::ParseFile( + $packagePath, [ref]$packageTokens, [ref]$packageParseErrors) +if ($verifyParseErrors.Count -ne 0 -or $packageParseErrors.Count -ne 0) { + throw 'Verifier trust contract could not parse the package/verifier scripts' +} +$sidecarCalls = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.GetCommandName() -ceq 'Assert-ReleaseZipSidecar' +}, $true)) +$zipExpansionCalls = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.GetCommandName() -ceq 'Expand-StackchanReleaseZipSafely' +}, $true)) +if ($sidecarCalls.Count -ne 1 -or $zipExpansionCalls.Count -ne 1 -or + $sidecarCalls[0].Extent.EndOffset -ge $zipExpansionCalls[0].Extent.StartOffset) { + throw 'Release ZIP sidecar verification must occur exactly once before ZIP extraction.' +} +function Assert-ClosedPackageInventoryGovernanceText { + param([Parameter(Mandatory = $true)][string]$Text) + foreach ($marker in @( + "'ls-tree', '-r', `$ExpectedCommit", + "'ls-files', '-v'", + "[string]`$CommitMaps.indexStates[`$sourcePath] -cne 'H'", + '$workingBlob -cne $trustedBlob', + 'tools inventory does not equal the trusted commit-side packaging policy', + 'provenance inventory does not equal the trusted commit-side packaging policy', + '$actualPackageFiles.Count -ne $allowedPackageFiles.Count', + '-not $allowedPackageFiles.Contains([string]$path)', + 'root file outside the trusted packaging policy', + 'file outside the trusted copy-tree policy' + )) { + if (-not $Text.Contains($marker)) { + throw "Closed package-inventory governance is missing: $marker" + } + } +} +Assert-ClosedPackageInventoryGovernanceText -Text $verifyText +foreach ($canary in @( + [pscustomobject]@{ label = 'hidden operational caller'; marker = "[string]`$CommitMaps.indexStates[`$sourcePath] -cne 'H'" }, + [pscustomobject]@{ label = 'modified source twin'; marker = '$workingBlob -cne $trustedBlob' }, + [pscustomobject]@{ label = 'whole-package deletion'; marker = '$actualPackageFiles.Count -ne $allowedPackageFiles.Count' }, + [pscustomobject]@{ label = 'whole-package extra'; marker = '-not $allowedPackageFiles.Contains([string]$path)' } +)) { + $mutatedGovernance = $verifyText.Replace([string]$canary.marker, '') + try { + Assert-ClosedPackageInventoryGovernanceText -Text $mutatedGovernance + throw "Inventory governance mutation canary survived: $($canary.label)" + } catch { + if ($_.Exception.Message -like 'Inventory governance mutation canary survived:*') { throw } + } +} +$releaseEligibleIfStatements = @($verifyAst.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.IfStatementAst] -and + $_.Clauses.Count -eq 1 -and + $_.Clauses[0].Item1.Extent.Text -eq '$RequireReleaseEligible' +}) +$releaseFrontDoorGates = @($releaseEligibleIfStatements | Where-Object { + $_.Extent.Text.Contains( + 'Release-eligible verification is fail-closed before Git or build-tool execution') +}) +$releaseEligibilityGates = @($releaseEligibleIfStatements | Where-Object { + $_.Extent.Text.Contains( + 'Operational release verification must run from the trusted checkout tools directory.') +}) +if ($releaseFrontDoorGates.Count -ne 1 -or + $releaseFrontDoorGates[0].Extent.EndOffset -ge + $verifyText.IndexOf('$trustedGitCommand = Get-Command', [StringComparison]::Ordinal)) { + throw 'Operational verifier must fail closed before resolving Git for eligible verification' +} +if ($releaseEligibilityGates.Count -ne 1) { + throw 'Operational verifier must contain exactly one top-level trusted checkout gate' +} +$releaseEligibilityGate = $releaseEligibilityGates[0] +$cleanupAssignments = @($verifyAst.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $_.Left.Extent.Text -eq '$cleanupDir' -and + $_.Right.Extent.Text -eq '$null' +}) +if ($cleanupAssignments.Count -ne 1) { + throw 'Operational verifier cleanup boundary is ambiguous' +} +$preGateCodeCommands = @($verifyAst.FindAll({ + param($node) + if ($node -isnot [System.Management.Automation.Language.CommandAst] -or + $node.Extent.StartOffset -ge $releaseEligibilityGate.Extent.EndOffset) { + return $false + } + $commandName = [string]$node.GetCommandName() + if ($node.InvocationOperator -eq [System.Management.Automation.Language.TokenKind]::Dot) { + return $true + } + if ($node.InvocationOperator -eq [System.Management.Automation.Language.TokenKind]::Ampersand) { + return $node.Extent.Text -cne '& $script:trustedGitExecutable @gitArguments' + } + if ($commandName -match '(?i)\.ps1$' -or + $commandName -in @('Invoke-Expression', 'Import-Module') -or + $commandName -match '^(?i:powershell(?:\.exe)?|pwsh(?:\.exe)?)$') { + return $true + } + return $false +}, $true)) +if ($preGateCodeCommands.Count -ne 0) { + throw "Operational verifier can load or execute code before its trusted checkout gate: $($preGateCodeCommands[0].Extent.Text)" +} +foreach ($helper in @( + 'firmware_reproducibility_proof.ps1', + 'release_zip_safety.ps1', + 'release_dependency_evidence.ps1', + 'release_git_trust.ps1', + 'platformio_resolver.ps1' +)) { + $expectedLoad = '. (Join-Path $PSScriptRoot "' + $helper + '")' + $helperLoads = @($verifyAst.EndBlock.Statements | Where-Object { + $_.Extent.Text -eq $expectedLoad + }) + if ($helperLoads.Count -ne 1 -or + $helperLoads[0].Extent.StartOffset -le $releaseEligibilityGate.Extent.EndOffset -or + $helperLoads[0].Extent.EndOffset -ge $cleanupAssignments[0].Extent.StartOffset) { + throw "Operational verifier loads $helper before its trusted checkout gate" + } +} +$bootstrapGitFunctions = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Invoke-TrustedVerifierGit' +}, $true)) +if ($bootstrapGitFunctions.Count -ne 1) { + throw 'Operational verifier trusted Git bootstrap is ambiguous' +} +$bootstrapGitText = $bootstrapGitFunctions[0].Extent.Text +foreach ($requiredBootstrap in @( + 'core.hooksPath=', + 'core.fsmonitor=false', + 'core.untrackedCache=false', + 'core.useBuiltinFSMonitor=false', + 'maintenance.auto=false', + 'GIT_NO_REPLACE_OBJECTS', + '& $script:trustedGitExecutable @gitArguments' +)) { + if (-not $bootstrapGitText.Contains($requiredBootstrap)) { + throw "Operational verifier trusted Git bootstrap is missing: $requiredBootstrap" + } +} +if ($bootstrapGitText.Contains('Invoke-StackchanTrustedGit')) { + throw 'Operational verifier trusted Git bootstrap depends on a pre-gate local helper' +} +foreach ($gitBootstrapMarker in @( + 'Get-Command -Name git -CommandType Application', + '$trustedGitExecutable = (Resolve-Path -LiteralPath ([string]$trustedGitCommand.Source)).Path' +)) { + if (-not $verifyText.Contains($gitBootstrapMarker)) { + throw "Operational verifier does not bootstrap-bind the Git application: $gitBootstrapMarker" + } +} + +$operationalRebuildFunctions = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Assert-OperationalFirmwareMatchesTrustedRebuild' +}, $true)) +if ($operationalRebuildFunctions.Count -ne 1) { + throw 'Operational verifier independent-rebuild function is ambiguous' +} +$operationalRebuild = $operationalRebuildFunctions[0] +$operationalRebuildText = $operationalRebuild.Extent.Text +$expectedOperationalArtifacts = @( + 'firmware.bin', 'firmware.elf', 'bootloader.bin', 'partitions.bin' +) +$operationalArtifactArrays = @($operationalRebuild.FindAll({ + param($node) + if ($node -isnot [System.Management.Automation.Language.ArrayExpressionAst]) { return $false } + $values = @(Get-DirectStringArrayValues -Ast $node) + return ($values.Count -eq 4 -and $values -contains 'firmware.bin') +}, $true)) +if ($operationalArtifactArrays.Count -ne 1) { + throw 'Operational rebuild must define one exact fresh-build artifact inventory' +} +foreach ($artifactArray in $operationalArtifactArrays) { + $values = @(Get-DirectStringArrayValues -Ast $artifactArray) + if ((Compare-Object -ReferenceObject $expectedOperationalArtifacts ` + -DifferenceObject $values -CaseSensitive).Count -ne 0) { + throw "Operational rebuild artifact inventory is not the exact four-file set: $($values -join ', ')" + } +} + +foreach ($requiredRebuildMarker in @( + "[ordered]@{ environment = 'stackchan'; packageDir = 'display_only'; coreDir = `$defaultCoreDir }", + "[ordered]@{ environment = 'stackchan_servo_calibration'; packageDir = 'servo_calibration'; coreDir = `$defaultCoreDir }", + "[ordered]@{ environment = 'stackchan_release_full'; packageDir = 'full_online'; coreDir = `$releaseCoreDir }", + '`$packageRelative = "firmware/`$([string]`$spec.packageDir)/`$artifact"', + "Get-Command -Name `$pioExecutable -CommandType Application", + "`$pioVersion -cne 'PlatformIO Core, version 6.1.19'", + '[string]$dependencyLock.platformioCore -cne $pioVersion', + "@(& `$pioExecutable 'pkg' 'list' '-d' `$rebuildWorktree '-e' `$environment 2>&1)", + 'Compare-Object -ReferenceObject $expectedDependencyIdentity', + '-DifferenceObject $actualDependencyIdentity -CaseSensitive', + 'Get-StackchanVerbosePlatformSource', + '[string]$spec.coreDir', + '[string]$expectedEnvironmentLock.platformSourceLeaf' +)) { + $marker = $requiredRebuildMarker.Replace('`$', '$') + if (-not $operationalRebuildText.Contains($marker)) { + throw "Operational rebuild contract is missing: $marker" + } +} + +$evidenceParentAssignments = @($operationalRebuild.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$rebuildEvidenceParent' +}, $true)) +$rebuildIdAssignments = @($operationalRebuild.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$rebuildId' +}, $true)) +if ($evidenceParentAssignments.Count -ne 1 -or $rebuildIdAssignments.Count -ne 1 -or + $evidenceParentAssignments[0].Right.Extent.Text.Contains('$Version') -or + $rebuildIdAssignments[0].Right.Extent.Text.Contains('$Version')) { + throw 'Operational rebuild evidence paths must not include the package Version value' +} + +foreach ($evidenceField in @( + 'packageChecksumsSha256 = $packageChecksumsSha256', + 'dependencyLockSha256 = $dependencyLockSha256', + 'sourceCommit = $ExpectedCommit', + 'sourceEpoch = $ExpectedSourceEpoch', + 'platformioExecutable = $pioExecutable', + 'platformioExecutableSha256 = $pioExecutableSha256', + 'platformioVersion = $pioVersion', + 'defaultPlatformioCore = $defaultCoreDir', + 'releasePlatformioCore = $releaseCoreDir', + 'records = @($rebuildRecords)' +)) { + if (-not $operationalRebuildText.Contains($evidenceField)) { + throw "Operational rebuild evidence is not strongly identity-bound: $evidenceField" + } +} +foreach ($forbiddenCacheMarker in @( + 'attestationKey', 'cachedAttestation', 'attestationRoot', + 'operational-firmware-rebuild-cache', 'attestation reused' +)) { + if ($operationalRebuildText.Contains($forbiddenCacheMarker)) { + throw "Operational verifier must perform a fresh rebuild instead of trusting ignored-output cache state: $forbiddenCacheMarker" + } +} +$operationalReturns = @($operationalRebuild.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.ReturnStatementAst] +}, $true)) +if ($operationalReturns.Count -ne 1 -or + -not $operationalRebuildText.Contains('if (-not $RequireReleaseEligible) { return }')) { + throw 'Operational rebuild must not contain a release-eligible cache short-circuit' +} +foreach ($failureProbeMarker in @( + '$worktreePathExists = Test-Path -LiteralPath $rebuildWorktree -PathType Container', + "'worktree', 'list', '--porcelain'", + '$_ -ceq "worktree $rebuildWorktree"', + '$worktreePreserved = $worktreePathExists -and $worktreeAttached', + "status = if (`$worktreePreserved) { 'failed-full-worktree-preserved' } else { 'failed-worktree-not-preserved' }" +)) { + if (-not $operationalRebuildText.Contains($failureProbeMarker)) { + throw "Operational rebuild failure status is not derived from actual worktree probes: $failureProbeMarker" + } +} +$publicVerifierGuards = @($packageAst.EndBlock.Statements | Where-Object { + if ($_ -isnot [System.Management.Automation.Language.IfStatementAst] -or + $_.Clauses.Count -ne 1 -or + $_.Clauses[0].Item1.Extent.Text -ne '-not $SkipBuild') { + return $false + } + $directStatements = @($_.Clauses[0].Item2.Statements) + $directAppends = @($directStatements | Where-Object { + $_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $_.Left.Extent.Text -eq '$packageVerifyArgs' -and + $_.Operator -eq [System.Management.Automation.Language.TokenKind]::PlusEquals -and + $_.Right.Extent.Text -eq '"-RequireReleaseEligible"' + }) + return ($directStatements.Count -eq 1 -and $directAppends.Count -eq 1) +}) +$publicVerifierGuardStatements = if ($publicVerifierGuards.Count -eq 1) { + @($publicVerifierGuards[0].Clauses[0].Item2.Statements) +} else { + @() +} +$publicVerifierRequireAppends = @($publicVerifierGuardStatements | Where-Object { + $_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $_.Left.Extent.Text -eq '$packageVerifyArgs' -and + $_.Operator -eq [System.Management.Automation.Language.TokenKind]::PlusEquals -and + $_.Right.Extent.Text -eq '"-RequireReleaseEligible"' +}) +$publicVerifierArgumentAssignments = @($packageAst.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $_.Left.Extent.Text -ceq '$packageVerifyArgs' -and + $_.Operator -eq [System.Management.Automation.Language.TokenKind]::Equals +}) +$publicVerifierInvocations = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + @($node.CommandElements | Where-Object { + $_ -is [System.Management.Automation.Language.VariableExpressionAst] -and + $_.Splatted -and $_.VariablePath.UserPath -ceq 'packageVerifyArgs' + }).Count -eq 1 +}, $true)) +if ($publicVerifierArgumentAssignments.Count -ne 1 -or + $publicVerifierInvocations.Count -ne 1 -or + $publicVerifierGuards.Count -ne 1 -or + $publicVerifierGuardStatements.Count -ne 1 -or + $publicVerifierRequireAppends.Count -ne 1 -or + $publicVerifierGuards[0].Extent.StartOffset -le $publicVerifierArgumentAssignments[0].Extent.EndOffset -or + $publicVerifierInvocations[0].Extent.StartOffset -le $publicVerifierGuards[0].Extent.EndOffset) { + throw 'Release-grade package verification does not conditionally require operational eligibility' +} +foreach ($required in @( + 'Release ZIP contains an unsafe, duplicate, or link entry', + 'Release ZIP exceeds the bounded extraction budget', + 'FileMode]::CreateNew', + 'Expand-StackchanReleaseZipSafely' +)) { + if (-not $zipSafetyText.Contains($required)) { + throw "Release ZIP safety contract is missing: $required" + } +} +foreach ($required in @( + 'core.hooksPath=', + 'core.fsmonitor=false', + 'core.untrackedCache=false', + 'GIT_NO_REPLACE_OBJECTS' +)) { + if (-not $gitTrustText.Contains($required)) { + throw "Trusted Git contract is missing: $required" + } +} +foreach ($relative in @( + 'verify_release_package.ps1', + 'verify_consumer_promotion.ps1', + 'flash_release_firmware.ps1', + 'prepare_device_arrival.ps1', + 'export_rollout_status.ps1', + 'start_hardware_evidence.ps1', + 'generate_synthetic_hardware_evidence.ps1' +)) { + $operationalText = Get-Content -LiteralPath (Join-Path $PSScriptRoot $relative) -Raw + if ($operationalText.Contains('Expand-Archive')) { + throw "Release ZIP consumer still uses raw Expand-Archive: $relative" + } + if ($relative -ne 'verify_release_package.ps1' -and + $operationalText.Contains('Expand-StackchanReleaseZipSafely') -and + -not $operationalText.Contains('release_zip_safety.ps1')) { + throw "Release ZIP consumer does not load the trusted safe extractor: $relative" + } +} +$operationalAsts = @{} +$operationalTexts = @{} +$operationalInvocations = @{} +$directEligibilitySpecs = @( + [pscustomobject]@{ file = 'prepare_device_arrival.ps1'; kind = 'variable'; variable = 'verifyScript'; count = 4 }, + [pscustomobject]@{ file = 'verify_consumer_promotion.ps1'; kind = 'file-variable'; variable = 'verifyPackage'; count = 2 }, + [pscustomobject]@{ file = 'verify_published_release.ps1'; kind = 'source-any-paren'; variable = ''; count = 3 }, + [pscustomobject]@{ file = 'run_device_preflight.ps1'; kind = 'variable'; variable = 'verifyScript'; count = 2 }, + [pscustomobject]@{ file = 'start_bridge_ai_supervised_qualification.ps1'; kind = 'source-file-paren'; variable = ''; count = 1 } +) +foreach ($spec in $directEligibilitySpecs) { + $ast = Read-OperationalScriptAst -RelativePath $spec.file + $operationalAsts[$spec.file] = $ast + $operationalTexts[$spec.file] = Get-Content -LiteralPath (Join-Path $PSScriptRoot $spec.file) -Raw + $commands = @($ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) + }, $true)) + $invocations = @($commands | Where-Object { + $command = $_ + $elements = @($command.CommandElements) + switch ([string]$spec.kind) { + 'variable' { + return ($elements.Count -gt 0 -and + $elements[0] -is [System.Management.Automation.Language.VariableExpressionAst] -and + $elements[0].VariablePath.UserPath -ceq [string]$spec.variable) + } + 'file-variable' { + return ($command.GetCommandName() -match '^(?i:powershell(?:\.exe)?|pwsh(?:\.exe)?)$' -and + @($elements | Where-Object { + $_ -is [System.Management.Automation.Language.VariableExpressionAst] -and + $_.VariablePath.UserPath -ceq [string]$spec.variable + }).Count -eq 1) + } + 'source-paren' { + return ($elements.Count -gt 0 -and + $elements[0] -is [System.Management.Automation.Language.ParenExpressionAst] -and + $elements[0].Extent.Text -match 'Join-Path\s+\$PSScriptRoot\s+["'']verify_release_package\.ps1["'']') + } + 'source-file-paren' { + return ($command.GetCommandName() -match '^(?i:powershell(?:\.exe)?|pwsh(?:\.exe)?)$' -and + @($elements | Where-Object { + $_ -is [System.Management.Automation.Language.ParenExpressionAst] -and + $_.Extent.Text -match 'Join-Path\s+\$PSScriptRoot\s+["'']verify_release_package\.ps1["'']' + }).Count -eq 1) + } + 'source-any-paren' { + return (@($elements | Where-Object { + $_ -is [System.Management.Automation.Language.ParenExpressionAst] -and + $_.Extent.Text -match 'Join-Path\s+\$PSScriptRoot\s+["'']verify_release_package\.ps1["'']' + }).Count -eq 1) + } + } + return $false + }) + if ($invocations.Count -ne [int]$spec.count) { + throw "Operational verifier invocation structure is ambiguous in $($spec.file): expected $($spec.count), got $($invocations.Count)" + } + foreach ($invocation in $invocations) { + if (-not (Test-DirectCommandParameter -Command $invocation -Name 'RequireReleaseEligible')) { + throw "Operational verifier invocation lacks a direct eligibility switch in $($spec.file): $($invocation.Extent.Text)" + } + if ($spec.kind -eq 'file-variable') { + $block = Get-NearestStatementBlock -Ast $invocation + $pathAssignments = @($block.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq ('$' + [string]$spec.variable) -and + $node.Extent.StartOffset -lt $invocation.Extent.StartOffset -and + (Get-NearestStatementBlock -Ast $node) -eq $block + }, $true)) + if ($pathAssignments.Count -ne 1 -or + $pathAssignments[0].Right.Extent.Text -notmatch 'Join-Path\s+\$PSScriptRoot\s+["'']verify_release_package\.ps1["'']') { + throw "Operational verifier path is not directly bound in the invocation block: $($spec.file)" + } + } + } + $priorInvocations = if ($operationalInvocations.ContainsKey($spec.file)) { + @($operationalInvocations[$spec.file]) + } else { @() } + $operationalInvocations[$spec.file] = @($priorInvocations + $invocations) +} + +$arrayEligibilitySpecs = @( + [pscustomobject]@{ file = 'flash_release_firmware.ps1'; variable = 'verifyArgs'; command = 'powershell.exe'; count = 1 }, + [pscustomobject]@{ file = 'start_hardware_evidence.ps1'; variable = 'verifyArgs'; command = 'powershell.exe'; count = 2 }, + [pscustomobject]@{ file = 'run_device_preflight.ps1'; variable = 'earlyVerifyArgs'; command = 'powershell.exe'; count = 1 }, + [pscustomobject]@{ file = 'export_rollout_status.ps1'; variable = 'packageVerifyArguments'; command = 'Invoke-ToolCapture'; count = 1 } +) +foreach ($spec in $arrayEligibilitySpecs) { + $ast = Read-OperationalScriptAst -RelativePath $spec.file + $operationalAsts[$spec.file] = $ast + $operationalTexts[$spec.file] = Get-Content -LiteralPath (Join-Path $PSScriptRoot $spec.file) -Raw + $invocations = @($ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.GetCommandName() -ceq [string]$spec.command -and + @($node.CommandElements | Where-Object { + $_ -is [System.Management.Automation.Language.VariableExpressionAst] -and + $_.VariablePath.UserPath -ceq [string]$spec.variable + }).Count -eq 1 + }, $true)) + if ($invocations.Count -ne [int]$spec.count) { + throw "Operational verifier argument-array invocation is ambiguous in $($spec.file): expected $($spec.count), got $($invocations.Count)" + } + foreach ($invocation in $invocations) { + $assignments = @($ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq ('$' + [string]$spec.variable) -and + $node.Operator -eq [System.Management.Automation.Language.TokenKind]::Equals -and + $node.Extent.StartOffset -lt $invocation.Extent.StartOffset + }, $true) | Where-Object { + $assignmentBlock = Get-NearestStatementBlock -Ast $_ + $null -ne $assignmentBlock -and + $assignmentBlock.Extent.StartOffset -le $invocation.Extent.StartOffset -and + $assignmentBlock.Extent.EndOffset -ge $invocation.Extent.EndOffset + } | Sort-Object ` + @{ Expression = { + (Get-NearestStatementBlock -Ast $_).Extent.EndOffset - + (Get-NearestStatementBlock -Ast $_).Extent.StartOffset + }; Ascending = $true }, + @{ Expression = { $_.Extent.StartOffset }; Descending = $true }) + $reachingAssignment = if ($assignments.Count -gt 0) { $assignments[0] } else { $null } + if ($null -eq $reachingAssignment -or + -not (Test-DirectArrayString -Ast $reachingAssignment.Right -Value '-RequireReleaseEligible')) { + throw "Operational verifier argument array lacks a directly reachable eligibility switch in $($spec.file)" + } + } + $priorInvocations = if ($operationalInvocations.ContainsKey($spec.file)) { + @($operationalInvocations[$spec.file]) + } else { @() } + $operationalInvocations[$spec.file] = @($priorInvocations + $invocations) +} + +$shareFile = 'share_release.ps1' +$shareAst = Read-OperationalScriptAst -RelativePath $shareFile +$operationalAsts[$shareFile] = $shareAst +$operationalTexts[$shareFile] = Get-Content -LiteralPath (Join-Path $PSScriptRoot $shareFile) -Raw +$shareVerifierFunctions = @($shareAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Invoke-OperationalPackageVerification' +}, $true)) +if ($shareVerifierFunctions.Count -ne 1) { + throw 'Release sharing must define one operational package-verification wrapper' +} +$shareVerifierCommands = @($shareVerifierFunctions[0].Body.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.CommandElements.Count -gt 0 -and + $node.CommandElements[0] -is [System.Management.Automation.Language.ParenExpressionAst] -and + $node.CommandElements[0].Extent.Text -match 'Join-Path\s+\$PSScriptRoot\s+["'']verify_release_package\.ps1["'']' +}, $true)) +if ($shareVerifierCommands.Count -ne 1 -or + -not (Test-DirectCommandParameter -Command $shareVerifierCommands[0] -Name 'RequireReleaseEligible')) { + throw 'Release sharing wrapper does not directly require operational package eligibility' +} +$shareWrapperCalls = @($shareAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.GetCommandName() -ceq 'Invoke-OperationalPackageVerification' +}, $true) | Sort-Object { $_.Extent.StartOffset }) +if ($shareWrapperCalls.Count -ne 1) { + throw "Release sharing must have exactly one reachable pre-share verifier call, got $($shareWrapperCalls.Count)" +} +$operationalInvocations[$shareFile] = $shareWrapperCalls + +$publishFile = 'publish_release.ps1' +$publishAst = Read-OperationalScriptAst -RelativePath $publishFile +$operationalAsts[$publishFile] = $publishAst +$operationalTexts[$publishFile] = Get-Content -LiteralPath (Join-Path $PSScriptRoot $publishFile) -Raw +$publishVerifierFunctions = @($publishAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Invoke-OperationalPackageVerification' +}, $true)) +if ($publishVerifierFunctions.Count -ne 1) { + throw 'Release publication must define one operational package-verification wrapper' +} +$publishVerifierFunction = $publishVerifierFunctions[0] +$publishVerifierCommands = @($publishVerifierFunction.Body.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.CommandElements.Count -gt 1 -and + $node.CommandElements[0] -is [System.Management.Automation.Language.ParenExpressionAst] -and + $node.CommandElements[0].Extent.Text -match 'Join-Path\s+\$PSScriptRoot\s+["'']verify_release_package\.ps1["'']' -and + @($node.CommandElements | Where-Object { + $_ -is [System.Management.Automation.Language.VariableExpressionAst] -and + $_.Splatted -and $_.VariablePath.UserPath -ceq 'arguments' + }).Count -eq 1 +}, $true)) +if ($publishVerifierCommands.Count -ne 1) { + throw 'Release publication wrapper does not directly invoke the source-side verifier exactly once' +} +$publishArgumentAssignments = @($publishVerifierFunction.Body.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$arguments' -and + $node.Operator -eq [System.Management.Automation.Language.TokenKind]::Equals +}, $true)) +if ($publishArgumentAssignments.Count -ne 1) { + throw 'Release publication wrapper verifier argument source is ambiguous' +} +$publishArgumentsNode = $publishArgumentAssignments[0].Right +if ($publishArgumentsNode -is [System.Management.Automation.Language.CommandExpressionAst]) { + $publishArgumentsNode = $publishArgumentsNode.Expression +} +$publishEligibilityPairs = if ($publishArgumentsNode -is [System.Management.Automation.Language.HashtableAst]) { + @($publishArgumentsNode.KeyValuePairs | Where-Object { + $_.Item1 -is [System.Management.Automation.Language.StringConstantExpressionAst] -and + $_.Item1.Value -ceq 'RequireReleaseEligible' + }) +} else { + @() +} +$publishEligibilityValue = if ($publishEligibilityPairs.Count -eq 1 -and + $publishEligibilityPairs[0].Item2 -is [System.Management.Automation.Language.PipelineAst] -and + $publishEligibilityPairs[0].Item2.PipelineElements.Count -eq 1 -and + $publishEligibilityPairs[0].Item2.PipelineElements[0] -is [System.Management.Automation.Language.CommandExpressionAst]) { + $publishEligibilityPairs[0].Item2.PipelineElements[0].Expression +} else { + $null +} +if ($publishEligibilityPairs.Count -ne 1 -or + $publishEligibilityValue -isnot [System.Management.Automation.Language.VariableExpressionAst] -or + $publishEligibilityValue.VariablePath.UserPath -cne 'true' -or + $publishArgumentAssignments[0].Extent.EndOffset -ge $publishVerifierCommands[0].Extent.StartOffset) { + throw 'Release publication wrapper does not directly bind RequireReleaseEligible to true before its verifier call' +} +$publishWrapperCalls = @($publishAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.GetCommandName() -ceq 'Invoke-OperationalPackageVerification' +}, $true) | Sort-Object { $_.Extent.StartOffset }) +if ($publishWrapperCalls.Count -ne 2) { + throw "Release publication must have exactly two reachable verification wrapper calls, got $($publishWrapperCalls.Count)" +} +$operationalInvocations[$publishFile] = $publishWrapperCalls + +foreach ($relativePath in @($operationalInvocations.Keys)) { + Assert-NoPreVerifierHelperLoad ` + -RelativePath $relativePath ` + -Ast $operationalAsts[$relativePath] ` + -VerifierInvocations @($operationalInvocations[$relativePath]) +} +foreach ($relativePath in @('publish_release.ps1', 'share_release.ps1')) { + $firstVerifier = @($operationalInvocations[$relativePath] | + Sort-Object { $_.Extent.StartOffset })[0] + Assert-NoPreVerifierSideEffects ` + -RelativePath $relativePath -Ast $operationalAsts[$relativePath] -FirstVerifier $firstVerifier +} +$shareText = [string]$operationalTexts['share_release.ps1'] +$shareMutations = @($shareAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.Left.Extent.Text -ceq '$shareRoot' +}, $true)) +if ($shareMutations.Count -ne 1 -or + $shareMutations[0].Extent.StartOffset -le $shareWrapperCalls[0].Extent.EndOffset -or + $shareText.Contains('Join-Path $packageRoot "tools/') -or + $shareText.Contains("Join-Path `$packageRoot 'tools/")) { + throw 'Release sharing must verify before share mutation and must never execute package-contained tools' +} +foreach ($mutableExporter in @( + 'export_github_actions_status.ps1', + 'export_rollout_status.ps1' +)) { + if ($shareText.Contains($mutableExporter)) { + throw "Release sharing must not mix mutable exporter output into the verified share: $mutableExporter" + } +} +$rolloutText = [string]$operationalTexts['export_rollout_status.ps1'] +$rolloutAst = $operationalAsts['export_rollout_status.ps1'] +$rolloutExtractCommands = @($rolloutAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.GetCommandName() -ceq 'Expand-StackchanReleaseZipSafely' +}, $true)) +$rolloutAuthorityAssignments = @($rolloutAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -in @('$ExpectedCommit', '$Version') -and + $node.Right.Extent.Text -match '\$manifest\.(?:commit|version)' +}, $true)) +$rolloutVerifierInvocation = @($operationalInvocations['export_rollout_status.ps1'])[0] +if ($rolloutExtractCommands.Count -ne 1 -or + $rolloutExtractCommands[0].Extent.StartOffset -le $rolloutVerifierInvocation.Extent.EndOffset -or + $rolloutAuthorityAssignments.Count -ne 0) { + throw 'Rollout export must verify before ZIP extraction and must preserve caller authority' +} +$publishText = [string]$operationalTexts['publish_release.ps1'] +foreach ($remoteMutation in @( + 'New-VerifiedReleaseTag -Tag $Version -Commit $tagCommit', + 'Assert-CurrentBranchPublishedAtCommit -Commit $tagCommit', + 'Invoke-Checked "Push tag $Version"' +)) { + $mutationCommands = @($publishAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.Extent.Text.Contains($remoteMutation) + }, $true)) + if ($mutationCommands.Count -ne 1 -or + $mutationCommands[0].Extent.StartOffset -le $publishWrapperCalls[0].Extent.EndOffset) { + throw "Release publication can mutate Git before operational package verification: $remoteMutation" + } +} +$auditText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'audit_published_release.ps1') -Raw +if ($auditText.Contains('$ExpectedCommit = [string]$manifest.commit') -or + -not $auditText.Contains('$trustedTagCommit')) { + throw 'Published-release audit must derive commit authority from trusted Git, never the package manifest' +} +$releaseWorkflowPath = Join-Path $repoRoot '.github/workflows/release.yml' +$releaseWorkflowLines = @(Get-Content -LiteralPath $releaseWorkflowPath) +$workflowVerifierBlocks = New-Object 'System.Collections.Generic.List[string]' +for ($lineIndex = 0; $lineIndex -lt $releaseWorkflowLines.Count; $lineIndex++) { + if ($releaseWorkflowLines[$lineIndex] -notmatch + '^\s*(?:&\s+)?\.?[/\\]tools[/\\]verify_release_package\.ps1(?:\s|`|$)') { + continue + } + $blockLines = New-Object 'System.Collections.Generic.List[string]' + do { + $blockLines.Add([string]$releaseWorkflowLines[$lineIndex]) + $continues = $releaseWorkflowLines[$lineIndex].TrimEnd().EndsWith('`') + if ($continues) { $lineIndex++ } + } while ($continues -and $lineIndex -lt $releaseWorkflowLines.Count) + $workflowVerifierBlocks.Add(($blockLines -join "`n")) +} +if ($workflowVerifierBlocks.Count -eq 0) { + throw 'Release workflow has no executable package verifier command' +} +foreach ($workflowVerifierBlock in $workflowVerifierBlocks) { + if ($workflowVerifierBlock -notmatch '(?m)(?:^|\s)-RequireReleaseEligible(?:\s|$)') { + throw "Release workflow verifier command omits operational package eligibility: $workflowVerifierBlock" + } +} +foreach ($required in @( + 'Assert-SafeReleaseVersionLeaf', + 'Join-ContainedReleaseOutputPath', + 'Refusing release output path outside the governed root' +)) { + if (-not $packageText.Contains($required)) { + throw "Package output-path contract is missing: $required" + } +} + +$maliciousVersion = 'diagnostic-x\..\contract-victim' +$savedBuildEnvironment = @( + Get-ChildItem Env: | Where-Object { $_.Name -like 'PLATFORMIO_*' -or $_.Name -like 'GIT_*' } +) +try { + foreach ($entry in $savedBuildEnvironment) { + Remove-Item ("Env:\" + $entry.Name) -ErrorAction SilentlyContinue + } + foreach ($scriptPath in @($packagePath, $verifyPath)) { + $arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath, '-Version', $maliciousVersion) + if ($scriptPath -eq $packagePath) { $arguments += @('-SkipBuild', '-AllowDirty') } + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = @(& powershell.exe @arguments 2>&1) + $childExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($childExit -eq 0 -or ($output | Out-String) -notmatch 'safe filename component') { + throw "Unsafe version was not rejected before path use by $scriptPath" + } + } +} finally { + foreach ($entry in $savedBuildEnvironment) { + Set-Item ("Env:\" + $entry.Name) -Value $entry.Value + } +} + +$savedGitDir = $env:GIT_DIR +try { + $env:GIT_DIR = Join-Path $repoRoot '.git' + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $gitOverrideOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPath ` + -Version 'git-override-contract' ` + -PackageRoot (Join-Path $repoRoot 'output/contract-tests/missing-package') ` + -ExpectedCommit ("1" * 40) 2>&1) + $gitOverrideExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($gitOverrideExit -eq 0 -or ($gitOverrideOutput | Out-String) -notmatch 'refuses ambient Git overrides') { + throw "Verifier did not reject ambient Git redirection before package/path work" + } +} finally { + if ($null -eq $savedGitDir) { + Remove-Item Env:\GIT_DIR -ErrorAction SilentlyContinue + } else { + $env:GIT_DIR = $savedGitDir + } +} + +$gitTrustRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-git-trust-contract-' + [guid]::NewGuid().ToString('N')) +try { + $gitMain = Join-Path $gitTrustRoot 'main' + $maliciousHooks = Join-Path $gitTrustRoot 'malicious-hooks' + $safeWorktree = Join-Path $gitTrustRoot 'safe-worktree' + $markerPath = Join-Path $gitTrustRoot 'LOCAL_GIT_CONFIG_EXECUTED.txt' + $disabledHooks = Join-Path $gitTrustRoot 'disabled-hooks-must-not-exist' + New-Item -ItemType Directory -Force -Path $gitMain, $maliciousHooks | Out-Null + & git -C $gitMain init -q + & git -C $gitMain config user.name 'Stackchan Contract' + & git -C $gitMain config user.email 'contract@example.invalid' + Set-Content -LiteralPath (Join-Path $gitMain 'tracked.txt') -Value 'trusted' -Encoding ASCII + & git -C $gitMain add tracked.txt + & git -C $gitMain commit -q -m trusted + $trustedCommit = (& git -C $gitMain rev-parse HEAD).Trim() + $postCheckout = Join-Path $maliciousHooks 'post-checkout' + @" +#!/bin/sh +printf executed > '$($markerPath.Replace('\', '/'))' +"@ | Set-Content -LiteralPath $postCheckout -Encoding ASCII + & git -C $gitMain config core.hooksPath $maliciousHooks + & git -C $gitMain config core.fsmonitor $postCheckout + + . $gitTrustPath + $gitApplication = Get-Command -Name git -CommandType Application -ErrorAction Stop | + Select-Object -First 1 + $gitExecutable = (Resolve-Path -LiteralPath ([string]$gitApplication.Source)).Path + Invoke-StackchanTrustedGit -GitExecutable $gitExecutable -DisabledHooksPath $disabledHooks -Arguments @( + '-C', $gitMain, 'worktree', 'add', '--detach', $safeWorktree, $trustedCommit) + if ($LASTEXITCODE -ne 0) { throw 'Trusted Git contract could not create the fixture worktree' } + $trustedStatus = @(Invoke-StackchanTrustedGit -GitExecutable $gitExecutable -DisabledHooksPath $disabledHooks -Arguments @( + '-C', $safeWorktree, 'status', '--porcelain=v1', '--untracked-files=all')) + if ($LASTEXITCODE -ne 0 -or $trustedStatus.Count -ne 0) { + throw 'Trusted Git contract could not audit the fixture worktree' + } + if (Test-Path -LiteralPath $markerPath) { + throw 'Trusted Git wrapper executed repository-local hook or fsmonitor configuration' + } + Invoke-StackchanTrustedGit -GitExecutable $gitExecutable -DisabledHooksPath $disabledHooks -Arguments @( + '-C', $gitMain, 'worktree', 'remove', $safeWorktree) + if ($LASTEXITCODE -ne 0) { throw 'Trusted Git contract could not remove the fixture worktree' } +} finally { + if ((Test-Path -LiteralPath (Join-Path $gitTrustRoot 'main/.git')) -and + (Test-Path -LiteralPath (Join-Path $gitTrustRoot 'safe-worktree'))) { + & git -C (Join-Path $gitTrustRoot 'main') worktree remove --force ` + (Join-Path $gitTrustRoot 'safe-worktree') 2>$null + } + if (Test-Path -LiteralPath $gitTrustRoot) { + Get-ChildItem -LiteralPath $gitTrustRoot -Recurse -Force -File -ErrorAction SilentlyContinue | + ForEach-Object { $_.IsReadOnly = $false } + [System.IO.Directory]::Delete($gitTrustRoot, $true) + } +} + +$releaseEligibilityUnavailable = $verifyText.Contains( + 'Release-eligible verification is fail-closed before Git or build-tool execution') +if (-not $releaseEligibilityUnavailable) { + $epochTrustRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-epoch-trust-contract-' + [guid]::NewGuid().ToString('N')) + try { + $epochTools = Join-Path $epochTrustRoot 'tools' + New-Item -ItemType Directory -Force -Path $epochTools | Out-Null + foreach ($relative in @( + 'verify_release_package.ps1', + 'firmware_reproducibility_proof.ps1', + 'release_zip_safety.ps1', + 'release_dependency_evidence.ps1', + 'release_git_trust.ps1', + 'platformio_resolver.ps1' + )) { + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $relative) -Destination $epochTools + } + @' +tools/release_dependency_evidence.ps1 filter=contractclean +'@ | Set-Content -LiteralPath (Join-Path $epochTrustRoot '.gitattributes') -Encoding ASCII + @' +import sys + +sys.stdin.buffer.read() +with open("filter_expected.bin", "rb") as expected: + sys.stdout.buffer.write(expected.read()) +'@ | Set-Content -LiteralPath (Join-Path $epochTrustRoot 'filter_clean.py') -Encoding ASCII + Copy-Item -LiteralPath (Join-Path $epochTools 'release_dependency_evidence.ps1') ` + -Destination (Join-Path $epochTrustRoot 'filter_expected.bin') + $earlyDiagnosticRoot = Join-Path $epochTrustRoot 'package' + New-Item -ItemType Directory -Path $earlyDiagnosticRoot | Out-Null + [ordered]@{ + version = 'diagnostic-early-contract' + diagnosticPackage = $true + } | ConvertTo-Json | Set-Content ` + -LiteralPath (Join-Path $earlyDiagnosticRoot 'release_manifest.json') -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $epochTrustRoot '.gitignore') ` + -Value "ignored-verifier/`n" -Encoding ASCII + & git -C $epochTrustRoot init -q + & git -C $epochTrustRoot config user.name 'Stackchan Contract' + & git -C $epochTrustRoot config user.email 'contract@example.invalid' + & git -C $epochTrustRoot add tools package .gitignore .gitattributes filter_clean.py filter_expected.bin + & git -C $epochTrustRoot commit -q -m trusted-epoch + & git -C $epochTrustRoot config filter.contractclean.clean 'python filter_clean.py' + & git -C $epochTrustRoot config filter.contractclean.smudge cat + & git -C $epochTrustRoot config filter.contractclean.required true + $epochCommit = (& git -C $epochTrustRoot rev-parse HEAD).Trim() + $epochValue = (& git -C $epochTrustRoot show -s --format=%ct HEAD).Trim() + $wrongEpoch = if ($epochValue -eq '1') { '2' } else { '1' } + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $epochOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $epochTools 'verify_release_package.ps1') ` + -Version 'epoch-contract' ` + -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` + -ExpectedCommit $epochCommit ` + -ExpectedSourceEpoch $wrongEpoch ` + -RequireReleaseEligible 2>&1) + $epochExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($epochExit -eq 0 -or + ($epochOutput | Out-String) -notmatch 'does not match the trusted checkout commit epoch') { + throw "Operational verifier did not derive and enforce the trusted commit epoch: $($epochOutput | Out-String)" + } + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $earlyDiagnosticOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $epochTools 'verify_release_package.ps1') ` + -Version 'diagnostic-early-contract' ` + -PackageRoot $earlyDiagnosticRoot ` + -ExpectedCommit $epochCommit ` + -ExpectedSourceEpoch $epochValue ` + -AllowDirtyPackage ` + -RequireReleaseEligible 2>&1) + $earlyDiagnosticExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($earlyDiagnosticExit -eq 0 -or + ($earlyDiagnosticOutput | Out-String) -notmatch 'Operational release verification refuses diagnostic packages') { + throw 'Operational verifier did not reject a diagnostic manifest at the early eligibility gate' + } + + foreach ($dirtyHelperName in @( + 'firmware_reproducibility_proof.ps1', + 'release_zip_safety.ps1', + 'release_dependency_evidence.ps1', + 'release_git_trust.ps1', + 'platformio_resolver.ps1' + )) { + $preGateMarker = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-dirty-helper-marker-' + [guid]::NewGuid().ToString('N') + '.txt') + $dirtyHelperPath = Join-Path $epochTools $dirtyHelperName + $markerLiteral = $preGateMarker.Replace("'", "''") + $dirtyHelperText = Get-Content -LiteralPath $dirtyHelperPath -Raw + ("[System.IO.File]::WriteAllText('$markerLiteral', 'executed')`r`n" + $dirtyHelperText) | + Set-Content -LiteralPath $dirtyHelperPath -Encoding UTF8 + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $dirtyHelperOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $epochTools 'verify_release_package.ps1') ` + -Version 'dirty-helper-contract' ` + -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` + -ExpectedCommit $epochCommit ` + -ExpectedSourceEpoch $epochValue ` + -RequireReleaseEligible 2>&1) + $dirtyHelperExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($dirtyHelperExit -eq 0 -or + ($dirtyHelperOutput | Out-String) -notmatch 'requires a clean trusted checkout') { + throw "Operational verifier did not reject dirty $dirtyHelperName at the checkout gate" + } + if (Test-Path -LiteralPath $preGateMarker) { + throw "Operational verifier executed dirty $dirtyHelperName before the checkout gate" + } + & git -C $epochTrustRoot checkout -- ("tools/" + $dirtyHelperName) + if ($LASTEXITCODE -ne 0) { + throw "Could not restore dirty-helper contract fixture: $dirtyHelperName" + } + } + + foreach ($hiddenIndexCase in @( + [pscustomobject]@{ + helper = 'firmware_reproducibility_proof.ps1' + setFlag = '--assume-unchanged' + clearFlag = '--no-assume-unchanged' + label = 'assume-unchanged' + }, + [pscustomobject]@{ + helper = 'release_zip_safety.ps1' + setFlag = '--skip-worktree' + clearFlag = '--no-skip-worktree' + label = 'skip-worktree' + } + )) { + $hiddenMarker = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-hidden-helper-marker-' + [guid]::NewGuid().ToString('N') + '.txt') + $hiddenRelative = 'tools/' + [string]$hiddenIndexCase.helper + $hiddenPath = Join-Path $epochTrustRoot $hiddenRelative + try { + & git -C $epochTrustRoot update-index ([string]$hiddenIndexCase.setFlag) -- $hiddenRelative + if ($LASTEXITCODE -ne 0) { + throw "Could not set $($hiddenIndexCase.label) on the dirty-helper fixture" + } + $hiddenLiteral = $hiddenMarker.Replace("'", "''") + $hiddenText = Get-Content -LiteralPath $hiddenPath -Raw + ("[System.IO.File]::WriteAllText('$hiddenLiteral', 'executed')`r`n" + $hiddenText) | + Set-Content -LiteralPath $hiddenPath -Encoding UTF8 + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $hiddenOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $epochTools 'verify_release_package.ps1') ` + -Version 'hidden-helper-contract' ` + -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` + -ExpectedCommit $epochCommit ` + -ExpectedSourceEpoch $epochValue ` + -RequireReleaseEligible 2>&1) + $hiddenExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($hiddenExit -eq 0 -or + ($hiddenOutput | Out-String) -notmatch 'refuses hidden index state|requires exact HEAD bytes') { + throw "Operational verifier did not reject $($hiddenIndexCase.label) helper state" + } + if (Test-Path -LiteralPath $hiddenMarker) { + throw "Operational verifier executed a $($hiddenIndexCase.label) dirty helper before rejection" + } + } finally { + & git -C $epochTrustRoot update-index ([string]$hiddenIndexCase.clearFlag) -- $hiddenRelative + & git -C $epochTrustRoot checkout -- $hiddenRelative + } + } + + $filteredHelperRelative = 'tools/release_dependency_evidence.ps1' + $filteredHelperPath = Join-Path $epochTrustRoot $filteredHelperRelative + $filteredMarker = Join-Path $epochTrustRoot 'CUSTOM_FILTER_HELPER_EXECUTED.txt' + try { + $filteredMarkerLiteral = $filteredMarker.Replace("'", "''") + $filteredHelperText = Get-Content -LiteralPath $filteredHelperPath -Raw + $filteredCanary = @( + "[System.IO.File]::WriteAllText('$filteredMarkerLiteral', 'executed') # CONTRACT_CANARY", + "throw 'custom clean-filter helper executed' # CONTRACT_CANARY" + ) -join "`r`n" + [System.IO.File]::WriteAllText( + $filteredHelperPath, ($filteredCanary + "`r`n" + $filteredHelperText), + (New-Object System.Text.UTF8Encoding($false))) + & git -C $epochTrustRoot update-index --really-refresh -- $filteredHelperRelative | Out-Null + $ordinaryFilteredStatus = @(& git -C $epochTrustRoot status --porcelain=v1 -- $filteredHelperRelative) + if ($LASTEXITCODE -ne 0 -or $ordinaryFilteredStatus.Count -ne 0) { + $ordinaryFilteredDiff = @(& git -C $epochTrustRoot diff -- $filteredHelperRelative) + throw "Custom clean-filter fixture did not hide the helper mutation from ordinary Git status: $($ordinaryFilteredStatus -join '; ') :: $($ordinaryFilteredDiff -join '; ')" + } + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $filteredOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $epochTools 'verify_release_package.ps1') ` + -Version 'custom-filter-helper-contract' ` + -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` + -ExpectedCommit $epochCommit ` + -ExpectedSourceEpoch $epochValue ` + -RequireReleaseEligible 2>&1) + $filteredExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($filteredExit -eq 0 -or + ($filteredOutput | Out-String) -notmatch 'requires canonical HEAD content') { + throw 'Operational verifier let a custom clean-filter hide a modified trusted helper' + } + if (Test-Path -LiteralPath $filteredMarker) { + throw 'Operational verifier executed the custom-filter-hidden helper before rejection' + } + } finally { + & git -C $epochTrustRoot checkout -- $filteredHelperRelative + } + + $infoAttributesPath = Join-Path $epochTrustRoot '.git/info/attributes' + $infoHelperRelative = 'tools/release_zip_safety.ps1' + $infoHelperPath = Join-Path $epochTrustRoot $infoHelperRelative + $infoMarker = Join-Path $epochTrustRoot 'INFO_ATTRIBUTES_HELPER_EXECUTED.txt' + try { + Set-Content -LiteralPath $infoAttributesPath ` + -Value 'tools/*.ps1 filter=contractclean' -Encoding ASCII + $infoMarkerLiteral = $infoMarker.Replace("'", "''") + $infoHelperText = Get-Content -LiteralPath $infoHelperPath -Raw + ("[System.IO.File]::WriteAllText('$infoMarkerLiteral', 'executed')`r`n" + + "throw 'info attributes helper executed'`r`n" + $infoHelperText) | + Set-Content -LiteralPath $infoHelperPath -Encoding UTF8 + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $infoAttributesOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $epochTools 'verify_release_package.ps1') ` + -Version 'info-attributes-helper-contract' ` + -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` + -ExpectedCommit $epochCommit ` + -ExpectedSourceEpoch $epochValue ` + -RequireReleaseEligible 2>&1) + $infoAttributesExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($infoAttributesExit -eq 0 -or + ($infoAttributesOutput | Out-String) -notmatch 'refuses repository-local Git attributes') { + throw 'Operational verifier did not reject repository-local info/attributes' + } + if (Test-Path -LiteralPath $infoMarker) { + throw 'Operational verifier executed an info/attributes-hidden helper before rejection' + } + } finally { + Remove-Item -LiteralPath $infoAttributesPath -Force -ErrorAction SilentlyContinue + & git -C $epochTrustRoot checkout -- $infoHelperRelative + } + + $nestedVerifierRoot = Join-Path $epochTrustRoot 'ignored-verifier/tools' + New-Item -ItemType Directory -Path $nestedVerifierRoot -Force | Out-Null + Copy-Item -LiteralPath (Join-Path $epochTools 'verify_release_package.ps1') ` + -Destination $nestedVerifierRoot + $nestedMarker = Join-Path $epochTrustRoot 'NESTED_HELPER_EXECUTED.txt' + $nestedMarkerLiteral = $nestedMarker.Replace("'", "''") + ("[System.IO.File]::WriteAllText('$nestedMarkerLiteral', 'executed')`r`n" + + (Get-Content -LiteralPath (Join-Path $epochTools 'firmware_reproducibility_proof.ps1') -Raw)) | + Set-Content -LiteralPath (Join-Path $nestedVerifierRoot 'firmware_reproducibility_proof.ps1') -Encoding UTF8 + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $nestedOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $nestedVerifierRoot 'verify_release_package.ps1') ` + -Version 'nested-ignored-contract' ` + -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` + -ExpectedCommit $epochCommit ` + -ExpectedSourceEpoch $epochValue ` + -RequireReleaseEligible 2>&1) + $nestedExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($nestedExit -eq 0 -or + ($nestedOutput | Out-String) -notmatch 'refuses a nested or ignored verifier') { + throw 'Operational verifier did not reject an ignored nested verifier before helper loading' + } + if (Test-Path -LiteralPath $nestedMarker) { + throw 'Ignored nested verifier loaded its local packaged helper before rejection' + } + } finally { + if (Test-Path -LiteralPath $epochTrustRoot) { + Get-ChildItem -LiteralPath $epochTrustRoot -Recurse -Force -File -ErrorAction SilentlyContinue | + ForEach-Object { $_.IsReadOnly = $false } + [System.IO.Directory]::Delete($epochTrustRoot, $true) + } + } +} + +$zipContractRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-verifier-zip-contract-' + [guid]::NewGuid().ToString('N')) +try { + New-Item -ItemType Directory -Path $zipContractRoot | Out-Null + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $unsafeZip = Join-Path $zipContractRoot 'unsafe.zip' + $archive = [System.IO.Compression.ZipFile]::Open($unsafeZip, [System.IO.Compression.ZipArchiveMode]::Create) + try { + $entry = $archive.CreateEntry('../OUTSIDE.txt') + $writer = New-Object System.IO.StreamWriter($entry.Open()) + try { $writer.Write('must not extract') } finally { $writer.Dispose() } + } finally { + $archive.Dispose() + } + $unsafeZipHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $unsafeZip).Hash.ToLowerInvariant() + [IO.File]::WriteAllText( + "$unsafeZip.sha256", + "$unsafeZipHash $([IO.Path]::GetFileName($unsafeZip))`n", + [Text.Encoding]::ASCII) + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $unsafeZipOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPath ` + -Version 'zip-contract' -ZipPath $unsafeZip -ExpectedCommit ("1" * 40) 2>&1) + $unsafeZipExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($unsafeZipExit -eq 0 -or ($unsafeZipOutput | Out-String) -notmatch 'unsafe, duplicate, or link entry') { + throw "ZIP traversal entry was not rejected before extraction" + } + if (Test-Path -LiteralPath (Join-Path $zipContractRoot 'OUTSIDE.txt')) { + throw "ZIP traversal entry escaped the verifier extraction root" + } + + . $zipSafetyPath + $hostileArchives = @() + $duplicateZip = Join-Path $zipContractRoot 'duplicate.zip' + $archive = [System.IO.Compression.ZipFile]::Open($duplicateZip, [System.IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($name in @('same.txt', 'SAME.txt')) { + $entry = $archive.CreateEntry($name) + $writer = New-Object System.IO.StreamWriter($entry.Open()) + try { $writer.Write($name) } finally { $writer.Dispose() } + } + } finally { $archive.Dispose() } + $hostileArchives += $duplicateZip + + $linkZip = Join-Path $zipContractRoot 'link.zip' + $archive = [System.IO.Compression.ZipFile]::Open($linkZip, [System.IO.Compression.ZipArchiveMode]::Create) + try { + $entry = $archive.CreateEntry('package-link') + $entry.ExternalAttributes = [int]((0xA000 -bor 0x1FF) -shl 16) + $writer = New-Object System.IO.StreamWriter($entry.Open()) + try { $writer.Write('target') } finally { $writer.Dispose() } + } finally { $archive.Dispose() } + $hostileArchives += $linkZip + + foreach ($hostileZip in $hostileArchives) { + $destination = Join-Path $zipContractRoot ([System.IO.Path]::GetFileNameWithoutExtension($hostileZip) + '-extract') + New-Item -ItemType Directory -Path $destination | Out-Null + try { + Expand-StackchanReleaseZipSafely -ZipPath $hostileZip -DestinationPath $destination + throw "Safe ZIP extractor accepted hostile archive: $hostileZip" + } catch { + if ($_.Exception.Message -like 'Safe ZIP extractor accepted hostile archive:*') { throw } + } + if (@(Get-ChildItem -LiteralPath $destination -Force).Count -ne 0) { + throw "Hostile ZIP was partially extracted before rejection: $hostileZip" + } + } +} finally { + if (Test-Path -LiteralPath $zipContractRoot) { + [System.IO.Directory]::Delete($zipContractRoot, $true) + } +} + +if (-not [string]::IsNullOrWhiteSpace($BehaviorFixtureRoot)) { + $sourceFixture = (Resolve-Path -LiteralPath $BehaviorFixtureRoot).Path + $behaviorRoot = if ($env:OS -eq 'Windows_NT') { + Join-Path ([System.IO.Path]::GetPathRoot($repoRoot)) ( + 'svt-' + [guid]::NewGuid().ToString('N').Substring(0, 12)) + } else { + Join-Path ([System.IO.Path]::GetTempPath()) ( + 'svt-' + [guid]::NewGuid().ToString('N').Substring(0, 12)) + } + $packageRoot = Join-Path $behaviorRoot 'package' + try { + New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null + if ($env:OS -eq 'Windows_NT') { + & robocopy.exe $sourceFixture $packageRoot /E /COPY:DAT /DCOPY:DAT /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -ge 8) { throw "Could not copy hostile-package behavior fixture (robocopy exit $LASTEXITCODE)" } + } else { + Get-ChildItem -LiteralPath $sourceFixture -Force | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $packageRoot -Recurse -Force + } + } + $manifest = Get-Content -LiteralPath (Join-Path $packageRoot 'release_manifest.json') -Raw | ConvertFrom-Json + $baselineArgs = @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $verifyPath, + '-Version', [string]$manifest.version, + '-PackageRoot', $packageRoot, + '-ExpectedCommit', [string]$manifest.commit + ) + if ([bool]$manifest.diagnosticPackage) { $baselineArgs += '-AllowDirtyPackage' } + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $baselineOutput = @(& powershell.exe @baselineArgs 2>&1) + $baselineExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + $expectedSuccess = if ([bool]$manifest.diagnosticPackage) { + 'Diagnostic archive integrity verified' + } else { + 'Release package verified' + } + if ($baselineExit -ne 0 -or ($baselineOutput | Out-String) -notmatch [regex]::Escape($expectedSuccess)) { + throw "Unmodified hostile-package fixture did not reach verifier success: $($baselineOutput | Out-String)" + } + + $mutatedRelatives = New-Object System.Collections.Generic.List[string] + $trustedPowerShellTwins = @( + 'tools/firmware_reproducibility_proof.ps1', + 'tools/release_zip_safety.ps1', + 'tools/release_dependency_evidence.ps1', + 'tools/release_git_trust.ps1', + 'tools/preview_python_resolver.ps1', + 'tools/verify_voice_samples.ps1', + 'tools/verify_tracked_rvc_assets.ps1', + 'tools/verify_speech_envelope_sidecar.ps1', + 'tools/verify_preview_media.ps1', + 'tools/verify_face_phase_a.ps1', + 'tools/verify_face_phase_b.ps1', + 'tools/verify_face_phase_c.ps1', + 'tools/verify_face_phase_d.ps1', + 'tools/verify_face_phase_e.ps1', + 'tools/verify_release_asset_contract.ps1' + ) + foreach ($relative in $trustedPowerShellTwins) { + $twinPath = Join-Path $packageRoot $relative + if (-not (Test-Path -LiteralPath $twinPath -PathType Leaf)) { + throw "Behavior fixture is missing trusted-script twin: $relative" + } + $markerPath = Join-Path $behaviorRoot ( + 'EXECUTED-' + ($relative -replace '[^A-Za-z0-9]', '_') + '.txt') + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $twinPath, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -ne 0) { throw "Cannot instrument invalid fixture script: $relative" } + $insertOffset = if ($null -ne $ast.ParamBlock) { $ast.ParamBlock.Extent.EndOffset } else { 0 } + $original = [System.IO.File]::ReadAllText($twinPath) + $canary = "`r`nSet-Content -LiteralPath '$($markerPath.Replace("'", "''"))' -Value 'executed' -Encoding ASCII`r`nthrow 'package canary executed'`r`n" + $instrumented = $original.Insert($insertOffset, $canary) + [System.IO.File]::WriteAllText( + $twinPath, $instrumented, (New-Object System.Text.UTF8Encoding($false))) + $reparseTokens = $null + $reparseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + $twinPath, [ref]$reparseTokens, [ref]$reparseErrors) + if ($reparseErrors.Count -ne 0) { throw "Instrumented fixture script is invalid: $relative" } + $mutatedRelatives.Add($relative) + } + + $nestedPackagedMarker = Join-Path $behaviorRoot 'EXECUTED-ignored_nested_verifier.txt' + $nestedPackagedVerifier = Join-Path $packageRoot 'tools/ignored/nested/verify_release_package.ps1' + New-Item -ItemType Directory -Path (Split-Path -Parent $nestedPackagedVerifier) -Force | Out-Null + @( + 'param()', + "Set-Content -LiteralPath '$($nestedPackagedMarker.Replace("'", "''"))' -Value 'executed' -Encoding ASCII", + "throw 'ignored nested packaged verifier executed'" + ) | Set-Content -LiteralPath $nestedPackagedVerifier -Encoding UTF8 + + $pythonRelative = 'bridge/lan_service.py' + $pythonPath = Join-Path $packageRoot $pythonRelative + if (-not (Test-Path -LiteralPath $pythonPath -PathType Leaf)) { + throw "Behavior fixture is missing Python trust twin: $pythonRelative" + } + $pythonMarker = Join-Path $behaviorRoot 'EXECUTED-bridge_lan_service_py.txt' + $pythonText = [System.IO.File]::ReadAllText($pythonPath) + $mainGuard = 'if __name__ == "__main__":' + $mainOffset = $pythonText.LastIndexOf($mainGuard, [System.StringComparison]::Ordinal) + if ($mainOffset -lt 0) { throw 'Behavior fixture Python twin lacks its main guard' } + $pythonCanary = "from pathlib import Path as _CanaryPath`n_CanaryPath(r'$($pythonMarker.Replace('\', '/'))').write_text('executed')`nraise RuntimeError('package canary executed')`n" + [System.IO.File]::WriteAllText( + $pythonPath, + $pythonText.Insert($mainOffset, $pythonCanary), + (New-Object System.Text.UTF8Encoding($false))) + $astCheckOutput = @(& python -I -B -c ` + 'import ast,sys; ast.parse(open(sys.argv[1], encoding=sys.getdefaultencoding()).read())' ` + $pythonPath 2>&1) + if ($LASTEXITCODE -ne 0) { throw "Instrumented Python twin is invalid: $($astCheckOutput | Out-String)" } + $mutatedRelatives.Add($pythonRelative) + + $firstMutatedHashPath = $null + foreach ($line in Get-Content -LiteralPath (Join-Path $packageRoot 'SHA256SUMS.txt')) { + if ($line -match '^[0-9a-fA-F]{64}\s{2}(.+)$' -and $mutatedRelatives.Contains($Matches[1])) { + $firstMutatedHashPath = $Matches[1] + break + } + } + if ([string]::IsNullOrWhiteSpace($firstMutatedHashPath)) { + throw 'Behavior fixture checksums do not cover the instrumented trust twins' + } + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $behaviorOutput = @(& powershell.exe @baselineArgs 2>&1) + $behaviorExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($behaviorExit -eq 0 -or + ($behaviorOutput | Out-String) -notmatch [regex]::Escape("SHA256 mismatch for $firstMutatedHashPath")) { + throw "Hostile-package run did not reach the final checksum checkpoint: $($behaviorOutput | Out-String)" + } + $executedMarkers = @(Get-ChildItem -LiteralPath $behaviorRoot -Filter 'EXECUTED-*.txt' -File -ErrorAction SilentlyContinue) + if ($executedMarkers.Count -ne 0) { + throw "Hostile package code executed: $($executedMarkers.Name -join ', ')" + } + } finally { + if (Test-Path -LiteralPath $behaviorRoot) { + $deleteRoot = if ($env:OS -eq 'Windows_NT' -and -not $behaviorRoot.StartsWith('\\?\')) { + "\\?\$behaviorRoot" + } else { $behaviorRoot } + [System.IO.Directory]::Delete($deleteRoot, $true) + } + } +} + +Write-Host "Release package verifier trust contract passed." diff --git a/tools/test_release_publication_safety_contract.ps1 b/tools/test_release_publication_safety_contract.ps1 new file mode 100644 index 00000000..e185e419 --- /dev/null +++ b/tools/test_release_publication_safety_contract.ps1 @@ -0,0 +1,435 @@ +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$publishPath = Join-Path $PSScriptRoot 'publish_release.ps1' +$sharePath = Join-Path $PSScriptRoot 'share_release.ps1' +$publishCmdPath = Join-Path $PSScriptRoot 'publish_release.cmd' +$shareCmdPath = Join-Path $PSScriptRoot 'share_release.cmd' +$gitCommand = Get-Command -Name git -CommandType Application -ErrorAction Stop | Select-Object -First 1 +$gitExecutable = (Resolve-Path -LiteralPath ([string]$gitCommand.Source)).Path +$powerShellCommand = Get-Command -Name powershell.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1 +$powerShellExecutable = (Resolve-Path -LiteralPath ([string]$powerShellCommand.Source)).Path + +function Assert-True { + param( + [Parameter(Mandatory = $true)][bool]$Condition, + [Parameter(Mandatory = $true)][string]$Message + ) + if (-not $Condition) { + throw $Message + } +} + +function Get-ScriptAst { + param([Parameter(Mandatory = $true)][string]$Path) + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $Path, + [ref]$tokens, + [ref]$errors + ) + if (@($errors).Count -gt 0) { + throw "PowerShell parse failed for $Path`: $(@($errors | ForEach-Object Message) -join '; ')" + } + return $ast +} + +function Get-FunctionDefinition { + param( + [Parameter(Mandatory = $true)]$Ast, + [Parameter(Mandatory = $true)][string]$Name + ) + $definition = $Ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $Name + }, $true) + if ($null -eq $definition) { + throw "Missing function definition: $Name" + } + return $definition +} + +$publishText = Get-Content -LiteralPath $publishPath -Raw +$publishAst = Get-ScriptAst -Path $publishPath +$shareText = Get-Content -LiteralPath $sharePath -Raw +$publishCmdText = Get-Content -LiteralPath $publishCmdPath -Raw +$shareCmdText = Get-Content -LiteralPath $shareCmdPath -Raw +$null = Get-ScriptAst -Path $sharePath + +Assert-True ($publishText -match 'Get-Command\s+-Name\s+git\s+-CommandType\s+Application') 'Publish must resolve Git as an Application.' +Assert-True ($publishText -match 'Get-Command\s+-Name\s+gh\s+-CommandType\s+Application') 'Publish must resolve GitHub CLI as an Application.' +Assert-True ($publishText -notmatch '\brepo\s+view\b') 'Publish must not infer repository authority through ambient GitHub CLI context.' +Assert-True ($publishText -match 'Real publication requires explicit -Repo owner/name') 'Real publication must require an explicit repository target.' +Assert-True ($publishText -match 'Assert-SafeGitHubRepositoryName -Value \$Repo') 'Publish must validate an explicitly supplied repository target.' +Assert-True ($shareText -match 'Get-Command\s+-Name\s+git\s+-CommandType\s+Application') 'Share must resolve Git as an Application.' +Assert-True ($publishText -match 'Get-Command\s+-Name\s+powershell\.exe\s+-CommandType\s+Application') 'Publish must resolve Windows PowerShell as an Application.' +Assert-True ($shareText -match 'Get-Command\s+-Name\s+powershell\.exe\s+-CommandType\s+Application') 'Share must resolve Windows PowerShell as an Application.' +Assert-True ($publishText -notmatch '&\s+powershell\.exe\b') 'Publish must never invoke ambient powershell.exe command resolution.' +Assert-True ($shareText -notmatch '&\s+powershell\.exe\b') 'Share must never invoke ambient powershell.exe command resolution.' +Assert-True ($publishText -match '&\s+\$script:publishPowerShellExecutable\b') 'Publish must invoke the exact resolved Windows PowerShell application.' +Assert-True ($shareText -match '&\s+\$script:sharePowerShellExecutable\b') 'Share must invoke the exact resolved Windows PowerShell application.' +Assert-True ($shareText -notmatch 'powershell\.exe\s+-NoProfile') 'Generated share helpers must not contain a bare PowerShell executable name.' +Assert-True ($shareText -match '`"\$script:sharePowerShellExecutable`" -NoProfile -ExecutionPolicy Bypass -File') 'Generated STOP_SHARING helper must quote the exact resolved PowerShell path and use -File.' +Assert-True ($publishText -notmatch '(?m)^\s*Set-Location\b') 'Publish must not change ambient working location.' +Assert-True ($shareText -notmatch '(?m)^\s*Set-Location\b') 'Share must not change ambient working location.' +foreach ($cmdWrapper in @( + [pscustomobject]@{ name = 'Publish'; text = $publishCmdText; variable = 'STACKCHAN_PUBLISH_POWERSHELL'; script = 'publish_release.ps1' }, + [pscustomobject]@{ name = 'Share'; text = $shareCmdText; variable = 'STACKCHAN_SHARE_POWERSHELL'; script = 'share_release.ps1' } + )) { + Assert-True ($cmdWrapper.text -notmatch '(?im)^\s*powershell(?:\.exe)?\s') "$($cmdWrapper.name) CMD wrapper must not use ambient PowerShell command resolution." + Assert-True ($cmdWrapper.text -match [regex]::Escape('%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe')) "$($cmdWrapper.name) CMD wrapper must pin the Windows PowerShell system path." + Assert-True ($cmdWrapper.text -match ('if not exist "%{0}%"' -f $cmdWrapper.variable)) "$($cmdWrapper.name) CMD wrapper must fail closed when exact Windows PowerShell is absent." + Assert-True ($cmdWrapper.text -match ('"%{0}%" -NoProfile -ExecutionPolicy Bypass -File "%~dp0{1}" %\*' -f $cmdWrapper.variable, $cmdWrapper.script)) "$($cmdWrapper.name) CMD wrapper must quote the exact executable and script paths." +} + +$publishGitInvocations = @($publishAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.Extent.Text -match '^&\s+\$script:publishGitExecutable\b' + }, $true)) +Assert-True ($publishGitInvocations.Count -eq 1) 'Publish must invoke the Git application only through one hardened wrapper.' +$publishGitWrapper = Get-FunctionDefinition -Ast $publishAst -Name 'Invoke-PublishTrustedGit' +Assert-True ($publishGitInvocations[0].Extent.StartOffset -gt $publishGitWrapper.Extent.StartOffset -and + $publishGitInvocations[0].Extent.EndOffset -lt $publishGitWrapper.Extent.EndOffset) 'Publish Git application invocation escaped the hardened wrapper.' +$publishGitHubInvocations = @($publishAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.Extent.Text -match '^&\s+\$script:publishGitHubExecutable\b' + }, $true)) +Assert-True ($publishGitHubInvocations.Count -gt 0) 'Publish GitHub CLI invocations were not found.' +$shareAst = Get-ScriptAst -Path $sharePath +$shareGitInvocations = @($shareAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.Extent.Text -match '^&\s+\$script:shareGitExecutable\b' + }, $true)) +Assert-True ($shareGitInvocations.Count -eq 1) 'Share must invoke the Git application only through one hardened wrapper.' +$shareGitWrapper = Get-FunctionDefinition -Ast $shareAst -Name 'Invoke-ShareTrustedGit' +Assert-True ($shareGitInvocations[0].Extent.StartOffset -gt $shareGitWrapper.Extent.StartOffset -and + $shareGitInvocations[0].Extent.EndOffset -lt $shareGitWrapper.Extent.EndOffset) 'Share Git application invocation escaped the hardened wrapper.' + +foreach ($wrapper in @( + [pscustomobject]@{ name = 'publish'; text = $publishGitWrapper.Extent.Text; root = '$script:publishRepoRoot' }, + [pscustomobject]@{ name = 'share'; text = $shareGitWrapper.Extent.Text; root = '$script:shareRepoRoot' } + )) { + foreach ($requiredTrustPattern in @( + 'core.hooksPath=', 'core.fsmonitor=false', 'core.untrackedCache=false', + 'core.useBuiltinFSMonitor=false', 'maintenance.auto=false', 'core.autocrlf=true', + 'core.attributesFile=', 'filter.lfs.process=', 'filter.lfs.clean=', + 'filter.lfs.smudge=', 'filter.lfs.required=false', "'-C', $($wrapper.root)", + "'GIT_NO_REPLACE_OBJECTS', '1', [EnvironmentVariableTarget]::Process", + "'GIT_ATTR_NOSYSTEM', '1', [EnvironmentVariableTarget]::Process" + )) { + Assert-True ($wrapper.text.IndexOf($requiredTrustPattern, [System.StringComparison]::Ordinal) -ge 0) "$($wrapper.name) Git wrapper is missing trust control: $requiredTrustPattern" + } +} + +$tagFunctionText = (Get-FunctionDefinition -Ast $publishAst -Name 'New-VerifiedReleaseTag').Extent.Text +foreach ($requiredPattern in @( + "Invoke-PublishTrustedGit -Arguments @('tag', '-a', `$Tag, `$Commit, '-m', `$Tag)", + "'rev-list', '-n', '1', `$Tag", + "Invoke-PublishTrustedGit -Arguments @('tag', '-d', `$Tag)" + )) { + Assert-True ($tagFunctionText.IndexOf($requiredPattern, [System.StringComparison]::Ordinal) -ge 0) "Tag creation guard missing exact pattern: $requiredPattern" +} + +$dryRunMarker = 'Dry run: verified parameters and local Git authority only' +$dryRunIndex = $publishText.IndexOf($dryRunMarker, [System.StringComparison]::Ordinal) +$firstOperationalVerifyAfterDryRun = $publishText.IndexOf('Invoke-OperationalPackageVerification `', $dryRunIndex, [System.StringComparison]::Ordinal) +$ghResolutionAfterDryRun = $publishText.IndexOf('$publishGitHubCommand = Get-Command', $dryRunIndex, [System.StringComparison]::Ordinal) +$snapshotAfterDryRun = $publishText.IndexOf('$snapshot = New-VerifiedPublicationSnapshot', $dryRunIndex, [System.StringComparison]::Ordinal) +Assert-True ($dryRunIndex -ge 0) 'Publish is missing the output-only dry-run branch.' +Assert-True ($firstOperationalVerifyAfterDryRun -gt $dryRunIndex) 'Dry-run return must precede operational verification.' +Assert-True ($ghResolutionAfterDryRun -gt $dryRunIndex) 'Dry-run return must precede GitHub CLI resolution/network access.' +Assert-True ($snapshotAfterDryRun -gt $dryRunIndex) 'Dry-run return must precede snapshot mutation.' +$dryRunTail = $publishText.Substring($dryRunIndex, $firstOperationalVerifyAfterDryRun - $dryRunIndex) +Assert-True ($dryRunTail -match '(?m)^\s*return\s*$') 'Dry-run branch must return before verification or mutation.' +foreach ($forbiddenPattern in @('New-Item', 'Copy-Item', 'Remove-Item', 'Expand-', 'Invoke-WebRequest', 'release create', 'release upload', 'ls-remote')) { + Assert-True ($dryRunTail.IndexOf($forbiddenPattern, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) "Dry-run branch contains forbidden operation: $forbiddenPattern" +} + +$sourceVerifyIndex = $shareText.IndexOf('Invoke-OperationalPackageVerification `', [System.StringComparison]::Ordinal) +$snapshotIndex = $shareText.IndexOf('$snapshot = New-VerifiedShareSnapshot', [System.StringComparison]::Ordinal) +$snapshotSidecarIndex = $shareText.IndexOf('$snapshotSidecar -cne', [System.StringComparison]::Ordinal) +$shareMutationIndex = $shareText.IndexOf('Stop-ExistingShare -ExistingShareRoot $shareRoot', [System.StringComparison]::Ordinal) +Assert-True ($sourceVerifyIndex -ge 0 -and $sourceVerifyIndex -lt $snapshotIndex) 'Source verification must precede share snapshot creation.' +Assert-True ($snapshotSidecarIndex -gt $snapshotIndex -and $snapshotSidecarIndex -lt $shareMutationIndex) 'Copied sidecar validation must precede persistent share mutation.' +Assert-True ($shareText.IndexOf('export_rollout_status.ps1', [System.StringComparison]::OrdinalIgnoreCase) -lt 0) 'Share must not execute the local rollout exporter into served output.' +Assert-True ($shareText.IndexOf('`"$ghMarker`"", 'exit /b 91') | Set-Content -LiteralPath (Join-Path $shimRoot 'gh.cmd') -Encoding ASCII + $env:PATH = "$shimRoot;$originalPath" + $env:STACKCHAN_PUBLICATION_POWERSHELL_MARKER = $powerShellMarker + Set-Item -Path 'Function:\global:powershell.exe' -Value { + 'invoked' | Set-Content -LiteralPath $env:STACKCHAN_PUBLICATION_POWERSHELL_MARKER -Encoding ASCII + throw 'Ambient powershell.exe function must never execute.' + } + + New-Item -ItemType Directory -Path $dryPackageRoot -Force | Out-Null + 'unchanged package fixture' | Set-Content -LiteralPath (Join-Path $dryPackageRoot 'fixture.txt') -Encoding ASCII + 'unchanged zip fixture' | Set-Content -LiteralPath $dryZipPath -Encoding ASCII + 'unchanged sidecar fixture' | Set-Content -LiteralPath $drySidecarPath -Encoding ASCII + $beforeCandidateHashes = @( + Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $dryPackageRoot 'fixture.txt'), $dryZipPath, $drySidecarPath | + ForEach-Object Hash + ) + $beforeTags = (& $gitExecutable -C $repoRoot tag --list | Sort-Object) -join "`n" + $beforeSnapshots = @(Get-ChildItem -LiteralPath ([System.IO.Path]::GetTempPath()) -Directory -Filter "stackchan-publish-$PID-*" -ErrorAction SilentlyContinue | ForEach-Object FullName | Sort-Object) + + $dryOutput = & $publishPath -Version $dryVersion -CreateTag -DryRun *>&1 + Assert-True ($LASTEXITCODE -eq 0) "Publish dry run failed: $(($dryOutput | Out-String).Trim())" + Assert-True (($dryOutput | Out-String).IndexOf($dryRunMarker, [System.StringComparison]::Ordinal) -ge 0) 'Publish dry run did not reach the output-only branch.' + Assert-True (-not (Test-Path -LiteralPath $ghMarker)) 'Publish dry run invoked GitHub CLI.' + Assert-True (-not (Test-Path -LiteralPath $powerShellMarker)) 'Publish dry run invoked ambient powershell.exe command resolution.' + Assert-True ((Get-Location).Path -ceq $originalLocation) 'Publish dry run changed ambient working location.' + $afterTags = (& $gitExecutable -C $repoRoot tag --list | Sort-Object) -join "`n" + Assert-True ($afterTags -ceq $beforeTags) 'Publish dry run mutated local tags.' + $afterCandidateHashes = @( + Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $dryPackageRoot 'fixture.txt'), $dryZipPath, $drySidecarPath | + ForEach-Object Hash + ) + Assert-True ((Compare-Object $beforeCandidateHashes $afterCandidateHashes).Count -eq 0) 'Publish dry run mutated package candidates.' + $afterSnapshots = @(Get-ChildItem -LiteralPath ([System.IO.Path]::GetTempPath()) -Directory -Filter "stackchan-publish-$PID-*" -ErrorAction SilentlyContinue | ForEach-Object FullName | Sort-Object) + Assert-True ((Compare-Object $beforeSnapshots $afterSnapshots).Count -eq 0) 'Publish dry run created a publication snapshot.' + + $targetRepo = Join-Path $fixtureRoot 'target' + $ambientRepo = Join-Path $fixtureRoot 'ambient' + & $gitExecutable init -q $targetRepo + & $gitExecutable -C $targetRepo config user.name 'Publication Contract' + & $gitExecutable -C $targetRepo config user.email 'publication-contract@example.invalid' + & $gitExecutable -C $targetRepo config core.autocrlf false + & $gitExecutable -C $targetRepo commit --allow-empty -m first | Out-Null + $firstCommit = (& $gitExecutable -C $targetRepo rev-parse HEAD).Trim().ToLowerInvariant() + & $gitExecutable -C $targetRepo commit --allow-empty -m second | Out-Null + $secondCommit = (& $gitExecutable -C $targetRepo rev-parse HEAD).Trim().ToLowerInvariant() + 'trusted filter input' | Set-Content -LiteralPath (Join-Path $targetRepo 'probe.txt') -Encoding ASCII + 'probe.txt filter=lfs' | Set-Content -LiteralPath (Join-Path $targetRepo '.gitattributes') -Encoding ASCII + & $gitExecutable -C $targetRepo add -- probe.txt .gitattributes + & $gitExecutable -C $targetRepo commit -m probe | Out-Null + & $gitExecutable init -q $ambientRepo + & $gitExecutable -C $ambientRepo config user.name 'Publication Contract' + & $gitExecutable -C $ambientRepo config user.email 'publication-contract@example.invalid' + & $gitExecutable -C $ambientRepo commit --allow-empty -m ambient | Out-Null + + . ([scriptblock]::Create($publishGitWrapper.Extent.Text)) + . ([scriptblock]::Create((Get-FunctionDefinition -Ast $publishAst -Name 'Invoke-Checked').Extent.Text)) + . ([scriptblock]::Create((Get-FunctionDefinition -Ast $publishAst -Name 'New-VerifiedReleaseTag').Extent.Text)) + $script:publishGitExecutable = $gitExecutable + $script:publishRepoRoot = $targetRepo + $script:publishGitDisabledHooksPath = Join-Path $targetRepo 'disabled-publish-hooks-must-not-exist' + $script:publishNullAttributesPath = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } + + $hookMarker = Join-Path $fixtureRoot 'hostile-hook-invoked.txt' + $fsmonitorMarker = Join-Path $fixtureRoot 'hostile-fsmonitor-invoked.txt' + $lfsMarker = Join-Path $fixtureRoot 'hostile-lfs-invoked.txt' + $hostileHooks = Join-Path $fixtureRoot 'hostile-hooks' + New-Item -ItemType Directory -Path $hostileHooks | Out-Null + $hookMarkerForShell = $hookMarker.Replace('\', '/') + $fsmonitorMarkerForShell = $fsmonitorMarker.Replace('\', '/') + $lfsMarkerForShell = $lfsMarker.Replace('\', '/') + @('#!/bin/sh', "echo invoked > `"$hookMarkerForShell`"", 'exit 0') | + Set-Content -LiteralPath (Join-Path $hostileHooks 'pre-push') -Encoding ASCII + $fsmonitorCommand = Join-Path $fixtureRoot 'hostile-fsmonitor' + @('#!/bin/sh', "echo invoked > `"$fsmonitorMarkerForShell`"", 'exit 0') | + Set-Content -LiteralPath $fsmonitorCommand -Encoding ASCII + $lfsCommand = Join-Path $fixtureRoot 'hostile-lfs-filter' + @('#!/bin/sh', "echo invoked > `"$lfsMarkerForShell`"", 'exit 91') | + Set-Content -LiteralPath $lfsCommand -Encoding ASCII + $globalAttributes = Join-Path $fixtureRoot 'hostile-global-attributes' + '*.txt contractattr=set' | Set-Content -LiteralPath $globalAttributes -Encoding ASCII + & $gitExecutable -C $targetRepo config core.hooksPath $hostileHooks + & $gitExecutable -C $targetRepo config core.fsmonitor $fsmonitorCommand + & $gitExecutable -C $targetRepo config core.untrackedCache true + & $gitExecutable -C $targetRepo config maintenance.auto true + & $gitExecutable -C $targetRepo config core.attributesFile $globalAttributes + & $gitExecutable -C $targetRepo config filter.lfs.process $lfsCommand + & $gitExecutable -C $targetRepo config filter.lfs.clean $lfsCommand + & $gitExecutable -C $targetRepo config filter.lfs.smudge $lfsCommand + & $gitExecutable -C $targetRepo config filter.lfs.required true + & $gitExecutable -C $targetRepo replace $firstCommit $secondCommit + $replacedSubject = (& $gitExecutable -C $targetRepo log -1 --format=%s $firstCommit).Trim() + Assert-True ($replacedSubject -ceq 'second') 'Hostile replace-object fixture did not affect unhardened Git as expected.' + + $trustedSubject = (Invoke-PublishTrustedGit -Arguments @( + 'log', '-1', '--format=%s', $firstCommit) | Out-String).Trim() + Assert-True ($trustedSubject -ceq 'first') 'Publish Git wrapper did not disable replace objects.' + $trustedAttribute = (Invoke-PublishTrustedGit -Arguments @( + 'check-attr', 'contractattr', '--', 'probe.txt') | Out-String).Trim() + Assert-True ($trustedAttribute -match 'contractattr:\s+unspecified$') 'Publish Git wrapper consulted hostile global attributes.' + $null = Invoke-PublishTrustedGit -Arguments @('hash-object', '--path=probe.txt', 'probe.txt') + $null = Invoke-PublishTrustedGit -Arguments @('status', '--porcelain=v1') + foreach ($marker in @($hookMarker, $fsmonitorMarker, $lfsMarker)) { + Assert-True (-not (Test-Path -LiteralPath $marker)) "Publish Git trust probe executed hostile external state: $marker" + } + + . ([scriptblock]::Create($shareGitWrapper.Extent.Text)) + . ([scriptblock]::Create((Get-FunctionDefinition -Ast $shareAst -Name 'Invoke-GitText').Extent.Text)) + $script:shareGitExecutable = $gitExecutable + $script:shareRepoRoot = $targetRepo + $script:shareGitDisabledHooksPath = Join-Path $targetRepo 'disabled-share-hooks-must-not-exist' + $script:shareNullAttributesPath = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } + $shareHead = Invoke-GitText @('rev-parse', 'HEAD') + Assert-True ($shareHead -match '^[0-9a-f]{40}$') 'Share pre-verifier trusted Git read did not return exact HEAD.' + $shareSubject = (Invoke-ShareTrustedGit -Arguments @( + 'log', '-1', '--format=%s', $firstCommit) | Out-String).Trim() + Assert-True ($shareSubject -ceq 'first') 'Share Git wrapper did not disable replace objects.' + $null = Invoke-ShareTrustedGit -Arguments @('status', '--porcelain=v1') + foreach ($marker in @($hookMarker, $fsmonitorMarker, $lfsMarker)) { + Assert-True (-not (Test-Path -LiteralPath $marker)) "Share pre-verifier Git read executed hostile external state: $marker" + } + + Set-Location $ambientRepo + New-VerifiedReleaseTag -Tag 'contract-explicit-target' -Commit $firstCommit + $tagCommit = (& $gitExecutable -C $targetRepo rev-list -n 1 contract-explicit-target).Trim().ToLowerInvariant() + Assert-True ($tagCommit -ceq $firstCommit) 'Annotated tag did not target the explicit verified commit.' + $ambientTag = & $gitExecutable -C $ambientRepo tag --list contract-explicit-target + Assert-True ([string]::IsNullOrWhiteSpace(($ambientTag | Out-String).Trim())) 'Tag creation mutated the ambient repository instead of the exact repository.' + $invalidTagRejected = $false + try { + New-VerifiedReleaseTag -Tag 'contract-invalid-target' -Commit ('0' * 40) 2>$null + } catch { + $invalidTagRejected = $true + } + Assert-True $invalidTagRejected 'Invalid explicit tag target was not rejected.' + $invalidTag = & $gitExecutable -C $targetRepo tag --list contract-invalid-target + Assert-True ([string]::IsNullOrWhiteSpace(($invalidTag | Out-String).Trim())) 'Failed explicit tag creation left a tag residue.' + + & $gitExecutable -C $targetRepo tag contract-lightweight $firstCommit + $remoteRepo = Join-Path $fixtureRoot 'remote.git' + & $gitExecutable init --bare -q $remoteRepo + & $gitExecutable -C $targetRepo remote add contract-remote $remoteRepo + Invoke-PublishTrustedGit -Arguments @( + 'push', '--quiet', 'contract-remote', + 'refs/tags/contract-explicit-target', 'refs/tags/contract-lightweight') 2>$null | Out-Null + Assert-True (-not (Test-Path -LiteralPath $hookMarker)) 'Hardened publication push executed the hostile pre-push hook.' + . ([scriptblock]::Create((Get-FunctionDefinition -Ast $publishAst -Name 'Assert-RemoteTagPublishedAtCommit').Extent.Text)) + $remoteRefsBefore = (& $gitExecutable --git-dir=$remoteRepo show-ref | Sort-Object) -join "`n" + Assert-RemoteTagPublishedAtCommit -Tag contract-explicit-target -Commit $firstCommit -Remote $remoteRepo + Assert-RemoteTagPublishedAtCommit -Tag contract-lightweight -Commit $firstCommit -Remote $remoteRepo + $wrongRemoteTargetRejected = $false + try { + Assert-RemoteTagPublishedAtCommit -Tag contract-explicit-target -Commit $secondCommit -Remote $remoteRepo + } catch { + $wrongRemoteTargetRejected = $true + } + Assert-True $wrongRemoteTargetRejected 'Remote annotated tag with the wrong peeled target was not rejected.' + $remoteRefsAfter = (& $gitExecutable --git-dir=$remoteRepo show-ref | Sort-Object) -join "`n" + Assert-True ($remoteRefsAfter -ceq $remoteRefsBefore) 'Remote tag verification mutated the remote fixture.' + + $fixtureTools = Join-Path $targetRepo 'tools' + New-Item -ItemType Directory -Path $fixtureTools | Out-Null + $fixturePublishPath = Join-Path $fixtureTools 'publish_release.ps1' + $fixtureSharePath = Join-Path $fixtureTools 'share_release.ps1' + Copy-Item -LiteralPath $publishPath -Destination $fixturePublishPath + Copy-Item -LiteralPath $sharePath -Destination $fixtureSharePath + $hostileDryVersion = 'hostile-dryrun-' + [guid]::NewGuid().ToString('N') + $hostileReleaseRoot = Join-Path $targetRepo 'output/release' + $hostilePackageRoot = Join-Path $hostileReleaseRoot $hostileDryVersion + New-Item -ItemType Directory -Path $hostilePackageRoot -Force | Out-Null + 'hostile dry-run package fixture' | Set-Content -LiteralPath (Join-Path $hostilePackageRoot 'fixture.txt') -Encoding ASCII + $hostileZip = Join-Path $hostileReleaseRoot "stackchan_alive_$hostileDryVersion.zip" + $hostileSidecar = "$hostileZip.sha256" + 'hostile dry-run zip fixture' | Set-Content -LiteralPath $hostileZip -Encoding ASCII + 'hostile dry-run sidecar fixture' | Set-Content -LiteralPath $hostileSidecar -Encoding ASCII + $hostileFilesBefore = @( + Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $hostilePackageRoot 'fixture.txt'), $hostileZip, $hostileSidecar | + ForEach-Object Hash + ) + $hostileDryOutput = & $fixturePublishPath -Version $hostileDryVersion -CreateTag -DryRun *>&1 + Assert-True ($LASTEXITCODE -eq 0) "Hostile-config publish dry run failed: $(($hostileDryOutput | Out-String).Trim())" + $hostileFilesAfter = @( + Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $hostilePackageRoot 'fixture.txt'), $hostileZip, $hostileSidecar | + ForEach-Object Hash + ) + Assert-True ((Compare-Object $hostileFilesBefore $hostileFilesAfter).Count -eq 0) 'Hostile-config publish dry run mutated candidate files.' + $hostileDryTag = & $gitExecutable -C $targetRepo tag --list $hostileDryVersion + Assert-True ([string]::IsNullOrWhiteSpace(($hostileDryTag | Out-String).Trim())) 'Hostile-config publish dry run created a tag.' + foreach ($marker in @($hookMarker, $fsmonitorMarker, $lfsMarker, $ghMarker)) { + Assert-True (-not (Test-Path -LiteralPath $marker)) "Hostile-config publish dry run executed external state: $marker" + } + + $shareFailure = $null + try { + & $fixtureSharePath -NoServe *>&1 | Out-Null + } catch { + $shareFailure = $_ + } + Assert-True ($null -ne $shareFailure -and $shareFailure.Exception.Message -like 'Missing release ZIP:*') 'Hostile-config share fixture did not stop at the expected pre-verifier missing-ZIP gate.' + Assert-True (-not (Test-Path -LiteralPath (Join-Path $targetRepo 'output/share'))) 'Share pre-verifier Git reads mutated persistent share state.' + foreach ($marker in @($hookMarker, $fsmonitorMarker, $lfsMarker, $ghMarker)) { + Assert-True (-not (Test-Path -LiteralPath $marker)) "Share pre-verifier Git reads executed external state: $marker" + } + + $writeStopHelperText = (Get-FunctionDefinition -Ast $shareAst -Name 'Write-StopHelper').Extent.Text + $quotedToolsRoot = "'" + $PSScriptRoot.Replace("'", "''") + "'" + $writeStopHelperText = $writeStopHelperText.Replace('$PSScriptRoot', $quotedToolsRoot) + . ([scriptblock]::Create($writeStopHelperText)) + $generatedShareRoot = Join-Path $fixtureRoot 'generated-share-helper' + New-Item -ItemType Directory -Path $generatedShareRoot | Out-Null + $shareRoot = $generatedShareRoot + $script:sharePowerShellExecutable = $powerShellExecutable + Write-StopHelper -ProcessIds @() + $generatedStopHelper = Get-Content -LiteralPath (Join-Path $generatedShareRoot 'STOP_SHARING.cmd') -Raw + $expectedPowerShellPrefix = "`"$powerShellExecutable`" -NoProfile -ExecutionPolicy Bypass -File `"" + Assert-True ($generatedStopHelper.IndexOf($expectedPowerShellPrefix, [System.StringComparison]::Ordinal) -ge 0) 'Generated STOP_SHARING.cmd does not invoke the exact quoted PowerShell application path.' + Assert-True ($generatedStopHelper.IndexOf('-Command', [System.StringComparison]::OrdinalIgnoreCase) -lt 0) 'Generated STOP_SHARING.cmd must use -File, not an interpolated -Command payload.' + Assert-True (-not (Test-Path -LiteralPath $powerShellMarker)) 'Generated stop-helper construction invoked ambient powershell.exe command resolution.' +} finally { + Set-Location $originalLocation + $env:PATH = $originalPath + Remove-Item -Path 'Function:\global:powershell.exe' -ErrorAction SilentlyContinue + Remove-Item Env:\STACKCHAN_PUBLICATION_POWERSHELL_MARKER -ErrorAction SilentlyContinue + foreach ($candidate in @($dryPackageRoot, $dryZipPath, $drySidecarPath, $fixtureRoot)) { + if (-not (Test-Path -LiteralPath $candidate)) { + continue + } + $resolvedCandidate = [System.IO.Path]::GetFullPath($candidate) + $resolvedFixtureRoot = [System.IO.Path]::GetFullPath($fixtureRoot) + $resolvedReleaseRoot = [System.IO.Path]::GetFullPath($releaseRoot).TrimEnd('\', '/') + $isFixture = $resolvedCandidate.StartsWith($resolvedFixtureRoot, [System.StringComparison]::OrdinalIgnoreCase) + $isExactDryCandidate = $resolvedCandidate -ceq [System.IO.Path]::GetFullPath($dryPackageRoot) -or + $resolvedCandidate -ceq [System.IO.Path]::GetFullPath($dryZipPath) -or + $resolvedCandidate -ceq [System.IO.Path]::GetFullPath($drySidecarPath) + if (-not $isFixture -and -not $isExactDryCandidate -and + -not $resolvedCandidate.StartsWith($resolvedReleaseRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing unsafe publication-contract cleanup: $resolvedCandidate" + } + Remove-Item -LiteralPath $resolvedCandidate -Recurse -Force + } +} + +Write-Host 'Release publication safety contract tests passed.' diff --git a/tools/test_release_source_binding_contract.ps1 b/tools/test_release_source_binding_contract.ps1 new file mode 100644 index 00000000..6381ca18 --- /dev/null +++ b/tools/test_release_source_binding_contract.ps1 @@ -0,0 +1,431 @@ +$ErrorActionPreference = "Stop" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$packagePath = Join-Path $PSScriptRoot "package_release.ps1" +$verifyPath = Join-Path $PSScriptRoot "verify_release_package.ps1" +. (Join-Path $PSScriptRoot "release_source_binding.ps1") +$packageText = Get-Content -LiteralPath $packagePath -Raw +$verifyText = Get-Content -LiteralPath $verifyPath -Raw +$tokens = $null +$parseErrors = $null +$packageAst = [System.Management.Automation.Language.Parser]::ParseFile( + $packagePath, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -ne 0) { + throw "Could not parse production package script: $($parseErrors[0].Message)" +} +$inventoryFunctions = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('ConvertTo-CanonicalPackageInventory', 'Get-CanonicalPackageFileInventory') +}, $true)) +if ($inventoryFunctions.Count -ne 2) { + throw 'Production package script must define the two canonical package inventory functions exactly once' +} +foreach ($inventoryFunctionName in @('ConvertTo-CanonicalPackageInventory', 'Get-CanonicalPackageFileInventory')) { + $definition = @($inventoryFunctions | Where-Object { $_.Name -eq $inventoryFunctionName }) + if ($definition.Count -ne 1) { + throw "Production package inventory function is ambiguous: $inventoryFunctionName" + } + Invoke-Expression $definition[0].Extent.Text +} +foreach ($requiredInventoryBinding in @( + '$includedToolsInventory = @(Get-CanonicalPackageFileInventory -Directory $toolsDir -PackagePrefix "tools")', + '$provenanceFileInventory = @(Get-CanonicalPackageFileInventory -Directory $provenanceDir -PackagePrefix "provenance")', + 'includedTools = @($includedToolsInventory)', + 'provenanceFiles = @($provenanceFileInventory)' +)) { + if (-not $packageText.Contains($requiredInventoryBinding)) { + throw "Release manifest is not bound to its copied-file inventory: $requiredInventoryBinding" + } +} +foreach ($trustedToolPolicyMember in @( + '"tools/searxng/compose.yaml"', + '"tools/searxng/settings.yml"' +)) { + $releaseToolsPolicyStart = $packageText.IndexOf('$releaseTools = @(') + $releaseToolsPolicyEnd = $packageText.IndexOf('foreach ($file in $releaseTools)', $releaseToolsPolicyStart) + if ($releaseToolsPolicyStart -lt 0 -or $releaseToolsPolicyEnd -le $releaseToolsPolicyStart -or + @([regex]::Matches( + $packageText.Substring($releaseToolsPolicyStart, $releaseToolsPolicyEnd - $releaseToolsPolicyStart), + [regex]::Escape($trustedToolPolicyMember))).Count -ne 1) { + throw "Trusted release-tool literal policy must contain exactly one nested member: $trustedToolPolicyMember" + } +} +foreach ($trustedPolicyMarker in @( + 'Get-TrustedReleaseToolPolicy', + 'Get-TrustedProvenancePolicy', + 'Get-OperationalTrustedCommitMaps', + 'Assert-OperationalSourceCheckoutBindings', + 'Operational whole-package inventory count does not match trusted packaging policy' +)) { + if (-not $verifyText.Contains($trustedPolicyMarker)) { + throw "Release verifier is missing trusted inventory policy: $trustedPolicyMarker" + } +} +$lastToolCopyIndex = $packageText.LastIndexOf('Copy-Item -LiteralPath $file -Destination $toolDestination') +$lastProvenanceCopyIndex = $packageText.LastIndexOf('Copy-Item -LiteralPath "bridge/models/README.md" -Destination (Join-Path $visionLicenseDir "README.md") -Force') +$toolInventoryIndex = $packageText.IndexOf('$includedToolsInventory = @(') +$provenanceInventoryIndex = $packageText.IndexOf('$provenanceFileInventory = @(') +$manifestIndex = $packageText.IndexOf('$manifest = [ordered]@{') +if ($lastToolCopyIndex -lt 0 -or $lastProvenanceCopyIndex -lt 0 -or + $toolInventoryIndex -le $lastToolCopyIndex -or + $provenanceInventoryIndex -le $lastProvenanceCopyIndex -or + $manifestIndex -le $toolInventoryIndex -or $manifestIndex -le $provenanceInventoryIndex) { + throw 'Release inventories are not captured after every tools/provenance copy and before manifest construction' +} + +$canonicalInventory = @(ConvertTo-CanonicalPackageInventory ` + -PackagePaths @('tools/z.ps1', 'tools/searxng/settings.yml', 'tools/a.ps1') ` + -RequiredPrefix 'tools') +if (($canonicalInventory -join '|') -cne 'tools/a.ps1|tools/searxng/settings.yml|tools/z.ps1') { + throw "Canonical package inventory is not ordinally sorted and normalized: $($canonicalInventory -join '|')" +} +foreach ($invalidInventory in @( + @('tools/a.ps1', 'tools/A.ps1'), + @('tools/../escape.ps1'), + @('provenance/not-a-tool.ps1') +)) { + try { + ConvertTo-CanonicalPackageInventory -PackagePaths $invalidInventory -RequiredPrefix 'tools' | Out-Null + throw "Canonical package inventory accepted invalid paths: $($invalidInventory -join ', ')" + } catch { + if ($_.Exception.Message -like 'Canonical package inventory accepted invalid paths:*') { + throw + } + } +} +$inventoryFixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-package-inventory-contract-' + [guid]::NewGuid().ToString('N')) +try { + $inventoryTools = Join-Path $inventoryFixtureRoot 'tools' + $inventorySearxng = Join-Path $inventoryTools 'searxng' + New-Item -ItemType Directory -Force -Path $inventorySearxng | Out-Null + Set-Content -LiteralPath (Join-Path $inventoryTools 'package_release.ps1') -Value 'fixture' + Set-Content -LiteralPath (Join-Path $inventorySearxng 'compose.yaml') -Value 'fixture' + Set-Content -LiteralPath (Join-Path $inventorySearxng 'settings.yml') -Value 'fixture' + $capturedToolInventory = @(Get-CanonicalPackageFileInventory -Directory $inventoryTools -PackagePrefix 'tools') + $expectedToolInventory = @( + 'tools/package_release.ps1', + 'tools/searxng/compose.yaml', + 'tools/searxng/settings.yml' + ) + if (($capturedToolInventory -join '|') -cne ($expectedToolInventory -join '|')) { + throw "Copied tools inventory omitted or invented a package member: $($capturedToolInventory -join '|')" + } + $inventoryProvenance = Join-Path $inventoryFixtureRoot 'provenance' + $inventorySource = Join-Path $inventoryProvenance 'src' + New-Item -ItemType Directory -Force -Path $inventorySource | Out-Null + Set-Content -LiteralPath (Join-Path $inventoryProvenance 'platformio.ini') -Value 'fixture' + Set-Content -LiteralPath (Join-Path $inventorySource 'main.cpp') -Value 'fixture' + $capturedProvenanceInventory = @( + Get-CanonicalPackageFileInventory -Directory $inventoryProvenance -PackagePrefix 'provenance') + $expectedProvenanceInventory = @('provenance/platformio.ini', 'provenance/src/main.cpp') + if (($capturedProvenanceInventory -join '|') -cne ($expectedProvenanceInventory -join '|')) { + throw "Copied provenance inventory omitted or invented a package member: $($capturedProvenanceInventory -join '|')" + } +} finally { + if (Test-Path -LiteralPath $inventoryFixtureRoot) { + Remove-Item -LiteralPath $inventoryFixtureRoot -Recurse -Force + } +} +$cleanupFunctions = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Remove-ReleaseSourceWorktree' +}, $true)) +if ($cleanupFunctions.Count -ne 1) { + throw 'Production package script must define exactly one release-source cleanup function' +} +Invoke-Expression $cleanupFunctions[0].Extent.Text +$identityFunctions = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Get-CanonicalReleaseGitIdentity', 'Assert-ReleaseSourceIdentity') +}, $true)) +if ($identityFunctions.Count -ne 2) { + throw 'Production package script must define the two source-identity audit functions exactly once' +} +foreach ($identityFunctionName in @('Get-CanonicalReleaseGitIdentity', 'Assert-ReleaseSourceIdentity')) { + $definition = @($identityFunctions | Where-Object { $_.Name -eq $identityFunctionName }) + if ($definition.Count -ne 1) { + throw "Production package script source-identity function is ambiguous: $identityFunctionName" + } + Invoke-Expression $definition[0].Extent.Text +} + +foreach ($required in @( + "New-ShortReleaseScratchPath -Label 'release-src'", + "worktree', 'add', '--detach', `$releaseSourceRoot, `$canonicalBuildCommit", + 'Push-Location $releaseSourceRoot', + '$packageTrackedSourceRoot = if ($SkipBuild) { [string]$repoRoot } else { [string]$releaseSourceRoot }', + 'Copy-StackchanCommitBoundPackageFile', + '$releaseToolsRoot = if ($SkipBuild)', + '-Phase "final commit-bound package source staging"', + '-Phase "final release checkout audit"', + 'Remove-ReleaseSourceWorktree', + '-RejectIgnored', + '--ignored=matching', + '--ignore-submodules=none', + 'packageSourceIsolationPolicy', + 'detached-clean-worktree-pinned-to-package-commit' +)) { + if (-not $packageText.Contains($required)) { + throw "Release source-binding contract is missing: $required" + } +} +if ($packageText.Contains('Join-Path $repoRoot ([string]$asset.source_path)')) { + throw 'Persona WAV packaging still reads from the mutable main checkout' +} +$sourceCleanupStart = $packageText.IndexOf('function Remove-ReleaseSourceWorktree') +$sourceCleanupEnd = $packageText.IndexOf('trap {', $sourceCleanupStart) +$sourceCleanupText = $packageText.Substring($sourceCleanupStart, $sourceCleanupEnd - $sourceCleanupStart) +if (-not $sourceCleanupText.Contains('full-failed-worktree-retained-attached') -or + -not $sourceCleanupText.Contains('is a package failure; the full worktree remains attached') -or + -not $sourceCleanupText.Contains('source root is missing while its worktree is registered') -or + -not $sourceCleanupText.Contains('final state cannot be audited') -or + $sourceCleanupText.Contains("'worktree', 'remove', '--force'")) { + throw 'Commit-bound package source drift is not retained as a complete attached worktree' +} +foreach ($required in @( + 'packageSourceIsolationPolicy -ne "detached-clean-worktree-pinned-to-package-commit"', + 'packageSourceCommit -cne $ExpectedCommit', + 'packageSourceEpoch' +)) { + if (-not $verifyText.Contains($required)) { + throw "Release source-binding verifier is missing: $required" + } +} +$sourceCreationIndex = $packageText.IndexOf("New-ShortReleaseScratchPath -Label 'release-src'") +$sourcePushIndex = $packageText.IndexOf('Push-Location $releaseSourceRoot') +$firstTrackedCopyIndex = $packageText.IndexOf('Copy-Item -LiteralPath "README.md"') +$finalVerifierCommands = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + @($node.CommandElements | Where-Object { + $_ -is [System.Management.Automation.Language.VariableExpressionAst] -and + $_.Splatted -and $_.VariablePath.UserPath -ceq 'packageVerifyArgs' + }).Count -eq 1 +}, $true)) +if ($finalVerifierCommands.Count -ne 1) { + throw 'Final package verifier invocation is structurally ambiguous' +} +$finalVerifierIndex = $finalVerifierCommands[0].Extent.StartOffset +$sourceRemovalIndex = $packageText.LastIndexOf('Remove-ReleaseSourceWorktree') +if ($sourceCreationIndex -lt 0 -or $sourcePushIndex -le $sourceCreationIndex -or + $firstTrackedCopyIndex -le $sourcePushIndex -or $sourceRemovalIndex -le $finalVerifierIndex) { + throw "Commit-bound source lifetime/order is invalid" +} +$topLevelStatements = @($packageAst.EndBlock.Statements) +$hashAssignments = @($topLevelStatements | Where-Object { + $_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $_.Left.Extent.Text -eq '$hashLines' +}) +if ($hashAssignments.Count -ne 1) { + throw 'Final package hashing boundary is ambiguous' +} +$hashStatementIndex = [array]::IndexOf($topLevelStatements, $hashAssignments[0]) +$finalAuditGuard = if ($hashStatementIndex -gt 0) { + $topLevelStatements[$hashStatementIndex - 1] +} else { + $null +} +if ($finalAuditGuard -isnot [System.Management.Automation.Language.IfStatementAst] -or + $finalAuditGuard.Clauses.Count -ne 1 -or + $finalAuditGuard.Clauses[0].Item1.Extent.Text -ne '-not $SkipBuild') { + throw 'Final release source audit must be the direct top-level predecessor of package hashing' +} +$finalAuditStatements = @($finalAuditGuard.Clauses[0].Item2.Statements) +$lastFinalAuditStatement = if ($finalAuditStatements.Count -gt 0) { + $finalAuditStatements[-1] +} else { + $null +} +$lastFinalAuditCommand = if ($lastFinalAuditStatement -is [System.Management.Automation.Language.PipelineAst] -and + $lastFinalAuditStatement.PipelineElements.Count -eq 1 -and + $lastFinalAuditStatement.PipelineElements[0] -is [System.Management.Automation.Language.CommandAst]) { + $lastFinalAuditStatement.PipelineElements[0] +} else { + $null +} +$lastFinalAuditParameters = if ($null -ne $lastFinalAuditCommand) { + @($lastFinalAuditCommand.CommandElements | + Where-Object { $_ -is [System.Management.Automation.Language.CommandParameterAst] } | + ForEach-Object { $_.ParameterName }) +} else { + @() +} +if ($null -eq $lastFinalAuditCommand -or + $lastFinalAuditCommand.GetCommandName() -ne 'Assert-ReleaseSourceIdentity' -or + $lastFinalAuditParameters -notcontains 'RejectIgnored' -or + $lastFinalAuditCommand.Extent.Text -notmatch '(?m)-Phase\s+"final commit-bound package source staging"' -or + $lastFinalAuditCommand.Extent.Text -notmatch '(?m)-ProjectRoot\s+\$releaseSourceRoot') { + throw 'Final package hash/ZIP/verifier chain is not immediately preceded by the reachable ignored-file source audit' +} +$zipCreationCommands = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'New-StackchanDeterministicReleaseZip' -and + $node.Extent.Text -match '(?m)-RootPath\s+\$outDir\s+-ZipPath\s+\$zipPath' +}, $true)) +if ($zipCreationCommands.Count -ne 1 -or + $zipCreationCommands[0].Extent.StartOffset -le $hashAssignments[0].Extent.EndOffset -or + $finalVerifierCommands[0].Extent.StartOffset -le $zipCreationCommands[0].Extent.EndOffset) { + throw 'Final ignored-file audit does not govern the package hash, ZIP, and final verifier in one ordered chain' +} + +$root = Join-Path ([System.IO.Path]::GetTempPath()) ( + "stackchan-source-binding-contract-" + [guid]::NewGuid().ToString("N")) +$main = Join-Path $root "main" +$snapshot = Join-Path $root "snapshot" +try { + New-Item -ItemType Directory -Force -Path $main | Out-Null + & git -C $main init -q + & git -C $main config user.name "Stackchan Contract" + & git -C $main config user.email "contract@example.invalid" + & git -C $main config core.autocrlf false + [System.IO.File]::WriteAllText((Join-Path $main 'tracked.txt'), "commit-a`n") + [System.IO.File]::WriteAllText((Join-Path $main '.gitignore'), "personas/private/`n") + New-Item -ItemType Directory -Path (Join-Path $main 'personas/spark/audio') -Force | Out-Null + [System.IO.File]::WriteAllBytes((Join-Path $main 'personas/spark/audio/prompt.wav'), [byte[]](1, 2, 3, 4)) + & git -C $main add tracked.txt + & git -C $main add .gitignore + & git -C $main add personas/spark/audio/prompt.wav + & git -C $main commit -q -m "commit a" + $commitA = (& git -C $main rev-parse HEAD).Trim() + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & git -C $main worktree add --detach $snapshot $commitA 2>&1 | Out-Null + $worktreeExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($worktreeExit -ne 0) { throw "Could not create source-binding fixture worktree" } + + [System.IO.File]::WriteAllText((Join-Path $main 'tracked.txt'), "commit-b`n") + [System.IO.File]::WriteAllBytes((Join-Path $main 'personas/spark/audio/prompt.wav'), [byte[]](9, 8, 7, 6)) + [System.IO.File]::WriteAllText((Join-Path $main 'untracked.txt'), "must-not-package`n") + & git -C $main add tracked.txt + & git -C $main commit -q -m "commit b" + + if ((Get-Content -LiteralPath (Join-Path $snapshot 'tracked.txt') -Raw) -ne "commit-a`n") { + throw "Detached package source changed with the mutable main worktree" + } + if (Test-Path -LiteralPath (Join-Path $snapshot 'untracked.txt')) { + throw "Mutable untracked main-worktree content entered the package source" + } + $packagedPrompt = Join-Path $root 'package/media/voice/prompt.wav' + Copy-StackchanCommitBoundPackageFile ` + -PackageSourceRoot $snapshot ` + -RelativePath 'personas/spark/audio/prompt.wav' ` + -DestinationPath $packagedPrompt + if (-not [System.Linq.Enumerable]::SequenceEqual( + [byte[]][System.IO.File]::ReadAllBytes($packagedPrompt), + [byte[]](1, 2, 3, 4))) { + throw "Production commit-bound copy helper read mutable main-worktree WAV bytes" + } + try { + Copy-StackchanCommitBoundPackageFile ` + -PackageSourceRoot $snapshot -RelativePath '../tracked.txt' ` + -DestinationPath (Join-Path $root 'escaped.txt') + throw "Production commit-bound copy helper accepted traversal" + } catch { + if ($_.Exception.Message -eq "Production commit-bound copy helper accepted traversal") { throw } + } + if ((& git -C $snapshot rev-parse HEAD).Trim() -cne $commitA -or + @(& git -C $snapshot status --porcelain=v1 --untracked-files=all).Count -ne 0) { + throw "Detached package source lost its clean commit identity" + } + + $script:releaseSourceLocationPushed = $false + $script:releaseSourceWorktreeAdded = $true + $script:releaseSourceRoot = Join-Path $root 'missing-release-source' + try { + Remove-ReleaseSourceWorktree + throw 'Production cleanup accepted a missing registered worktree' + } catch { + if ($_.Exception.Message -eq 'Production cleanup accepted a missing registered worktree') { throw } + if ($_.Exception.Message -notmatch 'final state cannot be audited') { + throw "Production cleanup returned the wrong missing-worktree failure: $($_.Exception.Message)" + } + } + + function Invoke-ReleaseGit { + param([Parameter(Mandatory = $true)][string[]]$Arguments) + & git @Arguments + } + $ignoredPayload = Join-Path $snapshot 'personas/private/hitchhiker.bin' + New-Item -ItemType Directory -Path (Split-Path -Parent $ignoredPayload) -Force | Out-Null + [System.IO.File]::WriteAllText($ignoredPayload, 'must-not-package') + $hitchhikeCopy = Join-Path $root 'hitchhike-package/personas' + Copy-Item -LiteralPath (Join-Path $snapshot 'personas') -Destination $hitchhikeCopy -Recurse + if (-not (Test-Path -LiteralPath (Join-Path $hitchhikeCopy 'private/hitchhiker.bin'))) { + throw 'Ignored-file fixture did not reproduce the broad recursive-copy risk' + } + $snapshotEpoch = (& git -C $snapshot show -s --format=%ct $commitA).Trim() + $archiveMarker = Join-Path $root 'ARCHIVE_CREATED.txt' + try { + Assert-ReleaseSourceIdentity ` + -ExpectedCommit $commitA ` + -ExpectedEpoch $snapshotEpoch ` + -Phase 'contract pre-archive audit' ` + -ProjectRoot $snapshot ` + -RejectIgnored + [System.IO.File]::WriteAllText($archiveMarker, 'archive-created') + throw 'Production source audit accepted an ignored hitchhiker before archive creation' + } catch { + if ($_.Exception.Message -eq 'Production source audit accepted an ignored hitchhiker before archive creation') { + throw + } + if ($_.Exception.Message -notmatch 'not clean during contract pre-archive audit') { + throw "Production source audit returned the wrong ignored-file failure: $($_.Exception.Message)" + } + } + if (Test-Path -LiteralPath $archiveMarker) { + throw 'Archive side effect occurred after an ignored package-source hitchhiker was planted' + } + $script:releaseSourceLocationPushed = $false + $script:releaseSourceWorktreeAdded = $true + $script:releaseSourceRoot = $snapshot + $script:releaseSourceFailureRecorded = $false + $canonicalBuildCommit = $commitA + $repoRoot = $main + $driftFailureRoot = Join-Path $main 'output/private/package-source-failures' + try { + Remove-ReleaseSourceWorktree + throw 'Production cleanup accepted commit-bound source drift' + } catch { + if ($_.Exception.Message -eq 'Production cleanup accepted commit-bound source drift') { throw } + if ($_.Exception.Message -notmatch 'source drift is a package failure') { + throw "Production cleanup returned the wrong drift failure: $($_.Exception.Message)" + } + } + $attachedWorktrees = @(& git -C $main worktree list --porcelain | + Where-Object { $_.StartsWith('worktree ') } | + ForEach-Object { $_.Substring('worktree '.Length).Replace('/', '\') }) + if (-not (Test-Path -LiteralPath $snapshot) -or + -not (Test-Path -LiteralPath $ignoredPayload) -or + $attachedWorktrees -notcontains $snapshot) { + throw 'Production cleanup did not retain the complete drifted worktree attached' + } + $failureEvidence = @(Get-ChildItem -LiteralPath $driftFailureRoot -Filter FAILURE_EVIDENCE.json -Recurse -File) + if ($failureEvidence.Count -ne 1 -or + (Get-Content -LiteralPath $failureEvidence[0].FullName -Raw) -notmatch + 'commit-bound-source-drift-full-worktree-preserved') { + throw 'Production cleanup did not preserve commit-bound source drift evidence' + } + & git -C $main worktree remove --force $snapshot + if ($LASTEXITCODE -ne 0) { throw "Could not remove source-binding fixture worktree" } +} finally { + if ((Test-Path -LiteralPath (Join-Path $main '.git')) -and + (Test-Path -LiteralPath $snapshot)) { + & git -C $main worktree remove --force $snapshot 2>$null + } + if (Test-Path -LiteralPath $root) { + Get-ChildItem -LiteralPath $root -Recurse -Force -File -ErrorAction SilentlyContinue | + ForEach-Object { $_.IsReadOnly = $false } + [System.IO.Directory]::Delete($root, $true) + } +} + +Write-Host "Release source-binding contract passed." diff --git a/tools/test_release_toolchain_identity_contract.ps1 b/tools/test_release_toolchain_identity_contract.ps1 new file mode 100644 index 00000000..0a38a8d9 --- /dev/null +++ b/tools/test_release_toolchain_identity_contract.ps1 @@ -0,0 +1,567 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +. (Join-Path $PSScriptRoot 'release_toolchain_identity.ps1') + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +function Assert-Throws { + param([scriptblock]$Action, [string]$Pattern) + try { + & $Action + } catch { + if ([string]$_.Exception.Message -notmatch $Pattern) { + throw "Expected failure matching '$Pattern', got: $($_.Exception.Message)" + } + return + } + throw "Expected failure matching '$Pattern', but the action succeeded." +} + +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ( + 'stackchan-toolchain-identity-contract-' + [guid]::NewGuid().ToString('N')) +$isolationEnvironmentNames = @( + 'PYTHONNOUSERSITE', 'PYTHONSAFEPATH', 'PYTHONDONTWRITEBYTECODE', + 'PYTHONHASHSEED', 'PYTHONUTF8', 'PYTHONIOENCODING', + '__PYVENV_LAUNCHER__', '_PYTHON_HOST_PLATFORM', + 'CONDA_DEFAULT_ENV', 'CONDA_PREFIX', 'VIRTUAL_ENV', + 'PYTHONBREAKPOINT', 'PYTHONCASEOK', 'PYTHONCOERCECLOCALE', 'PYTHONDEBUG', + 'PYTHONEXECUTABLE', 'PYTHONFAULTHANDLER', 'PYTHONHOME', 'PYTHONINSPECT', + 'PYTHONINTMAXSTRDIGITS', 'PYTHONMALLOC', 'PYTHONNODEBUGRANGES', 'PYTHONPATH', + 'PYTHONOPTIMIZE', 'PYTHONPERFSUPPORT', 'PYTHONPLATLIBDIR', 'PYTHONPROFILEIMPORTTIME', + 'PYTHONPYCACHEPREFIX', 'PYTHONSTARTUP', 'PYTHONTRACEMALLOC', 'PYTHONUSERBASE', + 'PYTHONWARNDEFAULTENCODING', 'PYTHONWARNINGS' +) +$savedIsolationEnvironment = @{} +foreach ($name in $isolationEnvironmentNames) { + $savedIsolationEnvironment[$name] = [Environment]::GetEnvironmentVariable( + $name, [EnvironmentVariableTarget]::Process) +} +New-Item -ItemType Directory -Path $testRoot | Out-Null +try { + $first = Join-Path $testRoot 'first' + $second = Join-Path $testRoot 'second' + New-Item -ItemType Directory -Path (Join-Path $first 'nested') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $second 'nested') -Force | Out-Null + + # Deliberately create the two path-independent fixtures in opposite orders. + [IO.File]::WriteAllBytes((Join-Path $first 'alpha.txt'), [byte[]](0, 1, 2, 255)) + [IO.File]::WriteAllText((Join-Path $first 'nested/zeta.txt'), "zeta`n", [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $second 'nested/zeta.txt'), "zeta`n", [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllBytes((Join-Path $second 'alpha.txt'), [byte[]](0, 1, 2, 255)) + $firstIdentity = Get-StackchanToolchainTreeIdentity -Root $first + $secondIdentity = Get-StackchanToolchainTreeIdentity -Root $second + Assert-True ($firstIdentity.treeSha256 -ceq $secondIdentity.treeSha256) ` + 'Tree identity depends on absolute path or creation/enumeration order.' + Assert-True ($firstIdentity.fileCount -eq 2 -and $firstIdentity.bytes -eq 9) ` + 'Tree identity count/size accounting is incorrect.' + + New-Item -ItemType Directory -Path (Join-Path $second '.git') | Out-Null + New-Item -ItemType Directory -Path (Join-Path $second '__pycache__') | Out-Null + [IO.File]::WriteAllText((Join-Path $second '.git/noise'), 'ignored') + [IO.File]::WriteAllText((Join-Path $second '__pycache__/noise.pyc'), 'ignored') + [IO.File]::WriteAllText((Join-Path $second 'noise.pyc'), 'ignored') + $cacheIdentity = Get-StackchanToolchainTreeIdentity -Root $second + Assert-True ($cacheIdentity.treeSha256 -cne $firstIdentity.treeSha256) ` + 'Executable bytecode or Git metadata was silently excluded from byte identity.' + + [IO.File]::WriteAllBytes((Join-Path $second 'alpha.txt'), [byte[]](0, 1, 3, 255)) + $mutatedIdentity = Get-StackchanToolchainTreeIdentity -Root $second + Assert-True ($mutatedIdentity.treeSha256 -cne $cacheIdentity.treeSha256) ` + 'A same-length byte mutation did not change the tree identity.' + + [IO.File]::WriteAllBytes((Join-Path $second 'alpha.txt'), [byte[]](0, 1, 2, 255)) + Move-Item -LiteralPath (Join-Path $second 'alpha.txt') -Destination (Join-Path $second 'case-tmp.txt') + Move-Item -LiteralPath (Join-Path $second 'case-tmp.txt') -Destination (Join-Path $second 'ALPHA.txt') + $caseIdentity = Get-StackchanToolchainTreeIdentity -Root $second + Assert-True ($caseIdentity.treeSha256 -cne $cacheIdentity.treeSha256) ` + 'A relative-path case mutation did not change the tree identity.' + + foreach ($unsafe in @('../escape', 'safe/../escape', 'C:/escape', '/escape', './escape', "bad`0name")) { + Assert-Throws { ConvertTo-StackchanSafeIdentityRelativePath $unsafe } '(Unsafe|Non-canonical)' + } + + if ($env:OS -ne 'Windows_NT') { + $caseRoot = Join-Path $testRoot 'case-ambiguous' + New-Item -ItemType Directory -Path $caseRoot | Out-Null + [IO.File]::WriteAllText((Join-Path $caseRoot 'A'), 'a') + [IO.File]::WriteAllText((Join-Path $caseRoot 'a'), 'b') + Assert-Throws { Get-StackchanToolchainTreeIdentity -Root $caseRoot } 'Case-ambiguous' + } + + $policy = @(Get-StackchanReleaseToolchainComponentPolicy -PlatformKey 'windows_amd64') + $names = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($component in @($policy | Where-Object phase -ceq 'preBuild')) { + Assert-True ($names.Add([string]$component.name)) "Duplicate policy component: $($component.name)" + [void](ConvertTo-StackchanSafeIdentityRelativePath ([string]$component.relativePath)) + Assert-True ([string]$component.phase -in @('preBuild', 'postBuild')) ` + "Invalid component phase: $($component.name)" + } + Assert-True (@($policy | Where-Object { $_.name -like 'project-libdeps-*' }).Count -eq 3) ` + 'Policy does not bind all three release libdeps trees after build.' + Assert-True (@($policy | Where-Object { $_.name -like '*toolchain*' }).Count -ge 3) ` + 'Policy does not bind the installed compiler toolchains.' + Assert-True (@($policy | Where-Object { $_.name -eq 'legacy-core-penv' }).Count -eq 1 -and + @($policy | Where-Object { $_.name -eq 'release-core-penv' }).Count -eq 1) ` + 'Policy does not bind both PlatformIO-managed Python environments.' + $pythonPolicy = @($policy | Where-Object { $_.name -eq 'python-installation' }) + Assert-True ($pythonPolicy.Count -eq 1 -and [string]$pythonPolicy[0].relativePath -ceq '@root') ` + 'Policy does not bind the complete Python installation as one closed root.' + Assert-Throws { + Get-StackchanReleaseToolchainComponentPolicy -PlatformKey 'linux_amd64' + } 'No reviewed release toolchain component policy' + + $fixtureRoots = @{ + pythonHome = Join-Path $testRoot 'identity-host/python' + legacyCore = Join-Path $testRoot 'identity-host/legacy-core' + releaseCore = Join-Path $testRoot 'identity-host/release-core' + projectRoot = Join-Path $testRoot 'identity-host/project' + } + foreach ($root in $fixtureRoots.Values) { + New-Item -ItemType Directory -Path $root -Force | Out-Null + } + foreach ($component in $policy) { + $componentPath = Resolve-StackchanIdentityComponentPath ` + -RootMap $fixtureRoots -Component $component + if ([IO.Path]::GetExtension($componentPath) -in @('.exe', '.dll')) { + New-Item -ItemType Directory -Path (Split-Path -Parent $componentPath) -Force | Out-Null + [IO.File]::WriteAllText($componentPath, [string]$component.name) + } else { + New-Item -ItemType Directory -Path $componentPath -Force | Out-Null + [IO.File]::WriteAllText((Join-Path $componentPath 'identity.fixture'), [string]$component.name) + } + } + New-Item -ItemType Directory -Path (Join-Path $fixtureRoots.pythonHome 'Scripts') -Force | Out-Null + foreach ($relative in @('python.exe', 'python312.zip', 'Scripts/pio.exe', 'Scripts/platformio.exe')) { + [IO.File]::WriteAllText((Join-Path $fixtureRoots.pythonHome $relative), "fixture:$relative") + } + $fixturePio = Join-Path $fixtureRoots.pythonHome 'Scripts/platformio.exe' + $fixturePython = Join-Path $fixtureRoots.pythonHome 'python.exe' + $fixtureAllowlist = Join-Path $testRoot 'reviewed-fixture-allowlist.json' + $candidate = [pscustomobject][ordered]@{ + schema = 'stackchan.release-toolchain-identity.v2' + platformKey = 'windows_amd64' + platformioCoreVersion = '6.1.19' + pythonVersion = '3.12.10' + identityScope = 'exact-host-installed-bytes' + portableAcrossHosts = $false + canonicalLibdepsSchema = 'stackchan.canonical-libdeps.v1' + platformioExecutableRelativePaths = @('Scripts/pio.exe', 'Scripts/platformio.exe') + pythonExecutableRelativePath = 'python.exe' + review = [pscustomobject][ordered]@{ + status = 'reviewed' + reviewer = 'fixture-reviewer' + reason = 'contract fixture' + } + components = @(Get-StackchanReleaseToolchainObservedComponents ` + -RootMap $fixtureRoots -Phase PreBuild -PlatformKey 'windows_amd64') + } + $candidate | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $fixtureAllowlist -Encoding UTF8 + $observedPython = @($candidate.components | Where-Object name -ceq 'python-installation') + Assert-True ($observedPython.Count -eq 1 -and $observedPython[0].fileCount -ge 5) ` + 'Complete Python installation inventory omitted root, archive, or Scripts bytes.' + + $pythonHomeFull = (Get-Item -LiteralPath $fixtureRoots.pythonHome).FullName.TrimEnd('\', '/') + $fixturePythonFull = (Get-Item -LiteralPath $fixturePython).FullName + $validProbe = [pscustomobject]@{ + executable = $fixturePythonFull + prefix = $pythonHomeFull + base_prefix = $pythonHomeFull + path = @( + (Join-Path $pythonHomeFull 'python312.zip'), (Join-Path $pythonHomeFull 'DLLs'), + (Join-Path $pythonHomeFull 'Lib'), $pythonHomeFull, + (Join-Path $pythonHomeFull 'Lib/site-packages')) + enable_user_site = $false + flags = [pscustomobject]@{ + no_user_site = 1; safe_path = $true; dont_write_bytecode = 1; optimize = 0 + } + } + Assert-StackchanPythonImportIsolationState ` + -Probe $validProbe -PythonHome $pythonHomeFull -PythonExecutable $fixturePythonFull + $escapedProbe = $validProbe | ConvertTo-Json -Depth 5 | ConvertFrom-Json + $escapedProbe.path += (Join-Path $testRoot 'external-python') + Assert-Throws { + Assert-StackchanPythonImportIsolationState ` + -Probe $escapedProbe -PythonHome $pythonHomeFull -PythonExecutable $fixturePythonFull + } 'import path contains an external' + $userSiteProbe = $validProbe | ConvertTo-Json -Depth 5 | ConvertFrom-Json + $userSiteProbe.enable_user_site = $true + Assert-Throws { + Assert-StackchanPythonImportIsolationState ` + -Probe $userSiteProbe -PythonHome $pythonHomeFull -PythonExecutable $fixturePythonFull + } 'no-user-site/safe-path' + $unsafePathProbe = $validProbe | ConvertTo-Json -Depth 5 | ConvertFrom-Json + $unsafePathProbe.flags.safe_path = $false + Assert-Throws { + Assert-StackchanPythonImportIsolationState ` + -Probe $unsafePathProbe -PythonHome $pythonHomeFull -PythonExecutable $fixturePythonFull + } 'no-user-site/safe-path' + $optimizedProbe = $validProbe | ConvertTo-Json -Depth 5 | ConvertFrom-Json + $optimizedProbe.flags.optimize = 1 + Assert-Throws { + Assert-StackchanPythonImportIsolationState ` + -Probe $optimizedProbe -PythonHome $pythonHomeFull -PythonExecutable $fixturePythonFull + } 'no-user-site/safe-path' + $wrongExecutableProbe = $validProbe | ConvertTo-Json -Depth 5 | ConvertFrom-Json + $wrongExecutable = Join-Path $fixtureRoots.pythonHome 'other-python.exe' + [IO.File]::WriteAllText($wrongExecutable, 'other') + $wrongExecutableProbe.executable = $wrongExecutable + Assert-Throws { + Assert-StackchanPythonImportIsolationState ` + -Probe $wrongExecutableProbe -PythonHome $pythonHomeFull -PythonExecutable $fixturePythonFull + } 'no-user-site/safe-path' + + foreach ($name in $isolationEnvironmentNames) { + [Environment]::SetEnvironmentVariable($name, $null, [EnvironmentVariableTarget]::Process) + } + $requiredIsolation = [ordered]@{ + PYTHONNOUSERSITE = '1'; PYTHONSAFEPATH = '1'; PYTHONDONTWRITEBYTECODE = '1' + PYTHONHASHSEED = '0'; PYTHONUTF8 = '1'; PYTHONIOENCODING = 'utf-8' + } + foreach ($entry in $requiredIsolation.GetEnumerator()) { + [Environment]::SetEnvironmentVariable( + [string]$entry.Key, [string]$entry.Value, [EnvironmentVariableTarget]::Process) + } + $escapePath = Join-Path $fixtureRoots.pythonHome 'Lib/site-packages/escape.pth' + New-Item -ItemType Directory -Path (Split-Path -Parent $escapePath) -Force | Out-Null + [IO.File]::WriteAllText($escapePath, (Join-Path $testRoot 'outside')) + Assert-Throws { + Assert-StackchanPythonImportIsolation ` + -PythonHome $pythonHomeFull -PythonExecutable $fixturePythonFull + } 'escape files' + Remove-Item -LiteralPath $escapePath -Force + [Environment]::SetEnvironmentVariable( + 'PYTHONNOUSERSITE', $null, [EnvironmentVariableTarget]::Process) + Assert-Throws { + Assert-StackchanPythonImportIsolation ` + -PythonHome $pythonHomeFull -PythonExecutable $fixturePythonFull + } 'requires PYTHONNOUSERSITE=1' + [Environment]::SetEnvironmentVariable( + 'PYTHONNOUSERSITE', '1', [EnvironmentVariableTarget]::Process) + [Environment]::SetEnvironmentVariable( + 'PYTHONOPTIMIZE', '1', [EnvironmentVariableTarget]::Process) + Assert-Throws { + Assert-StackchanPythonImportIsolation ` + -PythonHome $pythonHomeFull -PythonExecutable $fixturePythonFull + } 'ambient import/runtime override: PYTHONOPTIMIZE' + [Environment]::SetEnvironmentVariable( + 'PYTHONOPTIMIZE', $null, [EnvironmentVariableTarget]::Process) + + $candidate.platformioExecutableRelativePaths = @( + 'Scripts/pio.exe', 'Scripts/platformio.exe', 'Scripts/fake-pio.exe') + $candidate | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $fixtureAllowlist -Encoding UTF8 + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $fixtureAllowlist -RootMap @{} ` + -PlatformioExecutable $fixturePio -PythonExecutable $fixturePython ` + -Phase PreBuild -PlatformKey 'windows_amd64' + } 'executable paths are not the canonical policy' + $candidate.platformioExecutableRelativePaths = @('Scripts/pio.exe', 'Scripts/platformio.exe') + $candidate.pythonExecutableRelativePath = 'Scripts/python.exe' + $candidate | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $fixtureAllowlist -Encoding UTF8 + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $fixtureAllowlist -RootMap @{} ` + -PlatformioExecutable $fixturePio -PythonExecutable $fixturePython ` + -Phase PreBuild -PlatformKey 'windows_amd64' + } 'executable paths are not the canonical policy' + $candidate.pythonExecutableRelativePath = 'python.exe' + + $missingAllowlist = Join-Path $testRoot 'missing-allowlist.json' + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $missingAllowlist -RootMap @{} ` + -PlatformioExecutable $fixturePio -PythonExecutable $fixturePython ` + -Phase PreBuild -PlatformKey 'windows_amd64' + } '(Cannot find path|does not exist)' + $candidate.review.status = 'candidate-unreviewed' + $candidate | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $fixtureAllowlist -Encoding UTF8 + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $fixtureAllowlist -RootMap @{} ` + -PlatformioExecutable $fixturePio -PythonExecutable $fixturePython ` + -Phase PreBuild -PlatformKey 'windows_amd64' + } '(absent|unreviewed|another platform)' + $candidate.review.status = 'reviewed' + $candidate.platformKey = 'linux_amd64' + $candidate | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $fixtureAllowlist -Encoding UTF8 + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $fixtureAllowlist -RootMap @{} ` + -PlatformioExecutable $fixturePio -PythonExecutable $fixturePython ` + -Phase PreBuild -PlatformKey 'windows_amd64' + } '(absent|unreviewed|another platform)' + $candidate.platformKey = 'windows_amd64' + $candidate.identityScope = 'portable-version-only' + $candidate | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $fixtureAllowlist -Encoding UTF8 + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $fixtureAllowlist -RootMap @{} ` + -PlatformioExecutable $fixturePio -PythonExecutable $fixturePython ` + -Phase PreBuild -PlatformKey 'windows_amd64' + } 'version policy mismatch' + $candidate.identityScope = 'exact-host-installed-bytes' + $candidate | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $fixtureAllowlist -Encoding UTF8 + $beforeMutation = @(Get-StackchanReleaseToolchainObservedComponents ` + -RootMap $fixtureRoots -Phase PreBuild -PlatformKey 'windows_amd64') + [IO.File]::AppendAllText(( + Join-Path $fixtureRoots.legacyCore 'packages/toolchain-riscv32-esp/identity.fixture'), 'wrong-host') + $afterMutation = @(Get-StackchanReleaseToolchainObservedComponents ` + -RootMap $fixtureRoots -Phase PreBuild -PlatformKey 'windows_amd64') + $beforeCompiler = @($beforeMutation | Where-Object name -ceq 'legacy-package-toolchain-riscv32-esp')[0] + $afterCompiler = @($afterMutation | Where-Object name -ceq 'legacy-package-toolchain-riscv32-esp')[0] + Assert-True ($beforeCompiler.treeSha256 -cne $afterCompiler.treeSha256) ` + 'A compiler-toolchain byte mutation did not change the observed pre-build identity.' + + $staleLibdeps = Join-Path $fixtureRoots.projectRoot '.pio/libdeps/stackchan' + $stalePolicy = Get-StackchanExpectedLibdepsPolicy -Environment stackchan + New-Item -ItemType Directory -Path $staleLibdeps -Force | Out-Null + foreach ($leaf in @($stalePolicy.leaves) + @('unexpected-stale-library')) { + New-Item -ItemType Directory -Path (Join-Path $staleLibdeps $leaf) -Force | Out-Null + } + [IO.File]::WriteAllLines((Join-Path $staleLibdeps 'integrity.dat'), [string[]]$stalePolicy.requirements) + Assert-Throws { + Get-StackchanCanonicalLibdepsIdentity -Root $staleLibdeps -Environment stackchan + } 'stale or has unexpected packages/files' + Assert-Throws { + New-StackchanReleaseToolchainIdentityCandidate ` + -RootMap $fixtureRoots -PlatformioExecutable $fixturePio ` + -PythonExecutable $fixturePython -PlatformKey windows_amd64 + } 'isolation probe failed' + Assert-Throws { + Get-StackchanReleaseToolchainObservedComponents ` + -RootMap $fixtureRoots -Phase PostBuild -PlatformKey windows_amd64 + } 'PostBuild toolchain eligibility is disabled' + + function Invoke-FixtureGit { + param([string[]]$Arguments) + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = @(& git @Arguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousPreference + } + if ($exitCode -ne 0) { + throw "Fixture Git failed: git $($Arguments -join ' ')`n$($output -join "`n")" + } + return @($output) + } + $gitSeed = Join-Path $testRoot 'git-seed' + $gitFirst = Join-Path $testRoot 'git-first' + $gitSecond = Join-Path $testRoot 'git-second' + Invoke-FixtureGit @('init', '--initial-branch=main', $gitSeed) | Out-Null + Invoke-FixtureGit @('-C', $gitSeed, 'config', 'user.name', 'Fixture Builder') | Out-Null + Invoke-FixtureGit @('-C', $gitSeed, 'config', 'user.email', 'fixture@example.invalid') | Out-Null + Invoke-FixtureGit @('-C', $gitSeed, 'config', 'core.autocrlf', 'false') | Out-Null + New-Item -ItemType Directory -Path (Join-Path $gitSeed 'src') | Out-Null + [IO.File]::WriteAllText((Join-Path $gitSeed 'src/source.cpp'), "int fixture = 1;`n") + [IO.File]::WriteAllText((Join-Path $gitSeed 'builder.py'), "print('fixture')`n") + Invoke-FixtureGit @('-C', $gitSeed, 'add', '--', 'src/source.cpp', 'builder.py') | Out-Null + Invoke-FixtureGit @('-C', $gitSeed, 'commit', '-m', 'fixture source') | Out-Null + Invoke-FixtureGit @('clone', '--no-local', $gitSeed, $gitFirst) | Out-Null + Invoke-FixtureGit @('clone', '--no-local', $gitSeed, $gitSecond) | Out-Null + $fixtureCommit = (Invoke-FixtureGit @('-C', $gitSeed, 'rev-parse', 'HEAD') | Select-Object -Last 1).Trim() + $fixtureShortCommit = $fixtureCommit.Substring(0, 7) + $fixtureUri = "git+https://github.com/fixture/example.git#$fixtureShortCommit" + $installStamps = @('20260803101010', '20260803111111') + $cloneNumber = 0 + foreach ($clone in @($gitFirst, $gitSecond)) { + Invoke-FixtureGit @('-C', $clone, 'remote', 'set-url', 'origin', 'https://github.com/fixture/example.git') | Out-Null + $metadata = [ordered]@{ + type = 'library' + name = 'FixtureGitLibrary' + version = "0.0.0+$($installStamps[$cloneNumber]).sha.$fixtureShortCommit" + spec = [ordered]@{ + owner = $null + id = $null + name = 'FixtureGitLibrary' + requirements = $null + uri = $fixtureUri + } + } + [IO.File]::WriteAllText((Join-Path $clone '.git/.piopm'), ($metadata | ConvertTo-Json -Compress -Depth 4)) + $libraryMetadata = [ordered]@{ name = 'FixtureGitLibrary'; version = "0.0.0+$($installStamps[$cloneNumber])" } + [IO.File]::WriteAllText((Join-Path $clone 'library.json'), ($libraryMetadata | ConvertTo-Json -Depth 2)) + $cloneNumber++ + } + $gitFirstRecords = Get-StackchanCanonicalGitLibraryRecords ` + -LibraryRoot $gitFirst -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + $gitSecondRecords = Get-StackchanCanonicalGitLibraryRecords ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + $gitFirstIdentity = Get-StackchanIdentityFromRecords ` + -Records $gitFirstRecords.records -Schema 'stackchan.git-fixture.v1' + $gitSecondIdentity = Get-StackchanIdentityFromRecords ` + -Records $gitSecondRecords.records -Schema 'stackchan.git-fixture.v1' + Assert-True ($gitFirstIdentity.treeSha256 -ceq $gitSecondIdentity.treeSha256) ` + 'Install timestamps or path/stat-bearing Git metadata changed canonical Git identity.' + + $gitFirstTree = Get-StackchanCanonicalGitLibraryTreeIdentity ` + -LibraryRoot $gitFirst -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + $sourceBefore = Get-StackchanCanonicalGitLibraryTreeIdentity ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + Assert-True ($gitFirstTree.treeSha256 -ceq $sourceBefore.treeSha256) ` + 'Complete canonical Git library identity varies across equivalent installs.' + [IO.File]::WriteAllText((Join-Path $gitSecond 'src/source.cpp'), "int fixture = 2;`n") + $sourceAfter = Get-StackchanCanonicalGitLibraryTreeIdentity ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + Assert-True ($sourceBefore.treeSha256 -cne $sourceAfter.treeSha256) ` + 'Canonical libdeps identity did not bind an actual source-byte mutation.' + [IO.File]::WriteAllText((Join-Path $gitSecond 'src/source.cpp'), "int fixture = 1;`n") + $builderBefore = Get-StackchanCanonicalGitLibraryTreeIdentity ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + [IO.File]::WriteAllText((Join-Path $gitSecond 'builder.py'), "print('fixturf')`n") + $builderAfter = Get-StackchanCanonicalGitLibraryTreeIdentity ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + Assert-True ($builderBefore.treeSha256 -cne $builderAfter.treeSha256) ` + 'Canonical libdeps identity did not bind a build-readable builder.py mutation.' + [IO.File]::WriteAllText((Join-Path $gitSecond 'builder.py'), "print('fixture')`n") + + $headPath = Join-Path $gitSecond '.git/HEAD' + $headBytes = [IO.File]::ReadAllBytes($headPath) + [IO.File]::WriteAllText($headPath, "ref: refs/heads/unreviewed`n") + Assert-Throws { + Get-StackchanCanonicalGitLibraryTreeIdentity ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + } 'HEAD ref is missing' + [IO.File]::WriteAllBytes($headPath, $headBytes) + + $headRefPath = Join-Path $gitSecond '.git/refs/heads/main' + $headRefBytes = [IO.File]::ReadAllBytes($headRefPath) + [IO.File]::WriteAllText($headRefPath, (('0' * 40) + "`n")) + Assert-Throws { + Get-StackchanCanonicalGitLibraryTreeIdentity ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + } 'commit does not match reviewed policy' + [IO.File]::WriteAllBytes($headRefPath, $headRefBytes) + + $wrongCommitFirst = if ($fixtureCommit[0] -ceq '0') { '1' } else { '0' } + $wrongFullCommit = $wrongCommitFirst + $fixtureCommit.Substring(1) + Assert-Throws { + Get-StackchanCanonicalGitLibraryTreeIdentity ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $wrongFullCommit + } 'commit does not match reviewed policy' + + [IO.File]::WriteAllText((Join-Path $gitSecond '.git/hooks/pre-commit'), 'malicious hook') + Assert-Throws { + Get-StackchanCanonicalGitLibraryRecords ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + } 'Unexpected executable or object Git state' + Remove-Item -LiteralPath (Join-Path $gitSecond '.git/hooks/pre-commit') -Force + + $configPath = Join-Path $gitSecond '.git/config' + $configBytes = [IO.File]::ReadAllBytes($configPath) + [IO.File]::WriteAllText($configPath, ([IO.File]::ReadAllText($configPath) -replace + 'https://github.com/fixture/example.git', 'https://github.com/evil/example.git')) + Assert-Throws { + Get-StackchanCanonicalGitLibraryRecords ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + } 'remote source does not match reviewed policy' + [IO.File]::WriteAllBytes($configPath, $configBytes) + + $piopmPath = Join-Path $gitSecond '.git/.piopm' + $piopmBytes = [IO.File]::ReadAllBytes($piopmPath) + [IO.File]::WriteAllText($piopmPath, ([IO.File]::ReadAllText($piopmPath) -replace + [regex]::Escape($fixtureUri), 'git+https://github.com/evil/example.git#0000000')) + Assert-Throws { + Get-StackchanCanonicalGitLibraryRecords ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + } 'Unsafe PlatformIO Git package metadata' + [IO.File]::WriteAllBytes($piopmPath, $piopmBytes) + + $packPath = @(Get-ChildItem (Join-Path $gitSecond '.git/objects/pack') -Filter '*.pack')[0].FullName + $packBytes = [IO.File]::ReadAllBytes($packPath) + $packBytes[20] = $packBytes[20] -bxor 1 + (Get-Item -LiteralPath $packPath).IsReadOnly = $false + [IO.File]::WriteAllBytes($packPath, $packBytes) + Assert-Throws { + Get-StackchanCanonicalGitLibraryRecords ` + -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` + -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` + -ExpectedCommit $fixtureCommit + } 'Git pack content identity mismatch' + + $requirementsPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'requirements-firmware-release.txt' + $actualRequirements = @( + Get-Content -LiteralPath $requirementsPath | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -and -not $_.StartsWith('#') } + ) + $expectedRequirements = @( + 'ajsonrpc==1.2.0', 'anyio==4.14.1', 'bottle==0.13.4', + 'certifi==2026.6.17', 'charset-normalizer==3.4.7', 'click==8.3.3', + 'colorama==0.4.6', 'h11==0.16.0', 'idna==3.18', + 'marshmallow==3.26.2', 'packaging==26.2', 'platformio==6.1.19', + 'pyelftools==0.33', 'pyserial==3.5', 'requests==2.34.2', + 'semantic-version==2.10.0', 'starlette==0.52.1', 'tabulate==0.10.0', + 'typing-extensions==4.15.0', 'urllib3==2.7.0', 'uvicorn==0.40.0', + 'wsproto==1.3.2' + ) + Assert-True (($actualRequirements -join "`n") -ceq ($expectedRequirements -join "`n")) ` + 'Firmware release Python requirements are not the reviewed exact transitive closure.' + + $helperText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'release_toolchain_identity.ps1') -Raw + foreach ($pattern in @( + 'FileOptions]::SequentialScan', 'StringComparer]::OrdinalIgnoreCase', + 'FileAttributes]::ReparsePoint', 'NormalizationForm]::FormC', + 'candidate-unreviewed', 'refuses a reparse-point root', + 'entire Python installation as one closed root', 'exact-host-installed-bytes', + 'PYTHONNOUSERSITE', 'PYTHONSAFEPATH', 'python312.zip', + 'portableAcrossHosts')) { + Assert-True ($helperText.Contains($pattern)) "Toolchain identity helper missing safety policy: $pattern" + } + $candidateText = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'new_release_toolchain_identity_candidate.ps1') -Raw + Assert-True ($candidateText.Contains('refuses to overwrite the reviewed allowlist')) ` + 'Allowlist candidate workflow can overwrite reviewed policy without an explicit review step.' + + [pscustomobject][ordered]@{ + schema = 'stackchan.release-toolchain-identity-contract.v2' + status = 'pass' + fixtureFiles = $firstIdentity.fileCount + policyComponents = $policy.Count + pinnedPythonDistributions = $actualRequirements.Count + } | ConvertTo-Json -Compress +} finally { + foreach ($name in $isolationEnvironmentNames) { + [Environment]::SetEnvironmentVariable( + $name, $savedIsolationEnvironment[$name], [EnvironmentVariableTarget]::Process) + } + $resolvedTestRoot = [IO.Path]::GetFullPath($testRoot) + $resolvedTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\', '/') + if ($resolvedTestRoot.StartsWith($resolvedTemp + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) -and + (Split-Path -Leaf $resolvedTestRoot).StartsWith( + 'stackchan-toolchain-identity-contract-', [StringComparison]::Ordinal)) { + Remove-Item -LiteralPath $resolvedTestRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/tools/verify_consumer_promotion.ps1 b/tools/verify_consumer_promotion.ps1 index 16ef1317..ce655b1e 100644 --- a/tools/verify_consumer_promotion.ps1 +++ b/tools/verify_consumer_promotion.ps1 @@ -31,11 +31,18 @@ foreach ($arg in $args) { } if ([string]::IsNullOrWhiteSpace($Version)) { - $Version = (git describe --tags --always --dirty).Trim() + $Version = (& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + describe --tags --always | Out-String).Trim() } if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { - $ExpectedCommit = (git rev-parse HEAD).Trim() + $ExpectedCommit = (& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + rev-parse HEAD | Out-String).Trim() +} +if ($ExpectedCommit -notmatch "^[0-9a-fA-F]{40}$") { + throw "Pass a 40-hex -ExpectedCommit or run from a trusted Git checkout." } if ([string]::IsNullOrWhiteSpace($ExpectedFirmwareSourceCommit)) { $ExpectedFirmwareSourceCommit = $ExpectedCommit @@ -400,11 +407,20 @@ if ([string]::IsNullOrWhiteSpace($PackageZip)) { if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { Assert-FilePath $PackageZip 100000 $promotionPackageZipPath = (Resolve-Path -LiteralPath $PackageZip).Path + $verifyPackage = Join-Path $PSScriptRoot "verify_release_package.ps1" + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage ` + -Version $Version -ZipPath $promotionPackageZipPath ` + -ExpectedCommit $ExpectedCommit -RequireReleaseEligible + if ($LASTEXITCODE -ne 0) { + throw "Operational release ZIP verification failed before consumer-promotion extraction." + } + . (Join-Path $PSScriptRoot "release_zip_safety.ps1") $promotionPackageZipSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $promotionPackageZipPath).Hash.ToLowerInvariant() $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "stackchan-consumer-promotion" $cleanupDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $cleanupDir | Out-Null - Expand-Archive -LiteralPath $promotionPackageZipPath -DestinationPath $cleanupDir + Expand-StackchanReleaseZipSafely ` + -ZipPath $promotionPackageZipPath -DestinationPath $cleanupDir $PackageRoot = $cleanupDir } @@ -419,7 +435,7 @@ $packageRootPath = (Resolve-Path $PackageRoot).Path try { $verifyPackage = Join-Path $PSScriptRoot "verify_release_package.ps1" - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage -Version $Version -PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage -Version $Version -PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit -RequireReleaseEligible if ($LASTEXITCODE -ne 0) { throw "Release package verification failed." } diff --git a/tools/verify_published_release.ps1 b/tools/verify_published_release.ps1 index 5dfeb46d..260b4e62 100644 --- a/tools/verify_published_release.ps1 +++ b/tools/verify_published_release.ps1 @@ -12,7 +12,6 @@ $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $repoRoot -. (Join-Path $PSScriptRoot "release_asset_contract.ps1") function Assert-Command { param([string]$Name) @@ -124,21 +123,17 @@ function Assert-GitHubProvenanceAttestation { } if ([string]::IsNullOrWhiteSpace($Version)) { - $Version = (git describe --tags --always --dirty).Trim() + $Version = (& git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + describe --tags --always | Out-String).Trim() } Assert-Command "git" -Assert-Command "gh" - -if ([string]::IsNullOrWhiteSpace($Repo)) { - $Repo = (gh repo view --json nameWithOwner --jq ".nameWithOwner").Trim() - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($Repo)) { - throw "Unable to infer GitHub repo. Pass -Repo owner/name." - } -} if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { - $tagCommit = git rev-list -n 1 $Version 2>$null + $tagCommit = & git -c core.hooksPath=NUL -c core.fsmonitor=false ` + -c maintenance.auto=false -c core.untrackedCache=false ` + rev-list -n 1 $Version 2>$null if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace(($tagCommit | Out-String).Trim())) { throw "Unable to resolve tag $Version. Pass -ExpectedCommit explicitly or fetch/create the tag first." } @@ -161,6 +156,44 @@ Assert-File $PackageRoot Assert-File $ZipPath Assert-File $ZipSidecarPath +$previousErrorPreference = $ErrorActionPreference +try { + $ErrorActionPreference = "Continue" + $localVerifyOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $PSScriptRoot "verify_release_package.ps1") ` + -Version $Version -ZipPath $ZipPath -ExpectedCommit $ExpectedCommit ` + -RequireReleaseEligible 2>&1) + $localVerifyExit = $LASTEXITCODE +} finally { + $ErrorActionPreference = $previousErrorPreference +} +if ($localVerifyExit -ne 0) { + throw "Local release package is not operationally eligible; refusing published-release I/O: $(($localVerifyOutput | Out-String).Trim())" +} +try { + $ErrorActionPreference = "Continue" + $localRootVerifyOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $PSScriptRoot "verify_release_package.ps1") ` + -Version $Version -PackageRoot $PackageRoot -ExpectedCommit $ExpectedCommit ` + -RequireReleaseEligible 2>&1) + $localRootVerifyExit = $LASTEXITCODE +} finally { + $ErrorActionPreference = $previousErrorPreference +} +if ($localRootVerifyExit -ne 0) { + throw "Local extracted release root is not operationally eligible; refusing published-release I/O: $(($localRootVerifyOutput | Out-String).Trim())" +} + +. (Join-Path $PSScriptRoot "release_asset_contract.ps1") + +Assert-Command "gh" +if ([string]::IsNullOrWhiteSpace($Repo)) { + $Repo = (gh repo view --json nameWithOwner --jq ".nameWithOwner").Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($Repo)) { + throw "Unable to infer GitHub repo. Pass -Repo owner/name." + } +} + $release = gh release view $Version --repo $Repo --json url,isPrerelease,assets,tagName,targetCommitish | ConvertFrom-Json if ($LASTEXITCODE -ne 0) { throw "Unable to read GitHub release: $Version" @@ -392,7 +425,11 @@ if ($remoteZipHash -ne $Matches[1]) { throw "Published ZIP SHA256 sidecar does not match downloaded ZIP" } -& (Join-Path $PSScriptRoot "verify_release_package.ps1") -Version $Version -ZipPath $remoteZip -ExpectedCommit $ExpectedCommit +& (Join-Path $PSScriptRoot "verify_release_package.ps1") ` + -Version $Version -ZipPath $remoteZip -ExpectedCommit $ExpectedCommit -RequireReleaseEligible +if ($LASTEXITCODE -ne 0) { + throw "Downloaded published release package is not operationally eligible." +} Write-Host "Published release verified:" Write-Host $release.url diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 879177a2..080d278c 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -3,33 +3,367 @@ param( [string]$PackageRoot, [string]$ZipPath, [string]$ExpectedCommit, - [switch]$AllowDirtyPackage + [string]$ExpectedSourceEpoch, + [switch]$AllowDirtyPackage, + [switch]$RequireReleaseEligible ) $ErrorActionPreference = "Stop" +$script:verificationCleanupReady = $false + +if ($RequireReleaseEligible) { + throw @' +Release-eligible verification is fail-closed before Git or build-tool execution. No tracked +reviewed exact toolchain allowlist currently authorizes the Git executable, PlatformIO/Python +launchers, their complete runtime inputs, and the post-build dependency state. Diagnostic package +verification remains available without -RequireReleaseEligible and cannot establish eligibility. +'@ +} + +$ambientGitOverrides = @(Get-ChildItem Env: | Where-Object { $_.Name -like 'GIT_*' }) +if ($ambientGitOverrides.Count -gt 0) { + throw "Release verification refuses ambient Git overrides: $(@($ambientGitOverrides.Name | Sort-Object -Unique) -join ', ')" +} +$ambientBuildOverrides = @(Get-ChildItem Env: | Where-Object { + $_.Name -like 'PLATFORMIO_*' -or + $_.Name -in @( + 'STACKCHAN_BUILD_EPOCH', 'SOURCE_DATE_EPOCH', 'STACKCHAN_BUILD_STAMP', + 'STACKCHAN_DISABLE_REPRODUCIBLE_BUILD', 'STACKCHAN_EXPECTED_BUILD_COMMIT', + 'STACKCHAN_EXPECTED_BUILD_EPOCH', 'STACKCHAN_PERSONA', 'STACKCHAN_WIFI_SSID', + 'STACKCHAN_WIFI_PASSWORD', 'STACKCHAN_BRIDGE_HOST', 'STACKCHAN_BRIDGE_PORT', + 'STACKCHAN_BRIDGE_PATH', 'STACKCHAN_PAIRING_SHORT_CODE', 'STACKCHAN_OTA_TOKEN', + 'STACKCHAN_OTA_PORT') +}) +if ($ambientBuildOverrides.Count -gt 0) { + throw "Release verification refuses ambient build overrides: $(@($ambientBuildOverrides.Name | Sort-Object -Unique) -join ', ')" +} $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $repoRoot +$verifierSystemDirectory = [System.IO.Path]::GetFullPath([Environment]::SystemDirectory).TrimEnd('\', '/') +if ($env:OS -ne 'Windows_NT' -or -not [System.IO.Path]::IsPathRooted($verifierSystemDirectory)) { + throw 'Release verification requires a validated Windows system executable root.' +} +$verifierPowerShellExecutable = Join-Path $verifierSystemDirectory 'WindowsPowerShell/v1.0/powershell.exe' +if (-not (Test-Path -LiteralPath $verifierPowerShellExecutable -PathType Leaf)) { + throw "Required Windows PowerShell executable is missing: $verifierPowerShellExecutable" +} +$verifierPowerShellItem = Get-Item -LiteralPath $verifierPowerShellExecutable -Force +if ($verifierPowerShellItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint -or + [string]$verifierPowerShellItem.Extension -cne '.exe') { + throw "Release verification refuses a redirected or non-EXE PowerShell command: $verifierPowerShellExecutable" +} +$trustedGitCommand = Get-Command -Name git -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 +if ($null -eq $trustedGitCommand) { + throw 'Release verification requires a Git application executable; functions, aliases, and scripts are refused.' +} +$trustedGitExecutable = (Resolve-Path -LiteralPath ([string]$trustedGitCommand.Source)).Path +$trustedGitDisabledHooksPath = Join-Path $repoRoot ( + "output/private/disabled-verifier-git-hooks-$PID-" + [guid]::NewGuid().ToString('N')) +$trustedNullAttributesPath = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } +if (Test-Path -LiteralPath $trustedGitDisabledHooksPath) { + throw "Verifier Git disabled-hooks sentinel unexpectedly exists: $trustedGitDisabledHooksPath" +} + +function Invoke-TrustedVerifierGit { + param([Parameter(Mandatory = $true)][string[]]$Arguments) + + if (Test-Path -LiteralPath $script:trustedGitDisabledHooksPath) { + throw "Trusted Git disabled-hooks path must not exist: $script:trustedGitDisabledHooksPath" + } + $gitArguments = @( + '-c', "core.hooksPath=$script:trustedGitDisabledHooksPath", + '-c', 'core.fsmonitor=false', + '-c', 'core.untrackedCache=false', + '-c', 'core.useBuiltinFSMonitor=false', + '-c', 'maintenance.auto=false', + '-c', 'core.autocrlf=true', + '-c', "core.attributesFile=$script:trustedNullAttributesPath", + '-c', 'filter.lfs.process=', + '-c', 'filter.lfs.clean=', + '-c', 'filter.lfs.smudge=', + '-c', 'filter.lfs.required=false' + ) + $gitArguments += $Arguments + + $previousNoReplaceObjects = $env:GIT_NO_REPLACE_OBJECTS + $previousNoSystemAttributes = $env:GIT_ATTR_NOSYSTEM + try { + $env:GIT_NO_REPLACE_OBJECTS = '1' + $env:GIT_ATTR_NOSYSTEM = '1' + & $script:trustedGitExecutable @gitArguments + } finally { + if ($null -eq $previousNoReplaceObjects) { + Remove-Item Env:\GIT_NO_REPLACE_OBJECTS -ErrorAction SilentlyContinue + } else { + $env:GIT_NO_REPLACE_OBJECTS = $previousNoReplaceObjects + } + if ($null -eq $previousNoSystemAttributes) { + Remove-Item Env:\GIT_ATTR_NOSYSTEM -ErrorAction SilentlyContinue + } else { + $env:GIT_ATTR_NOSYSTEM = $previousNoSystemAttributes + } + } +} + +function Get-CanonicalGitBlobHash { + param( + [Parameter(Mandatory = $true)][string]$LiteralPath, + [Parameter(Mandatory = $true)][ValidateSet(40, 64)][int]$HashLength + ) + + # Release packages use one explicit Git-canonical content policy: byte-preserve + # files containing NUL and normalize CRLF to LF for text. This matches the + # repository's public checkout policy without consulting or executing filters. + $bytes = [System.IO.File]::ReadAllBytes($LiteralPath) + if (-not ($bytes -contains [byte]0)) { + $normalized = New-Object System.Collections.Generic.List[byte] + for ($index = 0; $index -lt $bytes.Length; $index++) { + if ($bytes[$index] -eq 13 -and $index + 1 -lt $bytes.Length -and + $bytes[$index + 1] -eq 10) { + $normalized.Add(10) + $index++ + } else { + $normalized.Add($bytes[$index]) + } + } + $bytes = $normalized.ToArray() + } + $header = [System.Text.Encoding]::ASCII.GetBytes("blob $($bytes.Length)`0") + $objectBytes = New-Object byte[] ($header.Length + $bytes.Length) + [System.Array]::Copy($header, 0, $objectBytes, 0, $header.Length) + [System.Array]::Copy($bytes, 0, $objectBytes, $header.Length, $bytes.Length) + $hasher = if ($HashLength -eq 40) { + [System.Security.Cryptography.SHA1]::Create() + } else { + [System.Security.Cryptography.SHA256]::Create() + } + try { + return (($hasher.ComputeHash($objectBytes) | ForEach-Object { $_.ToString('x2') }) -join '') + } finally { + $hasher.Dispose() + } +} + +function Assert-SafeReleaseVersionLeaf { + param([Parameter(Mandatory = $true)][string]$Value) + + if ($Value.Length -gt 128 -or + $Value -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or + $Value -in @('.', '..') -or + $Value.EndsWith('.', [System.StringComparison]::Ordinal)) { + throw "Version must be one safe filename component containing only letters, digits, '.', '_', or '-'." + } +} if ([string]::IsNullOrWhiteSpace($Version)) { - $Version = (git describe --tags --always --dirty).Trim() + $Version = (Invoke-TrustedVerifierGit -Arguments @('describe', '--tags', '--always', '--dirty')).Trim() } +Assert-SafeReleaseVersionLeaf -Value $Version if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { - $ExpectedCommit = (git rev-parse HEAD).Trim() + $ExpectedCommit = (Invoke-TrustedVerifierGit -Arguments @('rev-parse', 'HEAD')).Trim() +} +if ($ExpectedCommit -notmatch '^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$') { + throw "ExpectedCommit must be a full 40- or 64-character hexadecimal commit ID" +} +$ExpectedCommit = $ExpectedCommit.ToLowerInvariant() +if (-not [string]::IsNullOrWhiteSpace($ExpectedSourceEpoch) -and + $ExpectedSourceEpoch -notmatch '^[0-9]{1,12}$') { + throw "ExpectedSourceEpoch must be an exact unsigned decimal Unix epoch" +} +if ($RequireReleaseEligible) { + $resolvedVerifierRoot = [System.IO.Path]::GetFullPath([string]$repoRoot).TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $resolvedVerifierTools = [System.IO.Path]::GetFullPath([string]$PSScriptRoot).TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $expectedVerifierTools = [System.IO.Path]::GetFullPath( + (Join-Path $resolvedVerifierRoot 'tools')).TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + if (-not $resolvedVerifierTools.Equals( + $expectedVerifierTools, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'Operational release verification must run from the trusted checkout tools directory.' + } + $trustedTopLevel = (Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'rev-parse', '--show-toplevel')).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($trustedTopLevel)) { + throw 'Operational release verification could not resolve its exact Git top-level.' + } + $resolvedTrustedTopLevel = [System.IO.Path]::GetFullPath($trustedTopLevel).TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + if (-not $resolvedTrustedTopLevel.Equals( + $resolvedVerifierRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'Operational release verification refuses a nested or ignored verifier outside the exact Git top-level.' + } + $attributeCandidates = New-Object System.Collections.Generic.List[string] + $worktreeAttributes = (Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'rev-parse', '--git-path', 'info/attributes')).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($worktreeAttributes)) { + throw 'Operational release verification could not resolve worktree Git attributes state.' + } + $resolvedWorktreeAttributes = if ([System.IO.Path]::IsPathRooted($worktreeAttributes)) { + $worktreeAttributes + } else { + Join-Path $resolvedVerifierRoot $worktreeAttributes + } + $attributeCandidates.Add([System.IO.Path]::GetFullPath($resolvedWorktreeAttributes)) + $commonGitDir = (Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'rev-parse', '--git-common-dir')).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($commonGitDir)) { + throw 'Operational release verification could not resolve common Git attributes state.' + } + $commonAttributes = Join-Path $commonGitDir 'info/attributes' + $resolvedCommonAttributes = if ([System.IO.Path]::IsPathRooted($commonAttributes)) { + $commonAttributes + } else { + Join-Path $resolvedVerifierRoot $commonAttributes + } + $attributeCandidates.Add([System.IO.Path]::GetFullPath($resolvedCommonAttributes)) + foreach ($attributePath in @($attributeCandidates | Sort-Object -Unique)) { + if (Test-Path -LiteralPath $attributePath -PathType Leaf) { + throw "Operational release verification refuses repository-local Git attributes: $attributePath" + } + } + $trustedAttributesBlob = (Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'rev-parse', '--verify', + "${ExpectedCommit}:.gitattributes")).Trim().ToLowerInvariant() + $workingAttributesPath = Join-Path $resolvedVerifierRoot '.gitattributes' + if ($LASTEXITCODE -ne 0 -or $trustedAttributesBlob -notmatch '^[0-9a-f]{40,64}$' -or + -not (Test-Path -LiteralPath $workingAttributesPath -PathType Leaf)) { + throw 'Operational release verification requires the trusted .gitattributes policy.' + } + $workingAttributesBlob = Get-CanonicalGitBlobHash ` + -LiteralPath $workingAttributesPath -HashLength $trustedAttributesBlob.Length + if ($workingAttributesBlob -cne $trustedAttributesBlob) { + throw 'Operational release verification refuses modified .gitattributes content.' + } + $trustedVerifierCommit = (Invoke-TrustedVerifierGit -Arguments @('-C', [string]$repoRoot, 'rev-parse', '--verify', 'HEAD')).Trim().ToLowerInvariant() + if ($LASTEXITCODE -ne 0 -or $trustedVerifierCommit -cne $ExpectedCommit) { + throw "Operational release verification requires a trusted checkout at ExpectedCommit." + } + $trustedVerifierDirty = @(Invoke-TrustedVerifierGit -Arguments @('-C', [string]$repoRoot, 'status', '--porcelain=v1', '--untracked-files=all')) + if ($LASTEXITCODE -ne 0 -or $trustedVerifierDirty.Count -gt 0) { + throw "Operational release verification requires a clean trusted checkout." + } + foreach ($trustedBootstrapRelative in @( + 'tools/verify_release_package.ps1', + 'tools/firmware_reproducibility_proof.ps1', + 'tools/release_zip_safety.ps1', + 'tools/release_dependency_evidence.ps1', + 'tools/release_git_trust.ps1', + 'tools/platformio_resolver.ps1' + )) { + $indexRecord = @(Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'ls-files', '-v', '--', $trustedBootstrapRelative)) + if ($LASTEXITCODE -ne 0 -or $indexRecord.Count -ne 1 -or + [string]$indexRecord[0] -cne "H $trustedBootstrapRelative") { + throw "Operational release verification refuses hidden index state for $trustedBootstrapRelative." + } + $trustedBlob = (Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'rev-parse', '--verify', + "${ExpectedCommit}:$trustedBootstrapRelative")).Trim().ToLowerInvariant() + $workingBlob = if ($trustedBlob -match '^[0-9a-f]{40}$') { + Get-CanonicalGitBlobHash -LiteralPath (Join-Path $resolvedVerifierRoot $trustedBootstrapRelative) -HashLength 40 + } elseif ($trustedBlob -match '^[0-9a-f]{64}$') { + Get-CanonicalGitBlobHash -LiteralPath (Join-Path $resolvedVerifierRoot $trustedBootstrapRelative) -HashLength 64 + } else { '' } + if ($trustedBlob -notmatch '^[0-9a-f]{40,64}$' -or + $workingBlob -cne $trustedBlob) { + throw "Operational release verification requires canonical HEAD content for $trustedBootstrapRelative." + } + } + $trustedSourceEpoch = (Invoke-TrustedVerifierGit -Arguments @( + '-C', [string]$repoRoot, 'show', '-s', '--format=%ct', $ExpectedCommit)).Trim() + if ($LASTEXITCODE -ne 0 -or $trustedSourceEpoch -notmatch '^[0-9]{1,12}$') { + throw "Operational release verification could not resolve the trusted commit epoch." + } + if (-not [string]::IsNullOrWhiteSpace($ExpectedSourceEpoch) -and + $ExpectedSourceEpoch -cne $trustedSourceEpoch) { + throw "ExpectedSourceEpoch does not match the trusted checkout commit epoch." + } + $ExpectedSourceEpoch = $trustedSourceEpoch } +# In operational mode, no local helper is loaded until the trusted checkout has +# proven that this verifier and every helper are the exact clean commit inputs. +. (Join-Path $PSScriptRoot "firmware_reproducibility_proof.ps1") +. (Join-Path $PSScriptRoot "release_zip_safety.ps1") +. (Join-Path $PSScriptRoot "release_dependency_evidence.ps1") +. (Join-Path $PSScriptRoot "release_git_trust.ps1") +. (Join-Path $PSScriptRoot "platformio_resolver.ps1") + $cleanupDir = $null +$tempRoot = $null + +function Remove-VerificationExtraction { + if (-not $script:cleanupDir -or -not (Test-Path -LiteralPath $script:cleanupDir)) { return } + $resolvedCleanup = (Resolve-Path -LiteralPath $script:cleanupDir).Path + $resolvedTempRoot = (Resolve-Path -LiteralPath $script:tempRoot).Path.TrimEnd('\') + '\' + if (-not $resolvedCleanup.StartsWith($resolvedTempRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clean unexpected verification directory: $resolvedCleanup" + } + $cleanupFileSystemPath = if ($env:OS -eq "Windows_NT" -and -not $resolvedCleanup.StartsWith("\\?\")) { + "\\?\$resolvedCleanup" + } else { + $resolvedCleanup + } + [System.IO.Directory]::Delete($cleanupFileSystemPath, $true) + $script:cleanupDir = $null +} + +function Assert-ReleaseZipSidecar { + param([Parameter(Mandatory = $true)][string]$LiteralZipPath) + + $sidecarPath = "$LiteralZipPath.sha256" + if (-not (Test-Path -LiteralPath $sidecarPath -PathType Leaf)) { + throw "Missing release ZIP SHA-256 sidecar: $sidecarPath" + } + $sidecarBytes = [IO.File]::ReadAllBytes($sidecarPath) + if ($sidecarBytes.Length -lt 69 -or $sidecarBytes.Length -gt 512 -or + @($sidecarBytes | Where-Object { $_ -gt 127 }).Count -ne 0) { + throw 'Release ZIP SHA-256 sidecar is not bounded canonical ASCII.' + } + $sidecarText = [Text.Encoding]::ASCII.GetString($sidecarBytes) + if ($sidecarText -notmatch '\A([0-9a-f]{64}) ([A-Za-z0-9][A-Za-z0-9._-]*\.zip)(?:\r?\n)?\z') { + throw 'Release ZIP SHA-256 sidecar must contain one canonical lowercase hash and ZIP filename.' + } + $expectedHash = [string]$Matches[1] + $expectedName = [string]$Matches[2] + $actualName = Split-Path -Leaf $LiteralZipPath + if ($expectedName -cne $actualName) { + throw "Release ZIP SHA-256 sidecar names '$expectedName', not '$actualName'." + } + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $LiteralZipPath).Hash.ToLowerInvariant() + if ($actualHash -cne $expectedHash) { + throw "Release ZIP SHA-256 sidecar mismatch: expected $expectedHash, got $actualHash." + } +} + +$script:verificationCleanupReady = $true +trap { + $verificationFailure = $_ + if ($script:verificationCleanupReady) { + try { + Remove-VerificationExtraction + } catch { + Write-Warning "Could not remove failed verifier ZIP extraction; the input ZIP remains authoritative." + } + } + throw $verificationFailure +} if (-not [string]::IsNullOrWhiteSpace($ZipPath)) { if (-not (Test-Path -LiteralPath $ZipPath)) { throw "Missing release ZIP: $ZipPath" } + $ZipPath = (Resolve-Path -LiteralPath $ZipPath).Path + Assert-ReleaseZipSidecar -LiteralZipPath $ZipPath $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "stackchan-release-verify" $cleanupDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $cleanupDir | Out-Null - Expand-Archive -LiteralPath $ZipPath -DestinationPath $cleanupDir + Expand-StackchanReleaseZipSafely -ZipPath $ZipPath -DestinationPath $cleanupDir $PackageRoot = $cleanupDir } @@ -43,12 +377,38 @@ if (-not (Test-Path -LiteralPath $PackageRoot)) { $packageRootPath = (Resolve-Path $PackageRoot).Path $packageRootPrefix = $packageRootPath.TrimEnd('\') + '\' +$packageEnumerationRoot = if ($env:OS -eq 'Windows_NT' -and + -not $packageRootPath.StartsWith('\\?\')) { + '\\?\' + $packageRootPath +} else { + $packageRootPath +} +function Get-PackageItemFullName { + param([Parameter(Mandatory = $true)][object]$Item) + $fullName = [string]$Item.FullName + if ($fullName.StartsWith('\\?\', [System.StringComparison]::Ordinal)) { + return $fullName.Substring(4) + } + return $fullName +} +$eligibilityManifestPath = Join-Path $packageRootPath "release_manifest.json" +if (Test-Path -LiteralPath $eligibilityManifestPath -PathType Leaf) { + $eligibilityManifest = Get-Content -LiteralPath $eligibilityManifestPath -Raw | ConvertFrom-Json + if ([bool]$eligibilityManifest.diagnosticPackage) { + if ($RequireReleaseEligible) { + throw "Operational release verification refuses diagnostic packages." + } + if (-not $AllowDirtyPackage) { + throw "Diagnostic archive inspection requires -AllowDirtyPackage" + } + } +} $generatedPythonArtifacts = @( - Get-ChildItem -LiteralPath $packageRootPath -Recurse -Force | Where-Object { + Get-ChildItem -LiteralPath $packageEnumerationRoot -Recurse -Force | Where-Object { ($_.PSIsContainer -and $_.Name -eq "__pycache__") -or (-not $_.PSIsContainer -and $_.Extension.ToLowerInvariant() -in @(".pyc", ".pyo")) } | ForEach-Object { - $_.FullName.Substring($packageRootPrefix.Length).Replace('\', '/') + (Get-PackageItemFullName $_).Substring($packageRootPrefix.Length).Replace('\', '/') } ) if ($generatedPythonArtifacts.Count -gt 0) { @@ -56,8 +416,8 @@ if ($generatedPythonArtifacts.Count -gt 0) { } $restrictedVoicePayloads = @( - Get-ChildItem -LiteralPath $packageRootPath -File -Recurse | Where-Object { - $relative = $_.FullName.Substring($packageRootPrefix.Length).Replace('\', '/') + Get-ChildItem -LiteralPath $packageEnumerationRoot -File -Recurse | Where-Object { + $relative = (Get-PackageItemFullName $_).Substring($packageRootPrefix.Length).Replace('\', '/') $extension = $_.Extension.ToLowerInvariant() $allowedVisionModel = $relative -match '(?i)^(provenance/)?bridge/models/face_detection_yunet_2023mar\.onnx$' $allowedProductionVoice = $relative -match '(?i)^media/voice/rvc/(model\.pth|model\.index)$' @@ -67,7 +427,7 @@ $restrictedVoicePayloads = @( ($relative -match '(?i)(^|/)media/voice/rvc/(?!README\.md$|model\.pth$|model\.index$).+') -or ($_.Name -match '(?i)rvc.*\.(wav|mp3|html)$') } | ForEach-Object { - $_.FullName.Substring($packageRootPrefix.Length).Replace('\', '/') + (Get-PackageItemFullName $_).Substring($packageRootPrefix.Length).Replace('\', '/') } ) if ($restrictedVoicePayloads.Count -gt 0) { @@ -76,7 +436,20 @@ if ($restrictedVoicePayloads.Count -gt 0) { function Join-PackagePath { param([string]$RelativePath) - $path = Join-Path $packageRootPath ($RelativePath -replace "/", "\") + if ([string]::IsNullOrWhiteSpace($RelativePath)) { + throw "Package-relative path cannot be empty" + } + $normalizedRelative = $RelativePath.Replace('\', '/') + $segments = @($normalizedRelative.Split('/') | Where-Object { $_ -ne '' }) + if ([System.IO.Path]::IsPathRooted($normalizedRelative) -or + $normalizedRelative.Contains(':') -or + $segments -contains '.' -or $segments -contains '..') { + throw "Refusing unsafe package-relative path: $RelativePath" + } + $path = [System.IO.Path]::GetFullPath((Join-Path $packageRootPath ($normalizedRelative -replace "/", "\"))) + if (-not $path.StartsWith($packageRootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing package path outside package root: $RelativePath" + } if ($env:OS -eq "Windows_NT" -and $path.Length -ge 260 -and -not $path.StartsWith("\\?\")) { if ($path.StartsWith("\\")) { return "\\?\UNC\$($path.TrimStart('\'))" @@ -86,6 +459,669 @@ function Join-PackagePath { return $path } +function Assert-PackageFileMatchesTrustedGitBlob { + param( + [Parameter(Mandatory = $true)][string]$PackageRelativePath, + [Parameter(Mandatory = $true)][string]$TrustedSourceRelativePath + ) + + if (-not $RequireReleaseEligible) { return } + $packageRelative = $PackageRelativePath.Replace('\', '/') + $sourceRelative = $TrustedSourceRelativePath.Replace('\', '/') + foreach ($relative in @($packageRelative, $sourceRelative)) { + if ([string]::IsNullOrWhiteSpace($relative) -or + $relative.StartsWith('/') -or + $relative -match '(^|/)\.\.(/|$)' -or + $relative.Contains(':')) { + throw "Operational package Git binding refuses unsafe relative path: $relative" + } + } + $packageFile = Join-PackagePath $packageRelative + if (-not (Test-Path -LiteralPath $packageFile -PathType Leaf)) { + throw "Operational package Git binding is missing packaged file: $packageRelative" + } + $trustedBlob = (Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'rev-parse', '--verify', + "${ExpectedCommit}:$sourceRelative")).Trim().ToLowerInvariant() + if ($LASTEXITCODE -ne 0 -or $trustedBlob -notmatch '^[0-9a-f]{40,64}$') { + throw "Operational package Git binding cannot resolve trusted source blob: $sourceRelative" + } + $packageBlob = if ($trustedBlob.Length -eq 40) { + Get-CanonicalGitBlobHash -LiteralPath $packageFile -HashLength 40 + } else { + Get-CanonicalGitBlobHash -LiteralPath $packageFile -HashLength 64 + } + if ($packageBlob -cne $trustedBlob) { + throw "Operational package file does not match trusted Git bytes: $packageRelative" + } +} + +function Get-TrustedProvenanceSourceRelativePath { + param([Parameter(Mandatory = $true)][string]$PackageRelativePath) + + $normalized = $PackageRelativePath.Replace('\', '/') + if ($normalized -notmatch '^provenance/(.+)$') { + throw "Operational provenance binding requires a provenance path: $normalized" + } + $tail = $Matches[1] + if ($tail -in @( + 'firmware.yml', 'release.yml', 'pages.yml', 'companion-signing-readiness.yml')) { + return ".github/workflows/$tail" + } + return $tail +} + +function Get-PackagedFileInventory { + param([Parameter(Mandatory = $true)][string]$PackagePrefix) + + if ($PackagePrefix -notin @('tools', 'provenance')) { + throw "Operational package inventory refuses unsupported prefix: $PackagePrefix" + } + $inventoryRoot = Join-Path $packageEnumerationRoot $PackagePrefix + if (-not (Test-Path -LiteralPath $inventoryRoot -PathType Container)) { + throw "Operational package inventory is missing directory: $PackagePrefix" + } + return @( + Get-ChildItem -LiteralPath $inventoryRoot -Recurse -File -Force -ErrorAction Stop | + ForEach-Object { + (Get-PackageItemFullName $_).Substring($packageRootPrefix.Length).Replace('\', '/') + } + ) +} + +function Get-OperationalTrustedCommitMaps { + if ($null -ne $script:operationalTrustedCommitMaps) { + return $script:operationalTrustedCommitMaps + } + $treeBlobs = [System.Collections.Generic.Dictionary[string,string]]::new( + [System.StringComparer]::Ordinal) + $treeOutput = @(Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'ls-tree', '-r', $ExpectedCommit)) + if ($LASTEXITCODE -ne 0 -or $treeOutput.Count -eq 0) { + throw 'Operational package policy could not read the trusted commit tree.' + } + foreach ($lineValue in $treeOutput) { + $line = [string]$lineValue + if ($line -notmatch '^\d{6} blob ([0-9a-f]{40,64})\t(.+)$') { + throw "Operational package policy encountered an unsupported trusted tree entry: $line" + } + if ($treeBlobs.ContainsKey($Matches[2])) { + throw "Operational package policy encountered a duplicate trusted tree path: $($Matches[2])" + } + $treeBlobs.Add($Matches[2], $Matches[1].ToLowerInvariant()) + } + + $indexStates = [System.Collections.Generic.Dictionary[string,string]]::new( + [System.StringComparer]::Ordinal) + $indexOutput = @(Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'ls-files', '-v')) + if ($LASTEXITCODE -ne 0) { + throw 'Operational package policy could not read the trusted checkout index.' + } + foreach ($lineValue in $indexOutput) { + $line = [string]$lineValue + if ($line -notmatch '^(.?) (.+)$' -or $indexStates.ContainsKey($Matches[2])) { + throw "Operational package policy encountered an unsupported or duplicate index entry: $line" + } + $indexStates.Add($Matches[2], $Matches[1]) + } + $script:operationalTrustedCommitMaps = [pscustomobject]@{ + treeBlobs = $treeBlobs + indexStates = $indexStates + } + return $script:operationalTrustedCommitMaps +} + +function Get-TrustedReleaseToolPolicy { + param([Parameter(Mandatory = $true)][object]$CommitMaps) + + $policySource = 'tools/package_release.ps1' + if (-not $CommitMaps.treeBlobs.ContainsKey($policySource)) { + throw 'Operational package policy cannot find trusted package_release.ps1.' + } + $policyLines = @(Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'cat-file', 'blob', + [string]$CommitMaps.treeBlobs[$policySource])) + if ($LASTEXITCODE -ne 0 -or $policyLines.Count -eq 0) { + throw 'Operational package policy cannot read trusted package_release.ps1.' + } + $insidePolicy = $false + $closedPolicy = $false + $tools = New-Object System.Collections.Generic.List[string] + foreach ($lineValue in $policyLines) { + $line = [string]$lineValue + if (-not $insidePolicy) { + if ($line -ceq '$releaseTools = @(') { $insidePolicy = $true } + continue + } + if ($line -ceq ')') { $closedPolicy = $true; break } + if ($line -notmatch '^ "(tools/[A-Za-z0-9_.\-/]+)"[,]?$') { + throw "Trusted release-tools policy is not a canonical literal inventory: $line" + } + $tools.Add($Matches[1]) + } + if (-not $insidePolicy -or -not $closedPolicy -or $tools.Count -eq 0) { + throw 'Trusted release-tools policy literal inventory is missing or empty.' + } + $exact = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $folded = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($tool in $tools) { + if (-not $exact.Add($tool) -or -not $folded.Add($tool)) { + throw "Trusted release-tools policy has a duplicate or case collision: $tool" + } + } + $result = [string[]]$tools.ToArray() + [Array]::Sort($result, [System.StringComparer]::Ordinal) + return @($result) +} + +function Get-TrustedProvenancePolicy { + param([Parameter(Mandatory = $true)][object]$CommitMaps) + + $packageToSource = [System.Collections.Generic.Dictionary[string,string]]::new( + [System.StringComparer]::Ordinal) + foreach ($sourcePath in $CommitMaps.treeBlobs.Keys) { + $packagePath = $null + if ($sourcePath -in @( + 'platformio.ini', 'partitions_esp_sr_16.csv', 'requirements-preview.txt', + 'requirements-firmware-release.txt')) { + $packagePath = "provenance/$sourcePath" + } elseif ($sourcePath -in @( + '.github/workflows/firmware.yml', '.github/workflows/release.yml', + '.github/workflows/pages.yml', '.github/workflows/companion-signing-readiness.yml')) { + $packagePath = 'provenance/' + [System.IO.Path]::GetFileName($sourcePath) + } elseif ($sourcePath -eq 'data/commands.yaml') { + $packagePath = 'provenance/data/commands.yaml' + } elseif ($sourcePath -match '^(src|bridge|protocol-fixtures|personas|test)/') { + $packagePath = "provenance/$sourcePath" + } elseif ($sourcePath -match '^companion/(.+)$' -and + $sourcePath -notmatch '^companion/(build|\.gradle|\.kotlin)(/|$)' -and + $sourcePath -notmatch '/(build|\.gradle|\.kotlin)(/|$)') { + $packagePath = "provenance/$sourcePath" + } + if ($null -ne $packagePath) { + if ($packageToSource.ContainsKey($packagePath)) { + throw "Trusted provenance policy has a duplicate package mapping: $packagePath" + } + $packageToSource.Add($packagePath, $sourcePath) + } + } + if ($packageToSource.Count -eq 0) { + throw 'Trusted provenance policy resolved no files.' + } + return $packageToSource +} + +function Assert-OperationalSourceCheckoutBindings { + param( + [Parameter(Mandatory = $true)][object]$CommitMaps, + [Parameter(Mandatory = $true)][string[]]$SourcePaths + ) + + foreach ($sourcePath in @($SourcePaths | Sort-Object -Unique)) { + if (-not $CommitMaps.treeBlobs.ContainsKey($sourcePath) -or + -not $CommitMaps.indexStates.ContainsKey($sourcePath) -or + [string]$CommitMaps.indexStates[$sourcePath] -cne 'H') { + throw "Operational package policy refuses missing or hidden index state for source: $sourcePath" + } + $workingPath = Join-Path $resolvedVerifierRoot $sourcePath + if (-not (Test-Path -LiteralPath $workingPath -PathType Leaf)) { + throw "Operational package policy source is missing from the trusted checkout: $sourcePath" + } + $trustedBlob = [string]$CommitMaps.treeBlobs[$sourcePath] + $workingBlob = Get-CanonicalGitBlobHash -LiteralPath $workingPath -HashLength $trustedBlob.Length + if ($workingBlob -cne $trustedBlob) { + throw "Operational package policy source does not match canonical trusted commit bytes: $sourcePath" + } + } +} + +function Assert-PackageFileMatchesTrustedBlobMap { + param( + [Parameter(Mandatory = $true)][string]$PackageRelativePath, + [Parameter(Mandatory = $true)][string]$TrustedSourceRelativePath, + [Parameter(Mandatory = $true)][object]$CommitMaps + ) + if (-not $CommitMaps.treeBlobs.ContainsKey($TrustedSourceRelativePath)) { + throw "Operational package policy cannot resolve trusted source: $TrustedSourceRelativePath" + } + $packageFile = Join-PackagePath $PackageRelativePath + if (-not (Test-Path -LiteralPath $packageFile -PathType Leaf)) { + throw "Operational package policy is missing package file: $PackageRelativePath" + } + $trustedBlob = [string]$CommitMaps.treeBlobs[$TrustedSourceRelativePath] + $packageBlob = Get-CanonicalGitBlobHash -LiteralPath $packageFile -HashLength $trustedBlob.Length + if ($packageBlob -cne $trustedBlob) { + throw "Operational package file does not match trusted Git bytes: $PackageRelativePath" + } +} + +function Assert-ExactOperationalInventory { + param( + [Parameter(Mandatory = $true)][object[]]$ManifestEntries, + [Parameter(Mandatory = $true)][string]$PackagePrefix + ) + + $manifestExact = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + $manifestFolded = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + $manifestList = New-Object System.Collections.Generic.List[string] + foreach ($entryValue in @($ManifestEntries)) { + $entry = [string]$entryValue + $normalized = $entry.Replace('\', '/') + $segments = @($normalized.Split('/') | Where-Object { $_ -ne '' }) + if ([string]::IsNullOrWhiteSpace($entry) -or $entry -cne $normalized -or + [System.IO.Path]::IsPathRooted($normalized) -or $normalized.Contains(':') -or + $segments -contains '.' -or $segments -contains '..' -or + -not $normalized.StartsWith("$PackagePrefix/", [System.StringComparison]::Ordinal) -or + -not $manifestExact.Add($normalized) -or -not $manifestFolded.Add($normalized)) { + throw "Operational package manifest contains an unsafe, duplicate, or case-colliding $PackagePrefix inventory entry: $entry" + } + $manifestList.Add($normalized) + } + $sortedManifest = @($manifestList) + [Array]::Sort($sortedManifest, [StringComparer]::Ordinal) + for ($index = 0; $index -lt $sortedManifest.Count; $index++) { + if ($manifestList[$index] -cne $sortedManifest[$index]) { + throw "Operational package manifest $PackagePrefix inventory is not ordinally sorted." + } + } + + $actualExact = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + $actualFolded = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + $actualFiles = @(Get-PackagedFileInventory -PackagePrefix $PackagePrefix) + foreach ($actualFile in $actualFiles) { + if (-not $actualExact.Add($actualFile) -or -not $actualFolded.Add($actualFile)) { + throw "Operational package contains duplicate or case-colliding $PackagePrefix file: $actualFile" + } + } + if ($actualExact.Count -ne $manifestExact.Count) { + throw "Operational package manifest $PackagePrefix inventory count does not match packaged files." + } + foreach ($actualFile in $actualExact) { + if (-not $manifestExact.Contains($actualFile)) { + throw "Operational package contains an undeclared $PackagePrefix file: $actualFile" + } + } + foreach ($manifestFile in $manifestExact) { + if (-not $actualExact.Contains($manifestFile)) { + throw "Operational package manifest declares a missing $PackagePrefix file: $manifestFile" + } + } + return @($manifestList) +} + +function Assert-OperationalPackageGitBindings { + param([Parameter(Mandatory = $true)][object]$Manifest) + + if (-not $RequireReleaseEligible) { return } + $commitMaps = Get-OperationalTrustedCommitMaps + $trustedToolPolicy = @(Get-TrustedReleaseToolPolicy -CommitMaps $commitMaps) + $trustedIncludedTools = @(Assert-ExactOperationalInventory ` + -ManifestEntries @($Manifest.includedTools) -PackagePrefix 'tools') + if (($trustedIncludedTools -join "`n") -cne ($trustedToolPolicy -join "`n")) { + throw 'Operational package tools inventory does not equal the trusted commit-side packaging policy.' + } + $provenancePolicy = Get-TrustedProvenancePolicy -CommitMaps $commitMaps + $trustedProvenanceFiles = @(Assert-ExactOperationalInventory ` + -ManifestEntries @($Manifest.provenanceFiles) -PackagePrefix 'provenance') + $trustedProvenancePolicy = [string[]]@($provenancePolicy.Keys) + [Array]::Sort($trustedProvenancePolicy, [System.StringComparer]::Ordinal) + if (($trustedProvenanceFiles -join "`n") -cne ($trustedProvenancePolicy -join "`n")) { + throw 'Operational package provenance inventory does not equal the trusted commit-side packaging policy.' + } + + $allSources = @($trustedToolPolicy) + @($provenancePolicy.Values) + Assert-OperationalSourceCheckoutBindings -CommitMaps $commitMaps -SourcePaths $allSources + foreach ($includedTool in $trustedToolPolicy) { + Assert-PackageFileMatchesTrustedBlobMap -PackageRelativePath $includedTool ` + -TrustedSourceRelativePath $includedTool -CommitMaps $commitMaps + } + foreach ($provenanceFile in $trustedProvenancePolicy) { + Assert-PackageFileMatchesTrustedBlobMap -PackageRelativePath $provenanceFile ` + -TrustedSourceRelativePath ([string]$provenancePolicy[$provenanceFile]) ` + -CommitMaps $commitMaps + } + + # Every package tree that is copied directly from the repository is also + # content-bound. This prevents a regenerated checksum file from blessing a + # modified bridge, document, persona, data, or site payload. Generated trees + # are deliberately handled by their own deterministic inventories below. + $outerCopyMappings = [System.Collections.Generic.Dictionary[string,string]]::new( + [System.StringComparer]::Ordinal) + foreach ($prefix in @('bridge', 'docs', 'data', 'personas', 'site')) { + $treeRoot = Join-Path $packageEnumerationRoot $prefix + if (-not (Test-Path -LiteralPath $treeRoot -PathType Container)) { + throw "Operational package is missing trusted copy tree: $prefix" + } + foreach ($item in Get-ChildItem -LiteralPath $treeRoot -Recurse -File -Force) { + $packageRelative = (Get-PackageItemFullName $item).Substring( + $packageRootPrefix.Length).Replace('\', '/') + if (-not $commitMaps.treeBlobs.ContainsKey($packageRelative)) { + throw "Operational package contains a file outside the trusted copy-tree policy: $packageRelative" + } + $outerCopyMappings.Add($packageRelative, $packageRelative) + } + } + foreach ($mapping in @( + @('AGENTS.md', 'AGENTS.md'), + @('CONTRIBUTING.md', 'CONTRIBUTING.md'), + @('SECURITY.md', 'SECURITY.md'), + @('CODE_OF_CONDUCT.md', 'CODE_OF_CONDUCT.md'), + @('LICENSE', 'LICENSE'), + @('ARRIVAL_DAY_RUNBOOK.md', 'docs/ARRIVAL_DAY_RUNBOOK.md'), + @('QUICKSTART.md', 'docs/RELEASE_QUICKSTART.md') + )) { + $outerCopyMappings.Add([string]$mapping[0], [string]$mapping[1]) + } + Assert-OperationalSourceCheckoutBindings -CommitMaps $commitMaps ` + -SourcePaths @($outerCopyMappings.Values) + foreach ($packageRelative in $outerCopyMappings.Keys) { + Assert-PackageFileMatchesTrustedBlobMap -PackageRelativePath $packageRelative ` + -TrustedSourceRelativePath ([string]$outerCopyMappings[$packageRelative]) ` + -CommitMaps $commitMaps + } + + Assert-OperationalSourceCheckoutBindings -CommitMaps $commitMaps -SourcePaths @('README.md') + $trustedReadmeText = [System.IO.File]::ReadAllText((Join-Path $resolvedVerifierRoot 'README.md')) + $expectedPackageReadmeText = $trustedReadmeText.Replace('](docs/media/', '](media/') + $actualPackageReadmeText = [System.IO.File]::ReadAllText((Join-PackagePath 'README.md')) + if ($actualPackageReadmeText -cne $expectedPackageReadmeText) { + throw 'Operational package README does not match the trusted deterministic link rewrite policy.' + } + + $mediaSourceMappings = [System.Collections.Generic.Dictionary[string,string]]::new( + [System.StringComparer]::Ordinal) + $mediaTreeRoot = Join-Path $packageEnumerationRoot 'media' + foreach ($item in Get-ChildItem -LiteralPath $mediaTreeRoot -Recurse -File -Force) { + $packageRelative = (Get-PackageItemFullName $item).Substring( + $packageRootPrefix.Length).Replace('\', '/') + $sourceRelative = if ($commitMaps.treeBlobs.ContainsKey($packageRelative)) { + $packageRelative + } elseif ($commitMaps.treeBlobs.ContainsKey("docs/$packageRelative")) { + "docs/$packageRelative" + } else { $null } + if ($null -ne $sourceRelative) { + $mediaSourceMappings.Add($packageRelative, $sourceRelative) + } + } + Assert-OperationalSourceCheckoutBindings -CommitMaps $commitMaps ` + -SourcePaths @($mediaSourceMappings.Values) + foreach ($packageRelative in $mediaSourceMappings.Keys) { + Assert-PackageFileMatchesTrustedBlobMap -PackageRelativePath $packageRelative ` + -TrustedSourceRelativePath ([string]$mediaSourceMappings[$packageRelative]) ` + -CommitMaps $commitMaps + } + + $allowedTopDirectories = @( + 'artifacts', 'bridge', 'character-red-team', 'companion', 'data', 'docs', + 'firmware', 'media', 'personas', 'provenance', 'site', + 'third_party_licenses', 'tools') + $actualTopDirectories = @( + Get-ChildItem -LiteralPath $packageEnumerationRoot -Directory -Force | + ForEach-Object { $_.Name } | Sort-Object) + if (($actualTopDirectories -join "`n") -cne (($allowedTopDirectories | Sort-Object) -join "`n")) { + throw "Operational package top-level directory inventory does not match trusted policy: $($actualTopDirectories -join ', ')" + } +} + +function Assert-OperationalFirmwareMatchesTrustedRebuild { + if (-not $RequireReleaseEligible) { return } + + $pioExecutable = Get-StackchanPlatformioCommand + $pioCommands = @(Get-Command -Name $pioExecutable -CommandType Application -ErrorAction SilentlyContinue) + if ($pioCommands.Count -ne 1) { + throw 'Operational release verification requires PlatformIO for an independent firmware rebuild.' + } + $pioExecutable = (Resolve-Path -LiteralPath ([string]$pioCommands[0].Source)).Path + $pioVersion = ((@(& $pioExecutable --version 2>&1) | Out-String).Trim()) + if ($LASTEXITCODE -ne 0 -or $pioVersion -cne 'PlatformIO Core, version 6.1.19' -or + [string]$dependencyLock.platformioCore -cne $pioVersion) { + throw "Operational independent rebuild requires the packaged PlatformIO Core 6.1.19 identity." + } + $pioExecutableSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $pioExecutable).Hash.ToUpperInvariant() + $defaultCoreDir = Get-StackchanPlatformioCoreDir + if ([string]::IsNullOrWhiteSpace($defaultCoreDir) -or + -not (Test-Path -LiteralPath $defaultCoreDir -PathType Container)) { + throw 'Operational independent rebuild could not resolve the installed PlatformIO core directory.' + } + $defaultCoreDir = (Resolve-Path -LiteralPath $defaultCoreDir).Path + $releaseCoreDir = if ($env:OS -eq 'Windows_NT') { + Join-Path ([System.IO.Path]::GetPathRoot($env:SystemRoot)) 'spio/pioarduino' + } else { + Join-Path ([System.IO.Path]::GetTempPath()) 'stackchan-pio-release-cores/pioarduino' + } + if (-not (Test-Path -LiteralPath $releaseCoreDir -PathType Container)) { + throw "Operational independent rebuild is missing the release PlatformIO core: $releaseCoreDir" + } + $releaseCoreDir = (Resolve-Path -LiteralPath $releaseCoreDir).Path + $rebuildEvidenceParent = Join-Path $resolvedVerifierRoot 'output/private/operational-firmware-rebuilds' + New-Item -ItemType Directory -Force -Path $rebuildEvidenceParent | Out-Null + $packageChecksumsPath = Join-PackagePath 'SHA256SUMS.txt' + $dependencyLockPathForRebuild = Join-PackagePath 'dependency_lock.json' + $packageChecksumsSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $packageChecksumsPath).Hash.ToUpperInvariant() + $dependencyLockSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $dependencyLockPathForRebuild).Hash.ToUpperInvariant() + $rebuildId = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + + "-$PID-" + [guid]::NewGuid().ToString('N').Substring(0, 12) + $rebuildEvidenceRoot = Join-Path $rebuildEvidenceParent $rebuildId + New-Item -ItemType Directory -Path $rebuildEvidenceRoot | Out-Null + $rebuildCacheRoot = Join-Path $rebuildEvidenceRoot 'build-cache' + New-Item -ItemType Directory -Path $rebuildCacheRoot | Out-Null + + $rebuildWorktree = if ($env:OS -eq 'Windows_NT') { + Join-Path ([System.IO.Path]::GetPathRoot($resolvedVerifierRoot)) ( + 'sc-vr-' + $PID + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) + } else { + Join-Path ([System.IO.Path]::GetTempPath()) ( + 'sc-vr-' + $PID + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) + } + if (Test-Path -LiteralPath $rebuildWorktree) { + throw "Operational rebuild scratch path unexpectedly exists: $rebuildWorktree" + } + $worktreeAdded = $false + $environmentNames = @( + 'PLATFORMIO_CORE_DIR', 'PLATFORMIO_BUILD_CACHE_DIR', + 'STACKCHAN_EXPECTED_BUILD_COMMIT', 'STACKCHAN_EXPECTED_BUILD_EPOCH' + ) + $savedEnvironment = @{} + foreach ($environmentName in $environmentNames) { + $savedEnvironment[$environmentName] = [Environment]::GetEnvironmentVariable( + $environmentName, [EnvironmentVariableTarget]::Process) + } + $rebuildRecords = New-Object System.Collections.Generic.List[object] + try { + Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'worktree', 'add', '--detach', + $rebuildWorktree, $ExpectedCommit) | Out-Null + if ($LASTEXITCODE -ne 0) { + throw 'Operational verifier could not create the independent rebuild worktree.' + } + $worktreeAdded = $true + $rebuildCommit = (Invoke-TrustedVerifierGit -Arguments @( + '-C', $rebuildWorktree, 'rev-parse', '--verify', 'HEAD')).Trim().ToLowerInvariant() + $rebuildDirty = @(Invoke-TrustedVerifierGit -Arguments @( + '-C', $rebuildWorktree, 'status', '--porcelain=v1', '--untracked-files=all')) + if ($LASTEXITCODE -ne 0 -or $rebuildCommit -cne $ExpectedCommit -or + $rebuildDirty.Count -ne 0) { + throw 'Operational verifier independent rebuild worktree is not the exact clean release commit.' + } + + $buildSpecs = @( + [ordered]@{ environment = 'stackchan'; packageDir = 'display_only'; coreDir = $defaultCoreDir }, + [ordered]@{ environment = 'stackchan_servo_calibration'; packageDir = 'servo_calibration'; coreDir = $defaultCoreDir }, + [ordered]@{ environment = 'stackchan_release_full'; packageDir = 'full_online'; coreDir = $releaseCoreDir } + ) + foreach ($spec in $buildSpecs) { + $environment = [string]$spec.environment + $env:PLATFORMIO_CORE_DIR = [string]$spec.coreDir + $env:PLATFORMIO_BUILD_CACHE_DIR = Join-Path $rebuildCacheRoot $environment + $env:STACKCHAN_EXPECTED_BUILD_COMMIT = $ExpectedCommit + $env:STACKCHAN_EXPECTED_BUILD_EPOCH = $ExpectedSourceEpoch + New-Item -ItemType Directory -Path $env:PLATFORMIO_BUILD_CACHE_DIR | Out-Null + foreach ($phase in @('clean', 'build')) { + $pioArguments = @('run', '-d', $rebuildWorktree, '-e', $environment) + if ($phase -eq 'clean') { $pioArguments += @('-t', 'clean') } + $phaseOutput = @(& $pioExecutable @pioArguments 2>&1) + $phaseExit = $LASTEXITCODE + $phaseOutput | Set-Content -LiteralPath ( + Join-Path $rebuildEvidenceRoot "$environment-$phase.log") -Encoding UTF8 + if ($phaseExit -ne 0) { + throw "Operational independent firmware rebuild failed: $environment/$phase (exit $phaseExit)." + } + } + foreach ($artifact in @('firmware.bin', 'firmware.elf', 'bootloader.bin', 'partitions.bin')) { + $rebuiltPath = Join-Path $rebuildWorktree ".pio/build/$environment/$artifact" + if (-not (Test-Path -LiteralPath $rebuiltPath -PathType Leaf)) { + throw "Operational independent firmware rebuild is missing $environment/$artifact." + } + $rebuiltItem = Get-Item -LiteralPath $rebuiltPath + $rebuiltHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $rebuiltPath).Hash.ToUpperInvariant() + $proofMatches = @($reproducibilityProof.cycleBArtifacts | Where-Object { + [string]$_.environment -ceq $environment -and [string]$_.artifact -ceq $artifact + }) + if ($proofMatches.Count -ne 1 -or + [long]$proofMatches[0].bytes -ne [long]$rebuiltItem.Length -or + [string]$proofMatches[0].sha256 -cne $rebuiltHash) { + throw "Operational independent rebuild does not match the two-cycle proof: $environment/$artifact." + } + $packageRelative = "firmware/$([string]$spec.packageDir)/$artifact" + $packageArtifact = Join-PackagePath $packageRelative + if (-not (Test-Path -LiteralPath $packageArtifact -PathType Leaf)) { + throw "Operational package is missing rebuilt firmware artifact: $packageRelative" + } + $packageItem = Get-Item -LiteralPath $packageArtifact + $packageHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $packageArtifact).Hash.ToUpperInvariant() + if ([long]$packageItem.Length -ne [long]$rebuiltItem.Length -or + $packageHash -cne $rebuiltHash) { + throw "Operational packaged firmware does not match the trusted rebuild: $packageRelative" + } + $rebuildRecords.Add([ordered]@{ + environment = $environment + artifact = $artifact + packageRelative = $packageRelative + bytes = [long]$rebuiltItem.Length + sha256 = $rebuiltHash + }) | Out-Null + } + $packageListOutput = @(& $pioExecutable 'pkg' 'list' '-d' $rebuildWorktree '-e' $environment 2>&1) + $packageListExit = $LASTEXITCODE + $packageListOutput | Set-Content -LiteralPath ( + Join-Path $rebuildEvidenceRoot "$environment-pkg-list.log") -Encoding UTF8 + if ($packageListExit -ne 0) { + throw "Operational independent dependency inventory failed: $environment (exit $packageListExit)." + } + $resolvedPackages = @(Convert-StackchanPioPackageList ($packageListOutput -join "`n")) + $expectedEnvironmentLock = $dependencyLock.environments.PSObject.Properties[$environment].Value + if ($null -eq $expectedEnvironmentLock) { + throw "Operational dependency lock is missing environment: $environment" + } + $actualDependencyIdentity = @($resolvedPackages | ForEach-Object { + "$([string]$_.kind)`0$([string]$_.name)`0$([string]$_.version)`0$([string]$_.required)" + }) + $expectedDependencyIdentity = @(@($expectedEnvironmentLock.resolvedPackages) | ForEach-Object { + "$([string]$_.kind)`0$([string]$_.name)`0$([string]$_.version)`0$([string]$_.required)" + }) + [Array]::Sort($actualDependencyIdentity, [StringComparer]::Ordinal) + [Array]::Sort($expectedDependencyIdentity, [StringComparer]::Ordinal) + if ($actualDependencyIdentity.Count -ne $expectedDependencyIdentity.Count -or + (Compare-Object -ReferenceObject $expectedDependencyIdentity ` + -DifferenceObject $actualDependencyIdentity -CaseSensitive).Count -ne 0) { + throw "Operational independent dependency inventory does not match package evidence: $environment" + } + $verbosePackageOutput = @(& $pioExecutable 'pkg' 'list' '-d' $rebuildWorktree '-e' $environment '-v' 2>&1) + $verbosePackageExit = $LASTEXITCODE + $verbosePackageOutput | Set-Content -LiteralPath ( + Join-Path $rebuildEvidenceRoot "$environment-pkg-list-verbose.log") -Encoding UTF8 + if ($verbosePackageExit -ne 0) { + throw "Operational independent verbose dependency inventory failed: $environment (exit $verbosePackageExit)." + } + $platformSource = Get-StackchanVerbosePlatformSource ` + -VerbosePackageList ($verbosePackageOutput -join "`n") -PlatformioCoreDir ([string]$spec.coreDir) + if ([string]$platformSource.sourceLeaf -cne [string]$expectedEnvironmentLock.platformSourceLeaf) { + throw "Operational independent platform source does not match package evidence: $environment" + } + } + $postBuildCommit = (Invoke-TrustedVerifierGit -Arguments @( + '-C', $rebuildWorktree, 'rev-parse', '--verify', 'HEAD')).Trim().ToLowerInvariant() + $postBuildDirty = @(Invoke-TrustedVerifierGit -Arguments @( + '-C', $rebuildWorktree, 'status', '--porcelain=v1', '--untracked-files=all')) + if ($LASTEXITCODE -ne 0 -or $postBuildCommit -cne $ExpectedCommit -or + $postBuildDirty.Count -ne 0) { + throw 'Operational independent rebuild changed its tracked or nonignored source identity.' + } + $successfulAttestation = [ordered]@{ + schema = 'stackchan.operational-firmware-rebuild.v1' + status = 'verified-independent-trusted-rebuild' + packageChecksumsSha256 = $packageChecksumsSha256 + dependencyLockSha256 = $dependencyLockSha256 + version = $Version + sourceCommit = $ExpectedCommit + sourceEpoch = $ExpectedSourceEpoch + platformioExecutable = $pioExecutable + platformioExecutableSha256 = $pioExecutableSha256 + platformioVersion = $pioVersion + defaultPlatformioCore = $defaultCoreDir + releasePlatformioCore = $releaseCoreDir + verifiedUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + records = @($rebuildRecords) + } + $successfulAttestationJson = $successfulAttestation | ConvertTo-Json -Depth 6 + $successfulAttestationJson | Set-Content -LiteralPath ( + Join-Path $rebuildEvidenceRoot 'operational_firmware_rebuild.json') -Encoding UTF8 + Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'worktree', 'remove', '--force', $rebuildWorktree) | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Operational verifier could not remove successful rebuild worktree: $rebuildWorktree" + } + $worktreeAdded = $false + $resolvedCache = (Resolve-Path -LiteralPath $rebuildCacheRoot).Path + $resolvedEvidence = (Resolve-Path -LiteralPath $rebuildEvidenceRoot).Path.TrimEnd('\', '/') + + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolvedCache.StartsWith( + $resolvedEvidence, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Operational verifier refuses unexpected rebuild-cache cleanup target: $resolvedCache" + } + Remove-Item -LiteralPath $resolvedCache -Recurse -Force + } catch { + $failure = $_ + $worktreePathExists = Test-Path -LiteralPath $rebuildWorktree -PathType Container + $worktreeList = @(Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'worktree', 'list', '--porcelain') 2>$null) + $worktreeAttached = $false + if ($LASTEXITCODE -eq 0) { + $worktreeAttached = @($worktreeList | Where-Object { + $_ -ceq "worktree $rebuildWorktree" + }).Count -eq 1 + } + $worktreePreserved = $worktreePathExists -and $worktreeAttached + [ordered]@{ + schema = 'stackchan.operational-firmware-rebuild-failure.v1' + status = if ($worktreePreserved) { 'failed-full-worktree-preserved' } else { 'failed-worktree-not-preserved' } + version = $Version + sourceCommit = $ExpectedCommit + sourceEpoch = $ExpectedSourceEpoch + rebuildWorktree = $rebuildWorktree + worktreePathExists = $worktreePathExists + worktreeStillAttached = $worktreeAttached + capturedUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + message = [string]$failure.Exception.Message + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath ( + Join-Path $rebuildEvidenceRoot 'FAILURE_EVIDENCE.json') -Encoding UTF8 + if ($worktreePreserved) { + Write-Warning "Operational firmware rebuild failed; exact worktree remains attached at $rebuildWorktree; evidence: $rebuildEvidenceRoot" + } + throw $failure.Exception + } finally { + foreach ($environmentName in $environmentNames) { + $savedValue = $savedEnvironment[$environmentName] + if ($null -eq $savedValue) { + Remove-Item ("Env:\" + $environmentName) -ErrorAction SilentlyContinue + } else { + Set-Item ("Env:\" + $environmentName) -Value $savedValue + } + } + } +} + function Assert-File { param( [string]$RelativePath, @@ -163,6 +1199,23 @@ function Assert-Mp3File { } } +$m0GovernanceTools = @( + "tools/firmware_reproducibility_proof.ps1", + "tools/test_firmware_reproducibility_proof_contract.ps1", + "tools/firmware_reproducibility_failure.ps1", + "tools/test_firmware_reproducibility_failure_contract.ps1", + "tools/release_source_binding.ps1", + "tools/release_dependency_evidence.ps1", + "tools/test_release_dependency_evidence_contract.ps1", + "tools/release_git_trust.ps1", + "tools/platformio_resolver.ps1", + "tools/test_release_package_verifier_trust_contract.ps1", + "tools/test_release_source_binding_contract.ps1", + "tools/release_zip_safety.ps1", + "tools/platformio_reproducible_build.py", + "tools/test_firmware_reproducible_build_contract.ps1" +) + $requiredFiles = @( "README.md", "AGENTS.md", @@ -201,6 +1254,10 @@ $requiredFiles = @( "docs/store-assets/play/feature-graphic-1024x500.png", "docs/store-assets/desktop/stackchan-alive.ico", "docs/store-assets/play/README.md", + "docs/store-assets/desktop/README.md", + "docs/store-assets/play/PRIVACY_POLICY_DEPLOYMENT.json", + "docs/store-assets/play/SCREENSHOT_CAPTURE_PLAN.md", + "docs/CI_ACCOUNT_BLOCK_EXCEPTION_TEMPLATE.json", "provenance/pages.yml", "docs/BRAIN_MODEL.md", "docs/COMPANION_CROSS_PLATFORM_PLAN.md", @@ -247,6 +1304,7 @@ $requiredFiles = @( "data/voice_rvc_base.yaml", "data/voice_rvc_base_metadata.json", "data/persona_index.json", + "data/commands.yaml", "bridge/README.md", "bridge/bridge_memory.py", "bridge/test_bridge_memory.py", @@ -258,6 +1316,10 @@ $requiredFiles = @( "bridge/memory_probe.py", "bridge/test_memory_probe.py", "bridge/memory_prefill_probe.py", + "bridge/conversation_harness.py", + "bridge/test_conversation_harness.py", + "bridge/litert_lm_stackchan_wrapper.py", + "bridge/test_litert_lm_stackchan_wrapper.py", "bridge/character_harness.py", "bridge/test_character_harness.py", "bridge/character_red_team.py", @@ -392,6 +1454,20 @@ $requiredFiles = @( "personas/glow/expressions.yaml", "personas/glow/earcons.yaml", "personas/glow/voice.yaml", + "personas/bolt/pack.yaml", + "personas/bolt/character.yaml", + "personas/bolt/prompt.md", + "personas/bolt/behavior.yaml", + "personas/bolt/expressions.yaml", + "personas/bolt/earcons.yaml", + "personas/bolt/voice.yaml", + "personas/pip/pack.yaml", + "personas/pip/character.yaml", + "personas/pip/prompt.md", + "personas/pip/behavior.yaml", + "personas/pip/expressions.yaml", + "personas/pip/earcons.yaml", + "personas/pip/voice.yaml", "persona_pack_status.json", "persona_prompt_assets.json", "character-red-team/CHARACTER_RED_TEAM.md", @@ -613,6 +1689,8 @@ $requiredFiles = @( "tools/generate_speech_envelope_sidecar.cmd", "tools/generate_speech_envelope_sidecar.ps1", "tools/generate_speech_envelope_sidecar.py", + "tools/platformio_reproducible_build.py", + "tools/test_firmware_reproducible_build_contract.ps1", "tools/platformio_generate_persona_assets.py", "tools/platformio_generate_voice_assets.py", "tools/verify_speech_envelope_sidecar.cmd", @@ -928,21 +2006,33 @@ $requiredFiles = @( "provenance/personas/glow/earcons.yaml", "provenance/personas/glow/voice.yaml" ) +$requiredFiles += $m0GovernanceTools foreach ($file in $requiredFiles) { Assert-File $file } -. (Join-PackagePath "tools/preview_python_resolver.ps1") +. (Join-Path $PSScriptRoot "preview_python_resolver.ps1") $bridgeRuntimePython = Get-StackchanPreviewPython -$bridgeRuntimeHelp = @( - & $bridgeRuntimePython -B (Join-PackagePath "bridge/lan_service.py") --help 2>&1 -) -if ($LASTEXITCODE -ne 0) { - throw "Packaged bridge runtime import smoke failed: $($bridgeRuntimeHelp -join ' ')" -} -if (($bridgeRuntimeHelp | Out-String) -notmatch "Run the local Stackchan P7 LAN WebSocket bridge") { - throw "Packaged bridge runtime help output is incomplete." +$bridgeRuntimeAstCheck = @' +import ast +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +ast.parse(path.read_text(encoding=sys.getdefaultencoding()), filename=str(path)) +'@ +$previousAstErrorPreference = $ErrorActionPreference +try { + $ErrorActionPreference = 'Continue' + $bridgeRuntimeHelp = @(& $bridgeRuntimePython -I -B -c $bridgeRuntimeAstCheck ` + (Join-PackagePath "bridge/lan_service.py") 2>&1) + $bridgeRuntimeAstExit = $LASTEXITCODE +} finally { + $ErrorActionPreference = $previousAstErrorPreference +} +if ($bridgeRuntimeAstExit -ne 0) { + throw "Packaged bridge runtime AST validation failed: $($bridgeRuntimeHelp -join ' ')" } $projectLicenseText = Get-Content -LiteralPath (Join-PackagePath "LICENSE") -Raw @@ -1024,7 +2114,7 @@ $retiredRvcSharePatterns = @( "stackchan_rvc_safety_neutral.mp3", "Voice Source Gate", "RVC Candidate Base", "candidate-pending-rights-review" ) -foreach ($pattern in @(".zip.sha256", "Get-FileHash", "ZIP SHA256", "Wait-LocalUrlReady", "PublicUrlReadyWaitSeconds", "Wait-PublicUrlReady", "Find-CloudflarePublicUrl", "publicUrlReady", "Stop-ExistingShare", "Remove-ShareRoot", "Test-TcpPortAvailable", "Test-SharePortAvailable", "Find-AvailableTcpPort", "Requested share port", "Get-LanShareUrls", "Get-ShareLanDiagnosticsForBind", "Test-ShareUrlsFromHost", "OPEN_LOCAL_SHARE.cmd", "Write-OpenLocalShareHelper", "OpenLocal", "Invoke-OpenLocalShare", "openLocalRequested", "LAN_TROUBLESHOOTING.md", "share_probe_report.json", "stackchan.share-probe-report.v1", "hostProbeResults", "Assert-BindAddressAvailable", "Same-network URL candidates", "loopbackUrl", "lanUrls", "ROLLOUT_STATUS.md", "ROLLOUT_STATUS.json", "Next Action", "rolloutNextAction", "rolloutNextCommand", "ActionsStatusPath", "Pending Promotion Gates", "promotionGateItems", "hardwareGates", "requiredEvidence", "Do not mark this release consumer-ready", "Face Phase A", "phase_a_idle_10s.gif", "phase_a_blink_filmstrip_50ms.png", "phase_a_unlabeled_expression_sheet.png", "Face Phase B", "phase_b_unlabeled_expression_sheet.png", "procedural eye-corner cuts", "two-curve open mouth", "authored L0 pose keys", "Face Phase C", "phase_c_idle_10s.gif", "autonomic blink", "saccade jumps", "breathing offset", "Face Phase D", "phase_d_idle_to_listen_filmstrip_50ms.png", "phase_d_think_to_speak_filmstrip_50ms.png", "phase_d_idle_to_sleep_filmstrip_50ms.png", "transition choreography", "anticipation", "channel lag", "Face Phase E", "phase_e_speech_reactive_6s.gif", "speech envelope sidecar", "viseme-lite", "tools/verify_face_phase_e.ps1", "Arrival-Day Evidence Loop", "RUN_SPEECH_MOUTH_DEMO.cmd", "RUN_SPEAK_ALL_INTENTS.cmd", "speak_all_intents_serial.log", "speech envelope mouth demo", "RUN_PROGRESS_CHECK.cmd", "RUN_EVIDENCE_VERIFY.cmd", "RUN_CONSUMER_PROMOTION_CHECK.cmd", "Hardware Audio Evidence", "AUDIO_REVIEW.md", "real-device speaker sample", "Generated source WAVs alone do not count", "Dependency Provenance", "dependency_lock.json", "Voice Source Gate", "VOICE_SOURCE_PROVENANCE_TEMPLATE.md", "voice_source_provenance.yaml", "RVC Candidate Base", "voice_rvc_base.yaml", "candidate-pending-rights-review", "tools/verify_rvc_voice_base.ps1", "RVC Voice Auditions", "stackchan_rvc_neutral.wav", "stackchan_rvc_bright_robot.wav", "stackchan_rvc_bright_robot_less_static.wav", "voice/rvc/README.md", "RVC MP3 Readme", "RVC_AUDITION.html", "RVC_AUDITIONS.md", "stackchan_rvc_bright_robot.mp3", "stackchan_rvc_thinking_neutral.mp3", "stackchan_rvc_safety_neutral.mp3")) { +foreach ($pattern in @(".zip.sha256", "Get-FileHash", "ZIP SHA256", "Wait-LocalUrlReady", "PublicUrlReadyWaitSeconds", "Wait-PublicUrlReady", "Find-CloudflarePublicUrl", "publicUrlReady", "Stop-ExistingShare", "Remove-ShareRoot", "Test-TcpPortAvailable", "Test-SharePortAvailable", "Find-AvailableTcpPort", "Requested share port", "Get-LanShareUrls", "Get-ShareLanDiagnosticsForBind", "Test-ShareUrlsFromHost", "OPEN_LOCAL_SHARE.cmd", "Write-OpenLocalShareHelper", "OpenLocal", "Invoke-OpenLocalShare", "openLocalRequested", "LAN_TROUBLESHOOTING.md", "share_probe_report.json", "stackchan.share-probe-report.v1", "hostProbeResults", "Assert-BindAddressAvailable", "Same-network URL candidates", "loopbackUrl", "lanUrls", "ROLLOUT_STATUS.json", "Next Action", "rolloutNextAction", "rolloutNextCommand", "Pending Promotion Gates", "promotionGateItems", "hardwareGates", "requiredEvidence", "Do not mark this release consumer-ready", "Face Phase A", "phase_a_idle_10s.gif", "phase_a_blink_filmstrip_50ms.png", "phase_a_unlabeled_expression_sheet.png", "Face Phase B", "phase_b_unlabeled_expression_sheet.png", "procedural eye-corner cuts", "two-curve open mouth", "authored L0 pose keys", "Face Phase C", "phase_c_idle_10s.gif", "autonomic blink", "saccade jumps", "breathing offset", "Face Phase D", "phase_d_idle_to_listen_filmstrip_50ms.png", "phase_d_think_to_speak_filmstrip_50ms.png", "phase_d_idle_to_sleep_filmstrip_50ms.png", "transition choreography", "anticipation", "channel lag", "Face Phase E", "phase_e_speech_reactive_6s.gif", "speech envelope sidecar", "viseme-lite", "tools/verify_face_phase_e.ps1", "Arrival-Day Evidence Loop", "RUN_SPEECH_MOUTH_DEMO.cmd", "RUN_SPEAK_ALL_INTENTS.cmd", "speak_all_intents_serial.log", "speech envelope mouth demo", "RUN_PROGRESS_CHECK.cmd", "RUN_EVIDENCE_VERIFY.cmd", "RUN_CONSUMER_PROMOTION_CHECK.cmd", "Hardware Audio Evidence", "AUDIO_REVIEW.md", "real-device speaker sample", "Generated source WAVs alone do not count", "Dependency Provenance", "dependency_lock.json", "Voice Source Gate", "VOICE_SOURCE_PROVENANCE_TEMPLATE.md", "voice_source_provenance.yaml", "RVC Candidate Base", "voice_rvc_base.yaml", "candidate-pending-rights-review", "tools/verify_rvc_voice_base.ps1", "RVC Voice Auditions", "stackchan_rvc_neutral.wav", "stackchan_rvc_bright_robot.wav", "stackchan_rvc_bright_robot_less_static.wav", "voice/rvc/README.md", "RVC MP3 Readme", "RVC_AUDITION.html", "RVC_AUDITIONS.md", "stackchan_rvc_bright_robot.mp3", "stackchan_rvc_thinking_neutral.mp3", "stackchan_rvc_safety_neutral.mp3")) { if ($retiredRvcSharePatterns -contains $pattern) { continue } if ($shareGeneratorText -notmatch [regex]::Escape($pattern)) { throw "tools/share_release.ps1 missing required share generation logic: $pattern" @@ -1035,6 +2125,27 @@ foreach ($pattern in @("Production RVC Voice", "model.pth", "model.index", "prod throw "tools/share_release.ps1 missing production RVC marker: $pattern" } } +foreach ($pattern in @( + "verify_release_package.ps1", + "RequireReleaseEligible", + "New-VerifiedShareSnapshot", + "Remove-VerifiedShareSnapshot", + "Expand-StackchanReleaseZipSafely", + "Missing release ZIP SHA-256 sidecar" +)) { + if ($shareGeneratorText -notmatch [regex]::Escape($pattern)) { + throw "tools/share_release.ps1 missing release-eligibility trust logic: $pattern" + } +} +foreach ($mutableShareExporter in @('export_github_actions_status.ps1', 'export_rollout_status.ps1')) { + if ($shareGeneratorText.Contains($mutableShareExporter)) { + throw "tools/share_release.ps1 must not mix mutable exporter output into the verified share: $mutableShareExporter" + } +} +if ($shareGeneratorText.Contains('Join-Path $packageRoot "tools/') -or + $shareGeneratorText.Contains("Join-Path `$packageRoot 'tools/")) { + throw "tools/share_release.ps1 executes package-contained tools" +} $shareVerifierText = Get-Content -LiteralPath (Join-PackagePath "tools/verify_share_release.ps1") -Raw $retiredRvcShareVerifierPatterns = @( @@ -1283,7 +2394,7 @@ foreach ($pattern in @("stackchan.release-audit.v1", "verify_published_release.p } $releaseWorkflowText = Get-Content -LiteralPath (Join-PackagePath "provenance/release.yml") -Raw -foreach ($pattern in @("release_asset_contract.ps1", "verify_release_asset_contract.ps1", "Get-ReleaseFinalAssetEntries", "Get-ReleaseCompanionAssetEntries", "FirmwareAssetRoot `$stageDir", "FirmwareAssetPathMode Stage", "workflow-assets-", "companion-android-release", "companion-android-emulator-smoke", "companion-desktop-release", "gradle/actions/setup-gradle@v6", "check_companion_release_version.ps1", "check_android_play_release_readiness.ps1 -RequireUploadSigning -Json", "check_desktop_release_signing_readiness.ps1", "Validate production desktop signing credentials", "RequireNativeToolchain", "ValidateAppleNotaryCredentials", "STACKCHAN_ANDROID_KEYSTORE_B64", "STACKCHAN_WINDOWS_PFX_B64", "STACKCHAN_MACOS_CERTIFICATE_B64", ":app-desktop:notarizeDmg", "actions/attest@v4", "media/voice/*", "release_assets.json", "test_android_emulator_launch.ps1", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "prepare_desktop_python_runtime.ps1", "test_desktop_package_launch.ps1", "export_desktop_package_evidence.ps1", "RequireInstallerPayload", "RequireLaunchEvidence", "RequireDistributionTrust", "RequireUploadSigning", "RequireDesktopPackageEvidence", "RequireDesktopDistributionTrust", '$releaseAssetPaths', '@releaseAssetPaths')) { +foreach ($pattern in @("release_asset_contract.ps1", "verify_release_asset_contract.ps1", "Get-ReleaseFinalAssetEntries", "Get-ReleaseCompanionAssetEntries", "FirmwareAssetRoot `$stageDir", "FirmwareAssetPathMode Stage", "workflow-publication-", "RequireReleaseEligible", "Expand-StackchanReleaseZipSafely", "companion-android-release", "companion-android-emulator-smoke", "companion-desktop-release", "gradle/actions/setup-gradle@v6", "check_companion_release_version.ps1", "check_android_play_release_readiness.ps1 -RequireUploadSigning -Json", "check_desktop_release_signing_readiness.ps1", "Validate production desktop signing credentials", "RequireNativeToolchain", "ValidateAppleNotaryCredentials", "STACKCHAN_ANDROID_KEYSTORE_B64", "STACKCHAN_WINDOWS_PFX_B64", "STACKCHAN_MACOS_CERTIFICATE_B64", ":app-desktop:notarizeDmg", "actions/attest@v4", "media/voice/*", "release_assets.json", "test_android_emulator_launch.ps1", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "prepare_desktop_python_runtime.ps1", "test_desktop_package_launch.ps1", "export_desktop_package_evidence.ps1", "RequireInstallerPayload", "RequireLaunchEvidence", "RequireDistributionTrust", "RequireUploadSigning", "RequireDesktopPackageEvidence", "RequireDesktopDistributionTrust", '$releaseAssetPaths', '@releaseAssetPaths')) { if ($releaseWorkflowText -notmatch [regex]::Escape($pattern)) { throw "provenance/release.yml missing release asset contract upload logic: $pattern" } @@ -1339,14 +2450,14 @@ foreach ($pattern in @("Install bridge test dependencies", "sudo apt-get install throw "provenance/firmware.yml missing LiteRT-LM contract smoke workflow support: $pattern" } } -foreach ($pattern in @("workflow_dispatch", "github.event_name != 'workflow_dispatch'", "github.event_name == 'workflow_dispatch'", "STACKCHAN_CI_SOURCE_SHA", "github.event.pull_request.head.sha", "companion-platform-builds", "companion-android-emulator-smoke", "python-version: `"3.12`"", "gradle/actions/setup-gradle@v6", "test_android_emulator_release_evidence_contract.ps1", "test_desktop_package_evidence_contract.ps1", "test_desktop_release_signing_readiness_contract.ps1", "test_release_credential_hygiene_contract.ps1", "Run release credential hygiene contract", "test_companion_ci_candidate_contract.ps1", "Run exact-source companion CI candidate contract", "test_desktop_target_install_evidence_contract.ps1", "test_desktop_package_launch.ps1", "prepare_desktop_python_runtime.ps1", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT", "export_desktop_package_evidence.ps1", "RequireInstallerPayload", "RequireLaunchEvidence", "linux-package-evidence.json", "macos-package-evidence.json", "windows-package-evidence.json", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "DesktopPackageEvidenceRoot", "RequireDesktopPackageEvidence")) { +foreach ($pattern in @("workflow_dispatch", "github.event_name != 'workflow_dispatch'", "github.event_name == 'workflow_dispatch'", "STACKCHAN_CI_SOURCE_SHA", "github.event.pull_request.head.sha", "companion-platform-builds", "companion-android-emulator-smoke", "python-version: `"3.12.10`"", "Get-Command python -CommandType Application -ErrorAction Stop", "gradle/actions/setup-gradle@v6", "test_android_emulator_release_evidence_contract.ps1", "test_desktop_package_evidence_contract.ps1", "test_desktop_release_signing_readiness_contract.ps1", "test_release_credential_hygiene_contract.ps1", "Run release credential hygiene contract", "test_companion_ci_candidate_contract.ps1", "Run exact-source companion CI candidate contract", "test_desktop_target_install_evidence_contract.ps1", "test_desktop_package_launch.ps1", "prepare_desktop_python_runtime.ps1", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT", "export_desktop_package_evidence.ps1", "RequireInstallerPayload", "RequireLaunchEvidence", "linux-package-evidence.json", "macos-package-evidence.json", "windows-package-evidence.json", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "DesktopPackageEvidenceRoot", "RequireDesktopPackageEvidence")) { if ($firmwareWorkflowText -notmatch [regex]::Escape($pattern)) { throw "provenance/firmware.yml missing native desktop package/runtime PR evidence support: $pattern" } } $publisherText = Get-Content -LiteralPath (Join-PackagePath "tools/publish_release.ps1") -Raw -foreach ($pattern in @("release_asset_contract.ps1", "verify_release_asset_contract.ps1", "Get-ReleaseBaseAssetEntries", "Get-ReleaseFinalAssetEntries", "Export-ActionsStatusWithRetry", "Update-ReleaseArchive", "Clear-TransientPackageOutput", "output/voice_auditions/VOICE_AUDITION_INDEX.html", '$baseReleaseAssets', '$finalReleaseAssets', '@baseReleaseAssets', '@finalReleaseAssets', "Verify finalized release asset contract before upload", "FirmwareAssetRoot `$stageDir", "FirmwareAssetPathMode Stage", "SHA256SUMS.txt", "--clobber", "PushCurrentBranch", "Assert-CurrentBranchPublishedAtCommit", "git ls-remote", "Firmware workflow can be observed", "Push the branch first or pass -PushCurrentBranch", "audit_published_release.ps1", "-UploadToRelease")) { +foreach ($pattern in @("release_asset_contract.ps1", "verify_release_asset_contract.ps1", "Get-ReleaseFinalAssetEntries", "Export-ActionsStatusWithRetry", "Update-ReleaseArchive", "Clear-TransientPackageOutput", "output/voice_auditions/VOICE_AUDITION_INDEX.html", "New-VerifiedPublicationSnapshot", "Remove-VerifiedPublicationSnapshot", "RequireReleaseEligible", "Expand-StackchanReleaseZipSafely", '$finalReleaseAssets', '@finalReleaseAssets', "Verify finalized release asset contract against the safe extraction of the exact verified ZIP", "FirmwareAssetRoot `$stageDir", "FirmwareAssetPathMode Stage", "SHA256SUMS.txt", "--clobber", "PushCurrentBranch", "Assert-CurrentBranchPublishedAtCommit", "Assert-RemoteTagPublishedAtCommit", "Real publication requires explicit -Repo owner/name", "Firmware workflow can be observed", "Push the branch first or pass -PushCurrentBranch", "audit_published_release.ps1", "-UploadToRelease")) { if ($publisherText -notmatch [regex]::Escape($pattern)) { throw "tools/publish_release.ps1 missing required finalized Actions status publish logic: $pattern" } @@ -1938,6 +3049,20 @@ foreach ($pattern in @("stackchan.rollout-status.v1", "ROLLOUT_STATUS.md", "ROLL throw "tools/export_rollout_status.ps1 missing rollout status export logic: $pattern" } } +foreach ($pattern in @( + "verify_release_package.ps1", + "RequireReleaseEligible", + "release-package-eligibility", + "Expand-StackchanReleaseZipSafely" +)) { + if ($rolloutStatusExporterText -notmatch [regex]::Escape($pattern)) { + throw "tools/export_rollout_status.ps1 missing release-eligibility trust logic: $pattern" + } +} +if ($rolloutStatusExporterText.Contains('$ExpectedCommit = [string]$manifest.commit') -or + $rolloutStatusExporterText.Contains('$Version = [string]$manifest.version')) { + throw "tools/export_rollout_status.ps1 replaces trusted caller authority with package manifest values" +} $voiceToolsSetupText = Get-Content -LiteralPath (Join-PackagePath "tools/setup_voice_tools.ps1") -Raw foreach ($pattern in @("eSpeak-NG.eSpeak-NG", "ChrisBagwell.SoX", "ContinueOnInstallFailure", "RenderEspeakSamples", "render_voice_samples.ps1", "-Engine espeak", "verify_voice_samples.ps1", "stackchan.voice-tools-status.v1", "installFailures")) { @@ -2893,18 +4018,18 @@ Assert-Bytes "media/voice/stackchan_spark_thinking.wav" ([byte[]](0x52, 0x49, 0x Assert-Bytes "media/voice/stackchan_spark_safety.wav" ([byte[]](0x52, 0x49, 0x46, 0x46)) Assert-Bytes "media/voice/stackchan_spark_audition_warm_slow_greeting.wav" ([byte[]](0x52, 0x49, 0x46, 0x46)) Assert-Bytes "media/voice/stackchan_spark_audition_bright_robot_greeting.wav" ([byte[]](0x52, 0x49, 0x46, 0x46)) -& (Join-PackagePath "tools/verify_voice_samples.ps1") -VoiceRoot (Join-PackagePath "media/voice") -& (Join-PackagePath "tools/verify_tracked_rvc_assets.ps1") -VoiceRoot (Join-PackagePath "media/voice/rvc") +& (Join-Path $PSScriptRoot "verify_voice_samples.ps1") -VoiceRoot (Join-PackagePath "media/voice") +& (Join-Path $PSScriptRoot "verify_tracked_rvc_assets.ps1") -VoiceRoot (Join-PackagePath "media/voice/rvc") foreach ($asset in @($personaPromptAssets.assets)) { - & (Join-PackagePath "tools/verify_speech_envelope_sidecar.ps1") -Path (Join-PackagePath ([string]$asset.sidecar_path)) + & (Join-Path $PSScriptRoot "verify_speech_envelope_sidecar.ps1") -Path (Join-PackagePath ([string]$asset.sidecar_path)) } & (Join-Path $PSScriptRoot "verify_preview_media.ps1") -MediaRoot (Join-PackagePath "media") -& (Join-PackagePath "tools/verify_face_phase_a.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") -& (Join-PackagePath "tools/verify_face_phase_b.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") -& (Join-PackagePath "tools/verify_face_phase_c.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") -& (Join-PackagePath "tools/verify_face_phase_d.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") -& (Join-PackagePath "tools/verify_face_phase_e.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") +& (Join-Path $PSScriptRoot "verify_face_phase_a.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") +& (Join-Path $PSScriptRoot "verify_face_phase_b.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") +& (Join-Path $PSScriptRoot "verify_face_phase_c.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") +& (Join-Path $PSScriptRoot "verify_face_phase_d.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") +& (Join-Path $PSScriptRoot "verify_face_phase_e.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") $manifestPath = Join-PackagePath "release_manifest.json" $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json @@ -2918,7 +4043,7 @@ $contractZipPath = if ([string]::IsNullOrWhiteSpace($ZipPath)) { } else { $ZipPath } -& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-PackagePath "tools/verify_release_asset_contract.ps1") ` +& $verifierPowerShellExecutable -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "verify_release_asset_contract.ps1") ` -Version $Version ` -PackageRoot $packageRootPath ` -ZipPath $contractZipPath ` @@ -2951,12 +4076,207 @@ if (-not ($envs -contains "stackchan") -or throw "Manifest missing expected environments" } -if ($manifest.status -notmatch "test-ready prerelease" -or $manifest.status -notmatch "hardware validation pending") { +$firmwareReproducibility = $manifest.firmwareReproducibility +if ([bool]$manifest.diagnosticPackage) { + if (-not $Version.StartsWith("diagnostic-", [System.StringComparison]::Ordinal) -or + $manifest.commitRole -ne "package-source-only-not-firmware-identity" -or + $manifest.packageSourceIsolationPolicy -ne "diagnostic-mutable-source-unbound" -or + $null -ne $manifest.packageSourceCommit -or + $null -ne $manifest.packageSourceEpoch -or + $manifest.releaseEligible -ne $false -or + $manifest.hardwareValidationEligible -ne $false -or + $manifest.distributionEligible -ne $false -or + $manifest.flashEligible -ne $false -or + $firmwareReproducibility.mechanism -ne "unbound-preexisting-artifacts" -or + $null -ne $firmwareReproducibility.sourceCommit -or + $null -ne $firmwareReproducibility.sourceEpoch -or + $firmwareReproducibility.hookCoverage -ne "not-run-skip-build" -or + $firmwareReproducibility.releaseOverridePolicy -ne "diagnostic-artifacts-unbound" -or + $firmwareReproducibility.scope -ne "unknown/unbound-skip-build; copied pre-existing outputs whose source identity is not established" -or + $manifest.servoDefault -ne "boot and motion state unverified; copied firmware identity is unknown; do not flash") { + throw "Diagnostic manifest must leave copied firmware identity unbound and forbid release and hardware use" + } +} else { + if ($null -eq $firmwareReproducibility -or + $manifest.commitRole -ne "package-and-firmware-source" -or + $manifest.packageSourceIsolationPolicy -ne "detached-clean-worktree-pinned-to-package-commit" -or + $manifest.packageSourceCommit -cne $ExpectedCommit -or + [string]$manifest.packageSourceEpoch -cne [string]$firmwareReproducibility.sourceEpoch -or + $manifest.releaseEligible -ne $true -or + $manifest.hardwareValidationEligible -ne $true -or + $manifest.distributionEligible -ne $true -or + $manifest.flashEligible -ne $true -or + $firmwareReproducibility.mechanism -ne "git-commit-epoch-builtins-v1" -or + $firmwareReproducibility.sourceCommit -ne $ExpectedCommit -or + [string]$firmwareReproducibility.sourceEpoch -notmatch '^[0-9]{1,12}$' -or + $firmwareReproducibility.hook -ne "tools/platformio_reproducible_build.py" -or + $firmwareReproducibility.contract -ne "tools/test_firmware_reproducible_build_contract.ps1" -or + $firmwareReproducibility.hookCoverage -ne "exactly-one-effective-hook" -or + $firmwareReproducibility.releaseOverridePolicy -ne "release-overrides-fail-closed" -or + $firmwareReproducibility.scope -ne "same host/core paths and clean commit across distinct prefix-mapped project roots, canonical recorded PlatformIO toolchain/configuration, and no listed ambient build overrides") { + throw "Manifest firmwareReproducibility provenance is missing or invalid" + } + if (-not [string]::IsNullOrWhiteSpace($ExpectedSourceEpoch) -and + [string]$firmwareReproducibility.sourceEpoch -cne $ExpectedSourceEpoch) { + throw "Manifest firmware source epoch does not match ExpectedSourceEpoch" + } +} +$includedTools = @($manifest.includedTools) +foreach ($governanceTool in $m0GovernanceTools) { + if ($includedTools -notcontains $governanceTool) { + throw "Manifest includedTools is missing M0 governance input: $governanceTool" + } +} +Assert-OperationalPackageGitBindings -Manifest $manifest + +$reproducibilityHook = "pre:tools/platformio_reproducible_build.py" +$reproducibilityRootEnvironments = @( + "stackchan", + "stackchan_servo_calibration", + "stackchan_wifi_uplink", + "stackchan_wake_sr_probe", + "stackchan_wake_mww_probe", + "stackchan_wake_mww_uplink", + "stackchan_sd_provisioner", + "stackchan_wake_sr_direct_probe", + "stackchan_wake_sr_afe_lite", + "stackchan_full_online" +) +if (([regex]::Matches( + $platformioText, + "(?m)^\s+$([regex]::Escape($reproducibilityHook))\s*$")).Count -ne 10) { + throw "Packaged platformio.ini must contain exactly ten raw reproducibility hooks" +} +if ($platformioText -match '(?mi)^\s*build_cache_dir\s*=') { + throw "Packaged platformio.ini must not configure a persistent firmware build cache" +} +foreach ($environment in $reproducibilityRootEnvironments) { + $block = [regex]::Match( + $platformioText, + "(?ms)^\[env:$([regex]::Escape($environment))\]\s*(.*?)(?=^\[env:|\z)" + ).Value + $extraScripts = [regex]::Match( + $block, + '(?ms)^extra_scripts\s*=\s*\r?\n(?(?:[ \t]+[^\r\n]+\r?\n?)*)') + $entries = if ($extraScripts.Success) { + @($extraScripts.Groups['entries'].Value -split '\r?\n' | + ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } else { @() } + if ($entries.Count -eq 0 -or $entries[0] -cne $reproducibilityHook -or + ([regex]::Matches($block, [regex]::Escape($reproducibilityHook))).Count -ne 1) { + throw "Packaged platformio.ini has invalid reproducibility hook wiring for $environment" + } +} +foreach ($environment in @("stackchan_wifi", "native_logic")) { + $block = [regex]::Match( + $platformioText, + "(?ms)^\[env:$([regex]::Escape($environment))\]\s*(.*?)(?=^\[env:|\z)" + ).Value + if ($block.Contains($reproducibilityHook)) { + throw "Packaged platformio.ini must not redeclare the reproducibility hook in $environment" + } +} + +$reproducibilityHookText = Get-Content -LiteralPath ( + Join-PackagePath "tools/platformio_reproducible_build.py") -Raw +foreach ($pattern in @( + 'datetime.fromtimestamp(epoch, timezone.utc)', + '_MONTHS', + '--untracked-files=all', + '--show-toplevel', + 'not key.upper().startswith("GIT_")', + 'STACKCHAN_BUILD_EPOCH', + 'SOURCE_DATE_EPOCH', + 'STACKCHAN_BUILD_STAMP', + 'STACKCHAN_DISABLE_REPRODUCIBLE_BUILD', + 'GIT_DIR', + 'GIT_NO_REPLACE_OBJECTS', + 'STACKCHAN_EXPECTED_BUILD_COMMIT', + 'STACKCHAN_EXPECTED_BUILD_EPOCH', + 'AppendUnique', + '-D__DATE__=', + '-D__TIME__=', + '-ffile-prefix-map=', + '_CANONICAL_DEBUG_ROOT', + '_CANONICAL_CORE_ROOT', + 'PROJECT_CORE_DIR' +)) { + if (-not $reproducibilityHookText.Contains($pattern)) { + throw "Packaged reproducibility hook is missing fail-closed mechanism: $pattern" + } +} + +$reproducibilityContractText = Get-Content -LiteralPath ( + Join-PackagePath "tools/test_firmware_reproducible_build_contract.ps1") -Raw +foreach ($pattern in @( + 'platformio_resolver.ps1', + 'Invoke-StackchanPlatformio project config --json-output', + 'effective-hook-count', + 'hook-behavior-failed', + 'package-contract-order' +)) { + if (-not $reproducibilityContractText.Contains($pattern)) { + throw "Packaged reproducibility contract is missing required governance: $pattern" + } +} + +$packagedReleaseBuilderText = Get-Content -LiteralPath ( + Join-PackagePath "tools/package_release.ps1") -Raw +$packagedFailureHelperText = Get-Content -LiteralPath ( + Join-PackagePath "tools/firmware_reproducibility_failure.ps1") -Raw +$packagedReleaseGovernanceText = $packagedReleaseBuilderText + "`n" + $packagedFailureHelperText +$packagePathWorkIndex = $packagedReleaseBuilderText.IndexOf('$physicalRepoRoot =') +foreach ($overrideName in @( + "PLATFORMIO_BUILD_FLAGS", "STACKCHAN_BUILD_EPOCH", "SOURCE_DATE_EPOCH", + "STACKCHAN_BUILD_STAMP", "STACKCHAN_DISABLE_REPRODUCIBLE_BUILD", + "STACKCHAN_PERSONA", "PLATFORMIO_EXE", "PLATFORMIO_CORE_DIR", "PLATFORMIO_BUILD_CACHE_DIR", "GIT_DIR", + "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY" +)) { + $overrideIndex = $packagedReleaseBuilderText.IndexOf('"' + $overrideName + '"') + if ($overrideIndex -lt 0 -or $packagePathWorkIndex -lt 0 -or $overrideIndex -gt $packagePathWorkIndex) { + throw "Packaged release builder lacks an early fail-closed override guard: $overrideName" + } +} +foreach ($pattern in @( + 'Test-Path ("Env:\" + $releaseOverrideName)', + '$_.Name -like "PLATFORMIO_*"', + 'verified-two-clean-cycles', + 'minimumClockBoundarySeconds = 65', + "-CycleName 'cycle-a'", + "-CycleName 'cycle-b'", + 'isolated-empty-per-cycle-environment', + 'Assert-ReleaseSourceIdentity', + 'output/private/reproducibility-failures', + 'stackchan.firmware-reproducibility-failure.v2', + 'failed-full-worktree-preserved', + '-SkipBuild is diagnostic-only and requires -AllowDirty', + 'DIAGNOSTIC_PACKAGE_DO_NOT_FLASH.txt' +)) { + if (-not $packagedReleaseGovernanceText.Contains($pattern)) { + throw "Packaged release builder is missing reproducibility enforcement: $pattern" + } +} + +$reproducibilityProof = $firmwareReproducibility.proof +Assert-StackchanFirmwareReproducibilityProof ` + -Proof $reproducibilityProof ` + -DiagnosticPackage ([bool]$manifest.diagnosticPackage) ` + -AllowDirtyPackage ([bool]$AllowDirtyPackage) ` + -ManifestCommit ([string]$manifest.commit) ` + -SourceEpoch ([string]$firmwareReproducibility.sourceEpoch) ` + -ManifestStatus ([string]$manifest.status) ` + -PackageRoot $packageRootPath + +if (-not [bool]$manifest.diagnosticPackage -and + ($manifest.status -notmatch "test-ready prerelease" -or $manifest.status -notmatch "hardware validation pending")) { throw "Manifest status must identify a test-ready prerelease with hardware validation pending" } +if ($manifest.dirty -and -not [bool]$manifest.diagnosticPackage) { + throw "A dirty package can never be release-eligible" +} if ($manifest.dirty -and -not $AllowDirtyPackage) { - throw "Release package manifest reports a dirty source worktree" + throw "Diagnostic package manifest reports a dirty package-source worktree" } if ($manifest.dependencyReport -ne "DEPENDENCIES.md") { @@ -3470,6 +4790,21 @@ if ($dependencyLock.version -ne $Version) { if ($dependencyLock.commit -ne $ExpectedCommit) { throw "dependency_lock.json commit mismatch: expected $ExpectedCommit, got $($dependencyLock.commit)" } +if ([bool]$manifest.diagnosticPackage) { + if ($dependencyLock.dependencyEvidencePolicy -ne "diagnostic-current-state-unbound" -or + $null -ne $dependencyLock.dependencySourceCommit -or + $null -ne $dependencyLock.dependencySourceEpoch -or + $dependenciesText -notmatch "unbound to the copied firmware") { + throw "Diagnostic dependency evidence must remain explicitly unbound" + } +} else { + if ($dependencyLock.dependencyEvidencePolicy -ne "exact-cycle-b-build-snapshot" -or + $dependencyLock.dependencySourceCommit -cne $ExpectedCommit -or + [string]$dependencyLock.dependencySourceEpoch -cne [string]$firmwareReproducibility.sourceEpoch -or + $dependenciesText -notmatch "exact cycle-B dependency inventory") { + throw "Release dependency evidence is not bound to the proved cycle-B build" + } +} if ($dependencyLock.platformioCore -notmatch "PlatformIO Core, version 6\.1\.19") { throw "dependency_lock.json has unexpected PlatformIO version: $($dependencyLock.platformioCore)" @@ -3550,6 +4885,28 @@ foreach ($envName in @("stackchan", "stackchan_servo_calibration", "stackchan_re throw "dependency_lock.json framework mismatch for $envName`: $($envLock.framework)" } $packages = @($envLock.resolvedPackages) + $platformSourceLeaf = [string]$envLock.platformSourceLeaf + $expectedPlatformSourceLeaf = if ($envName -eq 'stackchan_release_full') { + 'espressif32' + } else { + 'espressif32@7.0.1' + } + if ($platformSourceLeaf -cne $expectedPlatformSourceLeaf) { + throw "dependency_lock.json platformSourceLeaf mismatch for $envName`: $platformSourceLeaf" + } + $resolvedPackageNames = @( + $packages | Where-Object { $_.kind -eq 'package' } | + ForEach-Object { [string]$_.name } | Sort-Object -Unique) + $corePackageNames = @($envLock.corePackageNames) + if ($corePackageNames.Count -eq 0) { + throw "dependency_lock.json missing corePackageNames for $envName" + } + foreach ($corePackageName in $corePackageNames) { + if ([string]$corePackageName -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or + $resolvedPackageNames -cnotcontains [string]$corePackageName) { + throw "dependency_lock.json has unbound core package for $envName`: $corePackageName" + } + } if ($envName -eq "stackchan_release_full") { if ($envLock.platform -ne "pioarduino/platform-espressif32@55.03.36") { throw "dependency_lock.json platform mismatch for $envName`: $($envLock.platform)" @@ -3694,8 +5051,23 @@ foreach ($entry in $thirdPartyLicenseIndex) { } $indexedThirdPartyPaths += $relative } +foreach ($envName in @('stackchan', 'stackchan_servo_calibration', 'stackchan_release_full')) { + Assert-StackchanCorePackageEvidenceAllowlisted ` + -Environment $envName ` + -CorePackageNames @($dependencyLock.environments.$envName.corePackageNames) ` + -IndexedThirdPartyPaths @($indexedThirdPartyPaths) + $platformSourceLeaf = [string]$dependencyLock.environments.$envName.platformSourceLeaf + foreach ($relative in @($indexedThirdPartyPaths)) { + if ($relative -match ('^' + [regex]::Escape($envName) + '/platform/([^/]+)/') -and + $Matches[1] -cne $platformSourceLeaf) { + throw "Unlisted PlatformIO platform evidence for $envName`: $($Matches[1])" + } + } +} $requiredThirdPartyPatterns = @( + '^stackchan/platform/espressif32@7\.0\.1/LICENSE$', + '^stackchan_servo_calibration/platform/espressif32@7\.0\.1/LICENSE$', '^stackchan_release_full/platform/espressif32/LICENSE$', '^stackchan_release_full/packages/framework-arduinoespressif32/package\.json$', '^stackchan_release_full/packages/framework-arduinoespressif32-libs/package\.json$', @@ -3718,20 +5090,34 @@ $releaseNotes = Get-Content -LiteralPath (Join-PackagePath "RELEASE_NOTES.md") - if ($releaseNotes -notmatch [regex]::Escape($ExpectedCommit)) { throw "RELEASE_NOTES.md missing expected commit" } -if ($releaseNotes -notmatch "Hardware validation is still required") { - throw "RELEASE_NOTES.md must state that hardware validation is still required" -} -foreach ($pattern in @("model.pth", "model.index", "private paired reference robot", "exact-image evidence", "does not validate another assembled unit")) { - if ($releaseNotes -notmatch [regex]::Escape($pattern)) { - throw "RELEASE_NOTES.md missing reference-versus-recipient validation boundary: $pattern" +if ([bool]$manifest.diagnosticPackage) { + foreach ($pattern in @( + "Diagnostic Package", "-SkipBuild -AllowDirty", "not a prerelease candidate", + "must not be flashed", "must not be flashed, run on hardware, or used as qualification evidence" + )) { + if ($releaseNotes -notmatch [regex]::Escape($pattern)) { + throw "Diagnostic RELEASE_NOTES.md missing required prohibition: $pattern" + } } -} -if ($releaseNotes -notmatch "READINESS_REPORT.md") { - throw "RELEASE_NOTES.md missing readiness report reference" -} -foreach ($pattern in @("No-hardware simulation quick check", "tools/run_prearrival_sim_check.cmd", "PREARRIVAL_SIM_CHECK.md/json", "nested LAN smoke report", "tools/run_prearrival_sim_check.cmd -RunModelBenchmark -Json", "model-benchmark-candidate", "tools/run_lan_smoke.cmd", "LAN_SMOKE.md/json", "run_litert_lm_smoke.cmd", "LITERT_LM_SMOKE.md/json", "summary.candidate_gate", "recommended_profile", "tools/run_character_red_team.cmd -Json", "tools/run_character_red_team.cmd -RequireRunner -Json", "RUN_SIM_HARDWARE_COMPARE.cmd", "SIM_HARDWARE_COMPARE.md/json", "Voice audition quick check", "tools/open_voice_audition.cmd", "tools/open_voice_audition.cmd -All", "tools/verify_tracked_rvc_assets.cmd", "model.pth", "model.index", "SHA-256", "stackchan_spark_audition_bright_robot_greeting.mp3", "stackchan_spark_thinking.mp3")) { - if ($releaseNotes -notmatch [regex]::Escape($pattern)) { - throw "RELEASE_NOTES.md missing voice audition guidance: $pattern" + if ($releaseNotes -match 'This is the publicly shareable .+ prerelease candidate') { + throw "Diagnostic RELEASE_NOTES.md must not advertise a publicly shareable prerelease candidate" + } +} else { + if ($releaseNotes -notmatch "Hardware validation is still required") { + throw "RELEASE_NOTES.md must state that hardware validation is still required" + } + foreach ($pattern in @("model.pth", "model.index", "private paired reference robot", "exact-image evidence", "does not validate another assembled unit")) { + if ($releaseNotes -notmatch [regex]::Escape($pattern)) { + throw "RELEASE_NOTES.md missing reference-versus-recipient validation boundary: $pattern" + } + } + if ($releaseNotes -notmatch "READINESS_REPORT.md") { + throw "RELEASE_NOTES.md missing readiness report reference" + } + foreach ($pattern in @("No-hardware simulation quick check", "tools/run_prearrival_sim_check.cmd", "PREARRIVAL_SIM_CHECK.md/json", "nested LAN smoke report", "tools/run_prearrival_sim_check.cmd -RunModelBenchmark -Json", "model-benchmark-candidate", "tools/run_lan_smoke.cmd", "LAN_SMOKE.md/json", "run_litert_lm_smoke.cmd", "LITERT_LM_SMOKE.md/json", "summary.candidate_gate", "recommended_profile", "tools/run_character_red_team.cmd -Json", "tools/run_character_red_team.cmd -RequireRunner -Json", "RUN_SIM_HARDWARE_COMPARE.cmd", "SIM_HARDWARE_COMPARE.md/json", "Voice audition quick check", "tools/open_voice_audition.cmd", "tools/open_voice_audition.cmd -All", "tools/verify_tracked_rvc_assets.cmd", "model.pth", "model.index", "SHA-256", "stackchan_spark_audition_bright_robot_greeting.mp3", "stackchan_spark_thinking.mp3")) { + if ($releaseNotes -notmatch [regex]::Escape($pattern)) { + throw "RELEASE_NOTES.md missing voice audition guidance: $pattern" + } } } @@ -3954,7 +5340,7 @@ foreach ($gate in @("production-model-hash", "production-index-hash", "owner-rel } } -& (Join-PackagePath "tools/verify_tracked_rvc_assets.ps1") -VoiceRoot (Join-PackagePath "media/voice/rvc") +& (Join-Path $PSScriptRoot "verify_tracked_rvc_assets.ps1") -VoiceRoot (Join-PackagePath "media/voice/rvc") Assert-File "media/voice/rvc/model.pth" 57577722 Assert-File "media/voice/rvc/model.index" 99428699 @@ -3985,36 +5371,66 @@ $acceptance = Get-Content -LiteralPath (Join-PackagePath "release_acceptance.jso if ($acceptance.schema -ne "stackchan.release-acceptance.v1") { throw "release_acceptance.json schema mismatch: $($acceptance.schema)" } -if ($acceptance.releaseClass -ne "test-ready-prerelease") { - throw "release_acceptance.json releaseClass mismatch: $($acceptance.releaseClass)" -} -if ($acceptance.currentDecision -ne "test-ready-for-device-arrival") { - throw "release_acceptance.json currentDecision mismatch: $($acceptance.currentDecision)" -} -if ($acceptance.consumerRolloutDecision -ne "blocked-pending-hardware-validation") { - throw "release_acceptance.json consumerRolloutDecision mismatch: $($acceptance.consumerRolloutDecision)" -} -foreach ($requirement in @("clean-release-package", "dependency-provenance-present", "voice-review-samples-present", "voice-source-provenance-template-present", "voice-source-status-report-present", "character-red-team-dry-run-present", "companion-c6-brain-supervision-evidence", "hardware-media-importer-present", "servo-risk-gated", "share-page-verifiable")) { - $match = @($acceptance.noHardwareAcceptance | Where-Object { $_.requirement -eq $requirement -and $_.status -eq "pass" }) - if ($match.Count -ne 1) { - throw "release_acceptance.json missing passed no-hardware requirement: $requirement" +if ([bool]$manifest.diagnosticPackage) { + if ($acceptance.diagnosticPackage -ne $true -or + $acceptance.releaseClass -ne "diagnostic-only-unqualified" -or + $acceptance.currentDecision -ne "release-and-hardware-use-forbidden" -or + $acceptance.consumerRolloutDecision -ne "forbidden-diagnostic-package") { + throw "Diagnostic release_acceptance.json contains release-ready labels" } -} -foreach ($requirement in @("display-only-flash", "speech-mouth-demo-evidence", "servo-calibration", "mixed-mode-soak", "power-cycle-recovery", "target-speaker-audio-evidence", "hardware-evidence-verification")) { - $match = @($acceptance.hardwareAcceptanceRequired | Where-Object { $_.requirement -eq $requirement -and $_.status -match "pending" }) - if ($match.Count -ne 1) { - throw "release_acceptance.json missing pending hardware requirement: $requirement" + foreach ($requirement in @($acceptance.noHardwareAcceptance)) { + if ($requirement.status -ne "not-accepted-diagnostic") { + throw "Diagnostic release_acceptance.json improperly accepts requirement: $($requirement.requirement)" + } + } + foreach ($requirement in @($acceptance.hardwareAcceptanceRequired)) { + if ($requirement.status -ne "forbidden-diagnostic") { + throw "Diagnostic release_acceptance.json improperly permits hardware requirement: $($requirement.requirement)" + } + } +} else { + if ($acceptance.diagnosticPackage -eq $true -or $acceptance.releaseClass -ne "test-ready-prerelease") { + throw "release_acceptance.json releaseClass mismatch: $($acceptance.releaseClass)" + } + if ($acceptance.currentDecision -ne "test-ready-for-device-arrival") { + throw "release_acceptance.json currentDecision mismatch: $($acceptance.currentDecision)" + } + if ($acceptance.consumerRolloutDecision -ne "blocked-pending-hardware-validation") { + throw "release_acceptance.json consumerRolloutDecision mismatch: $($acceptance.consumerRolloutDecision)" + } + foreach ($requirement in @("clean-release-package", "dependency-provenance-present", "voice-review-samples-present", "voice-source-provenance-template-present", "voice-source-status-report-present", "character-red-team-dry-run-present", "companion-c6-brain-supervision-evidence", "hardware-media-importer-present", "servo-risk-gated", "share-page-verifiable")) { + $match = @($acceptance.noHardwareAcceptance | Where-Object { $_.requirement -eq $requirement -and $_.status -eq "pass" }) + if ($match.Count -ne 1) { + throw "release_acceptance.json missing passed no-hardware requirement: $requirement" + } + } + foreach ($requirement in @("display-only-flash", "speech-mouth-demo-evidence", "servo-calibration", "mixed-mode-soak", "power-cycle-recovery", "target-speaker-audio-evidence", "hardware-evidence-verification")) { + $match = @($acceptance.hardwareAcceptanceRequired | Where-Object { $_.requirement -eq $requirement -and $_.status -match "pending" }) + if ($match.Count -ne 1) { + throw "release_acceptance.json missing pending hardware requirement: $requirement" + } + } + $productionVoiceRequirement = @($acceptance.hardwareAcceptanceRequired | Where-Object { $_.requirement -eq "production-voice-assets" -and $_.status -eq "pass" }) + if ($productionVoiceRequirement.Count -ne 1) { + throw "release_acceptance.json missing passed production voice asset requirement" } -} -$productionVoiceRequirement = @($acceptance.hardwareAcceptanceRequired | Where-Object { $_.requirement -eq "production-voice-assets" -and $_.status -eq "pass" }) -if ($productionVoiceRequirement.Count -ne 1) { - throw "release_acceptance.json missing passed production voice asset requirement" } $acceptanceText = Get-Content -LiteralPath (Join-PackagePath "RELEASE_ACCEPTANCE.md") -Raw -foreach ($pattern in @("test-ready for device arrival", "Consumer rollout: blocked pending hardware validation", "Required Physical Qualification", "source commit and firmware SHA-256", "Owner approval has not been recorded for this candidate", "Dependency provenance", "Voice review samples", "Voice source provenance template", "Voice source status report", "VOICE_SOURCE_STATUS.md", "Character red-team dry-run report", "CHARACTER_RED_TEAM.md", "Companion C6 brain-supervision evidence", "Hardware media importer", "add_hardware_evidence_media.cmd", "Speech-mouth demo evidence", "speech_mouth_demo_serial.log", "speak_all_intents_serial.log", "Power-cycle recovery", "USB power-cycle observation marked pass", "Target-speaker audio evidence", "AUDIO_REVIEW.md", "real-device speaker recording", "Production RVC model and index")) { - if ($acceptanceText -notmatch [regex]::Escape($pattern)) { - throw "RELEASE_ACCEPTANCE.md missing expected acceptance guidance: $pattern" +if ([bool]$manifest.diagnosticPackage) { + foreach ($pattern in @("Diagnostic Package", "release and hardware use forbidden", "Nothing in this package is accepted as release evidence", "Do not flash it")) { + if ($acceptanceText -notmatch [regex]::Escape($pattern)) { + throw "Diagnostic RELEASE_ACCEPTANCE.md missing prohibition: $pattern" + } + } + if ($acceptanceText -match 'Decision:\s*test-ready' -or $acceptanceText -match '\[x\]') { + throw "Diagnostic RELEASE_ACCEPTANCE.md contains acceptance claims" + } +} else { + foreach ($pattern in @("test-ready for device arrival", "Consumer rollout: blocked pending hardware validation", "Required Physical Qualification", "source commit and firmware SHA-256", "Owner approval has not been recorded for this candidate", "Dependency provenance", "Voice review samples", "Voice source provenance template", "Voice source status report", "VOICE_SOURCE_STATUS.md", "Character red-team dry-run report", "CHARACTER_RED_TEAM.md", "Companion C6 brain-supervision evidence", "Hardware media importer", "add_hardware_evidence_media.cmd", "Speech-mouth demo evidence", "speech_mouth_demo_serial.log", "speak_all_intents_serial.log", "Power-cycle recovery", "USB power-cycle observation marked pass", "Target-speaker audio evidence", "AUDIO_REVIEW.md", "real-device speaker recording", "Production RVC model and index")) { + if ($acceptanceText -notmatch [regex]::Escape($pattern)) { + throw "RELEASE_ACCEPTANCE.md missing expected acceptance guidance: $pattern" + } } } @@ -4031,13 +5447,24 @@ if ($actionsStatus.commit -ne $ExpectedCommit) { if ($null -eq $actionsStatus.firmwareCandidateReady) { throw "github_actions_status.json missing firmwareCandidateReady" } -if (@("post-push-check-required", "missing-required-workflow", "external-account-billing-or-spending-limit", "external-account-ci-pre-runner-allocation", "success") -notcontains $actionsStatus.status) { - throw "github_actions_status.json status is not release-acceptable: $($actionsStatus.status)" -} $requiredActionWorkflowNames = @($actionsStatus.requiredWorkflows | ForEach-Object { [string]$_ }) -foreach ($workflowName in @("Firmware", "Release")) { - if ($requiredActionWorkflowNames -notcontains $workflowName) { - throw "github_actions_status.json missing required workflow contract: $workflowName" +if ([bool]$manifest.diagnosticPackage) { + if ($actionsStatus.status -ne "diagnostic-not-applicable" -or + $actionsStatus.firmwareCandidateReady -ne $false -or + $actionsStatus.promotionReady -ne $false -or + $requiredActionWorkflowNames.Count -ne 0 -or + @($actionsStatus.missingRequiredWorkflows).Count -ne 0 -or + @($actionsStatus.workflows).Count -ne 0) { + throw "Diagnostic github_actions_status.json must not claim candidate evidence" + } +} else { + if (@("post-push-check-required", "missing-required-workflow", "external-account-billing-or-spending-limit", "external-account-ci-pre-runner-allocation", "success") -notcontains $actionsStatus.status) { + throw "github_actions_status.json status is not release-acceptable: $($actionsStatus.status)" + } + foreach ($workflowName in @("Firmware", "Release")) { + if ($requiredActionWorkflowNames -notcontains $workflowName) { + throw "github_actions_status.json missing required workflow contract: $workflowName" + } } } if ($actionsStatus.firmwareCandidateReady -eq $true) { @@ -4056,16 +5483,32 @@ if ($actionsStatus.firmwareCandidateReady -eq $true) { } $actionsStatusText = Get-Content -LiteralPath (Join-PackagePath "GITHUB_ACTIONS_STATUS.md") -Raw -foreach ($pattern in @("GitHub Actions Status", $Version, $ExpectedCommit, "Required workflows", "github_actions_status.json")) { +$actionsStatusPatterns = if ([bool]$manifest.diagnosticPackage) { + @("GitHub Actions Status -- Diagnostic Only", $Version, $ExpectedCommit, "diagnostic-not-applicable", "Do not push a release tag", "github_actions_status.json") +} else { + @("GitHub Actions Status", $Version, $ExpectedCommit, "Required workflows", "github_actions_status.json") +} +foreach ($pattern in $actionsStatusPatterns) { if ($actionsStatusText -notmatch [regex]::Escape($pattern)) { throw "GITHUB_ACTIONS_STATUS.md missing expected status text: $pattern" } } $readinessMarkdown = Get-Content -LiteralPath (Join-PackagePath "READINESS_REPORT.md") -Raw -foreach ($pattern in @($Version, $ExpectedCommit, "Status: test-ready prerelease", "Consumer rollout: blocked pending hardware validation", "Proven Without Hardware", "Required Physical Qualification", "Historical private paired-reference evidence", "source commit and firmware SHA-256", "recipient's assembled hardware", "GITHUB_ACTIONS_STATUS.md", "VOICE_SOURCE_STATUS.md", "Character red-team dry-run evidence", "Companion C6 brain-supervision evidence", "companion/evidence/", "configured local model", "add_hardware_evidence_media.cmd", "verify_hardware_evidence.cmd", "Speech-mouth demo evidence", "speech_mouth_demo_serial.log", "speak_all_intents_serial.log", "Power-cycle recovery", "USB power-cycle observation marked pass", "Production voice metadata", "Owner approval has not been recorded for this candidate")) { - if ($readinessMarkdown -notmatch [regex]::Escape($pattern)) { - throw "READINESS_REPORT.md missing expected text: $pattern" +if ([bool]$manifest.diagnosticPackage) { + foreach ($pattern in @($Version, $ExpectedCommit, "Diagnostic package:", "Status: diagnostic-only unqualified", "Consumer rollout: forbidden diagnostic package", "Release and hardware use: forbidden", "source identity is not established", "Do not flash it")) { + if ($readinessMarkdown -notmatch [regex]::Escape($pattern)) { + throw "Diagnostic READINESS_REPORT.md missing prohibition: $pattern" + } + } + if ($readinessMarkdown -match 'Status:\s*test-ready' -or $readinessMarkdown -match 'Proven Without Hardware') { + throw "Diagnostic READINESS_REPORT.md contains readiness claims" + } +} else { + foreach ($pattern in @($Version, $ExpectedCommit, "Status: test-ready prerelease", "Consumer rollout: blocked pending hardware validation", "Proven Without Hardware", "Required Physical Qualification", "Historical private paired-reference evidence", "source commit and firmware SHA-256", "recipient's assembled hardware", "GITHUB_ACTIONS_STATUS.md", "VOICE_SOURCE_STATUS.md", "Character red-team dry-run evidence", "Companion C6 brain-supervision evidence", "companion/evidence/", "configured local model", "add_hardware_evidence_media.cmd", "verify_hardware_evidence.cmd", "Speech-mouth demo evidence", "speech_mouth_demo_serial.log", "speak_all_intents_serial.log", "Power-cycle recovery", "USB power-cycle observation marked pass", "Production voice metadata", "Owner approval has not been recorded for this candidate")) { + if ($readinessMarkdown -notmatch [regex]::Escape($pattern)) { + throw "READINESS_REPORT.md missing expected text: $pattern" + } } } @@ -4079,59 +5522,80 @@ if ($readinessJson.version -ne $Version) { if ($readinessJson.commit -ne $ExpectedCommit) { throw "readiness_report.json commit mismatch: expected $ExpectedCommit, got $($readinessJson.commit)" } -if ($readinessJson.status -ne "test-ready-prerelease") { - throw "readiness_report.json status mismatch: $($readinessJson.status)" -} -if ($readinessJson.consumerRollout -ne "blocked-pending-hardware-validation") { - throw "readiness_report.json must block rollout pending hardware validation" -} -foreach ($gate in @($readinessJson.noHardwareProof)) { - if ($gate.status -ne "pass") { - throw "readiness_report.json has non-passing no-hardware gate: $($gate.gate)" +if ([bool]$manifest.diagnosticPackage) { + if ($readinessJson.diagnosticPackage -ne $true -or + $readinessJson.commitRole -ne "package-source-only-not-firmware-identity" -or + $readinessJson.firmwareIdentity -ne "unknown-unbound-preexisting-outputs" -or + $readinessJson.status -ne "diagnostic-only-unqualified" -or + $readinessJson.consumerRollout -ne "forbidden-diagnostic-package" -or + $readinessJson.releaseAndHardwareUse -ne "forbidden" -or + $null -ne $readinessJson.nextOperatorCommand) { + throw "Diagnostic readiness_report.json contains release-ready state" + } + foreach ($gate in @($readinessJson.noHardwareProof)) { + if ($gate.status -ne "not-qualified-diagnostic") { + throw "Diagnostic readiness_report.json improperly qualifies gate: $($gate.gate)" + } } -} -$voiceSourceNoHardwareGate = @($readinessJson.noHardwareProof | Where-Object { $_.gate -eq "voice-source-provenance-template-present" -and $_.status -eq "pass" }) -if ($voiceSourceNoHardwareGate.Count -ne 1) { - throw "readiness_report.json missing passed voice-source provenance template gate" -} -$voiceSourceStatusNoHardwareGate = @($readinessJson.noHardwareProof | Where-Object { $_.gate -eq "voice-source-status-report-present" -and $_.status -eq "pass" }) -if ($voiceSourceStatusNoHardwareGate.Count -ne 1) { - throw "readiness_report.json missing passed voice-source status report gate" -} -$characterRedTeamNoHardwareGate = @($readinessJson.noHardwareProof | Where-Object { $_.gate -eq "character-red-team-dry-run" -and $_.status -eq "pass" }) -if ($characterRedTeamNoHardwareGate.Count -ne 1) { - throw "readiness_report.json missing passed character red-team dry-run gate" -} -$companionC6NoHardwareGate = @($readinessJson.noHardwareProof | Where-Object { $_.gate -eq "companion-c6-brain-supervision-evidence" -and $_.status -eq "pass" }) -if ($companionC6NoHardwareGate.Count -ne 1) { - throw "readiness_report.json missing passed companion-c6-brain-supervision-evidence gate" -} -$mediaImporterNoHardwareGate = @($readinessJson.noHardwareProof | Where-Object { $_.gate -eq "hardware-media-importer-present" -and $_.status -eq "pass" }) -if ($mediaImporterNoHardwareGate.Count -ne 1) { - throw "readiness_report.json missing passed hardware-media-importer-present gate" -} -$speakerAudioGate = @($readinessJson.hardwareGates | Where-Object { $_.gate -eq "target-speaker-audio-evidence" -and $_.status -eq "pending-device" }) -if ($speakerAudioGate.Count -ne 1) { - throw "readiness_report.json missing pending target-speaker-audio-evidence gate" -} -$speechMouthGate = @($readinessJson.hardwareGates | Where-Object { $_.gate -eq "speech-mouth-demo-evidence" -and $_.status -eq "pending-device" }) -if ($speechMouthGate.Count -ne 1) { - throw "readiness_report.json missing pending speech-mouth-demo-evidence gate" -} -$powerCycleGate = @($readinessJson.hardwareGates | Where-Object { $_.gate -eq "power-cycle-recovery" -and $_.status -eq "pending-device" }) -if ($powerCycleGate.Count -ne 1) { - throw "readiness_report.json missing pending power-cycle-recovery gate" -} -foreach ($gate in @($readinessJson.hardwareGates)) { - $allowedStatus = if ($gate.gate -eq "production-voice-assets") { "pass" } else { "pending-device" } - if ($gate.status -ne $allowedStatus) { - throw "readiness_report.json hardware gate status mismatch: $($gate.gate)" + foreach ($gate in @($readinessJson.hardwareGates)) { + if ($gate.status -ne "forbidden-diagnostic") { + throw "Diagnostic readiness_report.json improperly permits hardware gate: $($gate.gate)" + } + } +} else { + if ($readinessJson.diagnosticPackage -eq $true -or $readinessJson.status -ne "test-ready-prerelease") { + throw "readiness_report.json status mismatch: $($readinessJson.status)" + } + if ($readinessJson.consumerRollout -ne "blocked-pending-hardware-validation") { + throw "readiness_report.json must block rollout pending hardware validation" + } + foreach ($gate in @($readinessJson.noHardwareProof)) { + if ($gate.status -ne "pass") { + throw "readiness_report.json has non-passing no-hardware gate: $($gate.gate)" + } + } + foreach ($requiredGate in @("voice-source-provenance-template-present", "voice-source-status-report-present", "character-red-team-dry-run", "companion-c6-brain-supervision-evidence", "hardware-media-importer-present")) { + if (@($readinessJson.noHardwareProof | Where-Object { $_.gate -eq $requiredGate -and $_.status -eq "pass" }).Count -ne 1) { + throw "readiness_report.json missing passed no-hardware gate: $requiredGate" + } + } + foreach ($requiredGate in @("target-speaker-audio-evidence", "speech-mouth-demo-evidence", "power-cycle-recovery")) { + if (@($readinessJson.hardwareGates | Where-Object { $_.gate -eq $requiredGate -and $_.status -eq "pending-device" }).Count -ne 1) { + throw "readiness_report.json missing pending hardware gate: $requiredGate" + } + } + foreach ($gate in @($readinessJson.hardwareGates)) { + $allowedStatus = if ($gate.gate -eq "production-voice-assets") { "pass" } else { "pending-device" } + if ($gate.status -ne $allowedStatus) { + throw "readiness_report.json hardware gate status mismatch: $($gate.gate)" + } } } if (@($readinessJson.hardwareGates).Count -lt 8) { throw "readiness_report.json is missing required hardware gates" } +if ([bool]$manifest.diagnosticPackage) { + $diagnosticMarker = Join-PackagePath "DIAGNOSTIC_PACKAGE_DO_NOT_FLASH.txt" + if (-not (Test-Path -LiteralPath $diagnosticMarker -PathType Leaf)) { + throw "Diagnostic package is missing DIAGNOSTIC_PACKAGE_DO_NOT_FLASH.txt" + } + $diagnosticMarkerText = Get-Content -LiteralPath $diagnosticMarker -Raw + foreach ($pattern in @("DIAGNOSTIC-ONLY UNQUALIFIED PACKAGE", "RELEASE AND HARDWARE USE ARE FORBIDDEN", "Do not flash it")) { + if ($diagnosticMarkerText -notmatch [regex]::Escape($pattern)) { + throw "Diagnostic package marker is missing prohibition: $pattern" + } + } + foreach ($bannerPath in @("README.md", "QUICKSTART.md", "ARRIVAL_DAY_RUNBOOK.md", "docs/README.md")) { + $bannerText = Get-Content -LiteralPath (Join-PackagePath $bannerPath) -Raw + foreach ($pattern in @("DIAGNOSTIC-ONLY UNQUALIFIED PACKAGE", "RELEASE AND HARDWARE USE ARE FORBIDDEN", "Do not flash it", "not this diagnostic archive")) { + if ($bannerText -notmatch [regex]::Escape($pattern)) { + throw "Diagnostic package file lacks the required banner: $bannerPath ($pattern)" + } + } + } +} + $hashPath = Join-PackagePath "SHA256SUMS.txt" $hashLines = Get-Content -LiteralPath $hashPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $seen = @{} @@ -4160,8 +5624,8 @@ foreach ($line in $hashLines) { } } -$packagedFiles = Get-ChildItem -LiteralPath $packageRootPath -File -Recurse | - ForEach-Object { $_.FullName.Substring($packageRootPath.Length + 1).Replace("\", "/") } | +$packagedFiles = Get-ChildItem -LiteralPath $packageEnumerationRoot -File -Recurse | + ForEach-Object { (Get-PackageItemFullName $_).Substring($packageRootPath.Length + 1).Replace("\", "/") } | Where-Object { $_ -ne "SHA256SUMS.txt" -and $_ -notlike "output/*" } foreach ($file in $packagedFiles) { @@ -4176,19 +5640,100 @@ foreach ($file in $seen.Keys) { } } -Write-Host "Release package verified:" -Write-Host $packageRootPath +function Assert-OperationalWholePackageInventory { + if (-not $RequireReleaseEligible) { return } + # Root admission is closed independently of SHA256SUMS. A caller cannot add + # a launcher/payload at the archive root and bless it by regenerating hashes. + $allowedRootFiles = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal) + foreach ($requiredFile in $requiredFiles) { + if ([string]$requiredFile -notmatch '/') { + [void]$allowedRootFiles.Add([string]$requiredFile) + } + } + foreach ($generatedRootFile in @( + 'RVC_VOICE_BASE_STATUS.md', 'rvc_voice_base_status.json')) { + [void]$allowedRootFiles.Add($generatedRootFile) + } + $actualRootFiles = @( + Get-ChildItem -LiteralPath $packageEnumerationRoot -File -Force | + ForEach-Object { $_.Name }) + foreach ($actualRootFile in $actualRootFiles) { + if (-not $allowedRootFiles.Contains([string]$actualRootFile)) { + throw "Operational package contains a root file outside the trusted packaging policy: $actualRootFile" + } + } + foreach ($allowedRootFile in $allowedRootFiles) { + if ($actualRootFiles -cnotcontains $allowedRootFile) { + throw "Operational package is missing a root file required by the trusted packaging policy: $allowedRootFile" + } + } -if ($cleanupDir) { - $resolvedCleanup = (Resolve-Path $cleanupDir).Path - $resolvedTempRoot = (Resolve-Path $tempRoot).Path - if (-not $resolvedCleanup.StartsWith($resolvedTempRoot, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to clean unexpected verification directory: $resolvedCleanup" + # Generated/license trees have independent exact inventories. This turns the + # existing content validators into admission control, not merely spot checks. + $allowedThirdParty = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal) + [void]$allowedThirdParty.Add('third_party_licenses/files.json') + foreach ($relative in $indexedThirdPartyPaths) { + [void]$allowedThirdParty.Add('third_party_licenses/' + [string]$relative) + } + $actualThirdParty = @( + Get-ChildItem -LiteralPath (Join-Path $packageEnumerationRoot 'third_party_licenses') ` + -File -Recurse -Force | ForEach-Object { + (Get-PackageItemFullName $_).Substring($packageRootPrefix.Length).Replace('\', '/') + }) + if ($actualThirdParty.Count -ne $allowedThirdParty.Count) { + throw 'Operational third-party-license tree inventory count does not match its deterministic index.' + } + foreach ($path in $actualThirdParty) { + if (-not $allowedThirdParty.Contains([string]$path)) { + throw "Operational package contains an unindexed third-party file: $path" + } } - $cleanupFileSystemPath = if ($env:OS -eq "Windows_NT" -and -not $resolvedCleanup.StartsWith("\\?\")) { - "\\?\$resolvedCleanup" - } else { - $resolvedCleanup + + $allowedPackageFiles = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal) + foreach ($path in @($requiredFiles) + @($manifest.includedTools) + + @($manifest.provenanceFiles) + @($manifest.mediaArtifacts) + + @($manifest.companionEvidence) + @($allowedThirdParty)) { + [void]$allowedPackageFiles.Add([string]$path) + } + foreach ($generatedRootFile in @( + 'RVC_VOICE_BASE_STATUS.md', 'rvc_voice_base_status.json')) { + [void]$allowedPackageFiles.Add($generatedRootFile) + } + $actualPackageFiles = @( + Get-ChildItem -LiteralPath $packageEnumerationRoot -File -Recurse -Force | + ForEach-Object { + (Get-PackageItemFullName $_).Substring($packageRootPrefix.Length).Replace('\', '/') + }) + if ($actualPackageFiles.Count -ne $allowedPackageFiles.Count) { + throw "Operational whole-package inventory count does not match trusted packaging policy: expected $($allowedPackageFiles.Count), got $($actualPackageFiles.Count)" + } + foreach ($path in $actualPackageFiles) { + if (-not $allowedPackageFiles.Contains([string]$path)) { + throw "Operational package contains a file outside the trusted whole-package inventory: $path" + } } - [System.IO.Directory]::Delete($cleanupFileSystemPath, $true) } +Assert-OperationalWholePackageInventory + +if ($RequireReleaseEligible -and + ([bool]$manifest.diagnosticPackage -or + $manifest.releaseEligible -ne $true -or + $manifest.hardwareValidationEligible -ne $true -or + $manifest.distributionEligible -ne $true -or + $manifest.flashEligible -ne $true)) { + throw "Diagnostic archive integrity verification never authorizes flashing, evidence capture, publication, or release workflows" +} + +Assert-OperationalFirmwareMatchesTrustedRebuild + +if ([bool]$manifest.diagnosticPackage) { + Write-Host "Diagnostic archive integrity verified; release and hardware use forbidden:" +} else { + Write-Host "Package integrity verified in non-authorizing mode; release eligibility not established:" +} +Write-Host $packageRootPath + +Remove-VerificationExtraction From 449e38234ad5beeb77e13cba603bc4e306580286 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Mon, 3 Aug 2026 12:39:23 -0400 Subject: [PATCH 11/46] test: align consumer promotion fail-closed contract --- tools/test_consumer_promotion_contract.ps1 | 88 ++++++++++++++++++++-- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/tools/test_consumer_promotion_contract.ps1 b/tools/test_consumer_promotion_contract.ps1 index 1997899b..161ac830 100644 --- a/tools/test_consumer_promotion_contract.ps1 +++ b/tools/test_consumer_promotion_contract.ps1 @@ -28,7 +28,7 @@ foreach ($fragment in $required) { } $identityBindings = @( - '& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage -Version $Version -PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit', + '& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage -Version $Version -PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit -RequireReleaseEligible', '$cameraEvidence = Assert-CameraFollowReady $CameraFollowSummaryPath $ExpectedFirmwareSourceCommit', '$bodyEvidence = Assert-BodySensorReady $BodySensorReportPath $ExpectedFirmwareSourceCommit', '$soakEvidence = Assert-FinalSoakReady $FullSystemSoakSummaryPath $ExpectedFirmwareSourceCommit $MinFinalSoakDurationSeconds', @@ -48,12 +48,24 @@ $packageVerifierSource = Get-Content -LiteralPath (Join-Path $RepoRoot "tools\ve $actionsExporterSource = Get-Content -LiteralPath (Join-Path $RepoRoot "tools\export_github_actions_status.ps1") -Raw foreach ($fragment in @( - 'status = "test-ready prerelease; hardware validation pending"', - 'status = "test-ready-prerelease"', - 'consumerRollout = "blocked-pending-hardware-validation"', - 'releaseClass = "test-ready-prerelease"', - 'currentDecision = "test-ready-for-device-arrival"', - 'consumerRolloutDecision = "blocked-pending-hardware-validation"', + 'Release-grade packaging is fail-closed before Git or build-tool execution.', + 'Diagnostic packaging remains', + 'available only with -SkipBuild -AllowDirty; it is never release eligible.', + 'if ($SkipBuild -and -not $AllowDirty) {', + 'if ($AllowDirty -and -not $SkipBuild) {', + 'if ($SkipBuild -and $ObserveCandidateActions) {', + '"diagnostic-only; reproducibility not proven; release and hardware validation forbidden"', + '"test-ready prerelease; hardware validation pending"', + 'diagnosticPackage = [bool]$SkipBuild', + 'releaseEligible = (-not $SkipBuild)', + 'hardwareValidationEligible = (-not $SkipBuild)', + 'distributionEligible = (-not $SkipBuild)', + 'flashEligible = (-not $SkipBuild)', + 'status = if ($SkipBuild) { "diagnostic-only-unqualified" } else { "test-ready-prerelease" }', + 'consumerRollout = if ($SkipBuild) { "forbidden-diagnostic-package" } else { "blocked-pending-hardware-validation" }', + 'releaseClass = if ($SkipBuild) { "diagnostic-only-unqualified" } else { "test-ready-prerelease" }', + 'currentDecision = if ($SkipBuild) { "release-and-hardware-use-forbidden" } else { "test-ready-for-device-arrival" }', + 'consumerRolloutDecision = if ($SkipBuild) { "forbidden-diagnostic-package" } else { "blocked-pending-hardware-validation" }', "Owner approval has not been recorded for this candidate" )) { if (-not $packageSource.Contains($fragment)) { @@ -61,6 +73,55 @@ foreach ($fragment in @( } } +foreach ($fragment in @( + '-ExpectedCommit $ExpectedCommit -RequireReleaseEligible', + 'Operational release ZIP verification failed before consumer-promotion extraction.', + 'Expand-StackchanReleaseZipSafely', + '-PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit -RequireReleaseEligible' + )) { + if (-not $source.Contains($fragment)) { + throw "Consumer promotion release-eligibility boundary missing fragment: $fragment" + } +} + +$packageFailClosedGuardIndex = $packageSource.IndexOf('if (-not $SkipBuild) {') +$packageFailClosedMessageIndex = $packageSource.IndexOf('Release-grade packaging is fail-closed before Git or build-tool execution.') +$packageFirstGitResolutionIndex = $packageSource.IndexOf('$releaseBootstrapGitCommand = Get-Command -Name git') +if ($packageFailClosedGuardIndex -lt 0 -or + $packageFailClosedMessageIndex -lt $packageFailClosedGuardIndex -or + $packageFirstGitResolutionIndex -lt $packageFailClosedMessageIndex) { + throw "Release package fail-closed guard must precede Git and build-tool resolution." +} + +foreach ($fragment in @( + '[switch]$RequireReleaseEligible', + 'Release-eligible verification is fail-closed before Git or build-tool execution.', + 'verification remains available without -RequireReleaseEligible and cannot establish eligibility.', + 'Operational release verification refuses diagnostic packages.', + '$manifest.releaseEligible -ne $false', + '$manifest.hardwareValidationEligible -ne $false', + '$manifest.distributionEligible -ne $false', + '$manifest.flashEligible -ne $false', + 'if ($RequireReleaseEligible -and', + '$manifest.releaseEligible -ne $true', + '$manifest.hardwareValidationEligible -ne $true', + '$manifest.distributionEligible -ne $true', + '$manifest.flashEligible -ne $true' + )) { + if (-not $packageVerifierSource.Contains($fragment)) { + throw "Release package verifier fail-closed eligibility contract missing fragment: $fragment" + } +} + +$verifierFailClosedGuardIndex = $packageVerifierSource.IndexOf('if ($RequireReleaseEligible) {') +$verifierFailClosedMessageIndex = $packageVerifierSource.IndexOf('Release-eligible verification is fail-closed before Git or build-tool execution.') +$verifierAmbientProcessingIndex = $packageVerifierSource.IndexOf('$ambientGitOverrides = @(') +if ($verifierFailClosedGuardIndex -lt 0 -or + $verifierFailClosedMessageIndex -lt $verifierFailClosedGuardIndex -or + $verifierAmbientProcessingIndex -lt $verifierFailClosedMessageIndex) { + throw "Release package verifier fail-closed guard must precede ambient, Git, tool, and package processing." +} + foreach ($fragment in @( "[switch]`$ObserveCandidateActions", "-AcceptFirmwareCandidate", @@ -72,6 +133,19 @@ foreach ($fragment in @( } } +$zipEligibilityVerifyIndex = $source.IndexOf('-ExpectedCommit $ExpectedCommit -RequireReleaseEligible') +$zipVerificationFailureIndex = $source.IndexOf('Operational release ZIP verification failed before consumer-promotion extraction.') +$safeExtractionIndex = $source.IndexOf('Expand-StackchanReleaseZipSafely') +$rootEligibilityVerifyIndex = $source.IndexOf('& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage -Version $Version -PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit -RequireReleaseEligible') +$firstEvidenceCheckIndex = $source.IndexOf('if ([string]::IsNullOrWhiteSpace($EvidenceRoot))') +if ($zipEligibilityVerifyIndex -lt 0 -or + $zipVerificationFailureIndex -lt $zipEligibilityVerifyIndex -or + $safeExtractionIndex -lt $zipVerificationFailureIndex -or + $rootEligibilityVerifyIndex -lt $safeExtractionIndex -or + $firstEvidenceCheckIndex -lt $rootEligibilityVerifyIndex) { + throw "Consumer promotion must verify release eligibility before safe extraction and again before evidence checks." +} + foreach ($fragment in @( "[switch]`$AcceptFirmwareCandidate", "`$firmwareCandidateReady", From 3bf07730960cbbcfd502c0157434abb157ee1cc8 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Mon, 3 Aug 2026 13:07:10 -0400 Subject: [PATCH 12/46] ci: isolate compiler normalization probe --- .github/workflows/firmware.yml | 5 +-- ...t_firmware_reproducible_build_contract.ps1 | 33 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 0bda5657..f365d725 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -745,8 +745,9 @@ jobs: shell: pwsh run: | ./tools/test_firmware_reproducible_build_contract.ps1 - $env:PLATFORMIO_CORE_DIR = Join-Path $env:RUNNER_TEMP "stackchan-pioarduino" - ./tools/test_firmware_reproducible_build_contract.ps1 + $pioarduinoCoreDir = Join-Path $env:RUNNER_TEMP "stackchan-pioarduino" + ./tools/test_firmware_reproducible_build_contract.ps1 ` + -CompilerProbeCoreDir $pioarduinoCoreDir - name: Build unit-test firmware run: pio test -e stackchan --without-uploading --without-testing diff --git a/tools/test_firmware_reproducible_build_contract.ps1 b/tools/test_firmware_reproducible_build_contract.ps1 index 5c54c64e..a3983354 100644 --- a/tools/test_firmware_reproducible_build_contract.ps1 +++ b/tools/test_firmware_reproducible_build_contract.ps1 @@ -1,3 +1,7 @@ +param( + [string]$CompilerProbeCoreDir +) + $ErrorActionPreference = "Stop" $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path @@ -130,6 +134,14 @@ $verifyGovernanceText = $verifyText + "`n" + $proofHelperText $workflowText = Get-Content -LiteralPath $workflowPath -Raw $contractText = Get-Content -LiteralPath $PSCommandPath -Raw +foreach ($workflowCompilerProbeMarker in @( + '$pioarduinoCoreDir = Join-Path $env:RUNNER_TEMP "stackchan-pioarduino"', + '-CompilerProbeCoreDir $pioarduinoCoreDir' + )) { + Require-ReproAssertion ($workflowText.Contains($workflowCompilerProbeMarker)) ` + "workflow-explicit-compiler-probe: missing $workflowCompilerProbeMarker" +} + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $proofContractPath if ($LASTEXITCODE -ne 0) { $issues.Add("proof-mutation-contract-failed: exit $LASTEXITCODE") @@ -811,11 +823,26 @@ function Test-PrefixMapCompiler { } } +$explicitCompilerProbeCore = -not [string]::IsNullOrWhiteSpace($CompilerProbeCoreDir) +$resolvedCompilerProbeCoreDir = if (-not $explicitCompilerProbeCore) { + Get-StackchanPlatformioCoreDir +} else { + $resolvedProbeRoot = Resolve-Path -LiteralPath $CompilerProbeCoreDir -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolvedProbeRoot.Path -PathType Container)) { + throw "Compiler probe PlatformIO core is not a directory: $CompilerProbeCoreDir" + } + $resolvedProbeRoot.Path +} + $compilerCandidates = @( - (Join-Path (Get-StackchanPlatformioCoreDir) 'packages/toolchain-xtensa-esp32s3/bin/xtensa-esp32s3-elf-g++.exe'), - (Join-Path (Get-StackchanPlatformioCoreDir) 'packages/toolchain-xtensa-esp-elf/bin/xtensa-esp32s3-elf-g++.exe') + (Join-Path $resolvedCompilerProbeCoreDir 'packages/toolchain-xtensa-esp32s3/bin/xtensa-esp32s3-elf-g++.exe'), + (Join-Path $resolvedCompilerProbeCoreDir 'packages/toolchain-xtensa-esp-elf/bin/xtensa-esp32s3-elf-g++.exe') ) -if ($env:OS -eq 'Windows_NT') { +if ($explicitCompilerProbeCore -and + @($compilerCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }).Count -eq 0) { + throw "No supported compiler found under explicit probe core: $resolvedCompilerProbeCoreDir" +} +if (-not $explicitCompilerProbeCore -and $env:OS -eq 'Windows_NT') { $compilerCandidates += Join-Path ([System.IO.Path]::GetPathRoot($env:SystemRoot)) ` 'spio/pioarduino/packages/toolchain-xtensa-esp-elf/bin/xtensa-esp32s3-elf-g++.exe' } From e52826a4a130f00718e20e71e5aea0f1cbc050ff Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Mon, 3 Aug 2026 14:31:11 -0400 Subject: [PATCH 13/46] fix: bind OTA selector release authority --- .github/workflows/release.yml | 95 ++++--- PROJECT_STATE.md | 42 +-- TASK_LEDGER.md | 37 ++- docs/ARRIVAL_DAY_RUNBOOK.md | 29 +- docs/FIRST_DEPLOY_STATUS.md | 36 ++- tools/firmware_reproducibility_proof.ps1 | 8 +- tools/flash_release_firmware.ps1 | 247 +++++++++++++++--- tools/package_release.ps1 | 52 +++- tools/publish_release.ps1 | 147 ++++++++++- tools/release_asset_contract.ps1 | 2 + tools/release_ota_selector_policy.ps1 | 152 +++++++++++ ...irmware_reproducibility_proof_contract.ps1 | 6 +- ...t_firmware_reproducible_build_contract.ps1 | 15 +- .../test_release_flash_snapshot_contract.ps1 | 94 +++++++ ...t_release_ota_selector_policy_contract.ps1 | 134 ++++++++++ ...elease_package_verifier_trust_contract.ps1 | 74 +++++- tools/verify_published_release.ps1 | 32 ++- tools/verify_release_asset_contract.ps1 | 17 +- tools/verify_release_package.ps1 | 58 +++- 19 files changed, 1126 insertions(+), 151 deletions(-) create mode 100644 tools/release_ota_selector_policy.ps1 create mode 100644 tools/test_release_flash_snapshot_contract.ps1 create mode 100644 tools/test_release_ota_selector_policy_contract.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01ba144d..cc793d2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -745,6 +745,7 @@ jobs: Copy-Item "$packageRoot/firmware/servo_calibration/firmware.bin" "$stageDir/firmware-servo-calibration.bin" Copy-Item "$packageRoot/firmware/display_only/bootloader.bin" "$stageDir/bootloader.bin" Copy-Item "$packageRoot/firmware/display_only/partitions.bin" "$stageDir/partitions.bin" + Copy-Item "$packageRoot/firmware/display_only/boot_app0.bin" "$stageDir/boot-app0.bin" function Copy-SingleCompanionArtifact([string]$Pattern, [string]$Destination, [string]$RequiredPathFragment = "") { $matches = @(Get-ChildItem "output/companion/release-input" -Recurse -File -Filter $Pattern | Where-Object { @@ -831,48 +832,72 @@ jobs: Copy-Item "$packageRoot/firmware/servo_calibration/firmware.bin" "$stageDir/firmware-servo-calibration.bin" Copy-Item "$packageRoot/firmware/display_only/bootloader.bin" "$stageDir/bootloader.bin" Copy-Item "$packageRoot/firmware/display_only/partitions.bin" "$stageDir/partitions.bin" + Copy-Item "$packageRoot/firmware/display_only/boot_app0.bin" "$stageDir/boot-app0.bin" $companionStageDir = Join-Path $publicationRoot "companion-assets" - ./tools/verify_release_asset_contract.ps1 -Version $version -PackageRoot $packageRoot -ZipPath $zipPath -ZipSidecarPath $zipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage $releaseAssetEntries = Get-ReleaseFinalAssetEntries -Version $version -PackageRoot $packageRoot -ZipPath $zipPath -ZipSidecarPath $zipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage $companionAssetEntries = Get-ReleaseCompanionAssetEntries -Version $version -CompanionAssetRoot $companionStageDir $releaseAssetPaths = @($releaseAssetEntries + $companionAssetEntries | ForEach-Object { $_.Path }) - $gitCommand = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 - $ghCommand = Get-Command gh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($null -eq $gitCommand -or $null -eq $ghCommand) { - throw "Release publication requires Git and GitHub CLI application executables." - } - $tagRef = "refs/tags/$version" - $peeledRef = "$tagRef^{}" - $remoteLines = @(& $gitCommand.Source ls-remote "https://github.com/${{ github.repository }}.git" $tagRef $peeledRef) - if ($LASTEXITCODE -ne 0) { - throw "Could not resolve the triggering release tag immediately before publication." - } - $resolvedRefs = @($remoteLines | ForEach-Object { - if ([string]$_ -match '^([0-9a-fA-F]{40})\s+(.+)$') { - [pscustomobject]@{ commit = $Matches[1].ToLowerInvariant(); ref = $Matches[2] } + $assetLocks = New-Object System.Collections.Generic.List[System.IO.FileStream] + try { + foreach ($assetPath in $releaseAssetPaths) { + $assetLocks.Add([System.IO.FileStream]::new( + (Resolve-Path -LiteralPath $assetPath).Path, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read)) } - }) - $peeled = @($resolvedRefs | Where-Object ref -CEQ $peeledRef) - $direct = @($resolvedRefs | Where-Object ref -CEQ $tagRef) - $remoteCommit = if ($peeled.Count -eq 1) { - [string]$peeled[0].commit - } elseif ($peeled.Count -eq 0 -and $direct.Count -eq 1) { - [string]$direct[0].commit - } else { - '' - } - if ($remoteCommit -cne ([string]$env:STACKCHAN_RELEASE_COMMIT).ToLowerInvariant()) { - throw "Remote release tag resolves to '$remoteCommit', not the package-verified commit $env:STACKCHAN_RELEASE_COMMIT." - } + ./tools/verify_release_asset_contract.ps1 -Version $version -PackageRoot $packageRoot -ZipPath $zipPath -ZipSidecarPath $zipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage - & $ghCommand.Source release create $version ` - @releaseAssetPaths ` - --repo "${{ github.repository }}" ` - --target $env:STACKCHAN_RELEASE_COMMIT ` - --title "Stackchan Alive $version" ` - --notes-file (Join-Path $packageRoot "RELEASE_NOTES.md") ` - --prerelease + $gitCommand = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + $ghCommand = Get-Command gh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $gitCommand -or $null -eq $ghCommand) { + throw "Release publication requires Git and GitHub CLI application executables." + } + $tagRef = "refs/tags/$version" + $peeledRef = "$tagRef^{}" + $remoteLines = @(& $gitCommand.Source ls-remote "https://github.com/${{ github.repository }}.git" $tagRef $peeledRef) + if ($LASTEXITCODE -ne 0) { + throw "Could not resolve the triggering release tag immediately before publication." + } + $resolvedRefs = @($remoteLines | ForEach-Object { + if ([string]$_ -match '^([0-9a-fA-F]{40})\s+(.+)$') { + [pscustomobject]@{ commit = $Matches[1].ToLowerInvariant(); ref = $Matches[2] } + } + }) + $peeled = @($resolvedRefs | Where-Object ref -CEQ $peeledRef) + $direct = @($resolvedRefs | Where-Object ref -CEQ $tagRef) + $remoteCommit = if ($peeled.Count -eq 1) { + [string]$peeled[0].commit + } elseif ($peeled.Count -eq 0 -and $direct.Count -eq 1) { + [string]$direct[0].commit + } else { + '' + } + if ($remoteCommit -cne ([string]$env:STACKCHAN_RELEASE_COMMIT).ToLowerInvariant()) { + throw "Remote release tag resolves to '$remoteCommit', not the package-verified commit $env:STACKCHAN_RELEASE_COMMIT." + } + + & $ghCommand.Source release create $version ` + @releaseAssetPaths ` + --repo "${{ github.repository }}" ` + --target $env:STACKCHAN_RELEASE_COMMIT ` + --title "Stackchan Alive $version" ` + --notes-file (Join-Path $packageRoot "RELEASE_NOTES.md") ` + --prerelease + if ($LASTEXITCODE -ne 0) { + throw "GitHub release creation failed." + } + ./tools/verify_published_release.ps1 ` + -Version $version ` + -Repo "${{ github.repository }}" ` + -PackageRoot $packageRoot ` + -ZipPath $zipPath ` + -ZipSidecarPath $zipSidecarPath ` + -ExpectedCommit ([string]$env:STACKCHAN_RELEASE_COMMIT) + } finally { + foreach ($assetLock in $assetLocks) { $assetLock.Dispose() } + } - name: Clean verified release publication snapshot if: always() diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7f68ebaf..3c7b3d25 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -19,13 +19,21 @@ Qualification audit found that the preserved `4d31de41` public full image autonomous refresh at boot. That package remains immutable historical evidence and is superseded for physical qualification. The selected correction keeps the public full profile motion-off at boot and explicitly disables autonomous boot refresh; it is committed as -`b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. Release-governance changes after that commit remain an -uncommitted working-tree experiment. Diagnostic packaging is available, but release-grade +`b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. Release-governance changes through `3bf07730` are +committed and published on draft PR #220. Diagnostic packaging is available, but release-grade packaging and release-eligible verification currently fail closed because no reviewed exact -toolchain allowlist exists. The clean reproducible replacement image, OTA-selector-safe install -path, passive no-motion runner, rollback proof, and physical qualification are still pending. -Repeated bounded live `/debug` probes were unavailable, so current actuator state and installed -application identity are unknown; USB enumeration and ping observations do not close that gap. +toolchain allowlist exists. The current uncommitted selector-authority slice binds per-environment +`boot_app0.bin` bytes to reviewed framework identity/size/SHA-256, closes flash and publication +reopen races, and writes the selector at `0xE000`; its diagnostic v13 rehearsal passed. The clean +reproducible replacement image, rollback proof, and +physical qualification are still pending. + +Fresh bounded `/debug` evidence now shows the live runtime request, autonomous state, motion, servo +rail, torque, and both power authorities off. Firmware self-reports confirmed `app0` and expected +SHA `69d3db27...8ebfa8`, but this is not independent current flash-byte identity. The installed image +also reports motion and autonomous motion enabled at boot, so it is not the P1 no-motion candidate. +No bridge/RVC process or relevant local listener was present; intermittent debug timeouts recovered +with increasing uptime and unchanged `boot_count=1` and are not classified as a robot freeze. The requested human/dog/cat following, natural naming/removal, and personality-shaped emotional motion work is now a separate design-only lane in @@ -39,10 +47,11 @@ dimensional projection behind controlled-source final-actuator and physical-safe - Repository: `RobVanProd/stackchan_alive` - Working branch: `codex/aliveness-repository-truth` -- Current committed firmware/source correction: `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3` - (`fix: keep public release motion off at boot`). -- Current release-governance/toolchain worktree: dirty and uncommitted; it must not be described as - the identity of a clean package or installed image. +- Current committed release-governance head: `3bf07730960cbbcfd502c0157434abb157ee1cc8` + (`ci: isolate compiler normalization probe`), including the firmware source correction at + `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. +- Current worktree: dirty only for the OTA-selector-authority/install/publication slice and completed evidence + reconciliation; it must not be described as a clean package or installed image until committed. - HTTP-containment contract-scope maintenance commit (test file only): `aa7dfb9ca077704dca84bc5635fbb2142e13e47c` - Separate package prerequisite commit: @@ -66,9 +75,12 @@ switched because live services use that checkout. Milestone 0 work uses the isol Selected experiment: `M0-004`, exact-source reproducible firmware and release-command governance. -- **Observed behavior:** The boot-motion prerequisite is committed at `b5ea5c5f`, but the current - release-governance tree is dirty. The diagnostic v8 package is explicitly dirty, diagnostic-only, - non-release-eligible, non-flashable, and does not prove firmware reproducibility. No tracked +- **Observed behavior:** The boot-motion prerequisite and release-governance slice are committed + through `3bf07730`; the current dirty slice is limited to selector-authority packaging/install, + publication integrity, and state reconciliation. Diagnostic v13 is explicitly dirty, + diagnostic-only, non-release-eligible, + non-flashable, and does not prove firmware reproducibility. It contains three exact 8,192-byte + selectors and the operational flasher rejects it before flash preparation. No tracked reviewed toolchain allowlist exists. Fresh canonical dependency evidence covers only the `stackchan` environment, and the current Git/runtime and packed-object semantics are not fully byte-authorized. @@ -86,8 +98,8 @@ Selected experiment: `M0-004`, exact-source reproducible firmware and release-co Do not create or promote an allowlist from the same untrusted host evidence. PostBuild and candidate generation remain disabled until all three environments and the remaining Git/runtime semantics have independent authority. -- **Frozen baseline:** Committed source `b5ea5c5f`, the contained production bridge, installed - firmware of unknown SHA-256, the verified private backup, voice/vision/model workers, OTA and +- **Frozen baseline:** Committed source/governance head `3bf07730`, the contained production bridge, + installed firmware with only self-reported expected SHA `69d3db27...8ebfa8`, the verified private backup, voice/vision/model workers, OTA and camera authorization, automatic recovery, the 50 ms face gate, actuator ownership, and all physical evidence. - **Rollback:** Revert only the eventual atomic M0 governance commit if a frozen invariant diff --git a/TASK_LEDGER.md b/TASK_LEDGER.md index c53d0c76..c0e3cb64 100644 --- a/TASK_LEDGER.md +++ b/TASK_LEDGER.md @@ -115,15 +115,20 @@ Ledger timestamp: 2026-08-03 America/New_York digest regions; this is the accepted expected-red evidence. PR #218 was reviewed read-only and will not be merged or cherry-picked because its hook inheritance, override handling, dirty-tree detection, and unrelated bridge-test change do not meet this gate. The public boot-motion - prerequisite is now committed at `b5ea5c5f`. The dirty working tree implements deterministic - inputs, source/dependency/toolchain analysis, safe packaging/verification, and hardened - publication paths. Diagnostic v8 is expressly dirty, diagnostic-only, non-release-eligible, + prerequisite is committed at `b5ea5c5f`. The deterministic input, source/dependency/toolchain, + safe packaging/verification, publication, consumer, and CI corrections are committed through + `3bf07730` and published on draft PR #220 with all checks green. The current selector-authority + slice binds one exact `boot_app0.bin` per firmware environment to reviewed framework identity, + size, and SHA-256; makes the release flasher write it at `0xE000` from locked, second-verified + snapshot bytes; and locks/verifies standalone publication assets. Diagnostic v13 proved the + five-file package inventory and selector address order while remaining expressly dirty, + diagnostic-only, non-release-eligible, non-flashable, and not reproducibility proof. No reviewed toolchain allowlist exists; fresh canonical dependency evidence covers only `stackchan`; PostBuild/candidate generation and every release-grade/eligibility path therefore remain fail closed. No reproducibility or hardware claim is earned yet. -- **Commit:** Boot-motion prerequisite `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`; - M0-004 implementation commit pending final verification and state reconciliation. +- **Commit:** Release-governance head `3bf07730960cbbcfd502c0157434abb157ee1cc8`; + OTA-selector-authority slice pending commit after full regression and review. - **Decision:** Continue the atomic governance slice, but do not mark it complete or generate an eligible package until command/toolchain authority, all three clean environments, and independent review are actually closed. @@ -160,15 +165,15 @@ Ledger timestamp: 2026-08-03 America/New_York ## M0-STATE-TRUTH — Reconcile Governance And Recovery Evidence -- **Problem:** The committed boot-motion correction, dirty M0 implementation, diagnostic package, +- **Problem:** The committed boot-motion/governance correction, current selector slice, diagnostic package, private firmware backup, and historical deployment prose could be read as one transferable release or physical identity. - **User-facing consequence:** A diagnostic ZIP or backup-time observation could be flashed, published, or cited as current qualification without a clean source/toolchain/device binding. -- **Evidence:** HEAD `b5ea5c5f`; dirty M0 worktree; diagnostic v8 manifest with release/flash/ - hardware eligibility false; verified private three-read backup; backup-time `app0` selection with - unknown source mapping; repeated `/debug` timeouts; matching CoreS3 PnP identity present on COM4 - without opening serial. +- **Evidence:** committed head `3bf07730`; diagnostic v13 manifest with release/flash/hardware/ + distribution eligibility false; verified private three-read backup; backup-time `app0` selection + with unknown source mapping; fresh intermittent `/debug` successes/timeouts; matching CoreS3 PnP + identity present on COM4 without opening serial. - **Priority:** P0 documentation and recovery truth before commit. - **Owner:** `/root`, with independent read-only Luna state audit. - **Allowed files:** `PROJECT_STATE.md`, `TASK_LEDGER.md`, `docs/FIRST_DEPLOY_STATUS.md`, and @@ -181,10 +186,14 @@ Ledger timestamp: 2026-08-03 America/New_York whitespace checks pass. - **Stop conditions:** Any update would expose private backup content, infer a current application from USB/network absence or presence, strengthen historical evidence, or authorize restore/flash. -- **Result:** Complete. State reconciliation records the committed correction, fail-closed M0 - status, verified backup limits, current COM4 enumeration, continued debug unavailability, and no - serial/control/flash/motion action. Independent read-only review found no remaining identity, - qualification, historical-evidence, or recovery-authority contradiction. +- **Result:** Complete for the latest snapshot. State reconciliation records the committed + governance head, fail-closed M0 status, verified backup limits, current COM4 enumeration, and + recovered intermittent debug. The live firmware self-reports confirmed `app0` and expected + `69d3db27...8ebfa8`, while independent current bytes remain unproven. Runtime motion, rail, + torque, and power authorities were off, but the installed image reports both motion and + autonomous motion enabled at boot. No serial/control/flash/motion action occurred. Independent + read-only review preserved the distinction between live self-report, backup extraction, source, + package, and physical qualification. - **Commit:** Pending with the atomic M0 governance slice. - **Decision:** Accept the reconciliation while keeping release and hardware promotion on hold; the backup remains recovery evidence only. diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index 6c7f54b1..ef4a6104 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -4,9 +4,10 @@ Use this when bringing up a physical Stackchan device from the public `v0.2.0` r locally rebuilt or post-release firmware as a new candidate until its applicable evidence gates below are complete. -Repository-truth warning (2026-08-03): the currently installed firmware SHA is unknown. Dated -“installed,” “current,” and “live” notes below are historical evidence and cannot replace discovery, -exact source/binary identity, or qualification of the image actually under test. +Repository-truth warning (2026-08-03): live firmware now self-reports confirmed `app0` and expected +SHA-256 `69d3db27...8ebfa8`, matching the historical accepted lead, but current flash bytes have not +been independently read back. Dated “installed,” “current,” and “live” notes below remain historical +evidence and cannot replace exact source/binary identity or qualification of the image under test. Current control-containment warning (2026-08-03): the committed SEC-002 source policy is `emergency_stop_only`. Its dashboard and legacy motion/wake runners refuse Resume, wake-reset, and @@ -25,10 +26,24 @@ motion-off, explicitly disables autonomous boot refresh, and is committed as command/toolchain trust is independently closed, two clean builds match for all three packaged environments, and the resulting exact package is verified and reviewed. Release-grade packaging currently refuses to run because no reviewed exact toolchain allowlist exists; diagnostic packages -are never flash or qualification inputs. The release installer must also deterministically write -the packaged OTA selector at `0xE000`; do not assume the live selector points to the application -written at `0x10000`. Until those gates and a fresh `/debug` motion-off snapshot succeed, the -physical step is `HOLD`, not a reason to reuse an older package or infer state from USB or ping. +are never flash or qualification inputs. The release package/flasher source now requires an OTA +selector bound to the exact legacy/release framework identities, 8,192-byte size, and reviewed +SHA-256, and deterministically writes it at `0xE000` between the partition table and application. +The operational flasher accepts only an explicit release ZIP, second-verifies a locked private +snapshot, derives checksums from that snapshot, and holds the four payloads read-locked through +esptool. Diagnostic v13 proved packaging, non-authorizing verification, selector authority, and +address ordering only; its release/flash/hardware/distribution flags remain false and the flasher +rejects it. A fresh `/debug` sample shows the current runtime request, +autonomous state, motion, rail, torque, and power authorities off, but also reports the installed +image was built with motion and autonomous motion enabled at boot. Until the toolchain, rollback, +exact eligible package, and P1 gates close, the physical step is `HOLD`, not a reason to reuse an +older package or infer state from USB or ping. + +Current read-only packet: ignored `output/private/p0-live-state-20260803`. Its successful debug JSON +is SHA-256 `070CA1CDEA6B78D7C15589559E204330716CB6CAD1542BE4BE6DE56DA5C594FB`. +Intermittent timeouts alternated with successful samples and increasing uptime at one boot; do not +classify them as a freeze. No local bridge or RVC process/listener was present, and the robot was in +bridge `offline` / network `backoff` with `tcp_connect_failed` at the captured sample. Private recovery evidence (2026-08-02): a verified three-read 16 MiB SPI-flash backup is preserved only under ignored `output/private/firmware-backups/20260802-233346-COM4`, with whole-flash SHA-256 diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index ca545699..b2d3aad8 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -16,10 +16,14 @@ off, and is committed as `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. The later M worktree can create and verify diagnostic-only packages, but release-grade packaging and `RequireReleaseEligible` verification intentionally fail closed because no reviewed exact toolchain allowlist exists. No clean reproducible replacement package, installation, physical -qualification, or soak exists yet. The current live application, motion request, servo rail, and -torque state remain unknown because repeated bounded `/debug` probes were unavailable. USB or ping -reachability alone is not a robot-health or actuator-state proof. Hold all flashing and physical -promotion until reproducible-build and toolchain-trust closure, an OTA-selector-safe installer, a +qualification, or soak exists yet. The release package/flasher source now carries a per-environment +`boot_app0.bin` bound to reviewed framework versions, exact 8,192-byte size, and SHA-256, and writes +it at `0xE000` between the partition table and application. The flasher uses a second-verified, +read-locked private ZIP snapshot and hashes locked payload streams against that snapshot before +esptool. Publication holds staged assets read-locked through upload and downloads the standalone +firmware assets for remote hash verification. A diagnostic-only v13 rehearsal verified those +contracts, but it is not a flash or qualification input. +Hold all flashing and physical promotion until reproducible-build and toolchain-trust closure, a reviewed rollback path, exact clean package verification, and a fresh passive no-motion preflight are complete. @@ -32,10 +36,26 @@ The whole-flash hash is not an application hash. This backup does not prove the current bytes, release identity, or automatic restore authority, and it must never be published. Bounded read-only update on 2026-08-03: the matching CoreS3 USB PnP identity is present on COM4; -the unrelated CH340 remains separate on COM3. Neither port was opened. Three `/debug` requests to -`192.168.1.238:8789` timed out and no local bridge/RVC/debug listener was present on ports 8765, -5059, or 8789. This records endpoint/service unavailability only; it is not evidence of a freeze, -reset, blackout, USB fault, board fault, or power root cause. +the unrelated CH340 remains separate on COM3. Neither port was opened. Later bounded `/debug` +retries intermittently returned HTTP 200 between isolated timeouts. A preserved successful sample +under ignored `output/private/p0-live-state-20260803` reports increasing uptime, `boot_count=1`, +`reset_reason=poweron`, motion request/autonomous/enabled false, servo rail and torque false, and +both power motion/rail authority false. It also self-reports confirmed `app0` with expected SHA-256 +`69d3db27f2d7197799fdc08ff3c1dc4d6e3011724fe29899367dc016e48ebfa8`. That value agrees with the +historical accepted lead but is not an independent current flash-byte readback. The same sample +reports `motion_enabled_at_boot=1` and `motion_autonomous_at_boot=1`, so the installed image is not +the planned P1 no-motion candidate even though its runtime actuator state was safely off. + +No local bridge/RVC/debug listener was present on ports 8765, 5059, or 8789, and the robot reported +`network_state=backoff`, `bridge_state=offline`, and `network_error=tcp_connect_failed`. The +intermittent HTTP timeouts, followed by later successful samples with increasing uptime and the same +boot count, are not evidence of a freeze, reset, blackout, USB fault, board fault, or power root +cause. Diagnostic selector-authority rehearsal v13 is +`output/diagnostics/stackchan_alive_diagnostic-m0-selector-authority-v13.zip`, 189,427,490 bytes, +SHA-256 `15D5609CE6F1706FB4E5B9771CFFDF402473026FA1BC13E632029B174C04E926`. Its three 8,192-byte selector +entries each hash to `F94C5D786A7A8FAB06AC5D10E33BF37711A6697636DC037559EA19CC410A17F0`; +the non-authorizing verifier passes, the operational flasher rejects it before flash preparation, +and all release, hardware-validation, flash, and distribution eligibility flags are false. ## Last Owner-Accepted Physical Lead — Historical Evidence; Current Installation Unknown diff --git a/tools/firmware_reproducibility_proof.ps1 b/tools/firmware_reproducibility_proof.ps1 index bf494146..fa934d9d 100644 --- a/tools/firmware_reproducibility_proof.ps1 +++ b/tools/firmware_reproducibility_proof.ps1 @@ -97,8 +97,8 @@ function Assert-StackchanFirmwareReproducibilityProof { $cycleAArtifacts = @($Proof.cycleAArtifacts) $cycleBArtifacts = @($Proof.cycleBArtifacts) - if ($cycleAArtifacts.Count -ne 12 -or $cycleBArtifacts.Count -ne 12) { - throw "Firmware reproducibility proof must contain 12 artifacts per cycle" + if ($cycleAArtifacts.Count -ne 15 -or $cycleBArtifacts.Count -ne 15) { + throw "Firmware reproducibility proof must contain 15 artifacts per cycle" } $packageFirmwareRoots = @{ stackchan = "firmware/display_only" @@ -109,12 +109,12 @@ function Assert-StackchanFirmwareReproducibilityProof { $packageRootPrefix = $packageRootPath + [System.IO.Path]::DirectorySeparatorChar $expectedProofKeys = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) foreach ($expectedEnvironment in @($packageFirmwareRoots.Keys)) { - foreach ($expectedArtifact in @("firmware.bin", "firmware.elf", "bootloader.bin", "partitions.bin")) { + foreach ($expectedArtifact in @("firmware.bin", "firmware.elf", "bootloader.bin", "partitions.bin", "boot_app0.bin")) { [void]$expectedProofKeys.Add("$expectedEnvironment/$expectedArtifact") } } $seenProofKeys = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) - for ($artifactIndex = 0; $artifactIndex -lt 12; $artifactIndex++) { + for ($artifactIndex = 0; $artifactIndex -lt 15; $artifactIndex++) { $left = $cycleAArtifacts[$artifactIndex] $right = $cycleBArtifacts[$artifactIndex] if ($left.environment -cne $right.environment -or diff --git a/tools/flash_release_firmware.ps1 b/tools/flash_release_firmware.ps1 index 00385d83..4af8051f 100644 --- a/tools/flash_release_firmware.ps1 +++ b/tools/flash_release_firmware.ps1 @@ -119,6 +119,12 @@ if (-not [string]::IsNullOrWhiteSpace($PackageZip) -and -not [string]::IsNullOrWhiteSpace($PackageRoot)) { throw "Pass only one of -PackageZip or -PackageRoot." } +if (-not [string]::IsNullOrWhiteSpace($PackageRoot)) { + throw "Operational flashing requires -PackageZip so one private verified snapshot is the sole flash source." +} +if ([string]::IsNullOrWhiteSpace($PackageZip)) { + throw "Operational flashing requires an explicit -PackageZip." +} if (-not [string]::IsNullOrWhiteSpace($PackageZip) -and [string]::IsNullOrWhiteSpace($Version)) { @@ -147,31 +153,15 @@ if ([string]::IsNullOrWhiteSpace($ExpectedCommit)) { } } -if ([string]::IsNullOrWhiteSpace($PackageZip) -and - [string]::IsNullOrWhiteSpace($PackageRoot)) { - $candidateManifest = Join-Path $root "release_manifest.json" - if (Test-Path -LiteralPath $candidateManifest -PathType Leaf) { - $PackageRoot = $root - } else { - $PackageRoot = Join-Path $root "output/release/$Version" - } -} - $verifyScript = Join-Path $PSScriptRoot "verify_release_package.ps1" $verifyArgs = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $verifyScript, "-Version", $Version, "-ExpectedCommit", $ExpectedCommit, "-RequireReleaseEligible" ) -if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { - Assert-File $PackageZip - $PackageZip = (Resolve-Path -LiteralPath $PackageZip).Path - $verifyArgs += @("-ZipPath", $PackageZip) -} else { - Assert-File $PackageRoot - $PackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path - $verifyArgs += @("-PackageRoot", $PackageRoot) -} +Assert-File $PackageZip +$PackageZip = (Resolve-Path -LiteralPath $PackageZip).Path +$verifyArgs += @("-ZipPath", $PackageZip) if ($AllowDirtyPackage) { $verifyArgs += "-AllowDirtyPackage" } @@ -182,6 +172,121 @@ if ($LASTEXITCODE -ne 0) { . (Join-Path $PSScriptRoot "platformio_resolver.ps1") . (Join-Path $PSScriptRoot "release_zip_safety.ps1") +. (Join-Path $PSScriptRoot "release_ota_selector_policy.ps1") + +function Copy-ReleaseZipSnapshot { + param( + [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string]$DestinationPath + ) + + $sourceStream = [System.IO.FileStream]::new( + $SourcePath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read, 1MB, [System.IO.FileOptions]::SequentialScan) + $destinationStream = $null + $transitionStream = $null + $lockedReadStream = $null + try { + $destinationStream = [System.IO.FileStream]::new( + $DestinationPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::Read, 1MB, [System.IO.FileOptions]::SequentialScan) + $sourceStream.CopyTo($destinationStream) + $destinationStream.Flush($true) + $transitionStream = [System.IO.FileStream]::new( + $DestinationPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::ReadWrite, 4096, [System.IO.FileOptions]::SequentialScan) + $destinationStream.Dispose() + $destinationStream = $null + $lockedReadStream = [System.IO.FileStream]::new( + $DestinationPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read, 1MB, [System.IO.FileOptions]::SequentialScan) + $transitionStream.Dispose() + $transitionStream = $null + return $lockedReadStream + } catch { + if ($null -ne $destinationStream) { $destinationStream.Dispose() } + if ($null -ne $transitionStream) { $transitionStream.Dispose() } + if ($null -ne $lockedReadStream) { $lockedReadStream.Dispose() } + throw + } finally { + $sourceStream.Dispose() + } +} + +function Get-LockedReleasePayloadSha256 { + param([Parameter(Mandatory = $true)][System.IO.FileStream]$Stream) + + $Stream.Position = 0 + $hasher = [System.Security.Cryptography.SHA256]::Create() + try { + return ([System.BitConverter]::ToString($hasher.ComputeHash($Stream)) -replace '-', '').ToUpperInvariant() + } finally { + $hasher.Dispose() + $Stream.Position = 0 + } +} + +function Get-LockedReleaseZipChecksumRecords { + param([Parameter(Mandatory = $true)][System.IO.FileStream]$SnapshotStream) + + Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop + $SnapshotStream.Position = 0 + $archive = [System.IO.Compression.ZipArchive]::new( + $SnapshotStream, [System.IO.Compression.ZipArchiveMode]::Read, $true) + try { + $checksumEntries = @($archive.Entries | Where-Object { + [string]$_.FullName -ceq 'SHA256SUMS.txt' + }) + if ($checksumEntries.Count -ne 1 -or + [long]$checksumEntries[0].Length -lt 68 -or + [long]$checksumEntries[0].Length -gt 10MB) { + throw 'Locked release ZIP does not contain one bounded canonical SHA256SUMS.txt entry.' + } + $entryStream = $checksumEntries[0].Open() + $reader = [System.IO.StreamReader]::new( + $entryStream, [System.Text.Encoding]::ASCII, $false, 4096, $false) + try { + $checksumText = $reader.ReadToEnd() + } finally { + $reader.Dispose() + } + } finally { + $archive.Dispose() + $SnapshotStream.Position = 0 + } + + $records = @{} + foreach ($line in @($checksumText -split '\r?\n')) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + if ($line -notmatch '^([a-f0-9]{64}) (.+)$') { + throw "Invalid locked-snapshot checksum record: $line" + } + if ($records.ContainsKey($Matches[2])) { + throw "Duplicate locked-snapshot checksum record: $($Matches[2])" + } + $records[$Matches[2]] = $Matches[1].ToUpperInvariant() + } + if ($records.Count -eq 0) { + throw 'Locked release ZIP checksum authority is empty.' + } + return $records +} + +function Get-ReleaseFlashWriteArguments { + param( + [Parameter(Mandatory = $true)][string]$Bootloader, + [Parameter(Mandatory = $true)][string]$Partitions, + [Parameter(Mandatory = $true)][string]$OtaSelector, + [Parameter(Mandatory = $true)][string]$FirmwareBin + ) + + return @( + '0x0', $Bootloader, + '0x8000', $Partitions, + '0xe000', $OtaSelector, + '0x10000', $FirmwareBin + ) +} if ($Firmware -in @("servo_calibration", "full_online")) { Write-Warning "$Firmware firmware contains motor support. Keep the body clear and powered safely." @@ -190,16 +295,37 @@ if ($Firmware -in @("servo_calibration", "full_online")) { } } +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "stackchan-release-flash" $cleanupDir = $null +$snapshotLock = $null try { - if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { - $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "stackchan-release-flash" - $cleanupDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) - New-Item -ItemType Directory -Force -Path $cleanupDir | Out-Null - Expand-StackchanReleaseZipSafely ` - -ZipPath $PackageZip -DestinationPath $cleanupDir - $PackageRoot = $cleanupDir + $cleanupDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $cleanupDir | Out-Null + $snapshotZip = Join-Path $cleanupDir 'verified-input.zip' + $snapshotLock = Copy-ReleaseZipSnapshot ` + -SourcePath $PackageZip -DestinationPath $snapshotZip + $snapshotSha256 = (Get-LockedReleasePayloadSha256 -Stream $snapshotLock).ToLowerInvariant() + [System.IO.File]::WriteAllText( + "$snapshotZip.sha256", "$snapshotSha256 verified-input.zip`n", + [System.Text.Encoding]::ASCII) + + $snapshotVerifyArgs = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $verifyScript, + "-Version", $Version, "-ExpectedCommit", $ExpectedCommit, + "-RequireReleaseEligible", "-ZipPath", $snapshotZip + ) + if ($AllowDirtyPackage) { + $snapshotVerifyArgs += "-AllowDirtyPackage" } + & powershell.exe @snapshotVerifyArgs + if ($LASTEXITCODE -ne 0) { + throw "Private release ZIP snapshot failed eligibility verification." + } + $checksumRecords = Get-LockedReleaseZipChecksumRecords -SnapshotStream $snapshotLock + + $PackageRoot = Join-Path $cleanupDir 'package' + Expand-StackchanReleaseZipSafely ` + -ZipPath $snapshotZip -DestinationPath $PackageRoot Assert-File $PackageRoot $manifestPath = Join-Path $PackageRoot "release_manifest.json" @@ -214,10 +340,49 @@ try { $firmwareDir = Join-Path $PackageRoot "firmware/$Firmware" $bootloader = Join-Path $firmwareDir "bootloader.bin" $partitions = Join-Path $firmwareDir "partitions.bin" + $otaSelector = Join-Path $firmwareDir "boot_app0.bin" $firmwareBin = Join-Path $firmwareDir "firmware.bin" Assert-File $bootloader Assert-File $partitions + Assert-File $otaSelector Assert-File $firmwareBin + $flashPayloads = @( + [ordered]@{ relative = "firmware/$Firmware/bootloader.bin"; path = $bootloader }, + [ordered]@{ relative = "firmware/$Firmware/partitions.bin"; path = $partitions }, + [ordered]@{ relative = "firmware/$Firmware/boot_app0.bin"; path = $otaSelector }, + [ordered]@{ relative = "firmware/$Firmware/firmware.bin"; path = $firmwareBin } + ) + $payloadLocks = New-Object System.Collections.Generic.List[System.IO.FileStream] + $payloadLocksByRelative = @{} + try { + foreach ($payload in $flashPayloads) { + $payloadLock = [System.IO.FileStream]::new( + [string]$payload.path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read, 4096, [System.IO.FileOptions]::SequentialScan) + $payloadLocks.Add($payloadLock) + $payloadLocksByRelative[[string]$payload.relative] = $payloadLock + } + if ($payloadLocksByRelative["firmware/$Firmware/boot_app0.bin"].Length -ne 8192) { + throw "Packaged OTA selector must be exactly 8192 bytes." + } + $selectorEnvironment = switch ($Firmware) { + 'display_only' { 'stackchan' } + 'servo_calibration' { 'stackchan_servo_calibration' } + 'full_online' { 'stackchan_release_full' } + } + Assert-StackchanReleaseOtaSelectorBytes ` + -Environment $selectorEnvironment -LiteralPath $otaSelector | Out-Null + + foreach ($payload in $flashPayloads) { + if (-not $checksumRecords.ContainsKey([string]$payload.relative)) { + throw "Package checksum is missing flash payload: $([string]$payload.relative)" + } + $actualPayloadHash = Get-LockedReleasePayloadSha256 ` + -Stream $payloadLocksByRelative[[string]$payload.relative] + if ($actualPayloadHash -cne [string]$checksumRecords[[string]$payload.relative]) { + throw "Flash payload changed after snapshot verification: $([string]$payload.relative)" + } + } $esptool = Get-EsptoolInvocation $esptoolArgs = @($esptool.BaseArgs) + @( @@ -243,22 +408,22 @@ try { "--flash_freq", "80m", "--flash_size", - "16MB", - "0x0", - $bootloader, - "0x8000", - $partitions, - "0x10000", - $firmwareBin + "16MB" ) + $esptoolArgs += Get-ReleaseFlashWriteArguments ` + -Bootloader $bootloader -Partitions $partitions ` + -OtaSelector $otaSelector -FirmwareBin $firmwareBin - if ($DryRun) { - Write-Host "Dry run: $(Format-Command @($esptool.Python)) $(Format-Command $esptoolArgs)" - } else { - & $esptool.Python @esptoolArgs - if ($LASTEXITCODE -ne 0) { - throw "esptool flashing failed with exit code $LASTEXITCODE" + if ($DryRun) { + Write-Host "Dry run: $(Format-Command @($esptool.Python)) $(Format-Command $esptoolArgs)" + } else { + & $esptool.Python @esptoolArgs + if ($LASTEXITCODE -ne 0) { + throw "esptool flashing failed with exit code $LASTEXITCODE" + } } + } finally { + foreach ($payloadLock in $payloadLocks) { $payloadLock.Dispose() } } if ($Monitor) { @@ -277,6 +442,10 @@ try { } } } finally { + if ($null -ne $snapshotLock) { + $snapshotLock.Dispose() + $snapshotLock = $null + } if ($cleanupDir -and (Test-Path -LiteralPath $cleanupDir)) { $resolvedCleanup = (Resolve-Path $cleanupDir).Path $resolvedTempRoot = (Resolve-Path $tempRoot).Path diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index c11cd995..e3b4510b 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -210,7 +210,8 @@ function Assert-ReleaseBootstrapTrust { 'tools/platformio_resolver.ps1', 'tools/preview_python_resolver.ps1', 'tools/release_asset_contract.ps1', 'tools/firmware_reproducibility_failure.ps1', 'tools/release_source_binding.ps1', 'tools/release_dependency_evidence.ps1', - 'tools/release_git_trust.ps1', 'tools/check_release_credential_hygiene.ps1' + 'tools/release_git_trust.ps1', 'tools/release_ota_selector_policy.ps1', + 'tools/check_release_credential_hygiene.ps1' ) foreach ($relative in $bootstrapFiles) { $indexRecord = @(Invoke-ReleaseBootstrapGit -Root $resolvedRoot -Arguments @( @@ -468,6 +469,7 @@ if ($LASTEXITCODE -ne 0) { . (Join-Path $PSScriptRoot "release_source_binding.ps1") . (Join-Path $PSScriptRoot "release_dependency_evidence.ps1") . (Join-Path $PSScriptRoot "release_git_trust.ps1") +. (Join-Path $PSScriptRoot "release_ota_selector_policy.ps1") $credentialHygieneJson = (& (Join-Path $PSScriptRoot "check_release_credential_hygiene.ps1") -Root $repoRoot -Json | Out-String) $credentialHygieneReport = $credentialHygieneJson | ConvertFrom-Json @@ -477,6 +479,17 @@ if ($credentialHygieneReport.status -ne "release-credential-hygiene-ready" -or [ Write-Host $credentialHygieneJson.Trim() $releaseLegacyPlatformioCore = Get-StackchanPlatformioCoreDir +if ([string]::IsNullOrWhiteSpace($releaseLegacyPlatformioCore) -and + -not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + $legacyCoreFallback = Join-Path $env:USERPROFILE '.platformio' + if (Test-Path -LiteralPath $legacyCoreFallback -PathType Container) { + $releaseLegacyPlatformioCore = (Resolve-Path -LiteralPath $legacyCoreFallback).Path + } +} +if ([string]::IsNullOrWhiteSpace($releaseLegacyPlatformioCore) -or + -not (Test-Path -LiteralPath $releaseLegacyPlatformioCore -PathType Container)) { + throw 'Release packaging could not resolve the legacy PlatformIO core directory.' +} $releasePlatformioCoreRoot = if ($env:OS -eq "Windows_NT") { # The repository may be running through a temporary subst drive. Keep the # pioarduino core on the physical system drive so it survives that mapping @@ -495,6 +508,15 @@ function Get-ReleasePlatformioCoreDir { return $releaseLegacyPlatformioCore } +function Get-ReleaseOtaSelectorPath { + param([Parameter(Mandatory = $true)][string]$Environment) + + $coreDir = Get-ReleasePlatformioCoreDir -Environment $Environment + $selector = Assert-StackchanReleaseFrameworkOtaSelector ` + -Environment $Environment -CoreDir $coreDir + return [string]$selector.path +} + function Invoke-StackchanReleasePlatformio { param( [string]$Environment, @@ -555,27 +577,34 @@ if ($SkipBuild -and -not $Version.StartsWith("diagnostic-", [System.StringCompar throw "Diagnostic -SkipBuild package versions must start with 'diagnostic-'." } -$firmwareArtifactNames = @( +$firmwareBuildArtifactNames = @( "firmware.bin", "firmware.elf", "bootloader.bin", "partitions.bin" ) +$firmwareArtifactNames = @($firmwareBuildArtifactNames + "boot_app0.bin") function Copy-BuildArtifacts { param( + [Parameter(Mandatory = $true)][string]$Environment, [string]$BuildDir, [string]$Destination ) New-Item -ItemType Directory -Force -Path $Destination | Out-Null - foreach ($file in $firmwareArtifactNames) { + foreach ($file in $firmwareBuildArtifactNames) { $source = Join-Path $BuildDir $file if (-not (Test-Path -LiteralPath $source)) { throw "Missing build artifact: $source" } Copy-Item -LiteralPath $source -Destination $Destination -Force } + $otaSelector = Get-ReleaseOtaSelectorPath -Environment $Environment + $packagedSelector = Join-Path $Destination "boot_app0.bin" + Copy-Item -LiteralPath $otaSelector -Destination $packagedSelector -Force + Assert-StackchanReleaseOtaSelectorBytes ` + -Environment $Environment -LiteralPath $packagedSelector | Out-Null } $releaseOutputRoot = if ($SkipBuild) { @@ -854,6 +883,7 @@ function Invoke-FirmwareBuildCycle { -LogPath (Join-Path $CycleRoot "logs/$environment-build.log") ` -Description "$CycleName/$environment build" | Out-Null Copy-BuildArtifacts ` + -Environment $environment ` -BuildDir (Join-Path $BuildProjectRoot ".pio/build/$environment") ` -Destination (Join-Path $CycleRoot $environment) Assert-ReleaseSourceIdentity ` @@ -1069,8 +1099,8 @@ if (-not $SkipBuild) { -Phase "reproducibility proof post-cycle-b" ` -ProjectRoot $activeBuildSourceRoot - if ($cycleAArtifacts.Count -ne 12 -or $cycleBArtifacts.Count -ne 12) { - throw "Firmware reproducibility proof did not produce all 12 artifacts per cycle." + if ($cycleAArtifacts.Count -ne 15 -or $cycleBArtifacts.Count -ne 15) { + throw "Firmware reproducibility proof did not produce all 15 artifacts per cycle." } for ($artifactIndex = 0; $artifactIndex -lt $cycleAArtifacts.Count; $artifactIndex++) { $left = $cycleAArtifacts[$artifactIndex] @@ -1298,11 +1328,12 @@ function Copy-SourceTree { function Copy-FirmwareSet { param( + [Parameter(Mandatory = $true)][string]$Environment, [string]$BuildDir, [string]$Destination ) - Copy-BuildArtifacts -BuildDir $BuildDir -Destination $Destination + Copy-BuildArtifacts -Environment $Environment -BuildDir $BuildDir -Destination $Destination } $firmwareSourceRoot = if ($builtFirmwareCache) { @@ -1310,9 +1341,9 @@ $firmwareSourceRoot = if ($builtFirmwareCache) { } else { Join-Path $repoRoot ".pio/build" } -Copy-FirmwareSet -BuildDir (Join-Path $firmwareSourceRoot "stackchan") -Destination $displayFirmwareDir -Copy-FirmwareSet -BuildDir (Join-Path $firmwareSourceRoot "stackchan_servo_calibration") -Destination $servoFirmwareDir -Copy-FirmwareSet -BuildDir (Join-Path $firmwareSourceRoot "stackchan_release_full") -Destination $fullOnlineFirmwareDir +Copy-FirmwareSet -Environment "stackchan" -BuildDir (Join-Path $firmwareSourceRoot "stackchan") -Destination $displayFirmwareDir +Copy-FirmwareSet -Environment "stackchan_servo_calibration" -BuildDir (Join-Path $firmwareSourceRoot "stackchan_servo_calibration") -Destination $servoFirmwareDir +Copy-FirmwareSet -Environment "stackchan_release_full" -BuildDir (Join-Path $firmwareSourceRoot "stackchan_release_full") -Destination $fullOnlineFirmwareDir $mediaFiles = @( "docs/media/stackchan_alive_preview.png", @@ -1740,6 +1771,9 @@ $releaseTools = @( "tools/release_dependency_evidence.ps1", "tools/test_release_dependency_evidence_contract.ps1", "tools/release_git_trust.ps1", + "tools/release_ota_selector_policy.ps1", + "tools/test_release_ota_selector_policy_contract.ps1", + "tools/test_release_flash_snapshot_contract.ps1", "tools/test_release_package_verifier_trust_contract.ps1", "tools/test_release_source_binding_contract.ps1", "tools/release_zip_safety.ps1", diff --git a/tools/publish_release.ps1 b/tools/publish_release.ps1 index ebd0df83..eb500197 100644 --- a/tools/publish_release.ps1 +++ b/tools/publish_release.ps1 @@ -338,6 +338,94 @@ function Invoke-OperationalPackageVerification { } } +function Get-PublicationLockedSha256 { + param([Parameter(Mandatory = $true)][System.IO.FileStream]$Stream) + + $Stream.Position = 0 + $hasher = [System.Security.Cryptography.SHA256]::Create() + try { + return ([System.BitConverter]::ToString($hasher.ComputeHash($Stream)) -replace '-', '').ToUpperInvariant() + } finally { + $hasher.Dispose() + $Stream.Position = 0 + } +} + +function Copy-LockedPublicationZipSnapshot { + param( + [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string]$DestinationPath + ) + + $sourceStream = [System.IO.FileStream]::new( + $SourcePath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read, 1MB, [System.IO.FileOptions]::SequentialScan) + $writer = $null + $transition = $null + $lockedReader = $null + try { + $writer = [System.IO.FileStream]::new( + $DestinationPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::Read, 1MB, [System.IO.FileOptions]::SequentialScan) + $sourceStream.CopyTo($writer) + $writer.Flush($true) + $transition = [System.IO.FileStream]::new( + $DestinationPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::ReadWrite, 4096, [System.IO.FileOptions]::SequentialScan) + $writer.Dispose() + $writer = $null + $lockedReader = [System.IO.FileStream]::new( + $DestinationPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read, 1MB, [System.IO.FileOptions]::SequentialScan) + $transition.Dispose() + $transition = $null + return $lockedReader + } catch { + if ($null -ne $writer) { $writer.Dispose() } + if ($null -ne $transition) { $transition.Dispose() } + if ($null -ne $lockedReader) { $lockedReader.Dispose() } + throw + } finally { + $sourceStream.Dispose() + } +} + +function Get-PublicationSnapshotChecksums { + param([Parameter(Mandatory = $true)][System.IO.FileStream]$SnapshotStream) + + Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop + $SnapshotStream.Position = 0 + $archive = [System.IO.Compression.ZipArchive]::new( + $SnapshotStream, [System.IO.Compression.ZipArchiveMode]::Read, $true) + try { + $entries = @($archive.Entries | Where-Object { + [string]$_.FullName -ceq 'SHA256SUMS.txt' + }) + if ($entries.Count -ne 1 -or [long]$entries[0].Length -gt 10MB) { + throw 'Locked publication snapshot lacks one bounded SHA256SUMS.txt authority.' + } + $reader = [System.IO.StreamReader]::new( + $entries[0].Open(), [System.Text.Encoding]::ASCII, $false, 4096, $false) + try { + $checksumText = $reader.ReadToEnd() + } finally { + $reader.Dispose() + } + } finally { + $archive.Dispose() + $SnapshotStream.Position = 0 + } + $records = @{} + foreach ($line in @($checksumText -split '\r?\n')) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + if ($line -notmatch '^([a-f0-9]{64}) (.+)$' -or $records.ContainsKey($Matches[2])) { + throw "Invalid locked publication checksum authority: $line" + } + $records[$Matches[2]] = $Matches[1].ToUpperInvariant() + } + return $records +} + function New-VerifiedPublicationSnapshot { param( [Parameter(Mandatory = $true)][string]$Version, @@ -352,10 +440,15 @@ function New-VerifiedPublicationSnapshot { $snapshotZip = Join-Path $snapshotRoot "stackchan_alive_$Version.zip" $snapshotSidecar = "$snapshotZip.sha256" $snapshotPackage = Join-Path $snapshotRoot "package" + $snapshotLock = $null try { New-Item -ItemType Directory -Path $snapshotRoot | Out-Null - Copy-Item -LiteralPath $ZipPath -Destination $snapshotZip - Copy-Item -LiteralPath $ZipSidecarPath -Destination $snapshotSidecar + $snapshotLock = Copy-LockedPublicationZipSnapshot ` + -SourcePath $ZipPath -DestinationPath $snapshotZip + $snapshotSha256 = (Get-PublicationLockedSha256 -Stream $snapshotLock).ToLowerInvariant() + [System.IO.File]::WriteAllText( + $snapshotSidecar, "$snapshotSha256 stackchan_alive_$Version.zip`n", + [System.Text.Encoding]::ASCII) Invoke-OperationalPackageVerification ` -Version $Version ` @@ -363,14 +456,21 @@ function New-VerifiedPublicationSnapshot { -ExpectedCommit $ExpectedCommit ` -AllowDirtyPackage:$AllowDirtyPackage | Out-Host Expand-StackchanReleaseZipSafely -ZipPath $snapshotZip -DestinationPath $snapshotPackage + $snapshotChecksums = Get-PublicationSnapshotChecksums -SnapshotStream $snapshotLock return [pscustomobject]@{ Root = $snapshotRoot ZipPath = $snapshotZip ZipSidecarPath = $snapshotSidecar PackageRoot = $snapshotPackage + ZipLock = $snapshotLock + Checksums = $snapshotChecksums } } catch { + if ($null -ne $snapshotLock) { + $snapshotLock.Dispose() + $snapshotLock = $null + } if (Test-Path -LiteralPath $snapshotRoot) { $resolvedSnapshot = (Resolve-Path -LiteralPath $snapshotRoot).Path $tempPrefix = $tempBase + [System.IO.Path]::DirectorySeparatorChar @@ -502,6 +602,7 @@ $snapshot = New-VerifiedPublicationSnapshot ` -ZipSidecarPath $zipSidecarPath ` -ExpectedCommit $tagCommit ` -AllowDirtyPackage:$AllowDirtyPackage +$finalAssetLocks = New-Object System.Collections.Generic.List[System.IO.FileStream] try { $publishedPackageRoot = $snapshot.PackageRoot $publishedZipPath = $snapshot.ZipPath @@ -513,6 +614,19 @@ try { Copy-Item -LiteralPath (Join-Path $publishedPackageRoot "firmware/servo_calibration/firmware.bin") -Destination (Join-Path $stageDir "firmware-servo-calibration.bin") Copy-Item -LiteralPath (Join-Path $publishedPackageRoot "firmware/display_only/bootloader.bin") -Destination (Join-Path $stageDir "bootloader.bin") Copy-Item -LiteralPath (Join-Path $publishedPackageRoot "firmware/display_only/partitions.bin") -Destination (Join-Path $stageDir "partitions.bin") + Copy-Item -LiteralPath (Join-Path $publishedPackageRoot "firmware/display_only/boot_app0.bin") -Destination (Join-Path $stageDir "boot-app0.bin") + + $finalReleaseAssetEntries = Get-ReleaseFinalAssetEntries -Version $Version -PackageRoot $publishedPackageRoot -ZipPath $publishedZipPath -ZipSidecarPath $publishedZipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage + $finalReleaseAssets = @($finalReleaseAssetEntries | ForEach-Object { $_.Path }) + $finalAssetLocksByPath = @{} + foreach ($assetPath in $finalReleaseAssets) { + $resolvedAssetPath = (Resolve-Path -LiteralPath $assetPath).Path + $assetLock = [System.IO.FileStream]::new( + $resolvedAssetPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read, 4096, [System.IO.FileOptions]::SequentialScan) + $finalAssetLocks.Add($assetLock) + $finalAssetLocksByPath[$resolvedAssetPath] = $assetLock + } Write-Host "Verify finalized release asset contract against the safe extraction of the exact verified ZIP." & (Join-Path $PSScriptRoot "verify_release_asset_contract.ps1") ` @@ -523,8 +637,27 @@ try { -FirmwareAssetRoot $stageDir ` -FirmwareAssetPathMode Stage - $finalReleaseAssetEntries = Get-ReleaseFinalAssetEntries -Version $Version -PackageRoot $publishedPackageRoot -ZipPath $publishedZipPath -ZipSidecarPath $publishedZipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage - $finalReleaseAssets = @($finalReleaseAssetEntries | ForEach-Object { $_.Path }) + $firmwareSnapshotBindings = @( + [ordered]@{ name = 'firmware-display-only.bin'; relative = 'firmware/display_only/firmware.bin' }, + [ordered]@{ name = 'firmware-servo-calibration.bin'; relative = 'firmware/servo_calibration/firmware.bin' }, + [ordered]@{ name = 'bootloader.bin'; relative = 'firmware/display_only/bootloader.bin' }, + [ordered]@{ name = 'partitions.bin'; relative = 'firmware/display_only/partitions.bin' }, + [ordered]@{ name = 'boot-app0.bin'; relative = 'firmware/display_only/boot_app0.bin' } + ) + foreach ($binding in $firmwareSnapshotBindings) { + $entry = @($finalReleaseAssetEntries | Where-Object { + [string]$_.Name -ceq [string]$binding.name + }) + if ($entry.Count -ne 1 -or + -not $snapshot.Checksums.ContainsKey([string]$binding.relative)) { + throw "Publication snapshot lacks exact firmware authority: $([string]$binding.name)" + } + $entryPath = (Resolve-Path -LiteralPath ([string]$entry[0].Path)).Path + $lockedHash = Get-PublicationLockedSha256 -Stream $finalAssetLocksByPath[$entryPath] + if ($lockedHash -cne [string]$snapshot.Checksums[[string]$binding.relative]) { + throw "Staged firmware asset does not match locked publication ZIP: $([string]$binding.name)" + } + } $releaseExists = $false if (-not $DryRun) { @@ -614,5 +747,11 @@ try { Write-Host "Release dry run passed without creating, pushing, or uploading anything." } } finally { + foreach ($finalAssetLock in $finalAssetLocks) { + $finalAssetLock.Dispose() + } + if ($null -ne $snapshot.ZipLock) { + $snapshot.ZipLock.Dispose() + } Remove-VerifiedPublicationSnapshot -SnapshotRoot $snapshot.Root } diff --git a/tools/release_asset_contract.ps1 b/tools/release_asset_contract.ps1 index 9e3b25bd..77e15cf2 100644 --- a/tools/release_asset_contract.ps1 +++ b/tools/release_asset_contract.ps1 @@ -32,6 +32,7 @@ function Get-ReleaseFirmwareAssetEntries { New-ReleaseAssetEntry -Name "firmware-servo-calibration.bin" -Path (Join-Path $FirmwareAssetRoot "firmware-servo-calibration.bin") -Phase $Phase New-ReleaseAssetEntry -Name "bootloader.bin" -Path (Join-Path $FirmwareAssetRoot "bootloader.bin") -Phase $Phase New-ReleaseAssetEntry -Name "partitions.bin" -Path (Join-Path $FirmwareAssetRoot "partitions.bin") -Phase $Phase + New-ReleaseAssetEntry -Name "boot-app0.bin" -Path (Join-Path $FirmwareAssetRoot "boot-app0.bin") -Phase $Phase ) } @@ -40,6 +41,7 @@ function Get-ReleaseFirmwareAssetEntries { New-ReleaseAssetEntry -Name "firmware-servo-calibration.bin" -Path (Join-Path $PackageRoot "firmware/servo_calibration/firmware.bin") -Phase $Phase New-ReleaseAssetEntry -Name "bootloader.bin" -Path (Join-Path $PackageRoot "firmware/display_only/bootloader.bin") -Phase $Phase New-ReleaseAssetEntry -Name "partitions.bin" -Path (Join-Path $PackageRoot "firmware/display_only/partitions.bin") -Phase $Phase + New-ReleaseAssetEntry -Name "boot-app0.bin" -Path (Join-Path $PackageRoot "firmware/display_only/boot_app0.bin") -Phase $Phase ) } diff --git a/tools/release_ota_selector_policy.ps1 b/tools/release_ota_selector_policy.ps1 new file mode 100644 index 00000000..09b9e3f3 --- /dev/null +++ b/tools/release_ota_selector_policy.ps1 @@ -0,0 +1,152 @@ +$script:StackchanReleaseOtaSelectorSchema = 'stackchan.release-ota-selector-policy.v1' +$script:StackchanReleaseOtaSelectorSha256 = ` + 'F94C5D786A7A8FAB06AC5D10E33BF37711A6697636DC037559EA19CC410A17F0' + +function Get-StackchanReleaseOtaSelectorPolicy { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('stackchan', 'stackchan_servo_calibration', 'stackchan_release_full')] + [string]$Environment + ) + + $frameworkVersion = if ($Environment -ceq 'stackchan_release_full') { + '3.3.6' + } else { + '3.20017.241212+sha.dcc1105b' + } + return [pscustomobject][ordered]@{ + schema = $script:StackchanReleaseOtaSelectorSchema + environment = $Environment + frameworkPackageName = 'framework-arduinoespressif32' + frameworkPackageVersion = $frameworkVersion + selectorRelativePath = 'tools/partitions/boot_app0.bin' + exactBytes = 8192 + sha256 = $script:StackchanReleaseOtaSelectorSha256 + } +} + +function Get-StackchanReleaseOtaSelectorSha256 { + param([Parameter(Mandatory = $true)][string]$LiteralPath) + + $stream = [System.IO.FileStream]::new( + $LiteralPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read, + 1MB, + [System.IO.FileOptions]::SequentialScan) + $hasher = [System.Security.Cryptography.SHA256]::Create() + try { + return ([System.BitConverter]::ToString($hasher.ComputeHash($stream)) -replace '-', '').ToUpperInvariant() + } finally { + $hasher.Dispose() + $stream.Dispose() + } +} + +function Assert-StackchanReleaseOtaSelectorBytes { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('stackchan', 'stackchan_servo_calibration', 'stackchan_release_full')] + [string]$Environment, + [Parameter(Mandatory = $true)][string]$LiteralPath + ) + + $policy = Get-StackchanReleaseOtaSelectorPolicy -Environment $Environment + if (-not (Test-Path -LiteralPath $LiteralPath -PathType Leaf)) { + throw "Missing reviewed OTA selector for $Environment`: $LiteralPath" + } + $item = Get-Item -LiteralPath $LiteralPath -Force + if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "Reviewed OTA selector may not be a reparse point for $Environment`: $LiteralPath" + } + if ([long]$item.Length -ne [long]$policy.exactBytes) { + throw "OTA selector byte count is not authorized for $Environment`: $($item.Length)" + } + $actualSha256 = Get-StackchanReleaseOtaSelectorSha256 -LiteralPath $item.FullName + if ($actualSha256 -cne [string]$policy.sha256) { + throw "OTA selector SHA-256 is not authorized for $Environment`: $actualSha256" + } + return [pscustomobject][ordered]@{ + policy = $policy + path = $item.FullName + bytes = [long]$item.Length + sha256 = $actualSha256 + } +} + +function Assert-StackchanReleaseFrameworkOtaSelector { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('stackchan', 'stackchan_servo_calibration', 'stackchan_release_full')] + [string]$Environment, + [Parameter(Mandatory = $true)][string]$CoreDir + ) + + $policy = Get-StackchanReleaseOtaSelectorPolicy -Environment $Environment + if (-not (Test-Path -LiteralPath $CoreDir -PathType Container)) { + throw "Missing PlatformIO core for OTA selector policy: $CoreDir" + } + $coreItem = Get-Item -LiteralPath $CoreDir -Force + if ($coreItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "Selected PlatformIO core may not be a reparse point for $Environment`: $CoreDir" + } + $resolvedCore = $coreItem.FullName.TrimEnd('\', '/') + $frameworkRoot = Join-Path $resolvedCore ( + 'packages/' + [string]$policy.frameworkPackageName) + if (-not (Test-Path -LiteralPath $frameworkRoot -PathType Container)) { + throw "Missing reviewed framework package for $Environment`: $frameworkRoot" + } + $frameworkItem = Get-Item -LiteralPath $frameworkRoot -Force + if ($frameworkItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "Reviewed framework package may not be a reparse point for $Environment`: $frameworkRoot" + } + $frameworkRoot = $frameworkItem.FullName.TrimEnd('\', '/') + $corePrefix = $resolvedCore + [System.IO.Path]::DirectorySeparatorChar + if (-not $frameworkRoot.StartsWith($corePrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Reviewed framework package escaped the selected PlatformIO core for $Environment." + } + + $metadataPath = Join-Path $frameworkRoot 'package.json' + if (-not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) { + throw "Missing framework package identity for $Environment`: $metadataPath" + } + $metadataItem = Get-Item -LiteralPath $metadataPath -Force + if ($metadataItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "Framework package identity may not be a reparse point for $Environment`: $metadataPath" + } + try { + $metadata = Get-Content -LiteralPath $metadataItem.FullName -Raw | ConvertFrom-Json + } catch { + throw "Framework package identity is unreadable for $Environment`: $($_.Exception.Message)" + } + if ([string]$metadata.name -cne [string]$policy.frameworkPackageName -or + [string]$metadata.version -cne [string]$policy.frameworkPackageVersion) { + throw ( + "Framework package identity is not authorized for $Environment`: " + + "$([string]$metadata.name)@$([string]$metadata.version)") + } + + $relativeParts = ([string]$policy.selectorRelativePath).Split('/') + $selectorPath = $frameworkRoot + foreach ($part in $relativeParts) { + if ([string]::IsNullOrWhiteSpace($part) -or $part -in @('.', '..')) { + throw "Unsafe OTA selector policy path for $Environment." + } + $selectorPath = Join-Path $selectorPath $part + if (-not (Test-Path -LiteralPath $selectorPath)) { + throw "Missing OTA selector policy path component for $Environment`: $selectorPath" + } + $component = Get-Item -LiteralPath $selectorPath -Force + if ($component.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "OTA selector policy path may not contain reparse points for $Environment`: $selectorPath" + } + } + $selectorPath = (Get-Item -LiteralPath $selectorPath -Force).FullName + $frameworkPrefix = $frameworkRoot + [System.IO.Path]::DirectorySeparatorChar + if (-not $selectorPath.StartsWith($frameworkPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "OTA selector escaped the reviewed framework package for $Environment." + } + return Assert-StackchanReleaseOtaSelectorBytes ` + -Environment $Environment -LiteralPath $selectorPath +} diff --git a/tools/test_firmware_reproducibility_proof_contract.ps1 b/tools/test_firmware_reproducibility_proof_contract.ps1 index efda2ae3..0434bd29 100644 --- a/tools/test_firmware_reproducibility_proof_contract.ps1 +++ b/tools/test_firmware_reproducibility_proof_contract.ps1 @@ -64,7 +64,7 @@ try { foreach ($environment in @($roots.Keys)) { $destination = Join-Path $fixtureRoot $roots[$environment] New-Item -ItemType Directory -Force -Path $destination | Out-Null - foreach ($artifact in @("firmware.bin", "firmware.elf", "bootloader.bin", "partitions.bin")) { + foreach ($artifact in @("firmware.bin", "firmware.elf", "bootloader.bin", "partitions.bin", "boot_app0.bin")) { $path = Join-Path $destination $artifact [System.IO.File]::WriteAllText( $path, @@ -107,8 +107,8 @@ try { Invoke-ExpectedProofFailure { param($p) $p.status = 'claimed' } "two-cycle firmware reproducibility proof" Invoke-ExpectedProofFailure { param($p) $p.minimumClockBoundarySeconds = 64 } "two-cycle firmware reproducibility proof" - Invoke-ExpectedProofFailure { param($p) $p.cycleAArtifacts = @($p.cycleAArtifacts | Select-Object -First 11) } "12 artifacts" - Invoke-ExpectedProofFailure { param($p) $p.cycleBArtifacts += (Copy-Proof $p.cycleBArtifacts[0]) } "12 artifacts" + Invoke-ExpectedProofFailure { param($p) $p.cycleAArtifacts = @($p.cycleAArtifacts | Select-Object -First 14) } "15 artifacts" + Invoke-ExpectedProofFailure { param($p) $p.cycleBArtifacts += (Copy-Proof $p.cycleBArtifacts[0]) } "15 artifacts" Invoke-ExpectedProofFailure { param($p) $p.cycleBArtifacts[1] = Copy-Proof $p.cycleBArtifacts[0] } "mismatch at artifact index" Invoke-ExpectedProofFailure { param($p) $p.cycleBArtifacts[0].bytes = [long]$p.cycleBArtifacts[0].bytes + 1 } "mismatch at artifact index" Invoke-ExpectedProofFailure { param($p) $p.cycleBArtifacts[0].sha256 = "0" * 64 } "mismatch at artifact index" diff --git a/tools/test_firmware_reproducible_build_contract.ps1 b/tools/test_firmware_reproducible_build_contract.ps1 index a3983354..6ad50ed0 100644 --- a/tools/test_firmware_reproducible_build_contract.ps1 +++ b/tools/test_firmware_reproducible_build_contract.ps1 @@ -16,6 +16,8 @@ $workflowPath = Join-Path $repoRoot ".github/workflows/firmware.yml" $proofContractPath = Join-Path $repoRoot "tools/test_firmware_reproducibility_proof_contract.ps1" $failureContractPath = Join-Path $repoRoot "tools/test_firmware_reproducibility_failure_contract.ps1" $verifierTrustContractPath = Join-Path $repoRoot "tools/test_release_package_verifier_trust_contract.ps1" +$selectorPolicyContractPath = Join-Path $repoRoot "tools/test_release_ota_selector_policy_contract.ps1" +$flashSnapshotContractPath = Join-Path $repoRoot "tools/test_release_flash_snapshot_contract.ps1" $sourceBindingContractPath = Join-Path $repoRoot "tools/test_release_source_binding_contract.ps1" $dependencyEvidenceContractPath = Join-Path $repoRoot "tools/test_release_dependency_evidence_contract.ps1" $issues = New-Object 'System.Collections.Generic.List[string]' @@ -154,6 +156,14 @@ if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) { $issues.Add("verifier-trust-contract-failed: exit $LASTEXITCODE") } +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $selectorPolicyContractPath +if ($LASTEXITCODE -ne 0) { + $issues.Add("release-ota-selector-policy-contract-failed: exit $LASTEXITCODE") +} +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $flashSnapshotContractPath +if ($LASTEXITCODE -ne 0) { + $issues.Add("release-flash-snapshot-contract-failed: exit $LASTEXITCODE") +} & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $sourceBindingContractPath if ($LASTEXITCODE -ne 0) { $issues.Add("release-source-binding-contract-failed: exit $LASTEXITCODE") @@ -342,6 +352,9 @@ $m0GovernanceTools = @( "tools/release_dependency_evidence.ps1", "tools/test_release_dependency_evidence_contract.ps1", "tools/release_git_trust.ps1", + "tools/release_ota_selector_policy.ps1", + "tools/test_release_ota_selector_policy_contract.ps1", + "tools/test_release_flash_snapshot_contract.ps1", "tools/test_release_package_verifier_trust_contract.ps1", "tools/test_release_source_binding_contract.ps1", "tools/release_zip_safety.ps1", @@ -532,7 +545,7 @@ foreach ($marker in @( foreach ($marker in @( "verified-two-clean-cycles", "not-proven-skip-build", - "Firmware reproducibility proof must contain 12 artifacts per cycle", + "Firmware reproducibility proof must contain 15 artifacts per cycle", "Packaged artifact does not match reproducibility cycle B", "Firmware reproducibility proof clock boundary is inconsistent", "expectedProofKeys", diff --git a/tools/test_release_flash_snapshot_contract.ps1 b/tools/test_release_flash_snapshot_contract.ps1 new file mode 100644 index 00000000..10b6f9c0 --- /dev/null +++ b/tools/test_release_flash_snapshot_contract.ps1 @@ -0,0 +1,94 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$flashPath = Join-Path $PSScriptRoot 'flash_release_firmware.ps1' +$tokens = $null +$parseErrors = $null +$flashAst = [System.Management.Automation.Language.Parser]::ParseFile( + $flashPath, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -ne 0) { + throw 'Release flasher does not parse for snapshot contract testing.' +} +foreach ($functionName in @( + 'Copy-ReleaseZipSnapshot', + 'Get-LockedReleasePayloadSha256', + 'Get-LockedReleaseZipChecksumRecords', + 'Get-ReleaseFlashWriteArguments')) { + $matches = @($flashAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $functionName + }, $true)) + if ($matches.Count -ne 1) { + throw "Release flasher snapshot helper is missing or ambiguous: $functionName" + } + . ([scriptblock]::Create($matches[0].Extent.Text)) +} +. (Join-Path $PSScriptRoot 'release_zip_safety.ps1') + +$pairs = @(Get-ReleaseFlashWriteArguments ` + -Bootloader BOOT -Partitions PART -OtaSelector OTA -FirmwareBin APP) +$expectedPairs = @('0x0', 'BOOT', '0x8000', 'PART', '0xe000', 'OTA', '0x10000', 'APP') +if (($pairs -join "`n") -cne ($expectedPairs -join "`n")) { + throw 'Release flasher address/payload helper is not exact.' +} + +$scratch = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-flash-snapshot-' + [guid]::NewGuid().ToString('N')) +$sourceRoot = Join-Path $scratch 'source' +$extractRoot = Join-Path $scratch 'extracted' +$sourceZip = Join-Path $scratch 'source.zip' +$snapshotZip = Join-Path $scratch 'snapshot.zip' +New-Item -ItemType Directory -Path $sourceRoot -Force | Out-Null +$snapshotLock = $null +try { + $payloadBytes = [System.Text.Encoding]::ASCII.GetBytes('immutable-payload') + $payloadPath = Join-Path $sourceRoot 'payload.bin' + [System.IO.File]::WriteAllBytes($payloadPath, $payloadBytes) + $payloadHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $payloadPath).Hash.ToLowerInvariant() + [System.IO.File]::WriteAllText( + (Join-Path $sourceRoot 'SHA256SUMS.txt'), "$payloadHash payload.bin`n", + [System.Text.Encoding]::ASCII) + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop + [System.IO.Compression.ZipFile]::CreateFromDirectory( + $sourceRoot, $sourceZip, [System.IO.Compression.CompressionLevel]::Optimal, $false) + + $snapshotLock = Copy-ReleaseZipSnapshot ` + -SourcePath $sourceZip -DestinationPath $snapshotZip + $snapshotHash = Get-LockedReleasePayloadSha256 -Stream $snapshotLock + if ($snapshotHash -cne (Get-FileHash -Algorithm SHA256 -LiteralPath $sourceZip).Hash) { + throw 'Private flash ZIP snapshot does not match its locked source copy.' + } + + $writeRejected = $false + try { + $writeProbe = [System.IO.FileStream]::new( + $snapshotZip, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Write, + [System.IO.FileShare]::ReadWrite) + $writeProbe.Dispose() + } catch { + $writeRejected = $true + } + if (-not $writeRejected) { + throw 'Locked flash ZIP snapshot allowed a concurrent writer.' + } + + $records = Get-LockedReleaseZipChecksumRecords -SnapshotStream $snapshotLock + if ($records.Count -ne 1 -or + [string]$records['payload.bin'] -cne $payloadHash.ToUpperInvariant()) { + throw 'Flash checksum authority was not read from the locked ZIP snapshot.' + } + Expand-StackchanReleaseZipSafely ` + -ZipPath $snapshotZip -DestinationPath $extractRoot + if ((Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $extractRoot 'payload.bin')).Hash ` + -cne $payloadHash.ToUpperInvariant()) { + throw 'Locked flash ZIP snapshot extraction changed its payload.' + } +} finally { + if ($null -ne $snapshotLock) { $snapshotLock.Dispose() } + if (Test-Path -LiteralPath $scratch) { + Remove-Item -LiteralPath $scratch -Recurse -Force + } +} + +Write-Host 'Release flash snapshot/offset contract passed.' diff --git a/tools/test_release_ota_selector_policy_contract.ps1 b/tools/test_release_ota_selector_policy_contract.ps1 new file mode 100644 index 00000000..3762ee5a --- /dev/null +++ b/tools/test_release_ota_selector_policy_contract.ps1 @@ -0,0 +1,134 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +. (Join-Path $PSScriptRoot 'release_ota_selector_policy.ps1') +. (Join-Path $PSScriptRoot 'platformio_resolver.ps1') + +function Assert-Throws { + param( + [Parameter(Mandatory = $true)][scriptblock]$Action, + [Parameter(Mandatory = $true)][string]$Label + ) + + $failed = $false + try { + & $Action + } catch { + $failed = $true + } + if (-not $failed) { + throw "OTA selector policy mutation was accepted: $Label" + } +} + +$legacyPolicy = Get-StackchanReleaseOtaSelectorPolicy -Environment 'stackchan' +$servoPolicy = Get-StackchanReleaseOtaSelectorPolicy -Environment 'stackchan_servo_calibration' +$releasePolicy = Get-StackchanReleaseOtaSelectorPolicy -Environment 'stackchan_release_full' +foreach ($policy in @($legacyPolicy, $servoPolicy, $releasePolicy)) { + if ([string]$policy.schema -cne 'stackchan.release-ota-selector-policy.v1' -or + [string]$policy.frameworkPackageName -cne 'framework-arduinoespressif32' -or + [string]$policy.selectorRelativePath -cne 'tools/partitions/boot_app0.bin' -or + [long]$policy.exactBytes -ne 8192 -or + [string]$policy.sha256 -cne + 'F94C5D786A7A8FAB06AC5D10E33BF37711A6697636DC037559EA19CC410A17F0') { + throw "OTA selector policy entry is not exact: $([string]$policy.environment)" + } +} +if ([string]$legacyPolicy.frameworkPackageVersion -cne '3.20017.241212+sha.dcc1105b' -or + [string]$servoPolicy.frameworkPackageVersion -cne '3.20017.241212+sha.dcc1105b' -or + [string]$releasePolicy.frameworkPackageVersion -cne '3.3.6') { + throw 'OTA selector environment-to-framework routing is not exact.' +} + +$scratch = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-selector-policy-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $scratch | Out-Null +try { + $wrongSize = Join-Path $scratch 'wrong-size.bin' + [System.IO.File]::WriteAllBytes($wrongSize, (New-Object byte[] 8191)) + Assert-Throws -Label '8191 bytes' -Action { + Assert-StackchanReleaseOtaSelectorBytes -Environment 'stackchan' -LiteralPath $wrongSize + } + + $wrongHash = Join-Path $scratch 'wrong-hash.bin' + [System.IO.File]::WriteAllBytes($wrongHash, (New-Object byte[] 8192)) + Assert-Throws -Label 'correct size with unreviewed hash' -Action { + Assert-StackchanReleaseOtaSelectorBytes -Environment 'stackchan' -LiteralPath $wrongHash + } + + $fakeCore = Join-Path $scratch 'fake-core' + $fakeFramework = Join-Path $fakeCore 'packages/framework-arduinoespressif32' + $fakeSelectorDir = Join-Path $fakeFramework 'tools/partitions' + New-Item -ItemType Directory -Path $fakeSelectorDir -Force | Out-Null + [System.IO.File]::WriteAllBytes( + (Join-Path $fakeSelectorDir 'boot_app0.bin'), (New-Object byte[] 8192)) + [System.IO.File]::WriteAllText( + (Join-Path $fakeFramework 'package.json'), + '{"name":"framework-arduinoespressif32","version":"3.20017.241212+sha.dcc1105b"}', + [System.Text.UTF8Encoding]::new($false)) + Assert-Throws -Label 'fake core same-size selector' -Action { + Assert-StackchanReleaseFrameworkOtaSelector -Environment 'stackchan' -CoreDir $fakeCore + } + Assert-Throws -Label 'release environment routed to legacy framework' -Action { + Assert-StackchanReleaseFrameworkOtaSelector -Environment 'stackchan_release_full' -CoreDir $fakeCore + } + + [System.IO.File]::WriteAllText( + (Join-Path $fakeFramework 'package.json'), + '{"name":"not-the-framework","version":"3.20017.241212+sha.dcc1105b"}', + [System.Text.UTF8Encoding]::new($false)) + Assert-Throws -Label 'wrong framework name' -Action { + Assert-StackchanReleaseFrameworkOtaSelector -Environment 'stackchan' -CoreDir $fakeCore + } + [System.IO.File]::WriteAllText( + (Join-Path $fakeFramework 'package.json'), + '{"name":"framework-arduinoespressif32","version":"0.0.0"}', + [System.Text.UTF8Encoding]::new($false)) + Assert-Throws -Label 'wrong framework version' -Action { + Assert-StackchanReleaseFrameworkOtaSelector -Environment 'stackchan' -CoreDir $fakeCore + } + Remove-Item -LiteralPath (Join-Path $fakeSelectorDir 'boot_app0.bin') -Force + Assert-Throws -Label 'missing selector' -Action { + Assert-StackchanReleaseFrameworkOtaSelector -Environment 'stackchan' -CoreDir $fakeCore + } + + if ($env:OS -eq 'Windows_NT') { + $junctionCore = Join-Path $scratch 'junction-core' + $junctionPackages = Join-Path $junctionCore 'packages' + New-Item -ItemType Directory -Path $junctionPackages -Force | Out-Null + $junctionTarget = Join-Path $scratch 'junction-target' + New-Item -ItemType Directory -Path $junctionTarget -Force | Out-Null + New-Item -ItemType Junction ` + -Path (Join-Path $junctionPackages 'framework-arduinoespressif32') ` + -Target $junctionTarget | Out-Null + Assert-Throws -Label 'framework junction' -Action { + Assert-StackchanReleaseFrameworkOtaSelector -Environment 'stackchan' -CoreDir $junctionCore + } + } +} finally { + if (Test-Path -LiteralPath $scratch) { + Remove-Item -LiteralPath $scratch -Recurse -Force + } +} + +$installedChecks = @( + [ordered]@{ environment = 'stackchan'; core = (Get-StackchanPlatformioCoreDir) }, + [ordered]@{ environment = 'stackchan_servo_calibration'; core = (Get-StackchanPlatformioCoreDir) } +) +$releaseCore = if ($env:OS -eq 'Windows_NT') { + Join-Path ([System.IO.Path]::GetPathRoot($env:SystemRoot)) 'spio/pioarduino' +} else { + Join-Path ([System.IO.Path]::GetTempPath()) 'stackchan-pio-release-cores/pioarduino' +} +$installedChecks += [ordered]@{ environment = 'stackchan_release_full'; core = $releaseCore } +foreach ($installed in $installedChecks) { + if (-not [string]::IsNullOrWhiteSpace([string]$installed.core) -and + (Test-Path -LiteralPath ([string]$installed.core) -PathType Container)) { + Assert-StackchanReleaseFrameworkOtaSelector ` + -Environment ([string]$installed.environment) ` + -CoreDir ([string]$installed.core) | Out-Null + } +} + +Write-Host 'Release OTA selector authority contract passed.' diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 index af8bd335..a21470ba 100644 --- a/tools/test_release_package_verifier_trust_contract.ps1 +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -15,8 +15,10 @@ if ($verifyText.Contains('Release package verified:') -or } $zipSafetyPath = Join-Path $PSScriptRoot 'release_zip_safety.ps1' $gitTrustPath = Join-Path $PSScriptRoot 'release_git_trust.ps1' +$selectorPolicyPath = Join-Path $PSScriptRoot 'release_ota_selector_policy.ps1' $zipSafetyText = Get-Content -LiteralPath $zipSafetyPath -Raw $gitTrustText = Get-Content -LiteralPath $gitTrustPath -Raw +$selectorPolicyText = Get-Content -LiteralPath $selectorPolicyPath -Raw function Test-WithinFunctionDefinition { param([System.Management.Automation.Language.Ast]$Ast) @@ -468,13 +470,13 @@ if ($operationalRebuildFunctions.Count -ne 1) { $operationalRebuild = $operationalRebuildFunctions[0] $operationalRebuildText = $operationalRebuild.Extent.Text $expectedOperationalArtifacts = @( - 'firmware.bin', 'firmware.elf', 'bootloader.bin', 'partitions.bin' + 'firmware.bin', 'firmware.elf', 'bootloader.bin', 'partitions.bin', 'boot_app0.bin' ) $operationalArtifactArrays = @($operationalRebuild.FindAll({ param($node) if ($node -isnot [System.Management.Automation.Language.ArrayExpressionAst]) { return $false } $values = @(Get-DirectStringArrayValues -Ast $node) - return ($values.Count -eq 4 -and $values -contains 'firmware.bin') + return ($values.Count -eq 5 -and $values -contains 'firmware.bin') }, $true)) if ($operationalArtifactArrays.Count -ne 1) { throw 'Operational rebuild must define one exact fresh-build artifact inventory' @@ -483,7 +485,7 @@ foreach ($artifactArray in $operationalArtifactArrays) { $values = @(Get-DirectStringArrayValues -Ast $artifactArray) if ((Compare-Object -ReferenceObject $expectedOperationalArtifacts ` -DifferenceObject $values -CaseSensitive).Count -ne 0) { - throw "Operational rebuild artifact inventory is not the exact four-file set: $($values -join ', ')" + throw "Operational rebuild artifact inventory is not the exact five-file set: $($values -join ', ')" } } @@ -492,6 +494,9 @@ foreach ($requiredRebuildMarker in @( "[ordered]@{ environment = 'stackchan_servo_calibration'; packageDir = 'servo_calibration'; coreDir = `$defaultCoreDir }", "[ordered]@{ environment = 'stackchan_release_full'; packageDir = 'full_online'; coreDir = `$releaseCoreDir }", '`$packageRelative = "firmware/`$([string]`$spec.packageDir)/`$artifact"', + 'Assert-StackchanReleaseFrameworkOtaSelector', + 'Get-StackchanReleaseOtaSelectorPolicy', + 'Two-cycle proof does not match reviewed OTA selector authority:', "Get-Command -Name `$pioExecutable -CommandType Application", "`$pioVersion -cne 'PlatformIO Core, version 6.1.19'", '[string]$dependencyLock.platformioCore -cne $pioVersion', @@ -635,6 +640,19 @@ foreach ($required in @( throw "Trusted Git contract is missing: $required" } } +foreach ($required in @( + 'stackchan.release-ota-selector-policy.v1', + '3.20017.241212+sha.dcc1105b', + '3.3.6', + 'tools/partitions/boot_app0.bin', + 'F94C5D786A7A8FAB06AC5D10E33BF37711A6697636DC037559EA19CC410A17F0', + 'ReparsePoint', + 'FileShare]::Read' +)) { + if (-not $selectorPolicyText.Contains($required)) { + throw "Release OTA selector authority contract is missing: $required" + } +} foreach ($relative in @( 'verify_release_package.ps1', 'verify_consumer_promotion.ps1', @@ -735,11 +753,12 @@ foreach ($spec in $directEligibilitySpecs) { $priorInvocations = if ($operationalInvocations.ContainsKey($spec.file)) { @($operationalInvocations[$spec.file]) } else { @() } - $operationalInvocations[$spec.file] = @($priorInvocations + $invocations) + $operationalInvocations[$spec.file] = @(@($priorInvocations) + @($invocations)) } $arrayEligibilitySpecs = @( [pscustomobject]@{ file = 'flash_release_firmware.ps1'; variable = 'verifyArgs'; command = 'powershell.exe'; count = 1 }, + [pscustomobject]@{ file = 'flash_release_firmware.ps1'; variable = 'snapshotVerifyArgs'; command = 'powershell.exe'; count = 1 }, [pscustomobject]@{ file = 'start_hardware_evidence.ps1'; variable = 'verifyArgs'; command = 'powershell.exe'; count = 2 }, [pscustomobject]@{ file = 'run_device_preflight.ps1'; variable = 'earlyVerifyArgs'; command = 'powershell.exe'; count = 1 }, [pscustomobject]@{ file = 'export_rollout_status.ps1'; variable = 'packageVerifyArguments'; command = 'Invoke-ToolCapture'; count = 1 } @@ -788,7 +807,51 @@ foreach ($spec in $arrayEligibilitySpecs) { $priorInvocations = if ($operationalInvocations.ContainsKey($spec.file)) { @($operationalInvocations[$spec.file]) } else { @() } - $operationalInvocations[$spec.file] = @($priorInvocations + $invocations) + $operationalInvocations[$spec.file] = @(@($priorInvocations) + @($invocations)) +} + +$flashReleaseText = [string]$operationalTexts['flash_release_firmware.ps1'] +foreach ($selectorMarker in @( + '$otaSelector = Join-Path $firmwareDir "boot_app0.bin"', + 'Packaged OTA selector must be exactly 8192 bytes.', + 'Assert-StackchanReleaseOtaSelectorBytes', + 'Copy-ReleaseZipSnapshot', + '$snapshotLock = Copy-ReleaseZipSnapshot', + '$snapshotZip.sha256', + '$snapshotLock.Dispose()', + 'Private release ZIP snapshot failed eligibility verification.', + 'Get-LockedReleaseZipChecksumRecords', + '$checksumRecords = Get-LockedReleaseZipChecksumRecords -SnapshotStream $snapshotLock', + "[string]`$_.FullName -ceq 'SHA256SUMS.txt'", + 'Add-Type -AssemblyName System.IO.Compression', + 'Flash payload changed after snapshot verification:', + 'FileShare]::Read', + 'Get-ReleaseFlashWriteArguments')) { + if (-not $flashReleaseText.Contains($selectorMarker)) { + throw "Release flasher does not fail closed over the packaged OTA selector: $selectorMarker" + } +} +$flashAst = $operationalAsts['flash_release_firmware.ps1'] +$flashArgumentFunctions = @($flashAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Get-ReleaseFlashWriteArguments' +}, $true)) +if ($flashArgumentFunctions.Count -ne 1) { + throw 'Release flasher must define exactly one pure flash-write argument helper.' +} +$semanticProbe = [scriptblock]::Create( + $flashArgumentFunctions[0].Extent.Text + "`n" + + "Get-ReleaseFlashWriteArguments -Bootloader BOOT -Partitions PART -OtaSelector OTA -FirmwareBin APP") +$actualFlashPairs = @(& $semanticProbe) +$expectedFlashPairs = @('0x0', 'BOOT', '0x8000', 'PART', '0xe000', 'OTA', '0x10000', 'APP') +if ($actualFlashPairs.Count -ne $expectedFlashPairs.Count) { + throw 'Release flasher returned an unexpected number of address/payload arguments.' +} +for ($index = 0; $index -lt $expectedFlashPairs.Count; $index++) { + if ([string]$actualFlashPairs[$index] -cne [string]$expectedFlashPairs[$index]) { + throw "Release flasher address/payload pair mismatch at index $index." + } } $shareFile = 'share_release.ps1' @@ -1549,6 +1612,7 @@ if (-not [string]::IsNullOrWhiteSpace($BehaviorFixtureRoot)) { 'tools/release_zip_safety.ps1', 'tools/release_dependency_evidence.ps1', 'tools/release_git_trust.ps1', + 'tools/release_ota_selector_policy.ps1', 'tools/preview_python_resolver.ps1', 'tools/verify_voice_samples.ps1', 'tools/verify_tracked_rvc_assets.ps1', diff --git a/tools/verify_published_release.ps1 b/tools/verify_published_release.ps1 index 260b4e62..169e47bc 100644 --- a/tools/verify_published_release.ps1 +++ b/tools/verify_published_release.ps1 @@ -83,7 +83,10 @@ function Assert-Asset { } $expectedDigest = "sha256:$(Get-Sha256 $ExpectedPath)" - if (-not [string]::IsNullOrWhiteSpace([string]$asset[0].digest) -and [string]$asset[0].digest -ne $expectedDigest) { + if ([string]::IsNullOrWhiteSpace([string]$asset[0].digest)) { + throw "Release asset digest is missing for $Name" + } + if ([string]$asset[0].digest -ne $expectedDigest) { throw "Release asset digest mismatch for $Name" } } @@ -185,6 +188,7 @@ if ($localRootVerifyExit -ne 0) { } . (Join-Path $PSScriptRoot "release_asset_contract.ps1") +. (Join-Path $PSScriptRoot "release_ota_selector_policy.ps1") Assert-Command "gh" if ([string]::IsNullOrWhiteSpace($Repo)) { @@ -299,6 +303,32 @@ foreach ($asset in $assets) { New-Item -ItemType Directory -Force -Path $remoteDir | Out-Null +$standaloneFirmwareNames = @( + 'firmware-display-only.bin', + 'firmware-servo-calibration.bin', + 'bootloader.bin', + 'partitions.bin', + 'boot-app0.bin' +) +foreach ($assetName in $standaloneFirmwareNames) { + $localEntry = @($expectedAssetEntries | Where-Object { [string]$_.Name -ceq $assetName }) + if ($localEntry.Count -ne 1) { + throw "Missing local standalone firmware contract entry: $assetName" + } + gh release download $Version --repo $Repo --pattern $assetName --dir $remoteDir --clobber + if ($LASTEXITCODE -ne 0) { + throw "Failed to download published standalone firmware asset $assetName for $Version" + } + $remoteFirmwarePath = Join-Path $remoteDir $assetName + if ((Get-Sha256 $remoteFirmwarePath) -cne (Get-Sha256 ([string]$localEntry[0].Path))) { + throw "Downloaded standalone firmware asset does not match the verified package: $assetName" + } + if ($assetName -ceq 'boot-app0.bin') { + Assert-StackchanReleaseOtaSelectorBytes ` + -Environment 'stackchan' -LiteralPath $remoteFirmwarePath | Out-Null + } +} + foreach ($assetEntry in $companionAssetEntries) { gh release download $Version --repo $Repo --pattern $assetEntry.Name --dir $remoteDir --clobber if ($LASTEXITCODE -ne 0) { diff --git a/tools/verify_release_asset_contract.ps1 b/tools/verify_release_asset_contract.ps1 index 4930ab30..f3ce23d0 100644 --- a/tools/verify_release_asset_contract.ps1 +++ b/tools/verify_release_asset_contract.ps1 @@ -13,6 +13,7 @@ $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") . (Join-Path $PSScriptRoot "release_asset_contract.ps1") +. (Join-Path $PSScriptRoot "release_ota_selector_policy.ps1") if ([string]::IsNullOrWhiteSpace($Version)) { $Version = (git -C $repoRoot describe --tags --always --dirty).Trim() @@ -99,6 +100,7 @@ function Get-PackageFirmwareSourcePath { "firmware-servo-calibration.bin" { return (Join-Path $packageRootPath "firmware/servo_calibration/firmware.bin") } "bootloader.bin" { return (Join-Path $packageRootPath "firmware/display_only/bootloader.bin") } "partitions.bin" { return (Join-Path $packageRootPath "firmware/display_only/partitions.bin") } + "boot-app0.bin" { return (Join-Path $packageRootPath "firmware/display_only/boot_app0.bin") } default { return "" } } } @@ -119,13 +121,23 @@ function Assert-StagedFirmwareMatchesPackage { if ($sourceHash -ne $stagedHash) { throw "Staged firmware asset does not match package source for $($Entry.Name)" } + if ([string]$Entry.Name -ceq 'boot-app0.bin') { + Assert-StackchanReleaseOtaSelectorBytes ` + -Environment 'stackchan' -LiteralPath $sourcePath | Out-Null + Assert-StackchanReleaseOtaSelectorBytes ` + -Environment 'stackchan' -LiteralPath ([string]$Entry.Path) | Out-Null + } } $baseEntries = Get-ReleaseBaseAssetEntries -Version $Version -PackageRoot $packageRootPath -ZipPath $ZipPath -ZipSidecarPath $ZipSidecarPath -FirmwareAssetRoot $firmwareAssetRootPath -FirmwareAssetPathMode $FirmwareAssetPathMode $finalEntries = Get-ReleaseFinalAssetEntries -Version $Version -PackageRoot $packageRootPath -ZipPath $ZipPath -ZipSidecarPath $ZipSidecarPath -FirmwareAssetRoot $firmwareAssetRootPath -FirmwareAssetPathMode $FirmwareAssetPathMode -Assert-AssetEntrySet -Entries $baseEntries -ExpectedCount 17 -Label "Base" -Assert-AssetEntrySet -Entries $finalEntries -ExpectedCount 20 -Label "Final" +Assert-StackchanReleaseOtaSelectorBytes ` + -Environment 'stackchan' ` + -LiteralPath (Join-Path $packageRootPath 'firmware/display_only/boot_app0.bin') | Out-Null + +Assert-AssetEntrySet -Entries $baseEntries -ExpectedCount 18 -Label "Base" +Assert-AssetEntrySet -Entries $finalEntries -ExpectedCount 21 -Label "Final" $manifestPath = Join-Path $packageRootPath "release_manifest.json" if (-not (Test-Path -LiteralPath $manifestPath)) { @@ -211,6 +223,7 @@ foreach ($requiredAssetName in @( "firmware-servo-calibration.bin", "bootloader.bin", "partitions.bin", + "boot-app0.bin", "stackchan_spark_audition_bright_robot_greeting.mp3", "stackchan_spark_thinking.mp3" )) { diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 080d278c..4eb46ff6 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -252,6 +252,7 @@ if ($RequireReleaseEligible) { 'tools/release_zip_safety.ps1', 'tools/release_dependency_evidence.ps1', 'tools/release_git_trust.ps1', + 'tools/release_ota_selector_policy.ps1', 'tools/platformio_resolver.ps1' )) { $indexRecord = @(Invoke-TrustedVerifierGit -Arguments @( @@ -291,6 +292,7 @@ if ($RequireReleaseEligible) { . (Join-Path $PSScriptRoot "release_zip_safety.ps1") . (Join-Path $PSScriptRoot "release_dependency_evidence.ps1") . (Join-Path $PSScriptRoot "release_git_trust.ps1") +. (Join-Path $PSScriptRoot "release_ota_selector_policy.ps1") . (Join-Path $PSScriptRoot "platformio_resolver.ps1") $cleanupDir = $null @@ -968,8 +970,13 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { throw "Operational independent firmware rebuild failed: $environment/$phase (exit $phaseExit)." } } - foreach ($artifact in @('firmware.bin', 'firmware.elf', 'bootloader.bin', 'partitions.bin')) { - $rebuiltPath = Join-Path $rebuildWorktree ".pio/build/$environment/$artifact" + foreach ($artifact in @('firmware.bin', 'firmware.elf', 'bootloader.bin', 'partitions.bin', 'boot_app0.bin')) { + $rebuiltPath = if ($artifact -ceq 'boot_app0.bin') { + [string](Assert-StackchanReleaseFrameworkOtaSelector ` + -Environment $environment -CoreDir ([string]$spec.coreDir)).path + } else { + Join-Path $rebuildWorktree ".pio/build/$environment/$artifact" + } if (-not (Test-Path -LiteralPath $rebuiltPath -PathType Leaf)) { throw "Operational independent firmware rebuild is missing $environment/$artifact." } @@ -978,6 +985,14 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { $proofMatches = @($reproducibilityProof.cycleBArtifacts | Where-Object { [string]$_.environment -ceq $environment -and [string]$_.artifact -ceq $artifact }) + if ($artifact -ceq 'boot_app0.bin') { + $selectorPolicy = Get-StackchanReleaseOtaSelectorPolicy -Environment $environment + if ($proofMatches.Count -ne 1 -or + [long]$proofMatches[0].bytes -ne [long]$selectorPolicy.exactBytes -or + [string]$proofMatches[0].sha256 -cne [string]$selectorPolicy.sha256) { + throw "Two-cycle proof does not match reviewed OTA selector authority: $environment." + } + } if ($proofMatches.Count -ne 1 -or [long]$proofMatches[0].bytes -ne [long]$rebuiltItem.Length -or [string]$proofMatches[0].sha256 -cne $rebuiltHash) { @@ -1208,6 +1223,9 @@ $m0GovernanceTools = @( "tools/release_dependency_evidence.ps1", "tools/test_release_dependency_evidence_contract.ps1", "tools/release_git_trust.ps1", + "tools/release_ota_selector_policy.ps1", + "tools/test_release_ota_selector_policy_contract.ps1", + "tools/test_release_flash_snapshot_contract.ps1", "tools/platformio_resolver.ps1", "tools/test_release_package_verifier_trust_contract.ps1", "tools/test_release_source_binding_contract.ps1", @@ -1481,14 +1499,17 @@ $requiredFiles = @( "companion/evidence/c6-gui-rehearsal/GUI_REHEARSAL.md", "companion/evidence/c6-gui-rehearsal/DIAGNOSTICS_EXPORT.json", "firmware/display_only/bootloader.bin", + "firmware/display_only/boot_app0.bin", "firmware/display_only/firmware.bin", "firmware/display_only/firmware.elf", "firmware/display_only/partitions.bin", "firmware/servo_calibration/bootloader.bin", + "firmware/servo_calibration/boot_app0.bin", "firmware/servo_calibration/firmware.bin", "firmware/servo_calibration/firmware.elf", "firmware/servo_calibration/partitions.bin", "firmware/full_online/bootloader.bin", + "firmware/full_online/boot_app0.bin", "firmware/full_online/firmware.bin", "firmware/full_online/firmware.elf", "firmware/full_online/partitions.bin", @@ -2366,14 +2387,14 @@ foreach ($pattern in @("CompanionV1EvidenceRoot", "Assert-CompanionV1PromotionRe } $releaseAssetContractText = Get-Content -LiteralPath (Join-PackagePath "tools/release_asset_contract.ps1") -Raw -foreach ($pattern in @("Get-ReleaseBaseAssetEntries", "Get-ReleaseFinalAssetEntries", "Get-ReleaseAllowedAuditAssetEntries", "Get-ReleaseCompanionAssetEntries", "firmware-display-only.bin", "firmware-servo-calibration.bin", "stackchan_spark_audition_bright_robot_greeting.mp3", "stackchan_spark_thinking.mp3", "stackchan-companion-android-`$Version.apk", "stackchan-companion-android-`$Version.aab", "stackchan-companion-windows-`$Version.msi", "stackchan-companion-linux-`$Version.deb", "stackchan-companion-macos-`$Version.dmg", "COMPANION_RELEASE_EVIDENCE.json")) { +foreach ($pattern in @("Get-ReleaseBaseAssetEntries", "Get-ReleaseFinalAssetEntries", "Get-ReleaseAllowedAuditAssetEntries", "Get-ReleaseCompanionAssetEntries", "firmware-display-only.bin", "firmware-servo-calibration.bin", "boot-app0.bin", "stackchan_spark_audition_bright_robot_greeting.mp3", "stackchan_spark_thinking.mp3", "stackchan-companion-android-`$Version.apk", "stackchan-companion-android-`$Version.aab", "stackchan-companion-windows-`$Version.msi", "stackchan-companion-linux-`$Version.deb", "stackchan-companion-macos-`$Version.dmg", "COMPANION_RELEASE_EVIDENCE.json")) { if ($releaseAssetContractText -notmatch [regex]::Escape($pattern)) { throw "tools/release_asset_contract.ps1 missing required release asset contract logic: $pattern" } } $releaseAssetContractVerifierText = Get-Content -LiteralPath (Join-PackagePath "tools/verify_release_asset_contract.ps1") -Raw -foreach ($pattern in @("Get-ReleaseBaseAssetEntries", "Get-ReleaseFinalAssetEntries", "ExpectedCount 17", "ExpectedCount 20", "release_assets.json", "stackchan.release-assets.v1", "release_manifest.json", "mediaArtifacts", "duplicate asset names", "FirmwareAssetRoot", "FirmwareAssetPathMode", "Assert-StagedFirmwareMatchesPackage", "Get-FileHash", "Release asset contract verified")) { +foreach ($pattern in @("Get-ReleaseBaseAssetEntries", "Get-ReleaseFinalAssetEntries", "ExpectedCount 18", "ExpectedCount 21", "boot-app0.bin", "release_assets.json", "stackchan.release-assets.v1", "release_manifest.json", "mediaArtifacts", "duplicate asset names", "FirmwareAssetRoot", "FirmwareAssetPathMode", "Assert-StagedFirmwareMatchesPackage", "Get-FileHash", "Release asset contract verified")) { if ($releaseAssetContractVerifierText -notmatch [regex]::Escape($pattern)) { throw "tools/verify_release_asset_contract.ps1 missing required asset contract verification logic: $pattern" } @@ -3955,6 +3976,18 @@ foreach ($pattern in @("character_red_team.py", "character-red-team", "CHARACTER Assert-File "firmware/display_only/firmware.bin" 100000 Assert-File "firmware/servo_calibration/firmware.bin" 100000 Assert-File "firmware/full_online/firmware.bin" 1000000 +Assert-File "firmware/display_only/boot_app0.bin" 8192 +Assert-File "firmware/servo_calibration/boot_app0.bin" 8192 +Assert-File "firmware/full_online/boot_app0.bin" 8192 +foreach ($selectorRelativePath in @( + [ordered]@{ path = "firmware/display_only/boot_app0.bin"; environment = "stackchan" }, + [ordered]@{ path = "firmware/servo_calibration/boot_app0.bin"; environment = "stackchan_servo_calibration" }, + [ordered]@{ path = "firmware/full_online/boot_app0.bin"; environment = "stackchan_release_full" })) { + $selectorPath = Join-PackagePath ([string]$selectorRelativePath.path) + Assert-StackchanReleaseOtaSelectorBytes ` + -Environment ([string]$selectorRelativePath.environment) ` + -LiteralPath $selectorPath | Out-Null +} Assert-File "media/stackchan_alive_preview.png" 1000 Assert-File "media/stackchan_alive_expression_sheet.png" 2000 Assert-File "media/face_gallery.png" 2000 @@ -4267,6 +4300,23 @@ Assert-StackchanFirmwareReproducibilityProof ` -ManifestStatus ([string]$manifest.status) ` -PackageRoot $packageRootPath +if (-not [bool]$manifest.diagnosticPackage) { + foreach ($cycleName in @('cycleAArtifacts', 'cycleBArtifacts')) { + foreach ($environment in @('stackchan', 'stackchan_servo_calibration', 'stackchan_release_full')) { + $policy = Get-StackchanReleaseOtaSelectorPolicy -Environment $environment + $selectorProof = @($reproducibilityProof.$cycleName | Where-Object { + [string]$_.environment -ceq $environment -and + [string]$_.artifact -ceq 'boot_app0.bin' + }) + if ($selectorProof.Count -ne 1 -or + [long]$selectorProof[0].bytes -ne [long]$policy.exactBytes -or + [string]$selectorProof[0].sha256 -cne [string]$policy.sha256) { + throw "Firmware proof $cycleName does not match reviewed OTA selector authority: $environment." + } + } + } +} + if (-not [bool]$manifest.diagnosticPackage -and ($manifest.status -notmatch "test-ready prerelease" -or $manifest.status -notmatch "hardware validation pending")) { throw "Manifest status must identify a test-ready prerelease with hardware validation pending" From 616424e4b87bc8cc7c737a849d543eda7bf51dfd Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Mon, 3 Aug 2026 23:19:51 -0400 Subject: [PATCH 14/46] harden release toolchain identity --- .github/workflows/release.yml | 48 +- PROJECT_STATE.md | 54 +- README.md | 26 +- TASK_LEDGER.md | 19 +- docs/ARRIVAL_DAY_RUNBOOK.md | 21 +- docs/BRIDGE_AI_QUALIFICATION.md | 10 +- docs/COMPANION_APP_GAP_ANALYSIS.md | 6 +- docs/DEVICE_BRINGUP.md | 30 +- docs/FIRST_DEPLOY_STATUS.md | 20 +- docs/HARDWARE_SIMULATION.md | 3 +- docs/RELEASE_PROCESS.md | 95 +- docs/RELEASE_QUICKSTART.md | 41 +- docs/ROLLOUT_CHECKLIST.md | 18 +- tools/RELEASE_TOOLCHAIN_IDENTITY.md | 138 ++- tools/audit_published_release.ps1 | 24 +- tools/check_companion_v1_readiness.ps1 | 6 +- tools/export_github_actions_status.ps1 | 2 +- tools/export_rollout_status.ps1 | 57 +- tools/flash_release_firmware.ps1 | 24 +- .../generate_synthetic_hardware_evidence.ps1 | 2 +- ...w_release_toolchain_identity_candidate.ps1 | 41 +- tools/package_release.ps1 | 401 ++++++- tools/platformio_resolver.ps1 | 24 +- tools/prepare_device_arrival.ps1 | 39 +- tools/publish_release.ps1 | 26 +- tools/release_toolchain_identity.ps1 | 993 ++++++++++++++++-- .../release_toolchain_identity_allowlist.json | 215 ++++ tools/run_device_preflight.ps1 | 45 +- tools/seal_pioarduino_release_core.ps1 | 80 ++ tools/share_release.ps1 | 32 +- ...art_bridge_ai_supervised_qualification.ps1 | 11 +- tools/start_hardware_evidence.ps1 | 42 +- tools/test_consumer_promotion_contract.ps1 | 35 +- ...t_firmware_reproducible_build_contract.ps1 | 10 + tools/test_git_pack_semantic_verifier.py | 134 +++ tools/test_platformio_utf8_contract.ps1 | 83 +- tools/test_release_command_trust_contract.ps1 | 16 +- ...elease_package_verifier_trust_contract.ps1 | 185 ++-- .../test_release_source_binding_contract.ps1 | 12 +- .../test_release_toolchain_cache_contract.ps1 | 302 ++++++ ...lease_toolchain_documentation_contract.ps1 | 129 +++ ...st_release_toolchain_identity_contract.ps1 | 479 ++++++++- ...release_toolchain_integration_contract.ps1 | 240 +++++ tools/verify_consumer_promotion.ps1 | 19 +- tools/verify_git_pack_semantics.py | 423 ++++++++ tools/verify_published_release.ps1 | 19 +- tools/verify_release_package.ps1 | 424 +++++++- 47 files changed, 4576 insertions(+), 527 deletions(-) create mode 100644 tools/release_toolchain_identity_allowlist.json create mode 100644 tools/seal_pioarduino_release_core.ps1 create mode 100644 tools/test_git_pack_semantic_verifier.py create mode 100644 tools/test_release_toolchain_cache_contract.ps1 create mode 100644 tools/test_release_toolchain_documentation_contract.ps1 create mode 100644 tools/test_release_toolchain_integration_contract.ps1 create mode 100644 tools/verify_git_pack_semantics.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc793d2b..00681987 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -572,7 +572,13 @@ jobs: release: needs: [companion-android-release, companion-android-emulator-smoke, companion-desktop-release, companion-windows-sign] - runs-on: windows-latest + runs-on: [self-hosted, Windows, X64, stackchan-release-toolchain-20260803] + env: + STACKCHAN_RELEASE_GIT_EXECUTABLE: ${{ vars.STACKCHAN_RELEASE_GIT_EXECUTABLE }} + STACKCHAN_RELEASE_PYTHON_EXECUTABLE: ${{ vars.STACKCHAN_RELEASE_PYTHON_EXECUTABLE }} + STACKCHAN_RELEASE_PLATFORMIO_EXECUTABLE: ${{ vars.STACKCHAN_RELEASE_PLATFORMIO_EXECUTABLE }} + STACKCHAN_RELEASE_LEGACY_CORE_DIR: ${{ vars.STACKCHAN_RELEASE_LEGACY_CORE_DIR }} + STACKCHAN_RELEASE_RELEASE_CORE_DIR: ${{ vars.STACKCHAN_RELEASE_RELEASE_CORE_DIR }} steps: - uses: actions/checkout@v7 @@ -646,7 +652,13 @@ jobs: if ($version.Length -gt 128 -or $version -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or $version -in @('.', '..') -or $version.EndsWith('.')) { throw "Release tag must be one safe filename component." } - ./tools/package_release.ps1 -Version $version + ./tools/package_release.ps1 -Version $version ` + -ToolchainAllowlistPath (Join-Path $PWD 'tools/release_toolchain_identity_allowlist.json') ` + -GitExecutable ([string]$env:STACKCHAN_RELEASE_GIT_EXECUTABLE) ` + -PythonExecutable ([string]$env:STACKCHAN_RELEASE_PYTHON_EXECUTABLE) ` + -PlatformioExecutable ([string]$env:STACKCHAN_RELEASE_PLATFORMIO_EXECUTABLE) ` + -LegacyCoreDir ([string]$env:STACKCHAN_RELEASE_LEGACY_CORE_DIR) ` + -ReleaseCoreDir ([string]$env:STACKCHAN_RELEASE_RELEASE_CORE_DIR) - name: Verify release package shell: pwsh @@ -662,6 +674,12 @@ jobs: -Version $version ` -ZipPath "output/release/stackchan_alive_$version.zip" ` -ExpectedCommit ([string]$env:STACKCHAN_RELEASE_COMMIT) ` + -ToolchainAllowlistPath (Join-Path $PWD 'tools/release_toolchain_identity_allowlist.json') ` + -GitExecutable ([string]$env:STACKCHAN_RELEASE_GIT_EXECUTABLE) ` + -PythonExecutable ([string]$env:STACKCHAN_RELEASE_PYTHON_EXECUTABLE) ` + -PlatformioExecutable ([string]$env:STACKCHAN_RELEASE_PLATFORMIO_EXECUTABLE) ` + -LegacyCoreDir ([string]$env:STACKCHAN_RELEASE_LEGACY_CORE_DIR) ` + -ReleaseCoreDir ([string]$env:STACKCHAN_RELEASE_RELEASE_CORE_DIR) ` -RequireReleaseEligible - name: Export strict companion release evidence @@ -712,6 +730,12 @@ jobs: -Version $version ` -ZipPath $sourceZipPath ` -ExpectedCommit ([string]$env:STACKCHAN_RELEASE_COMMIT) ` + -ToolchainAllowlistPath (Join-Path $PWD 'tools/release_toolchain_identity_allowlist.json') ` + -GitExecutable ([string]$env:STACKCHAN_RELEASE_GIT_EXECUTABLE) ` + -PythonExecutable ([string]$env:STACKCHAN_RELEASE_PYTHON_EXECUTABLE) ` + -PlatformioExecutable ([string]$env:STACKCHAN_RELEASE_PLATFORMIO_EXECUTABLE) ` + -LegacyCoreDir ([string]$env:STACKCHAN_RELEASE_LEGACY_CORE_DIR) ` + -ReleaseCoreDir ([string]$env:STACKCHAN_RELEASE_RELEASE_CORE_DIR) ` -RequireReleaseEligible . ./tools/release_asset_contract.ps1 . ./tools/release_zip_safety.ps1 @@ -734,6 +758,12 @@ jobs: -Version $version ` -ZipPath $zipPath ` -ExpectedCommit ([string]$env:STACKCHAN_RELEASE_COMMIT) ` + -ToolchainAllowlistPath (Join-Path $PWD 'tools/release_toolchain_identity_allowlist.json') ` + -GitExecutable ([string]$env:STACKCHAN_RELEASE_GIT_EXECUTABLE) ` + -PythonExecutable ([string]$env:STACKCHAN_RELEASE_PYTHON_EXECUTABLE) ` + -PlatformioExecutable ([string]$env:STACKCHAN_RELEASE_PLATFORMIO_EXECUTABLE) ` + -LegacyCoreDir ([string]$env:STACKCHAN_RELEASE_LEGACY_CORE_DIR) ` + -ReleaseCoreDir ([string]$env:STACKCHAN_RELEASE_RELEASE_CORE_DIR) ` -RequireReleaseEligible $packageRoot = Join-Path $publicationRoot "package" @@ -810,6 +840,12 @@ jobs: -Version $version ` -ZipPath $zipPath ` -ExpectedCommit ([string]$env:STACKCHAN_RELEASE_COMMIT) ` + -ToolchainAllowlistPath (Join-Path $PWD 'tools/release_toolchain_identity_allowlist.json') ` + -GitExecutable ([string]$env:STACKCHAN_RELEASE_GIT_EXECUTABLE) ` + -PythonExecutable ([string]$env:STACKCHAN_RELEASE_PYTHON_EXECUTABLE) ` + -PlatformioExecutable ([string]$env:STACKCHAN_RELEASE_PLATFORMIO_EXECUTABLE) ` + -LegacyCoreDir ([string]$env:STACKCHAN_RELEASE_LEGACY_CORE_DIR) ` + -ReleaseCoreDir ([string]$env:STACKCHAN_RELEASE_RELEASE_CORE_DIR) ` -RequireReleaseEligible . ./tools/release_asset_contract.ps1 . ./tools/release_zip_safety.ps1 @@ -894,7 +930,13 @@ jobs: -PackageRoot $packageRoot ` -ZipPath $zipPath ` -ZipSidecarPath $zipSidecarPath ` - -ExpectedCommit ([string]$env:STACKCHAN_RELEASE_COMMIT) + -ExpectedCommit ([string]$env:STACKCHAN_RELEASE_COMMIT) ` + -ToolchainAllowlistPath (Join-Path $PWD 'tools/release_toolchain_identity_allowlist.json') ` + -GitExecutable ([string]$env:STACKCHAN_RELEASE_GIT_EXECUTABLE) ` + -PythonExecutable ([string]$env:STACKCHAN_RELEASE_PYTHON_EXECUTABLE) ` + -PlatformioExecutable ([string]$env:STACKCHAN_RELEASE_PLATFORMIO_EXECUTABLE) ` + -LegacyCoreDir ([string]$env:STACKCHAN_RELEASE_LEGACY_CORE_DIR) ` + -ReleaseCoreDir ([string]$env:STACKCHAN_RELEASE_RELEASE_CORE_DIR) } finally { foreach ($assetLock in $assetLocks) { $assetLock.Dispose() } } diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 3c7b3d25..6bef456c 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -19,14 +19,13 @@ Qualification audit found that the preserved `4d31de41` public full image autonomous refresh at boot. That package remains immutable historical evidence and is superseded for physical qualification. The selected correction keeps the public full profile motion-off at boot and explicitly disables autonomous boot refresh; it is committed as -`b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. Release-governance changes through `3bf07730` are -committed and published on draft PR #220. Diagnostic packaging is available, but release-grade -packaging and release-eligible verification currently fail closed because no reviewed exact -toolchain allowlist exists. The current uncommitted selector-authority slice binds per-environment -`boot_app0.bin` bytes to reviewed framework identity/size/SHA-256, closes flash and publication -reopen races, and writes the selector at `0xE000`; its diagnostic v13 rehearsal passed. The clean -reproducible replacement image, rollback proof, and -physical qualification are still pending. +`b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. Release-governance and OTA-selector authority are +committed through `e52826a4a130f00718e20e71e5aea0f1cbc050ff` and published on draft PR #220. +The current worktree adds a Luna-reviewed 24-component exact-host toolchain allowlist, independent +Git-pack semantic decoding, pre/post-build identity records, independent rebuild enforcement, and +authority propagation through operational callers. The policy, semantic, adversarial verifier, +caller-integration, and broad reproducibility contracts pass. This dirty worktree is not a release +input: the clean governed package, rollback proof, and physical qualification are still pending. Fresh bounded `/debug` evidence now shows the live runtime request, autonomous state, motion, servo rail, torque, and both power authorities off. Firmware self-reports confirmed `app0` and expected @@ -47,10 +46,10 @@ dimensional projection behind controlled-source final-actuator and physical-safe - Repository: `RobVanProd/stackchan_alive` - Working branch: `codex/aliveness-repository-truth` -- Current committed release-governance head: `3bf07730960cbbcfd502c0157434abb157ee1cc8` - (`ci: isolate compiler normalization probe`), including the firmware source correction at +- Current committed release-governance head: `e52826a4a130f00718e20e71e5aea0f1cbc050ff` + (`fix: bind OTA selector release authority`), including the firmware source correction at `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. -- Current worktree: dirty only for the OTA-selector-authority/install/publication slice and completed evidence +- Current worktree: dirty for the reviewed toolchain/semantic-verifier integration and evidence reconciliation; it must not be described as a clean package or installed image until committed. - HTTP-containment contract-scope maintenance commit (test file only): `aa7dfb9ca077704dca84bc5635fbb2142e13e47c` @@ -75,15 +74,14 @@ switched because live services use that checkout. Milestone 0 work uses the isol Selected experiment: `M0-004`, exact-source reproducible firmware and release-command governance. -- **Observed behavior:** The boot-motion prerequisite and release-governance slice are committed - through `3bf07730`; the current dirty slice is limited to selector-authority packaging/install, - publication integrity, and state reconciliation. Diagnostic v13 is explicitly dirty, +- **Observed behavior:** The boot-motion prerequisite, release-governance, and selector-authority + slice are committed through `e52826a4`. Diagnostic v13 is explicitly dirty, diagnostic-only, non-release-eligible, non-flashable, and does not prove firmware reproducibility. It contains three exact 8,192-byte - selectors and the operational flasher rejects it before flash preparation. No tracked - reviewed toolchain allowlist exists. Fresh canonical dependency evidence covers only the - `stackchan` environment, and the current Git/runtime and packed-object semantics are not fully - byte-authorized. + selectors and the operational flasher rejects it before flash preparation. The tracked reviewed + allowlist covers 24 exact-host components. Independent B/C evidence matches all three canonical + libdeps identities, and the reviewed semantic verifier decodes and validates Git pack/index/rev + mappings. No clean governed package has run from the final committed source yet. - **Primary hypothesis:** Deterministic build inputs, exact source/package binding, safe ZIP handling, hardened publication commands, and an independently reviewed executable/toolchain allowlist can make a clean three-environment two-cycle package auditable without weakening @@ -93,12 +91,11 @@ Selected experiment: `M0-004`, exact-source reproducible firmware and release-co differ; a diagnostic package is accepted for release, flash, or hardware qualification; a hostile ZIP escapes or bypasses inventories; or publication mutates remote state before exact repository, commit, tag, asset, and package verification. -- **Current decision:** Keep release-grade packaging and release-eligible verification fail closed - before Git or build-tool execution. Preserve diagnostic packaging only for verifier development. - Do not create or promote an allowlist from the same untrusted host evidence. PostBuild and - candidate generation remain disabled until all three environments and the remaining Git/runtime - semantics have independent authority. -- **Frozen baseline:** Committed source/governance head `3bf07730`, the contained production bridge, +- **Current decision:** Commit and review the completed toolchain-integration slice, then run the + governed package only from that exact clean commit with all six explicit authorities. Preserve + diagnostic packages as non-authorizing verifier fixtures. Do not flash until the exact package, + rollback, passive P1, and supervised stop gates pass. +- **Frozen baseline:** Committed source/governance head `e52826a4`, the contained production bridge, installed firmware with only self-reported expected SHA `69d3db27...8ebfa8`, the verified private backup, voice/vision/model workers, OTA and camera authorization, automatic recovery, the 50 ms face gate, actuator ownership, and all physical evidence. @@ -542,11 +539,10 @@ qualifies the installed firmware or authorizes a service restart. packaging and `RequireReleaseEligible` refuse before unauthenticated tools; diagnostic packages cannot authorize release, flashing, distribution, or hardware qualification; managed ZIP, pinned system commands, disabled Git/LFS hooks/filters, and hostile shim tests remain intact. -- Independent toolchain review passes the PreBuild analysis after adding full Python-installation - hashing, exact import isolation including `PYTHONOPTIMIZE`, canonical source/build-byte binding, - and source/HEAD/ref/commit mutation tests. No tracked reviewed allowlist exists. PostBuild and - candidate generation remain disabled because fresh evidence does not cover all three packaged - environments and Git/runtime pack semantics are not yet independently byte-authorized. +- Independent toolchain review recomputed all 24 tracked allowlist components and matched clean + B/C libdeps identities for all three packaged environments. The source-bound semantic verifier + fully decodes the reviewed Git pack formats; PreBuild and environment-filtered pre/post-build + assertions, caller propagation, and adversarial checkout-gate contracts pass. - Current regressions pass: native firmware logic 294/294, bridge 567/567, trusted-facts smoke with zero model invocations and zero audio, and the full-system-soak/current-lead/archive synthetic evidence contracts. These are source/contract results, not current hardware qualification. diff --git a/README.md b/README.md index 7d8f28f9..1eefe38c 100644 --- a/README.md +++ b/README.md @@ -328,32 +328,37 @@ Designing a face is a YAML edit plus a re-render; see ## Release And Evidence Flow +Run every release-authorizing command below from the exact clean trusted source checkout. Define +the six-value `$releaseToolchain` splat in `docs/RELEASE_PROCESS.md`; a downloaded or extracted +archive does not confer release authority. + Create a verified prerelease package: ```powershell -.\tools\package_release.cmd -Version -.\tools\verify_release_package.cmd -Version -ZipPath output\release\stackchan_alive_.zip -ExpectedCommit -RequireReleaseEligible +# Define the six reviewed authority values shown in docs/RELEASE_PROCESS.md first. +.\tools\package_release.ps1 -Version @releaseToolchain +.\tools\verify_release_package.ps1 -Version -ZipPath output\release\stackchan_alive_.zip -ExpectedCommit -RequireReleaseEligible @releaseToolchain ``` Share the package locally or through a tunnel: ```powershell -.\tools\share_release.cmd -Version -OpenLocal -.\tools\share_release.cmd -Version -Lan -.\tools\share_release.cmd -Version -CloudflareTunnel -DownloadCloudflared +.\tools\share_release.ps1 -Version -OpenLocal @releaseToolchain +.\tools\share_release.ps1 -Version -Lan @releaseToolchain +.\tools\share_release.ps1 -Version -CloudflareTunnel -DownloadCloudflared @releaseToolchain ``` Publish a verified prerelease manually when hosted Actions cannot run: ```powershell -.\tools\publish_release.cmd -Version -Repo RobVanProd/stackchan_alive -CreateTag -PushCurrentBranch -PushTag -.\tools\audit_published_release.cmd -Version +.\tools\publish_release.ps1 -Version -Repo RobVanProd/stackchan_alive -CreateTag -PushCurrentBranch -PushTag @releaseToolchain +.\tools\audit_published_release.ps1 -Version @releaseToolchain ``` Start a hardware evidence packet when the device is connected: ```powershell -.\tools\start_hardware_evidence.cmd -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +.\tools\start_hardware_evidence.ps1 -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 @releaseToolchain ``` Evidence packets include `RUN_HARDWARE_SIM_BASELINE.cmd` for the pre-arrival virtual @@ -364,7 +369,7 @@ Verify completed evidence before promotion: ```powershell .\tools\verify_hardware_evidence.cmd -EvidenceRoot output\hardware-evidence\ -.\tools\verify_consumer_promotion.cmd ` +.\tools\verify_consumer_promotion.ps1 ` -Version ` -PackageZip output\release\stackchan_alive_.zip ` -EvidenceRoot output\hardware-evidence\ ` @@ -373,7 +378,8 @@ Verify completed evidence before promotion: -CameraFollowSummaryPath ` -BodySensorReportPath ` -FullSystemSoakSummaryPath ` - -MinFinalSoakDurationSeconds 28800 + -MinFinalSoakDurationSeconds 28800 ` + @releaseToolchain ``` The release commit and tested firmware source commit are normally identical. Keep them separate diff --git a/TASK_LEDGER.md b/TASK_LEDGER.md index c0e3cb64..e0716ad2 100644 --- a/TASK_LEDGER.md +++ b/TASK_LEDGER.md @@ -116,19 +116,20 @@ Ledger timestamp: 2026-08-03 America/New_York will not be merged or cherry-picked because its hook inheritance, override handling, dirty-tree detection, and unrelated bridge-test change do not meet this gate. The public boot-motion prerequisite is committed at `b5ea5c5f`. The deterministic input, source/dependency/toolchain, - safe packaging/verification, publication, consumer, and CI corrections are committed through - `3bf07730` and published on draft PR #220 with all checks green. The current selector-authority + safe packaging/verification, publication, consumer, CI, and selector-authority corrections are + committed through `e52826a4` and published on draft PR #220 with all checks green. The selector slice binds one exact `boot_app0.bin` per firmware environment to reviewed framework identity, size, and SHA-256; makes the release flasher write it at `0xE000` from locked, second-verified snapshot bytes; and locks/verifies standalone publication assets. Diagnostic v13 proved the five-file package inventory and selector address order while remaining expressly dirty, diagnostic-only, non-release-eligible, - non-flashable, and not reproducibility proof. No reviewed toolchain allowlist exists; fresh - canonical dependency evidence covers only `stackchan`; PostBuild/candidate generation and every - release-grade/eligibility path therefore remain fail closed. No reproducibility or hardware - claim is earned yet. -- **Commit:** Release-governance head `3bf07730960cbbcfd502c0157434abb157ee1cc8`; - OTA-selector-authority slice pending commit after full regression and review. + non-flashable, and not reproducibility proof. The current dirty slice contains a Luna-reviewed + 24-component exact-host allowlist, all-three-environment clean B/C canonical dependency equality, + a source-bound semantic Git-pack verifier, operational caller propagation, and passing policy, + adversarial verifier, integration, and broad reproducibility contracts. No clean governed release + package or hardware claim is earned yet. +- **Commit:** Release-governance/selector head `e52826a4a130f00718e20e71e5aea0f1cbc050ff`; + reviewed toolchain integration pending commit after final regression and review. - **Decision:** Continue the atomic governance slice, but do not mark it complete or generate an eligible package until command/toolchain authority, all three clean environments, and independent review are actually closed. @@ -170,7 +171,7 @@ Ledger timestamp: 2026-08-03 America/New_York release or physical identity. - **User-facing consequence:** A diagnostic ZIP or backup-time observation could be flashed, published, or cited as current qualification without a clean source/toolchain/device binding. -- **Evidence:** committed head `3bf07730`; diagnostic v13 manifest with release/flash/hardware/ +- **Evidence:** committed head `e52826a4`; diagnostic v13 manifest with release/flash/hardware/ distribution eligibility false; verified private three-read backup; backup-time `app0` selection with unknown source mapping; fresh intermittent `/debug` successes/timeouts; matching CoreS3 PnP identity present on COM4 without opening serial. diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index ef4a6104..99e11996 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -22,11 +22,12 @@ SEC-002 package correction (2026-08-03): do not flash the preserved `4d31de41` p SHA-256 `4256F2E5...B31055` for a no-motion gate. That exact image was built with motion request and autonomous refresh enabled at boot. The source correction now makes the public full profile inherit motion-off, explicitly disables autonomous boot refresh, and is committed as -`b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. It is still not an install candidate until release -command/toolchain trust is independently closed, two clean builds match for all three packaged -environments, and the resulting exact package is verified and reviewed. Release-grade packaging -currently refuses to run because no reviewed exact toolchain allowlist exists; diagnostic packages -are never flash or qualification inputs. The release package/flasher source now requires an OTA +`b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. It is still not an install candidate until the +current reviewed-toolchain integration is committed, the governed two-cycle package build and +independent rebuild match for all three packaged environments, and the exact package is verified +and reviewed. The 24-component exact-host allowlist has passed independent recomputation, but no +clean governed package has been produced from this worktree yet; diagnostic packages are never +flash or qualification inputs. The release package/flasher source requires an OTA selector bound to the exact legacy/release framework identities, 8,192-byte size, and reviewed SHA-256, and deterministically writes it at `0xE000` between the partition table and application. The operational flasher accepts only an explicit release ZIP, second-verifies a locked private @@ -197,13 +198,17 @@ steps are in `docs/HARDWARE_FEATURE_ROADMAP.md` under **Optional 64 GB microSD** ## 1. Create The Evidence Packet -From the extracted release folder: +Run this from the exact clean trusted source checkout after defining the six-value +`$releaseToolchain` splat in `docs/RELEASE_PROCESS.md`. A downloaded or extracted archive does not +confer release authority; pass its ZIP path to the source-side helper: ```powershell -.\tools\prepare_device_arrival.cmd -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +.\tools\prepare_device_arrival.ps1 -ReleaseTag -PackageZip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 @releaseToolchain ``` -Before plugging in hardware, open `companion/evidence/c6-evidence/EVIDENCE.md` from the same release folder. It should show the committed C6 desktop companion brain-supervision gate passing, including GUI-driven Python brain start, simulated turns, restart, and diagnostics export. +Before plugging in hardware, open `companion/evidence/c6-evidence/EVIDENCE.md` from the verified +package for reference. It should show the committed C6 desktop companion brain-supervision gate +passing, including GUI-driven Python brain start, simulated turns, restart, and diagnostics export. Open the newest folder under `output\hardware-evidence\`. Run every command below from that packet folder unless noted otherwise. diff --git a/docs/BRIDGE_AI_QUALIFICATION.md b/docs/BRIDGE_AI_QUALIFICATION.md index 44ba03a9..14d1815e 100644 --- a/docs/BRIDGE_AI_QUALIFICATION.md +++ b/docs/BRIDGE_AI_QUALIFICATION.md @@ -75,20 +75,24 @@ Its restart and restart-failure counters must not advance during the qualificati ## Open An Evidence Session +Run the command from the exact clean trusted source checkout after defining `$releaseToolchain` as +shown in `docs/RELEASE_PROCESS.md`. A downloaded or extracted archive does not confer release +authority. + This command is passive. It does not start, stop, restart, flash, or move the robot. It refuses the session unless the live bridge has all candidate flags, local persistent STT, redacted logs, no private audio evidence, a configured dashboard, and a connected motion-off robot. ```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -File tools\start_bridge_ai_supervised_qualification.ps1 ` +.\tools\start_bridge_ai_supervised_qualification.ps1 ` -PackageZip "output\release\stackchan_alive_.zip" ` -ExpectedFirmwareSha256 "" ` -ExpectedFirmwareSourceCommit "" ` -OperatorPresent ` -ConfirmMotionOff ` -MinReplyWindows 100 ` - -Json + -Json ` + @releaseToolchain ``` Preserve the returned evidence-root path. During that one session: diff --git a/docs/COMPANION_APP_GAP_ANALYSIS.md b/docs/COMPANION_APP_GAP_ANALYSIS.md index 1f40ea1c..e7a73046 100644 --- a/docs/COMPANION_APP_GAP_ANALYSIS.md +++ b/docs/COMPANION_APP_GAP_ANALYSIS.md @@ -313,5 +313,9 @@ current v1 companion branch. 7. Assemble the Android v1 evidence bundle and run `tools\check_android_v1_evidence_bundle.cmd -RequireReady -Json`. 8. Exercise PC Brain Mode against the physical robot with `tools\start_pc_brain.cmd`, the deploy evidence collector, and the strict 600-second quiet soak. 9. Use the tag matrix's native managed-runtime reports to assemble the Desktop v1 evidence bundle and run `tools\check_desktop_v1_evidence_bundle.cmd -EvidenceRoot output\desktop-v1-evidence\latest -RequireReady -Json`. -10. Verify the prerelease with `tools\verify_published_release.cmd -Version ` and retain its exact-commit companion evidence. +10. From the exact clean trusted source checkout, define `$releaseToolchain` as documented in + `docs/RELEASE_PROCESS.md`, then verify the prerelease with + `tools\verify_published_release.ps1 -Version @releaseToolchain` and retain its + exact-commit companion evidence. A downloaded or extracted archive does not confer release + authority. 11. Assemble the final Companion v1 evidence bundle and run `tools\check_companion_v1_evidence_bundle.cmd -EvidenceRoot output\companion-v1-evidence\latest -RequireReady -Json` before calling v1 release-ready. diff --git a/docs/DEVICE_BRINGUP.md b/docs/DEVICE_BRINGUP.md index 0a463313..997c301b 100644 --- a/docs/DEVICE_BRINGUP.md +++ b/docs/DEVICE_BRINGUP.md @@ -2,6 +2,10 @@ Use this when the Stack-chan hardware arrives. +Run release-authorizing commands from the exact clean trusted source checkout after defining the +six-value `$releaseToolchain` splat in `docs/RELEASE_PROCESS.md`. A downloaded or extracted archive +does not confer release authority. + ## Preflight 1. Confirm battery is charged or USB-C power is stable. @@ -9,7 +13,7 @@ Use this when the Stack-chan hardware arrives. 3. Start an evidence packet for the release under test from the source checkout: ```powershell -.\tools\start_hardware_evidence.cmd -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +.\tools\start_hardware_evidence.ps1 -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 @releaseToolchain ``` This copies the release ZIP into the packet and writes `logs/package_verify.log`, which is required for promotion. @@ -151,25 +155,26 @@ contract before a real LiteRT-LM engine is available. Use this one-step preparation helper instead when you want package verification, display-flash dry-run, and evidence packet creation together: ```powershell -.\tools\prepare_device_arrival.cmd -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +.\tools\prepare_device_arrival.ps1 -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 @releaseToolchain ``` -If you only have the extracted release ZIP, run the same helper from inside the extracted folder: +If the release ZIP was downloaded elsewhere, remain in the trusted source checkout and pass its +absolute path: ```powershell -.\tools\prepare_device_arrival.cmd -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +.\tools\prepare_device_arrival.ps1 -ReleaseTag -PackageZip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 @releaseToolchain ``` 4. Check the exact release-binary flash command without touching the device: ```powershell -.\tools\flash_release_firmware.cmd -PackageZip output\release\stackchan_alive_.zip -Firmware display_only -DryRun -Monitor -Port COM3 +.\tools\flash_release_firmware.ps1 -PackageZip output\release\stackchan_alive_.zip -Version -ExpectedCommit -Firmware display_only -DryRun -Monitor -Port COM3 @releaseToolchain ``` 5. Flash the display-only binary from the verified release package first: ```powershell -.\tools\flash_release_firmware.cmd -PackageZip output\release\stackchan_alive_.zip -Firmware display_only -Monitor -Port COM3 +.\tools\flash_release_firmware.ps1 -PackageZip output\release\stackchan_alive_.zip -Version -ExpectedCommit -Firmware display_only -Monitor -Port COM3 @releaseToolchain ``` Expected result: the CoreS3 display shows the procedural face and serial logs include dry-run servo mode. @@ -289,13 +294,13 @@ Servos are disabled by default in `platformio.ini`: Use the default display-only environment first: ```powershell -.\tools\flash_release_firmware.cmd -PackageZip output\release\stackchan_alive_.zip -Firmware display_only -Monitor -Port COM3 +.\tools\flash_release_firmware.ps1 -PackageZip output\release\stackchan_alive_.zip -Version -ExpectedCommit -Firmware display_only -Monitor -Port COM3 @releaseToolchain ``` Check the servo upload command without touching the device: ```powershell -.\tools\flash_release_firmware.cmd -PackageZip output\release\stackchan_alive_.zip -Firmware servo_calibration -ConfirmServoRisk -DryRun -Monitor -Port COM3 +.\tools\flash_release_firmware.ps1 -PackageZip output\release\stackchan_alive_.zip -Version -ExpectedCommit -Firmware servo_calibration -ConfirmServoRisk -DryRun -Monitor -Port COM3 @releaseToolchain ``` Only use the servo calibration environment after the display-only build runs and the body is on a clear surface: @@ -303,10 +308,15 @@ Only use the servo calibration environment after the display-only build runs and The servo-calibration firmware keeps `STACKCHAN_ENABLE_SPEAKER=0` so calibration and motion-risk checks do not produce speaker output. The display-only firmware keeps speaker output enabled and should report `audio_out_hw_ready=1` if the M5 speaker path initializes. ```powershell -.\tools\flash_release_firmware.cmd -PackageZip output\release\stackchan_alive_.zip -Firmware servo_calibration -ConfirmServoRisk -Monitor -Port COM3 +.\tools\flash_release_firmware.ps1 -PackageZip output\release\stackchan_alive_.zip -Version -ExpectedCommit -Firmware servo_calibration -ConfirmServoRisk -Monitor -Port COM3 @releaseToolchain ``` -For development builds from source, use `tools/flash_device.cmd`; for release evidence, use `tools/flash_release_firmware.cmd` so the tested device matches the verified package. Use `tools\flash_device.cmd -Environment stackchan_wifi` only for source-side lab bring-up where the robot must host the Wi-Fi bridge runtime code before release-package evidence exists; credentials still enter through serial provisioning, not build flags. +For development builds from source, use `tools/flash_device.cmd`; for release evidence, use the +trusted-checkout `tools/flash_release_firmware.ps1 ... @releaseToolchain` path so the tested device +matches the verified package. Use `tools\flash_device.cmd -Environment stackchan_wifi` only for +source-side lab bring-up where the robot must host the Wi-Fi bridge runtime code before +release-package evidence exists; credentials still enter through serial provisioning, +not build flags. For the physical M5StackChan body used in the first live bring-up, the proven M5 SCS servo UART mapping is CoreS3 host `RX=GPIO7`, `TX=GPIO6`. Do not change this back to the generic CoreS3 `GPIO1/GPIO2` assumption; that mapping failed attach discovery on this body. The guarded source-side lead environment is currently `stackchan_wake_mww_uplink_servos_m5_voiceout`, with servo hardware compiled in, motion disabled at boot, actuator writes rate-limited, speaker downlink enabled, recovery/debug endpoints on port `8789`, and session auto-stop enabled. diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index b2d3aad8..aa7b87b7 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -6,26 +6,28 @@ Status timestamp: 2026-08-03 America/New_York The clean `SEC-002` package built from `4d31de41` is preserved as source/package evidence only. Its public `full_online` image is SHA-256 -`4256F2E5B4D81E3615D1F074737E867E86925B6737A4F38A4EF158C2B31055` and its effective build +`4256F2E5F4A5567361A97796CFC2A81E7DE24EC7F2202FCFB7C9C4CFC1B31055` and its effective build configuration requests motion and autonomous motion at boot. It must not be installed for the planned exact-image no-motion qualification. This corrects the package's former role without rewriting its historical hash or claiming that it was deployed. The replacement source profile inherits motion-off-at-boot, explicitly keeps autonomous refresh -off, and is committed as `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. The later M0 governance -worktree can create and verify diagnostic-only packages, but release-grade packaging and -`RequireReleaseEligible` verification intentionally fail closed because no reviewed exact -toolchain allowlist exists. No clean reproducible replacement package, installation, physical -qualification, or soak exists yet. The release package/flasher source now carries a per-environment +off, and is committed as `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. OTA selector authority and +publication locking are committed through `e52826a4a130f00718e20e71e5aea0f1cbc050ff`. A reviewed +24-component exact-host toolchain allowlist now exists in the current M0 worktree; independent +recomputation matched every component and clean B/C canonical libdeps for all three release +environments. The packager/verifier integration and broad reproducibility contracts pass, but +this integration is not yet a clean committed release input and no governed replacement package, +installation, physical qualification, or soak exists yet. The release package/flasher source carries a per-environment `boot_app0.bin` bound to reviewed framework versions, exact 8,192-byte size, and SHA-256, and writes it at `0xE000` between the partition table and application. The flasher uses a second-verified, read-locked private ZIP snapshot and hashes locked payload streams against that snapshot before esptool. Publication holds staged assets read-locked through upload and downloads the standalone firmware assets for remote hash verification. A diagnostic-only v13 rehearsal verified those contracts, but it is not a flash or qualification input. -Hold all flashing and physical promotion until reproducible-build and toolchain-trust closure, a -reviewed rollback path, exact clean package verification, and a fresh passive no-motion preflight -are complete. +Hold all flashing and physical promotion until this toolchain integration is committed, the exact +clean governed package passes its own two-cycle build and independent rebuild, a reviewed rollback +path exists, and a fresh passive no-motion preflight is complete. A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 diff --git a/docs/HARDWARE_SIMULATION.md b/docs/HARDWARE_SIMULATION.md index 1acd499b..ba02230e 100644 --- a/docs/HARDWARE_SIMULATION.md +++ b/docs/HARDWARE_SIMULATION.md @@ -61,7 +61,8 @@ PR/push through the `bridge-tests` job. It uploads `output/lan-smoke/latest/` as `lan-bridge-smoke` artifact, `output/hardware-sim/latest/` as the `hardware-simulation` artifact, and `output/prearrival-sim/latest/` as the `prearrival-simulation-check` artifact. -Evidence packets generated by `tools/start_hardware_evidence.cmd` also include +Evidence packets generated from the trusted checkout by +`tools/start_hardware_evidence.ps1 ... @releaseToolchain` also include `RUN_HARDWARE_SIM_BASELINE.cmd`. Run it before the physical unit arrives to store the same virtual Stackchan report under `simulation/hardware-sim/latest/` plus `logs/hardware_simulation_baseline.log` inside the packet. This is only a comparison diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 53249759..1cca6abd 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -2,11 +2,39 @@ This project can produce a pre-device review release now and a hardware-validated release later. +Release-authorizing tools run only from the exact clean trusted source checkout. A downloaded or +extracted archive does not confer release authority, even when it contains copies of the helper +scripts. Use the six-value `$releaseToolchain` splat below for every eligible verify, flash, +evidence, publication, audit, and promotion command. + ## Local Package ```powershell -.\tools\package_release.cmd -Version -``` +$releaseToolchain = @{ + ToolchainAllowlistPath = (Resolve-Path tools\release_toolchain_identity_allowlist.json).Path + GitExecutable = 'C:\Program Files\Git\cmd\git.exe' + PythonExecutable = 'C:\path\to\reviewed\Python312\python.exe' + PlatformioExecutable = 'C:\path\to\reviewed\Python312\Scripts\pio.exe' + LegacyCoreDir = 'C:\path\to\reviewed\.platformio' + ReleaseCoreDir = 'C:\spio\pioarduino' +} +.\tools\package_release.ps1 -Version @releaseToolchain +``` + +All five executable/core paths are mandatory for a release-grade build; the tracked allowlist path +is shown explicitly so the invocation is auditable. Paths are not identities. The entry point +leases and hashes the reviewed allowlist, identity helper, semantic pack verifier, selected Git, +Python, PlatformIO, and both core trees before trusted Git/build-tool execution. A different host +or same-version installation fails closed. + +The exact self-hosted pioarduino core must already contain the reviewed sealed +`platforms/espressif32/builder/penv_setup.py` bytes. Provision an original matching core only from +the clean trusted source checkout with +`tools/seal_pioarduino_release_core.ps1 -ReleaseCoreDir C:\spio\pioarduino`, then regenerate and +independently review the installed-byte allowlist. Do not run the seal during packaging and do not +treat a packaged copy as authority. Its two-line distribution-name correction prevents the pinned +6.1.18 core from reinstalling itself and mutating the authenticated penv on every build; the +toolchain lifetime guard still rejects any later penv or platform mutation. The package is written under `output/release//` and includes safe display-only, servo-calibration, and secret-free full-online firmware binaries; preview media; an expression @@ -44,8 +72,14 @@ waits for at least a 65-second start-time boundary, compares the firmware BIN an and partition-table hashes, and packages only the verified second-cycle artifacts. The cycles use different short detached clean worktree paths of different lengths, pinned to the captured commit, plus a distinct initially empty PlatformIO compiled-artifact cache per cycle/environment. The proof -records six identity attestations and binds both cycles to the manifest commit and epoch. Exact -cycle-B package inventories, license files, and package metadata are captured before that build +records 13 toolchain observations—one PreBuild record plus pre-execution and post-build records for +each environment in both cycles—and six source identity attestations, binding both cycles to the +manifest commit and epoch. The process retains the authenticated PreBuild bytes and namespace +watchers for its lifetime. PostBuild observations reuse only an immutable, +exact-authority-bound copy of the already compared PreBuild records after a full namespace +barrier, then freshly hash the selected libdeps. Cache reuse cannot cross allowlists, installed +roots, executables, or a closed PreBuild scope. Exact cycle-B package inventories, license files, +and package metadata are captured before that build worktree is removed. The captured platform directory is the exact path reported by verbose PlatformIO resolution, and shared-core package evidence is limited to resolved package names; unrelated installed packages are excluded. Release dependency provenance never falls back to the @@ -84,7 +118,7 @@ After the exact branch commit's `Firmware` workflow passes, rebuild the hardware with observed prerelease CI provenance: ```powershell -.\tools\package_release.cmd -Version -ObserveCandidateActions +.\tools\package_release.ps1 -Version -ObserveCandidateActions @releaseToolchain ``` This mode fails unless every observed `Firmware` run for the exact commit completed successfully @@ -98,7 +132,10 @@ with separate PlatformIO cores, and snapshots each successful build before the n replace shared packages. On Windows the pioarduino core stays at the short physical path `C:\spio\pioarduino` even when a temporary `subst` drive shortens the checkout. Generated package reports must use package-relative paths; the verifier rejects host-specific absolute paths. -Release packages also include flash, evidence-capture, and package-verification helper scripts under `tools/`. Use `tools/flash_release_firmware.cmd` to flash the exact binaries from a verified ZIP instead of rebuilding during arrival-day testing. +Release packages also include reference copies of flash, evidence-capture, and package-verification +helpers under `tools/`; those copies are not an authority root. Use the trusted checkout's +`tools/flash_release_firmware.ps1 ... @releaseToolchain` path to flash the exact binaries from a +verified ZIP instead of rebuilding during arrival-day testing. For the companion C8 distribution path, run `tools/export_companion_release_evidence.cmd` after Android APK or desktop package artifacts are built. It writes `COMPANION_RELEASE_EVIDENCE.json/md` with artifact SHA256s, git commit, @@ -254,8 +291,8 @@ If the native logic test step reports missing `gcc`/`g++`, run `.\tools\check_na Verify the package before sharing it: ```powershell -.\tools\verify_release_package.cmd -Version -ZipPath output\release\stackchan_alive_.zip -ExpectedCommit -RequireReleaseEligible -.\tools\run_device_preflight.cmd -PackageZip output\release\stackchan_alive_.zip +.\tools\verify_release_package.ps1 -Version -ZipPath output\release\stackchan_alive_.zip -ExpectedCommit -RequireReleaseEligible @releaseToolchain +.\tools\run_device_preflight.ps1 -PackageZip output\release\stackchan_alive_.zip @releaseToolchain ``` Voice review samples are verified as part of the package gate. To check only the generated Stackchan Spark Synth WAVs and notes: @@ -326,13 +363,13 @@ preserve upstream notices; they do not choose a license for Stackchan: Alive its Dry-run the release-binary flasher before connecting hardware: ```powershell -.\tools\flash_release_firmware.cmd -PackageZip output\release\stackchan_alive_.zip -Version -ExpectedCommit -Firmware display_only -DryRun -Monitor -Port COM3 +.\tools\flash_release_firmware.ps1 -PackageZip output\release\stackchan_alive_.zip -Version -ExpectedCommit -Firmware display_only -DryRun -Monitor -Port COM3 @releaseToolchain ``` Create a hardware evidence packet when testing a physical device: ```powershell -.\tools\start_hardware_evidence.cmd -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +.\tools\start_hardware_evidence.ps1 -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 @releaseToolchain ``` Packet creation copies the tested ZIP and records `logs/package_verify.log`. Promotion evidence must include that successful package-verification transcript unless the verifier is run with `-AllowMissingPackage` for a diagnostic-only packet. @@ -344,18 +381,24 @@ Verifier self-tests can generate an explicit diagnostic-only synthetic packet: .\tools\generate_synthetic_hardware_evidence.cmd -Version -PackageZip output\release\stackchan_alive_.zip -Verify ``` -Synthetic packets are written under `output/hardware-evidence-diagnostic/`, include `BENCH_STATUS.md/json`, copied voice-gate reports, and a real `RUN_ROLLOUT_STATUS.cmd` to exercise the same handoff path as real packets. They are rejected by `tools\verify_hardware_evidence.cmd` unless `-AllowSyntheticEvidence` is passed. Do not use them as rollout evidence. +Synthetic packets are written under `output/hardware-evidence-diagnostic/`, include +`BENCH_STATUS.md/json` and copied voice-gate reports. Their `RUN_ROLLOUT_STATUS.cmd` is an explicit +diagnostic refusal, because a synthetic packet has no exact-host release authority. They are +rejected by `tools\verify_hardware_evidence.cmd` unless `-AllowSyntheticEvidence` is passed. Do +not use them as rollout evidence. To prepare the release for arrival-day testing in one no-hardware-safe step: ```powershell -.\tools\prepare_device_arrival.cmd -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +.\tools\prepare_device_arrival.ps1 -ReleaseTag -PackageZip output\release\stackchan_alive_.zip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 @releaseToolchain ``` -If you only have an extracted release ZIP, run the same helper from inside the extracted package folder: +If the ZIP was downloaded elsewhere, stay in the trusted source checkout and pass its absolute +path. Never run the packaged helper as authority: ```powershell -.\tools\prepare_device_arrival.cmd -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +$packageZip = (Resolve-Path 'C:\Downloads\stackchan_alive_.zip').Path +.\tools\prepare_device_arrival.ps1 -ReleaseTag -PackageZip $packageZip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 @releaseToolchain ``` Before promoting a prerelease, verify the completed hardware evidence packet: @@ -369,7 +412,7 @@ the ready aggregate Companion v1 packet, GitHub Actions status, and production v verification: ```powershell -.\tools\verify_consumer_promotion.cmd ` +.\tools\verify_consumer_promotion.ps1 ` -Version ` -PackageZip output\release\stackchan_alive_.zip ` -EvidenceRoot output\hardware-evidence\ ` @@ -379,7 +422,8 @@ verification: -CameraFollowSummaryPath ` -BodySensorReportPath ` -FullSystemSoakSummaryPath ` - -MinFinalSoakDurationSeconds 28800 + -MinFinalSoakDurationSeconds 28800 ` + @releaseToolchain ``` `-ExpectedCommit` pins the public package, CI, and any CI exception to the release commit. @@ -409,6 +453,13 @@ git tag git push origin ``` +The release job runs only on a Windows x64 self-hosted runner labeled +`stackchan-release-toolchain-20260803`. Configure the repository variables +`STACKCHAN_RELEASE_GIT_EXECUTABLE`, `STACKCHAN_RELEASE_PYTHON_EXECUTABLE`, +`STACKCHAN_RELEASE_PLATFORMIO_EXECUTABLE`, `STACKCHAN_RELEASE_LEGACY_CORE_DIR`, and +`STACKCHAN_RELEASE_RELEASE_CORE_DIR` to the exact reviewed roots on that runner. A +GitHub-hosted `windows-latest` runner is not equivalent to the current exact-host allowlist. + The release workflow builds both firmware variants, runs native logic tests, compile-checks the embedded test firmware, renders preview media, creates and verifies an auditable package, builds upload-signed Android APK/AAB artifacts, builds runtime-bundled Windows MSI, Linux DEB, @@ -501,7 +552,7 @@ If GitHub Actions cannot run, the manual helper remains available for a firmware publication: ```powershell -.\tools\publish_release.cmd -Version -Repo RobVanProd/stackchan_alive -CreateTag -PushCurrentBranch -PushTag +.\tools\publish_release.ps1 -Version -Repo RobVanProd/stackchan_alive -CreateTag -PushCurrentBranch -PushTag @releaseToolchain ``` The manual helper requires an explicit `owner/name` repository target, verifies the local ZIP, @@ -513,7 +564,7 @@ companion release asset contract by itself. Audit an existing GitHub release after publication: ```powershell -.\tools\verify_published_release.cmd -Version +.\tools\verify_published_release.ps1 -Version @releaseToolchain ``` The published-release verifier checks the firmware and companion asset set, compares sizes and @@ -528,7 +579,7 @@ plus ZIP SHA256 sidecar, validates the sidecar, and runs the package verifier on For a single post-publish operator summary, run: ```powershell -.\tools\audit_published_release.cmd -Version +.\tools\audit_published_release.ps1 -Version @releaseToolchain ``` The audit wraps the published-release verifier, refreshes GitHub Actions status, exports rollout status without requiring hardware evidence, and writes `RELEASE_AUDIT.md/json` under `output/release-audit//`. The publish helper runs the same audit with `-UploadToRelease` so the audit files are attached to the GitHub release after upload verification. @@ -537,7 +588,7 @@ Stage a local handoff page with direct links to the ZIP, ZIP SHA256 sidecar, ima sheet, video, GIF, voice samples, voice hash report, release notes, readiness report, and checksums: ```powershell -.\tools\share_release.cmd -Version +.\tools\share_release.ps1 -Version @releaseToolchain ``` Add `-OpenLocal` to open the host-only local page automatically after the readiness probe passes. @@ -545,11 +596,11 @@ For same-network phone/laptop review without Cloudflare, add `-Lan`. The helper It also writes `OPEN_LOCAL_SHARE.cmd`, `LAN_TROUBLESHOOTING.md`, and `share_probe_report.json` with adapter metadata, virtual/VPN/no-gateway notes, and host-side reachability probes for the loopback and LAN candidate URLs. If a phone cannot open a LAN URL, first run `OPEN_LOCAL_SHARE.cmd` on the Windows host to prove the server is alive, then use the troubleshooting file and try a non-virtual candidate on the same Wi-Fi/LAN before falling back to Cloudflare. If `cloudflared` is installed, add `-CloudflareTunnel` to start a tunnel for remote review. The script writes the static share folder under `output/share//`. If `cloudflared` is not installed, add `-DownloadCloudflared` to place a local copy under `output/tools/` before starting the tunnel. -Run `tools/share_release.cmd` from the trusted source checkout. Version and commit authority come +Run `tools/share_release.ps1` with `@releaseToolchain` from the trusted source checkout. Version and commit authority come from explicit arguments or trusted Git state, never from an unverified package manifest; the tool verifies release eligibility before creating the temporary ZIP under `output/share//`. When the quick tunnel URL is available, the script prints the public `trycloudflare.com` URL, writes it to `output/share//PUBLIC_URL.txt`, writes process and URL state to `share_status.json`, and keeps the local server plus tunnel running in hidden background processes. For `-Lan`, use the first printed same-network URL unless the machine is on a VPN-only or isolated network. A local-only share is acceptable for same-machine or LAN review after `verify_share_release.cmd` passes; the evidence packet writes the pinned URL to `share/VERIFIED_URL.txt`. -For a no-server static integrity check, run `tools/verify_share_release.cmd -Version -Offline` after `tools/share_release.cmd -Version -NoServe`. This writes `share_static_verification_report.json` with an `offline-static:` URL marker; it proves the share folder contents and hashes, but it is not hosted-media evidence because no URL was probed. +For a no-server static integrity check, run `tools/verify_share_release.cmd -Version -Offline` after `tools/share_release.ps1 -Version -NoServe @releaseToolchain`. This writes `share_static_verification_report.json` with an `offline-static:` URL marker; it proves the share folder contents and hashes, but it is not hosted-media evidence because no URL was probed. Run `tools/verify_share_release.cmd -Version -RequirePublicUrl` before sending a public tunnel URL; omit `-RequirePublicUrl` for local or LAN review. It checks the handoff page plus the preview media, production voice hashes, readiness report, ZIP, sidecar, and package checksums. diff --git a/docs/RELEASE_QUICKSTART.md b/docs/RELEASE_QUICKSTART.md index 935f7d61..16bb3497 100644 --- a/docs/RELEASE_QUICKSTART.md +++ b/docs/RELEASE_QUICKSTART.md @@ -1,6 +1,8 @@ # Stackchan: Alive Release Quickstart -Use this from an extracted release package when the device arrives. +Use this from the exact clean trusted source checkout when the device arrives. Define the +six-value `$releaseToolchain` splat from `docs/RELEASE_PROCESS.md` first. A downloaded or extracted +archive does not confer release authority; pass the ZIP to the trusted source-side tools. ## First 30 Minutes @@ -8,7 +10,7 @@ Follow this once, in order. Stop at the first failed command or unexpected robot ahead to motion. 1. Put Stackchan on a stable surface with the body clear. Leave servos unpowered. -2. Open PowerShell in the extracted package and run the no-hardware check: +2. Open PowerShell in the trusted source checkout and run the no-hardware check: ```powershell .\tools\run_hardware_simulation.cmd @@ -18,7 +20,7 @@ ahead to motion. 4. Create the device packet, replacing the three placeholders: ```powershell - .\tools\prepare_device_arrival.cmd -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 + .\tools\prepare_device_arrival.ps1 -ReleaseTag -PackageZip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 @releaseToolchain ``` 5. Open the newest `output\hardware-evidence\` and run: @@ -253,16 +255,23 @@ validated for v1. ## Remote Review Link -From an extracted release package: +Run sharing only from the exact clean trusted source checkout that can independently verify the +release ZIP. Do not run the packaged copy of the verifier as authority. Define the six-value +`$releaseToolchain` splat from `docs/RELEASE_PROCESS.md`, then run: ```powershell -.\tools\share_release.cmd -CloudflareTunnel -DownloadCloudflared +.\tools\share_release.ps1 -Version -CloudflareTunnel -DownloadCloudflared @releaseToolchain ``` This serves the release ZIP, ZIP SHA256 sidecar, preview image, expression sheet, video, quickstart, release notes, readiness report, and checksums. It downloads a local `cloudflared.exe` under `output\tools` only when `cloudflared` is not already installed. Use `-OpenLocal` when you want the helper to open the host-only local page automatically after it proves the server is answering. The public URL is saved as `output\share\\PUBLIC_URL.txt` when a tunnel exists. Local-only shares are also valid for same-machine or LAN review: after `verify_share_release.cmd`, the evidence packet records the verified URL in `share\VERIFIED_URL.txt`. The share folder includes `OPEN_LOCAL_SHARE.cmd` for opening the host-only local page plus `STOP_SHARING.cmd` to stop the local server and tunnel. -After `share_release.cmd -NoServe`, use `.\tools\verify_share_release.cmd -Version -Offline` to check the static folder and ZIP hash without starting a server. Offline mode writes `share_static_verification_report.json` with an `offline-static:` URL marker; it does not replace the HTTP verifier when you need hosted-media evidence. +The transparent `share_release.cmd` wrapper remains available when all six authority parameters are +passed literally; the PowerShell splat is less error-prone. After `share_release.ps1 -NoServe +@releaseToolchain`, use `.\tools\verify_share_release.cmd -Version -Offline` to check the +static folder and ZIP hash without starting a server. Offline mode writes +`share_static_verification_report.json` with an `offline-static:` URL marker; it does not replace +the HTTP verifier when you need hosted-media evidence. Before sending the URL, verify the handoff page and public assets: ```powershell @@ -282,13 +291,13 @@ The cleanup command only stops processes recorded under `output\share` that stil From a source checkout, pass the release version: ```powershell -.\tools\share_release.cmd -Version -CloudflareTunnel -DownloadCloudflared +.\tools\share_release.ps1 -Version -CloudflareTunnel -DownloadCloudflared @releaseToolchain ``` If Cloudflare DNS or tunnel startup is unreliable and the reviewer is on the same network, use a LAN share instead: ```powershell -.\tools\share_release.cmd -Version -Lan +.\tools\share_release.ps1 -Version -Lan @releaseToolchain ``` Open the first printed same-network URL on the other device. The loopback URL is for the machine running the share command. @@ -296,10 +305,11 @@ If the first same-network URL fails, run `output\share\\OPEN_LOCAL_SHAR ## Prepare The Arrival Packet -From inside the extracted release folder: +From the trusted source checkout, pass the absolute path to the downloaded ZIP: ```powershell -.\tools\prepare_device_arrival.cmd -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 +$packageZip = (Resolve-Path 'C:\Downloads\stackchan_alive_.zip').Path +.\tools\prepare_device_arrival.ps1 -ReleaseTag -PackageZip $packageZip -ExpectedCommit -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001 @releaseToolchain ``` Replace `COM3`, `Your Name`, and `STACKCHAN-001` with the device serial port, operator name, and physical device identifier. @@ -525,10 +535,13 @@ After servo calibration, Wi-Fi/pairing provisioning, and the short hardware gate the secret-free full system from the verified release ZIP: ```powershell -.\tools\flash_release_firmware.cmd ` - -PackageZip .\stackchan_alive_.zip ` +.\tools\flash_release_firmware.ps1 ` + -PackageZip output\release\stackchan_alive_.zip ` + -Version ` + -ExpectedCommit ` -Firmware full_online ` - -ConfirmServoRisk + -ConfirmServoRisk ` + @releaseToolchain ``` `full_online` contains wake, bridge audio, speaker playback, RGB, touch, IMU, power @@ -627,7 +640,7 @@ The generated command uses `output\companion-v1-evidence\latest` by default and release ZIP. Pending aggregate evidence cannot pass consumer promotion. The generated command also assumes the package commit and tested firmware source commit are identical. If documentation or host-only release commits were made after the exact firmware image was flashed, -run `tools\verify_consumer_promotion.cmd` directly and pass both `-ExpectedCommit` for the package +run `tools\verify_consumer_promotion.ps1 @releaseToolchain` directly and pass both `-ExpectedCommit` for the package and `-ExpectedFirmwareSourceCommit` for the physical camera, body-sensor, and soak evidence, plus `-CompanionV1EvidenceRoot` for the completed aggregate packet. diff --git a/docs/ROLLOUT_CHECKLIST.md b/docs/ROLLOUT_CHECKLIST.md index 2a35e1da..20125f52 100644 --- a/docs/ROLLOUT_CHECKLIST.md +++ b/docs/ROLLOUT_CHECKLIST.md @@ -2,6 +2,10 @@ Use this as the arrival-day test record. Do not promote a release from prerelease until every gate has explicit evidence. +Run every release-authorizing command from the exact clean trusted source checkout after defining +the six-value `$releaseToolchain` splat in `docs/RELEASE_PROCESS.md`. A downloaded or extracted +archive does not confer release authority. + When completing `OBSERVATIONS.md`, use promotion-verifiable values: `Result: pass`, reset/heat/brownout/stall/jitter observed fields as `no`, `Procedural face visible: yes`, `Dry-run servo log observed: yes`, `Yaw classification: angle`, `velocity`, or `disabled`, soak `Duration` of at least `30 minutes`, and `USB power-cycle recovery: pass`. Promotion evidence must include at least one real photo or video under `photos/`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.mp4`, `.mov`, or `.webm`. Text placeholders do not count. Promotion evidence must include `AUDIO_REVIEW.md` plus at least one real-device speaker recording under `audio/`: `.wav`, `.mp3`, `.m4a`, `.aac`, `.mp4`, `.mov`, or `.webm`. Text placeholders or generated source WAVs alone do not count as target-speaker evidence. @@ -18,22 +22,22 @@ Speech-mouth evidence must include `logs/speech_mouth_demo_serial.log` with stre - [ ] `tools/run_device_preflight.ps1` passes. - [ ] GitHub Actions `Firmware` workflow is green on `main`. - [ ] Release package ZIP contains firmware, media, docs, manifest, dependency provenance, `dependency_lock.json`, copied build inputs, and checksums. -- [ ] `tools/verify_release_package.ps1` passes for the release ZIP. +- [ ] `tools/verify_release_package.ps1 -RequireReleaseEligible ... @releaseToolchain` passes for the release ZIP. - [x] Production RVC model and index hashes match the released files. -- [ ] `tools/flash_release_firmware.ps1 -PackageZip -Firmware display_only -DryRun -Monitor` passes for the release ZIP. -- [ ] Hardware evidence packet created with `tools/start_hardware_evidence.ps1`. +- [ ] `tools/flash_release_firmware.ps1 -PackageZip -Firmware display_only -DryRun -Monitor @releaseToolchain` passes for the release ZIP. +- [ ] Hardware evidence packet created with `tools/start_hardware_evidence.ps1 ... @releaseToolchain`. - [ ] Evidence packet includes the tested ZIP and `logs/package_verify.log`, or records a verified extracted package root. - [ ] Photo/video and speaker recordings imported with `RUN_ADD_MEDIA.cmd`, producing `media_manifest.json`. - [ ] Evidence packet includes completed `AUDIO_REVIEW.md` and a real-device speaker recording under `audio/`. - [ ] `RUN_PROGRESS_CHECK.cmd` has no remaining missing evidence items. -- [ ] If testing from an extracted release package, `tools/prepare_device_arrival.ps1 -Port -Operator -DeviceId ` passes from inside that package root. +- [ ] If the ZIP was downloaded, the trusted checkout's `tools/prepare_device_arrival.ps1 -PackageZip ... @releaseToolchain` passes; no packaged helper is treated as authority. ## Display-Only Flash Command: ```powershell -.\tools\flash_release_firmware.cmd -PackageZip output\release\stackchan_alive_.zip -Firmware display_only -Monitor +.\tools\flash_release_firmware.ps1 -PackageZip output\release\stackchan_alive_.zip -Version -ExpectedCommit -Firmware display_only -Monitor @releaseToolchain ``` Pass criteria: @@ -54,7 +58,7 @@ Pass criteria: Command: ```powershell -.\tools\flash_release_firmware.cmd -PackageZip output\release\stackchan_alive_.zip -Firmware servo_calibration -ConfirmServoRisk -Monitor +.\tools\flash_release_firmware.ps1 -PackageZip output\release\stackchan_alive_.zip -Version -ExpectedCommit -Firmware servo_calibration -ConfirmServoRisk -Monitor @releaseToolchain ``` Pass criteria: @@ -84,7 +88,7 @@ Pass criteria: Only after all checks pass: - [ ] `tools/verify_hardware_evidence.ps1` passes for the completed evidence packet. -- [ ] `tools/verify_consumer_promotion.ps1` passes for the release package and evidence packet. +- [ ] `tools/verify_consumer_promotion.ps1 ... @releaseToolchain` passes for the release package and evidence packet. - [ ] Create a hardware-validated release tag. - [ ] Mark GitHub release as non-prerelease. - [ ] Attach updated release notes with test evidence. diff --git a/tools/RELEASE_TOOLCHAIN_IDENTITY.md b/tools/RELEASE_TOOLCHAIN_IDENTITY.md index 66295105..0bd5b96d 100644 --- a/tools/RELEASE_TOOLCHAIN_IDENTITY.md +++ b/tools/RELEASE_TOOLCHAIN_IDENTITY.md @@ -13,6 +13,15 @@ component, so `python312.zip`, `DLLs`, `Lib`, every site-package, `python.exe`, `Scripts` are bound together. Both PlatformIO core `penv` trees, platforms, frameworks, compiler packages, and tools are also hashed. +The reviewed pioarduino core is sealed before allowlist review with +`tools/seal_pioarduino_release_core.ps1`. Upstream `penv_setup.py` keyed its pinned core URL as +`platformio`, while the installed distribution is named `pioarduino-core`; that mismatch caused +every build to reinstall the same 6.1.18 core and alternately swap `urllib3` 2.7.0/1.26.20. The +seal recognizes both names while retaining the exact v6.1.18 comparison and URL. It accepts only +the reviewed original SHA-256, writes and verifies a private original-byte backup, atomically +installs only the reviewed patched SHA-256, and also validates the backup on already-sealed runs. +It is a trusted-source provisioning step, not an archive-side authority or a package-time repair. + The Python claim additionally requires an exact process isolation state. The caller must set `PYTHONNOUSERSITE=1`, `PYTHONSAFEPATH=1`, `PYTHONDONTWRITEBYTECODE=1`, `PYTHONHASHSEED=0`, `PYTHONUTF8=1`, and `PYTHONIOENCODING=utf-8`, and must remove the ambient Python, virtualenv, and @@ -23,7 +32,7 @@ as both prefix values, and exactly this ordered import path: `python312.zip`, `D installation root, and `Lib/site-packages`. Any `.pth`, `.egg-link`, `sitecustomize.py`, or `usercustomize.py` anywhere under the installation fails closed before the runtime is started. -The analysis-only post-build `.pio/libdeps` identity uses `stackchan.canonical-libdeps.v1`. Every source, header, build +The post-build `.pio/libdeps` identity uses `stackchan.canonical-libdeps.v1`. Every source, header, build script, hidden executable file, and registry-package byte remains exact. Only five proven package-manager/VCS representation classes are canonicalized, each with separate validation: @@ -44,18 +53,28 @@ state remain exact records. The fresh-install shape and exact requirement set ar each of the three release environments, so stale libraries or duplicate version directories are rejected before candidate generation. -This canonical form is not currently eligible release evidence. The local parser validates pack, -index, and reverse-index checksums and binds the advertised Git object-ID set, but it does not -independently decode every packed object/delta to prove that index object-to-offset mappings match -the pack. Calling an installed `git verify-pack` would move that trust to an executable/runtime not -yet included in the exact pre-build identity. Fresh reproducibility evidence also exists only for -`stackchan`, not the other two release environments. Therefore `PostBuild` and candidate generation -are deliberately disabled and fail closed. +`verify_git_pack_semantics.py` independently decodes the observed SHA-1 Git pack formats, including +OFS/REF deltas, and proves object-to-offset, CRC, reverse-index, object-ID, and checksum linkage under +bounded resource limits. Its own source bytes are a reviewed pre-build component. Clean B/C roots +independently produced identical canonical libdeps identities for all three release environments. +Those closures permit reviewed `PostBuild` assertions and candidate generation; they do not by +themselves establish a reproducible release package or authorize hardware use. ## Integration API -Dot-source the helper and call the assertion immediately before the first build. `PostBuild` is a -reserved fail-closed phase until the dependency trust limits below are closed. +Dot-source the helper and call `PreBuild` before the first trusted Git/build-tool execution. After +each environment's dependency staging, call its environment-filtered `PostBuild` assertion before +clean/build, then repeat it after the build. The packager and independent verifier enforce this +ordering and require the complete record set. + +The guarded PreBuild holds read leases for existing bytes, recursive namespace watchers, and a +namespace baseline until final closure. After the allowlist comparison and isolated Python probe +succeed, it stores an immutable copy of the observed PreBuild records bound to the exact allowlist +hash, platform key, four installed roots, and three executable paths. Guarded PostBuild first +performs a namespace-verified mutation barrier, revalidates every cached record against the current +leased allowlist, and then hashes only the fresh environment libdeps. It never reuses a cache after +the PreBuild scope closes, across a different authority binding, or in the PreBuild scope itself. +This removes repeated multi-gigabyte reads without allowing same-path or create/delete drift. ```powershell . tools/release_toolchain_identity.ps1 @@ -68,23 +87,37 @@ $env:PYTHONIOENCODING = 'utf-8' # Also clear every forbidden override named by Assert-StackchanPythonImportIsolation. $roots = @{ pythonHome = 'C:\path\to\Python312' + gitHome = 'C:\Program Files\Git' legacyCore = 'C:\path\to\.platformio' releaseCore = 'C:\spio\pioarduino' projectRoot = (Get-Location).Path + libdepsRoot = (Join-Path (Get-Location).Path '.pio/libdeps') } +$leaseState = New-StackchanToolchainLeaseState Assert-StackchanReleaseToolchainIdentity ` -AllowlistPath tools/release_toolchain_identity_allowlist.json ` -RootMap $roots ` -PlatformioExecutable C:\path\to\Python312\Scripts\platformio.exe ` -PythonExecutable C:\path\to\Python312\python.exe ` - -Phase PreBuild -# Run the governed clean build here. This remains intentionally blocked: + -GitExecutable 'C:\Program Files\Git\cmd\git.exe' ` + -Phase PreBuild ` + -LeaseState $leaseState ` + -LeaseScope pre-build +# Stage dependencies for one environment, authenticate them, then build. Assert-StackchanReleaseToolchainIdentity ` -AllowlistPath tools/release_toolchain_identity_allowlist.json ` -RootMap $roots ` -PlatformioExecutable C:\path\to\Python312\Scripts\platformio.exe ` -PythonExecutable C:\path\to\Python312\python.exe ` - -Phase PostBuild + -GitExecutable 'C:\Program Files\Git\cmd\git.exe' ` + -Phase PostBuild ` + -Environment stackchan ` + -LeaseState $leaseState ` + -LeaseScope cycle-a +# Close each detached-worktree dependency scope before removing that worktree. +Close-StackchanToolchainLeaseScope -LeaseState $leaseState -Scope cycle-a -RequireUnchanged +# Final success requires the full guarded namespace closure. +Close-StackchanToolchainLeaseState -LeaseState $leaseState -RequireUnchanged ``` The selected PlatformIO and Python executables must resolve to the reviewed Python installation. @@ -96,43 +129,44 @@ inherit, not a separate `python -I` mode that the package invocation does not us ## Review/update workflow -`new_release_toolchain_identity_candidate.ps1` writes an unreviewed candidate under -`output/private/toolchain-identity-candidates/`. It refuses to overwrite the tracked allowlist. +`new_release_toolchain_identity_candidate.ps1` writes a new, unreviewed, create-only candidate +under `output/private/toolchain-identity-candidates/`. It refuses output elsewhere and cannot +overwrite the tracked allowlist. Review the exact package sources, every component, the 22 version pins in `requirements-firmware-release.txt`, and the candidate diff. Only then may a reviewer set `review.status=reviewed` and record non-empty `reviewer` and `reason` fields in a committed allowlist. Never promote a candidate merely because it was generated by the same host being -checked. Candidate generation includes PostBuild and therefore currently refuses every candidate. -All three environments need independent fresh-install evidence, and Git pack semantics need an -independently trusted validator, before a new candidate can be reviewed. +checked. The current tracked policy was promoted only after independent recomputation of all 24 +components, source-byte confirmation, and clean B/C canonical equality for all three environments. +Every future candidate requires a fresh independent review; the current review cannot transfer to +changed bytes. ## Retained analysis evidence -The only retained pristine external tree used by this slice is -`D:\CodexArtifacts\stackchan-toolchain-libdeps-repro-2`. For `stackchan`, its canonical identity is -`4D18A5A5A8F385BA8CB6A88429F82240797459A76A2716A8A209D4223AA104F8` over 1,243 raw files and -166,158,472 raw bytes, producing 1,237 canonical records and 165,978,723 canonical bytes. +The retained clean B/C roots are `D:\CodexArtifacts\stackchan-toolchain-all-repro-b` and +`D:\CodexArtifacts\stackchan-toolchain-all-repro-c`. Both independently match these reviewed +canonical libdeps identities: -`D:\CodexArtifacts\stackchan-toolchain-libdeps-repro` was mutated by a build and is discarded. It -is not used for equality, reproducibility, Git representation, or any other supporting claim. -The contract instead creates two controlled local clones independently. It verifies canonical -equality across distinct PlatformIO install timestamps and tests that actual source bytes, -`builder.py`, HEAD, the branch ref, the complete reviewed commit, hooks, remote configuration, -package metadata, and pack corruption remain bound or fail closed. +- `stackchan` and `stackchan_servo_calibration`: + `79C18DC5078CAB8A35CCB4DAD385FDCB2BFB11126C778975F74C8F4B7096279B`; +- `stackchan_release_full`: + `74B343038114CC2E90927E1C641B14D47806EA0759BF0FED711235B61C705273`. -The earlier candidate -`release_toolchain_identity_allowlist_candidate_20260803-143148.json` is rejected: it used raw -libdeps identities and captured a stale extra `M5GFX@0.2.24` tree. No tracked reviewed allowlist -exists. +The failed A root and older candidates remain rejected evidence and are not release inputs. The +reviewed tracked allowlist was derived from candidate +`release_toolchain_identity_allowlist_candidate_20260803-201018.json`, candidate SHA-256 +`7E89C23B11783E66228A0A7C12F94E7AB0A0BC85D393ACF4A7C46F1AEE594CF4`. Promotion approved only +the byte policy; release eligibility still requires the packager/verifier record and artifact gates. ## Portability and CI limit An installed-byte candidate is explicitly scoped as `exact-host-installed-bytes` and `portableAcrossHosts=false`. A local Windows candidate is not a GitHub-hosted-runner identity. Hosted `setup-python` baselines, path-embedded bytecode, unpinned wheel files, and PlatformIO-owned -`penv` contents can differ even when visible versions match. No reviewed allowlist is committed -until that environment exists, so release verification must fail closed rather than silently -claiming toolchain eligibility. +`penv` contents can differ even when visible versions match. The tag release job therefore targets +the explicitly provisioned `stackchan-release-toolchain-20260803` self-hosted Windows runner and +passes repository-variable paths as explicit arguments. A missing, different, or hosted toolchain +fails the reviewed identity rather than silently claiming equivalence. A portable CI design requires a fixed-path isolated Python environment, a reviewed wheelhouse with SHA-256-pinned requirements installed using `--require-hashes --no-deps`, bytecode generation @@ -140,20 +174,20 @@ disabled (`PYTHONDONTWRITEBYTECODE=1`) with bytecode absent before use, and sepa allowlists for each OS/architecture/runtime image. Exact version pins alone are necessary but not sufficient. This repository's current offline evidence cannot supply trustworthy wheel hashes. -The current pre-build proof still does not byte-identify Windows system DLLs, the kernel, or every -program a package may resolve from `PATH`. The import closure is exact only under the required -process environment and reported `sys.path`. This is a host-installed-byte policy, not a claim -that Python or PlatformIO is hermetic from the operating system. The Git executable used during -analysis is also not byte-identified; it cannot authorize PostBuild, which remains disabled. - -The rejected raw candidate covered more than 6.1 GB on the reviewed Windows host; a strict pass exceeded two -minutes in local measurement. That cost is intentional and should be paid once before and once -after the governed release build, not on ordinary developer builds. - -The analysis parser relies on Git's SHA-1 object identity and supports only the observed formats: -index v2, pack index v2, reverse index v1, and SHA-1 packs. It validates checksums, reverse-index -permutations, pack linkage, sources, commits, and object-ID inventories, but does not fully decode -pack deltas or prove index offsets/CRCs against decoded objects. New index, hash, extension, -loose-object, or package-manager metadata formats require review and a contract update. Until an -exactly identified Git/runtime or an independent pack decoder closes this gap, PostBuild remains -disabled rather than treating a useful analysis identity as release authorization. +The current pre-build proof still does not byte-identify Windows system DLLs or the kernel. The +import closure is exact only under the required process environment and reported `sys.path`. The +packager and verifier constrain `PATH`, reject ambient Python/Git/PlatformIO overrides, and bind the +complete selected Git and Python installations, but this remains a host-installed-byte policy—not +a claim that the operating system itself is hermetic. + +The reviewed PreBuild closure covers about 5.98 GiB and 88,000 files on the Windows host. That full +byte cost is intentional and is paid once per governed packager/verifier process. Subsequent +guarded PostBuild checks still verify every watched namespace and revalidate the cached records, +but rehash only the selected environment's fresh libdeps. Ordinary developer builds do not run +this release-authorizing proof. + +The semantic verifier relies on Git's SHA-1 object identity and supports the reviewed formats: Git +pack v2/v3, pack index v2, reverse index v1, repository index v2, and SHA-1 objects. It fully decodes +ordinary and delta objects and proves offsets, CRCs, reverse mapping, and checksums with explicit +resource caps. New hash algorithms, index/pack extensions, or package-manager metadata formats +require review and a contract update; unknown forms fail closed. diff --git a/tools/audit_published_release.ps1 b/tools/audit_published_release.ps1 index 917b99f6..f59abb02 100644 --- a/tools/audit_published_release.ps1 +++ b/tools/audit_published_release.ps1 @@ -8,7 +8,13 @@ param( [string]$OutDir = "", [switch]$AllowNonPrerelease, [switch]$UploadToRelease, - [switch]$StrictPromotion + [switch]$StrictPromotion, + [string]$ToolchainAllowlistPath = "", + [string]$GitExecutable = "", + [string]$PythonExecutable = "", + [string]$PlatformioExecutable = "", + [string]$LegacyCoreDir = "", + [string]$ReleaseCoreDir = "" ) $ErrorActionPreference = "Stop" @@ -170,7 +176,13 @@ $publishedVerifyArgs = @( "-PackageRoot", $PackageRoot, "-ZipPath", $ZipPath, "-ZipSidecarPath", $ZipSidecarPath, - "-ExpectedCommit", $ExpectedCommit + "-ExpectedCommit", $ExpectedCommit, + "-ToolchainAllowlistPath", $ToolchainAllowlistPath, + "-GitExecutable", $GitExecutable, + "-PythonExecutable", $PythonExecutable, + "-PlatformioExecutable", $PlatformioExecutable, + "-LegacyCoreDir", $LegacyCoreDir, + "-ReleaseCoreDir", $ReleaseCoreDir ) if ($AllowNonPrerelease) { $publishedVerifyArgs += "-AllowNonPrerelease" @@ -200,7 +212,13 @@ if ($publishedVerify.exitCode -eq 0) { "-Version", $Version, "-PackageRoot", $PackageRoot, "-ExpectedCommit", $ExpectedCommit, - "-OutDir", $rolloutDir + "-OutDir", $rolloutDir, + "-ToolchainAllowlistPath", $ToolchainAllowlistPath, + "-GitExecutable", $GitExecutable, + "-PythonExecutable", $PythonExecutable, + "-PlatformioExecutable", $PlatformioExecutable, + "-LegacyCoreDir", $LegacyCoreDir, + "-ReleaseCoreDir", $ReleaseCoreDir ) $rollout = Read-JsonFile (Join-Path $rolloutDir "ROLLOUT_STATUS.json") } diff --git a/tools/check_companion_v1_readiness.ps1 b/tools/check_companion_v1_readiness.ps1 index e25ed63c..6002ab5a 100644 --- a/tools/check_companion_v1_readiness.ps1 +++ b/tools/check_companion_v1_readiness.ps1 @@ -81,7 +81,7 @@ $pendingGates = @( [ordered]@{ name = "c8-tagged-release-distribution" evidence = "GitHub prerelease assets plus COMPANION_RELEASE_EVIDENCE.json and output/companion-v1-evidence/" - detail = "Create the exact upload-signed prerelease tag, run tools\verify_published_release.cmd -Version , assemble Android/Desktop/rollout evidence, and pass tools\check_companion_v1_evidence_bundle.cmd -EvidenceRoot -RequireReady -Json." + detail = "Create the exact upload-signed prerelease tag; from the exact clean trusted source checkout, define releaseToolchain as documented and run tools\verify_published_release.ps1 -Version @releaseToolchain; then assemble Android/Desktop/rollout evidence and pass tools\check_companion_v1_evidence_bundle.cmd -EvidenceRoot -RequireReady -Json. The downloaded archive does not confer release authority." } ) @@ -354,7 +354,7 @@ Test-TextEvidence ` -Id "readme-current-eight-hour-evidence" ` -Name "Public README carries corrected exact-image soak evidence" ` -RelativePaths @("README.md") ` - -Patterns @("Status as of July 13, 2026", "corrected exact paired candidate", "28807 s", "5643/5643", "77/77", "bounded final stop") + -Patterns @("Status as of August 2, 2026", "corrected exact paired candidate", "28807 s", "5643/5643", "77/77", "bounded final stop") Test-TextEvidence ` -Id "production-readiness-current-eight-hour-evidence" ` @@ -1128,7 +1128,7 @@ Test-TextEvidence ` -Id "ci-companion-tests" ` -Name "Companion CI pre-arrival checks" ` -RelativePaths @(".github/workflows/firmware.yml", "provenance/firmware.yml") ` - -Patterns @("workflow_dispatch", "github.event_name != 'workflow_dispatch'", "github.event_name == 'workflow_dispatch'", "STACKCHAN_CI_SOURCE_SHA", "github.event.pull_request.head.sha", "companion-tests", "companion-android-emulator-smoke", "companion-platform-builds", "companion-release-evidence", "export_companion_release_evidence.ps1", "java-version: `"21`"", "python-version: `"3.12`"", "android-actions/setup-android", "gradle/actions/setup-gradle@v6", "platforms;android-36", "build-tools;36.0.0", "system-images;android-35;aosp_atd;x86_64", "ANDROID_AVD_HOME", "timeout 180 adb wait-for-device", "./gradlew check :app-desktop:c0Spike", ":app-android:bundleRelease", "stackchan.allowLabDebugReleaseSigning=true", "check_companion_release_version.ps1", "test_companion_release_version_contract.ps1", "check_android_play_release_readiness.ps1", "test_android_upload_signing_contract.ps1", "test_android_emulator_launch.ps1", "test_android_emulator_release_evidence_contract.ps1", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "test_desktop_package_evidence_contract.ps1", "test_desktop_package_launch.ps1", "prepare_desktop_python_runtime.ps1", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT", "export_desktop_package_evidence.ps1", "RequireInstallerPayload", "RequireLaunchEvidence", "RequireDesktopPackageEvidence") + -Patterns @("workflow_dispatch", "github.event_name != 'workflow_dispatch'", "github.event_name == 'workflow_dispatch'", "STACKCHAN_CI_SOURCE_SHA", "github.event.pull_request.head.sha", "companion-tests", "companion-android-emulator-smoke", "companion-platform-builds", "companion-release-evidence", "export_companion_release_evidence.ps1", "java-version: `"21`"", "python-version: `"3.12.10`"", "android-actions/setup-android", "gradle/actions/setup-gradle@v6", "platforms;android-36", "build-tools;36.0.0", "system-images;android-35;aosp_atd;x86_64", "ANDROID_AVD_HOME", "timeout 180 adb wait-for-device", "./gradlew check :app-desktop:c0Spike", ":app-android:bundleRelease", "stackchan.allowLabDebugReleaseSigning=true", "check_companion_release_version.ps1", "test_companion_release_version_contract.ps1", "check_android_play_release_readiness.ps1", "test_android_upload_signing_contract.ps1", "test_android_emulator_launch.ps1", "test_android_emulator_release_evidence_contract.ps1", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "test_desktop_package_evidence_contract.ps1", "test_desktop_package_launch.ps1", "prepare_desktop_python_runtime.ps1", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT", "export_desktop_package_evidence.ps1", "RequireInstallerPayload", "RequireLaunchEvidence", "RequireDesktopPackageEvidence") Test-AggregateTextEvidence ` -Id "companion-ci-candidate-handoff" ` diff --git a/tools/export_github_actions_status.ps1 b/tools/export_github_actions_status.ps1 index 10d9bdcd..db6282c9 100644 --- a/tools/export_github_actions_status.ps1 +++ b/tools/export_github_actions_status.ps1 @@ -231,7 +231,7 @@ $nextCommand = if ($summaryStatus -eq "failed-or-incomplete" -and $runReports.Co } elseif ($summaryStatus -eq "missing-required-workflow" -and $missingRequiredWorkflows -contains "Release") { "git tag $Version; git push origin $Version" } elseif ($summaryStatus -eq "success") { - ".\tools\audit_published_release.cmd -Version $Version" + "" } else { "" } diff --git a/tools/export_rollout_status.ps1 b/tools/export_rollout_status.ps1 index 07ac41bf..dad8a3a2 100644 --- a/tools/export_rollout_status.ps1 +++ b/tools/export_rollout_status.ps1 @@ -5,7 +5,13 @@ param( [string]$EvidenceRoot = "", [string]$OutDir = "", [string]$ActionsStatusPath = "", - [string]$ExpectedCommit = "" + [string]$ExpectedCommit = "", + [string]$ToolchainAllowlistPath = "", + [string]$GitExecutable = "", + [string]$PythonExecutable = "", + [string]$PlatformioExecutable = "", + [string]$LegacyCoreDir = "", + [string]$ReleaseCoreDir = "" ) $ErrorActionPreference = "Stop" @@ -15,6 +21,30 @@ Set-Location $repoRoot $cleanupDir = $null +function ConvertTo-RolloutPowerShellLiteral { + param([Parameter(Mandatory = $true)][string]$Value) + return "'" + ($Value -replace "'", "''") + "'" +} + +function New-RolloutTrustedSourceCommand { + param( + [Parameter(Mandatory = $true)][string]$ScriptName, + [Parameter(Mandatory = $true)][string]$Arguments + ) + + $scriptPath = Join-Path $PSScriptRoot $ScriptName + $toolchainCommandArguments = @( + '-ToolchainAllowlistPath ' + (ConvertTo-RolloutPowerShellLiteral $ToolchainAllowlistPath), + '-GitExecutable ' + (ConvertTo-RolloutPowerShellLiteral $GitExecutable), + '-PythonExecutable ' + (ConvertTo-RolloutPowerShellLiteral $PythonExecutable), + '-PlatformioExecutable ' + (ConvertTo-RolloutPowerShellLiteral $PlatformioExecutable), + '-LegacyCoreDir ' + (ConvertTo-RolloutPowerShellLiteral $LegacyCoreDir), + '-ReleaseCoreDir ' + (ConvertTo-RolloutPowerShellLiteral $ReleaseCoreDir) + ) -join ' ' + return '& ' + (ConvertTo-RolloutPowerShellLiteral $scriptPath) + ' ' + + $Arguments + ' ' + $toolchainCommandArguments +} + function Join-ResolvedPath { param( [string]$Root, @@ -317,7 +347,8 @@ function Get-RolloutNextAction { return [ordered]@{ owner = "package" action = "Regenerate or verify the release package so release_manifest.json matches the expected commit." - command = ".\tools\package_release.cmd -Version $ReleaseVersion" + command = New-RolloutTrustedSourceCommand -ScriptName 'package_release.ps1' ` + -Arguments ('-Version ' + (ConvertTo-RolloutPowerShellLiteral $ReleaseVersion)) reason = [string]$manifestGate.evidence } } @@ -348,7 +379,12 @@ function Get-RolloutNextAction { return [ordered]@{ owner = "hardware" action = "Create or refresh the hardware evidence packet and run its progress check." - command = ".\tools\start_hardware_evidence.cmd -ReleaseTag $ReleaseVersion -PackageZip output\release\stackchan_alive_$ReleaseVersion.zip -Port COM3 -Operator `"Your Name`" -DeviceId STACKCHAN-001" + command = New-RolloutTrustedSourceCommand -ScriptName 'start_hardware_evidence.ps1' ` + -Arguments (( + '-ReleaseTag {0} -PackageZip {1} -ExpectedCommit {2} -Port COM3 -Operator ''Your Name'' -DeviceId STACKCHAN-001' -f + (ConvertTo-RolloutPowerShellLiteral $ReleaseVersion), + (ConvertTo-RolloutPowerShellLiteral (Join-Path $repoRoot "output/release/stackchan_alive_$ReleaseVersion.zip")), + (ConvertTo-RolloutPowerShellLiteral $ExpectedCommit))) reason = [string]$progressGate.evidence } } @@ -416,7 +452,12 @@ function Get-RolloutNextAction { return [ordered]@{ owner = "release" action = "Run the consumer promotion verifier." - command = ".\tools\verify_consumer_promotion.cmd -Version $ReleaseVersion -PackageZip -EvidenceRoot -CompanionV1EvidenceRoot output\companion-v1-evidence\latest" + command = New-RolloutTrustedSourceCommand -ScriptName 'verify_consumer_promotion.ps1' ` + -Arguments (( + '-Version {0} -PackageZip '''' -EvidenceRoot '''' -CompanionV1EvidenceRoot {1} -ExpectedCommit {2}' -f + (ConvertTo-RolloutPowerShellLiteral $ReleaseVersion), + (ConvertTo-RolloutPowerShellLiteral (Join-Path $repoRoot 'output/companion-v1-evidence/latest')), + (ConvertTo-RolloutPowerShellLiteral $ExpectedCommit))) reason = "All rollout component gates are passing; the aggregate Companion v1 packet remains mandatory for terminal promotion." } } @@ -440,7 +481,13 @@ try { (Join-Path $PSScriptRoot "verify_release_package.ps1"), "-Version", $Version, "-ExpectedCommit", $ExpectedCommit, - "-RequireReleaseEligible" + "-RequireReleaseEligible", + "-ToolchainAllowlistPath", $ToolchainAllowlistPath, + "-GitExecutable", $GitExecutable, + "-PythonExecutable", $PythonExecutable, + "-PlatformioExecutable", $PlatformioExecutable, + "-LegacyCoreDir", $LegacyCoreDir, + "-ReleaseCoreDir", $ReleaseCoreDir ) if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { if (-not (Test-Path -LiteralPath $PackageZip)) { diff --git a/tools/flash_release_firmware.ps1 b/tools/flash_release_firmware.ps1 index 4af8051f..32dabd1c 100644 --- a/tools/flash_release_firmware.ps1 +++ b/tools/flash_release_firmware.ps1 @@ -10,7 +10,13 @@ param( [switch]$Monitor, [switch]$ConfirmServoRisk, [switch]$AllowDirtyPackage, - [switch]$DryRun + [switch]$DryRun, + [string]$ToolchainAllowlistPath = "", + [string]$GitExecutable = "", + [string]$PythonExecutable = "", + [string]$PlatformioExecutable = "", + [string]$LegacyCoreDir = "", + [string]$ReleaseCoreDir = "" ) $ErrorActionPreference = "Stop" @@ -157,7 +163,13 @@ $verifyScript = Join-Path $PSScriptRoot "verify_release_package.ps1" $verifyArgs = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $verifyScript, "-Version", $Version, "-ExpectedCommit", $ExpectedCommit, - "-RequireReleaseEligible" + "-RequireReleaseEligible", + "-ToolchainAllowlistPath", $ToolchainAllowlistPath, + "-GitExecutable", $GitExecutable, + "-PythonExecutable", $PythonExecutable, + "-PlatformioExecutable", $PlatformioExecutable, + "-LegacyCoreDir", $LegacyCoreDir, + "-ReleaseCoreDir", $ReleaseCoreDir ) Assert-File $PackageZip $PackageZip = (Resolve-Path -LiteralPath $PackageZip).Path @@ -312,7 +324,13 @@ try { $snapshotVerifyArgs = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $verifyScript, "-Version", $Version, "-ExpectedCommit", $ExpectedCommit, - "-RequireReleaseEligible", "-ZipPath", $snapshotZip + "-RequireReleaseEligible", "-ZipPath", $snapshotZip, + "-ToolchainAllowlistPath", $ToolchainAllowlistPath, + "-GitExecutable", $GitExecutable, + "-PythonExecutable", $PythonExecutable, + "-PlatformioExecutable", $PlatformioExecutable, + "-LegacyCoreDir", $LegacyCoreDir, + "-ReleaseCoreDir", $ReleaseCoreDir ) if ($AllowDirtyPackage) { $snapshotVerifyArgs += "-AllowDirtyPackage" diff --git a/tools/generate_synthetic_hardware_evidence.ps1 b/tools/generate_synthetic_hardware_evidence.ps1 index c5918f25..403646c0 100644 --- a/tools/generate_synthetic_hardware_evidence.ps1 +++ b/tools/generate_synthetic_hardware_evidence.ps1 @@ -665,7 +665,7 @@ if ($packageInfo -and $packageInfo.Contains("copiedFile")) { if ($AllowDirtyPackage) { $verifyPackageCommand += " -AllowDirtyPackage" } -$rolloutStatusCommand = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"%~dp0..\..\..\tools\export_rollout_status.ps1`" -Version $Version $rolloutPackageArg -EvidenceRoot `"%~dp0.`" -ExpectedCommit $ExpectedCommit -OutDir `"%~dp0.`"" +$rolloutStatusCommand = "echo Diagnostic-only synthetic packet: rollout-status generation requires the exact trusted source checkout and six exact-host toolchain authorities. The archive does not confer release authority." $commandFiles = @{ "RUN_PLAY_LEAD_VOICE.cmd" = "echo Synthetic diagnostic packet. Use a real hardware packet for target speaker playback." diff --git a/tools/new_release_toolchain_identity_candidate.ps1 b/tools/new_release_toolchain_identity_candidate.ps1 index 78dd41f1..d937320f 100644 --- a/tools/new_release_toolchain_identity_candidate.ps1 +++ b/tools/new_release_toolchain_identity_candidate.ps1 @@ -3,6 +3,8 @@ param( [Parameter(Mandatory = $true)][string]$ReleaseCoreDir, [Parameter(Mandatory = $true)][string]$PlatformioExecutable, [Parameter(Mandatory = $true)][string]$PythonExecutable, + [Parameter(Mandatory = $true)][string]$GitExecutable, + [string]$LibdepsRoot, [string]$ProjectRoot, [string]$OutputPath ) @@ -13,32 +15,61 @@ $ErrorActionPreference = 'Stop' if ([string]::IsNullOrWhiteSpace($ProjectRoot)) { $ProjectRoot = Split-Path -Parent $PSScriptRoot } +$resolvedProjectRoot = (Get-Item -LiteralPath $ProjectRoot -Force -ErrorAction Stop).FullName +$candidateRoot = Join-Path $resolvedProjectRoot 'output/private/toolchain-identity-candidates' if ([string]::IsNullOrWhiteSpace($OutputPath)) { - $candidateRoot = Join-Path $ProjectRoot 'output/private/toolchain-identity-candidates' New-Item -ItemType Directory -Force -Path $candidateRoot | Out-Null $OutputPath = Join-Path $candidateRoot ( 'release_toolchain_identity_allowlist_candidate_' + (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + '.json') } -$resolvedProjectRoot = (Get-Item -LiteralPath $ProjectRoot -Force -ErrorAction Stop).FullName +if ([string]::IsNullOrWhiteSpace($LibdepsRoot)) { + $LibdepsRoot = Join-Path $resolvedProjectRoot '.pio/libdeps' +} $trackedAllowlist = [IO.Path]::GetFullPath((Join-Path $resolvedProjectRoot 'tools/release_toolchain_identity_allowlist.json')) $resolvedOutput = [IO.Path]::GetFullPath($OutputPath) if ($resolvedOutput.Equals($trackedAllowlist, [StringComparison]::OrdinalIgnoreCase)) { throw 'Candidate generation refuses to overwrite the reviewed allowlist. Generate under output/private, review the diff, and update the tracked file explicitly.' } +$resolvedCandidateRoot = [IO.Path]::GetFullPath($candidateRoot).TrimEnd('\', '/') +$candidatePrefix = $resolvedCandidateRoot + [IO.Path]::DirectorySeparatorChar +if (-not $resolvedOutput.StartsWith($candidatePrefix, [StringComparison]::OrdinalIgnoreCase) -or + (Split-Path -Leaf $resolvedOutput) -notmatch '^release_toolchain_identity_allowlist_candidate_[0-9]{8}-[0-9]{6}\.json$' -or + (Test-Path -LiteralPath $resolvedOutput)) { + throw 'Candidate output must be one fresh timestamped JSON file under output/private/toolchain-identity-candidates.' +} +New-Item -ItemType Directory -Force -Path $resolvedCandidateRoot | Out-Null +$candidateRootItem = Get-Item -LiteralPath $resolvedCandidateRoot -Force +if (-not $candidateRootItem.PSIsContainer -or + ($candidateRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Candidate output root must be one real private directory.' +} $pythonHome = Split-Path -Parent (Split-Path -Parent ( (Get-Item -LiteralPath $PlatformioExecutable -Force -ErrorAction Stop).FullName)) +$resolvedGit = (Get-Item -LiteralPath $GitExecutable -Force -ErrorAction Stop).FullName +$gitHome = Split-Path -Parent (Split-Path -Parent $resolvedGit) $rootMap = @{ pythonHome = $pythonHome + gitHome = $gitHome legacyCore = (Get-Item -LiteralPath $DefaultCoreDir -Force -ErrorAction Stop).FullName releaseCore = (Get-Item -LiteralPath $ReleaseCoreDir -Force -ErrorAction Stop).FullName projectRoot = $resolvedProjectRoot + libdepsRoot = (Get-Item -LiteralPath $LibdepsRoot -Force -ErrorAction Stop).FullName } $candidate = New-StackchanReleaseToolchainIdentityCandidate ` -RootMap $rootMap ` -PlatformioExecutable $PlatformioExecutable ` - -PythonExecutable $PythonExecutable -New-Item -ItemType Directory -Force -Path (Split-Path -Parent $resolvedOutput) | Out-Null -$candidate | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $resolvedOutput -Encoding UTF8 + -PythonExecutable $PythonExecutable ` + -GitExecutable $resolvedGit +$candidateJson = $candidate | ConvertTo-Json -Depth 8 +$stream = [IO.File]::Open( + $resolvedOutput, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) +$writer = [IO.StreamWriter]::new($stream, [Text.UTF8Encoding]::new($false)) +try { + $writer.WriteLine($candidateJson) +} finally { + $writer.Dispose() + $stream.Dispose() +} Write-Output $resolvedOutput diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index e3b4510b..ad9acaae 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -3,7 +3,13 @@ param( [switch]$SkipBuild, [switch]$AllowDirty, [switch]$ObserveCandidateActions, - [switch]$ReleaseShortPathChild + [switch]$ReleaseShortPathChild, + [string]$ToolchainAllowlistPath, + [string]$GitExecutable, + [string]$PythonExecutable, + [string]$PlatformioExecutable, + [string]$LegacyCoreDir, + [string]$ReleaseCoreDir ) $ErrorActionPreference = "Stop" @@ -68,23 +74,172 @@ if ($unexpectedGitOverrides.Count -gt 0) { throw "Release packaging refuses ambient Git overrides: $($unexpectedGitNames -join ', ')" } -if (-not $SkipBuild) { - throw @' -Release-grade packaging is fail-closed before Git or build-tool execution. No tracked reviewed -exact toolchain allowlist currently authorizes the Git executable, PlatformIO/Python launchers, -their complete runtime inputs, and the post-build dependency state. Diagnostic packaging remains -available only with -SkipBuild -AllowDirty; it is never release eligible. -'@ +$releaseBootstrapNullAttributes = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } +$releaseToolchainAllowlistSha256 = '30607EB46546E49CB72A231C98CDB62FE5987D24252237821F344C1E1B797A5D' # reviewed allowlist SHA-256 +$releaseToolchainIdentityHelperSha256 = '35D688C55E3CF7694B8E8644813A5C8658E2D26DE78DCF8331E62445DDC49BE4' # reviewed identity helper SHA-256 +$releaseToolchainSemanticVerifierSha256 = '649DE0BBF4A966ADF389A4C2F98190B87958E2ECCC1DF15A6E6FE04D86A4BEBA' # reviewed semantic verifier SHA-256 +$releaseToolchainPreBuild = $null +$script:releaseToolchainIdentityRecords = [System.Collections.Generic.List[object]]::new() +$releaseToolchainEligible = $false +$releaseToolchainIdentityEvidence = $null +$script:releaseToolchainReadLeases = [System.Collections.Generic.List[IO.FileStream]]::new() +$script:releaseToolchainLeaseState = $null + +function Add-ReleaseToolchainReadLease { + param([Parameter(Mandatory = $true)][string]$LiteralPath) + $item = Get-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Release bootstrap refuses a non-file or redirected lease target: $LiteralPath" + } + $lease = [IO.File]::Open( + $item.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $script:releaseToolchainReadLeases.Add($lease) | Out-Null +} + +function Get-ReleaseBootstrapSha256 { + param([Parameter(Mandatory = $true)][string]$LiteralPath) + $item = Get-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Release bootstrap refuses a non-file or redirected input: $LiteralPath" + } + $stream = [IO.File]::Open($item.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $hasher = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($hasher.ComputeHash($stream)) -replace '-', '').ToUpperInvariant() + } finally { + $hasher.Dispose() + $stream.Dispose() + } } -$releaseBootstrapNullAttributes = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } -$releaseBootstrapGitCommand = Get-Command -Name git -CommandType Application -ErrorAction SilentlyContinue | - Select-Object -First 1 -if ($null -eq $releaseBootstrapGitCommand) { - throw 'Release packaging requires a Git application executable; functions, aliases, and scripts are refused.' +function Close-ReleaseToolchainResources { + if ($null -ne $script:releaseToolchainLeaseState -and + -not [bool]$script:releaseToolchainLeaseState.closed) { + $closeCommand = Get-Command -Name Close-StackchanToolchainLeaseState ` + -CommandType Function -ErrorAction SilentlyContinue + if ($null -eq $closeCommand) { + throw 'Release toolchain guard exists but its cleanup function is unavailable.' + } + Close-StackchanToolchainLeaseState -LeaseState $script:releaseToolchainLeaseState + } + foreach ($lease in @($script:releaseToolchainReadLeases)) { + $lease.Dispose() + } + $script:releaseToolchainReadLeases.Clear() +} + +trap { + $packageFailure = $_ + if ($script:releaseSourceCleanupReady -and + $null -ne (Get-Command -Name Remove-ReleaseSourceWorktree ` + -CommandType Function -ErrorAction SilentlyContinue)) { + try { + Remove-ReleaseSourceWorktree + } catch { + Write-Warning 'Could not clean the exact release source worktree; it remains preserved for inspection.' + } + } + try { + Close-ReleaseToolchainResources + } catch { + Write-Warning "Could not fully release package toolchain resources: $($_.Exception.Message)" + } + throw $packageFailure +} + +if (-not $SkipBuild) { + $requiredToolchainArguments = [ordered]@{ + GitExecutable = $GitExecutable + PythonExecutable = $PythonExecutable + PlatformioExecutable = $PlatformioExecutable + LegacyCoreDir = $LegacyCoreDir + ReleaseCoreDir = $ReleaseCoreDir + } + foreach ($entry in $requiredToolchainArguments.GetEnumerator()) { + if ([string]::IsNullOrWhiteSpace([string]$entry.Value)) { + throw "Release packaging requires explicit -$($entry.Key) authority." + } + } + if ([string]::IsNullOrWhiteSpace($ToolchainAllowlistPath)) { + $ToolchainAllowlistPath = Join-Path $PSScriptRoot 'release_toolchain_identity_allowlist.json' + } + $ToolchainAllowlistPath = (Get-Item -LiteralPath $ToolchainAllowlistPath -Force -ErrorAction Stop).FullName + $identityHelperPath = Join-Path $PSScriptRoot 'release_toolchain_identity.ps1' + $semanticVerifierPath = Join-Path $PSScriptRoot 'verify_git_pack_semantics.py' + $bootstrapInputs = @( + [ordered]@{ path = $ToolchainAllowlistPath; expected = $releaseToolchainAllowlistSha256; label = 'allowlist' }, + [ordered]@{ path = $identityHelperPath; expected = $releaseToolchainIdentityHelperSha256; label = 'identity helper' }, + [ordered]@{ path = $semanticVerifierPath; expected = $releaseToolchainSemanticVerifierSha256; label = 'semantic verifier' } + ) + foreach ($input in $bootstrapInputs) { + if ([string]$input.expected -notmatch '^[0-9A-F]{64}$') { + throw "Release packaging has no reviewed pre-Git byte authority for the $([string]$input.label)." + } + Add-ReleaseToolchainReadLease -LiteralPath ([string]$input.path) + $actual = Get-ReleaseBootstrapSha256 -LiteralPath ([string]$input.path) + if ($actual -cne [string]$input.expected) { + throw "Release packaging pre-Git byte authority mismatch: $([string]$input.label)" + } + } + $requiredPythonEnvironment = [ordered]@{ + PYTHONNOUSERSITE = '1' + PYTHONSAFEPATH = '1' + PYTHONDONTWRITEBYTECODE = '1' + PYTHONHASHSEED = '0' + PYTHONUTF8 = '1' + PYTHONIOENCODING = 'utf-8' + } + $unexpectedPythonEnvironment = @(Get-ChildItem Env: | Where-Object { + $_.Name -like 'PYTHON*' -and -not $requiredPythonEnvironment.Contains($_.Name) + }) + if ($unexpectedPythonEnvironment.Count -ne 0) { + throw "Release packaging refuses ambient Python overrides: $(@($unexpectedPythonEnvironment.Name | Sort-Object) -join ', ')" + } + foreach ($entry in $requiredPythonEnvironment.GetEnumerator()) { + $existing = [Environment]::GetEnvironmentVariable( + [string]$entry.Key, [EnvironmentVariableTarget]::Process) + if (-not [string]::IsNullOrWhiteSpace($existing) -and $existing -cne [string]$entry.Value) { + throw "Release packaging refuses ambient Python override: $($entry.Key)" + } + [Environment]::SetEnvironmentVariable( + [string]$entry.Key, [string]$entry.Value, [EnvironmentVariableTarget]::Process) + } + . $identityHelperPath + $script:releaseToolchainLeaseState = New-StackchanToolchainLeaseState + $resolvedGitExecutable = (Get-Item -LiteralPath $GitExecutable -Force -ErrorAction Stop).FullName + $resolvedPythonExecutable = (Get-Item -LiteralPath $PythonExecutable -Force -ErrorAction Stop).FullName + $resolvedPlatformioExecutable = (Get-Item -LiteralPath $PlatformioExecutable -Force -ErrorAction Stop).FullName + $resolvedLegacyCore = (Get-Item -LiteralPath $LegacyCoreDir -Force -ErrorAction Stop).FullName + $resolvedReleaseCore = (Get-Item -LiteralPath $ReleaseCoreDir -Force -ErrorAction Stop).FullName + foreach ($executable in @($resolvedGitExecutable, $resolvedPythonExecutable, $resolvedPlatformioExecutable)) { + Add-ReleaseToolchainReadLease -LiteralPath $executable + } + $releaseToolchainRootMap = @{ + pythonHome = Split-Path -Parent $resolvedPythonExecutable + gitHome = Split-Path -Parent (Split-Path -Parent $resolvedGitExecutable) + legacyCore = $resolvedLegacyCore + releaseCore = $resolvedReleaseCore + projectRoot = (Get-Item -LiteralPath (Split-Path -Parent $PSScriptRoot) -Force).FullName + libdepsRoot = Join-Path (Split-Path -Parent $PSScriptRoot) '.pio/libdeps' + } + $releaseToolchainPreBuild = Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $ToolchainAllowlistPath -RootMap $releaseToolchainRootMap ` + -PlatformioExecutable $resolvedPlatformioExecutable ` + -PythonExecutable $resolvedPythonExecutable -GitExecutable $resolvedGitExecutable ` + -Phase PreBuild -LeaseState $script:releaseToolchainLeaseState -LeaseScope 'pre-build' + $script:releaseToolchainIdentityRecords.Add([ordered]@{ + stage = 'preBuild'; cycle = $null; environment = $null; result = $releaseToolchainPreBuild + }) | Out-Null + $releaseBootstrapGitExecutable = $resolvedGitExecutable +} else { + $releaseBootstrapGitCommand = Get-Command -Name git -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -eq $releaseBootstrapGitCommand) { + throw 'Release packaging requires a Git application executable; functions, aliases, and scripts are refused.' + } + $releaseBootstrapGitExecutable = ( + Resolve-Path -LiteralPath ([string]$releaseBootstrapGitCommand.Source)).Path } -$releaseBootstrapGitExecutable = ( - Resolve-Path -LiteralPath ([string]$releaseBootstrapGitCommand.Source)).Path function Invoke-ReleaseBootstrapGit { param( [Parameter(Mandatory = $true)][string]$Root, @@ -109,7 +264,15 @@ function Invoke-ReleaseBootstrapGit { try { $env:GIT_NO_REPLACE_OBJECTS = '1' $env:GIT_ATTR_NOSYSTEM = '1' + if ($null -ne $script:releaseToolchainLeaseState) { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:releaseToolchainLeaseState -Context 'before trusted Git execution' + } & $script:releaseBootstrapGitExecutable @gitArguments + if ($null -ne $script:releaseToolchainLeaseState) { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:releaseToolchainLeaseState -Context 'after trusted Git execution' + } } finally { if ($null -eq $previousNoReplaceObjects) { Remove-Item Env:\GIT_NO_REPLACE_OBJECTS -ErrorAction SilentlyContinue @@ -207,6 +370,15 @@ function Assert-ReleaseBootstrapTrust { $bootstrapFiles = @( '.gitattributes', 'tools/package_release.ps1', 'tools/test_firmware_reproducible_build_contract.ps1', + 'tools/test_release_toolchain_integration_contract.ps1', + 'tools/test_release_toolchain_documentation_contract.ps1', + 'tools/test_release_toolchain_cache_contract.ps1', + 'tools/seal_pioarduino_release_core.ps1', + 'tools/release_toolchain_identity.ps1', + 'tools/release_toolchain_identity_allowlist.json', + 'tools/verify_git_pack_semantics.py', + 'tools/test_git_pack_semantic_verifier.py', + 'tools/test_release_toolchain_identity_contract.ps1', 'tools/platformio_resolver.ps1', 'tools/preview_python_resolver.ps1', 'tools/release_asset_contract.ps1', 'tools/firmware_reproducibility_failure.ps1', 'tools/release_source_binding.ps1', 'tools/release_dependency_evidence.ps1', @@ -434,6 +606,12 @@ if ( if ($SkipBuild) { $childArgs += "-SkipBuild" } if ($AllowDirty) { $childArgs += "-AllowDirty" } if ($ObserveCandidateActions) { $childArgs += "-ObserveCandidateActions" } + if ($ToolchainAllowlistPath) { $childArgs += @('-ToolchainAllowlistPath', $ToolchainAllowlistPath) } + if ($GitExecutable) { $childArgs += @('-GitExecutable', $GitExecutable) } + if ($PythonExecutable) { $childArgs += @('-PythonExecutable', $PythonExecutable) } + if ($PlatformioExecutable) { $childArgs += @('-PlatformioExecutable', $PlatformioExecutable) } + if ($LegacyCoreDir) { $childArgs += @('-LegacyCoreDir', $LegacyCoreDir) } + if ($ReleaseCoreDir) { $childArgs += @('-ReleaseCoreDir', $ReleaseCoreDir) } & $script:releasePowerShellExecutable @childArgs $childExit = $LASTEXITCODE } finally { @@ -470,6 +648,9 @@ if ($LASTEXITCODE -ne 0) { . (Join-Path $PSScriptRoot "release_dependency_evidence.ps1") . (Join-Path $PSScriptRoot "release_git_trust.ps1") . (Join-Path $PSScriptRoot "release_ota_selector_policy.ps1") +if (-not $SkipBuild) { + $script:StackchanPlatformioCommand = $resolvedPlatformioExecutable +} $credentialHygieneJson = (& (Join-Path $PSScriptRoot "check_release_credential_hygiene.ps1") -Root $repoRoot -Json | Out-String) $credentialHygieneReport = $credentialHygieneJson | ConvertFrom-Json @@ -478,7 +659,11 @@ if ($credentialHygieneReport.status -ne "release-credential-hygiene-ready" -or [ } Write-Host $credentialHygieneJson.Trim() -$releaseLegacyPlatformioCore = Get-StackchanPlatformioCoreDir +$releaseLegacyPlatformioCore = if ($SkipBuild) { + Get-StackchanPlatformioCoreDir +} else { + $resolvedLegacyCore +} if ([string]::IsNullOrWhiteSpace($releaseLegacyPlatformioCore) -and -not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { $legacyCoreFallback = Join-Path $env:USERPROFILE '.platformio' @@ -503,6 +688,7 @@ function Get-ReleasePlatformioCoreDir { param([string]$Environment) if ($Environment -eq "stackchan_release_full") { + if (-not $SkipBuild) { return $resolvedReleaseCore } return Join-Path $releasePlatformioCoreRoot "pioarduino" } return $releaseLegacyPlatformioCore @@ -523,6 +709,7 @@ function Invoke-StackchanReleasePlatformio { [string]$BuildCacheDir, [string]$ExpectedCommit, [string]$ExpectedEpoch, + [string]$BuildProjectRoot, [string[]]$Arguments ) @@ -530,6 +717,8 @@ function Invoke-StackchanReleasePlatformio { $previousBuildCacheDir = $env:PLATFORMIO_BUILD_CACHE_DIR $previousExpectedCommit = $env:STACKCHAN_EXPECTED_BUILD_COMMIT $previousExpectedEpoch = $env:STACKCHAN_EXPECTED_BUILD_EPOCH + $previousPythonSafePath = $env:PYTHONSAFEPATH + $previousProcessPath = $env:PATH try { $env:PLATFORMIO_CORE_DIR = Get-ReleasePlatformioCoreDir -Environment $Environment if (-not [string]::IsNullOrWhiteSpace($BuildCacheDir)) { @@ -544,7 +733,29 @@ function Invoke-StackchanReleasePlatformio { $env:STACKCHAN_EXPECTED_BUILD_COMMIT = $ExpectedCommit $env:STACKCHAN_EXPECTED_BUILD_EPOCH = $ExpectedEpoch } + if (-not $SkipBuild) { + if ([string]::IsNullOrWhiteSpace($BuildProjectRoot)) { + throw 'Release PlatformIO execution requires one explicit BuildProjectRoot.' + } + Remove-Item Env:\PYTHONSAFEPATH -ErrorAction SilentlyContinue + $env:PATH = @( + (Split-Path -Parent $resolvedPlatformioExecutable), + (Split-Path -Parent $resolvedPythonExecutable), + (Split-Path -Parent $resolvedGitExecutable), + (Join-Path ([Environment]::GetFolderPath('Windows')) 'System32'), + (Join-Path ([Environment]::GetFolderPath('Windows')) 'System32/WindowsPowerShell/v1.0') + ) -join [IO.Path]::PathSeparator + Assert-StackchanReleaseBuildPythonEnvironment -ProjectRoot $BuildProjectRoot + } + if ($null -ne $script:releaseToolchainLeaseState) { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:releaseToolchainLeaseState -Context 'before PlatformIO execution' + } Invoke-StackchanPlatformio @Arguments + if ($null -ne $script:releaseToolchainLeaseState) { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:releaseToolchainLeaseState -Context 'after PlatformIO execution' + } } finally { if ($null -eq $previousCoreDir) { Remove-Item Env:\PLATFORMIO_CORE_DIR -ErrorAction SilentlyContinue @@ -566,6 +777,12 @@ function Invoke-StackchanReleasePlatformio { } else { $env:STACKCHAN_EXPECTED_BUILD_EPOCH = $previousExpectedEpoch } + if ($null -eq $previousPythonSafePath) { + Remove-Item Env:\PYTHONSAFEPATH -ErrorAction SilentlyContinue + } else { + $env:PYTHONSAFEPATH = $previousPythonSafePath + } + $env:PATH = $previousProcessPath } } @@ -718,6 +935,7 @@ function Invoke-LoggedReleasePlatformio { [Parameter(Mandatory = $true)][string]$BuildCacheDir, [Parameter(Mandatory = $true)][string]$ExpectedCommit, [Parameter(Mandatory = $true)][string]$ExpectedEpoch, + [Parameter(Mandatory = $true)][string]$BuildProjectRoot, [Parameter(Mandatory = $true)][string[]]$Arguments, [Parameter(Mandatory = $true)][string]$LogPath, [Parameter(Mandatory = $true)][string]$Description @@ -731,6 +949,7 @@ function Invoke-LoggedReleasePlatformio { -BuildCacheDir $BuildCacheDir ` -ExpectedCommit $ExpectedCommit ` -ExpectedEpoch $ExpectedEpoch ` + -BuildProjectRoot $BuildProjectRoot ` -Arguments $Arguments 2>&1 | ForEach-Object { $line = [string]$_ $lines.Add($line) @@ -786,6 +1005,7 @@ function Save-BuildDependencySnapshot { -BuildCacheDir $BuildCacheDir ` -ExpectedCommit $ExpectedCommit ` -ExpectedEpoch $ExpectedEpoch ` + -BuildProjectRoot $BuildProjectRoot ` -Arguments @('pkg', 'list', '-d', $BuildProjectRoot, '-e', $Environment) ` -LogPath (Join-Path $environmentRoot 'pkg-list.txt') ` -Description "$Environment dependency inventory") @@ -794,6 +1014,7 @@ function Save-BuildDependencySnapshot { -BuildCacheDir $BuildCacheDir ` -ExpectedCommit $ExpectedCommit ` -ExpectedEpoch $ExpectedEpoch ` + -BuildProjectRoot $BuildProjectRoot ` -Arguments @('pkg', 'list', '-d', $BuildProjectRoot, '-e', $Environment, '-v') ` -LogPath (Join-Path $environmentRoot 'pkg-list-verbose.txt') ` -Description "$Environment verbose dependency inventory") @@ -802,6 +1023,7 @@ function Save-BuildDependencySnapshot { -BuildCacheDir $BuildCacheDir ` -ExpectedCommit $ExpectedCommit ` -ExpectedEpoch $ExpectedEpoch ` + -BuildProjectRoot $BuildProjectRoot ` -Arguments @('--version') ` -LogPath (Join-Path $environmentRoot 'platformio-version.txt') ` -Description "$Environment PlatformIO version capture" | Out-Null @@ -866,6 +1088,34 @@ function Invoke-FirmwareBuildCycle { -BuildCacheDir $environmentBuildCache ` -ExpectedCommit $ExpectedCommit ` -ExpectedEpoch $ExpectedEpoch ` + -BuildProjectRoot $BuildProjectRoot ` + -Arguments @('pkg', 'install', '-d', $BuildProjectRoot, '-e', $environment) ` + -LogPath (Join-Path $CycleRoot "logs/$environment-dependency-stage.log") ` + -Description "$CycleName/$environment dependency staging" | Out-Null + $dependencyRootMap = @{ + pythonHome = [string]$releaseToolchainRootMap.pythonHome + gitHome = [string]$releaseToolchainRootMap.gitHome + legacyCore = [string]$releaseToolchainRootMap.legacyCore + releaseCore = [string]$releaseToolchainRootMap.releaseCore + projectRoot = $BuildProjectRoot + libdepsRoot = Join-Path $BuildProjectRoot '.pio/libdeps' + } + $preExecutionIdentity = Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $ToolchainAllowlistPath -RootMap $dependencyRootMap ` + -PlatformioExecutable $resolvedPlatformioExecutable ` + -PythonExecutable $resolvedPythonExecutable -GitExecutable $resolvedGitExecutable ` + -Phase PostBuild -Environment $environment ` + -LeaseState $script:releaseToolchainLeaseState -LeaseScope $CycleName + $script:releaseToolchainIdentityRecords.Add([ordered]@{ + stage = 'preExecution'; cycle = $CycleName; environment = $environment + result = $preExecutionIdentity + }) | Out-Null + Invoke-LoggedReleasePlatformio ` + -Environment $environment ` + -BuildCacheDir $environmentBuildCache ` + -ExpectedCommit $ExpectedCommit ` + -ExpectedEpoch $ExpectedEpoch ` + -BuildProjectRoot $BuildProjectRoot ` -Arguments @("run", "-d", $BuildProjectRoot, "-e", $environment, "-t", "clean") ` -LogPath (Join-Path $CycleRoot "logs/$environment-clean.log") ` -Description "$CycleName/$environment clean" | Out-Null @@ -879,9 +1129,20 @@ function Invoke-FirmwareBuildCycle { -BuildCacheDir $environmentBuildCache ` -ExpectedCommit $ExpectedCommit ` -ExpectedEpoch $ExpectedEpoch ` + -BuildProjectRoot $BuildProjectRoot ` -Arguments @("run", "-d", $BuildProjectRoot, "-e", $environment) ` -LogPath (Join-Path $CycleRoot "logs/$environment-build.log") ` -Description "$CycleName/$environment build" | Out-Null + $postBuildIdentity = Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $ToolchainAllowlistPath -RootMap $dependencyRootMap ` + -PlatformioExecutable $resolvedPlatformioExecutable ` + -PythonExecutable $resolvedPythonExecutable -GitExecutable $resolvedGitExecutable ` + -Phase PostBuild -Environment $environment ` + -LeaseState $script:releaseToolchainLeaseState -LeaseScope $CycleName + $script:releaseToolchainIdentityRecords.Add([ordered]@{ + stage = 'postBuild'; cycle = $CycleName; environment = $environment + result = $postBuildIdentity + }) | Out-Null Copy-BuildArtifacts ` -Environment $environment ` -BuildDir (Join-Path $BuildProjectRoot ".pio/build/$environment") ` @@ -1001,17 +1262,6 @@ function Remove-ReleaseSourceWorktree { } $script:releaseSourceCleanupReady = $true -trap { - $packageFailure = $_ - if ($script:releaseSourceCleanupReady) { - try { - Remove-ReleaseSourceWorktree - } catch { - Write-Warning "Could not clean the exact release source worktree; it remains preserved for inspection." - } - } - throw $packageFailure -} if (-not $SkipBuild) { # The three profiles span two PlatformIO core roots. Each proof cycle uses a @@ -1063,6 +1313,9 @@ if (-not $SkipBuild) { -ExpectedEpoch $canonicalBuildEpoch ` -Phase "reproducibility proof post-cycle-a" ` -ProjectRoot $activeBuildSourceRoot + Close-StackchanToolchainLeaseScope ` + -LeaseState $script:releaseToolchainLeaseState -Scope 'cycle-a' ` + -RequireUnchanged -Context 'cycle-a final authenticated namespace' Invoke-ReleaseGit -Arguments @( '-C', $repoRoot, 'worktree', 'remove', '--force', $activeBuildSourceRoot) if ($LASTEXITCODE -ne 0) { throw "Could not remove the cycle-a detached firmware worktree." } @@ -1098,6 +1351,9 @@ if (-not $SkipBuild) { -ExpectedEpoch $canonicalBuildEpoch ` -Phase "reproducibility proof post-cycle-b" ` -ProjectRoot $activeBuildSourceRoot + Close-StackchanToolchainLeaseScope ` + -LeaseState $script:releaseToolchainLeaseState -Scope 'cycle-b' ` + -RequireUnchanged -Context 'cycle-b final authenticated namespace' if ($cycleAArtifacts.Count -ne 15 -or $cycleBArtifacts.Count -ne 15) { throw "Firmware reproducibility proof did not produce all 15 artifacts per cycle." @@ -1161,6 +1417,38 @@ if (-not $SkipBuild) { } throw $buildFailure.Exception } + $identityRecords = @($script:releaseToolchainIdentityRecords) + $preExecutionRecords = @($identityRecords | Where-Object { [string]$_.stage -ceq 'preExecution' }) + $postBuildRecords = @($identityRecords | Where-Object { [string]$_.stage -ceq 'postBuild' }) + $invalidIdentityRecords = @($identityRecords | Where-Object { + [string]$_.result.status -cne 'verified' -or + [string]$_.result.allowlistSha256 -cne $releaseToolchainAllowlistSha256 -or + [string]$_.result.observationSha256 -notmatch '^[0-9A-F]{64}$' + }) + if ($identityRecords.Count -ne 13 -or $preExecutionRecords.Count -ne 6 -or + $postBuildRecords.Count -ne 6 -or $invalidIdentityRecords.Count -ne 0) { + throw 'Release toolchain identity did not verify PreBuild plus pre-execution/post-build dependency state for both cycles.' + } + $reviewedAllowlist = Get-Content -LiteralPath $ToolchainAllowlistPath -Raw | ConvertFrom-Json + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:releaseToolchainLeaseState ` + -Context 'release toolchain eligibility decision' -VerifyNamespace + $releaseToolchainEligible = $true + $releaseToolchainIdentityEvidence = [ordered]@{ + schema = 'stackchan.release-toolchain-package-evidence.v1' + status = 'verified-reviewed-toolchain-and-two-cycle-dependencies' + platformKey = [string]$releaseToolchainPreBuild.platformKey + allowlistSha256 = $releaseToolchainAllowlistSha256 + identityHelperSha256 = $releaseToolchainIdentityHelperSha256 + semanticVerifierSha256 = $releaseToolchainSemanticVerifierSha256 + review = $reviewedAllowlist.review + gitExecutable = $resolvedGitExecutable + pythonExecutable = $resolvedPythonExecutable + platformioExecutable = $resolvedPlatformioExecutable + preBuild = $releaseToolchainPreBuild + preExecution = $preExecutionRecords + postBuild = $postBuildRecords + } $builtFirmwareCache = $cycleBRoot Assert-ReleaseSourceIdentity ` -ExpectedCommit $canonicalBuildCommit ` @@ -1507,6 +1795,7 @@ Copy-Item -LiteralPath "site/index.html" -Destination $siteDir Copy-Item -LiteralPath "site/privacy/index.html" -Destination $privacySiteDir Copy-Item -LiteralPath "docs/BRAIN_MODEL.md" -Destination $docsDir Copy-Item -LiteralPath "docs/COMPANION_CROSS_PLATFORM_PLAN.md" -Destination $docsDir +Copy-Item -LiteralPath "docs/COMPANION_APP_GAP_ANALYSIS.md" -Destination $docsDir Copy-Item -LiteralPath "docs/CONVERSATION_V2_ROADMAP.md" -Destination $docsDir Copy-Item -LiteralPath "docs/CHARACTER_LOCK.md" -Destination $docsDir Copy-Item -LiteralPath "docs/CREATING_PERSONAS.md" -Destination $docsDir @@ -1763,6 +2052,15 @@ foreach ($file in $companionEvidenceFiles) { $releaseTools = @( "tools/package_release.ps1", + "tools/release_toolchain_identity.ps1", + "tools/release_toolchain_identity_allowlist.json", + "tools/verify_git_pack_semantics.py", + "tools/test_git_pack_semantic_verifier.py", + "tools/test_release_toolchain_identity_contract.ps1", + "tools/test_release_toolchain_integration_contract.ps1", + "tools/test_release_toolchain_documentation_contract.ps1", + "tools/test_release_toolchain_cache_contract.ps1", + "tools/seal_pioarduino_release_core.ps1", "tools/firmware_reproducibility_proof.ps1", "tools/test_firmware_reproducibility_proof_contract.ps1", "tools/firmware_reproducibility_failure.ps1", @@ -2710,6 +3008,19 @@ $manifest = [ordered]@{ } proof = $firmwareReproducibilityProof } + toolchainIdentity = if ($SkipBuild) { + [ordered]@{ + schema = 'stackchan.release-toolchain-package-evidence.v1' + status = 'not-applicable-diagnostic-skip-build' + allowlistSha256 = $null + identityHelperSha256 = $null + semanticVerifierSha256 = $null + preExecution = @() + postBuild = @() + } + } else { + $releaseToolchainIdentityEvidence + } servoDefault = if ($SkipBuild) { "boot and motion state unverified; copied firmware identity is unknown; do not flash" } else { @@ -2724,10 +3035,10 @@ $manifest = [ordered]@{ packageSourceIsolationPolicy = if ($SkipBuild) { "diagnostic-mutable-source-unbound" } else { "detached-clean-worktree-pinned-to-package-commit" } packageSourceCommit = if ($SkipBuild) { $null } else { $canonicalBuildCommit } packageSourceEpoch = if ($SkipBuild) { $null } else { $canonicalBuildEpoch } - releaseEligible = (-not $SkipBuild) - hardwareValidationEligible = (-not $SkipBuild) - distributionEligible = (-not $SkipBuild) - flashEligible = (-not $SkipBuild) + releaseEligible = ($releaseToolchainEligible -and (-not $SkipBuild)) + hardwareValidationEligible = ($releaseToolchainEligible -and (-not $SkipBuild)) + distributionEligible = ($releaseToolchainEligible -and (-not $SkipBuild)) + flashEligible = ($releaseToolchainEligible -and (-not $SkipBuild)) dirty = ($sourceDirtyFiles.Count -gt 0) dirtyFiles = @($sourceDirtyFiles) generatedMediaDirtyFiles = @($generatedMediaDirtyFiles) @@ -3012,10 +3323,11 @@ $readinessReport = [ordered]@{ } else { "Promotion requires source-matched supervised hardware qualification, bridge AI qualification, the required soak, successful release checks, and explicit owner approval." } - nextOperatorCommand = if ($SkipBuild) { - $null + nextOperatorCommand = $null + nextOperatorGuidance = if ($SkipBuild) { + 'Diagnostic packages have no arrival or hardware authority.' } else { - ".\tools\prepare_device_arrival.cmd -Port COM3 -Operator `"Your Name`" -DeviceId STACKCHAN-001" + 'Return to the exact clean trusted source checkout, define the six-value releaseToolchain splat from docs/RELEASE_PROCESS.md, and pass this ZIP to tools/prepare_device_arrival.ps1. The archive does not confer release authority.' } } @@ -3226,9 +3538,10 @@ Owner approval has not been recorded for this candidate. Promotion requires sour supervised hardware qualification, bridge AI qualification, the required soak, successful release checks, and explicit owner approval. -Recommended arrival command from the extracted package: - - $($readinessReport.nextOperatorCommand) +Arrival authority is intentionally not embedded in this archive. Return to the exact clean trusted +source checkout, define the six-value ``releaseToolchain`` splat from ``docs/RELEASE_PROCESS.md``, +and pass this ZIP to ``tools/prepare_device_arrival.ps1``. The archive does not confer release +authority. "@ | Set-Content -Path (Join-Path $outDir "READINESS_REPORT.md") -Encoding UTF8 } @@ -3430,7 +3743,14 @@ if ($AllowDirty) { $packageVerifyArgs += "-AllowDirtyPackage" } if (-not $SkipBuild) { - $packageVerifyArgs += "-RequireReleaseEligible" + $packageVerifyArgs += @( + '-RequireReleaseEligible', + '-ToolchainAllowlistPath', $ToolchainAllowlistPath, + '-GitExecutable', $resolvedGitExecutable, + '-PythonExecutable', $resolvedPythonExecutable, + '-PlatformioExecutable', $resolvedPlatformioExecutable, + '-LegacyCoreDir', $resolvedLegacyCore, + '-ReleaseCoreDir', $resolvedReleaseCore) } $previousVerifyErrorPreference = $ErrorActionPreference try { @@ -3455,8 +3775,13 @@ if (-not $SkipBuild) { $builtFirmwareCache = $null $firmwareBuildCacheRoot = $null $firmwareDependencySnapshotRoot = $null + Close-StackchanToolchainLeaseState ` + -LeaseState $script:releaseToolchainLeaseState -RequireUnchanged ` + -Context 'completed governed release package' } +Close-ReleaseToolchainResources + if ($SkipBuild) { Write-Host "Diagnostic archive only -- release and hardware use forbidden:" } else { diff --git a/tools/platformio_resolver.ps1 b/tools/platformio_resolver.ps1 index 5b74a68f..2d9ea616 100644 --- a/tools/platformio_resolver.ps1 +++ b/tools/platformio_resolver.ps1 @@ -102,6 +102,11 @@ function Invoke-StackchanUtf8Process { [string[]]$Arguments = @() ) + $resolvedCommand = Resolve-StackchanExactApplicationExecutable -Candidate $Command + if ($null -eq $resolvedCommand) { + throw "Command did not start its resolved native executable: $(Format-StackchanCommand (@($Command) + $Arguments))" + } + $Command = $resolvedCommand $previousPythonIoEncoding = $env:PYTHONIOENCODING $previousPythonUtf8 = $env:PYTHONUTF8 $previousOutputEncoding = $OutputEncoding @@ -112,8 +117,23 @@ function Invoke-StackchanUtf8Process { $env:PYTHONUTF8 = "1" $OutputEncoding = $utf8 [Console]::OutputEncoding = $utf8 - & $Command @Arguments - $processExitCode = $LASTEXITCODE + $previousErrorActionPreference = $ErrorActionPreference + try { + # Windows PowerShell presents native stderr as ErrorRecord objects. Do + # not let the inherited Stop policy unwind while the authenticated child + # is still running; consume its complete lifecycle, then classify the + # native exit explicitly below. + $ErrorActionPreference = "Continue" + $nativeExitSentinel = [int]::MinValue + $global:LASTEXITCODE = $nativeExitSentinel + & $Command @Arguments + $processExitCode = $global:LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($processExitCode -eq $nativeExitSentinel) { + throw "Command did not start its resolved native executable: $(Format-StackchanCommand (@($Command) + $Arguments))" + } if ($processExitCode -ne 0) { throw "Command failed with exit code $processExitCode`: $(Format-StackchanCommand (@($Command) + $Arguments))" } diff --git a/tools/prepare_device_arrival.ps1 b/tools/prepare_device_arrival.ps1 index 2f80e313..1be60f9d 100644 --- a/tools/prepare_device_arrival.ps1 +++ b/tools/prepare_device_arrival.ps1 @@ -8,7 +8,13 @@ param( [string]$DeviceId = "", [string]$ShareRoot = "", [switch]$AllowIncompleteMetadata, - [switch]$AllowDirtyPackage + [switch]$AllowDirtyPackage, + [string]$ToolchainAllowlistPath = "", + [string]$GitExecutable = "", + [string]$PythonExecutable = "", + [string]$PlatformioExecutable = "", + [string]$LegacyCoreDir = "", + [string]$ReleaseCoreDir = "" ) $ErrorActionPreference = "Stop" @@ -90,17 +96,29 @@ if (-not [string]::IsNullOrWhiteSpace($Port)) { Write-Host "" Write-Host "==> Verify release package" $verifyScript = Join-Path $PSScriptRoot "verify_release_package.ps1" +$releaseToolchainArguments = @{ + ToolchainAllowlistPath = $ToolchainAllowlistPath + GitExecutable = $GitExecutable + PythonExecutable = $PythonExecutable + PlatformioExecutable = $PlatformioExecutable + LegacyCoreDir = $LegacyCoreDir + ReleaseCoreDir = $ReleaseCoreDir +} if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { if ($AllowDirtyPackage) { - & $verifyScript -Version $ReleaseTag -ZipPath $PackageZip -ExpectedCommit $commit -AllowDirtyPackage -RequireReleaseEligible + & $verifyScript -Version $ReleaseTag -ZipPath $PackageZip -ExpectedCommit $commit ` + -AllowDirtyPackage -RequireReleaseEligible @releaseToolchainArguments } else { - & $verifyScript -Version $ReleaseTag -ZipPath $PackageZip -ExpectedCommit $commit -RequireReleaseEligible + & $verifyScript -Version $ReleaseTag -ZipPath $PackageZip -ExpectedCommit $commit ` + -RequireReleaseEligible @releaseToolchainArguments } } else { if ($AllowDirtyPackage) { - & $verifyScript -Version $ReleaseTag -PackageRoot $PackageRoot -ExpectedCommit $commit -AllowDirtyPackage -RequireReleaseEligible + & $verifyScript -Version $ReleaseTag -PackageRoot $PackageRoot -ExpectedCommit $commit ` + -AllowDirtyPackage -RequireReleaseEligible @releaseToolchainArguments } else { - & $verifyScript -Version $ReleaseTag -PackageRoot $PackageRoot -ExpectedCommit $commit -RequireReleaseEligible + & $verifyScript -Version $ReleaseTag -PackageRoot $PackageRoot -ExpectedCommit $commit ` + -RequireReleaseEligible @releaseToolchainArguments } } if ($LASTEXITCODE -ne 0) { @@ -113,23 +131,24 @@ $flashScript = Join-Path $PSScriptRoot "flash_release_firmware.ps1" if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { if ($AllowDirtyPackage) { & $flashScript -PackageZip $PackageZip -Firmware display_only -Version $ReleaseTag ` - -ExpectedCommit $commit -DryRun -Monitor -Port $Port -AllowDirtyPackage + -ExpectedCommit $commit -DryRun -Monitor -Port $Port -AllowDirtyPackage ` + @releaseToolchainArguments } else { & $flashScript -PackageZip $PackageZip -Firmware display_only -Version $ReleaseTag ` - -ExpectedCommit $commit -DryRun -Monitor -Port $Port + -ExpectedCommit $commit -DryRun -Monitor -Port $Port @releaseToolchainArguments } } else { if ($AllowDirtyPackage) { - & $flashScript -PackageRoot $PackageRoot -Firmware display_only -Version $ReleaseTag -ExpectedCommit $commit -DryRun -Monitor -Port $Port -AllowDirtyPackage + & $flashScript -PackageRoot $PackageRoot -Firmware display_only -Version $ReleaseTag -ExpectedCommit $commit -DryRun -Monitor -Port $Port -AllowDirtyPackage @releaseToolchainArguments } else { - & $flashScript -PackageRoot $PackageRoot -Firmware display_only -Version $ReleaseTag -ExpectedCommit $commit -DryRun -Monitor -Port $Port + & $flashScript -PackageRoot $PackageRoot -Firmware display_only -Version $ReleaseTag -ExpectedCommit $commit -DryRun -Monitor -Port $Port @releaseToolchainArguments } } Write-Host "" Write-Host "==> Create hardware evidence packet" $evidenceScript = Join-Path $PSScriptRoot "start_hardware_evidence.ps1" -$metadataArgs = @{} +$metadataArgs = @{} + $releaseToolchainArguments if ($AllowIncompleteMetadata) { $metadataArgs["AllowIncompleteMetadata"] = $true } diff --git a/tools/publish_release.ps1 b/tools/publish_release.ps1 index eb500197..b556c42e 100644 --- a/tools/publish_release.ps1 +++ b/tools/publish_release.ps1 @@ -6,7 +6,13 @@ param( [switch]$PushCurrentBranch, [switch]$AllowExistingRelease, [switch]$AllowDirtyPackage, - [switch]$DryRun + [switch]$DryRun, + [string]$ToolchainAllowlistPath = "", + [string]$GitExecutable = "", + [string]$PythonExecutable = "", + [string]$PlatformioExecutable = "", + [string]$LegacyCoreDir = "", + [string]$ReleaseCoreDir = "" ) $ErrorActionPreference = "Stop" @@ -328,6 +334,12 @@ function Invoke-OperationalPackageVerification { ZipPath = $ZipPath ExpectedCommit = $ExpectedCommit RequireReleaseEligible = $true + ToolchainAllowlistPath = $ToolchainAllowlistPath + GitExecutable = $GitExecutable + PythonExecutable = $PythonExecutable + PlatformioExecutable = $PlatformioExecutable + LegacyCoreDir = $LegacyCoreDir + ReleaseCoreDir = $ReleaseCoreDir } if ($AllowDirtyPackage) { $arguments.AllowDirtyPackage = $true @@ -729,6 +741,12 @@ try { ZipPath = $publishedZipPath ZipSidecarPath = $publishedZipSidecarPath ExpectedCommit = $tagCommit + ToolchainAllowlistPath = $ToolchainAllowlistPath + GitExecutable = $GitExecutable + PythonExecutable = $PythonExecutable + PlatformioExecutable = $PlatformioExecutable + LegacyCoreDir = $LegacyCoreDir + ReleaseCoreDir = $ReleaseCoreDir } & (Join-Path $PSScriptRoot "verify_published_release.ps1") @publishedVerifyArgs @@ -739,6 +757,12 @@ try { -ZipPath $publishedZipPath ` -ZipSidecarPath $publishedZipSidecarPath ` -ExpectedCommit $tagCommit ` + -ToolchainAllowlistPath $ToolchainAllowlistPath ` + -GitExecutable $GitExecutable ` + -PythonExecutable $PythonExecutable ` + -PlatformioExecutable $PlatformioExecutable ` + -LegacyCoreDir $LegacyCoreDir ` + -ReleaseCoreDir $ReleaseCoreDir ` -UploadToRelease Write-Host "Release published and verified:" diff --git a/tools/release_toolchain_identity.ps1 b/tools/release_toolchain_identity.ps1 index 09b525b5..4efbe2df 100644 --- a/tools/release_toolchain_identity.ps1 +++ b/tools/release_toolchain_identity.ps1 @@ -1,9 +1,19 @@ Set-StrictMode -Version Latest -$script:StackchanToolchainIdentitySchema = 'stackchan.release-toolchain-identity.v2' +$script:StackchanToolchainIdentitySchema = 'stackchan.release-toolchain-identity.v3' $script:StackchanToolchainInventorySchema = 'stackchan.byte-tree.v1' $script:StackchanCanonicalLibdepsSchema = 'stackchan.canonical-libdeps.v1' $script:StackchanCanonicalGitLibrarySchema = 'stackchan.canonical-git-library.v1' +$script:StackchanGitPackSemanticVerifierPath = Join-Path $PSScriptRoot 'verify_git_pack_semantics.py' + +function Get-StackchanGitPackVerifierPython { + $command = Get-Command -Name python -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -eq $command) { + throw 'Git pack semantic verification requires an explicit Python application executable.' + } + return [IO.Path]::GetFullPath([string]$command.Source) +} function Get-StackchanReleaseToolchainPlatformKey { if ($env:OS -eq 'Windows_NT') { @@ -24,7 +34,7 @@ function Assert-StackchanPythonImportIsolationState { param( [Parameter(Mandatory = $true)]$Probe, [Parameter(Mandatory = $true)][string]$PythonHome, - [Parameter(Mandatory = $true)][string]$PythonExecutable + [string]$PythonExecutable = (Get-StackchanGitPackVerifierPython) ) $pythonRoot = (Get-Item -LiteralPath $PythonHome -Force -ErrorAction Stop).FullName.TrimEnd('\', '/') @@ -70,7 +80,7 @@ function Assert-StackchanPythonImportIsolationState { function Assert-StackchanPythonImportIsolation { param( [Parameter(Mandatory = $true)][string]$PythonHome, - [Parameter(Mandatory = $true)][string]$PythonExecutable + [string]$PythonExecutable = (Get-StackchanGitPackVerifierPython) ) $requiredEnvironment = [ordered]@{ @@ -149,6 +159,677 @@ print(json.dumps({ -Probe $probe -PythonHome $pythonRoot -PythonExecutable $PythonExecutable } +function Assert-StackchanReleaseBuildPythonEnvironment { + param([Parameter(Mandatory = $true)][string]$ProjectRoot) + + $requiredEnvironment = [ordered]@{ + PYTHONNOUSERSITE = '1' + PYTHONDONTWRITEBYTECODE = '1' + PYTHONHASHSEED = '0' + PYTHONUTF8 = '1' + PYTHONIOENCODING = 'utf-8' + } + foreach ($entry in $requiredEnvironment.GetEnumerator()) { + if ([Environment]::GetEnvironmentVariable( + [string]$entry.Key, [EnvironmentVariableTarget]::Process) -cne [string]$entry.Value) { + throw "Release build Python environment requires $($entry.Key)=$($entry.Value)." + } + } + if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable( + 'PYTHONSAFEPATH', [EnvironmentVariableTarget]::Process))) { + throw 'Release build Python environment requires PYTHONSAFEPATH to be unset so byte-identified PlatformIO tool packages can import their adjacent modules.' + } + $forbiddenEnvironment = @( + '__PYVENV_LAUNCHER__', '_PYTHON_HOST_PLATFORM', + 'CONDA_DEFAULT_ENV', 'CONDA_PREFIX', 'VIRTUAL_ENV', + 'PYTHONBREAKPOINT', 'PYTHONCASEOK', 'PYTHONCOERCECLOCALE', 'PYTHONDEBUG', + 'PYTHONEXECUTABLE', 'PYTHONFAULTHANDLER', 'PYTHONHOME', 'PYTHONINSPECT', + 'PYTHONINTMAXSTRDIGITS', 'PYTHONMALLOC', 'PYTHONNODEBUGRANGES', 'PYTHONPATH', + 'PYTHONOPTIMIZE', 'PYTHONPERFSUPPORT', 'PYTHONPLATLIBDIR', 'PYTHONPROFILEIMPORTTIME', + 'PYTHONPYCACHEPREFIX', 'PYTHONSTARTUP', 'PYTHONTRACEMALLOC', 'PYTHONUSERBASE', + 'PYTHONWARNDEFAULTENCODING', 'PYTHONWARNINGS' + ) + foreach ($name in $forbiddenEnvironment) { + if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable( + $name, [EnvironmentVariableTarget]::Process))) { + throw "Release build Python environment refuses ambient import/runtime override: $name" + } + } + $project = Get-Item -LiteralPath $ProjectRoot -Force -ErrorAction Stop + if (-not $project.PSIsContainer -or ($project.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Release build project root is not one real directory.' + } + foreach ($name in @('.pth', '.egg-link', 'sitecustomize.py', 'usercustomize.py')) { + if (Test-Path -LiteralPath (Join-Path $project.FullName $name)) { + throw "Release build project root contains a Python import escape file: $name" + } + } +} + +function New-StackchanToolchainLeaseState { + $pathComparer = if ($env:OS -eq 'Windows_NT') { + [StringComparer]::OrdinalIgnoreCase + } else { + [StringComparer]::Ordinal + } + return [pscustomobject][ordered]@{ + schema = 'stackchan.toolchain-lifetime-lease.v1' + id = [guid]::NewGuid().ToString('N') + streams = [Collections.Generic.Dictionary[string, object]]::new($pathComparer) + watchers = [Collections.Generic.Dictionary[string, object]]::new($pathComparer) + violation = $null + violationEvidence = [Collections.Generic.List[object]]::new() + preBuildVerified = $false + preBuildComponents = @() + preBuildAuthorityKey = $null + preBuildScope = $null + closed = $false + } +} + +function Copy-StackchanToolchainIdentityComponents { + param([Parameter(Mandatory = $true)][object[]]$Components) + + return @($Components | ForEach-Object { + [pscustomobject][ordered]@{ + name = [string]$_.name + phase = [string]$_.phase + identitySchema = [string]$_.identitySchema + treeSha256 = [string]$_.treeSha256 + fileCount = [int]$_.fileCount + bytes = [long]$_.bytes + } + }) +} + +function Get-StackchanToolchainPreBuildAuthorityKey { + param( + [Parameter(Mandatory = $true)][string]$AllowlistPath, + [Parameter(Mandatory = $true)][hashtable]$RootMap, + [Parameter(Mandatory = $true)][string]$PlatformKey, + [Parameter(Mandatory = $true)][string]$PlatformioExecutable, + [Parameter(Mandatory = $true)][string]$PythonExecutable, + [Parameter(Mandatory = $true)][string]$GitExecutable + ) + + $parts = [Collections.Generic.List[string]]::new() + $parts.Add($PlatformKey) | Out-Null + $parts.Add([IO.Path]::GetFullPath((Get-Item -LiteralPath $AllowlistPath -Force).FullName)) | Out-Null + $parts.Add((Get-StackchanFileSha256 -LiteralPath $AllowlistPath)) | Out-Null + foreach ($rootKey in @('pythonHome', 'gitHome', 'legacyCore', 'releaseCore')) { + if (-not $RootMap.ContainsKey($rootKey) -or + [string]::IsNullOrWhiteSpace([string]$RootMap[$rootKey])) { + throw "Release toolchain cache authority is missing root: $rootKey" + } + $parts.Add([IO.Path]::GetFullPath((Get-Item -LiteralPath ( + [string]$RootMap[$rootKey]) -Force -ErrorAction Stop).FullName).TrimEnd('\', '/')) | Out-Null + } + foreach ($executable in @($PlatformioExecutable, $PythonExecutable, $GitExecutable)) { + $parts.Add([IO.Path]::GetFullPath((Get-Item -LiteralPath $executable -Force -ErrorAction Stop).FullName)) | Out-Null + } + $comparisonText = if ($env:OS -eq 'Windows_NT') { + (@($parts) | ForEach-Object { $_.ToUpperInvariant() }) -join "`0" + } else { + @($parts) -join "`0" + } + $hasher = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($hasher.ComputeHash( + [Text.Encoding]::UTF8.GetBytes("stackchan.prebuild-authority.v1`n$comparisonText`n"))) -replace '-', '').ToUpperInvariant() + } finally { + $hasher.Dispose() + } +} + +function Assert-StackchanToolchainLeaseState { + param([Parameter(Mandatory = $true)]$LeaseState) + + if ([string]$LeaseState.schema -cne 'stackchan.toolchain-lifetime-lease.v1' -or + [bool]$LeaseState.closed) { + throw 'Release toolchain lifetime lease state is invalid or already closed.' + } +} + +function Add-StackchanToolchainFileLease { + param( + [Parameter(Mandatory = $true)]$LeaseState, + [Parameter(Mandatory = $true)][string]$LiteralPath, + [Parameter(Mandatory = $true)][string]$Scope + ) + + Assert-StackchanToolchainLeaseState -LeaseState $LeaseState + if ([string]::IsNullOrWhiteSpace($Scope)) { + throw 'Release toolchain file leases require a non-empty scope.' + } + $item = Get-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Release toolchain lifetime lease refuses a non-file or redirected path: $LiteralPath" + } + $fullPath = [IO.Path]::GetFullPath($item.FullName) + if ($LeaseState.streams.ContainsKey($fullPath)) { return } + $stream = [IO.FileStream]::new( + $fullPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::SequentialScan) + try { + $LeaseState.streams.Add($fullPath, [pscustomobject][ordered]@{ + path = $fullPath + scope = $Scope + stream = $stream + }) + } catch { + $stream.Dispose() + throw + } +} + +function Add-StackchanToolchainTreeWatcher { + param( + [Parameter(Mandatory = $true)]$LeaseState, + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Scope + ) + + Assert-StackchanToolchainLeaseState -LeaseState $LeaseState + $rootItem = Get-Item -LiteralPath $Root -Force -ErrorAction Stop + if (-not $rootItem.PSIsContainer -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Release toolchain lifetime watcher requires one real directory: $Root" + } + $resolvedRoot = [IO.Path]::GetFullPath($rootItem.FullName).TrimEnd('\', '/') + if ($LeaseState.watchers.ContainsKey($resolvedRoot)) { return } + + $watcher = [IO.FileSystemWatcher]::new($resolvedRoot, '*') + $sourceIdentifiers = [Collections.Generic.List[string]]::new() + try { + $watcher.IncludeSubdirectories = $true + $watcher.InternalBufferSize = 65536 + $watcher.NotifyFilter = ( + [IO.NotifyFilters]::FileName -bor [IO.NotifyFilters]::DirectoryName -bor + [IO.NotifyFilters]::LastWrite -bor [IO.NotifyFilters]::Size -bor + [IO.NotifyFilters]::CreationTime -bor [IO.NotifyFilters]::Attributes -bor + [IO.NotifyFilters]::Security) + foreach ($eventName in @('Changed', 'Created', 'Deleted', 'Renamed', 'Error')) { + $sourceIdentifier = "stackchan-toolchain-$($LeaseState.id)-$([guid]::NewGuid().ToString('N'))-$eventName" + [void](Microsoft.PowerShell.Utility\Register-ObjectEvent ` + -InputObject $watcher -EventName $eventName -SourceIdentifier $sourceIdentifier) + $sourceIdentifiers.Add($sourceIdentifier) | Out-Null + } + $LeaseState.watchers.Add($resolvedRoot, [pscustomobject][ordered]@{ + root = $resolvedRoot + scope = $Scope + watcher = $watcher + sourceIdentifiers = @($sourceIdentifiers) + baselineNamespaceSha256 = $null + }) + $watcher.EnableRaisingEvents = $true + } catch { + foreach ($sourceIdentifier in $sourceIdentifiers) { + Microsoft.PowerShell.Utility\Unregister-Event ` + -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue + Microsoft.PowerShell.Utility\Remove-Event ` + -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue + } + $watcher.Dispose() + throw + } +} + +function Get-StackchanToolchainNamespaceSha256 { + param([Parameter(Mandatory = $true)][string]$Root) + + $rootItem = Get-Item -LiteralPath $Root -Force -ErrorAction Stop + if (-not $rootItem.PSIsContainer -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Release toolchain namespace root is not one real directory: $Root" + } + $rootPath = $rootItem.FullName.TrimEnd('\', '/') + $queue = [Collections.Generic.Queue[object]]::new() + $queue.Enqueue([pscustomobject]@{ item = $rootItem; relative = '' }) + $records = [Collections.Generic.List[string]]::new() + while ($queue.Count -gt 0) { + $current = $queue.Dequeue() + foreach ($item in @(Get-ChildItem -LiteralPath $current.item.FullName -Force -ErrorAction Stop)) { + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Release toolchain namespace refuses reparse points: $($item.FullName)" + } + $relative = if ([string]::IsNullOrEmpty([string]$current.relative)) { + [string]$item.Name + } else { + [string]$current.relative + '/' + [string]$item.Name + } + $relative = ConvertTo-StackchanSafeIdentityRelativePath $relative + if ($item.PSIsContainer) { + $records.Add("D`0$relative") | Out-Null + $queue.Enqueue([pscustomobject]@{ item = $item; relative = $relative }) + } elseif ($item -is [IO.FileInfo]) { + $records.Add("F`0$relative") | Out-Null + } else { + throw "Unsupported release toolchain namespace entry: $($item.FullName)" + } + } + } + $ordered = [string[]]@($records) + [Array]::Sort($ordered, [StringComparer]::Ordinal) + $namespaceText = "stackchan.toolchain-namespace.v1`n" + (($ordered | ForEach-Object { "$_`n" }) -join '') + $hasher = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($hasher.ComputeHash( + [Text.Encoding]::UTF8.GetBytes($namespaceText))) -replace '-', '').ToUpperInvariant() + } finally { + $hasher.Dispose() + } +} + +function Set-StackchanToolchainTreeWatcherBaseline { + param( + [Parameter(Mandatory = $true)]$LeaseState, + [Parameter(Mandatory = $true)][string]$Root + ) + + Assert-StackchanToolchainLeaseState -LeaseState $LeaseState + $resolvedRoot = [IO.Path]::GetFullPath((Get-Item -LiteralPath $Root -Force -ErrorAction Stop).FullName). + TrimEnd('\', '/') + if (-not $LeaseState.watchers.ContainsKey($resolvedRoot)) { + throw "Release toolchain namespace has no active watcher: $resolvedRoot" + } + $record = $LeaseState.watchers[$resolvedRoot] + if ([string]::IsNullOrWhiteSpace([string]$record.baselineNamespaceSha256)) { + $record.baselineNamespaceSha256 = Get-StackchanToolchainNamespaceSha256 -Root $resolvedRoot + } +} + +function Get-StackchanToolchainEventPathMetadata { + param([string]$LiteralPath) + + $metadata = [ordered]@{ + observedUtc = (Get-Date).ToUniversalTime().ToString('o') + path = $LiteralPath + exists = $false + isContainer = $null + length = $null + attributes = $null + creationTimeUtc = $null + lastWriteTimeUtc = $null + lastAccessTimeUtc = $null + accessSddl = $null + error = $null + } + if ([string]::IsNullOrWhiteSpace($LiteralPath)) { + return [pscustomobject]$metadata + } + try { + if (-not (Test-Path -LiteralPath $LiteralPath)) { + return [pscustomobject]$metadata + } + $item = Get-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop + $metadata.exists = $true + $metadata.isContainer = [bool]$item.PSIsContainer + if (-not $item.PSIsContainer -and $item -is [IO.FileInfo]) { + $metadata.length = [long]$item.Length + } + $metadata.attributes = [string]$item.Attributes + $metadata.creationTimeUtc = $item.CreationTimeUtc.ToString('o') + $metadata.lastWriteTimeUtc = $item.LastWriteTimeUtc.ToString('o') + $metadata.lastAccessTimeUtc = $item.LastAccessTimeUtc.ToString('o') + try { + $acl = Get-Acl -LiteralPath $item.FullName -ErrorAction Stop + $sections = [Security.AccessControl.AccessControlSections]::Access -bor + [Security.AccessControl.AccessControlSections]::Owner -bor + [Security.AccessControl.AccessControlSections]::Group + $metadata.accessSddl = $acl.GetSecurityDescriptorSddlForm($sections) + } catch { + $metadata.error = "ACL: $($_.Exception.Message)" + } + } catch { + $metadata.error = $_.Exception.Message + } + return [pscustomobject]$metadata +} + +function Add-StackchanToolchainQueuedEventEvidence { + param([Parameter(Mandatory = $true)]$LeaseState) + + # Snapshot every watcher subscription before recording anything so evidence + # is ordered across the entire guarded state, rather than once per root. + # Events delivered after this snapshot remain queued for the next drain. + $queued = [Collections.Generic.List[object]]::new() + foreach ($watchRecord in @($LeaseState.watchers.Values)) { + foreach ($sourceIdentifier in @($watchRecord.sourceIdentifiers)) { + foreach ($eventRecord in @(Microsoft.PowerShell.Utility\Get-Event ` + -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue)) { + $queued.Add([pscustomobject][ordered]@{ + sourceIdentifier = [string]$sourceIdentifier + watchRecord = $watchRecord + eventRecord = $eventRecord + }) | Out-Null + } + } + } + if ($queued.Count -eq 0) { return 0 } + + $orderedEvents = @($queued | Sort-Object ` + @{ Expression = { [datetime]$_.eventRecord.TimeGenerated } }, + @{ Expression = { [long]$_.eventRecord.EventIdentifier } }, + @{ Expression = { [string]$_.sourceIdentifier } }) + foreach ($queuedRecord in $orderedEvents) { + $eventRecord = $queuedRecord.eventRecord + $watchRecord = $queuedRecord.watchRecord + $eventArgs = $eventRecord.SourceEventArgs + $sourceIdentifier = [string]$queuedRecord.sourceIdentifier + $registeredEventName = if ($sourceIdentifier -match '-(Changed|Created|Deleted|Renamed|Error)$') { + [string]$Matches[1] + } else { + 'Unknown' + } + $changeType = if ($null -ne $eventArgs -and + $null -ne $eventArgs.PSObject.Properties['ChangeType']) { + [string]$eventArgs.ChangeType + } else { + $registeredEventName + } + $fullPath = if ($null -ne $eventArgs -and + $null -ne $eventArgs.PSObject.Properties['FullPath']) { + [string]$eventArgs.FullPath + } else { + [string]$watchRecord.root + } + $name = if ($null -ne $eventArgs -and + $null -ne $eventArgs.PSObject.Properties['Name']) { + [string]$eventArgs.Name + } else { $null } + $oldFullPath = if ($null -ne $eventArgs -and + $null -ne $eventArgs.PSObject.Properties['OldFullPath']) { + [string]$eventArgs.OldFullPath + } else { $null } + $oldName = if ($null -ne $eventArgs -and + $null -ne $eventArgs.PSObject.Properties['OldName']) { + [string]$eventArgs.OldName + } else { $null } + $eventException = $null + if ($registeredEventName -ceq 'Error' -and $null -ne $eventArgs) { + try { $eventException = $eventArgs.GetException() } catch {} + } + $timeGeneratedUtc = ([datetime]$eventRecord.TimeGenerated).ToUniversalTime().ToString('o') + $observedUtc = (Get-Date).ToUniversalTime().ToString('o') + $evidence = [pscustomobject][ordered]@{ + schema = 'stackchan.toolchain-watcher-event.v1' + ordinal = $LeaseState.violationEvidence.Count + eventIdentifier = [long]$eventRecord.EventIdentifier + sourceIdentifier = $sourceIdentifier + registeredEventName = $registeredEventName + changeType = $changeType + timeGeneratedUtc = $timeGeneratedUtc + observedUtc = $observedUtc + watcherRoot = [string]$watchRecord.root + watcherScope = [string]$watchRecord.scope + watcherNotifyFilter = [string]$watchRecord.watcher.NotifyFilter + fullPath = $fullPath + name = $name + oldFullPath = $oldFullPath + oldName = $oldName + errorType = if ($null -eq $eventException) { $null } else { $eventException.GetType().FullName } + errorMessage = if ($null -eq $eventException) { $null } else { $eventException.Message } + pathMetadata = Get-StackchanToolchainEventPathMetadata -LiteralPath $fullPath + oldPathMetadata = Get-StackchanToolchainEventPathMetadata -LiteralPath $oldFullPath + watcherRootMetadata = Get-StackchanToolchainEventPathMetadata -LiteralPath ([string]$watchRecord.root) + queueRemovalSucceeded = $false + queueRemovalError = $null + } + $LeaseState.violationEvidence.Add($evidence) | Out-Null + try { + Microsoft.PowerShell.Utility\Remove-Event ` + -EventIdentifier ([int]$eventRecord.EventIdentifier) -ErrorAction Stop + $evidence.queueRemovalSucceeded = $true + } catch { + $evidence.queueRemovalError = $_.Exception.Message + } + } + $cumulativeEvidence = @($LeaseState.violationEvidence | Sort-Object ` + @{ Expression = { [datetime]$_.timeGeneratedUtc } }, + @{ Expression = { [long]$_.eventIdentifier } }, + @{ Expression = { [string]$_.sourceIdentifier } }) + $LeaseState.violationEvidence.Clear() + for ($ordinal = 0; $ordinal -lt $cumulativeEvidence.Count; $ordinal++) { + $cumulativeEvidence[$ordinal].ordinal = $ordinal + $LeaseState.violationEvidence.Add($cumulativeEvidence[$ordinal]) | Out-Null + } + $firstEvidence = $LeaseState.violationEvidence[0] + $firstSummary = "$([string]$firstEvidence.registeredEventName)/$([string]$firstEvidence.changeType) " + + "at $([string]$firstEvidence.fullPath) generatedUtc=$([string]$firstEvidence.timeGeneratedUtc)" + if ([string]::IsNullOrWhiteSpace([string]$LeaseState.violation) -or + [string]$LeaseState.violation -like 'filesystem watcher events=*') { + $LeaseState.violation = "filesystem watcher events=$($LeaseState.violationEvidence.Count) first=$firstSummary" + } + return $orderedEvents.Count +} + +function Complete-StackchanToolchainQueuedEventDrain { + param( + [Parameter(Mandatory = $true)]$LeaseState, + [ValidateRange(1, 20)][int]$RequiredQuietPasses = 3, + [ValidateRange(1, 100)][int]$MaximumPasses = 40, + [ValidateRange(1, 1000)][int]$DelayMilliseconds = 25 + ) + + $quietPasses = 0 + $drainedEvents = 0 + for ($pass = 1; $pass -le $MaximumPasses; $pass++) { + [Threading.Thread]::Sleep($DelayMilliseconds) + $drained = Add-StackchanToolchainQueuedEventEvidence -LeaseState $LeaseState + $drainedEvents += $drained + if ($drained -eq 0) { + $quietPasses++ + if ($quietPasses -ge $RequiredQuietPasses) { return $drainedEvents } + } else { + $quietPasses = 0 + } + } + + $quiescenceFailure = "watcher event queue did not quiesce after $MaximumPasses passes" + if ([string]::IsNullOrWhiteSpace([string]$LeaseState.violation)) { + $LeaseState.violation = $quiescenceFailure + } elseif ([string]$LeaseState.violation -notlike "*$quiescenceFailure*") { + $LeaseState.violation = "$($LeaseState.violation); $quiescenceFailure" + } + return $drainedEvents +} + +function Assert-StackchanToolchainLeaseStateUnchanged { + param( + [Parameter(Mandatory = $true)]$LeaseState, + [Parameter(Mandatory = $true)][string]$Context, + [switch]$VerifyNamespace + ) + + Assert-StackchanToolchainLeaseState -LeaseState $LeaseState + # FileSystemWatcher delivery is asynchronous. Existing inputs cannot be + # changed because their read leases deny write/delete sharing; this short + # drain interval makes transient new-path events observable before success. + [Threading.Thread]::Sleep(50) + foreach ($watchRecord in @($LeaseState.watchers.Values)) { + if (-not [bool]$watchRecord.watcher.EnableRaisingEvents) { + if ([string]::IsNullOrWhiteSpace([string]$LeaseState.violation)) { + $LeaseState.violation = "watcher disabled for $([string]$watchRecord.root)" + } + } + foreach ($sourceIdentifier in @($watchRecord.sourceIdentifiers)) { + $subscribers = @(Microsoft.PowerShell.Utility\Get-EventSubscriber ` + -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue) + if ($subscribers.Count -ne 1) { + if ([string]::IsNullOrWhiteSpace([string]$LeaseState.violation)) { + $LeaseState.violation = "watcher subscription missing for $([string]$watchRecord.root)" + } + } + } + } + [void](Add-StackchanToolchainQueuedEventEvidence -LeaseState $LeaseState) + foreach ($watchRecord in @($LeaseState.watchers.Values)) { + if ($VerifyNamespace -and + [string]::IsNullOrWhiteSpace([string]$LeaseState.violation)) { + if ([string]::IsNullOrWhiteSpace([string]$watchRecord.baselineNamespaceSha256)) { + $LeaseState.violation = "watcher namespace baseline missing for $([string]$watchRecord.root)" + throw "Release toolchain changed after authentication during $Context`: $($LeaseState.violation)" + } + $actualNamespace = Get-StackchanToolchainNamespaceSha256 -Root ([string]$watchRecord.root) + if ($actualNamespace -cne [string]$watchRecord.baselineNamespaceSha256) { + $LeaseState.violation = "namespace drift at $([string]$watchRecord.root)" + throw "Release toolchain changed after authentication during $Context`: $($LeaseState.violation)" + } + } + } + if (-not [string]::IsNullOrWhiteSpace([string]$LeaseState.violation)) { + throw "Release toolchain changed after authentication during $Context`: $($LeaseState.violation)" + } + if ($VerifyNamespace) { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $LeaseState -Context "$Context post-namespace event drain" + } +} + +function Close-StackchanToolchainLeaseScope { + param( + [Parameter(Mandatory = $true)]$LeaseState, + [Parameter(Mandatory = $true)][string]$Scope, + [switch]$RequireUnchanged, + [string]$Context = 'toolchain lease scope closure' + ) + + Assert-StackchanToolchainLeaseState -LeaseState $LeaseState + if ($RequireUnchanged) { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $LeaseState -Context $Context -VerifyNamespace + } + $closingWatchers = @($LeaseState.watchers.Values | Where-Object { + [string]$_.scope -ceq $Scope + }) + foreach ($record in $closingWatchers) { + $record.watcher.EnableRaisingEvents = $false + } + if ($closingWatchers.Count -gt 0) { + [void](Complete-StackchanToolchainQueuedEventDrain -LeaseState $LeaseState) + } + foreach ($record in $closingWatchers) { + foreach ($sourceIdentifier in @($record.sourceIdentifiers)) { + Microsoft.PowerShell.Utility\Unregister-Event ` + -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue + } + } + if ($closingWatchers.Count -gt 0) { + [void](Complete-StackchanToolchainQueuedEventDrain -LeaseState $LeaseState) + } + foreach ($root in @($LeaseState.watchers.Keys)) { + $record = $LeaseState.watchers[$root] + if ([string]$record.scope -cne $Scope) { continue } + foreach ($sourceIdentifier in @($record.sourceIdentifiers)) { + Microsoft.PowerShell.Utility\Remove-Event ` + -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue + } + $record.watcher.Dispose() + [void]$LeaseState.watchers.Remove($root) + } + foreach ($path in @($LeaseState.streams.Keys)) { + $record = $LeaseState.streams[$path] + if ([string]$record.scope -cne $Scope) { continue } + $record.stream.Dispose() + [void]$LeaseState.streams.Remove($path) + } + if ([string]$LeaseState.preBuildScope -ceq $Scope) { + $LeaseState.preBuildVerified = $false + $LeaseState.preBuildComponents = @() + $LeaseState.preBuildAuthorityKey = $null + $LeaseState.preBuildScope = $null + } + if ($RequireUnchanged -and + -not [string]::IsNullOrWhiteSpace([string]$LeaseState.violation)) { + throw "Release toolchain changed during guarded scope closure: $($LeaseState.violation)" + } +} + +function Close-StackchanToolchainLeaseState { + param( + [Parameter(Mandatory = $true)]$LeaseState, + [switch]$RequireUnchanged, + [string]$Context = 'toolchain lease state closure' + ) + + if ([bool]$LeaseState.closed) { return } + if ($RequireUnchanged) { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $LeaseState -Context $Context -VerifyNamespace + } + foreach ($record in @($LeaseState.watchers.Values)) { + $record.watcher.EnableRaisingEvents = $false + } + if ($LeaseState.watchers.Count -gt 0) { + [void](Complete-StackchanToolchainQueuedEventDrain -LeaseState $LeaseState) + } + foreach ($record in @($LeaseState.watchers.Values)) { + foreach ($sourceIdentifier in @($record.sourceIdentifiers)) { + Microsoft.PowerShell.Utility\Unregister-Event ` + -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue + } + } + if ($LeaseState.watchers.Count -gt 0) { + [void](Complete-StackchanToolchainQueuedEventDrain -LeaseState $LeaseState) + } + foreach ($record in @($LeaseState.watchers.Values)) { + foreach ($sourceIdentifier in @($record.sourceIdentifiers)) { + Microsoft.PowerShell.Utility\Remove-Event ` + -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue + } + $record.watcher.Dispose() + } + foreach ($record in @($LeaseState.streams.Values)) { + $record.stream.Dispose() + } + $LeaseState.watchers.Clear() + $LeaseState.streams.Clear() + $LeaseState.preBuildVerified = $false + $LeaseState.preBuildComponents = @() + $LeaseState.preBuildAuthorityKey = $null + $LeaseState.preBuildScope = $null + $LeaseState.closed = $true + if ($RequireUnchanged -and + -not [string]::IsNullOrWhiteSpace([string]$LeaseState.violation)) { + throw "Release toolchain changed during guarded state closure: $($LeaseState.violation)" + } +} + +function Protect-StackchanToolchainTree { + param( + [Parameter(Mandatory = $true)]$LeaseState, + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Scope + ) + + Add-StackchanToolchainTreeWatcher -LeaseState $LeaseState -Root $Root -Scope $Scope + $queue = [Collections.Generic.Queue[object]]::new() + $rootItem = Get-Item -LiteralPath $Root -Force -ErrorAction Stop + $queue.Enqueue($rootItem) + while ($queue.Count -gt 0) { + $current = $queue.Dequeue() + foreach ($item in @(Get-ChildItem -LiteralPath $current.FullName -Force -ErrorAction Stop)) { + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Release toolchain lifetime lease refuses reparse points: $($item.FullName)" + } + if ($item.PSIsContainer) { + $queue.Enqueue($item) + } elseif ($item -is [IO.FileInfo]) { + Add-StackchanToolchainFileLease ` + -LeaseState $LeaseState -LiteralPath $item.FullName -Scope $Scope + } else { + throw "Unsupported release toolchain lease entry: $($item.FullName)" + } + } + } + Set-StackchanToolchainTreeWatcherBaseline ` + -LeaseState $LeaseState -Root $rootItem.FullName +} + function Get-StackchanFileSha256 { param([Parameter(Mandatory = $true)][string]$LiteralPath) @@ -243,7 +924,9 @@ function Get-StackchanIdentityFromRecords { function Get-StackchanToolchainTreeIdentity { param( [Parameter(Mandatory = $true)][string]$Root, - [switch]$IncludeRecords + [switch]$IncludeRecords, + $LeaseState, + [string]$LeaseScope ) if (-not (Test-Path -LiteralPath $Root -PathType Container)) { @@ -254,6 +937,10 @@ function Get-StackchanToolchainTreeIdentity { throw "Toolchain identity refuses a reparse-point root: $Root" } $resolvedRoot = $resolvedRootItem.FullName.TrimEnd('\', '/') + if ($null -ne $LeaseState) { + Add-StackchanToolchainTreeWatcher ` + -LeaseState $LeaseState -Root $resolvedRoot -Scope $LeaseScope + } $queue = [System.Collections.Generic.Queue[object]]::new() $queue.Enqueue([pscustomobject]@{ Item = $resolvedRootItem; Relative = '' }) $records = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) @@ -292,6 +979,10 @@ function Get-StackchanToolchainTreeIdentity { if ($item.PSIsContainer) { $queue.Enqueue([pscustomobject]@{ Item = $item; Relative = $relative }) } elseif ($item -is [System.IO.FileInfo]) { + if ($null -ne $LeaseState) { + Add-StackchanToolchainFileLease ` + -LeaseState $LeaseState -LiteralPath $item.FullName -Scope $LeaseScope + } $lengthBefore = [long]$item.Length $sha256 = Get-StackchanFileSha256 -LiteralPath $item.FullName $lengthAfter = [long](Get-Item -LiteralPath $item.FullName -Force -ErrorAction Stop).Length @@ -312,6 +1003,11 @@ function Get-StackchanToolchainTreeIdentity { } } + if ($null -ne $LeaseState) { + Set-StackchanToolchainTreeWatcherBaseline ` + -LeaseState $LeaseState -Root $resolvedRoot + } + return Get-StackchanIdentityFromRecords -Records @($records.Values) ` -Schema $script:StackchanToolchainInventorySchema -IncludeRecords:$IncludeRecords } @@ -431,12 +1127,23 @@ function Get-StackchanCanonicalReflogText { } function Get-StackchanCanonicalGitPackText { - param([Parameter(Mandatory = $true)][string]$PackRoot) + param( + [Parameter(Mandatory = $true)][string]$PackRoot, + [Parameter(Mandatory = $true)][string]$PythonExecutable, + [Parameter(Mandatory = $true)][string]$ExpectedObjectId + ) $items = @(Get-ChildItem -LiteralPath $PackRoot -File -Force -ErrorAction Stop) if ($items.Count -eq 0) { throw "Git object pack directory is empty: $PackRoot" } $groups = @($items | Group-Object { [IO.Path]::GetFileNameWithoutExtension($_.Name) }) $objectIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $pythonItem = Get-Item -LiteralPath $PythonExecutable -Force -ErrorAction Stop + $verifierItem = Get-Item -LiteralPath $script:StackchanGitPackSemanticVerifierPath -Force -ErrorAction Stop + if (($pythonItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + ($verifierItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + $pythonItem.PSIsContainer -or $verifierItem.PSIsContainer) { + throw 'Git pack semantic verification refuses reparse-point or non-file executables/sources.' + } foreach ($group in $groups) { if ($group.Name -notmatch '^pack-[0-9a-f]{40}$') { throw "Unsafe Git pack name: $($group.Name)" } $extensions = @($group.Group | ForEach-Object { $_.Extension.ToLowerInvariant() } | Sort-Object) @@ -446,56 +1153,38 @@ function Get-StackchanCanonicalGitPackText { $idxPath = ($group.Group | Where-Object Extension -eq '.idx').FullName $packPath = ($group.Group | Where-Object Extension -eq '.pack').FullName $revPath = ($group.Group | Where-Object Extension -eq '.rev').FullName - [byte[]]$idx = [IO.File]::ReadAllBytes($idxPath) - if ($idx.Length -lt 1104 -or (ConvertTo-StackchanLowerHex ([byte[]]$idx[0..3])) -cne 'ff744f63' -or - (Get-StackchanUInt32BigEndian $idx 4) -ne 2) { - throw "Unsupported Git pack index: $idxPath" - } - $idxPayloadLength = $idx.Length - 20 - if ((Get-StackchanSha1Hex ([byte[]]$idx[0..($idxPayloadLength - 1)])) -cne - (ConvertTo-StackchanLowerHex ([byte[]]$idx[$idxPayloadLength..($idx.Length - 1)]))) { - throw "Git pack index checksum mismatch: $idxPath" - } - $count = [int](Get-StackchanUInt32BigEndian $idx (8 + 255 * 4)) - $objectOffset = 8 + 256 * 4 - if ($objectOffset + $count * 20 + 40 -gt $idx.Length) { throw "Truncated Git pack index: $idxPath" } - for ($i = 0; $i -lt $count; $i++) { - $start = $objectOffset + $i * 20 - $id = ConvertTo-StackchanLowerHex ([byte[]]$idx[$start..($start + 19)]) - if (-not $objectIds.Add($id)) { throw "Duplicate Git object identity across packs: $id" } + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = @(& $pythonItem.FullName $verifierItem.FullName ` + '--pack' $packPath '--index' $idxPath '--reverse-index' $revPath 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousPreference } - $idxPackChecksum = ConvertTo-StackchanLowerHex ([byte[]]$idx[($idx.Length - 40)..($idx.Length - 21)]) - [byte[]]$pack = [IO.File]::ReadAllBytes($packPath) - if ($pack.Length -lt 32 -or [Text.Encoding]::ASCII.GetString($pack, 0, 4) -cne 'PACK') { - throw "Invalid Git pack: $packPath" + if ($exitCode -ne 0) { + throw "Git pack object-to-offset mapping verification failed: $($output -join "`n")" } - $packChecksum = ConvertTo-StackchanLowerHex ([byte[]]$pack[($pack.Length - 20)..($pack.Length - 1)]) - if ((Get-StackchanSha1Hex ([byte[]]$pack[0..($pack.Length - 21)])) -cne $packChecksum -or - $idxPackChecksum -cne $packChecksum -or $group.Name -cne "pack-$packChecksum") { - throw "Git pack content identity mismatch: $packPath" + try { + $result = ($output -join "`n") | ConvertFrom-Json + } catch { + throw "Git pack semantic verifier returned invalid JSON: $($output -join "`n")" } - [byte[]]$rev = [IO.File]::ReadAllBytes($revPath) - $expectedRevLength = 12 + 4 * $count + 40 - if ($rev.Length -ne $expectedRevLength -or - [Text.Encoding]::ASCII.GetString($rev, 0, 4) -cne 'RIDX' -or - (Get-StackchanUInt32BigEndian $rev 4) -ne 1 -or - (Get-StackchanUInt32BigEndian $rev 8) -ne 1) { - throw "Invalid Git reverse index: $revPath" + if ([string]$result.schema -cne 'stackchan.git-pack-semantics.v1' -or + -not [bool]$result.objectOffsetMappingVerified -or + -not [bool]$result.objectCrcMappingVerified -or + -not [bool]$result.reverseIndexMappingVerified -or + [int]$result.objectCount -ne @($result.objectIds).Count) { + throw "Git pack semantic verifier omitted required mapping proof: $packPath" } - $seenPositions = [Collections.Generic.HashSet[uint32]]::new() - for ($i = 0; $i -lt $count; $i++) { - $position = Get-StackchanUInt32BigEndian $rev (12 + 4 * $i) - if ($position -ge $count -or -not $seenPositions.Add($position)) { - throw "Invalid Git reverse-index permutation: $revPath" + foreach ($id in @($result.objectIds)) { + if ([string]$id -notmatch '^[0-9a-f]{40}$' -or -not $objectIds.Add([string]$id)) { + throw "Duplicate or malformed Git object identity across packs: $id" } } - $revPackOffset = 12 + 4 * $count - $revPackChecksum = ConvertTo-StackchanLowerHex ([byte[]]$rev[$revPackOffset..($revPackOffset + 19)]) - $revChecksum = ConvertTo-StackchanLowerHex ([byte[]]$rev[($rev.Length - 20)..($rev.Length - 1)]) - if ($revPackChecksum -cne $packChecksum -or - (Get-StackchanSha1Hex ([byte[]]$rev[0..($rev.Length - 21)])) -cne $revChecksum) { - throw "Git reverse-index checksum mismatch: $revPath" - } + } + if (-not $objectIds.Contains($ExpectedObjectId)) { + throw "Git pack object set does not contain the expected checked-out commit: $ExpectedObjectId" } $orderedIds = [string[]]@($objectIds) [Array]::Sort($orderedIds, [StringComparer]::Ordinal) @@ -508,7 +1197,8 @@ function Get-StackchanCanonicalGitLibraryRecords { [Parameter(Mandatory = $true)][string]$LibraryLeaf, [Parameter(Mandatory = $true)][string]$ExpectedPackageName, [Parameter(Mandatory = $true)][string]$ExpectedSourceUri, - [Parameter(Mandatory = $true)][string]$ExpectedCommit + [Parameter(Mandatory = $true)][string]$ExpectedCommit, + [string]$PythonExecutable = (Get-StackchanGitPackVerifierPython) ) if ($ExpectedPackageName -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or @@ -613,7 +1303,10 @@ function Get-StackchanCanonicalGitLibraryRecords { if (-not $packHandled) { $canonicalRecords.Add((New-StackchanCanonicalIdentityRecord ` -RelativePath "$LibraryLeaf/.git/objects/pack/@object-set" ` - -CanonicalText (Get-StackchanCanonicalGitPackText (Join-Path $gitRoot 'objects/pack')))) | Out-Null + -CanonicalText (Get-StackchanCanonicalGitPackText ` + -PackRoot (Join-Path $gitRoot 'objects/pack') ` + -PythonExecutable $PythonExecutable ` + -ExpectedObjectId $ExpectedCommit))) | Out-Null $packHandled = $true } } else { @@ -640,6 +1333,7 @@ function Get-StackchanCanonicalGitLibraryTreeIdentity { [Parameter(Mandatory = $true)][string]$ExpectedPackageName, [Parameter(Mandatory = $true)][string]$ExpectedSourceUri, [Parameter(Mandatory = $true)][string]$ExpectedCommit, + [string]$PythonExecutable = (Get-StackchanGitPackVerifierPython), [switch]$IncludeRecords ) @@ -651,7 +1345,8 @@ function Get-StackchanCanonicalGitLibraryTreeIdentity { -LibraryRoot $LibraryRoot -LibraryLeaf $LibraryLeaf ` -ExpectedPackageName $ExpectedPackageName ` -ExpectedSourceUri $ExpectedSourceUri ` - -ExpectedCommit $ExpectedCommit + -ExpectedCommit $ExpectedCommit ` + -PythonExecutable $PythonExecutable $canonicalRecords = [Collections.Generic.List[object]]::new() foreach ($record in $gitIdentity.records) { $canonicalRecords.Add($record) | Out-Null } foreach ($record in $rawTree.records) { @@ -702,7 +1397,7 @@ function Get-StackchanExpectedLibdepsPolicy { if ($Environment -in @('stackchan', 'stackchan_servo_calibration')) { return [pscustomobject][ordered]@{ leaves = @( - 'ArduinoJson', 'Dynamixel2Arduino', 'ESP32Servo', 'M5GFX', 'M5Unified', + 'ArduinoJson', 'Dynamixel2Arduino', 'ESP32Servo', 'M5GFX', 'M5GFX@0.2.24', 'M5Unified', 'M5Unified@0.2.17', 'SCServo', 'SCServo@src-8a1b26565e1a43aa7e250db85a311724', 'ServoEasing', 'stackchan-arduino', 'YAMLDuino' @@ -762,6 +1457,7 @@ function Get-StackchanCanonicalLibdepsIdentity { param( [Parameter(Mandatory = $true)][string]$Root, [Parameter(Mandatory = $true)][string]$Environment, + [string]$PythonExecutable = (Get-StackchanGitPackVerifierPython), [switch]$IncludeRecords ) @@ -811,7 +1507,8 @@ function Get-StackchanCanonicalLibdepsIdentity { -LibraryRoot $libraryRoot -LibraryLeaf $leaf ` -ExpectedPackageName ([string]$sourcePolicy.packageName) ` -ExpectedSourceUri ([string]$sourcePolicy.uri) ` - -ExpectedCommit ([string]$sourcePolicy.commit) -IncludeRecords + -ExpectedCommit ([string]$sourcePolicy.commit) ` + -PythonExecutable $PythonExecutable -IncludeRecords foreach ($record in $libraryTree.records) { $canonicalRecords.Add($record) | Out-Null } } else { if ($policy.gitSources.ContainsKey($leaf)) { @@ -870,6 +1567,11 @@ function Get-StackchanReleaseToolchainComponentPolicy { # Hash the entire Python installation as one closed root, including # python312.zip, DLLs, Lib, site-packages, and every Scripts launcher. Add-PolicyComponent 'python-installation' 'preBuild' 'pythonHome' '@root' + Add-PolicyComponent 'git-installation' 'preBuild' 'gitHome' '@root' + Add-PolicyComponent 'release-toolchain-identity-policy-source' 'preBuild' 'projectRoot' ` + 'tools/release_toolchain_identity.ps1' + Add-PolicyComponent 'git-pack-semantic-verifier-source' 'preBuild' 'projectRoot' ` + 'tools/verify_git_pack_semantics.py' Add-PolicyComponent 'legacy-core-penv' 'preBuild' 'legacyCore' 'penv' Add-PolicyComponent 'legacy-platform-espressif32-7.0.1' 'preBuild' 'legacyCore' 'platforms/espressif32@7.0.1' @@ -890,7 +1592,7 @@ function Get-StackchanReleaseToolchainComponentPolicy { } foreach ($environment in @('stackchan', 'stackchan_servo_calibration', 'stackchan_release_full')) { - Add-PolicyComponent "project-libdeps-$environment" 'postBuild' 'projectRoot' ".pio/libdeps/$environment" + Add-PolicyComponent "project-libdeps-$environment" 'postBuild' 'libdepsRoot' $environment } return @($components) } @@ -930,24 +1632,45 @@ function Get-StackchanReleaseToolchainObservedComponents { param( [Parameter(Mandatory = $true)][hashtable]$RootMap, [Parameter(Mandatory = $true)][ValidateSet('PreBuild', 'PostBuild')][string]$Phase, - [string]$PlatformKey = (Get-StackchanReleaseToolchainPlatformKey) + [ValidateSet('stackchan', 'stackchan_servo_calibration', 'stackchan_release_full')] + [string]$Environment, + [string]$PythonExecutable = (Get-StackchanGitPackVerifierPython), + [string]$PlatformKey = (Get-StackchanReleaseToolchainPlatformKey), + $LeaseState, + [string]$LeaseScope, + [switch]$PostBuildComponentsOnly ) - if ($Phase -ceq 'PostBuild') { - throw 'PostBuild toolchain eligibility is disabled: canonical libdeps analysis does not yet prove Git pack object-to-offset mappings with an independently trusted Git/runtime, and fresh evidence does not yet cover all three environments.' - } - $policy = @(Get-StackchanReleaseToolchainComponentPolicy -PlatformKey $PlatformKey) + if ($PostBuildComponentsOnly -and + ($Phase -cne 'PostBuild' -or $null -eq $LeaseState)) { + throw 'PostBuild-only observation is valid only for a guarded PostBuild identity.' + } + if ($Phase -ceq 'PreBuild' -and -not [string]::IsNullOrWhiteSpace($Environment)) { + throw 'A dependency environment filter is valid only for PostBuild identity.' + } $selected = @($policy | Where-Object { - [string]$_.phase -ceq 'preBuild' -or $Phase -ceq 'PostBuild' + (-not $PostBuildComponentsOnly -and [string]$_.phase -ceq 'preBuild') -or + ($Phase -ceq 'PostBuild' -and [string]$_.phase -ceq 'postBuild' -and ( + [string]::IsNullOrWhiteSpace($Environment) -or + [string]$_.name -ceq "project-libdeps-$Environment")) }) $observed = [System.Collections.Generic.List[object]]::new() foreach ($component in $selected) { $path = Resolve-StackchanIdentityComponentPath -RootMap $RootMap -Component $component if ([string]$component.phase -ceq 'postBuild') { + if ($null -ne $LeaseState) { + Protect-StackchanToolchainTree ` + -LeaseState $LeaseState -Root $path -Scope $LeaseScope + } $environment = ([string]$component.name).Substring('project-libdeps-'.Length) - $identity = Get-StackchanCanonicalLibdepsIdentity -Root $path -Environment $environment + $identity = Get-StackchanCanonicalLibdepsIdentity ` + -Root $path -Environment $environment -PythonExecutable $PythonExecutable } elseif (Test-Path -LiteralPath $path -PathType Leaf) { + if ($null -ne $LeaseState) { + Add-StackchanToolchainFileLease ` + -LeaseState $LeaseState -LiteralPath $path -Scope $LeaseScope + } $leaf = Split-Path -Leaf $path $sha256 = Get-StackchanFileSha256 -LiteralPath $path $length = [long](Get-Item -LiteralPath $path -Force).Length @@ -966,7 +1689,8 @@ function Get-StackchanReleaseToolchainObservedComponents { bytes = $length } } elseif (Test-Path -LiteralPath $path -PathType Container) { - $identity = Get-StackchanToolchainTreeIdentity -Root $path + $identity = Get-StackchanToolchainTreeIdentity ` + -Root $path -LeaseState $LeaseState -LeaseScope $LeaseScope } else { throw "Required release toolchain component is missing: $($component.name) ($path)" } @@ -987,6 +1711,7 @@ function New-StackchanReleaseToolchainIdentityCandidate { [Parameter(Mandatory = $true)][hashtable]$RootMap, [Parameter(Mandatory = $true)][string]$PlatformioExecutable, [Parameter(Mandatory = $true)][string]$PythonExecutable, + [string]$GitExecutable, [string]$PlatformKey = (Get-StackchanReleaseToolchainPlatformKey) ) @@ -996,6 +1721,10 @@ function New-StackchanReleaseToolchainIdentityCandidate { } $resolvedPio = (Get-Item -LiteralPath $PlatformioExecutable -Force -ErrorAction Stop).FullName $resolvedPython = (Get-Item -LiteralPath $PythonExecutable -Force -ErrorAction Stop).FullName + $gitHome = (Get-Item -LiteralPath ([string]$RootMap.gitHome) -Force -ErrorAction Stop).FullName.TrimEnd('\', '/') + $expectedGit = [IO.Path]::GetFullPath((Join-Path $gitHome 'cmd/git.exe')) + if ([string]::IsNullOrWhiteSpace($GitExecutable)) { $GitExecutable = $expectedGit } + $resolvedGit = (Get-Item -LiteralPath $GitExecutable -Force -ErrorAction Stop).FullName $comparison = if ($env:OS -eq 'Windows_NT') { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } if (@($expectedPio | Where-Object { $_.Equals($resolvedPio, $comparison) }).Count -ne 1) { throw 'PlatformIO executable is outside the reviewed Python installation.' @@ -1004,10 +1733,14 @@ function New-StackchanReleaseToolchainIdentityCandidate { if (-not $expectedPython.Equals($resolvedPython, $comparison)) { throw 'Python executable is outside the reviewed Python installation.' } + if (-not $expectedGit.Equals($resolvedGit, $comparison)) { + throw 'Git executable is outside the reviewed Git installation.' + } Assert-StackchanPythonImportIsolation ` -PythonHome $pythonHome -PythonExecutable $resolvedPython $components = @(Get-StackchanReleaseToolchainObservedComponents ` - -RootMap $RootMap -Phase PostBuild -PlatformKey $PlatformKey) + -RootMap $RootMap -Phase PostBuild -PlatformKey $PlatformKey ` + -PythonExecutable $resolvedPython) return [pscustomobject][ordered]@{ schema = $script:StackchanToolchainIdentitySchema platformKey = $PlatformKey @@ -1018,6 +1751,7 @@ function New-StackchanReleaseToolchainIdentityCandidate { canonicalLibdepsSchema = $script:StackchanCanonicalLibdepsSchema platformioExecutableRelativePaths = @('Scripts/pio.exe', 'Scripts/platformio.exe') pythonExecutableRelativePath = 'python.exe' + gitExecutableRelativePath = 'cmd/git.exe' generatedUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') review = [ordered]@{ status = 'candidate-unreviewed' @@ -1033,10 +1767,32 @@ function Assert-StackchanReleaseToolchainIdentity { [Parameter(Mandatory = $true)][hashtable]$RootMap, [Parameter(Mandatory = $true)][string]$PlatformioExecutable, [Parameter(Mandatory = $true)][string]$PythonExecutable, + [string]$GitExecutable, [Parameter(Mandatory = $true)][ValidateSet('PreBuild', 'PostBuild')][string]$Phase, - [string]$PlatformKey = (Get-StackchanReleaseToolchainPlatformKey) + [ValidateSet('stackchan', 'stackchan_servo_calibration', 'stackchan_release_full')] + [string]$Environment, + [string]$PlatformKey = (Get-StackchanReleaseToolchainPlatformKey), + $LeaseState, + [string]$LeaseScope ) + if ($null -ne $LeaseState) { + Assert-StackchanToolchainLeaseState -LeaseState $LeaseState + if ([string]::IsNullOrWhiteSpace($LeaseScope)) { + throw 'Guarded release toolchain identity requires one explicit lease scope.' + } + if ($Phase -ceq 'PreBuild' -and [bool]$LeaseState.preBuildVerified) { + throw 'A guarded toolchain session may verify PreBuild only once.' + } + if ($Phase -ceq 'PostBuild' -and -not [bool]$LeaseState.preBuildVerified) { + throw 'Guarded PostBuild identity requires the same session to verify PreBuild first.' + } + if ($Phase -ceq 'PostBuild' -and + [string]$LeaseState.preBuildScope -ceq $LeaseScope) { + throw 'Guarded PostBuild identity requires a scope distinct from its PreBuild authority.' + } + } + $allowlist = Get-Content -LiteralPath $AllowlistPath -Raw -ErrorAction Stop | ConvertFrom-Json if ([string]$allowlist.schema -cne $script:StackchanToolchainIdentitySchema -or [string]$allowlist.platformKey -cne $PlatformKey -or @@ -1056,7 +1812,8 @@ function Assert-StackchanReleaseToolchainIdentity { $allowlistedLaunchers = @($allowlist.platformioExecutableRelativePaths) if ($allowlistedLaunchers.Count -ne $canonicalLaunchers.Count -or ($allowlistedLaunchers -join "`n") -cne ($canonicalLaunchers -join "`n") -or - [string]$allowlist.pythonExecutableRelativePath -cne 'python.exe') { + [string]$allowlist.pythonExecutableRelativePath -cne 'python.exe' -or + [string]$allowlist.gitExecutableRelativePath -cne 'cmd/git.exe') { throw 'Release toolchain allowlist executable paths are not the canonical policy.' } @@ -1076,15 +1833,79 @@ function Assert-StackchanReleaseToolchainIdentity { if (-not $expectedPython.Equals($resolvedPython, $comparison)) { throw 'Selected Python executable is not the byte-allowlisted runtime.' } + $gitHome = (Get-Item -LiteralPath ([string]$RootMap.gitHome) -Force -ErrorAction Stop).FullName.TrimEnd('\', '/') + $gitRelative = ConvertTo-StackchanSafeIdentityRelativePath ([string]$allowlist.gitExecutableRelativePath) + $expectedGit = [IO.Path]::GetFullPath((Join-Path $gitHome ($gitRelative -replace '/', [IO.Path]::DirectorySeparatorChar))) + if ([string]::IsNullOrWhiteSpace($GitExecutable)) { $GitExecutable = $expectedGit } + $resolvedGit = (Get-Item -LiteralPath $GitExecutable -Force -ErrorAction Stop).FullName + if (-not $expectedGit.Equals($resolvedGit, $comparison)) { + throw 'Selected Git executable is not the byte-allowlisted application.' + } - Assert-StackchanPythonImportIsolation ` - -PythonHome $pythonHome -PythonExecutable $resolvedPython + $authorityKey = Get-StackchanToolchainPreBuildAuthorityKey ` + -AllowlistPath $AllowlistPath -RootMap $RootMap -PlatformKey $PlatformKey ` + -PlatformioExecutable $resolvedPio -PythonExecutable $resolvedPython ` + -GitExecutable $resolvedGit - $observed = @(Get-StackchanReleaseToolchainObservedComponents ` - -RootMap $RootMap -Phase $Phase -PlatformKey $PlatformKey) $expected = @($allowlist.components | Where-Object { - [string]$_.phase -ceq 'preBuild' -or $Phase -ceq 'PostBuild' + [string]$_.phase -ceq 'preBuild' -or + ($Phase -ceq 'PostBuild' -and ( + [string]::IsNullOrWhiteSpace($Environment) -or + [string]$_.name -ceq "project-libdeps-$Environment")) }) + + $observedArguments = @{ + RootMap = $RootMap + Phase = $Phase + PlatformKey = $PlatformKey + PythonExecutable = $resolvedPython + LeaseState = $LeaseState + LeaseScope = $LeaseScope + } + if (-not [string]::IsNullOrWhiteSpace($Environment)) { + $observedArguments.Environment = $Environment + } + if ($null -ne $LeaseState -and $Phase -ceq 'PostBuild') { + if ([string]::IsNullOrWhiteSpace([string]$LeaseState.preBuildAuthorityKey) -or + [string]$LeaseState.preBuildAuthorityKey -cne $authorityKey) { + throw 'Guarded PostBuild authority differs from the verified PreBuild toolchain roots, executables, or allowlist.' + } + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $LeaseState -Context 'PostBuild cached PreBuild reuse' -VerifyNamespace + if (@($LeaseState.preBuildComponents).Count -eq 0) { + throw 'Guarded PostBuild identity has no verified PreBuild component cache.' + } + $cachedPreBuild = @(Copy-StackchanToolchainIdentityComponents ` + -Components @($LeaseState.preBuildComponents)) + $expectedCachedPreBuild = @($expected | Where-Object { + [string]$_.phase -ceq 'preBuild' + }) + if ($expectedCachedPreBuild.Count -ne $cachedPreBuild.Count) { + throw 'Release toolchain allowlist component count mismatch for phase PreBuild cache.' + } + $expectedCachedNames = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal) + foreach ($entry in $expectedCachedPreBuild) { + $name = [string]$entry.name + if ([string]::IsNullOrWhiteSpace($name) -or -not $expectedCachedNames.Add($name)) { + throw "Release toolchain allowlist has an invalid or duplicate component: $name" + } + $matches = @($cachedPreBuild | Where-Object { [string]$_.name -ceq $name }) + if ($matches.Count -ne 1 -or + [string]$entry.phase -cne [string]$matches[0].phase -or + [string]$entry.identitySchema -cne [string]$matches[0].identitySchema -or + [string]$entry.treeSha256 -cne [string]$matches[0].treeSha256 -or + [int]$entry.fileCount -ne [int]$matches[0].fileCount -or + [long]$entry.bytes -ne [long]$matches[0].bytes) { + throw "Release toolchain byte identity mismatch: $name" + } + } + $observedArguments.PostBuildComponentsOnly = $true + $freshPostBuild = @(Get-StackchanReleaseToolchainObservedComponents @observedArguments) + $observed = @($cachedPreBuild) + @($freshPostBuild) + } else { + $observed = @(Get-StackchanReleaseToolchainObservedComponents @observedArguments) + } if ($expected.Count -ne $observed.Count) { throw "Release toolchain allowlist component count mismatch for phase $Phase." } @@ -1104,11 +1925,41 @@ function Assert-StackchanReleaseToolchainIdentity { throw "Release toolchain byte identity mismatch: $name" } } + if ($null -ne $LeaseState) { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $LeaseState -Context "$Phase allowlist comparison" + } + Assert-StackchanPythonImportIsolation ` + -PythonHome $pythonHome -PythonExecutable $resolvedPython + if ($null -ne $LeaseState) { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $LeaseState -Context "$Phase Python isolation probe" + if ($Phase -ceq 'PreBuild') { + $LeaseState.preBuildComponents = @(Copy-StackchanToolchainIdentityComponents ` + -Components $observed) + $LeaseState.preBuildAuthorityKey = $authorityKey + $LeaseState.preBuildScope = $LeaseScope + $LeaseState.preBuildVerified = $true + } + } + $observationText = (@($observed | Sort-Object name | ForEach-Object { + "$([string]$_.name)`0$([string]$_.phase)`0$([string]$_.identitySchema)`0$([string]$_.treeSha256)`0$([int]$_.fileCount)`0$([long]$_.bytes)" + }) -join "`n") + "`n" + $observationHasher = [Security.Cryptography.SHA256]::Create() + try { + $observationSha256 = ([BitConverter]::ToString($observationHasher.ComputeHash( + [Text.Encoding]::UTF8.GetBytes($observationText))) -replace '-', '').ToUpperInvariant() + } finally { + $observationHasher.Dispose() + } return [pscustomobject][ordered]@{ schema = $script:StackchanToolchainIdentitySchema status = 'verified' platformKey = $PlatformKey phase = $Phase + environment = if ([string]::IsNullOrWhiteSpace($Environment)) { $null } else { $Environment } componentCount = $observed.Count + allowlistSha256 = Get-StackchanFileSha256 -LiteralPath $AllowlistPath + observationSha256 = $observationSha256 } } diff --git a/tools/release_toolchain_identity_allowlist.json b/tools/release_toolchain_identity_allowlist.json new file mode 100644 index 00000000..c22d834b --- /dev/null +++ b/tools/release_toolchain_identity_allowlist.json @@ -0,0 +1,215 @@ +{ + "schema": "stackchan.release-toolchain-identity.v3", + "platformKey": "windows_amd64", + "platformioCoreVersion": "6.1.19", + "pythonVersion": "3.12.10", + "identityScope": "exact-host-installed-bytes", + "portableAcrossHosts": false, + "canonicalLibdepsSchema": "stackchan.canonical-libdeps.v1", + "platformioExecutableRelativePaths": [ + "Scripts/pio.exe", + "Scripts/platformio.exe" + ], + "pythonExecutableRelativePath": "python.exe", + "gitExecutableRelativePath": "cmd/git.exe", + "generatedUtc": "2026-08-03T23:41:23Z", + "review": { + "status": "reviewed", + "reviewer": "Luna independent pioarduino byte/provenance audit, Luna lifetime-cache/TOCTOU review, and Luna adversarial watcher-forensics review; Codex integration", + "reason": "Independently recomputed the 21 unchanged windows_amd64 exact-host components and reviewed three intentional deltas: the sealed pioarduino release-core penv, the converged pioarduino espressif32 platform bytes, and the policy source that adds process-lifetime leases, namespace watchers, guarded PreBuild caching, fail-fast cache revalidation, exact-once state-wide watcher drains, bounded post-disable/post-unregister quiescence, and cumulative chronological evidence. Adversarial review reproduced and closed duplicate-drain, cross-root ordering, late-after-snapshot cleanup, post-unregister delivery, and cross-batch chronology defects while retaining fail-closed anomaly behavior. Clean canonical dependencies for all three firmware environments remain explicitly bound. The original pioarduino source byte is retained only in ignored private recovery evidence and has no release authority. This review promotes only these exact allowlist bytes; it does not prove the retained runtime guard, establish release eligibility, authorize publication, or authorize physical qualification." + }, + "components": [ + { + "name": "python-installation", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "14B55DD417A755CCD8F2B479172A62C6FB3687F1DE28BA5EDE6BCEFED177632E", + "fileCount": 20270, + "bytes": 452204560 + }, + { + "name": "git-installation", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "19744036AC9232C7862F333A048ECD2BDAD2B398207EFFEEF09B949D7D45989B", + "fileCount": 9514, + "bytes": 425255243 + }, + { + "name": "release-toolchain-identity-policy-source", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "5895FA33E7FFE2E848C86B9FC5DA2A77933D45B423079796EF7C643D45B8CB22", + "fileCount": 1, + "bytes": 88173 + }, + { + "name": "git-pack-semantic-verifier-source", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "395CB65FCEA32F6AE9DE2D885694C17265469B974F00A58D87B11FE98601BF2D", + "fileCount": 1, + "bytes": 19626 + }, + { + "name": "legacy-core-penv", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "98F54162C98C39418B1D258A44EB0FA9B7E329C7C053A3A1868D8CCA1A268356", + "fileCount": 3145, + "bytes": 123351444 + }, + { + "name": "legacy-platform-espressif32-7.0.1", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "C13A988ED2BE7E3D428C50348FB9ED1550777C6474F3EB8814F25FABA3974109", + "fileCount": 463, + "bytes": 664040 + }, + { + "name": "legacy-package-framework-arduinoespressif32", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "A3FAE56BF9562BA54D2413267F226BE2A18A150B95AADAB6E047A936A36E8116", + "fileCount": 10059, + "bytes": 771573491 + }, + { + "name": "legacy-package-toolchain-riscv32-esp", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "AE0936B79C65BD2E8F1C74BC02BEA4947F14CBB79B8DB74D618472B7E0020AEC", + "fileCount": 1627, + "bytes": 952252150 + }, + { + "name": "legacy-package-toolchain-xtensa-esp32s3", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "647D4B2A87DB26C44CBD5855362028AEDAA0B37B4924DE458A15C58AD83C9CAA", + "fileCount": 1328, + "bytes": 274397176 + }, + { + "name": "legacy-package-tool-esptoolpy", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "45A9A5A96AB117BC0057AD5D643C51F79A6935E27CBE5B635FBFCFF54551EDE1", + "fileCount": 227, + "bytes": 2647183 + }, + { + "name": "legacy-package-tool-mkfatfs", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "F99847572137BCF7123529EEF83A6D4A8C66BE25C43EE8C5C9655CDE1F649918", + "fileCount": 6, + "bytes": 3519100 + }, + { + "name": "legacy-package-tool-mklittlefs", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "E6F2DE1E27AB57AF39AE66E60623DFCC1E2AC1A5381CDC75112386CB0A56FC10", + "fileCount": 3, + "bytes": 984066 + }, + { + "name": "legacy-package-tool-mkspiffs", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "44B48B20C63E9C093DC1455B1984D5C39296B76E7FED88E6692BD6523C19E318", + "fileCount": 5, + "bytes": 2019304 + }, + { + "name": "release-core-penv", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "A1E366961C1410F32DD31102F37725B500FF0F3FB8E9604A4BA34715FBC08C84", + "fileCount": 3154, + "bytes": 124599325 + }, + { + "name": "release-platform-espressif32", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "B524A0DA5B8CF1CB88D82E808584FD6200CB090E41DFB7F6B60405DC119EF401", + "fileCount": 616, + "bytes": 22522049 + }, + { + "name": "release-package-contrib-piohome", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "4B41344B3E7A320EE72965186F9436757AC69B43C547934DE3164AB1F3F82D10", + "fileCount": 13, + "bytes": 3358744 + }, + { + "name": "release-package-framework-arduinoespressif32", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "81C11DB32DC9A653FF14EE8811394F6DF0410FF85189F2056E8AE02B69268ECA", + "fileCount": 1855, + "bytes": 50764476 + }, + { + "name": "release-package-framework-arduinoespressif32-libs", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "4C86469E19BD779918E4AF25EAE2A9393A018729492686F3A1A428EBBF08F075", + "fileCount": 33418, + "bytes": 1879561242 + }, + { + "name": "release-package-toolchain-xtensa-esp-elf", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "1E2130613E26E8C5F1B4D87E040130C9ACE76856D8710C7A95DB4235F92863D2", + "fileCount": 2056, + "bytes": 1321027144 + }, + { + "name": "release-package-tool-esptoolpy", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "9A35EA4E986A5DE6FDD8878EA4C35CFD89BBE0FECDF6CAAEC7C40EF8C9BC445E", + "fileCount": 189, + "bytes": 2262196 + }, + { + "name": "release-package-tool-scons", + "phase": "preBuild", + "identitySchema": "stackchan.byte-tree.v1", + "treeSha256": "5B4E535525647CDBF78B9E22D8344D713C751DFE6F2F2929B888D1D45F4E5028", + "fileCount": 287, + "bytes": 3396185 + }, + { + "name": "project-libdeps-stackchan", + "phase": "postBuild", + "identitySchema": "stackchan.canonical-libdeps.v1", + "treeSha256": "79C18DC5078CAB8A35CCB4DAD385FDCB2BFB11126C778975F74C8F4B7096279B", + "fileCount": 1516, + "bytes": 322711060 + }, + { + "name": "project-libdeps-stackchan_servo_calibration", + "phase": "postBuild", + "identitySchema": "stackchan.canonical-libdeps.v1", + "treeSha256": "79C18DC5078CAB8A35CCB4DAD385FDCB2BFB11126C778975F74C8F4B7096279B", + "fileCount": 1516, + "bytes": 322711060 + }, + { + "name": "project-libdeps-stackchan_release_full", + "phase": "postBuild", + "identitySchema": "stackchan.canonical-libdeps.v1", + "treeSha256": "74B343038114CC2E90927E1C641B14D47806EA0759BF0FED711235B61C705273", + "fileCount": 1146, + "bytes": 318248745 + } + ] +} diff --git a/tools/run_device_preflight.ps1 b/tools/run_device_preflight.ps1 index 420c2451..d4225007 100644 --- a/tools/run_device_preflight.ps1 +++ b/tools/run_device_preflight.ps1 @@ -3,12 +3,34 @@ param( [string]$Version = "", [string]$ExpectedCommit = "", [string]$ReportDir = "", - [switch]$AllowDirty + [switch]$AllowDirty, + [string]$ToolchainAllowlistPath = "", + [string]$GitExecutable = "", + [string]$PythonExecutable = "", + [string]$PlatformioExecutable = "", + [string]$LegacyCoreDir = "", + [string]$ReleaseCoreDir = "" ) $ErrorActionPreference = "Stop" $physicalRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$preflightToolchainProcessArgs = @( + "-ToolchainAllowlistPath", $ToolchainAllowlistPath, + "-GitExecutable", $GitExecutable, + "-PythonExecutable", $PythonExecutable, + "-PlatformioExecutable", $PlatformioExecutable, + "-LegacyCoreDir", $LegacyCoreDir, + "-ReleaseCoreDir", $ReleaseCoreDir +) +$preflightToolchainSplat = @{ + ToolchainAllowlistPath = $ToolchainAllowlistPath + GitExecutable = $GitExecutable + PythonExecutable = $PythonExecutable + PlatformioExecutable = $PlatformioExecutable + LegacyCoreDir = $LegacyCoreDir + ReleaseCoreDir = $ReleaseCoreDir +} if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { if ([string]::IsNullOrWhiteSpace($Version)) { $zipName = [System.IO.Path]::GetFileName($PackageZip) @@ -35,6 +57,7 @@ if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { "-Version", $Version, "-ZipPath", $PackageZip, "-ExpectedCommit", $ExpectedCommit, "-RequireReleaseEligible" ) + $earlyVerifyArgs += $preflightToolchainProcessArgs if ($AllowDirty) { $earlyVerifyArgs += "-AllowDirtyPackage" } & powershell.exe @earlyVerifyArgs if ($LASTEXITCODE -ne 0) { @@ -71,6 +94,7 @@ if ( if ($ExpectedCommit) { $childArgs += @("-ExpectedCommit", $ExpectedCommit) } if ($ReportDir) { $childArgs += @("-ReportDir", $ReportDir) } if ($AllowDirty) { $childArgs += "-AllowDirty" } + $childArgs += $preflightToolchainProcessArgs & powershell.exe @childArgs $childExit = $LASTEXITCODE } finally { @@ -913,6 +937,7 @@ function Assert-RolloutStatusActionsOverrideGate { "-OutDir", $outRoot, "-ActionsStatusPath", $overridePath, "-ExpectedCommit", $fixtureCommit + $preflightToolchainProcessArgs ) if ($result.ExitCode -ne 2) { throw "Rollout status fixture should remain blocked because no hardware evidence was supplied. Exit code: $($result.ExitCode). Output:$([Environment]::NewLine)$($result.Text)" @@ -1295,7 +1320,7 @@ function Assert-ReleaseFlashHelperSafety { "-Firmware", "servo_calibration", "-DryRun" ) - $blockedServo = Invoke-ToolText ($blockedArgs + $dirtyPackageArg) + $blockedServo = Invoke-ToolText ($blockedArgs + $dirtyPackageArg + $preflightToolchainProcessArgs) if ($blockedServo.ExitCode -eq 0) { throw "Servo calibration package dry-run succeeded without -ConfirmServoRisk" } @@ -1309,7 +1334,7 @@ function Assert-ReleaseFlashHelperSafety { "-Monitor", "-Port", "COM_TEST" ) - $displayDryRun = Invoke-ToolText ($displayArgs + $dirtyPackageArg) + $displayDryRun = Invoke-ToolText ($displayArgs + $dirtyPackageArg + $preflightToolchainProcessArgs) if ($displayDryRun.ExitCode -ne 0) { throw "Display package dry-run failed unexpectedly:$([Environment]::NewLine)$($displayDryRun.Text)" } @@ -1327,7 +1352,7 @@ function Assert-ReleaseFlashHelperSafety { "-DryRun", "-Port", "COM_TEST" ) - $servoDryRun = Invoke-ToolText ($servoArgs + $dirtyPackageArg) + $servoDryRun = Invoke-ToolText ($servoArgs + $dirtyPackageArg + $preflightToolchainProcessArgs) if ($servoDryRun.ExitCode -ne 0) { throw "Servo package dry-run failed unexpectedly:$([Environment]::NewLine)$($servoDryRun.Text)" } @@ -1355,7 +1380,7 @@ function Assert-ReleasePublishBranchGuard { $publishArgs += "-AllowDirtyPackage" } - $publishDryRun = Invoke-ToolText $publishArgs + $publishDryRun = Invoke-ToolText ($publishArgs + $preflightToolchainProcessArgs) if ($publishDryRun.ExitCode -ne 0) { throw "Publish dry-run failed unexpectedly:$([Environment]::NewLine)$($publishDryRun.Text)" } @@ -1783,9 +1808,9 @@ function Assert-ArrivalPacketScaffoldGate { $startScript = Join-Path $PSScriptRoot "start_hardware_evidence.ps1" try { if ($AllowDirtyPackage) { - $createdOutput = & $startScript -ReleaseTag $Version -PackageZip $ZipPath -Port "COM_TEST" -Operator "preflight" -DeviceId "SELFTEST" -AllowDirtyPackage 2>&1 + $createdOutput = & $startScript -ReleaseTag $Version -PackageZip $ZipPath -Port "COM_TEST" -Operator "preflight" -DeviceId "SELFTEST" -AllowDirtyPackage @preflightToolchainSplat 2>&1 } else { - $createdOutput = & $startScript -ReleaseTag $Version -PackageZip $ZipPath -Port "COM_TEST" -Operator "preflight" -DeviceId "SELFTEST" 2>&1 + $createdOutput = & $startScript -ReleaseTag $Version -PackageZip $ZipPath -Port "COM_TEST" -Operator "preflight" -DeviceId "SELFTEST" @preflightToolchainSplat 2>&1 } $createdText = ($createdOutput | Out-String) } catch { @@ -1945,7 +1970,7 @@ function Assert-ArrivalPacketScaffoldGate { Assert-TextContains $checklist 'Pre-marked no-hardware gates were proven by the matching preflight report' Assert-TextContains $checklist '- [x] `pio run -e stackchan` passes.' Assert-TextContains $checklist '- [x] `tools/run_device_preflight.ps1` passes.' - Assert-TextContains $checklist '- [x] `tools/verify_release_package.ps1` passes for the release ZIP.' + Assert-TextContains $checklist '- [x] `tools/verify_release_package.ps1 -RequireReleaseEligible ... @releaseToolchain` passes for the release ZIP.' Assert-TextContains $checklist '- [ ] GitHub Actions `Firmware` workflow is green on `main`.' Assert-TextContains $checklist '- [x] Production RVC model and index hashes match the released files.' Assert-TextContains $checklist '- [ ] Live robot speech through the verified DirectML RVC path is recorded and reviewed on the target speaker.' @@ -2298,10 +2323,10 @@ if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { $verifyScript = Join-Path $PSScriptRoot "verify_release_package.ps1" if ($AllowDirty) { & $verifyScript -Version $Version -ZipPath $PackageZip -ExpectedCommit $ExpectedCommit ` - -AllowDirtyPackage -RequireReleaseEligible + -AllowDirtyPackage -RequireReleaseEligible @preflightToolchainSplat } else { & $verifyScript -Version $Version -ZipPath $PackageZip -ExpectedCommit $ExpectedCommit ` - -RequireReleaseEligible + -RequireReleaseEligible @preflightToolchainSplat } } diff --git a/tools/seal_pioarduino_release_core.ps1 b/tools/seal_pioarduino_release_core.ps1 new file mode 100644 index 00000000..13da7c6d --- /dev/null +++ b/tools/seal_pioarduino_release_core.ps1 @@ -0,0 +1,80 @@ +param( + [Parameter(Mandatory = $true)][string]$ReleaseCoreDir +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$originalSha256 = '6FC4C8912CBB1FA65A84A527EC5A3CB1280BBA399B02D4885C8C1D91AB7CC9D0' +$sealedSha256 = 'D16479CFAD23EF7C392B48C66B9E2422C0294E185746814ED4F7F9E4EFFACB60' +$relativeTarget = 'platforms/espressif32/builder/penv_setup.py' + +$coreItem = Get-Item -LiteralPath $ReleaseCoreDir -Force -ErrorAction Stop +if (-not $coreItem.PSIsContainer -or + ($coreItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'ReleaseCoreDir must be one real PlatformIO core directory.' +} +$coreRoot = $coreItem.FullName.TrimEnd('\', '/') +$targetPath = [IO.Path]::GetFullPath((Join-Path $coreRoot $relativeTarget)) +$targetItem = Get-Item -LiteralPath $targetPath -Force -ErrorAction Stop +if ($targetItem.PSIsContainer -or + ($targetItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'The pioarduino penv setup target must be one real file.' +} + +$actualSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $targetPath).Hash +$backupRoot = Join-Path (Split-Path -Parent $PSScriptRoot) 'output/private/toolchain-backups' +$backupPath = Join-Path $backupRoot "penv_setup.py.$originalSha256.bak" +if ($actualSha256 -ceq $sealedSha256) { + if (-not (Test-Path -LiteralPath $backupPath -PathType Leaf) -or + (Get-FileHash -Algorithm SHA256 -LiteralPath $backupPath).Hash -cne $originalSha256) { + throw 'Sealed pioarduino release core is missing its exact private original-byte backup.' + } + Write-Output "Pioarduino release core is already sealed: $targetPath" + exit 0 +} +if ($actualSha256 -cne $originalSha256) { + throw "Refusing unreviewed pioarduino penv setup bytes: $actualSha256" +} + +$originalBytes = [IO.File]::ReadAllBytes($targetPath) +$utf8 = [Text.UTF8Encoding]::new($false, $true) +$originalText = $utf8.GetString($originalBytes) +$oldDependency = ' "platformio": "https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.18.zip",' +$newDependency = ' "pioarduino-core": "https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.18.zip",' +$oldBranch = ' elif name == "platformio":' +$newBranch = ' elif name in ("platformio", "pioarduino-core"):' +if (($originalText.Split($oldDependency).Count - 1) -ne 1 -or + ($originalText.Split($oldBranch).Count - 1) -ne 1) { + throw 'Reviewed pioarduino seal anchors are missing or ambiguous.' +} +$sealedText = $originalText.Replace($oldDependency, $newDependency).Replace($oldBranch, $newBranch) +$sealedBytes = $utf8.GetBytes($sealedText) + +$tempPath = "$targetPath.stackchan-sealed-$PID.tmp" +if (Test-Path -LiteralPath $tempPath) { + throw "Refusing pre-existing seal temporary path: $tempPath" +} +New-Item -ItemType Directory -Force -Path $backupRoot | Out-Null +if (-not (Test-Path -LiteralPath $backupPath)) { + [IO.File]::WriteAllBytes($backupPath, $originalBytes) +} +if ((Get-FileHash -Algorithm SHA256 -LiteralPath $backupPath).Hash -cne $originalSha256) { + throw 'Private pioarduino backup does not match the reviewed original bytes.' +} + +try { + [IO.File]::WriteAllBytes($tempPath, $sealedBytes) + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $tempPath).Hash -cne $sealedSha256) { + throw 'Generated pioarduino seal bytes do not match the reviewed patched identity.' + } + [IO.File]::Replace($tempPath, $targetPath, $null) +} finally { + if (Test-Path -LiteralPath $tempPath) { + Remove-Item -LiteralPath $tempPath -Force + } +} +if ((Get-FileHash -Algorithm SHA256 -LiteralPath $targetPath).Hash -cne $sealedSha256) { + throw 'Pioarduino release core seal did not persist the reviewed bytes.' +} +Write-Output "Sealed pioarduino release core: $targetPath" diff --git a/tools/share_release.ps1 b/tools/share_release.ps1 index d0fb542e..413da870 100644 --- a/tools/share_release.ps1 +++ b/tools/share_release.ps1 @@ -11,7 +11,13 @@ param( [int]$PublicUrlReadyPollSeconds = 2, [switch]$StopAfterUrl, [switch]$OpenLocal, - [switch]$NoServe + [switch]$NoServe, + [string]$ToolchainAllowlistPath = "", + [string]$GitExecutable = "", + [string]$PythonExecutable = "", + [string]$PlatformioExecutable = "", + [string]$LegacyCoreDir = "", + [string]$ReleaseCoreDir = "" ) $ErrorActionPreference = "Stop" @@ -145,6 +151,12 @@ function Invoke-OperationalPackageVerification { -Version $Version ` -ZipPath $ZipPath ` -ExpectedCommit $ExpectedCommit ` + -ToolchainAllowlistPath $ToolchainAllowlistPath ` + -GitExecutable $GitExecutable ` + -PythonExecutable $PythonExecutable ` + -PlatformioExecutable $PlatformioExecutable ` + -LegacyCoreDir $LegacyCoreDir ` + -ReleaseCoreDir $ReleaseCoreDir ` -RequireReleaseEligible if ($LASTEXITCODE -ne 0) { throw "Operational release package verification failed with exit code $LASTEXITCODE." @@ -1047,9 +1059,9 @@ $rolloutStatus = if (Test-Path -LiteralPath $rolloutStatusPath) { [pscustomobject]@{ status = "blocked-or-pending" nextOwner = "hardware" - nextAction = "Create the arrival-day evidence packet and run the progress check." - nextCommand = ".\tools\prepare_device_arrival.cmd -Port COM3 -Operator `"Your Name`" -DeviceId STACKCHAN-001" - nextReason = "ROLLOUT_STATUS.json was not available in this share." + nextAction = "Return to the exact clean trusted source checkout, define its six exact-host authority values, and create the arrival-day evidence packet there." + nextCommand = $null + nextReason = "ROLLOUT_STATUS.json was not available in this share, and a shared archive does not confer release authority." } } $generatedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") @@ -1059,6 +1071,11 @@ $consumerRollout = [string]$readiness.consumerRollout $rolloutNextOwner = [System.Net.WebUtility]::HtmlEncode([string]$rolloutStatus.nextOwner) $rolloutNextAction = [System.Net.WebUtility]::HtmlEncode([string]$rolloutStatus.nextAction) $rolloutNextCommand = [System.Net.WebUtility]::HtmlEncode([string]$rolloutStatus.nextCommand) +$rolloutNextCommandHtml = if ([string]::IsNullOrWhiteSpace([string]$rolloutStatus.nextCommand)) { + 'No command is emitted by the shared archive. Return to the exact trusted source checkout.' +} else { + "$rolloutNextCommand" +} $rolloutNextReason = [System.Net.WebUtility]::HtmlEncode([string]$rolloutStatus.nextReason) $voiceSourceGateStatus = [System.Net.WebUtility]::HtmlEncode([string]$voiceSourceStatus.status) $voiceSourceBlockedGateCount = [int]$voiceSourceStatus.blockedGateCount @@ -1123,7 +1140,7 @@ $promotionGateItems = (@($readiness.hardwareGates) | ForEach-Object { Next owner: $rolloutNextOwner

Action: $rolloutNextAction

-

Command: $rolloutNextCommand

+

Command: $rolloutNextCommandHtml

Reason: $rolloutNextReason

Rollout status is intentionally not generated into this release share. Run the packaged arrival-day evidence command separately so mutable hardware state cannot be mistaken for verified release content.

@@ -1306,9 +1323,8 @@ $preflightDownloadItems

Device Arrival Quickstart

Bench operator runbook: ARRIVAL_DAY_RUNBOOK.md

-

After downloading and extracting the release ZIP, run this from inside the extracted folder:

-
.\tools\prepare_device_arrival.cmd -Port COM3 -Operator "Your Name" -DeviceId STACKCHAN-001
-

This verifies the package, dry-runs the display-only flash command, and creates a hardware evidence packet with runnable RUN_*.cmd files.

+

This page and the downloaded archive do not confer release authority. Return to the exact clean trusted source checkout, define the six-value releaseToolchain splat from docs/RELEASE_PROCESS.md, and pass the downloaded ZIP to the source-side tools/prepare_device_arrival.ps1.

+

The trusted source-side flow independently verifies the package, dry-runs the display-only flash command, and creates a hardware evidence packet with authority-bound RUN_*.cmd files.

Arrival-Day Evidence Loop

  1. Run RUN_DISPLAY_ONLY.cmd and confirm the face appears with dry-run servo logs.
  2. diff --git a/tools/start_bridge_ai_supervised_qualification.ps1 b/tools/start_bridge_ai_supervised_qualification.ps1 index 491b3fbc..7d93b111 100644 --- a/tools/start_bridge_ai_supervised_qualification.ps1 +++ b/tools/start_bridge_ai_supervised_qualification.ps1 @@ -17,7 +17,13 @@ param( [int]$MinReplyWindows = 100, [switch]$OperatorPresent, [switch]$ConfirmMotionOff, - [switch]$Json + [switch]$Json, + [string]$ToolchainAllowlistPath = "", + [string]$GitExecutable = "", + [string]$PythonExecutable = "", + [string]$PlatformioExecutable = "", + [string]$LegacyCoreDir = "", + [string]$ReleaseCoreDir = "" ) $ErrorActionPreference = "Stop" @@ -55,6 +61,9 @@ try { $PackageVerifyOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` -File (Join-Path $PSScriptRoot "verify_release_package.ps1") ` -Version $PackageVersion -ZipPath $PackageZipPath -ExpectedCommit $SourceCommit ` + -ToolchainAllowlistPath $ToolchainAllowlistPath -GitExecutable $GitExecutable ` + -PythonExecutable $PythonExecutable -PlatformioExecutable $PlatformioExecutable ` + -LegacyCoreDir $LegacyCoreDir -ReleaseCoreDir $ReleaseCoreDir ` -RequireReleaseEligible 2>&1) $PackageVerifyExit = $LASTEXITCODE } finally { diff --git a/tools/start_hardware_evidence.ps1 b/tools/start_hardware_evidence.ps1 index 5e6ad1bc..d83d071f 100644 --- a/tools/start_hardware_evidence.ps1 +++ b/tools/start_hardware_evidence.ps1 @@ -9,7 +9,13 @@ param( [string]$ShareRoot = "", [string]$CompanionV1EvidenceRoot = "output/companion-v1-evidence/latest", [switch]$AllowIncompleteMetadata, - [switch]$AllowDirtyPackage + [switch]$AllowDirtyPackage, + [string]$ToolchainAllowlistPath = "", + [string]$GitExecutable = "", + [string]$PythonExecutable = "", + [string]$PlatformioExecutable = "", + [string]$LegacyCoreDir = "", + [string]$ReleaseCoreDir = "" ) $ErrorActionPreference = "Stop" @@ -416,7 +422,7 @@ function Write-EvidenceChecklist { '`pio test -e native_logic` passes.', '`pio test -e stackchan --without-uploading --without-testing` passes.', '`tools/run_device_preflight.ps1` passes.', - '`tools/flash_release_firmware.ps1 -PackageZip -Firmware display_only -DryRun -Monitor` passes for the release ZIP.' + '`tools/flash_release_firmware.ps1 -PackageZip -Firmware display_only -DryRun -Monitor @releaseToolchain` passes for the release ZIP.' )) { $lines = Set-ChecklistItemState -Lines $lines -ExactItemText $item -Checked $true } @@ -425,8 +431,8 @@ function Write-EvidenceChecklist { if ($PackageVerified) { foreach ($item in @( 'Release package ZIP contains firmware, media, docs, manifest, dependency provenance, `dependency_lock.json`, copied build inputs, and checksums.', - '`tools/verify_release_package.ps1` passes for the release ZIP.', - 'Hardware evidence packet created with `tools/start_hardware_evidence.ps1`.' + '`tools/verify_release_package.ps1 -RequireReleaseEligible ... @releaseToolchain` passes for the release ZIP.', + 'Hardware evidence packet created with `tools/start_hardware_evidence.ps1 ... @releaseToolchain`.' )) { $lines = Set-ChecklistItemState -Lines $lines -ExactItemText $item -Checked $true } @@ -482,6 +488,14 @@ if ([string]::IsNullOrWhiteSpace($ExpectedCommit) -or throw "Pass a 40-hex -ExpectedCommit or run from a trusted Git checkout." } $commit = $ExpectedCommit.ToLowerInvariant() +$toolchainProcessArguments = @( + "-ToolchainAllowlistPath", $ToolchainAllowlistPath, + "-GitExecutable", $GitExecutable, + "-PythonExecutable", $PythonExecutable, + "-PlatformioExecutable", $PlatformioExecutable, + "-LegacyCoreDir", $LegacyCoreDir, + "-ReleaseCoreDir", $ReleaseCoreDir +) $packageVerifyOutput = @() $packageVerifyExitCode = 0 @@ -496,6 +510,7 @@ if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { "-Version", $ReleaseTag, "-ZipPath", $PackageZip, "-ExpectedCommit", $commit, "-RequireReleaseEligible" ) + $verifyArgs += $toolchainProcessArguments if ($AllowDirtyPackage) { $verifyArgs += "-AllowDirtyPackage" } $previousErrorPreference = $ErrorActionPreference try { @@ -516,6 +531,7 @@ if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { "-Version", $ReleaseTag, "-PackageRoot", $PackageRoot, "-ExpectedCommit", $commit, "-RequireReleaseEligible" ) + $verifyArgs += $toolchainProcessArguments if ($AllowDirtyPackage) { $verifyArgs += "-AllowDirtyPackage" } $previousErrorPreference = $ErrorActionPreference try { @@ -748,6 +764,14 @@ if (-not [string]::IsNullOrWhiteSpace($Port)) { $packageFlashArg = " -PackageZip $(Quote-PowerShellArgument '')" $verifyPackageArg = "-ZipPath $(Quote-PowerShellArgument '')" +$toolchainCommandArguments = @( + "-ToolchainAllowlistPath $(Quote-PowerShellArgument $ToolchainAllowlistPath)", + "-GitExecutable $(Quote-PowerShellArgument $GitExecutable)", + "-PythonExecutable $(Quote-PowerShellArgument $PythonExecutable)", + "-PlatformioExecutable $(Quote-PowerShellArgument $PlatformioExecutable)", + "-LegacyCoreDir $(Quote-PowerShellArgument $LegacyCoreDir)", + "-ReleaseCoreDir $(Quote-PowerShellArgument $ReleaseCoreDir)" +) -join ' ' if ($packageInfo -and $packageInfo.Contains("copiedFile")) { $packageFlashZip = Join-Path $packageDir ([System.IO.Path]::GetFileName($packageInfo["sourcePath"])) $packageFlashArg = " -PackageZip $(Quote-PowerShellArgument $packageFlashZip)" @@ -770,8 +794,8 @@ $androidCompanionSoakDir = Join-Path $androidDir "screen-off-soak" $androidUdpBeaconProbeDir = Join-Path $androidDir "udp-beacon-probe" $androidLogcatDir = Join-Path $androidDir "logcat" $androidApkInstallDir = Join-Path $androidDir "apk-install" -$displayCommand = "& '.\tools\flash_release_firmware.ps1'$packageFlashArg -Firmware display_only$portArg -Monitor 2>&1 | Tee-Object -FilePath $displayLog" -$servoCommand = "& '.\tools\flash_release_firmware.ps1'$packageFlashArg -Firmware servo_calibration$portArg -Monitor -ConfirmServoRisk 2>&1 | Tee-Object -FilePath $servoLog" +$displayCommand = "& '.\tools\flash_release_firmware.ps1'$packageFlashArg -Firmware display_only$portArg -Monitor $toolchainCommandArguments 2>&1 | Tee-Object -FilePath $displayLog" +$servoCommand = "& '.\tools\flash_release_firmware.ps1'$packageFlashArg -Firmware servo_calibration$portArg -Monitor -ConfirmServoRisk $toolchainCommandArguments 2>&1 | Tee-Object -FilePath $servoLog" $speechDemoBody = "& '.\tools\send_speech_mouth_demo.ps1'$portArg" if ($voiceLeadInfo) { $leadAudioPath = Join-Path $outDir ([string]$voiceLeadInfo.referenceFile -replace "/", "\") @@ -785,7 +809,7 @@ $speakAllCommand = "& '.\tools\send_speak_all_intents_demo.ps1'$portArg 2>&1 | T $bridgeReplayCommand = "& '.\tools\send_bridge_replay_demo.ps1'$portArg 2>&1 | Tee-Object -FilePath $bridgeReplayLog" $hardwareSimBaselineCommand = "& '.\tools\run_hardware_simulation.ps1' -OutputDir $hardwareSimBaselineDir -Json 2>&1 | Tee-Object -FilePath $hardwareSimBaselineLog" $simHardwareCompareCommand = "& '.\tools\compare_hardware_sim_baseline.ps1' -EvidenceRoot $(Quote-PowerShellArgument $outDir)" -$verifyCommand = "& '.\tools\verify_release_package.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $verifyPackageArg -ExpectedCommit $(Quote-PowerShellArgument $commit) -RequireReleaseEligible" +$verifyCommand = "& '.\tools\verify_release_package.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $verifyPackageArg -ExpectedCommit $(Quote-PowerShellArgument $commit) -RequireReleaseEligible $toolchainCommandArguments" if ($AllowDirtyPackage) { $verifyCommand += " -AllowDirtyPackage" } @@ -800,8 +824,8 @@ if ($packageInfo -and $packageInfo.Contains("copiedFile")) { } elseif (-not [string]::IsNullOrWhiteSpace($PackageRoot)) { $rolloutPackageArg = "-PackageRoot $(Quote-PowerShellArgument $PackageRoot)" } -$rolloutStatusCommand = "& '.\tools\export_rollout_status.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $rolloutPackageArg -EvidenceRoot $(Quote-PowerShellArgument $outDir) -ExpectedCommit $(Quote-PowerShellArgument $commit) -OutDir $(Quote-PowerShellArgument $outDir)" -$consumerPromotionCommand = "& '.\tools\verify_consumer_promotion.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $consumerPromotionPackageArg -EvidenceRoot $(Quote-PowerShellArgument $outDir) -CompanionV1EvidenceRoot $(Quote-PowerShellArgument $CompanionV1EvidenceRoot) -ExpectedCommit $(Quote-PowerShellArgument $commit)" +$rolloutStatusCommand = "& '.\tools\export_rollout_status.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $rolloutPackageArg -EvidenceRoot $(Quote-PowerShellArgument $outDir) -ExpectedCommit $(Quote-PowerShellArgument $commit) -OutDir $(Quote-PowerShellArgument $outDir) $toolchainCommandArguments" +$consumerPromotionCommand = "& '.\tools\verify_consumer_promotion.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $consumerPromotionPackageArg -EvidenceRoot $(Quote-PowerShellArgument $outDir) -CompanionV1EvidenceRoot $(Quote-PowerShellArgument $CompanionV1EvidenceRoot) -ExpectedCommit $(Quote-PowerShellArgument $commit) $toolchainCommandArguments" $platformioResolver = Quote-PowerShellArgument (Join-Path $PSScriptRoot "platformio_resolver.ps1") $soakCommand = ". $platformioResolver; Invoke-StackchanPlatformio device monitor --baud 115200$monitorPortArg 2>&1 | Tee-Object -FilePath $soakLog" $playLeadCommand = "Write-Host 'No voice playback reference was copied into this packet.'" diff --git a/tools/test_consumer_promotion_contract.ps1 b/tools/test_consumer_promotion_contract.ps1 index 161ac830..c30c74f1 100644 --- a/tools/test_consumer_promotion_contract.ps1 +++ b/tools/test_consumer_promotion_contract.ps1 @@ -28,7 +28,9 @@ foreach ($fragment in $required) { } $identityBindings = @( - '& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage -Version $Version -PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit -RequireReleaseEligible', + '-RequireReleaseEligible -ToolchainAllowlistPath $ToolchainAllowlistPath', + '-GitExecutable $GitExecutable -PythonExecutable $PythonExecutable', + '-LegacyCoreDir $LegacyCoreDir -ReleaseCoreDir $ReleaseCoreDir', '$cameraEvidence = Assert-CameraFollowReady $CameraFollowSummaryPath $ExpectedFirmwareSourceCommit', '$bodyEvidence = Assert-BodySensorReady $BodySensorReportPath $ExpectedFirmwareSourceCommit', '$soakEvidence = Assert-FinalSoakReady $FullSystemSoakSummaryPath $ExpectedFirmwareSourceCommit $MinFinalSoakDurationSeconds', @@ -48,19 +50,19 @@ $packageVerifierSource = Get-Content -LiteralPath (Join-Path $RepoRoot "tools\ve $actionsExporterSource = Get-Content -LiteralPath (Join-Path $RepoRoot "tools\export_github_actions_status.ps1") -Raw foreach ($fragment in @( - 'Release-grade packaging is fail-closed before Git or build-tool execution.', - 'Diagnostic packaging remains', - 'available only with -SkipBuild -AllowDirty; it is never release eligible.', + 'Assert-StackchanReleaseToolchainIdentity', + 'pre-Git byte authority mismatch', + 'release_toolchain_identity_allowlist.json', 'if ($SkipBuild -and -not $AllowDirty) {', 'if ($AllowDirty -and -not $SkipBuild) {', 'if ($SkipBuild -and $ObserveCandidateActions) {', '"diagnostic-only; reproducibility not proven; release and hardware validation forbidden"', '"test-ready prerelease; hardware validation pending"', 'diagnosticPackage = [bool]$SkipBuild', - 'releaseEligible = (-not $SkipBuild)', - 'hardwareValidationEligible = (-not $SkipBuild)', - 'distributionEligible = (-not $SkipBuild)', - 'flashEligible = (-not $SkipBuild)', + 'releaseEligible = ($releaseToolchainEligible -and (-not $SkipBuild))', + 'hardwareValidationEligible = ($releaseToolchainEligible -and (-not $SkipBuild))', + 'distributionEligible = ($releaseToolchainEligible -and (-not $SkipBuild))', + 'flashEligible = ($releaseToolchainEligible -and (-not $SkipBuild))', 'status = if ($SkipBuild) { "diagnostic-only-unqualified" } else { "test-ready-prerelease" }', 'consumerRollout = if ($SkipBuild) { "forbidden-diagnostic-package" } else { "blocked-pending-hardware-validation" }', 'releaseClass = if ($SkipBuild) { "diagnostic-only-unqualified" } else { "test-ready-prerelease" }', @@ -77,7 +79,8 @@ foreach ($fragment in @( '-ExpectedCommit $ExpectedCommit -RequireReleaseEligible', 'Operational release ZIP verification failed before consumer-promotion extraction.', 'Expand-StackchanReleaseZipSafely', - '-PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit -RequireReleaseEligible' + '-PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit', + '-RequireReleaseEligible -ToolchainAllowlistPath $ToolchainAllowlistPath' )) { if (-not $source.Contains($fragment)) { throw "Consumer promotion release-eligibility boundary missing fragment: $fragment" @@ -85,7 +88,7 @@ foreach ($fragment in @( } $packageFailClosedGuardIndex = $packageSource.IndexOf('if (-not $SkipBuild) {') -$packageFailClosedMessageIndex = $packageSource.IndexOf('Release-grade packaging is fail-closed before Git or build-tool execution.') +$packageFailClosedMessageIndex = $packageSource.IndexOf('Assert-StackchanReleaseToolchainIdentity') $packageFirstGitResolutionIndex = $packageSource.IndexOf('$releaseBootstrapGitCommand = Get-Command -Name git') if ($packageFailClosedGuardIndex -lt 0 -or $packageFailClosedMessageIndex -lt $packageFailClosedGuardIndex -or @@ -95,8 +98,8 @@ if ($packageFailClosedGuardIndex -lt 0 -or foreach ($fragment in @( '[switch]$RequireReleaseEligible', - 'Release-eligible verification is fail-closed before Git or build-tool execution.', - 'verification remains available without -RequireReleaseEligible and cannot establish eligibility.', + 'Assert-StackchanReleaseToolchainIdentity', + 'Release verifier pre-Git byte authority mismatch:', 'Operational release verification refuses diagnostic packages.', '$manifest.releaseEligible -ne $false', '$manifest.hardwareValidationEligible -ne $false', @@ -114,12 +117,12 @@ foreach ($fragment in @( } $verifierFailClosedGuardIndex = $packageVerifierSource.IndexOf('if ($RequireReleaseEligible) {') -$verifierFailClosedMessageIndex = $packageVerifierSource.IndexOf('Release-eligible verification is fail-closed before Git or build-tool execution.') -$verifierAmbientProcessingIndex = $packageVerifierSource.IndexOf('$ambientGitOverrides = @(') +$verifierFailClosedMessageIndex = $packageVerifierSource.IndexOf('Assert-StackchanReleaseToolchainIdentity') +$verifierAmbientProcessingIndex = $packageVerifierSource.IndexOf('$trustedGitDisabledHooksPath = Join-Path') if ($verifierFailClosedGuardIndex -lt 0 -or $verifierFailClosedMessageIndex -lt $verifierFailClosedGuardIndex -or $verifierAmbientProcessingIndex -lt $verifierFailClosedMessageIndex) { - throw "Release package verifier fail-closed guard must precede ambient, Git, tool, and package processing." + throw "Release package verifier identity guard must precede trusted Git, tool, and package processing." } foreach ($fragment in @( @@ -136,7 +139,7 @@ foreach ($fragment in @( $zipEligibilityVerifyIndex = $source.IndexOf('-ExpectedCommit $ExpectedCommit -RequireReleaseEligible') $zipVerificationFailureIndex = $source.IndexOf('Operational release ZIP verification failed before consumer-promotion extraction.') $safeExtractionIndex = $source.IndexOf('Expand-StackchanReleaseZipSafely') -$rootEligibilityVerifyIndex = $source.IndexOf('& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage -Version $Version -PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit -RequireReleaseEligible') +$rootEligibilityVerifyIndex = $source.IndexOf('-Version $Version -PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit') $firstEvidenceCheckIndex = $source.IndexOf('if ([string]::IsNullOrWhiteSpace($EvidenceRoot))') if ($zipEligibilityVerifyIndex -lt 0 -or $zipVerificationFailureIndex -lt $zipEligibilityVerifyIndex -or diff --git a/tools/test_firmware_reproducible_build_contract.ps1 b/tools/test_firmware_reproducible_build_contract.ps1 index 6ad50ed0..6be5eb7f 100644 --- a/tools/test_firmware_reproducible_build_contract.ps1 +++ b/tools/test_firmware_reproducible_build_contract.ps1 @@ -20,6 +20,8 @@ $selectorPolicyContractPath = Join-Path $repoRoot "tools/test_release_ota_select $flashSnapshotContractPath = Join-Path $repoRoot "tools/test_release_flash_snapshot_contract.ps1" $sourceBindingContractPath = Join-Path $repoRoot "tools/test_release_source_binding_contract.ps1" $dependencyEvidenceContractPath = Join-Path $repoRoot "tools/test_release_dependency_evidence_contract.ps1" +$toolchainIntegrationContractPath = Join-Path $repoRoot "tools/test_release_toolchain_integration_contract.ps1" +$toolchainDocumentationContractPath = Join-Path $repoRoot "tools/test_release_toolchain_documentation_contract.ps1" $issues = New-Object 'System.Collections.Generic.List[string]' function Require-ReproAssertion { @@ -172,6 +174,14 @@ if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) { $issues.Add("release-dependency-evidence-contract-failed: exit $LASTEXITCODE") } +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $toolchainIntegrationContractPath +if ($LASTEXITCODE -ne 0) { + $issues.Add("release-toolchain-integration-contract-failed: exit $LASTEXITCODE") +} +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $toolchainDocumentationContractPath +if ($LASTEXITCODE -ne 0) { + $issues.Add("release-toolchain-documentation-contract-failed: exit $LASTEXITCODE") +} Require-ReproAssertion ($contractText.Contains('platformio_resolver.ps1') -and $contractText.Contains('Invoke-StackchanPlatformio project config --json-output') -and diff --git a/tools/test_git_pack_semantic_verifier.py b/tools/test_git_pack_semantic_verifier.py new file mode 100644 index 00000000..19f4b8ff --- /dev/null +++ b/tools/test_git_pack_semantic_verifier.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Hostile contract for the release Git-pack semantic verifier.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +TOOLS_ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(TOOLS_ROOT)) + +import verify_git_pack_semantics as verifier # noqa: E402 + +VerificationError = verifier.VerificationError +verify_pack_triplet = verifier.verify_pack_triplet + + +def _run_git(*args: str, cwd: Path | None = None) -> str: + environment = os.environ.copy() + environment.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TERMINAL_PROMPT": "0", + "GIT_OPTIONAL_LOCKS": "0", + } + ) + completed = subprocess.run( + ["git", *args], + cwd=cwd, + env=environment, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + if completed.returncode: + raise AssertionError( + f"git {' '.join(args)} failed ({completed.returncode}):\n" + f"{completed.stdout}\n{completed.stderr}" + ) + return completed.stdout.strip() + + +def _swap_index_offsets_without_changing_object_ids(index_path: Path) -> None: + data = bytearray(index_path.read_bytes()) + if data[:4] != b"\xfftOc" or struct.unpack_from(">I", data, 4)[0] != 2: + raise AssertionError("fixture did not produce a Git pack index v2") + count = struct.unpack_from(">I", data, 8 + 255 * 4)[0] + if count < 2: + raise AssertionError("fixture pack needs at least two objects") + crc_start = 8 + 256 * 4 + count * 20 + offset_start = crc_start + count * 4 + chosen: list[int] = [] + for position in range(count): + encoded = struct.unpack_from(">I", data, offset_start + position * 4)[0] + if encoded & 0x80000000 == 0: + chosen.append(position) + if len(chosen) == 2: + break + if len(chosen) != 2: + raise AssertionError("fixture pack did not expose two ordinary offsets") + first, second = chosen + first_offset = data[offset_start + first * 4 : offset_start + first * 4 + 4] + second_offset = data[offset_start + second * 4 : offset_start + second * 4 + 4] + data[offset_start + first * 4 : offset_start + first * 4 + 4] = second_offset + data[offset_start + second * 4 : offset_start + second * 4 + 4] = first_offset + # Keep the index structurally valid so only semantic decoding can reject it. + data[-20:] = hashlib.sha1(data[:-20]).digest() + os.chmod(index_path, 0o600) + index_path.write_bytes(data) + + +class GitPackSemanticVerifierContract(unittest.TestCase): + def setUp(self) -> None: + self.temp_root = Path(tempfile.mkdtemp(prefix="stackchan-git-pack-semantic-")) + self.repo = self.temp_root / "repo" + _run_git("init", "--initial-branch=main", str(self.repo)) + _run_git("config", "user.name", "Stackchan Contract", cwd=self.repo) + _run_git("config", "user.email", "contract@example.invalid", cwd=self.repo) + _run_git("config", "core.autocrlf", "false", cwd=self.repo) + for index in range(24): + source = self.repo / f"source-{index:02d}.txt" + source.write_text((f"object-{index:02d}\n" * (index + 3)), encoding="utf-8") + _run_git("add", "--", source.name, cwd=self.repo) + _run_git("commit", "-m", f"fixture {index:02d}", cwd=self.repo) + _run_git("repack", "-ad", cwd=self.repo) + pack_root = self.repo / ".git" / "objects" / "pack" + self.pack = next(pack_root.glob("*.pack")) + self.index = self.pack.with_suffix(".idx") + self.reverse = self.pack.with_suffix(".rev") + if not self.reverse.exists(): + _run_git("index-pack", "--rev-index", str(self.pack), cwd=self.repo) + + def tearDown(self) -> None: + shutil.rmtree(self.temp_root, ignore_errors=True) + + def test_valid_pack_proves_exact_object_offset_and_reverse_index_mapping(self) -> None: + result = verify_pack_triplet(self.pack, self.index, self.reverse) + self.assertGreater(result["objectCount"], 20) + self.assertTrue(result["objectOffsetMappingVerified"]) + self.assertTrue(result["reverseIndexMappingVerified"]) + self.assertEqual(result["objectCount"], len(result["objectIds"])) + + def test_checksum_valid_index_with_swapped_offsets_is_rejected(self) -> None: + _swap_index_offsets_without_changing_object_ids(self.index) + with self.assertRaisesRegex(VerificationError, "object-to-offset mapping"): + verify_pack_triplet(self.pack, self.index, self.reverse) + + def test_object_resource_limit_is_fail_closed(self) -> None: + with mock.patch.object(verifier, "MAX_OBJECT_BYTES", 0): + with self.assertRaisesRegex(VerificationError, "resource limit"): + verify_pack_triplet(self.pack, self.index, self.reverse) + + def test_symbolic_link_triplet_is_rejected_when_supported(self) -> None: + linked_pack = self.pack.with_name("linked.pack") + try: + linked_pack.symlink_to(self.pack) + except OSError: + self.skipTest("symbolic links are unavailable to this Windows user") + with self.assertRaisesRegex(VerificationError, "symbolic link"): + verify_pack_triplet(linked_pack, self.index, self.reverse) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/test_platformio_utf8_contract.ps1 b/tools/test_platformio_utf8_contract.ps1 index 53660af0..dd41c962 100644 --- a/tools/test_platformio_utf8_contract.ps1 +++ b/tools/test_platformio_utf8_contract.ps1 @@ -8,50 +8,119 @@ $python = (Get-Command python -ErrorAction Stop).Source $script = @' import os import sys +import time +from pathlib import Path assert os.environ.get("PYTHONUTF8") == "1" assert os.environ.get("PYTHONIOENCODING") == "utf-8" assert sys.stdout.encoding.lower().replace("-", "") == "utf8" +print("stderr-before-success", file=sys.stderr, flush=True) +time.sleep(0.25) +Path(sys.argv[1]).write_text("success-child-complete", encoding="utf-8") +print("stderr-after-success", file=sys.stderr, flush=True) print("esptool progress: \u2588\u2591") '@ $tempScript = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-utf8-" + [guid]::NewGuid().ToString("N") + ".py") +$successSentinel = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-utf8-success-" + [guid]::NewGuid().ToString("N") + ".txt") try { Set-Content -LiteralPath $tempScript -Value $script -Encoding UTF8 - $output = @(Invoke-StackchanUtf8Process -Command $python -Arguments @($tempScript)) + $successTimer = [Diagnostics.Stopwatch]::StartNew() + $output = @(Invoke-StackchanUtf8Process -Command $python ` + -Arguments @($tempScript, $successSentinel) 2>&1) + $successTimer.Stop() if ($LASTEXITCODE -ne 0) { throw "UTF-8 subprocess contract exited with code $LASTEXITCODE." } + if ($ErrorActionPreference -cne 'Stop') { + throw 'UTF-8 subprocess wrapper did not restore the caller error-action policy after success.' + } + if (-not (Test-Path -LiteralPath $successSentinel -PathType Leaf) -or + (Get-Content -LiteralPath $successSentinel -Raw) -cne 'success-child-complete' -or + $successTimer.ElapsedMilliseconds -lt 150) { + throw "UTF-8 subprocess wrapper returned before the stderr-producing native child completed." + } } finally { - Remove-Item -LiteralPath $tempScript -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tempScript,$successSentinel -Force -ErrorAction SilentlyContinue } $text = $output -join [Environment]::NewLine -if ($text -notmatch "esptool progress:.*\u2588\u2591") { - throw "UTF-8 subprocess contract did not preserve esptool progress characters." +foreach ($expectedLine in @('stderr-before-success', 'stderr-after-success', "esptool progress: $([char]0x2588)$([char]0x2591)")) { + if (-not $text.Contains($expectedLine)) { + throw "UTF-8 subprocess contract lost native output: $expectedLine" + } } $failureScript = @' import sys +import time +from pathlib import Path + +print("stderr-before-failure", file=sys.stderr, flush=True) +time.sleep(0.25) +Path(sys.argv[1]).write_text("failure-child-complete", encoding="utf-8") +print("stderr-after-failure", file=sys.stderr, flush=True) sys.exit(7) '@ $tempFailureScript = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-utf8-fail-" + [guid]::NewGuid().ToString("N") + ".py") +$failureSentinel = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-utf8-fail-" + [guid]::NewGuid().ToString("N") + ".txt") try { Set-Content -LiteralPath $tempFailureScript -Value $failureScript -Encoding UTF8 $failureRaised = $false + $failureLines = [Collections.Generic.List[string]]::new() + $failureTimer = [Diagnostics.Stopwatch]::StartNew() try { - Invoke-StackchanUtf8Process -Command $python -Arguments @($tempFailureScript) + Invoke-StackchanUtf8Process -Command $python ` + -Arguments @($tempFailureScript, $failureSentinel) 2>&1 | ForEach-Object { + $failureLines.Add([string]$_) | Out-Null + } } catch { + $failureLines.Add([string]$_) | Out-Null $failureRaised = $_.Exception.Message -match "exit code 7" + } finally { + $failureTimer.Stop() } if (-not $failureRaised) { throw "UTF-8 subprocess wrapper did not propagate a failing native exit code." } + if ($ErrorActionPreference -cne 'Stop') { + throw 'UTF-8 subprocess wrapper did not restore the caller error-action policy after failure.' + } + if (-not (Test-Path -LiteralPath $failureSentinel -PathType Leaf) -or + (Get-Content -LiteralPath $failureSentinel -Raw) -cne 'failure-child-complete' -or + $failureTimer.ElapsedMilliseconds -lt 150) { + throw "UTF-8 subprocess wrapper classified failure before the native child completed." + } + $failureText = $failureLines -join [Environment]::NewLine + foreach ($expectedLine in @('stderr-before-failure', 'stderr-after-failure')) { + if (-not $failureText.Contains($expectedLine)) { + throw "UTF-8 subprocess failure path lost native diagnostics: $expectedLine" + } + } } finally { - Remove-Item -LiteralPath $tempFailureScript -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tempFailureScript,$failureSentinel -Force -ErrorAction SilentlyContinue +} + +$missingLines = [Collections.Generic.List[string]]::new() +$missingRaised = $false +$missingCommand = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-definitely-missing-' + [guid]::NewGuid().ToString('N') + '.exe') +try { + Invoke-StackchanUtf8Process -Command $missingCommand 2>&1 | ForEach-Object { + $missingLines.Add([string]$_) | Out-Null + } +} catch { + $missingLines.Add([string]$_) | Out-Null + $missingRaised = $_.Exception.Message -match 'did not start its resolved native executable' +} +if (-not $missingRaised -or $ErrorActionPreference -cne 'Stop') { + throw 'UTF-8 subprocess wrapper did not fail closed on a missing native executable.' } $resolverText = Get-Content -LiteralPath "tools\platformio_resolver.ps1" -Raw -foreach ($required in @("PYTHONIOENCODING", "PYTHONUTF8", "Console]::OutputEncoding", "Invoke-StackchanUtf8Process", "processExitCode")) { +foreach ($required in @( + "PYTHONIOENCODING", "PYTHONUTF8", "Console]::OutputEncoding", + "Invoke-StackchanUtf8Process", "processExitCode", "nativeExitSentinel", + '$ErrorActionPreference = "Continue"')) { if ($resolverText -notmatch [regex]::Escape($required)) { throw "Shared PlatformIO resolver is missing UTF-8 guard: $required" } diff --git a/tools/test_release_command_trust_contract.ps1 b/tools/test_release_command_trust_contract.ps1 index f89d2f13..1d8a3dfd 100644 --- a/tools/test_release_command_trust_contract.ps1 +++ b/tools/test_release_command_trust_contract.ps1 @@ -24,20 +24,20 @@ foreach ($scriptPath in @( } } -$packageFailureOffset = $packageText.IndexOf('if (-not $SkipBuild)', [StringComparison]::Ordinal) +$packageFailureOffset = $packageText.IndexOf('Assert-StackchanReleaseToolchainIdentity', [StringComparison]::Ordinal) $packageGitOffset = $packageText.IndexOf( '$releaseBootstrapGitCommand = Get-Command', [StringComparison]::Ordinal) if ($packageFailureOffset -lt 0 -or $packageGitOffset -lt 0 -or $packageFailureOffset -ge $packageGitOffset -or - -not $packageText.Contains('Release-grade packaging is fail-closed before Git or build-tool execution')) { - throw 'Release-grade packaging is not fail-closed before its first Git resolution.' + -not $packageText.Contains('pre-Git byte authority mismatch')) { + throw 'Release-grade packaging does not authenticate exact toolchain bytes before Git resolution.' } -$verifyFailureOffset = $verifyText.IndexOf('if ($RequireReleaseEligible)', [StringComparison]::Ordinal) +$verifyFailureOffset = $verifyText.IndexOf('Assert-StackchanReleaseToolchainIdentity', [StringComparison]::Ordinal) $verifyGitOffset = $verifyText.IndexOf('$trustedGitCommand = Get-Command', [StringComparison]::Ordinal) if ($verifyFailureOffset -lt 0 -or $verifyGitOffset -lt 0 -or $verifyFailureOffset -ge $verifyGitOffset -or - -not $verifyText.Contains('Release-eligible verification is fail-closed before Git or build-tool execution')) { - throw 'Release-eligible verification is not fail-closed before its first Git resolution.' + -not $verifyText.Contains('pre-Git byte authority mismatch')) { + throw 'Release-eligible verification does not authenticate exact toolchain bytes before Git resolution.' } foreach ($forbidden in @('& powershell.exe', '& subst.exe', '& tar.exe', 'git-lfs')) { @@ -155,8 +155,8 @@ try { } catch { $message = $_.Exception.Message if ($Verifier) { - if ($message -notlike '*Release-eligible verification is fail-closed before Git or build-tool execution*') { throw } - } elseif ($message -notlike '*Release-grade packaging is fail-closed before Git or build-tool execution*') { + if ($message -notlike '*Release-eligible verification requires explicit -GitExecutable authority*') { throw } + } elseif ($message -notlike '*Release packaging requires explicit -GitExecutable authority*') { throw } } diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 index a21470ba..e04a2b0e 100644 --- a/tools/test_release_package_verifier_trust_contract.ps1 +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -361,8 +361,9 @@ $releaseEligibleIfStatements = @($verifyAst.EndBlock.Statements | Where-Object { $_.Clauses[0].Item1.Extent.Text -eq '$RequireReleaseEligible' }) $releaseFrontDoorGates = @($releaseEligibleIfStatements | Where-Object { - $_.Extent.Text.Contains( - 'Release-eligible verification is fail-closed before Git or build-tool execution') + $_.Extent.Text.Contains('requiredToolchainArguments') -and + $_.Extent.Text.Contains('Assert-StackchanReleaseToolchainIdentity') -and + $_.Extent.Text.Contains('pre-Git byte authority mismatch') }) $releaseEligibilityGates = @($releaseEligibleIfStatements | Where-Object { $_.Extent.Text.Contains( @@ -370,8 +371,8 @@ $releaseEligibilityGates = @($releaseEligibleIfStatements | Where-Object { }) if ($releaseFrontDoorGates.Count -ne 1 -or $releaseFrontDoorGates[0].Extent.EndOffset -ge - $verifyText.IndexOf('$trustedGitCommand = Get-Command', [StringComparison]::Ordinal)) { - throw 'Operational verifier must fail closed before resolving Git for eligible verification' + $verifyText.IndexOf('$trustedGitDisabledHooksPath = Join-Path', [StringComparison]::Ordinal)) { + throw 'Operational verifier must authenticate exact toolchain bytes before trusted Git setup' } if ($releaseEligibilityGates.Count -ne 1) { throw 'Operational verifier must contain exactly one top-level trusted checkout gate' @@ -393,7 +394,7 @@ $preGateCodeCommands = @($verifyAst.FindAll({ } $commandName = [string]$node.GetCommandName() if ($node.InvocationOperator -eq [System.Management.Automation.Language.TokenKind]::Dot) { - return $true + return $node.Extent.Text -cne '. $identityHelperPath' } if ($node.InvocationOperator -eq [System.Management.Automation.Language.TokenKind]::Ampersand) { return $node.Extent.Text -cne '& $script:trustedGitExecutable @gitArguments' @@ -433,6 +434,21 @@ $bootstrapGitFunctions = @($verifyAst.FindAll({ if ($bootstrapGitFunctions.Count -ne 1) { throw 'Operational verifier trusted Git bootstrap is ambiguous' } +$canonicalBlobHashFunctions = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Get-CanonicalGitBlobHash' +}, $true)) +$earlyDiagnosticGates = @($verifyAst.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.IfStatementAst] -and + $_.Clauses.Count -eq 1 -and + $_.Clauses[0].Item1.Extent.Text -ceq + 'Test-Path -LiteralPath $eligibilityManifestPath -PathType Leaf' -and + $_.Extent.Text.Contains('Operational release verification refuses diagnostic packages.') +}) +if ($canonicalBlobHashFunctions.Count -ne 1 -or $earlyDiagnosticGates.Count -ne 1) { + throw 'Operational verifier checkout-gate harness inputs are ambiguous' +} $bootstrapGitText = $bootstrapGitFunctions[0].Extent.Text foreach ($requiredBootstrap in @( 'core.hooksPath=', @@ -497,7 +513,7 @@ foreach ($requiredRebuildMarker in @( 'Assert-StackchanReleaseFrameworkOtaSelector', 'Get-StackchanReleaseOtaSelectorPolicy', 'Two-cycle proof does not match reviewed OTA selector authority:', - "Get-Command -Name `$pioExecutable -CommandType Application", + '`$pioExecutable = `$resolvedPlatformioExecutable', "`$pioVersion -cne 'PlatformIO Core, version 6.1.19'", '[string]$dependencyLock.platformioCore -cne $pioVersion', "@(& `$pioExecutable 'pkg' 'list' '-d' `$rebuildWorktree '-e' `$environment 2>&1)", @@ -505,7 +521,11 @@ foreach ($requiredRebuildMarker in @( '-DifferenceObject $actualDependencyIdentity -CaseSensitive', 'Get-StackchanVerbosePlatformSource', '[string]$spec.coreDir', - '[string]$expectedEnvironmentLock.platformSourceLeaf' + '[string]$expectedEnvironmentLock.platformSourceLeaf', + "@(& `$pioExecutable 'pkg' 'install' '-d' `$rebuildWorktree '-e' `$environment 2>&1)", + 'Assert-StackchanReleaseToolchainIdentity', + '-Phase PostBuild -Environment $environment', + 'Assert-StackchanReleaseBuildPythonEnvironment' )) { $marker = $requiredRebuildMarker.Replace('`$', '$') if (-not $operationalRebuildText.Contains($marker)) { @@ -539,6 +559,10 @@ foreach ($evidenceField in @( 'platformioVersion = $pioVersion', 'defaultPlatformioCore = $defaultCoreDir', 'releasePlatformioCore = $releaseCoreDir', + 'toolchainIdentity = [ordered]@{', + 'allowlistSha256 = $verifierToolchainAllowlistSha256', + 'preExecution = @($script:verifierToolchainIdentityRecords', + 'postBuild = @($script:verifierToolchainIdentityRecords', 'records = @($rebuildRecords)' )) { if (-not $operationalRebuildText.Contains($evidenceField)) { @@ -573,30 +597,22 @@ foreach ($failureProbeMarker in @( } } $publicVerifierGuards = @($packageAst.EndBlock.Statements | Where-Object { - if ($_ -isnot [System.Management.Automation.Language.IfStatementAst] -or - $_.Clauses.Count -ne 1 -or - $_.Clauses[0].Item1.Extent.Text -ne '-not $SkipBuild') { - return $false - } - $directStatements = @($_.Clauses[0].Item2.Statements) - $directAppends = @($directStatements | Where-Object { - $_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and - $_.Left.Extent.Text -eq '$packageVerifyArgs' -and - $_.Operator -eq [System.Management.Automation.Language.TokenKind]::PlusEquals -and - $_.Right.Extent.Text -eq '"-RequireReleaseEligible"' - }) - return ($directStatements.Count -eq 1 -and $directAppends.Count -eq 1) + $_ -is [System.Management.Automation.Language.IfStatementAst] -and + $_.Clauses.Count -eq 1 -and $_.Clauses[0].Item1.Extent.Text -eq '-not $SkipBuild' -and + $_.Extent.Text.Contains("'-RequireReleaseEligible'") -and + $_.Extent.Text.Contains("'-ToolchainAllowlistPath'") -and + $_.Extent.Text.Contains("'-GitExecutable'") }) $publicVerifierGuardStatements = if ($publicVerifierGuards.Count -eq 1) { @($publicVerifierGuards[0].Clauses[0].Item2.Statements) -} else { - @() -} +} else { @() } $publicVerifierRequireAppends = @($publicVerifierGuardStatements | Where-Object { $_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and $_.Left.Extent.Text -eq '$packageVerifyArgs' -and $_.Operator -eq [System.Management.Automation.Language.TokenKind]::PlusEquals -and - $_.Right.Extent.Text -eq '"-RequireReleaseEligible"' + $_.Right.Extent.Text.Contains("'-RequireReleaseEligible'") -and + $_.Right.Extent.Text.Contains("'-ToolchainAllowlistPath'") -and + $_.Right.Extent.Text.Contains("'-ReleaseCoreDir'") }) $publicVerifierArgumentAssignments = @($packageAst.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and @@ -1179,12 +1195,9 @@ printf executed > '$($markerPath.Replace('\', '/'))' } } -$releaseEligibilityUnavailable = $verifyText.Contains( - 'Release-eligible verification is fail-closed before Git or build-tool execution') -if (-not $releaseEligibilityUnavailable) { - $epochTrustRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( - 'stackchan-epoch-trust-contract-' + [guid]::NewGuid().ToString('N')) - try { +$epochTrustRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-epoch-trust-contract-' + [guid]::NewGuid().ToString('N')) +try { $epochTools = Join-Path $epochTrustRoot 'tools' New-Item -ItemType Directory -Force -Path $epochTools | Out-Null foreach ($relative in @( @@ -1193,6 +1206,7 @@ if (-not $releaseEligibilityUnavailable) { 'release_zip_safety.ps1', 'release_dependency_evidence.ps1', 'release_git_trust.ps1', + 'release_ota_selector_policy.ps1', 'platformio_resolver.ps1' )) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot $relative) -Destination $epochTools @@ -1209,6 +1223,43 @@ with open("filter_expected.bin", "rb") as expected: '@ | Set-Content -LiteralPath (Join-Path $epochTrustRoot 'filter_clean.py') -Encoding ASCII Copy-Item -LiteralPath (Join-Path $epochTools 'release_dependency_evidence.ps1') ` -Destination (Join-Path $epochTrustRoot 'filter_expected.bin') + $gateHarnessPath = Join-Path $epochTools 'release_checkout_gate_harness.ps1' + $gateHarnessText = @( +@' +param( + [Parameter(Mandatory = $true)][string]$ExpectedCommit, + [Parameter(Mandatory = $true)][string]$ExpectedSourceEpoch, + [Parameter(Mandatory = $true)][string]$GitExecutable, + [string]$PackageRoot, + [switch]$AllowDirtyPackage +) +$ErrorActionPreference = 'Stop' +$RequireReleaseEligible = $true +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +Set-Location $repoRoot +$script:trustedGitExecutable = (Get-Item -LiteralPath $GitExecutable -Force -ErrorAction Stop).FullName +$script:trustedGitDisabledHooksPath = Join-Path $repoRoot ( + 'output/private/disabled-verifier-git-hooks-' + $PID + '-' + [guid]::NewGuid().ToString('N')) +$script:trustedNullAttributesPath = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } +if (Test-Path -LiteralPath $script:trustedGitDisabledHooksPath) { + throw "Verifier Git disabled-hooks sentinel unexpectedly exists: $script:trustedGitDisabledHooksPath" +} +'@ + $bootstrapGitFunctions[0].Extent.Text + $canonicalBlobHashFunctions[0].Extent.Text + $releaseEligibilityGate.Extent.Text +@' +if (-not [string]::IsNullOrWhiteSpace($PackageRoot)) { + $packageRootPath = (Resolve-Path -LiteralPath $PackageRoot).Path + $eligibilityManifestPath = Join-Path $packageRootPath 'release_manifest.json' +'@ + $earlyDiagnosticGates[0].Extent.Text +@' +} +'@ + ) -join "`r`n`r`n" + [IO.File]::WriteAllText( + $gateHarnessPath, $gateHarnessText, (New-Object Text.UTF8Encoding($false))) $earlyDiagnosticRoot = Join-Path $epochTrustRoot 'package' New-Item -ItemType Directory -Path $earlyDiagnosticRoot | Out-Null [ordered]@{ @@ -1229,16 +1280,21 @@ with open("filter_expected.bin", "rb") as expected: $epochCommit = (& git -C $epochTrustRoot rev-parse HEAD).Trim() $epochValue = (& git -C $epochTrustRoot show -s --format=%ct HEAD).Trim() $wrongEpoch = if ($epochValue -eq '1') { '2' } else { '1' } + $fixtureGitCommand = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -eq $fixtureGitCommand) { + throw 'Checkout-gate fixture requires a Git application' + } + $fixtureGitExecutable = ( + Resolve-Path -LiteralPath ([string]$fixtureGitCommand.Source)).Path + $gateBaseArgs = @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $gateHarnessPath, + '-ExpectedCommit', $epochCommit, '-GitExecutable', $fixtureGitExecutable) $previousErrorPreference = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' - $epochOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -File (Join-Path $epochTools 'verify_release_package.ps1') ` - -Version 'epoch-contract' ` - -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` - -ExpectedCommit $epochCommit ` - -ExpectedSourceEpoch $wrongEpoch ` - -RequireReleaseEligible 2>&1) + $epochOutput = @(& powershell.exe @gateBaseArgs ` + -ExpectedSourceEpoch $wrongEpoch 2>&1) $epochExit = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorPreference @@ -1250,14 +1306,11 @@ with open("filter_expected.bin", "rb") as expected: $previousErrorPreference = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' - $earlyDiagnosticOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -File (Join-Path $epochTools 'verify_release_package.ps1') ` - -Version 'diagnostic-early-contract' ` + $earlyDiagnosticOutput = @(& powershell.exe @gateBaseArgs ` -PackageRoot $earlyDiagnosticRoot ` - -ExpectedCommit $epochCommit ` -ExpectedSourceEpoch $epochValue ` -AllowDirtyPackage ` - -RequireReleaseEligible 2>&1) + 2>&1) $earlyDiagnosticExit = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorPreference @@ -1272,6 +1325,7 @@ with open("filter_expected.bin", "rb") as expected: 'release_zip_safety.ps1', 'release_dependency_evidence.ps1', 'release_git_trust.ps1', + 'release_ota_selector_policy.ps1', 'platformio_resolver.ps1' )) { $preGateMarker = Join-Path ([System.IO.Path]::GetTempPath()) ( @@ -1284,13 +1338,8 @@ with open("filter_expected.bin", "rb") as expected: $previousErrorPreference = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' - $dirtyHelperOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -File (Join-Path $epochTools 'verify_release_package.ps1') ` - -Version 'dirty-helper-contract' ` - -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` - -ExpectedCommit $epochCommit ` - -ExpectedSourceEpoch $epochValue ` - -RequireReleaseEligible 2>&1) + $dirtyHelperOutput = @(& powershell.exe @gateBaseArgs ` + -ExpectedSourceEpoch $epochValue 2>&1) $dirtyHelperExit = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorPreference @@ -1338,13 +1387,8 @@ with open("filter_expected.bin", "rb") as expected: $previousErrorPreference = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' - $hiddenOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -File (Join-Path $epochTools 'verify_release_package.ps1') ` - -Version 'hidden-helper-contract' ` - -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` - -ExpectedCommit $epochCommit ` - -ExpectedSourceEpoch $epochValue ` - -RequireReleaseEligible 2>&1) + $hiddenOutput = @(& powershell.exe @gateBaseArgs ` + -ExpectedSourceEpoch $epochValue 2>&1) $hiddenExit = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorPreference @@ -1384,13 +1428,8 @@ with open("filter_expected.bin", "rb") as expected: $previousErrorPreference = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' - $filteredOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -File (Join-Path $epochTools 'verify_release_package.ps1') ` - -Version 'custom-filter-helper-contract' ` - -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` - -ExpectedCommit $epochCommit ` - -ExpectedSourceEpoch $epochValue ` - -RequireReleaseEligible 2>&1) + $filteredOutput = @(& powershell.exe @gateBaseArgs ` + -ExpectedSourceEpoch $epochValue 2>&1) $filteredExit = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorPreference @@ -1421,13 +1460,8 @@ with open("filter_expected.bin", "rb") as expected: $previousErrorPreference = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' - $infoAttributesOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -File (Join-Path $epochTools 'verify_release_package.ps1') ` - -Version 'info-attributes-helper-contract' ` - -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` - -ExpectedCommit $epochCommit ` - -ExpectedSourceEpoch $epochValue ` - -RequireReleaseEligible 2>&1) + $infoAttributesOutput = @(& powershell.exe @gateBaseArgs ` + -ExpectedSourceEpoch $epochValue 2>&1) $infoAttributesExit = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorPreference @@ -1448,6 +1482,8 @@ with open("filter_expected.bin", "rb") as expected: New-Item -ItemType Directory -Path $nestedVerifierRoot -Force | Out-Null Copy-Item -LiteralPath (Join-Path $epochTools 'verify_release_package.ps1') ` -Destination $nestedVerifierRoot + $nestedHarnessPath = Join-Path $nestedVerifierRoot 'release_checkout_gate_harness.ps1' + Copy-Item -LiteralPath $gateHarnessPath -Destination $nestedHarnessPath $nestedMarker = Join-Path $epochTrustRoot 'NESTED_HELPER_EXECUTED.txt' $nestedMarkerLiteral = $nestedMarker.Replace("'", "''") ("[System.IO.File]::WriteAllText('$nestedMarkerLiteral', 'executed')`r`n" + @@ -1457,12 +1493,11 @@ with open("filter_expected.bin", "rb") as expected: try { $ErrorActionPreference = 'Continue' $nestedOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -File (Join-Path $nestedVerifierRoot 'verify_release_package.ps1') ` - -Version 'nested-ignored-contract' ` - -PackageRoot (Join-Path $epochTrustRoot 'missing-package') ` + -File $nestedHarnessPath ` -ExpectedCommit $epochCommit ` + -GitExecutable $fixtureGitExecutable ` -ExpectedSourceEpoch $epochValue ` - -RequireReleaseEligible 2>&1) + 2>&1) $nestedExit = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorPreference @@ -1481,8 +1516,6 @@ with open("filter_expected.bin", "rb") as expected: [System.IO.Directory]::Delete($epochTrustRoot, $true) } } -} - $zipContractRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( 'stackchan-verifier-zip-contract-' + [guid]::NewGuid().ToString('N')) try { diff --git a/tools/test_release_source_binding_contract.ps1 b/tools/test_release_source_binding_contract.ps1 index 6381ca18..d556ae2d 100644 --- a/tools/test_release_source_binding_contract.ps1 +++ b/tools/test_release_source_binding_contract.ps1 @@ -176,9 +176,15 @@ foreach ($required in @( if ($packageText.Contains('Join-Path $repoRoot ([string]$asset.source_path)')) { throw 'Persona WAV packaging still reads from the mutable main checkout' } -$sourceCleanupStart = $packageText.IndexOf('function Remove-ReleaseSourceWorktree') -$sourceCleanupEnd = $packageText.IndexOf('trap {', $sourceCleanupStart) -$sourceCleanupText = $packageText.Substring($sourceCleanupStart, $sourceCleanupEnd - $sourceCleanupStart) +$sourceCleanupFunctions = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Remove-ReleaseSourceWorktree' +}, $true)) +if ($sourceCleanupFunctions.Count -ne 1) { + throw 'Commit-bound package source cleanup function is missing or ambiguous.' +} +$sourceCleanupText = $sourceCleanupFunctions[0].Extent.Text if (-not $sourceCleanupText.Contains('full-failed-worktree-retained-attached') -or -not $sourceCleanupText.Contains('is a package failure; the full worktree remains attached') -or -not $sourceCleanupText.Contains('source root is missing while its worktree is registered') -or diff --git a/tools/test_release_toolchain_cache_contract.ps1 b/tools/test_release_toolchain_cache_contract.ps1 new file mode 100644 index 00000000..565ff2b6 --- /dev/null +++ b/tools/test_release_toolchain_cache_contract.ps1 @@ -0,0 +1,302 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +. (Join-Path $PSScriptRoot 'release_toolchain_identity.ps1') + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +function Assert-Throws { + param([scriptblock]$Action, [string]$Pattern) + try { + & $Action + } catch { + if ([string]$_.Exception.Message -notmatch $Pattern) { + throw "Expected failure matching '$Pattern', got: $($_.Exception.Message)" + } + return + } + throw "Expected failure matching '$Pattern', but the action succeeded." +} + +function Copy-FixtureComponents { + param([object[]]$Components) + return @(Copy-StackchanToolchainIdentityComponents -Components $Components) +} + +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ( + 'stackchan-toolchain-cache-contract-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $testRoot | Out-Null +$savedPythonPath = [Environment]::GetEnvironmentVariable( + 'STACKCHAN_CACHE_TEST_PYTHON_POISON', [EnvironmentVariableTarget]::Process) +try { + $pythonHome = Join-Path $testRoot 'python' + $gitHome = Join-Path $testRoot 'git' + $legacyCore = Join-Path $testRoot 'legacy-core' + $releaseCore = Join-Path $testRoot 'release-core' + $projectRoot = Join-Path $testRoot 'project' + $libdepsRoot = Join-Path $testRoot 'libdeps' + foreach ($directory in @( + $pythonHome, (Join-Path $pythonHome 'Scripts'), $gitHome, + (Join-Path $gitHome 'cmd'), $legacyCore, $releaseCore, + $projectRoot, $libdepsRoot)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + $python = Join-Path $pythonHome 'python.exe' + $pio = Join-Path $pythonHome 'Scripts/pio.exe' + $git = Join-Path $gitHome 'cmd/git.exe' + [IO.File]::WriteAllText($python, 'fixture-python') + [IO.File]::WriteAllText($pio, 'fixture-pio') + [IO.File]::WriteAllText($git, 'fixture-git') + $rootMap = @{ + pythonHome = $pythonHome + gitHome = $gitHome + legacyCore = $legacyCore + releaseCore = $releaseCore + projectRoot = $projectRoot + libdepsRoot = $libdepsRoot + } + + $script:fixturePreCalls = 0 + $script:fixturePostCalls = 0 + $script:fixturePreRecord = [pscustomobject][ordered]@{ + name = 'python-installation'; phase = 'preBuild' + identitySchema = 'stackchan.byte-tree.v1'; treeSha256 = ('A' * 64) + fileCount = 1; bytes = 10 + } + $script:fixturePostRecord = [pscustomobject][ordered]@{ + name = 'project-libdeps-stackchan'; phase = 'postBuild' + identitySchema = 'stackchan.canonical-libdeps.v1'; treeSha256 = ('B' * 64) + fileCount = 2; bytes = 20 + } + + function Get-StackchanReleaseToolchainObservedComponents { + param( + [hashtable]$RootMap, [string]$Phase, [string]$Environment, + [string]$PythonExecutable, [string]$PlatformKey, $LeaseState, + [string]$LeaseScope, [switch]$PostBuildComponentsOnly) + if ($Phase -ceq 'PreBuild') { + if ($PostBuildComponentsOnly) { throw 'PreBuild cannot be post-only.' } + $script:fixturePreCalls++ + return @($script:fixturePreRecord) + } + if (-not $PostBuildComponentsOnly) { + throw 'Guarded PostBuild did not request only fresh dependency components.' + } + $script:fixturePostCalls++ + return @($script:fixturePostRecord) + } + + function Assert-StackchanPythonImportIsolation { + param([string]$PythonHome, [string]$PythonExecutable) + if (-not [string]::IsNullOrWhiteSpace( + [Environment]::GetEnvironmentVariable( + 'STACKCHAN_CACHE_TEST_PYTHON_POISON', [EnvironmentVariableTarget]::Process))) { + throw 'mock Python isolation rejected ambient state' + } + } + + $allowlistPath = Join-Path $testRoot 'allowlist.json' + [ordered]@{ + schema = 'stackchan.release-toolchain-identity.v3' + platformKey = 'windows_amd64' + platformioCoreVersion = '6.1.19' + pythonVersion = '3.12.10' + identityScope = 'exact-host-installed-bytes' + portableAcrossHosts = $false + canonicalLibdepsSchema = 'stackchan.canonical-libdeps.v1' + platformioExecutableRelativePaths = @('Scripts/pio.exe', 'Scripts/platformio.exe') + pythonExecutableRelativePath = 'python.exe' + gitExecutableRelativePath = 'cmd/git.exe' + review = [ordered]@{ status = 'reviewed'; reviewer = 'fixture'; reason = 'cache contract' } + components = @($script:fixturePreRecord, $script:fixturePostRecord) + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $allowlistPath -Encoding UTF8 + + $common = @{ + AllowlistPath = $allowlistPath + RootMap = $rootMap + PlatformioExecutable = $pio + PythonExecutable = $python + GitExecutable = $git + PlatformKey = 'windows_amd64' + } + + $beforePreState = New-StackchanToolchainLeaseState + try { + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @common -Phase PostBuild ` + -Environment stackchan -LeaseState $beforePreState -LeaseScope cycle-a + } 'requires the same session to verify PreBuild first' + } finally { + Close-StackchanToolchainLeaseState -LeaseState $beforePreState + } + + $state = New-StackchanToolchainLeaseState + $preResult = Assert-StackchanReleaseToolchainIdentity @common -Phase PreBuild ` + -LeaseState $state -LeaseScope pre-build + Assert-True ($preResult.componentCount -eq 1 -and $script:fixturePreCalls -eq 1) ` + 'Guarded PreBuild fixture did not authenticate exactly once.' + Assert-True ([bool]$state.preBuildVerified -and + @($state.preBuildComponents).Count -eq 1 -and + [string]$state.preBuildScope -ceq 'pre-build' -and + [string]$state.preBuildAuthorityKey -match '^[0-9A-F]{64}$') ` + 'Guarded PreBuild did not establish a complete cache binding.' + + $script:fixturePreRecord.treeSha256 = ('C' * 64) + $postResult = Assert-StackchanReleaseToolchainIdentity @common -Phase PostBuild ` + -Environment stackchan -LeaseState $state -LeaseScope cycle-a + Assert-True ($postResult.componentCount -eq 2 -and + $script:fixturePreCalls -eq 1 -and $script:fixturePostCalls -eq 1) ` + 'Guarded PostBuild rehashed PreBuild or omitted its fresh dependency record.' + $script:fixturePreRecord.treeSha256 = ('A' * 64) + $expectedObservationText = @( + "project-libdeps-stackchan`0postBuild`0stackchan.canonical-libdeps.v1`0$('B' * 64)`02`020", + "python-installation`0preBuild`0stackchan.byte-tree.v1`0$('A' * 64)`01`010" + ) -join "`n" + $expectedObservationText += "`n" + $observationHasher = [Security.Cryptography.SHA256]::Create() + try { + $expectedObservation = ([BitConverter]::ToString($observationHasher.ComputeHash( + [Text.Encoding]::UTF8.GetBytes($expectedObservationText))) -replace '-', '').ToUpperInvariant() + } finally { + $observationHasher.Dispose() + } + Assert-True ([string]$postResult.observationSha256 -ceq $expectedObservation) ` + 'Cached plus fresh observation changed the established manifest semantics.' + + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @common -Phase PreBuild ` + -LeaseState $state -LeaseScope pre-build + } 'verify PreBuild only once' + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @common -Phase PostBuild ` + -Environment stackchan -LeaseState $state -LeaseScope pre-build + } 'scope distinct' + + $postCallsBeforeAuthorityMismatch = $script:fixturePostCalls + $alternateRoot = Join-Path $testRoot 'alternate-release-core' + New-Item -ItemType Directory -Path $alternateRoot | Out-Null + $alternateRootMap = $rootMap.Clone() + $alternateRootMap.releaseCore = $alternateRoot + $alternateCommon = $common.Clone() + $alternateCommon.RootMap = $alternateRootMap + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @alternateCommon -Phase PostBuild ` + -Environment stackchan -LeaseState $state -LeaseScope cycle-a + } 'authority differs' + Assert-True ($script:fixturePostCalls -eq $postCallsBeforeAuthorityMismatch) ` + 'Alternate authority reached fresh dependency scanning before rejection.' + $alternatePio = Join-Path $pythonHome 'Scripts/platformio.exe' + [IO.File]::WriteAllText($alternatePio, 'fixture-platformio') + $alternateExecutableCommon = $common.Clone() + $alternateExecutableCommon.PlatformioExecutable = $alternatePio + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @alternateExecutableCommon -Phase PostBuild ` + -Environment stackchan -LeaseState $state -LeaseScope cycle-a + } 'authority differs' + Assert-True ($script:fixturePostCalls -eq $postCallsBeforeAuthorityMismatch) ` + 'Alternate executable reached fresh dependency scanning before rejection.' + + Close-StackchanToolchainLeaseScope -LeaseState $state -Scope cycle-a + Assert-True ([bool]$state.preBuildVerified -and + @($state.preBuildComponents).Count -eq 1) ` + 'Closing a PostBuild scope invalidated the verified PreBuild cache.' + [void](Assert-StackchanReleaseToolchainIdentity @common -Phase PostBuild ` + -Environment stackchan -LeaseState $state -LeaseScope cycle-b) + + $goodCache = @(Copy-FixtureComponents -Components @($state.preBuildComponents)) + $state.preBuildComponents = @() + $postCallsBeforeCacheMutation = $script:fixturePostCalls + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @common -Phase PostBuild ` + -Environment stackchan -LeaseState $state -LeaseScope cycle-a + } 'no verified PreBuild component cache' + Assert-True ($script:fixturePostCalls -eq $postCallsBeforeCacheMutation) ` + 'Empty PreBuild cache reached fresh dependency scanning.' + $state.preBuildComponents = @(Copy-FixtureComponents -Components $goodCache) + $state.preBuildComponents[0].treeSha256 = ('D' * 64) + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @common -Phase PostBuild ` + -Environment stackchan -LeaseState $state -LeaseScope cycle-a + } 'byte identity mismatch' + Assert-True ($script:fixturePostCalls -eq $postCallsBeforeCacheMutation) ` + 'Hash-corrupt PreBuild cache reached fresh dependency scanning.' + $state.preBuildComponents = @( + (Copy-FixtureComponents -Components $goodCache)[0], + (Copy-FixtureComponents -Components $goodCache)[0]) + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @common -Phase PostBuild ` + -Environment stackchan -LeaseState $state -LeaseScope cycle-a + } 'component count mismatch' + Assert-True ($script:fixturePostCalls -eq $postCallsBeforeCacheMutation) ` + 'Duplicate PreBuild cache reached fresh dependency scanning.' + $state.preBuildComponents = @(Copy-FixtureComponents -Components $goodCache) + + $script:fixturePostRecord.treeSha256 = ('E' * 64) + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @common -Phase PostBuild ` + -Environment stackchan -LeaseState $state -LeaseScope cycle-a + } 'byte identity mismatch' + $script:fixturePostRecord.treeSha256 = ('B' * 64) + + [Environment]::SetEnvironmentVariable( + 'STACKCHAN_CACHE_TEST_PYTHON_POISON', '1', [EnvironmentVariableTarget]::Process) + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @common -Phase PostBuild ` + -Environment stackchan -LeaseState $state -LeaseScope cycle-a + } 'mock Python isolation rejected ambient state' + [Environment]::SetEnvironmentVariable( + 'STACKCHAN_CACHE_TEST_PYTHON_POISON', $null, [EnvironmentVariableTarget]::Process) + + $poisonRoot = Join-Path $testRoot 'poison-root' + New-Item -ItemType Directory -Path $poisonRoot | Out-Null + [IO.File]::WriteAllText((Join-Path $poisonRoot 'input.txt'), 'reviewed') + Protect-StackchanToolchainTree -LeaseState $state -Root $poisonRoot -Scope pre-build + $transient = Join-Path $poisonRoot 'transient.py' + [IO.File]::WriteAllText($transient, 'unreviewed') + Remove-Item -LiteralPath $transient -Force + Start-Sleep -Milliseconds 250 + $postCallsBeforePoison = $script:fixturePostCalls + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @common -Phase PostBuild ` + -Environment stackchan -LeaseState $state -LeaseScope cycle-a + } 'changed after authentication' + Assert-True ($script:fixturePostCalls -eq $postCallsBeforePoison) ` + 'Poisoned cache session reached fresh dependency scanning.' + Close-StackchanToolchainLeaseState -LeaseState $state + Assert-True (-not [bool]$state.preBuildVerified -and + @($state.preBuildComponents).Count -eq 0 -and [bool]$state.closed) ` + 'Closing a lease state did not invalidate its PreBuild cache.' + + $scopeState = New-StackchanToolchainLeaseState + [void](Assert-StackchanReleaseToolchainIdentity @common -Phase PreBuild ` + -LeaseState $scopeState -LeaseScope pre-build) + Close-StackchanToolchainLeaseScope -LeaseState $scopeState -Scope pre-build + Assert-True (-not [bool]$scopeState.preBuildVerified -and + @($scopeState.preBuildComponents).Count -eq 0) ` + 'Closing the PreBuild scope did not invalidate its cache.' + Assert-Throws { + Assert-StackchanReleaseToolchainIdentity @common -Phase PostBuild ` + -Environment stackchan -LeaseState $scopeState -LeaseScope cycle-a + } 'requires the same session to verify PreBuild first' + Close-StackchanToolchainLeaseState -LeaseState $scopeState + + [pscustomobject][ordered]@{ + schema = 'stackchan.release-toolchain-cache-contract.v1' + status = 'pass' + preBuildScans = $script:fixturePreCalls + freshPostBuildScans = $script:fixturePostCalls + } | ConvertTo-Json -Compress +} finally { + [Environment]::SetEnvironmentVariable( + 'STACKCHAN_CACHE_TEST_PYTHON_POISON', $savedPythonPath, + [EnvironmentVariableTarget]::Process) + $resolved = [IO.Path]::GetFullPath($testRoot) + $temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\', '/') + if ($resolved.StartsWith($temp + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) -and + (Split-Path -Leaf $resolved) -like 'stackchan-toolchain-cache-contract-*') { + Remove-Item -LiteralPath $resolved -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/tools/test_release_toolchain_documentation_contract.ps1 b/tools/test_release_toolchain_documentation_contract.ps1 new file mode 100644 index 00000000..3d9b7f98 --- /dev/null +++ b/tools/test_release_toolchain_documentation_contract.ps1 @@ -0,0 +1,129 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$authorityNames = @( + 'ToolchainAllowlistPath', 'GitExecutable', 'PythonExecutable', + 'PlatformioExecutable', 'LegacyCoreDir', 'ReleaseCoreDir') +$authorizingTools = @( + 'package_release', 'verify_release_package', 'run_device_preflight', + 'flash_release_firmware', 'start_hardware_evidence', 'prepare_device_arrival', + 'start_bridge_ai_supervised_qualification', 'verify_consumer_promotion', + 'publish_release', 'audit_published_release', 'verify_published_release', + 'share_release', 'export_rollout_status') +$operatorDocs = @( + 'README.md', 'docs/RELEASE_PROCESS.md', 'docs/RELEASE_QUICKSTART.md', + 'docs/ARRIVAL_DAY_RUNBOOK.md', 'docs/DEVICE_BRINGUP.md', + 'docs/ROLLOUT_CHECKLIST.md', 'docs/BRIDGE_AI_QUALIFICATION.md', + 'docs/COMPANION_APP_GAP_ANALYSIS.md') + +function Require-Text { + param([string]$Text, [string]$Needle, [string]$Message) + if (-not $Text.Contains($Needle)) { throw $Message } +} + +$toolAlternation = ($authorizingTools | ForEach-Object { [regex]::Escape($_) }) -join '|' +$toolInvocationPattern = '(?im)(?:^|[\\/])(?:' + $toolAlternation + ')\.(?:cmd|ps1)' +$cmdInvocationPattern = '(?im)(?:^|[\\/])(?:' + $toolAlternation + ')\.cmd' +foreach ($relative in $operatorDocs) { + $text = Get-Content -LiteralPath (Join-Path $repoRoot $relative) -Raw + if ($text -notmatch '(?i)archive\s+does\s+not\s+confer\s+release\s+authority') { + throw "Operator document does not state the archive authority boundary: $relative" + } + if ($text -match ('(?is)(from inside|inside).{0,80}extracted.{0,240}(?:' + + $toolAlternation + ')\.(?:cmd|ps1)')) { + throw "Operator document tells users to authorize from an extracted archive: $relative" + } + + foreach ($match in [regex]::Matches($text, '(?ms)```powershell\s*(.*?)\s*```')) { + $block = [string]$match.Groups[1].Value + if ($block -notmatch $toolInvocationPattern) { continue } + $isNoPackagePreflightSelfTest = $block.Trim() -ceq '.\tools\run_device_preflight.cmd' + if ($block -match $cmdInvocationPattern -and -not $isNoPackagePreflightSelfTest) { + throw "Authorizing documentation uses a transparent CMD wrapper instead of the auditable PowerShell splat: $relative" + } + $hasSplat = $block.Contains('@releaseToolchain') + $hasAllLiteralAuthority = @($authorityNames | Where-Object { -not $block.Contains('-' + $_) }).Count -eq 0 + if (-not $isNoPackagePreflightSelfTest -and + -not $hasSplat -and -not $hasAllLiteralAuthority) { + throw "Authorizing PowerShell block omits exact-host authority: $relative" + } + } +} + +$packageText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'package_release.ps1') -Raw +foreach ($needle in @( + 'nextOperatorCommand = $null', 'nextOperatorGuidance', + 'The archive does not confer release authority', + 'completed governed release package')) { + Require-Text $packageText $needle "Package handoff is missing non-authorizing guidance: $needle" +} +if ($packageText.Contains('.\tools\prepare_device_arrival.cmd -Port COM3')) { + throw 'Package readiness output still emits an extracted-package arrival command.' +} + +$shareText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'share_release.ps1') -Raw +foreach ($needle in @( + 'nextCommand = $null', 'No command is emitted by the shared archive', + 'shared archive does not confer release authority', + 'source-side tools/prepare_device_arrival.ps1')) { + Require-Text $shareText $needle "Share handoff is missing the trusted-source boundary: $needle" +} +if ($shareText.Contains('run this from inside the extracted folder') -or + $shareText.Contains('.\tools\prepare_device_arrival.cmd -Port COM3')) { + throw 'Share page still emits an extracted-package arrival command.' +} + +$rolloutText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'export_rollout_status.ps1') -Raw +foreach ($needle in @( + 'New-RolloutTrustedSourceCommand', 'package_release.ps1', + 'start_hardware_evidence.ps1', 'verify_consumer_promotion.ps1')) { + Require-Text $rolloutText $needle "Rollout report lacks a trusted-source command path: $needle" +} + +$hardwareEvidenceText = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'start_hardware_evidence.ps1') -Raw +foreach ($checklistAuthorityItem in @( + '`tools/verify_release_package.ps1 -RequireReleaseEligible ... @releaseToolchain` passes for the release ZIP.', + '`tools/flash_release_firmware.ps1 -PackageZip -Firmware display_only -DryRun -Monitor @releaseToolchain` passes for the release ZIP.', + 'Hardware evidence packet created with `tools/start_hardware_evidence.ps1 ... @releaseToolchain`.')) { + Require-Text $hardwareEvidenceText $checklistAuthorityItem ` + "Hardware evidence checklist marking is stale: $checklistAuthorityItem" +} +$preflightText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'run_device_preflight.ps1') -Raw +Require-Text $preflightText ` + '- [x] `tools/verify_release_package.ps1 -RequireReleaseEligible ... @releaseToolchain` passes for the release ZIP.' ` + 'Preflight scaffold assertion does not match the authority-bound rollout checklist.' +foreach ($authorityName in $authorityNames) { + Require-Text $rolloutText ("-$authorityName ") ` + "Rollout report does not serialize $authorityName into generated commands." +} +foreach ($retired in @( + '.\tools\package_release.cmd -Version', + '.\tools\start_hardware_evidence.cmd -ReleaseTag', + '.\tools\verify_consumer_promotion.cmd -Version')) { + if ($rolloutText.Contains($retired)) { + throw "Rollout report retains an authority-less generated command: $retired" + } +} + +$actionsText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'export_github_actions_status.ps1') -Raw +if ($actionsText.Contains('.\tools\audit_published_release.cmd -Version')) { + throw 'GitHub status output still emits an authority-less release audit command.' +} +$companionText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'check_companion_v1_readiness.ps1') -Raw +foreach ($needle in @('verify_published_release.ps1 -Version @releaseToolchain', + 'downloaded archive does not confer release authority')) { + Require-Text $companionText $needle "Companion handoff is missing trusted-source guidance: $needle" +} +$syntheticText = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'generate_synthetic_hardware_evidence.ps1') -Raw +foreach ($needle in @('Diagnostic-only synthetic packet', + 'six exact-host toolchain authorities', 'archive does not confer release authority')) { + Require-Text $syntheticText $needle "Synthetic fixture does not refuse operational rollout: $needle" +} +if ($syntheticText -match '\$rolloutStatusCommand\s*=.*export_rollout_status\.ps1') { + throw 'Synthetic evidence still generates an authority-less operational rollout command.' +} + +Write-Output 'Release toolchain documentation contract passed.' diff --git a/tools/test_release_toolchain_identity_contract.ps1 b/tools/test_release_toolchain_identity_contract.ps1 index 0a38a8d9..31f30503 100644 --- a/tools/test_release_toolchain_identity_contract.ps1 +++ b/tools/test_release_toolchain_identity_contract.ps1 @@ -58,6 +58,401 @@ try { Assert-True ($firstIdentity.fileCount -eq 2 -and $firstIdentity.bytes -eq 9) ` 'Tree identity count/size accounting is incorrect.' + $leaseRoot = Join-Path $testRoot 'lifetime-lease' + New-Item -ItemType Directory -Path $leaseRoot | Out-Null + $leaseFile = Join-Path $leaseRoot 'leased.txt' + [IO.File]::WriteAllText($leaseFile, 'reviewed') + $leaseState = New-StackchanToolchainLeaseState + try { + $leaseIdentity = Get-StackchanToolchainTreeIdentity ` + -Root $leaseRoot -LeaseState $leaseState -LeaseScope fixture + $leaseHandleCount = $leaseState.streams.Count + $repeatLeaseIdentity = Get-StackchanToolchainTreeIdentity ` + -Root $leaseRoot -LeaseState $leaseState -LeaseScope fixture + Assert-True ($repeatLeaseIdentity.treeSha256 -ceq $leaseIdentity.treeSha256 -and + $leaseState.streams.Count -eq $leaseHandleCount) ` + 'Repeated guarded identity grew the retained-handle set or changed identity.' + Assert-True ($leaseState.watchers.Count -eq 1) ` + 'Guarded identity did not retain exactly one watcher for one component root.' + if ($env:OS -eq 'Windows_NT') { + Assert-Throws { [IO.File]::WriteAllText($leaseFile, 'unreviewed') } ` + '(used by another process|cannot access|being used)' + Assert-Throws { + Move-Item -LiteralPath $leaseFile -Destination (Join-Path $leaseRoot 'renamed.txt') + } '(used by another process|cannot access|being used)' + Assert-Throws { Remove-Item -LiteralPath $leaseFile -Force } ` + '(used by another process|cannot access|being used)' + } + $transientPath = Join-Path $leaseRoot 'transient-injection.py' + [IO.File]::WriteAllText($transientPath, 'unreviewed') + Remove-Item -LiteralPath $transientPath -Force + Start-Sleep -Milliseconds 250 + Assert-Throws { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $leaseState -Context 'transient injection fixture' -VerifyNamespace + } 'changed after authentication' + $transientEvidence = @($leaseState.violationEvidence) + Assert-True ($transientEvidence.Count -gt 0 -and + @($transientEvidence | Where-Object { + [string]$_.schema -cne 'stackchan.toolchain-watcher-event.v1' -or + [string]::IsNullOrWhiteSpace([string]$_.timeGeneratedUtc) -or + [string]::IsNullOrWhiteSpace([string]$_.observedUtc) -or + [string]::IsNullOrWhiteSpace([string]$_.watcherRoot) -or + $null -eq $_.pathMetadata + }).Count -eq 0) ` + 'Transient watcher failure did not retain structured event evidence.' + } finally { + Close-StackchanToolchainLeaseState -LeaseState $leaseState + } + [IO.File]::WriteAllText($leaseFile, 'released') + Assert-True ([IO.File]::ReadAllText($leaseFile) -ceq 'released') ` + 'Closing the guard did not release its retained file handles.' + + $subscriberRoot = Join-Path $testRoot 'missing-subscriber' + New-Item -ItemType Directory -Path $subscriberRoot | Out-Null + [IO.File]::WriteAllText((Join-Path $subscriberRoot 'input.txt'), 'reviewed') + $subscriberState = New-StackchanToolchainLeaseState + try { + [void](Get-StackchanToolchainTreeIdentity ` + -Root $subscriberRoot -LeaseState $subscriberState -LeaseScope fixture) + $subscriberWatcher = @($subscriberState.watchers.Values)[0] + $removedSourceIdentifier = [string]$subscriberWatcher.sourceIdentifiers[0] + Microsoft.PowerShell.Utility\Unregister-Event -SourceIdentifier $removedSourceIdentifier + Assert-Throws { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $subscriberState -Context 'missing subscriber fixture' + } 'watcher subscription missing' + } finally { + Close-StackchanToolchainLeaseState -LeaseState $subscriberState + } + + $overflowRoot = Join-Path $testRoot 'overflow-event' + New-Item -ItemType Directory -Path $overflowRoot | Out-Null + [IO.File]::WriteAllText((Join-Path $overflowRoot 'input.txt'), 'reviewed') + $overflowState = New-StackchanToolchainLeaseState + try { + [void](Get-StackchanToolchainTreeIdentity ` + -Root $overflowRoot -LeaseState $overflowState -LeaseScope fixture) + $overflowWatcher = @($overflowState.watchers.Values)[0] + $errorSourceIdentifier = @($overflowWatcher.sourceIdentifiers | Where-Object { $_ -like '*-Error' })[0] + [void](Microsoft.PowerShell.Utility\New-Event ` + -SourceIdentifier $errorSourceIdentifier ` + -EventArguments @($overflowWatcher.watcher, + [IO.ErrorEventArgs]::new([IO.InternalBufferOverflowException]::new('synthetic overflow')))) + Assert-Throws { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $overflowState -Context 'overflow fixture' + } 'changed after authentication' + $overflowEvidence = @($overflowState.violationEvidence) + Assert-True ($overflowEvidence.Count -eq 1 -and + [string]$overflowEvidence[0].registeredEventName -ceq 'Error' -and + [string]$overflowEvidence[0].errorType -match 'InternalBufferOverflowException' -and + [string]$overflowEvidence[0].errorMessage -match 'synthetic overflow') ` + 'Watcher overflow did not retain its typed error evidence.' + } finally { + Close-StackchanToolchainLeaseState -LeaseState $overflowState + } + + $queuedRoot = Join-Path $testRoot 'queued-events' + New-Item -ItemType Directory -Path $queuedRoot | Out-Null + [IO.File]::WriteAllText((Join-Path $queuedRoot 'input.txt'), 'reviewed') + $queuedState = New-StackchanToolchainLeaseState + try { + [void](Get-StackchanToolchainTreeIdentity ` + -Root $queuedRoot -LeaseState $queuedState -LeaseScope fixture) + $queuedWatcher = @($queuedState.watchers.Values)[0] + $changedSource = @($queuedWatcher.sourceIdentifiers | Where-Object { $_ -like '*-Changed' })[0] + $createdSource = @($queuedWatcher.sourceIdentifiers | Where-Object { $_ -like '*-Created' })[0] + $deletedSource = @($queuedWatcher.sourceIdentifiers | Where-Object { $_ -like '*-Deleted' })[0] + $renamedSource = @($queuedWatcher.sourceIdentifiers | Where-Object { $_ -like '*-Renamed' })[0] + [void](Microsoft.PowerShell.Utility\New-Event -SourceIdentifier $changedSource ` + -EventArguments @($queuedWatcher.watcher, + [IO.FileSystemEventArgs]::new([IO.WatcherChangeTypes]::Changed, $queuedRoot, 'input.txt'))) + [void](Microsoft.PowerShell.Utility\New-Event -SourceIdentifier $createdSource ` + -EventArguments @($queuedWatcher.watcher, + [IO.FileSystemEventArgs]::new([IO.WatcherChangeTypes]::Created, $queuedRoot, 'new.txt'))) + [void](Microsoft.PowerShell.Utility\New-Event -SourceIdentifier $deletedSource ` + -EventArguments @($queuedWatcher.watcher, + [IO.FileSystemEventArgs]::new([IO.WatcherChangeTypes]::Deleted, $queuedRoot, 'gone.txt'))) + [void](Microsoft.PowerShell.Utility\New-Event -SourceIdentifier $renamedSource ` + -EventArguments @($queuedWatcher.watcher, + [IO.RenamedEventArgs]::new([IO.WatcherChangeTypes]::Renamed, $queuedRoot, + 'new-name.txt', 'old-name.txt'))) + Assert-Throws { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $queuedState -Context 'complete queued event fixture' + } 'events=4' + $queuedEvidence = @($queuedState.violationEvidence) + $queuedNames = @($queuedEvidence.registeredEventName | Sort-Object) + Assert-True ($queuedEvidence.Count -eq 4 -and + ($queuedNames -join ',') -ceq 'Changed,Created,Deleted,Renamed' -and + @($queuedEvidence.ordinal) -join ',' -ceq '0,1,2,3' -and + @($queuedEvidence | Where-Object { -not [bool]$_.queueRemovalSucceeded }).Count -eq 0 -and + @($queuedEvidence | Where-Object { + [string]$_.registeredEventName -ceq 'Renamed' -and + [string]$_.oldName -ceq 'old-name.txt' -and + [string]$_.oldFullPath -ceq (Join-Path $queuedRoot 'old-name.txt') + }).Count -eq 1) ` + 'Watcher drain did not preserve every queued event and rename field.' + $evidenceCountBeforeRepeatDrain = $queuedState.violationEvidence.Count + $repeatDrainCount = Add-StackchanToolchainQueuedEventEvidence -LeaseState $queuedState + Assert-True ($repeatDrainCount -eq 0 -and + $queuedState.violationEvidence.Count -eq $evidenceCountBeforeRepeatDrain) ` + 'Repeated watcher drain duplicated retained event evidence.' + } finally { + Close-StackchanToolchainLeaseState -LeaseState $queuedState + } + + $orderedRootA = Join-Path $testRoot 'globally-ordered-a' + $orderedRootB = Join-Path $testRoot 'globally-ordered-b' + New-Item -ItemType Directory -Path $orderedRootA, $orderedRootB | Out-Null + [IO.File]::WriteAllText((Join-Path $orderedRootA 'input.txt'), 'reviewed') + [IO.File]::WriteAllText((Join-Path $orderedRootB 'input.txt'), 'reviewed') + $orderedState = New-StackchanToolchainLeaseState + try { + [void](Get-StackchanToolchainTreeIdentity ` + -Root $orderedRootA -LeaseState $orderedState -LeaseScope fixture) + [void](Get-StackchanToolchainTreeIdentity ` + -Root $orderedRootB -LeaseState $orderedState -LeaseScope fixture) + $orderedWatcherA = $orderedState.watchers[[IO.Path]::GetFullPath($orderedRootA)] + $orderedWatcherB = $orderedState.watchers[[IO.Path]::GetFullPath($orderedRootB)] + $orderedChangedA = @($orderedWatcherA.sourceIdentifiers | Where-Object { + $_ -like '*-Changed' })[0] + $orderedChangedB = @($orderedWatcherB.sourceIdentifiers | Where-Object { + $_ -like '*-Changed' })[0] + [void](Microsoft.PowerShell.Utility\New-Event ` + -SourceIdentifier $orderedChangedB ` + -EventArguments @($orderedWatcherB.watcher, + [IO.FileSystemEventArgs]::new( + [IO.WatcherChangeTypes]::Changed, $orderedRootB, 'input.txt'))) + Start-Sleep -Milliseconds 100 + [void](Microsoft.PowerShell.Utility\New-Event ` + -SourceIdentifier $orderedChangedA ` + -EventArguments @($orderedWatcherA.watcher, + [IO.FileSystemEventArgs]::new( + [IO.WatcherChangeTypes]::Changed, $orderedRootA, 'input.txt'))) + Assert-Throws { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $orderedState -Context 'global chronological fixture' + } 'events=2' + $orderedEvidence = @($orderedState.violationEvidence) + Assert-True ($orderedEvidence.Count -eq 2 -and + [string]$orderedEvidence[0].watcherRoot -ceq [IO.Path]::GetFullPath($orderedRootB) -and + [string]$orderedEvidence[1].watcherRoot -ceq [IO.Path]::GetFullPath($orderedRootA) -and + [datetime]$orderedEvidence[0].timeGeneratedUtc -lt + [datetime]$orderedEvidence[1].timeGeneratedUtc) ` + 'Watcher evidence was not ordered chronologically across guarded roots.' + } finally { + Close-StackchanToolchainLeaseState -LeaseState $orderedState + } + + $crossBatchRootA = Join-Path $testRoot 'cross-batch-a' + $crossBatchRootB = Join-Path $testRoot 'cross-batch-b' + New-Item -ItemType Directory -Path $crossBatchRootA, $crossBatchRootB | Out-Null + [IO.File]::WriteAllText((Join-Path $crossBatchRootA 'input.txt'), 'reviewed') + [IO.File]::WriteAllText((Join-Path $crossBatchRootB 'input.txt'), 'reviewed') + $crossBatchState = New-StackchanToolchainLeaseState + try { + [void](Get-StackchanToolchainTreeIdentity ` + -Root $crossBatchRootA -LeaseState $crossBatchState -LeaseScope fixture) + [void](Get-StackchanToolchainTreeIdentity ` + -Root $crossBatchRootB -LeaseState $crossBatchState -LeaseScope fixture) + $crossBatchPathA = [IO.Path]::GetFullPath($crossBatchRootA) + $crossBatchPathB = [IO.Path]::GetFullPath($crossBatchRootB) + $crossBatchWatcherA = $crossBatchState.watchers[$crossBatchPathA] + $crossBatchWatcherB = $crossBatchState.watchers[$crossBatchPathB] + $crossBatchChangedA = @($crossBatchWatcherA.sourceIdentifiers | Where-Object { + $_ -like '*-Changed' })[0] + $crossBatchChangedB = @($crossBatchWatcherB.sourceIdentifiers | Where-Object { + $_ -like '*-Changed' })[0] + [void](Microsoft.PowerShell.Utility\New-Event ` + -SourceIdentifier $crossBatchChangedA ` + -EventArguments @($crossBatchWatcherA.watcher, + [IO.FileSystemEventArgs]::new( + [IO.WatcherChangeTypes]::Changed, $crossBatchRootA, 'earlier.txt'))) + Start-Sleep -Milliseconds 100 + [void](Microsoft.PowerShell.Utility\New-Event ` + -SourceIdentifier $crossBatchChangedB ` + -EventArguments @($crossBatchWatcherB.watcher, + [IO.FileSystemEventArgs]::new( + [IO.WatcherChangeTypes]::Changed, $crossBatchRootB, 'later.txt'))) + [void]$crossBatchState.watchers.Remove($crossBatchPathA) + $crossBatchFirstDrain = Add-StackchanToolchainQueuedEventEvidence ` + -LeaseState $crossBatchState + $crossBatchState.watchers.Add($crossBatchPathA, $crossBatchWatcherA) + $crossBatchSecondDrain = Add-StackchanToolchainQueuedEventEvidence ` + -LeaseState $crossBatchState + $crossBatchEvidence = @($crossBatchState.violationEvidence) + Assert-True ($crossBatchFirstDrain -eq 1 -and $crossBatchSecondDrain -eq 1 -and + $crossBatchEvidence.Count -eq 2 -and + [string]$crossBatchEvidence[0].name -ceq 'earlier.txt' -and + [string]$crossBatchEvidence[1].name -ceq 'later.txt' -and + @($crossBatchEvidence.ordinal) -join ',' -ceq '0,1' -and + [datetime]$crossBatchEvidence[0].timeGeneratedUtc -lt + [datetime]$crossBatchEvidence[1].timeGeneratedUtc) ` + 'Cumulative watcher evidence was not globally reordered across drain batches.' + } finally { + if (-not $crossBatchState.watchers.ContainsKey([IO.Path]::GetFullPath($crossBatchRootA))) { + $crossBatchState.watchers.Add( + [IO.Path]::GetFullPath($crossBatchRootA), $crossBatchWatcherA) + } + Close-StackchanToolchainLeaseState -LeaseState $crossBatchState + } + + foreach ($closureKind in @('scope', 'state')) { + $closureRoot = Join-Path $testRoot "queued-$closureKind-closure" + New-Item -ItemType Directory -Path $closureRoot | Out-Null + [IO.File]::WriteAllText((Join-Path $closureRoot 'input.txt'), 'reviewed') + $closureState = New-StackchanToolchainLeaseState + try { + [void](Get-StackchanToolchainTreeIdentity ` + -Root $closureRoot -LeaseState $closureState -LeaseScope fixture) + $closureWatcher = @($closureState.watchers.Values)[0] + $script:closureCreatedSource = @($closureWatcher.sourceIdentifiers | Where-Object { + $_ -like '*-Created' })[0] + $script:closureWatcher = $closureWatcher + $script:closureRoot = $closureRoot + $script:closureAssertDepth = 0 + $script:closureOriginalAssert = ${function:Assert-StackchanToolchainLeaseStateUnchanged} + Set-Item -LiteralPath Function:Assert-StackchanToolchainLeaseStateUnchanged -Value { + param( + [Parameter(Mandatory = $true)]$LeaseState, + [Parameter(Mandatory = $true)][string]$Context, + [switch]$VerifyNamespace + ) + $script:closureAssertDepth++ + try { + & $script:closureOriginalAssert @PSBoundParameters + } finally { + $script:closureAssertDepth-- + } + if ($script:closureAssertDepth -eq 0) { + [void](Microsoft.PowerShell.Utility\New-Event ` + -SourceIdentifier $script:closureCreatedSource ` + -EventArguments @($script:closureWatcher.watcher, + [IO.FileSystemEventArgs]::new( + [IO.WatcherChangeTypes]::Created, $script:closureRoot, 'late.txt'))) + } + } + try { + if ($closureKind -ceq 'scope') { + Assert-Throws { + Close-StackchanToolchainLeaseScope -LeaseState $closureState -Scope fixture ` + -RequireUnchanged -Context 'queued scope closure fixture' + } 'changed during guarded scope closure' + } else { + Assert-Throws { + Close-StackchanToolchainLeaseState -LeaseState $closureState ` + -RequireUnchanged -Context 'queued state closure fixture' + } 'changed during guarded state closure' + } + } finally { + Set-Item -LiteralPath Function:Assert-StackchanToolchainLeaseStateUnchanged ` + -Value $script:closureOriginalAssert + } + Assert-True (@($closureState.violationEvidence).Count -eq 1 -and + [string]$closureState.violationEvidence[0].registeredEventName -ceq 'Created') ` + "Queued $closureKind closure did not retain its event evidence." + if ($closureKind -ceq 'scope') { + Assert-True ($closureState.watchers.Count -eq 0 -and + $closureState.streams.Count -eq 0) ` + 'Scope closure event was caught before, rather than during, post-disable drain.' + } else { + Assert-True ([bool]$closureState.closed) ` + 'State closure event was caught before, rather than during, post-disable drain.' + } + } finally { + Close-StackchanToolchainLeaseState -LeaseState $closureState + } + } + + foreach ($lateCleanupKind in @('scope', 'state')) { + $lateCleanupRoot = Join-Path $testRoot "late-after-snapshot-$lateCleanupKind" + New-Item -ItemType Directory -Path $lateCleanupRoot | Out-Null + [IO.File]::WriteAllText((Join-Path $lateCleanupRoot 'input.txt'), 'reviewed') + $lateCleanupState = New-StackchanToolchainLeaseState + try { + [void](Get-StackchanToolchainTreeIdentity ` + -Root $lateCleanupRoot -LeaseState $lateCleanupState -LeaseScope fixture) + $lateCleanupWatcher = @($lateCleanupState.watchers.Values)[0] + $lateChangedSource = @($lateCleanupWatcher.sourceIdentifiers | Where-Object { + $_ -like '*-Changed' })[0] + $script:lateCreatedSource = @($lateCleanupWatcher.sourceIdentifiers | Where-Object { + $_ -like '*-Created' })[0] + $script:lateCleanupWatcher = $lateCleanupWatcher + $script:lateCleanupRoot = $lateCleanupRoot + $script:lateMetadataInjected = $false + $script:lateOriginalMetadata = ${function:Get-StackchanToolchainEventPathMetadata} + Set-Item -LiteralPath Function:Get-StackchanToolchainEventPathMetadata -Value { + param([AllowNull()][AllowEmptyString()][string]$LiteralPath) + $result = & $script:lateOriginalMetadata -LiteralPath $LiteralPath + if (-not $script:lateMetadataInjected) { + $script:lateMetadataInjected = $true + [void](Microsoft.PowerShell.Utility\New-Event ` + -SourceIdentifier $script:lateCreatedSource ` + -EventArguments @($script:lateCleanupWatcher.watcher, + [IO.FileSystemEventArgs]::new( + [IO.WatcherChangeTypes]::Created, + $script:lateCleanupRoot, + 'late-after-snapshot.txt'))) + } + return $result + } + [void](Microsoft.PowerShell.Utility\New-Event ` + -SourceIdentifier $lateChangedSource ` + -EventArguments @($lateCleanupWatcher.watcher, + [IO.FileSystemEventArgs]::new( + [IO.WatcherChangeTypes]::Changed, $lateCleanupRoot, 'input.txt'))) + try { + Assert-Throws { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $lateCleanupState -Context 'late-after-snapshot fixture' + } 'events=1' + } finally { + Set-Item -LiteralPath Function:Get-StackchanToolchainEventPathMetadata ` + -Value $script:lateOriginalMetadata + } + Assert-True (@($lateCleanupState.violationEvidence).Count -eq 1 -and + @(Microsoft.PowerShell.Utility\Get-Event ` + -SourceIdentifier $script:lateCreatedSource -ErrorAction SilentlyContinue).Count -eq 1) ` + 'Late-after-snapshot fixture did not retain one queued event before cleanup.' + if ($lateCleanupKind -ceq 'scope') { + Close-StackchanToolchainLeaseScope ` + -LeaseState $lateCleanupState -Scope fixture + } else { + Close-StackchanToolchainLeaseState -LeaseState $lateCleanupState + } + $lateCleanupEvidence = @($lateCleanupState.violationEvidence) + Assert-True ($lateCleanupEvidence.Count -eq 2 -and + [string]$lateCleanupEvidence[1].registeredEventName -ceq 'Created' -and + [string]$lateCleanupEvidence[1].name -ceq 'late-after-snapshot.txt' -and + [bool]$lateCleanupEvidence[1].queueRemovalSucceeded -and + @(Microsoft.PowerShell.Utility\Get-Event ` + -SourceIdentifier $script:lateCreatedSource -ErrorAction SilentlyContinue).Count -eq 0) ` + "Non-requiring $lateCleanupKind cleanup discarded late queued watcher evidence." + } finally { + Set-Item -LiteralPath Function:Get-StackchanToolchainEventPathMetadata ` + -Value $script:lateOriginalMetadata -ErrorAction SilentlyContinue + Close-StackchanToolchainLeaseState -LeaseState $lateCleanupState + } + } + + $disabledRoot = Join-Path $testRoot 'disabled-watcher' + New-Item -ItemType Directory -Path $disabledRoot | Out-Null + [IO.File]::WriteAllText((Join-Path $disabledRoot 'input.txt'), 'reviewed') + $disabledState = New-StackchanToolchainLeaseState + try { + [void](Get-StackchanToolchainTreeIdentity ` + -Root $disabledRoot -LeaseState $disabledState -LeaseScope fixture) + @($disabledState.watchers.Values)[0].watcher.EnableRaisingEvents = $false + Assert-Throws { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $disabledState -Context 'disabled watcher fixture' + } 'watcher disabled' + } finally { + Close-StackchanToolchainLeaseState -LeaseState $disabledState + } + New-Item -ItemType Directory -Path (Join-Path $second '.git') | Out-Null New-Item -ItemType Directory -Path (Join-Path $second '__pycache__') | Out-Null [IO.File]::WriteAllText((Join-Path $second '.git/noise'), 'ignored') @@ -106,6 +501,9 @@ try { Assert-True (@($policy | Where-Object { $_.name -eq 'legacy-core-penv' }).Count -eq 1 -and @($policy | Where-Object { $_.name -eq 'release-core-penv' }).Count -eq 1) ` 'Policy does not bind both PlatformIO-managed Python environments.' + Assert-True (@($policy | Where-Object { + $_.name -eq 'release-toolchain-identity-policy-source' + }).Count -eq 1) 'Policy does not bind its reviewed PowerShell implementation bytes.' $pythonPolicy = @($policy | Where-Object { $_.name -eq 'python-installation' }) Assert-True ($pythonPolicy.Count -eq 1 -and [string]$pythonPolicy[0].relativePath -ceq '@root') ` 'Policy does not bind the complete Python installation as one closed root.' @@ -115,9 +513,11 @@ try { $fixtureRoots = @{ pythonHome = Join-Path $testRoot 'identity-host/python' + gitHome = Join-Path $testRoot 'identity-host/git' legacyCore = Join-Path $testRoot 'identity-host/legacy-core' releaseCore = Join-Path $testRoot 'identity-host/release-core' projectRoot = Join-Path $testRoot 'identity-host/project' + libdepsRoot = Join-Path $testRoot 'identity-host/project/.pio/libdeps' } foreach ($root in $fixtureRoots.Values) { New-Item -ItemType Directory -Path $root -Force | Out-Null @@ -125,7 +525,7 @@ try { foreach ($component in $policy) { $componentPath = Resolve-StackchanIdentityComponentPath ` -RootMap $fixtureRoots -Component $component - if ([IO.Path]::GetExtension($componentPath) -in @('.exe', '.dll')) { + if ([IO.Path]::GetExtension($componentPath) -in @('.exe', '.dll', '.py')) { New-Item -ItemType Directory -Path (Split-Path -Parent $componentPath) -Force | Out-Null [IO.File]::WriteAllText($componentPath, [string]$component.name) } else { @@ -139,9 +539,12 @@ try { } $fixturePio = Join-Path $fixtureRoots.pythonHome 'Scripts/platformio.exe' $fixturePython = Join-Path $fixtureRoots.pythonHome 'python.exe' + $fixtureGit = Join-Path $fixtureRoots.gitHome 'cmd/git.exe' + New-Item -ItemType Directory -Path (Split-Path -Parent $fixtureGit) -Force | Out-Null + [IO.File]::WriteAllText($fixtureGit, 'fixture:cmd/git.exe') $fixtureAllowlist = Join-Path $testRoot 'reviewed-fixture-allowlist.json' $candidate = [pscustomobject][ordered]@{ - schema = 'stackchan.release-toolchain-identity.v2' + schema = 'stackchan.release-toolchain-identity.v3' platformKey = 'windows_amd64' platformioCoreVersion = '6.1.19' pythonVersion = '3.12.10' @@ -150,6 +553,7 @@ try { canonicalLibdepsSchema = 'stackchan.canonical-libdeps.v1' platformioExecutableRelativePaths = @('Scripts/pio.exe', 'Scripts/platformio.exe') pythonExecutableRelativePath = 'python.exe' + gitExecutableRelativePath = 'cmd/git.exe' review = [pscustomobject][ordered]@{ status = 'reviewed' reviewer = 'fixture-reviewer' @@ -249,6 +653,31 @@ try { [Environment]::SetEnvironmentVariable( 'PYTHONOPTIMIZE', $null, [EnvironmentVariableTarget]::Process) + [Environment]::SetEnvironmentVariable( + 'PYTHONSAFEPATH', $null, [EnvironmentVariableTarget]::Process) + Assert-StackchanReleaseBuildPythonEnvironment -ProjectRoot $fixtureRoots.projectRoot + [Environment]::SetEnvironmentVariable( + 'PYTHONPATH', (Join-Path $testRoot 'outside'), [EnvironmentVariableTarget]::Process) + Assert-Throws { + Assert-StackchanReleaseBuildPythonEnvironment -ProjectRoot $fixtureRoots.projectRoot + } 'ambient import/runtime override: PYTHONPATH' + [Environment]::SetEnvironmentVariable( + 'PYTHONPATH', $null, [EnvironmentVariableTarget]::Process) + [Environment]::SetEnvironmentVariable( + 'PYTHONSAFEPATH', '1', [EnvironmentVariableTarget]::Process) + Assert-Throws { + Assert-StackchanReleaseBuildPythonEnvironment -ProjectRoot $fixtureRoots.projectRoot + } 'requires PYTHONSAFEPATH to be unset' + [Environment]::SetEnvironmentVariable( + 'PYTHONSAFEPATH', $null, [EnvironmentVariableTarget]::Process) + [IO.File]::WriteAllText((Join-Path $fixtureRoots.projectRoot 'sitecustomize.py'), 'raise SystemExit(1)') + Assert-Throws { + Assert-StackchanReleaseBuildPythonEnvironment -ProjectRoot $fixtureRoots.projectRoot + } 'Python import escape file' + Remove-Item -LiteralPath (Join-Path $fixtureRoots.projectRoot 'sitecustomize.py') -Force + [Environment]::SetEnvironmentVariable( + 'PYTHONSAFEPATH', '1', [EnvironmentVariableTarget]::Process) + $candidate.platformioExecutableRelativePaths = @( 'Scripts/pio.exe', 'Scripts/platformio.exe', 'Scripts/fake-pio.exe') $candidate | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $fixtureAllowlist -Encoding UTF8 @@ -333,7 +762,12 @@ try { Assert-Throws { Get-StackchanReleaseToolchainObservedComponents ` -RootMap $fixtureRoots -Phase PostBuild -PlatformKey windows_amd64 - } 'PostBuild toolchain eligibility is disabled' + } 'stale or has unexpected packages/files' + Assert-Throws { + Get-StackchanReleaseToolchainObservedComponents ` + -RootMap $fixtureRoots -Phase PreBuild -Environment stackchan ` + -PlatformKey windows_amd64 + } 'filter is valid only for PostBuild' function Invoke-FixtureGit { param([string[]]$Arguments) @@ -508,7 +942,7 @@ try { -LibraryRoot $gitSecond -LibraryLeaf FixtureGitLibrary ` -ExpectedPackageName FixtureGitLibrary -ExpectedSourceUri $fixtureUri ` -ExpectedCommit $fixtureCommit - } 'Git pack content identity mismatch' + } 'Git pack (content identity mismatch|object-to-offset mapping verification failed)' $requirementsPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'requirements-firmware-release.txt' $actualRequirements = @( @@ -536,16 +970,49 @@ try { 'candidate-unreviewed', 'refuses a reparse-point root', 'entire Python installation as one closed root', 'exact-host-installed-bytes', 'PYTHONNOUSERSITE', 'PYTHONSAFEPATH', 'python312.zip', - 'portableAcrossHosts')) { + 'portableAcrossHosts', 'verify_git_pack_semantics.py', + 'object-to-offset mapping', 'stackchan.toolchain-lifetime-lease.v1', + 'FileSystemWatcher', 'InternalBufferSize = 65536', 'Register-ObjectEvent', + 'watcher subscription missing', 'VerifyNamespace', 'preBuildVerified', + '$observedArguments.Environment = $Environment')) { Assert-True ($helperText.Contains($pattern)) "Toolchain identity helper missing safety policy: $pattern" } + Assert-True (-not $helperText.Contains('-Environment $Environment -PythonExecutable')) ` + 'PreBuild still forwards an empty optional environment into a ValidateSet parameter.' + $reviewedAllowlistPath = Join-Path $PSScriptRoot 'release_toolchain_identity_allowlist.json' + $reviewedAllowlist = Get-Content -LiteralPath $reviewedAllowlistPath -Raw | ConvertFrom-Json + $reviewedHelper = @($reviewedAllowlist.components | Where-Object { + [string]$_.name -ceq 'release-toolchain-identity-policy-source' + }) + $helperItem = Get-Item -LiteralPath (Join-Path $PSScriptRoot 'release_toolchain_identity.ps1') -Force + $helperSha256 = Get-StackchanFileSha256 -LiteralPath $helperItem.FullName + $helperRecordText = "$script:StackchanToolchainInventorySchema`nF`0$($helperItem.Name)`0$([long]$helperItem.Length)`0$helperSha256`n" + $helperRecordHasher = [Security.Cryptography.SHA256]::Create() + try { + $helperTreeSha256 = ([BitConverter]::ToString($helperRecordHasher.ComputeHash( + [Text.Encoding]::UTF8.GetBytes($helperRecordText))) -replace '-', '').ToUpperInvariant() + } finally { + $helperRecordHasher.Dispose() + } + Assert-True ($reviewedHelper.Count -eq 1 -and + [string]$reviewedHelper[0].treeSha256 -ceq $helperTreeSha256 -and + [long]$reviewedHelper[0].bytes -eq [long]$helperItem.Length) ` + 'Reviewed allowlist does not contain the production leaf identity for the helper source.' $candidateText = Get-Content -LiteralPath ( Join-Path $PSScriptRoot 'new_release_toolchain_identity_candidate.ps1') -Raw Assert-True ($candidateText.Contains('refuses to overwrite the reviewed allowlist')) ` 'Allowlist candidate workflow can overwrite reviewed policy without an explicit review step.' + foreach ($candidateSafety in @( + 'output/private/toolchain-identity-candidates', 'FileMode]::CreateNew', + 'FileShare]::None', 'Candidate output root must be one real private directory')) { + Assert-True ($candidateText.Contains($candidateSafety)) ` + "Allowlist candidate workflow is missing output safety: $candidateSafety" + } + Assert-True (-not $candidateText.Contains('New-StackchanToolchainLeaseState')) ` + 'Unreviewed candidate generation unexpectedly acquires release-authorizing lifetime guards.' [pscustomobject][ordered]@{ - schema = 'stackchan.release-toolchain-identity-contract.v2' + schema = 'stackchan.release-toolchain-identity-contract.v3' status = 'pass' fixtureFiles = $firstIdentity.fileCount policyComponents = $policy.Count diff --git a/tools/test_release_toolchain_integration_contract.ps1 b/tools/test_release_toolchain_integration_contract.ps1 new file mode 100644 index 00000000..240d600e --- /dev/null +++ b/tools/test_release_toolchain_integration_contract.ps1 @@ -0,0 +1,240 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$packagePath = Join-Path $PSScriptRoot 'package_release.ps1' +$verifierPath = Join-Path $PSScriptRoot 'verify_release_package.ps1' +$helperPath = Join-Path $PSScriptRoot 'release_toolchain_identity.ps1' +$allowlistPath = Join-Path $PSScriptRoot 'release_toolchain_identity_allowlist.json' +$documentationContractPath = Join-Path $PSScriptRoot 'test_release_toolchain_documentation_contract.ps1' +$cacheContractPath = Join-Path $PSScriptRoot 'test_release_toolchain_cache_contract.ps1' +$pioarduinoSealPath = Join-Path $PSScriptRoot 'seal_pioarduino_release_core.ps1' + +function Require-Text { + param([string]$Text, [string]$Needle, [string]$Message) + if (-not $Text.Contains($Needle)) { throw $Message } +} + +function Require-Order { + param([string]$Text, [string]$First, [string]$Second, [string]$Message) + $firstIndex = $Text.IndexOf($First, [StringComparison]::Ordinal) + $secondIndex = $Text.IndexOf($Second, [StringComparison]::Ordinal) + if ($firstIndex -lt 0 -or $secondIndex -lt 0 -or $firstIndex -ge $secondIndex) { + throw $Message + } +} + +$packageText = Get-Content -LiteralPath $packagePath -Raw +$verifierText = Get-Content -LiteralPath $verifierPath -Raw +$helperText = Get-Content -LiteralPath $helperPath -Raw + +foreach ($parameter in @( + 'ToolchainAllowlistPath', 'GitExecutable', 'PythonExecutable', + 'PlatformioExecutable', 'LegacyCoreDir', 'ReleaseCoreDir')) { + Require-Text $packageText "`$$parameter" "Release packager is missing explicit $parameter authority." + Require-Text $verifierText "`$$parameter" "Release verifier is missing explicit $parameter authority." +} + +foreach ($text in @($packageText, $verifierText)) { + if ($text.Contains('No tracked reviewed exact toolchain allowlist currently authorizes')) { + throw 'Permanent release-toolchain refusal remains in an operational release path.' + } + foreach ($needle in @( + 'release_toolchain_identity_allowlist.json', 'release_toolchain_identity.ps1', + 'verify_git_pack_semantics.py', 'Assert-StackchanReleaseToolchainIdentity', + '-Phase PreBuild', 'allowlistSha256', 'observationSha256')) { + Require-Text $text $needle "Release path is missing toolchain authority/provenance logic: $needle" + } + if ($text -notmatch "(?m)'[0-9A-F]{64}'\s*# reviewed allowlist SHA-256" -or + $text -notmatch "(?m)'[0-9A-F]{64}'\s*# reviewed identity helper SHA-256" -or + $text -notmatch "(?m)'[0-9A-F]{64}'\s*# reviewed semantic verifier SHA-256") { + throw 'Release entry point lacks literal pre-Git byte authority for its allowlist/helper/verifier.' + } +} + +Require-Order $packageText 'Assert-StackchanReleaseToolchainIdentity' ` + 'Assert-ReleaseBootstrapTrust -Root' ` + 'Packager does not assert exact PreBuild identity before first trusted Git execution.' +Require-Order $verifierText 'Assert-StackchanReleaseToolchainIdentity' ` + "Invoke-TrustedVerifierGit -Arguments @('describe'" ` + 'Verifier does not assert exact PreBuild identity before first trusted Git execution.' + +foreach ($needle in @( + 'Assert-StackchanReleaseBuildPythonEnvironment', 'Remove-Item Env:\PYTHONSAFEPATH', + 'previousPythonSafePath', 'releaseToolchainIdentityRecords', + "'preExecution'", "'postBuild'", "'pkg', 'install'", + '-Environment $Environment', '$releaseToolchainEligible')) { + Require-Text $packageText $needle "Packager is missing fail-closed build identity behavior: $needle" +} +Require-Order $packageText "'pkg', 'install'" '-Phase PostBuild' ` + 'Packager does not semantically authenticate resolved dependencies after staging.' +Require-Order $packageText '-Phase PostBuild' '@("run", "-d", $BuildProjectRoot' ` + 'Packager executes clean/build before staged dependencies are authenticated.' + +foreach ($needle in @( + 'toolchainIdentity', 'allowlistSha256', 'identityHelperSha256', + 'semanticVerifierSha256', 'preExecution', 'postBuild', + '$releaseToolchainEligible -and (-not $SkipBuild)')) { + Require-Text $packageText $needle "Release manifest/eligibility is not bound to toolchain proof: $needle" +} +foreach ($needle in @( + 'toolchainIdentity', 'allowlistSha256', 'identityHelperSha256', + 'semanticVerifierSha256', 'preExecution', 'postBuild', + 'Assert-OperationalFirmwareMatchesTrustedRebuild')) { + Require-Text $verifierText $needle "Verifier does not independently enforce toolchain proof: $needle" +} +Require-Order $verifierText 'Assert-StackchanReleaseBuildPythonEnvironment -ProjectRoot $rebuildWorktree' ` + '$pioExecutable --version' ` + 'Verifier executes PlatformIO before establishing the approved build-Python environment.' +Require-Order $verifierText '$pioExecutable --version' "& `$pioExecutable 'pkg' 'install'" ` + 'Verifier does not validate the exact PlatformIO launcher before dependency staging.' + +foreach ($needle in @( + 'release-toolchain-identity-policy-source', 'Environment', + 'allowlistSha256', 'observationSha256', + 'Release build Python environment refuses ambient import/runtime override', + 'New-StackchanToolchainLeaseState', 'Add-StackchanToolchainFileLease', + 'FileSystemWatcher', 'InternalBufferSize = 65536', 'Register-ObjectEvent', + 'watcher subscription missing', 'VerifyNamespace', 'preBuildVerified', + 'Guarded PostBuild identity requires the same session to verify PreBuild first', + 'preBuildComponents', 'preBuildAuthorityKey', 'preBuildScope', + 'PostBuild cached PreBuild reuse', 'PostBuildComponentsOnly', + 'Copy-StackchanToolchainIdentityComponents')) { + Require-Text $helperText $needle "Identity helper is missing required integration behavior: $needle" +} + +$sealText = Get-Content -LiteralPath $pioarduinoSealPath -Raw +foreach ($sealNeedle in @( + '6FC4C8912CBB1FA65A84A527EC5A3CB1280BBA399B02D4885C8C1D91AB7CC9D0', + 'D16479CFAD23EF7C392B48C66B9E2422C0294E185746814ED4F7F9E4EFFACB60', + '"pioarduino-core"', 'name in ("platformio", "pioarduino-core")', + '[IO.File]::Replace', 'output/private/toolchain-backups')) { + Require-Text $sealText $sealNeedle "Pioarduino release-core seal is missing: $sealNeedle" +} +foreach ($packagedSealInput in @( + 'tools/test_release_toolchain_cache_contract.ps1', + 'tools/seal_pioarduino_release_core.ps1')) { + Require-Text $packageText $packagedSealInput ` + "Packager does not bind/copy sealed-toolchain governance input: $packagedSealInput" +} +$guardedAssertText = $helperText.Substring( + $helperText.IndexOf('function Assert-StackchanReleaseToolchainIdentity', [StringComparison]::Ordinal)) +Require-Order $guardedAssertText 'Release toolchain byte identity mismatch' ` + 'Assert-StackchanPythonImportIsolation' ` + 'Guarded identity executes Python before installed bytes match the reviewed allowlist.' + +foreach ($entry in @( + [ordered]@{ + text = $packageText; state = '$script:releaseToolchainLeaseState'; label = 'packager' + cleanup = 'Close-ReleaseToolchainResources'; leases = '$script:releaseToolchainReadLeases.Clear()' + }, + [ordered]@{ + text = $verifierText; state = '$script:verifierToolchainLeaseState'; label = 'verifier' + cleanup = 'Close-VerifierToolchainResources'; leases = '$script:verifierToolchainReadLeases.Clear()' + })) { + foreach ($needle in @( + 'New-StackchanToolchainLeaseState', '-LeaseState ' + [string]$entry.state, + '-LeaseScope', 'Assert-StackchanToolchainLeaseStateUnchanged', + 'Close-StackchanToolchainLeaseScope', 'Close-StackchanToolchainLeaseState', + '-RequireUnchanged')) { + Require-Text ([string]$entry.text) $needle ` + "Release $([string]$entry.label) is missing lifetime-guard behavior: $needle" + } + foreach ($cleanupNeedle in @([string]$entry.cleanup, [string]$entry.leases, '.Dispose()')) { + Require-Text ([string]$entry.text) $cleanupNeedle ` + "Release $([string]$entry.label) is missing explicit resource cleanup: $cleanupNeedle" + } +} +Require-Order $packageText 'trap {' 'if (-not $SkipBuild) {' ` + 'Packager failure cleanup is not registered before guarded pre-build authentication.' +Require-Order $verifierText 'trap {' '$ambientGitOverrides =' ` + 'Verifier failure cleanup is not registered before guarded pre-build authentication.' +Require-Order $packageText "-Context 'release toolchain eligibility decision' -VerifyNamespace" ` + '$releaseToolchainEligible = $true' ` + 'Packager can establish eligibility before its final guarded namespace barrier.' +foreach ($scopeContext in @( + "-RequireUnchanged -Context 'cycle-a final authenticated namespace'", + "-RequireUnchanged -Context 'cycle-b final authenticated namespace'")) { + Require-Text $packageText $scopeContext ` + "Packager scope closure lacks its final guarded namespace barrier: $scopeContext" +} +foreach ($context in @( + 'before trusted Git execution', 'after trusted Git execution', + 'before PlatformIO execution', 'after PlatformIO execution')) { + Require-Text $packageText $context "Packager does not guard external execution: $context" +} +foreach ($context in @( + 'before trusted verifier Git execution', 'after trusted verifier Git execution', + 'before PlatformIO version execution', 'after PlatformIO version execution', + 'independent rebuild final authenticated namespace', + 'completed release-eligible verification')) { + Require-Text $verifierText $context "Verifier does not guard external execution: $context" +} + +if (-not (Test-Path -LiteralPath $allowlistPath -PathType Leaf)) { + throw 'Reviewed release toolchain allowlist is missing.' +} +$allowlist = Get-Content -LiteralPath $allowlistPath -Raw | ConvertFrom-Json +if ([string]$allowlist.review.status -cne 'reviewed' -or + [string]::IsNullOrWhiteSpace([string]$allowlist.review.reviewer) -or + [string]::IsNullOrWhiteSpace([string]$allowlist.review.reason) -or + @($allowlist.components).Count -ne 24) { + throw 'Tracked release toolchain allowlist is not the reviewed 24-component policy.' +} + +$broadContract = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'test_firmware_reproducible_build_contract.ps1') -Raw +Require-Text $broadContract 'test_release_toolchain_integration_contract.ps1' ` + 'Firmware reproducibility gate does not run the release-toolchain integration contract.' + +$authorityNames = @( + 'ToolchainAllowlistPath', 'GitExecutable', 'PythonExecutable', + 'PlatformioExecutable', 'LegacyCoreDir', 'ReleaseCoreDir') +foreach ($relative in @( + 'audit_published_release.ps1', 'export_rollout_status.ps1', + 'flash_release_firmware.ps1', 'prepare_device_arrival.ps1', + 'publish_release.ps1', 'run_device_preflight.ps1', 'share_release.ps1', + 'start_bridge_ai_supervised_qualification.ps1', 'start_hardware_evidence.ps1', + 'verify_consumer_promotion.ps1', 'verify_published_release.ps1')) { + $callerText = Get-Content -LiteralPath (Join-Path $PSScriptRoot $relative) -Raw + if ($relative -cne 'audit_published_release.ps1') { + Require-Text $callerText 'RequireReleaseEligible' ` + "Operational caller does not preserve the release-eligibility gate: $relative" + } + foreach ($authorityName in $authorityNames) { + Require-Text $callerText $authorityName ` + "Operational caller does not propagate $authorityName authority: $relative" + } +} +$hardwareEvidenceText = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'start_hardware_evidence.ps1') -Raw +foreach ($generatedCommand in @( + '$displayCommand', '$servoCommand', '$verifyCommand', + '$rolloutStatusCommand', '$consumerPromotionCommand')) { + if ($hardwareEvidenceText -notmatch ( + '(?m)^\s*' + [regex]::Escape($generatedCommand) + + '.*\$toolchainCommandArguments')) { + throw "Generated evidence command does not carry reviewed toolchain authority: $generatedCommand" + } +} + +$workflowText = Get-Content -LiteralPath ( + Join-Path (Split-Path -Parent $PSScriptRoot) '.github/workflows/release.yml') -Raw +foreach ($needle in @( + 'runs-on: [self-hosted, Windows, X64, stackchan-release-toolchain-20260803]', + 'STACKCHAN_RELEASE_GIT_EXECUTABLE', 'STACKCHAN_RELEASE_PYTHON_EXECUTABLE', + 'STACKCHAN_RELEASE_PLATFORMIO_EXECUTABLE', 'STACKCHAN_RELEASE_LEGACY_CORE_DIR', + 'STACKCHAN_RELEASE_RELEASE_CORE_DIR', 'release_toolchain_identity_allowlist.json')) { + Require-Text $workflowText $needle "Release workflow is missing exact-host authority: $needle" +} + +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $documentationContractPath +if ($LASTEXITCODE -ne 0) { + throw 'Release toolchain documentation contract failed.' +} + +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $cacheContractPath +if ($LASTEXITCODE -ne 0) { + throw 'Release toolchain cache contract failed.' +} + +Write-Output 'Release toolchain integration contract passed.' diff --git a/tools/verify_consumer_promotion.ps1 b/tools/verify_consumer_promotion.ps1 index ce655b1e..0e24926e 100644 --- a/tools/verify_consumer_promotion.ps1 +++ b/tools/verify_consumer_promotion.ps1 @@ -16,6 +16,12 @@ param( [string]$ExpectedFirmwareSourceCommit, [string]$Repo = "RobVanProd/stackchan_alive", [string]$ActionsStatusPath, + [string]$ToolchainAllowlistPath, + [string]$GitExecutable, + [string]$PythonExecutable, + [string]$PlatformioExecutable, + [string]$LegacyCoreDir, + [string]$ReleaseCoreDir, [switch]$AllowExternalAccountCiBlock ) @@ -410,7 +416,11 @@ if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { $verifyPackage = Join-Path $PSScriptRoot "verify_release_package.ps1" & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage ` -Version $Version -ZipPath $promotionPackageZipPath ` - -ExpectedCommit $ExpectedCommit -RequireReleaseEligible + -ExpectedCommit $ExpectedCommit -RequireReleaseEligible ` + -ToolchainAllowlistPath $ToolchainAllowlistPath ` + -GitExecutable $GitExecutable -PythonExecutable $PythonExecutable ` + -PlatformioExecutable $PlatformioExecutable ` + -LegacyCoreDir $LegacyCoreDir -ReleaseCoreDir $ReleaseCoreDir if ($LASTEXITCODE -ne 0) { throw "Operational release ZIP verification failed before consumer-promotion extraction." } @@ -435,7 +445,12 @@ $packageRootPath = (Resolve-Path $PackageRoot).Path try { $verifyPackage = Join-Path $PSScriptRoot "verify_release_package.ps1" - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage -Version $Version -PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit -RequireReleaseEligible + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage ` + -Version $Version -PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit ` + -RequireReleaseEligible -ToolchainAllowlistPath $ToolchainAllowlistPath ` + -GitExecutable $GitExecutable -PythonExecutable $PythonExecutable ` + -PlatformioExecutable $PlatformioExecutable ` + -LegacyCoreDir $LegacyCoreDir -ReleaseCoreDir $ReleaseCoreDir if ($LASTEXITCODE -ne 0) { throw "Release package verification failed." } diff --git a/tools/verify_git_pack_semantics.py b/tools/verify_git_pack_semantics.py new file mode 100644 index 00000000..ebf19bd4 --- /dev/null +++ b/tools/verify_git_pack_semantics.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +"""Independently decode one Git SHA-1 pack/index/reverse-index triplet. + +The release dependency proof must not accept a checksum-valid index whose object IDs +point at the wrong packed-object offsets. This verifier uses only the byte-identified +CPython runtime and its standard library; it never invokes Git. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import struct +import sys +from typing import Any +import zlib + + +MAX_PACK_BYTES = 64 * 1024 * 1024 +MAX_INDEX_BYTES = 16 * 1024 * 1024 +MAX_REVERSE_INDEX_BYTES = 8 * 1024 * 1024 +MAX_OBJECT_COUNT = 50_000 +MAX_OBJECT_BYTES = 64 * 1024 * 1024 +MAX_TOTAL_INFLATED_BYTES = 256 * 1024 * 1024 +MAX_DELTA_DEPTH = 128 + + +class VerificationError(ValueError): + """The pack triplet is malformed, unsupported, or semantically inconsistent.""" + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise VerificationError(message) + + +def _u32(data: bytes, offset: int, context: str) -> int: + _require(offset >= 0 and offset + 4 <= len(data), f"truncated {context}") + return struct.unpack_from(">I", data, offset)[0] + + +def _u64(data: bytes, offset: int, context: str) -> int: + _require(offset >= 0 and offset + 8 <= len(data), f"truncated {context}") + return struct.unpack_from(">Q", data, offset)[0] + + +def _sha1(data: bytes) -> bytes: + return hashlib.sha1(data).digest() + + +def _parse_index(index_data: bytes) -> dict[str, Any]: + _require(len(index_data) <= MAX_INDEX_BYTES, "Git pack index exceeds the verifier resource limit") + _require(len(index_data) >= 8 + 256 * 4 + 40, "Git pack index is truncated") + _require(index_data[:4] == b"\xfftOc", "unsupported Git pack index signature") + _require(_u32(index_data, 4, "Git pack index version") == 2, "unsupported Git pack index version") + _require(_sha1(index_data[:-20]) == index_data[-20:], "Git pack index checksum mismatch") + + fanout = [_u32(index_data, 8 + number * 4, "Git pack fanout") for number in range(256)] + _require(all(fanout[index] <= fanout[index + 1] for index in range(255)), "Git pack fanout is not monotonic") + count = fanout[-1] + _require(count <= MAX_OBJECT_COUNT, "Git pack object count exceeds the verifier resource limit") + object_id_start = 8 + 256 * 4 + crc_start = object_id_start + count * 20 + offset_start = crc_start + count * 4 + ordinary_offset_end = offset_start + count * 4 + _require(ordinary_offset_end + 40 <= len(index_data), "Git pack index tables are truncated") + + object_ids = [ + index_data[object_id_start + index * 20 : object_id_start + (index + 1) * 20] + for index in range(count) + ] + _require( + all(object_ids[index] < object_ids[index + 1] for index in range(max(0, count - 1))), + "Git pack index object IDs are duplicated or unsorted", + ) + observed_fanout = [0] * 256 + for object_id in object_ids: + observed_fanout[object_id[0]] += 1 + running = 0 + for index, value in enumerate(observed_fanout): + running += value + observed_fanout[index] = running + _require(observed_fanout == fanout, "Git pack fanout does not match indexed object IDs") + + encoded_offsets = [ + _u32(index_data, offset_start + index * 4, "Git pack object offset") + for index in range(count) + ] + large_slots = [offset & 0x7FFFFFFF for offset in encoded_offsets if offset & 0x80000000] + large_count = len(large_slots) + expected_length = ordinary_offset_end + large_count * 8 + 40 + _require(len(index_data) == expected_length, "Git pack index has trailing or missing offset data") + _require(sorted(large_slots) == list(range(large_count)), "Git pack large-offset table is ambiguous") + large_offsets = [ + _u64(index_data, ordinary_offset_end + index * 8, "Git pack large offset") + for index in range(large_count) + ] + offsets: list[int] = [] + for encoded in encoded_offsets: + offsets.append(large_offsets[encoded & 0x7FFFFFFF] if encoded & 0x80000000 else encoded) + _require(len(set(offsets)) == count, "Git pack index contains duplicate object offsets") + + crc_values = [_u32(index_data, crc_start + index * 4, "Git pack CRC") for index in range(count)] + entries = [ + { + "ordinal": index, + "object_id": object_ids[index], + "offset": offsets[index], + "crc32": crc_values[index], + } + for index in range(count) + ] + return { + "count": count, + "entries": entries, + "pack_checksum": index_data[-40:-20], + "index_checksum": index_data[-20:], + } + + +def _parse_object_header(pack_data: bytes, position: int, end: int) -> tuple[int, int, int]: + start = position + _require(position < end, "truncated Git packed-object header") + current = pack_data[position] + position += 1 + object_type = (current >> 4) & 0x07 + declared_size = current & 0x0F + shift = 4 + while current & 0x80: + _require(position < end and shift < 64, "invalid Git packed-object size") + current = pack_data[position] + position += 1 + declared_size |= (current & 0x7F) << shift + shift += 7 + _require(object_type in {1, 2, 3, 4, 6, 7}, f"unsupported Git packed-object type {object_type} at {start}") + return object_type, declared_size, position + + +def _parse_ofs_delta_base(pack_data: bytes, position: int, end: int, object_offset: int) -> tuple[int, int]: + _require(position < end, "truncated Git OFS_DELTA base") + current = pack_data[position] + position += 1 + distance = current & 0x7F + while current & 0x80: + _require(position < end and distance < (1 << 56), "invalid Git OFS_DELTA base") + current = pack_data[position] + position += 1 + distance = ((distance + 1) << 7) | (current & 0x7F) + base_offset = object_offset - distance + _require(base_offset >= 12 and base_offset < object_offset, "Git OFS_DELTA base offset is invalid") + return base_offset, position + + +def _inflate_one(pack_data: bytes, position: int, end: int, declared_size: int) -> tuple[bytes, int]: + _require(position < end, "missing Git packed-object zlib stream") + _require(declared_size <= MAX_OBJECT_BYTES, "Git packed object exceeds the verifier resource limit") + compressed_and_tail = pack_data[position:end] + decoder = zlib.decompressobj() + try: + payload = decoder.decompress(compressed_and_tail, declared_size + 1) + _require(len(payload) <= declared_size, "Git packed object exceeds its declared size") + payload += decoder.flush(declared_size + 1 - len(payload)) + except zlib.error as error: + raise VerificationError(f"invalid Git packed-object zlib stream: {error}") from error + _require(decoder.eof, "truncated Git packed-object zlib stream") + _require(not decoder.unconsumed_tail, "Git packed-object zlib stream was not fully consumed") + consumed = len(compressed_and_tail) - len(decoder.unused_data) + _require(consumed > 0, "empty Git packed-object zlib stream") + _require(len(payload) == declared_size, "Git packed-object declared size mismatch") + return payload, position + consumed + + +def _read_delta_varint(delta: bytes, position: int, label: str) -> tuple[int, int]: + value = 0 + shift = 0 + while True: + _require(position < len(delta) and shift < 64, f"invalid Git delta {label} size") + current = delta[position] + position += 1 + value |= (current & 0x7F) << shift + if current & 0x80 == 0: + return value, position + shift += 7 + + +def _apply_delta(base: bytes, delta: bytes) -> bytes: + base_size, position = _read_delta_varint(delta, 0, "base") + result_size, position = _read_delta_varint(delta, position, "result") + _require(base_size == len(base), "Git delta base size mismatch") + _require(result_size <= MAX_OBJECT_BYTES, "Git delta result exceeds the verifier resource limit") + result = bytearray() + while position < len(delta): + opcode = delta[position] + position += 1 + if opcode & 0x80: + copy_offset = 0 + copy_size = 0 + for bit, shift in ((0x01, 0), (0x02, 8), (0x04, 16), (0x08, 24)): + if opcode & bit: + _require(position < len(delta), "truncated Git delta copy offset") + copy_offset |= delta[position] << shift + position += 1 + for bit, shift in ((0x10, 0), (0x20, 8), (0x40, 16)): + if opcode & bit: + _require(position < len(delta), "truncated Git delta copy size") + copy_size |= delta[position] << shift + position += 1 + if copy_size == 0: + copy_size = 0x10000 + _require(copy_offset + copy_size <= len(base), "Git delta copy escapes base object") + result.extend(base[copy_offset : copy_offset + copy_size]) + else: + _require(opcode != 0, "Git delta contains reserved zero opcode") + _require(position + opcode <= len(delta), "Git delta insert is truncated") + result.extend(delta[position : position + opcode]) + position += opcode + _require(len(result) <= result_size, "Git delta exceeds declared result size") + _require(len(result) == result_size, "Git delta result size mismatch") + return bytes(result) + + +def _object_id(object_type: str, payload: bytes) -> bytes: + header = f"{object_type} {len(payload)}\0".encode("ascii") + return _sha1(header + payload) + + +def _parse_pack(pack_data: bytes) -> dict[str, Any]: + _require(len(pack_data) <= MAX_PACK_BYTES, "Git pack exceeds the verifier resource limit") + _require(len(pack_data) >= 32, "Git pack is truncated") + _require(pack_data[:4] == b"PACK", "invalid Git pack signature") + version = _u32(pack_data, 4, "Git pack version") + _require(version in {2, 3}, f"unsupported Git pack version {version}") + count = _u32(pack_data, 8, "Git pack object count") + _require(count <= MAX_OBJECT_COUNT, "Git pack object count exceeds the verifier resource limit") + content_end = len(pack_data) - 20 + pack_checksum = pack_data[-20:] + _require(_sha1(pack_data[:content_end]) == pack_checksum, "Git pack checksum mismatch") + + records: list[dict[str, Any]] = [] + total_inflated_bytes = 0 + position = 12 + for _ in range(count): + object_offset = position + object_type, declared_size, position = _parse_object_header(pack_data, position, content_end) + base_offset: int | None = None + base_object_id: bytes | None = None + if object_type == 6: + base_offset, position = _parse_ofs_delta_base(pack_data, position, content_end, object_offset) + elif object_type == 7: + _require(position + 20 <= content_end, "truncated Git REF_DELTA base") + base_object_id = pack_data[position : position + 20] + position += 20 + packed_payload, position = _inflate_one(pack_data, position, content_end, declared_size) + total_inflated_bytes += len(packed_payload) + _require( + total_inflated_bytes <= MAX_TOTAL_INFLATED_BYTES, + "Git pack inflated data exceeds the verifier resource limit", + ) + records.append( + { + "offset": object_offset, + "packed_type": object_type, + "packed_payload": packed_payload, + "base_offset": base_offset, + "base_object_id": base_object_id, + "end": position, + } + ) + _require(position == content_end, "Git pack contains trailing bytes or a mismatched object count") + + resolved_by_offset: dict[int, dict[str, Any]] = {} + resolved_by_id: dict[bytes, dict[str, Any]] = {} + type_names = {1: "commit", 2: "tree", 3: "blob", 4: "tag"} + unresolved = list(records) + while unresolved: + progress = False + next_unresolved: list[dict[str, Any]] = [] + for record in unresolved: + packed_type = record["packed_type"] + if packed_type in type_names: + object_type = type_names[packed_type] + payload = record["packed_payload"] + delta_depth = 0 + else: + base = ( + resolved_by_offset.get(record["base_offset"]) + if packed_type == 6 + else resolved_by_id.get(record["base_object_id"]) + ) + if base is None: + next_unresolved.append(record) + continue + delta_depth = int(base["delta_depth"]) + 1 + _require(delta_depth <= MAX_DELTA_DEPTH, "Git pack delta chain exceeds the verifier depth limit") + object_type = base["object_type"] + payload = _apply_delta(base["payload"], record["packed_payload"]) + identifier = _object_id(object_type, payload) + _require(identifier not in resolved_by_id, "Git pack contains duplicate decoded object IDs") + record["object_type"] = object_type + record["payload"] = payload + record["object_id"] = identifier + record["delta_depth"] = delta_depth + resolved_by_offset[record["offset"]] = record + resolved_by_id[identifier] = record + progress = True + _require(progress, "Git pack contains an unresolved thin or cyclic delta base") + unresolved = next_unresolved + return { + "version": version, + "count": count, + "checksum": pack_checksum, + "records": records, + } + + +def _verify_reverse_index(reverse_data: bytes, index: dict[str, Any], pack_checksum: bytes) -> None: + _require(len(reverse_data) <= MAX_REVERSE_INDEX_BYTES, "Git reverse index exceeds the verifier resource limit") + count = index["count"] + expected_length = 12 + count * 4 + 40 + _require(len(reverse_data) == expected_length, "Git reverse index length mismatch") + _require(reverse_data[:4] == b"RIDX", "invalid Git reverse index signature") + _require(_u32(reverse_data, 4, "Git reverse index version") == 1, "unsupported Git reverse index version") + _require(_u32(reverse_data, 8, "Git reverse index hash") == 1, "unsupported Git reverse index hash") + _require(reverse_data[-40:-20] == pack_checksum, "Git reverse index pack checksum mismatch") + _require(_sha1(reverse_data[:-20]) == reverse_data[-20:], "Git reverse index checksum mismatch") + actual = [_u32(reverse_data, 12 + position * 4, "Git reverse index position") for position in range(count)] + _require(sorted(actual) == list(range(count)), "Git reverse index is not a permutation") + expected = [entry["ordinal"] for entry in sorted(index["entries"], key=lambda entry: entry["offset"])] + _require(actual == expected, "Git reverse index does not match indexed object offsets") + + +def _stable_read(path: Path, maximum_bytes: int, label: str) -> tuple[bytes, tuple[int, int, int, int]]: + _require(not path.is_symlink(), f"{label} path is a symbolic link") + with path.open("rb") as stream: + before = os.fstat(stream.fileno()) + _require(before.st_size <= maximum_bytes, f"{label} exceeds the verifier resource limit") + data = stream.read(maximum_bytes + 1) + after = os.fstat(stream.fileno()) + _require(len(data) <= maximum_bytes, f"{label} exceeds the verifier resource limit") + identity = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) + _require( + identity == (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns), + f"{label} changed while it was read", + ) + current = path.stat() + _require( + identity == (current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns), + f"{label} was replaced while it was read", + ) + return data, identity + + +def verify_pack_triplet(pack_path: Path | str, index_path: Path | str, reverse_path: Path | str) -> dict[str, Any]: + unresolved_paths = (Path(pack_path), Path(index_path), Path(reverse_path)) + _require(all(not path.is_symlink() for path in unresolved_paths), "Git pack triplet contains a symbolic link") + pack_file, index_file, reverse_file = (path.resolve(strict=True) for path in unresolved_paths) + _require(pack_file.suffix == ".pack" and index_file.suffix == ".idx" and reverse_file.suffix == ".rev", "Git pack triplet extensions are invalid") + _require(pack_file.stem == index_file.stem == reverse_file.stem, "Git pack triplet names do not match") + + pack_data, pack_identity = _stable_read(pack_file, MAX_PACK_BYTES, "Git pack") + index_data, index_identity = _stable_read(index_file, MAX_INDEX_BYTES, "Git pack index") + reverse_data, reverse_identity = _stable_read( + reverse_file, MAX_REVERSE_INDEX_BYTES, "Git reverse index" + ) + for path, identity, label in ( + (pack_file, pack_identity, "Git pack"), + (index_file, index_identity, "Git pack index"), + (reverse_file, reverse_identity, "Git reverse index"), + ): + current = path.stat() + _require( + identity == (current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns), + f"{label} changed while the triplet was snapshotted", + ) + pack = _parse_pack(pack_data) + index = _parse_index(index_data) + _require(pack["count"] == index["count"], "Git pack/index object count mismatch") + _require(pack["checksum"] == index["pack_checksum"], "Git pack/index checksum linkage mismatch") + expected_stem = f"pack-{pack['checksum'].hex()}" + _require(pack_file.stem == expected_stem, "Git pack filename does not match its content identity") + + decoded_by_id = {record["object_id"]: record for record in pack["records"]} + _require(len(decoded_by_id) == pack["count"], "Git pack decoded object count mismatch") + for entry in index["entries"]: + record = decoded_by_id.get(entry["object_id"]) + _require(record is not None, "Git pack index advertises an undecoded object ID") + _require(record["offset"] == entry["offset"], "Git pack object-to-offset mapping mismatch") + actual_crc = zlib.crc32(pack_data[record["offset"] : record["end"]]) & 0xFFFFFFFF + _require(actual_crc == entry["crc32"], "Git pack indexed object CRC mismatch") + + _verify_reverse_index(reverse_data, index, pack["checksum"]) + object_ids = sorted(identifier.hex() for identifier in decoded_by_id) + return { + "schema": "stackchan.git-pack-semantics.v1", + "packSha1": pack["checksum"].hex(), + "objectCount": pack["count"], + "objectIds": object_ids, + "objectOffsetMappingVerified": True, + "objectCrcMappingVerified": True, + "reverseIndexMappingVerified": True, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pack", required=True, type=Path) + parser.add_argument("--index", required=True, type=Path) + parser.add_argument("--reverse-index", required=True, type=Path) + arguments = parser.parse_args() + try: + result = verify_pack_triplet(arguments.pack, arguments.index, arguments.reverse_index) + except (OSError, VerificationError) as error: + print(json.dumps({"schema": "stackchan.git-pack-semantics.v1", "status": "rejected", "error": str(error)}, sort_keys=True), file=sys.stderr) + return 1 + print(json.dumps(result, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_published_release.ps1 b/tools/verify_published_release.ps1 index 169e47bc..f0b72b3e 100644 --- a/tools/verify_published_release.ps1 +++ b/tools/verify_published_release.ps1 @@ -5,7 +5,13 @@ param( [string]$ZipPath = "", [string]$ZipSidecarPath = "", [string]$ExpectedCommit = "", - [switch]$AllowNonPrerelease + [switch]$AllowNonPrerelease, + [string]$ToolchainAllowlistPath = "", + [string]$GitExecutable = "", + [string]$PythonExecutable = "", + [string]$PlatformioExecutable = "", + [string]$LegacyCoreDir = "", + [string]$ReleaseCoreDir = "" ) $ErrorActionPreference = "Stop" @@ -165,6 +171,9 @@ try { $localVerifyOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` -File (Join-Path $PSScriptRoot "verify_release_package.ps1") ` -Version $Version -ZipPath $ZipPath -ExpectedCommit $ExpectedCommit ` + -ToolchainAllowlistPath $ToolchainAllowlistPath -GitExecutable $GitExecutable ` + -PythonExecutable $PythonExecutable -PlatformioExecutable $PlatformioExecutable ` + -LegacyCoreDir $LegacyCoreDir -ReleaseCoreDir $ReleaseCoreDir ` -RequireReleaseEligible 2>&1) $localVerifyExit = $LASTEXITCODE } finally { @@ -178,6 +187,9 @@ try { $localRootVerifyOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass ` -File (Join-Path $PSScriptRoot "verify_release_package.ps1") ` -Version $Version -PackageRoot $PackageRoot -ExpectedCommit $ExpectedCommit ` + -ToolchainAllowlistPath $ToolchainAllowlistPath -GitExecutable $GitExecutable ` + -PythonExecutable $PythonExecutable -PlatformioExecutable $PlatformioExecutable ` + -LegacyCoreDir $LegacyCoreDir -ReleaseCoreDir $ReleaseCoreDir ` -RequireReleaseEligible 2>&1) $localRootVerifyExit = $LASTEXITCODE } finally { @@ -456,7 +468,10 @@ if ($remoteZipHash -ne $Matches[1]) { } & (Join-Path $PSScriptRoot "verify_release_package.ps1") ` - -Version $Version -ZipPath $remoteZip -ExpectedCommit $ExpectedCommit -RequireReleaseEligible + -Version $Version -ZipPath $remoteZip -ExpectedCommit $ExpectedCommit ` + -ToolchainAllowlistPath $ToolchainAllowlistPath -GitExecutable $GitExecutable ` + -PythonExecutable $PythonExecutable -PlatformioExecutable $PlatformioExecutable ` + -LegacyCoreDir $LegacyCoreDir -ReleaseCoreDir $ReleaseCoreDir -RequireReleaseEligible if ($LASTEXITCODE -ne 0) { throw "Downloaded published release package is not operationally eligible." } diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 4eb46ff6..0132423e 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -5,19 +5,86 @@ param( [string]$ExpectedCommit, [string]$ExpectedSourceEpoch, [switch]$AllowDirtyPackage, - [switch]$RequireReleaseEligible + [switch]$RequireReleaseEligible, + [string]$ToolchainAllowlistPath, + [string]$GitExecutable, + [string]$PythonExecutable, + [string]$PlatformioExecutable, + [string]$LegacyCoreDir, + [string]$ReleaseCoreDir ) $ErrorActionPreference = "Stop" $script:verificationCleanupReady = $false -if ($RequireReleaseEligible) { - throw @' -Release-eligible verification is fail-closed before Git or build-tool execution. No tracked -reviewed exact toolchain allowlist currently authorizes the Git executable, PlatformIO/Python -launchers, their complete runtime inputs, and the post-build dependency state. Diagnostic package -verification remains available without -RequireReleaseEligible and cannot establish eligibility. -'@ +$verifierToolchainAllowlistSha256 = '30607EB46546E49CB72A231C98CDB62FE5987D24252237821F344C1E1B797A5D' # reviewed allowlist SHA-256 +$verifierToolchainIdentityHelperSha256 = '35D688C55E3CF7694B8E8644813A5C8658E2D26DE78DCF8331E62445DDC49BE4' # reviewed identity helper SHA-256 +$verifierToolchainSemanticVerifierSha256 = '649DE0BBF4A966ADF389A4C2F98190B87958E2ECCC1DF15A6E6FE04D86A4BEBA' # reviewed semantic verifier SHA-256 +$verifierToolchainPreBuild = $null +$script:verifierToolchainIdentityRecords = [System.Collections.Generic.List[object]]::new() +$script:verifierToolchainReadLeases = [System.Collections.Generic.List[IO.FileStream]]::new() +$script:verifierToolchainLeaseState = $null + +function Add-VerifierToolchainReadLease { + param([Parameter(Mandatory = $true)][string]$LiteralPath) + $item = Get-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Release verifier refuses a non-file or redirected lease target: $LiteralPath" + } + $lease = [IO.File]::Open( + $item.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $script:verifierToolchainReadLeases.Add($lease) | Out-Null +} + +function Get-VerifierBootstrapSha256 { + param([Parameter(Mandatory = $true)][string]$LiteralPath) + $item = Get-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Release verifier refuses a non-file or redirected bootstrap input: $LiteralPath" + } + $stream = [IO.File]::Open($item.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $hasher = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($hasher.ComputeHash($stream)) -replace '-', '').ToUpperInvariant() + } finally { + $hasher.Dispose() + $stream.Dispose() + } +} + +function Close-VerifierToolchainResources { + if ($null -ne $script:verifierToolchainLeaseState -and + -not [bool]$script:verifierToolchainLeaseState.closed) { + $closeCommand = Get-Command -Name Close-StackchanToolchainLeaseState ` + -CommandType Function -ErrorAction SilentlyContinue + if ($null -eq $closeCommand) { + throw 'Verifier toolchain guard exists but its cleanup function is unavailable.' + } + Close-StackchanToolchainLeaseState -LeaseState $script:verifierToolchainLeaseState + } + foreach ($lease in @($script:verifierToolchainReadLeases)) { + $lease.Dispose() + } + $script:verifierToolchainReadLeases.Clear() +} + +trap { + $verificationFailure = $_ + if ($script:verificationCleanupReady -and + $null -ne (Get-Command -Name Remove-VerificationExtraction ` + -CommandType Function -ErrorAction SilentlyContinue)) { + try { + Remove-VerificationExtraction + } catch { + Write-Warning 'Could not remove failed verifier ZIP extraction; the input ZIP remains authoritative.' + } + } + try { + Close-VerifierToolchainResources + } catch { + Write-Warning "Could not fully release verifier toolchain resources: $($_.Exception.Message)" + } + throw $verificationFailure } $ambientGitOverrides = @(Get-ChildItem Env: | Where-Object { $_.Name -like 'GIT_*' }) @@ -53,12 +120,85 @@ if ($verifierPowerShellItem.Attributes -band [System.IO.FileAttributes]::Reparse [string]$verifierPowerShellItem.Extension -cne '.exe') { throw "Release verification refuses a redirected or non-EXE PowerShell command: $verifierPowerShellExecutable" } -$trustedGitCommand = Get-Command -Name git -CommandType Application -ErrorAction SilentlyContinue | - Select-Object -First 1 -if ($null -eq $trustedGitCommand) { - throw 'Release verification requires a Git application executable; functions, aliases, and scripts are refused.' +if ($RequireReleaseEligible) { + $requiredToolchainArguments = [ordered]@{ + GitExecutable = $GitExecutable + PythonExecutable = $PythonExecutable + PlatformioExecutable = $PlatformioExecutable + LegacyCoreDir = $LegacyCoreDir + ReleaseCoreDir = $ReleaseCoreDir + } + foreach ($entry in $requiredToolchainArguments.GetEnumerator()) { + if ([string]::IsNullOrWhiteSpace([string]$entry.Value)) { + throw "Release-eligible verification requires explicit -$($entry.Key) authority." + } + } + if ([string]::IsNullOrWhiteSpace($ToolchainAllowlistPath)) { + $ToolchainAllowlistPath = Join-Path $PSScriptRoot 'release_toolchain_identity_allowlist.json' + } + $ToolchainAllowlistPath = (Get-Item -LiteralPath $ToolchainAllowlistPath -Force -ErrorAction Stop).FullName + $identityHelperPath = Join-Path $PSScriptRoot 'release_toolchain_identity.ps1' + $semanticVerifierPath = Join-Path $PSScriptRoot 'verify_git_pack_semantics.py' + foreach ($input in @( + [ordered]@{ path = $ToolchainAllowlistPath; expected = $verifierToolchainAllowlistSha256; label = 'allowlist' }, + [ordered]@{ path = $identityHelperPath; expected = $verifierToolchainIdentityHelperSha256; label = 'identity helper' }, + [ordered]@{ path = $semanticVerifierPath; expected = $verifierToolchainSemanticVerifierSha256; label = 'semantic verifier' })) { + Add-VerifierToolchainReadLease -LiteralPath ([string]$input.path) + if ([string]$input.expected -notmatch '^[0-9A-F]{64}$' -or + (Get-VerifierBootstrapSha256 -LiteralPath ([string]$input.path)) -cne [string]$input.expected) { + throw "Release verifier pre-Git byte authority mismatch: $([string]$input.label)" + } + } + $requiredPythonEnvironment = [ordered]@{ + PYTHONNOUSERSITE = '1'; PYTHONSAFEPATH = '1'; PYTHONDONTWRITEBYTECODE = '1' + PYTHONHASHSEED = '0'; PYTHONUTF8 = '1'; PYTHONIOENCODING = 'utf-8' + } + $unexpectedPythonEnvironment = @(Get-ChildItem Env: | Where-Object { + $_.Name -like 'PYTHON*' -and -not $requiredPythonEnvironment.Contains($_.Name) + }) + if ($unexpectedPythonEnvironment.Count -ne 0) { + throw "Release verifier refuses ambient Python overrides: $(@($unexpectedPythonEnvironment.Name | Sort-Object) -join ', ')" + } + foreach ($entry in $requiredPythonEnvironment.GetEnumerator()) { + $existing = [Environment]::GetEnvironmentVariable([string]$entry.Key, [EnvironmentVariableTarget]::Process) + if (-not [string]::IsNullOrWhiteSpace($existing) -and $existing -cne [string]$entry.Value) { + throw "Release verifier refuses ambient Python override: $($entry.Key)" + } + [Environment]::SetEnvironmentVariable( + [string]$entry.Key, [string]$entry.Value, [EnvironmentVariableTarget]::Process) + } + . $identityHelperPath + $script:verifierToolchainLeaseState = New-StackchanToolchainLeaseState + $resolvedGitExecutable = (Get-Item -LiteralPath $GitExecutable -Force -ErrorAction Stop).FullName + $resolvedPythonExecutable = (Get-Item -LiteralPath $PythonExecutable -Force -ErrorAction Stop).FullName + $resolvedPlatformioExecutable = (Get-Item -LiteralPath $PlatformioExecutable -Force -ErrorAction Stop).FullName + $resolvedLegacyCore = (Get-Item -LiteralPath $LegacyCoreDir -Force -ErrorAction Stop).FullName + $resolvedReleaseCore = (Get-Item -LiteralPath $ReleaseCoreDir -Force -ErrorAction Stop).FullName + foreach ($executable in @($resolvedGitExecutable, $resolvedPythonExecutable, $resolvedPlatformioExecutable)) { + Add-VerifierToolchainReadLease -LiteralPath $executable + } + $verifierToolchainRootMap = @{ + pythonHome = Split-Path -Parent $resolvedPythonExecutable + gitHome = Split-Path -Parent (Split-Path -Parent $resolvedGitExecutable) + legacyCore = $resolvedLegacyCore + releaseCore = $resolvedReleaseCore + projectRoot = [string]$repoRoot + libdepsRoot = Join-Path ([string]$repoRoot) '.pio/libdeps' + } + $verifierToolchainPreBuild = Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $ToolchainAllowlistPath -RootMap $verifierToolchainRootMap ` + -PlatformioExecutable $resolvedPlatformioExecutable ` + -PythonExecutable $resolvedPythonExecutable -GitExecutable $resolvedGitExecutable ` + -Phase PreBuild -LeaseState $script:verifierToolchainLeaseState -LeaseScope 'pre-build' + $trustedGitExecutable = $resolvedGitExecutable +} else { + $trustedGitCommand = Get-Command -Name git -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -eq $trustedGitCommand) { + throw 'Release verification requires a Git application executable; functions, aliases, and scripts are refused.' + } + $trustedGitExecutable = (Resolve-Path -LiteralPath ([string]$trustedGitCommand.Source)).Path } -$trustedGitExecutable = (Resolve-Path -LiteralPath ([string]$trustedGitCommand.Source)).Path $trustedGitDisabledHooksPath = Join-Path $repoRoot ( "output/private/disabled-verifier-git-hooks-$PID-" + [guid]::NewGuid().ToString('N')) $trustedNullAttributesPath = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } @@ -92,7 +232,15 @@ function Invoke-TrustedVerifierGit { try { $env:GIT_NO_REPLACE_OBJECTS = '1' $env:GIT_ATTR_NOSYSTEM = '1' + if ($null -ne $script:verifierToolchainLeaseState) { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context 'before trusted verifier Git execution' + } & $script:trustedGitExecutable @gitArguments + if ($null -ne $script:verifierToolchainLeaseState) { + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context 'after trusted verifier Git execution' + } } finally { if ($null -eq $previousNoReplaceObjects) { Remove-Item Env:\GIT_NO_REPLACE_OBJECTS -ErrorAction SilentlyContinue @@ -343,17 +491,6 @@ function Assert-ReleaseZipSidecar { } $script:verificationCleanupReady = $true -trap { - $verificationFailure = $_ - if ($script:verificationCleanupReady) { - try { - Remove-VerificationExtraction - } catch { - Write-Warning "Could not remove failed verifier ZIP extraction; the input ZIP remains authoritative." - } - } - throw $verificationFailure -} if (-not [string]::IsNullOrWhiteSpace($ZipPath)) { if (-not (Test-Path -LiteralPath $ZipPath)) { @@ -869,33 +1006,9 @@ function Assert-OperationalPackageGitBindings { function Assert-OperationalFirmwareMatchesTrustedRebuild { if (-not $RequireReleaseEligible) { return } - $pioExecutable = Get-StackchanPlatformioCommand - $pioCommands = @(Get-Command -Name $pioExecutable -CommandType Application -ErrorAction SilentlyContinue) - if ($pioCommands.Count -ne 1) { - throw 'Operational release verification requires PlatformIO for an independent firmware rebuild.' - } - $pioExecutable = (Resolve-Path -LiteralPath ([string]$pioCommands[0].Source)).Path - $pioVersion = ((@(& $pioExecutable --version 2>&1) | Out-String).Trim()) - if ($LASTEXITCODE -ne 0 -or $pioVersion -cne 'PlatformIO Core, version 6.1.19' -or - [string]$dependencyLock.platformioCore -cne $pioVersion) { - throw "Operational independent rebuild requires the packaged PlatformIO Core 6.1.19 identity." - } - $pioExecutableSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $pioExecutable).Hash.ToUpperInvariant() - $defaultCoreDir = Get-StackchanPlatformioCoreDir - if ([string]::IsNullOrWhiteSpace($defaultCoreDir) -or - -not (Test-Path -LiteralPath $defaultCoreDir -PathType Container)) { - throw 'Operational independent rebuild could not resolve the installed PlatformIO core directory.' - } - $defaultCoreDir = (Resolve-Path -LiteralPath $defaultCoreDir).Path - $releaseCoreDir = if ($env:OS -eq 'Windows_NT') { - Join-Path ([System.IO.Path]::GetPathRoot($env:SystemRoot)) 'spio/pioarduino' - } else { - Join-Path ([System.IO.Path]::GetTempPath()) 'stackchan-pio-release-cores/pioarduino' - } - if (-not (Test-Path -LiteralPath $releaseCoreDir -PathType Container)) { - throw "Operational independent rebuild is missing the release PlatformIO core: $releaseCoreDir" - } - $releaseCoreDir = (Resolve-Path -LiteralPath $releaseCoreDir).Path + $pioExecutable = $resolvedPlatformioExecutable + $defaultCoreDir = $resolvedLegacyCore + $releaseCoreDir = $resolvedReleaseCore $rebuildEvidenceParent = Join-Path $resolvedVerifierRoot 'output/private/operational-firmware-rebuilds' New-Item -ItemType Directory -Force -Path $rebuildEvidenceParent | Out-Null $packageChecksumsPath = Join-PackagePath 'SHA256SUMS.txt' @@ -922,7 +1035,8 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { $worktreeAdded = $false $environmentNames = @( 'PLATFORMIO_CORE_DIR', 'PLATFORMIO_BUILD_CACHE_DIR', - 'STACKCHAN_EXPECTED_BUILD_COMMIT', 'STACKCHAN_EXPECTED_BUILD_EPOCH' + 'STACKCHAN_EXPECTED_BUILD_COMMIT', 'STACKCHAN_EXPECTED_BUILD_EPOCH', + 'PYTHONSAFEPATH', 'PATH' ) $savedEnvironment = @{} foreach ($environmentName in $environmentNames) { @@ -946,6 +1060,27 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { $rebuildDirty.Count -ne 0) { throw 'Operational verifier independent rebuild worktree is not the exact clean release commit.' } + Remove-Item Env:\PYTHONSAFEPATH -ErrorAction SilentlyContinue + $env:PATH = @( + (Split-Path -Parent $resolvedPlatformioExecutable), + (Split-Path -Parent $resolvedPythonExecutable), + (Split-Path -Parent $resolvedGitExecutable), + (Join-Path ([Environment]::GetFolderPath('Windows')) 'System32'), + (Join-Path ([Environment]::GetFolderPath('Windows')) 'System32/WindowsPowerShell/v1.0') + ) -join [IO.Path]::PathSeparator + Assert-StackchanReleaseBuildPythonEnvironment -ProjectRoot $rebuildWorktree + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context 'before PlatformIO version execution' + $pioVersion = ((@(& $pioExecutable --version 2>&1) | Out-String).Trim()) + $pioVersionExit = $LASTEXITCODE + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context 'after PlatformIO version execution' + if ($pioVersionExit -ne 0 -or $pioVersion -cne 'PlatformIO Core, version 6.1.19' -or + [string]$dependencyLock.platformioCore -cne $pioVersion) { + throw "Operational independent rebuild requires the packaged PlatformIO Core 6.1.19 identity." + } + $pioExecutableSha256 = ( + Get-FileHash -Algorithm SHA256 -LiteralPath $pioExecutable).Hash.ToUpperInvariant() $buildSpecs = @( [ordered]@{ environment = 'stackchan'; packageDir = 'display_only'; coreDir = $defaultCoreDir }, @@ -959,17 +1094,62 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { $env:STACKCHAN_EXPECTED_BUILD_COMMIT = $ExpectedCommit $env:STACKCHAN_EXPECTED_BUILD_EPOCH = $ExpectedSourceEpoch New-Item -ItemType Directory -Path $env:PLATFORMIO_BUILD_CACHE_DIR | Out-Null + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "before $environment dependency staging" + $dependencyStageOutput = @(& $pioExecutable 'pkg' 'install' '-d' $rebuildWorktree '-e' $environment 2>&1) + $dependencyStageExit = $LASTEXITCODE + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "after $environment dependency staging" + $dependencyStageOutput | Set-Content -LiteralPath ( + Join-Path $rebuildEvidenceRoot "$environment-dependency-stage.log") -Encoding UTF8 + if ($dependencyStageExit -ne 0) { + throw "Operational dependency staging failed: $environment (exit $dependencyStageExit)." + } + $env:PYTHONSAFEPATH = '1' + $dependencyRootMap = @{ + pythonHome = [string]$verifierToolchainRootMap.pythonHome + gitHome = [string]$verifierToolchainRootMap.gitHome + legacyCore = [string]$verifierToolchainRootMap.legacyCore + releaseCore = [string]$verifierToolchainRootMap.releaseCore + projectRoot = $rebuildWorktree + libdepsRoot = Join-Path $rebuildWorktree '.pio/libdeps' + } + $preExecutionIdentity = Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $ToolchainAllowlistPath -RootMap $dependencyRootMap ` + -PlatformioExecutable $resolvedPlatformioExecutable ` + -PythonExecutable $resolvedPythonExecutable -GitExecutable $resolvedGitExecutable ` + -Phase PostBuild -Environment $environment ` + -LeaseState $script:verifierToolchainLeaseState -LeaseScope 'independent-rebuild' + $script:verifierToolchainIdentityRecords.Add([ordered]@{ + stage = 'preExecution'; environment = $environment; result = $preExecutionIdentity + }) | Out-Null + Remove-Item Env:\PYTHONSAFEPATH -ErrorAction SilentlyContinue foreach ($phase in @('clean', 'build')) { $pioArguments = @('run', '-d', $rebuildWorktree, '-e', $environment) if ($phase -eq 'clean') { $pioArguments += @('-t', 'clean') } + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "before $environment $phase" $phaseOutput = @(& $pioExecutable @pioArguments 2>&1) $phaseExit = $LASTEXITCODE + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "after $environment $phase" $phaseOutput | Set-Content -LiteralPath ( Join-Path $rebuildEvidenceRoot "$environment-$phase.log") -Encoding UTF8 if ($phaseExit -ne 0) { throw "Operational independent firmware rebuild failed: $environment/$phase (exit $phaseExit)." } } + $env:PYTHONSAFEPATH = '1' + $postBuildIdentity = Assert-StackchanReleaseToolchainIdentity ` + -AllowlistPath $ToolchainAllowlistPath -RootMap $dependencyRootMap ` + -PlatformioExecutable $resolvedPlatformioExecutable ` + -PythonExecutable $resolvedPythonExecutable -GitExecutable $resolvedGitExecutable ` + -Phase PostBuild -Environment $environment ` + -LeaseState $script:verifierToolchainLeaseState -LeaseScope 'independent-rebuild' + $script:verifierToolchainIdentityRecords.Add([ordered]@{ + stage = 'postBuild'; environment = $environment; result = $postBuildIdentity + }) | Out-Null + Remove-Item Env:\PYTHONSAFEPATH -ErrorAction SilentlyContinue foreach ($artifact in @('firmware.bin', 'firmware.elf', 'bootloader.bin', 'partitions.bin', 'boot_app0.bin')) { $rebuiltPath = if ($artifact -ceq 'boot_app0.bin') { [string](Assert-StackchanReleaseFrameworkOtaSelector ` @@ -1017,8 +1197,12 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { sha256 = $rebuiltHash }) | Out-Null } + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "before $environment dependency inventory" $packageListOutput = @(& $pioExecutable 'pkg' 'list' '-d' $rebuildWorktree '-e' $environment 2>&1) $packageListExit = $LASTEXITCODE + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "after $environment dependency inventory" $packageListOutput | Set-Content -LiteralPath ( Join-Path $rebuildEvidenceRoot "$environment-pkg-list.log") -Encoding UTF8 if ($packageListExit -ne 0) { @@ -1042,8 +1226,12 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { -DifferenceObject $actualDependencyIdentity -CaseSensitive).Count -ne 0) { throw "Operational independent dependency inventory does not match package evidence: $environment" } + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "before $environment verbose dependency inventory" $verbosePackageOutput = @(& $pioExecutable 'pkg' 'list' '-d' $rebuildWorktree '-e' $environment '-v' 2>&1) $verbosePackageExit = $LASTEXITCODE + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "after $environment verbose dependency inventory" $verbosePackageOutput | Set-Content -LiteralPath ( Join-Path $rebuildEvidenceRoot "$environment-pkg-list-verbose.log") -Encoding UTF8 if ($verbosePackageExit -ne 0) { @@ -1055,6 +1243,25 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { throw "Operational independent platform source does not match package evidence: $environment" } } + $independentIdentityRecords = @($script:verifierToolchainIdentityRecords) + if (@($independentIdentityRecords | Where-Object stage -ceq 'preExecution').Count -ne 3 -or + @($independentIdentityRecords | Where-Object stage -ceq 'postBuild').Count -ne 3) { + throw 'Operational rebuild did not record all three pre-execution and post-build dependency identities.' + } + foreach ($identityRecord in $independentIdentityRecords) { + $packagedStageRecords = if ([string]$identityRecord.stage -ceq 'preExecution') { + @($toolchainIdentity.preExecution) + } else { + @($toolchainIdentity.postBuild) + } + $packagedMatches = @($packagedStageRecords | Where-Object { + [string]$_.environment -ceq [string]$identityRecord.environment -and + [string]$_.result.observationSha256 -ceq [string]$identityRecord.result.observationSha256 + }) + if ([string]$identityRecord.result.status -cne 'verified' -or $packagedMatches.Count -ne 2) { + throw "Operational rebuild toolchain identity does not match both packaged cycles: $([string]$identityRecord.stage)/$([string]$identityRecord.environment)" + } + } $postBuildCommit = (Invoke-TrustedVerifierGit -Arguments @( '-C', $rebuildWorktree, 'rev-parse', '--verify', 'HEAD')).Trim().ToLowerInvariant() $postBuildDirty = @(Invoke-TrustedVerifierGit -Arguments @( @@ -1076,12 +1283,23 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { platformioVersion = $pioVersion defaultPlatformioCore = $defaultCoreDir releasePlatformioCore = $releaseCoreDir + toolchainIdentity = [ordered]@{ + allowlistSha256 = $verifierToolchainAllowlistSha256 + identityHelperSha256 = $verifierToolchainIdentityHelperSha256 + semanticVerifierSha256 = $verifierToolchainSemanticVerifierSha256 + preBuild = $verifierToolchainPreBuild + preExecution = @($script:verifierToolchainIdentityRecords | Where-Object stage -ceq 'preExecution') + postBuild = @($script:verifierToolchainIdentityRecords | Where-Object stage -ceq 'postBuild') + } verifiedUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') records = @($rebuildRecords) } $successfulAttestationJson = $successfulAttestation | ConvertTo-Json -Depth 6 $successfulAttestationJson | Set-Content -LiteralPath ( Join-Path $rebuildEvidenceRoot 'operational_firmware_rebuild.json') -Encoding UTF8 + Close-StackchanToolchainLeaseScope ` + -LeaseState $script:verifierToolchainLeaseState -Scope 'independent-rebuild' ` + -RequireUnchanged -Context 'independent rebuild final authenticated namespace' Invoke-TrustedVerifierGit -Arguments @( '-C', $resolvedVerifierRoot, 'worktree', 'remove', '--force', $rebuildWorktree) | Out-Null if ($LASTEXITCODE -ne 0) { @@ -2106,7 +2324,7 @@ if ($visionModelSha256 -ne "8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604 } $quickstartText = Get-Content -LiteralPath (Join-PackagePath "QUICKSTART.md") -Raw -foreach ($pattern in @("share_release.cmd", "verify_share_release.cmd", "DownloadCloudflared", "-Lan", "same-network URL", "stop_share.cmd -All", "PUBLIC_URL.txt", "VERIFIED_URL.txt", "STOP_SHARING.cmd", "run_engine_probe.cmd", "RunModelSmoke", "RunModelBenchmark", "run_character_red_team.cmd", "-RequireRunner", "run_litert_lm_smoke.cmd", "LITERT_LM_SMOKE.md/json", "run_prearrival_sim_check.cmd", "PREARRIVAL_SIM_CHECK.md/json", "check_companion_v1_readiness.cmd", "source-ready-pending-hardware", "protocol fixture", "export_companion_release_evidence.cmd", "COMPANION_RELEASE_EVIDENCE.json", "-RequireArtifacts", "model-benchmark/MODEL_BENCHMARK.md/json", "prepare_device_arrival.cmd", "-Operator", "-DeviceId", "-ShareRoot", "NEXT_STEPS.md", "HOSTED_MEDIA_REFERENCE.md", "RUN_DISPLAY_ONLY.cmd", "RUN_SPEECH_MOUTH_DEMO.cmd", "RUN_SPEAK_ALL_INTENTS.cmd", "RUN_SERVO_CALIBRATION.cmd", "RUN_ANDROID_APK_INSTALL.cmd", "-SourceCommit ", "check_android_toolchain.cmd", "SDK Platform 36", "cd companion", ".\gradlew.bat :app-android:assembleRelease", "companion\app-android\build\outputs\apk\release\app-android-release.apk", "android\apk-install\", "RUN_ANDROID_COMPANION_PROBE.cmd", "RUN_ANDROID_SCREEN_OFF_SOAK.cmd", "android\screen-off-soak\", "RUN_ANDROID_UDP_BEACON_PROBE.cmd", "RUN_ANDROID_LOGCAT_CAPTURE.cmd", "android/logcat/", "Android dashboard connected state", "foreground service state", "RUN_ADD_MEDIA.cmd -Type Photo -Notes", "Android dashboard connected state; robot identity; firmware/version signal; last bridge frame; active brain owner; foreground service state", "RUN_PROGRESS_CHECK.cmd", "RUN_ROLLOUT_STATUS.cmd", "ROLLOUT_STATUS.md", "RUN_ADD_MEDIA.cmd", "RUN_PLAY_LEAD_VOICE.cmd", "RVC_LEAD_AUDITION.md", "reference_audio\", "Stackchan Spark Bright Robot Playback Aid", "AUDIO_REVIEW.md", "real-device speaker recording", "audio\", "generated source WAVs alone do not count", "-ConfirmServoRisk", "Hardware validation is still required")) { +foreach ($pattern in @("share_release.cmd", "verify_share_release.cmd", "DownloadCloudflared", "-Lan", "same-network URL", "stop_share.cmd -All", "PUBLIC_URL.txt", "VERIFIED_URL.txt", "STOP_SHARING.cmd", "run_engine_probe.cmd", "RunModelSmoke", "RunModelBenchmark", "run_character_red_team.cmd", "-RequireRunner", "run_litert_lm_smoke.cmd", "LITERT_LM_SMOKE.md/json", "run_prearrival_sim_check.cmd", "PREARRIVAL_SIM_CHECK.md/json", "check_companion_v1_readiness.cmd", "source-ready-pending-hardware", "protocol fixture", "export_companion_release_evidence.cmd", "COMPANION_RELEASE_EVIDENCE.json", "-RequireArtifacts", "model-benchmark/MODEL_BENCHMARK.md/json", "prepare_device_arrival.ps1", "@releaseToolchain", "archive does not confer release authority", "-Operator", "-DeviceId", "-ShareRoot", "NEXT_STEPS.md", "HOSTED_MEDIA_REFERENCE.md", "RUN_DISPLAY_ONLY.cmd", "RUN_SPEECH_MOUTH_DEMO.cmd", "RUN_SPEAK_ALL_INTENTS.cmd", "RUN_SERVO_CALIBRATION.cmd", "RUN_ANDROID_APK_INSTALL.cmd", "-SourceCommit ", "check_android_toolchain.cmd", "SDK Platform 36", "cd companion", ".\gradlew.bat :app-android:assembleRelease", "companion\app-android\build\outputs\apk\release\app-android-release.apk", "android\apk-install\", "RUN_ANDROID_COMPANION_PROBE.cmd", "RUN_ANDROID_SCREEN_OFF_SOAK.cmd", "android\screen-off-soak\", "RUN_ANDROID_UDP_BEACON_PROBE.cmd", "RUN_ANDROID_LOGCAT_CAPTURE.cmd", "android/logcat/", "Android dashboard connected state", "foreground service state", "RUN_ADD_MEDIA.cmd -Type Photo -Notes", "Android dashboard connected state; robot identity; firmware/version signal; last bridge frame; active brain owner; foreground service state", "RUN_PROGRESS_CHECK.cmd", "RUN_ROLLOUT_STATUS.cmd", "ROLLOUT_STATUS.md", "RUN_ADD_MEDIA.cmd", "RUN_PLAY_LEAD_VOICE.cmd", "RVC_LEAD_AUDITION.md", "reference_audio\", "Stackchan Spark Bright Robot Playback Aid", "AUDIO_REVIEW.md", "real-device speaker recording", "audio\", "generated source WAVs alone do not count", "-ConfirmServoRisk", "Hardware validation is still required")) { if ($quickstartText -notmatch [regex]::Escape($pattern)) { throw "QUICKSTART.md missing required guidance: $pattern" } @@ -2146,6 +2364,38 @@ foreach ($pattern in @("Production RVC Voice", "model.pth", "model.index", "prod throw "tools/share_release.ps1 missing production RVC marker: $pattern" } } + +$packageAuthorityDocPaths = @( + 'README.md', 'QUICKSTART.md', 'ARRIVAL_DAY_RUNBOOK.md', + 'docs/RELEASE_PROCESS.md', 'docs/DEVICE_BRINGUP.md', 'docs/ROLLOUT_CHECKLIST.md', + 'docs/BRIDGE_AI_QUALIFICATION.md', 'docs/COMPANION_APP_GAP_ANALYSIS.md') +$packageAuthorityToolPattern = '(?im)(?:^|[\\/])(?:package_release|verify_release_package|run_device_preflight|flash_release_firmware|start_hardware_evidence|prepare_device_arrival|start_bridge_ai_supervised_qualification|verify_consumer_promotion|publish_release|audit_published_release|verify_published_release|share_release|export_rollout_status)\.(?:cmd|ps1)' +$packageAuthorityCmdPattern = '(?im)(?:^|[\\/])(?:package_release|verify_release_package|run_device_preflight|flash_release_firmware|start_hardware_evidence|prepare_device_arrival|start_bridge_ai_supervised_qualification|verify_consumer_promotion|publish_release|audit_published_release|verify_published_release|share_release|export_rollout_status)\.cmd' +foreach ($relativePath in $packageAuthorityDocPaths) { + $authorityText = Get-Content -LiteralPath (Join-PackagePath $relativePath) -Raw + if ($authorityText -notmatch '(?i)archive\s+does\s+not\s+confer\s+release\s+authority') { + throw "Packaged operator document omits the archive authority boundary: $relativePath" + } + if ($authorityText -match '(?is)(from inside|inside).{0,80}extracted.{0,240}(?:prepare_device_arrival|flash_release_firmware|start_hardware_evidence)\.(?:cmd|ps1)') { + throw "Packaged operator document authorizes an extracted archive: $relativePath" + } + foreach ($match in [regex]::Matches($authorityText, '(?ms)```powershell\s*(.*?)\s*```')) { + $block = [string]$match.Groups[1].Value + if ($block -notmatch $packageAuthorityToolPattern) { continue } + $isNoPackagePreflightSelfTest = $block.Trim() -ceq '.\tools\run_device_preflight.cmd' + $missingLiteralAuthority = @( + @('ToolchainAllowlistPath', 'GitExecutable', 'PythonExecutable', + 'PlatformioExecutable', 'LegacyCoreDir', 'ReleaseCoreDir') | Where-Object { + -not $block.Contains('-' + $_) + }) + if (($block -match $packageAuthorityCmdPattern -and -not $isNoPackagePreflightSelfTest) -or + (-not $isNoPackagePreflightSelfTest -and + -not $block.Contains('@releaseToolchain') -and + $missingLiteralAuthority.Count -ne 0)) { + throw "Packaged operator document contains an authority-less release command: $relativePath" + } + } +} foreach ($pattern in @( "verify_release_package.ps1", "RequireReleaseEligible", @@ -2486,7 +2736,7 @@ foreach ($pattern in @("release_asset_contract.ps1", "verify_release_asset_contr foreach ($docPath in @("README.md", "docs/RELEASE_PROCESS.md")) { $publishDocText = Get-Content -LiteralPath (Join-PackagePath $docPath) -Raw - foreach ($pattern in @("publish_release.cmd", "-PushCurrentBranch", "-PushTag", "audit_published_release.cmd")) { + foreach ($pattern in @("publish_release.ps1", "@releaseToolchain", "-PushCurrentBranch", "-PushTag", "audit_published_release.ps1")) { if ($publishDocText -notmatch [regex]::Escape($pattern)) { throw "$docPath missing safe publish guidance: $pattern" } @@ -4110,6 +4360,7 @@ if (-not ($envs -contains "stackchan") -or } $firmwareReproducibility = $manifest.firmwareReproducibility +$toolchainIdentity = $manifest.toolchainIdentity if ([bool]$manifest.diagnosticPackage) { if (-not $Version.StartsWith("diagnostic-", [System.StringComparison]::Ordinal) -or $manifest.commitRole -ne "package-source-only-not-firmware-identity" -or @@ -4126,10 +4377,45 @@ if ([bool]$manifest.diagnosticPackage) { $firmwareReproducibility.hookCoverage -ne "not-run-skip-build" -or $firmwareReproducibility.releaseOverridePolicy -ne "diagnostic-artifacts-unbound" -or $firmwareReproducibility.scope -ne "unknown/unbound-skip-build; copied pre-existing outputs whose source identity is not established" -or - $manifest.servoDefault -ne "boot and motion state unverified; copied firmware identity is unknown; do not flash") { + $manifest.servoDefault -ne "boot and motion state unverified; copied firmware identity is unknown; do not flash" -or + [string]$toolchainIdentity.status -cne 'not-applicable-diagnostic-skip-build' -or + @($toolchainIdentity.preExecution).Count -ne 0 -or @($toolchainIdentity.postBuild).Count -ne 0) { throw "Diagnostic manifest must leave copied firmware identity unbound and forbid release and hardware use" } } else { + $packagedToolchainFiles = [ordered]@{ + 'tools/release_toolchain_identity_allowlist.json' = $verifierToolchainAllowlistSha256 + 'tools/release_toolchain_identity.ps1' = $verifierToolchainIdentityHelperSha256 + 'tools/verify_git_pack_semantics.py' = $verifierToolchainSemanticVerifierSha256 + } + foreach ($entry in $packagedToolchainFiles.GetEnumerator()) { + $packagedHash = (Get-FileHash -LiteralPath (Join-PackagePath ([string]$entry.Key)) ` + -Algorithm SHA256).Hash.ToUpperInvariant() + if ($packagedHash -cne [string]$entry.Value) { + throw "Packaged release toolchain authority mismatch: $($entry.Key)" + } + } + $preExecutionIdentity = @($toolchainIdentity.preExecution) + $postBuildIdentity = @($toolchainIdentity.postBuild) + $invalidToolchainRecords = @($preExecutionIdentity + $postBuildIdentity | Where-Object { + [string]$_.result.status -cne 'verified' -or + [string]$_.result.allowlistSha256 -cne $verifierToolchainAllowlistSha256 -or + [string]$_.result.observationSha256 -notmatch '^[0-9A-F]{64}$' + }) + $expectedCycleEnvironmentKeys = @( + foreach ($cycle in @('cycle-a', 'cycle-b')) { + foreach ($environment in @('stackchan', 'stackchan_servo_calibration', 'stackchan_release_full')) { + "$cycle/$environment" + } + } + ) + $actualPreExecutionKeys = @($preExecutionIdentity | ForEach-Object { + "$([string]$_.cycle)/$([string]$_.environment)" + } | Sort-Object) + $actualPostBuildKeys = @($postBuildIdentity | ForEach-Object { + "$([string]$_.cycle)/$([string]$_.environment)" + } | Sort-Object) + $expectedCycleEnvironmentKeys = @($expectedCycleEnvironmentKeys | Sort-Object) if ($null -eq $firmwareReproducibility -or $manifest.commitRole -ne "package-and-firmware-source" -or $manifest.packageSourceIsolationPolicy -ne "detached-clean-worktree-pinned-to-package-commit" -or @@ -4146,7 +4432,18 @@ if ([bool]$manifest.diagnosticPackage) { $firmwareReproducibility.contract -ne "tools/test_firmware_reproducible_build_contract.ps1" -or $firmwareReproducibility.hookCoverage -ne "exactly-one-effective-hook" -or $firmwareReproducibility.releaseOverridePolicy -ne "release-overrides-fail-closed" -or - $firmwareReproducibility.scope -ne "same host/core paths and clean commit across distinct prefix-mapped project roots, canonical recorded PlatformIO toolchain/configuration, and no listed ambient build overrides") { + $firmwareReproducibility.scope -ne "same host/core paths and clean commit across distinct prefix-mapped project roots, canonical recorded PlatformIO toolchain/configuration, and no listed ambient build overrides" -or + [string]$toolchainIdentity.status -cne 'verified-reviewed-toolchain-and-two-cycle-dependencies' -or + [string]$toolchainIdentity.allowlistSha256 -cne $verifierToolchainAllowlistSha256 -or + [string]$toolchainIdentity.identityHelperSha256 -cne $verifierToolchainIdentityHelperSha256 -or + [string]$toolchainIdentity.semanticVerifierSha256 -cne $verifierToolchainSemanticVerifierSha256 -or + [string]$toolchainIdentity.preBuild.status -cne 'verified' -or + [string]$toolchainIdentity.preBuild.allowlistSha256 -cne $verifierToolchainAllowlistSha256 -or + [string]$toolchainIdentity.preBuild.observationSha256 -cne [string]$verifierToolchainPreBuild.observationSha256 -or + $preExecutionIdentity.Count -ne 6 -or $postBuildIdentity.Count -ne 6 -or + ($actualPreExecutionKeys -join "`n") -cne ($expectedCycleEnvironmentKeys -join "`n") -or + ($actualPostBuildKeys -join "`n") -cne ($expectedCycleEnvironmentKeys -join "`n") -or + $invalidToolchainRecords.Count -ne 0) { throw "Manifest firmwareReproducibility provenance is missing or invalid" } if (-not [string]::IsNullOrWhiteSpace($ExpectedSourceEpoch) -and @@ -5555,7 +5852,7 @@ if ([bool]$manifest.diagnosticPackage) { throw "Diagnostic READINESS_REPORT.md contains readiness claims" } } else { - foreach ($pattern in @($Version, $ExpectedCommit, "Status: test-ready prerelease", "Consumer rollout: blocked pending hardware validation", "Proven Without Hardware", "Required Physical Qualification", "Historical private paired-reference evidence", "source commit and firmware SHA-256", "recipient's assembled hardware", "GITHUB_ACTIONS_STATUS.md", "VOICE_SOURCE_STATUS.md", "Character red-team dry-run evidence", "Companion C6 brain-supervision evidence", "companion/evidence/", "configured local model", "add_hardware_evidence_media.cmd", "verify_hardware_evidence.cmd", "Speech-mouth demo evidence", "speech_mouth_demo_serial.log", "speak_all_intents_serial.log", "Power-cycle recovery", "USB power-cycle observation marked pass", "Production voice metadata", "Owner approval has not been recorded for this candidate")) { + foreach ($pattern in @($Version, $ExpectedCommit, "Status: test-ready prerelease", "Consumer rollout: blocked pending hardware validation", "Proven Without Hardware", "Required Physical Qualification", "Historical private paired-reference evidence", "source commit and firmware SHA-256", "recipient's assembled hardware", "GITHUB_ACTIONS_STATUS.md", "VOICE_SOURCE_STATUS.md", "Character red-team dry-run evidence", "Companion C6 brain-supervision evidence", "companion/evidence/", "configured local model", "add_hardware_evidence_media.cmd", "verify_hardware_evidence.cmd", "Speech-mouth demo evidence", "speech_mouth_demo_serial.log", "speak_all_intents_serial.log", "Power-cycle recovery", "USB power-cycle observation marked pass", "Production voice metadata", "Owner approval has not been recorded for this candidate", "exact clean trusted source checkout", "archive does not confer release authority")) { if ($readinessMarkdown -notmatch [regex]::Escape($pattern)) { throw "READINESS_REPORT.md missing expected text: $pattern" } @@ -5593,7 +5890,11 @@ if ([bool]$manifest.diagnosticPackage) { } } } else { - if ($readinessJson.diagnosticPackage -eq $true -or $readinessJson.status -ne "test-ready-prerelease") { + if ($readinessJson.diagnosticPackage -eq $true -or + $readinessJson.status -ne "test-ready-prerelease" -or + $null -ne $readinessJson.nextOperatorCommand -or + [string]$readinessJson.nextOperatorGuidance -notmatch 'trusted source checkout' -or + [string]$readinessJson.nextOperatorGuidance -notmatch 'archive does not confer release authority') { throw "readiness_report.json status mismatch: $($readinessJson.status)" } if ($readinessJson.consumerRollout -ne "blocked-pending-hardware-validation") { @@ -5779,6 +6080,12 @@ if ($RequireReleaseEligible -and Assert-OperationalFirmwareMatchesTrustedRebuild +if ($RequireReleaseEligible) { + Close-StackchanToolchainLeaseState ` + -LeaseState $script:verifierToolchainLeaseState -RequireUnchanged ` + -Context 'completed release-eligible verification' +} + if ([bool]$manifest.diagnosticPackage) { Write-Host "Diagnostic archive integrity verified; release and hardware use forbidden:" } else { @@ -5787,3 +6094,4 @@ if ([bool]$manifest.diagnosticPackage) { Write-Host $packageRootPath Remove-VerificationExtraction +Close-VerifierToolchainResources From 318c81388979e3b760aa7bf825606be9e3359571 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Mon, 3 Aug 2026 23:22:12 -0400 Subject: [PATCH 15/46] record committed toolchain gate --- PROJECT_STATE.md | 27 ++++++++++++++------------- TASK_LEDGER.md | 15 ++++++++------- docs/ARRIVAL_DAY_RUNBOOK.md | 7 ++++--- docs/FIRST_DEPLOY_STATUS.md | 13 +++++++------ 4 files changed, 33 insertions(+), 29 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 6bef456c..37e7d2b2 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -21,11 +21,13 @@ for physical qualification. The selected correction keeps the public full profil boot and explicitly disables autonomous boot refresh; it is committed as `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. Release-governance and OTA-selector authority are committed through `e52826a4a130f00718e20e71e5aea0f1cbc050ff` and published on draft PR #220. -The current worktree adds a Luna-reviewed 24-component exact-host toolchain allowlist, independent +Commit `616424e4b87bc8cc7c737a849d543eda7bf51dfd` adds the Luna-reviewed 24-component exact-host +toolchain allowlist, independent Git-pack semantic decoding, pre/post-build identity records, independent rebuild enforcement, and authority propagation through operational callers. The policy, semantic, adversarial verifier, -caller-integration, and broad reproducibility contracts pass. This dirty worktree is not a release -input: the clean governed package, rollback proof, and physical qualification are still pending. +caller-integration, and broad reproducibility contracts pass. This commit is now the clean source +input for the retained exact-host guard and governed package; neither has passed for this commit +yet, so rollback proof and physical qualification remain pending. Fresh bounded `/debug` evidence now shows the live runtime request, autonomous state, motion, servo rail, torque, and both power authorities off. Firmware self-reports confirmed `app0` and expected @@ -46,11 +48,10 @@ dimensional projection behind controlled-source final-actuator and physical-safe - Repository: `RobVanProd/stackchan_alive` - Working branch: `codex/aliveness-repository-truth` -- Current committed release-governance head: `e52826a4a130f00718e20e71e5aea0f1cbc050ff` - (`fix: bind OTA selector release authority`), including the firmware source correction at - `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. -- Current worktree: dirty for the reviewed toolchain/semantic-verifier integration and evidence - reconciliation; it must not be described as a clean package or installed image until committed. +- Current committed M0 release-toolchain head: + `616424e4b87bc8cc7c737a849d543eda7bf51dfd` (`harden release toolchain identity`), including + OTA-selector authority at `e52826a4a130f00718e20e71e5aea0f1cbc050ff` and the firmware source + correction at `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. - HTTP-containment contract-scope maintenance commit (test file only): `aa7dfb9ca077704dca84bc5635fbb2142e13e47c` - Separate package prerequisite commit: @@ -74,8 +75,8 @@ switched because live services use that checkout. Milestone 0 work uses the isol Selected experiment: `M0-004`, exact-source reproducible firmware and release-command governance. -- **Observed behavior:** The boot-motion prerequisite, release-governance, and selector-authority - slice are committed through `e52826a4`. Diagnostic v13 is explicitly dirty, +- **Observed behavior:** The boot-motion prerequisite, release-governance, selector-authority, and + reviewed exact-host toolchain slice are committed through `616424e4`. Diagnostic v13 is explicitly dirty, diagnostic-only, non-release-eligible, non-flashable, and does not prove firmware reproducibility. It contains three exact 8,192-byte selectors and the operational flasher rejects it before flash preparation. The tracked reviewed @@ -91,11 +92,11 @@ Selected experiment: `M0-004`, exact-source reproducible firmware and release-co differ; a diagnostic package is accepted for release, flash, or hardware qualification; a hostile ZIP escapes or bypasses inventories; or publication mutates remote state before exact repository, commit, tag, asset, and package verification. -- **Current decision:** Commit and review the completed toolchain-integration slice, then run the - governed package only from that exact clean commit with all six explicit authorities. Preserve +- **Current decision:** Regenerate and review the retained exact-host guard for `616424e4`, then run + the governed package only from that exact clean commit with all six explicit authorities. Preserve diagnostic packages as non-authorizing verifier fixtures. Do not flash until the exact package, rollback, passive P1, and supervised stop gates pass. -- **Frozen baseline:** Committed source/governance head `e52826a4`, the contained production bridge, +- **Frozen baseline:** Committed source/governance head `616424e4`, the contained production bridge, installed firmware with only self-reported expected SHA `69d3db27...8ebfa8`, the verified private backup, voice/vision/model workers, OTA and camera authorization, automatic recovery, the 50 ms face gate, actuator ownership, and all physical evidence. diff --git a/TASK_LEDGER.md b/TASK_LEDGER.md index e0716ad2..121a806e 100644 --- a/TASK_LEDGER.md +++ b/TASK_LEDGER.md @@ -123,16 +123,17 @@ Ledger timestamp: 2026-08-03 America/New_York snapshot bytes; and locks/verifies standalone publication assets. Diagnostic v13 proved the five-file package inventory and selector address order while remaining expressly dirty, diagnostic-only, non-release-eligible, - non-flashable, and not reproducibility proof. The current dirty slice contains a Luna-reviewed + non-flashable, and not reproducibility proof. Commit `616424e4` contains the Luna-reviewed 24-component exact-host allowlist, all-three-environment clean B/C canonical dependency equality, a source-bound semantic Git-pack verifier, operational caller propagation, and passing policy, adversarial verifier, integration, and broad reproducibility contracts. No clean governed release package or hardware claim is earned yet. -- **Commit:** Release-governance/selector head `e52826a4a130f00718e20e71e5aea0f1cbc050ff`; - reviewed toolchain integration pending commit after final regression and review. -- **Decision:** Continue the atomic governance slice, but do not mark it complete or generate an - eligible package until command/toolchain authority, all three clean environments, and independent - review are actually closed. +- **Commit:** Reviewed toolchain integration + `616424e4b87bc8cc7c737a849d543eda7bf51dfd`; release-governance/selector prerequisite + `e52826a4a130f00718e20e71e5aea0f1cbc050ff`. +- **Decision:** Keep M0 open until the retained exact-host guard and governed package prove the + exact clean commit across all three packaged environments and the independent rebuild. Do not + flash or create qualification evidence before those gates close. ## M0-005 — Reconcile Stale Status Documents @@ -195,7 +196,7 @@ Ledger timestamp: 2026-08-03 America/New_York autonomous motion enabled at boot. No serial/control/flash/motion action occurred. Independent read-only review preserved the distinction between live self-report, backup extraction, source, package, and physical qualification. -- **Commit:** Pending with the atomic M0 governance slice. +- **Commit:** `616424e4b87bc8cc7c737a849d543eda7bf51dfd` with the atomic M0 governance slice. - **Decision:** Accept the reconciliation while keeping release and hardware promotion on hold; the backup remains recovery evidence only. diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index 99e11996..0c92e9ec 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -22,11 +22,12 @@ SEC-002 package correction (2026-08-03): do not flash the preserved `4d31de41` p SHA-256 `4256F2E5...B31055` for a no-motion gate. That exact image was built with motion request and autonomous refresh enabled at boot. The source correction now makes the public full profile inherit motion-off, explicitly disables autonomous boot refresh, and is committed as -`b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. It is still not an install candidate until the -current reviewed-toolchain integration is committed, the governed two-cycle package build and +`b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. The reviewed toolchain integration is committed as +`616424e4b87bc8cc7c737a849d543eda7bf51dfd`, but it is still not an install candidate until the +retained exact-host guard, governed two-cycle package build, and independent rebuild match for all three packaged environments, and the exact package is verified and reviewed. The 24-component exact-host allowlist has passed independent recomputation, but no -clean governed package has been produced from this worktree yet; diagnostic packages are never +clean governed package has been produced from that commit yet; diagnostic packages are never flash or qualification inputs. The release package/flasher source requires an OTA selector bound to the exact legacy/release framework identities, 8,192-byte size, and reviewed SHA-256, and deterministically writes it at `0xE000` between the partition table and application. diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index aa7b87b7..2dbce1db 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -13,11 +13,12 @@ rewriting its historical hash or claiming that it was deployed. The replacement source profile inherits motion-off-at-boot, explicitly keeps autonomous refresh off, and is committed as `b5ea5c5f95e737d50c2ef2619b8efc4d846b4ea3`. OTA selector authority and -publication locking are committed through `e52826a4a130f00718e20e71e5aea0f1cbc050ff`. A reviewed -24-component exact-host toolchain allowlist now exists in the current M0 worktree; independent +publication locking are committed through `e52826a4a130f00718e20e71e5aea0f1cbc050ff`. The reviewed +24-component exact-host toolchain integration is committed as +`616424e4b87bc8cc7c737a849d543eda7bf51dfd`; independent recomputation matched every component and clean B/C canonical libdeps for all three release -environments. The packager/verifier integration and broad reproducibility contracts pass, but -this integration is not yet a clean committed release input and no governed replacement package, +environments. The packager/verifier integration and broad reproducibility contracts pass, but the +retained exact-host guard and governed package have not yet passed for this commit. No replacement package, installation, physical qualification, or soak exists yet. The release package/flasher source carries a per-environment `boot_app0.bin` bound to reviewed framework versions, exact 8,192-byte size, and SHA-256, and writes it at `0xE000` between the partition table and application. The flasher uses a second-verified, @@ -25,8 +26,8 @@ read-locked private ZIP snapshot and hashes locked payload streams against that esptool. Publication holds staged assets read-locked through upload and downloads the standalone firmware assets for remote hash verification. A diagnostic-only v13 rehearsal verified those contracts, but it is not a flash or qualification input. -Hold all flashing and physical promotion until this toolchain integration is committed, the exact -clean governed package passes its own two-cycle build and independent rebuild, a reviewed rollback +Hold all flashing and physical promotion until the exact-host guard and exact clean governed +package pass their two-cycle build and independent rebuild for the committed source, a reviewed rollback path exists, and a fresh passive no-motion preflight is complete. A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored From 924fc19f9edc969378b13534eb4493058a70a3f1 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 03:53:05 -0400 Subject: [PATCH 16/46] Harden M5 dependency resolution --- platformio.ini | 30 ++--- tools/RELEASE_TOOLCHAIN_IDENTITY.md | 32 +++-- tools/package_release.ps1 | 4 +- tools/release_dependency_evidence.ps1 | 17 +++ tools/release_toolchain_identity.ps1 | 64 ++++++++-- .../release_toolchain_identity_allowlist.json | 28 ++--- tools/test_platformio_utf8_contract.ps1 | 10 +- ...test_release_dependency_audit_contract.ps1 | 25 ++-- ...t_release_dependency_evidence_contract.ps1 | 110 ++++++++++++++++++ ...st_release_toolchain_identity_contract.ps1 | 49 +++++++- ...release_toolchain_integration_contract.ps1 | 13 +++ tools/verify_release_package.ps1 | 44 ++----- 12 files changed, 335 insertions(+), 91 deletions(-) diff --git a/platformio.ini b/platformio.ini index 3818a833..2dd70346 100644 --- a/platformio.ini +++ b/platformio.ini @@ -3,12 +3,12 @@ default_envs = stackchan [common] lib_deps = + M5Stack/M5GFX@0.2.24 + M5Stack/M5Unified@0.2.17 https://github.com/stack-chan/stackchan-arduino.git#b7b98f5 bblanchon/ArduinoJson@7.4.3 robotis-git/Dynamixel2Arduino@0.7.0 madhephaestus/ESP32Servo@0.13.0 - M5Stack/M5Unified@0.2.17 - M5GFX@0.2.24 https://github.com/mongonta0716/SCServo.git#ee6ee4a arminjo/ServoEasing@3.1.0 tobozo/YAMLDuino@1.5.0 @@ -223,9 +223,9 @@ build_flags = -D ESP_SR_M5_AFE_TASK_PRIORITY=1 -D ESP_SR_M5_AFE_MEMORY_ALLOC_MODE=AFE_MEMORY_ALLOC_MORE_PSRAM lib_deps = - bblanchon/ArduinoJson@7.4.3 + M5Stack/M5GFX@0.2.24 M5Stack/M5Unified@0.2.17 - M5GFX@0.2.24 + bblanchon/ArduinoJson@7.4.3 tobozo/YAMLDuino@1.5.0 https://github.com/74th/ESP-SR-For-M5Unified.git#95903511e4c011b778a4469ffe05be58ea2350b1 monitor_rts = 0 @@ -289,9 +289,9 @@ build_flags = -D STACKCHAN_SR_WAKE_MIC_TASK_PRIORITY=2 -D STACKCHAN_SR_WAKE_MIC_NOISE_FILTER_LEVEL=0 lib_deps = - bblanchon/ArduinoJson@7.4.3 + M5Stack/M5GFX@0.2.24 M5Stack/M5Unified@0.2.17 - M5GFX@0.2.24 + bblanchon/ArduinoJson@7.4.3 tobozo/YAMLDuino@1.5.0 https://github.com/esphome-libs/esp-micro-speech-features.git#351c4c69530f5a802da5433581c4863afadf0a00 monitor_rts = 0 @@ -364,9 +364,9 @@ build_flags = -D STACKCHAN_SR_WAKE_MIC_TASK_PRIORITY=2 -D STACKCHAN_SR_WAKE_MIC_NOISE_FILTER_LEVEL=0 lib_deps = - bblanchon/ArduinoJson@7.4.3 + M5Stack/M5GFX@0.2.24 M5Stack/M5Unified@0.2.17 - M5GFX@0.2.24 + bblanchon/ArduinoJson@7.4.3 tobozo/YAMLDuino@1.5.0 https://github.com/esphome-libs/esp-micro-speech-features.git#351c4c69530f5a802da5433581c4863afadf0a00 monitor_rts = 0 @@ -393,9 +393,9 @@ build_flags = -D STACKCHAN_SERVO_PING_TIMEOUT_MS=20 -D STACKCHAN_SERVO_PING_RETRY_DELAY_MS=100 lib_deps = - bblanchon/ArduinoJson@7.4.3 + M5Stack/M5GFX@0.2.24 M5Stack/M5Unified@0.2.17 - M5GFX@0.2.24 + bblanchon/ArduinoJson@7.4.3 tobozo/YAMLDuino@1.5.0 https://github.com/mongonta0716/SCServo.git#ee6ee4a https://github.com/esphome-libs/esp-micro-speech-features.git#351c4c69530f5a802da5433581c4863afadf0a00 @@ -552,8 +552,8 @@ build_flags = -O2 -D CORE_DEBUG_LEVEL=0 lib_deps = + M5Stack/M5GFX@0.2.24 M5Stack/M5Unified@0.2.17 - M5GFX@0.2.24 [env:stackchan_wake_sr_direct_probe] platform = https://github.com/pioarduino/platform-espressif32.git#55.03.36 @@ -604,9 +604,9 @@ build_flags = -D STACKCHAN_SR_WAKE_MIC_TASK_PRIORITY=2 -D STACKCHAN_SR_WAKE_MIC_NOISE_FILTER_LEVEL=0 lib_deps = - bblanchon/ArduinoJson@7.4.3 + M5Stack/M5GFX@0.2.24 M5Stack/M5Unified@0.2.17 - M5GFX@0.2.24 + bblanchon/ArduinoJson@7.4.3 tobozo/YAMLDuino@1.5.0 https://github.com/74th/ESP-SR-For-M5Unified.git#95903511e4c011b778a4469ffe05be58ea2350b1 monitor_rts = 0 @@ -668,9 +668,9 @@ build_flags = -D STACKCHAN_SR_WAKE_MIC_TASK_PRIORITY=2 -D STACKCHAN_SR_WAKE_MIC_NOISE_FILTER_LEVEL=0 lib_deps = - bblanchon/ArduinoJson@7.4.3 + M5Stack/M5GFX@0.2.24 M5Stack/M5Unified@0.2.17 - M5GFX@0.2.24 + bblanchon/ArduinoJson@7.4.3 tobozo/YAMLDuino@1.5.0 https://github.com/74th/ESP-SR-For-M5Unified.git#95903511e4c011b778a4469ffe05be58ea2350b1 monitor_rts = 0 diff --git a/tools/RELEASE_TOOLCHAIN_IDENTITY.md b/tools/RELEASE_TOOLCHAIN_IDENTITY.md index 0bd5b96d..28e10103 100644 --- a/tools/RELEASE_TOOLCHAIN_IDENTITY.md +++ b/tools/RELEASE_TOOLCHAIN_IDENTITY.md @@ -53,6 +53,16 @@ state remain exact records. The fresh-install shape and exact requirement set ar each of the three release environments, so stale libraries or duplicate version directories are rejected before candidate generation. +The M5 pair is deliberately owner-qualified and appears first in every effective `lib_deps` +block: `M5Stack/M5GFX@0.2.24` followed by `M5Stack/M5Unified@0.2.17`, before any dependency can +resolve either library transitively. +PlatformIO 6.1.19 skips unqualified direct registry +specs during `pio pkg install`; an unqualified exact request can therefore appear in +`integrity.dat` while the installed library silently remains the newer transitive version. The +policy requires one canonical leaf for each package, verifies `library.json` is exactly M5GFX +`0.2.24` and M5Unified `0.2.17`, and rejects newer or duplicate version-suffixed leaves before byte +identity review. + `verify_git_pack_semantics.py` independently decodes the observed SHA-1 Git pack formats, including OFS/REF deltas, and proves object-to-offset, CRC, reverse-index, object-ID, and checksum linkage under bounded resource limits. Its own source bytes are a reviewed pre-build component. Clean B/C roots @@ -143,20 +153,26 @@ changed bytes. ## Retained analysis evidence -The retained clean B/C roots are `D:\CodexArtifacts\stackchan-toolchain-all-repro-b` and -`D:\CodexArtifacts\stackchan-toolchain-all-repro-c`. Both independently match these reviewed -canonical libdeps identities: +The retained clean B/C roots are +`D:\spio\stackchan-governed-libdeps-proof-index0-20260804-065319\B` and +`D:\spio\stackchan-governed-libdeps-proof-index0-20260804-065319\C`. Both independently match +these reviewed canonical libdeps identities: - `stackchan` and `stackchan_servo_calibration`: - `79C18DC5078CAB8A35CCB4DAD385FDCB2BFB11126C778975F74C8F4B7096279B`; + `248A6E4A19A7079F920B9C192AE47161377555442A80889742FD6B6FD43B986E`; - `stackchan_release_full`: - `74B343038114CC2E90927E1C641B14D47806EA0759BF0FED711235B61C705273`. + `AB7DB6C267BF82C5B8AC72624D266CB3A0ABE6D5E227BEF6B4750D52800B760E`. The failed A root and older candidates remain rejected evidence and are not release inputs. The reviewed tracked allowlist was derived from candidate -`release_toolchain_identity_allowlist_candidate_20260803-201018.json`, candidate SHA-256 -`7E89C23B11783E66228A0A7C12F94E7AB0A0BC85D393ACF4A7C46F1AEE594CF4`. Promotion approved only -the byte policy; release eligibility still requires the packager/verifier record and artifact gates. +`release_toolchain_identity_allowlist_candidate_20260804-073551.json`, candidate SHA-256 +`2E95CC671A65F9600BDA3D99ABF1FB0BB039CDA71478F621B103B6740FAE899E`, after two fresh isolated +B/C roots reproduced all three dependency identities. The earlier candidate SHA-256 +`119D97845AA5998CB678AE4299BE248ABF0118C60C1C197CF0D0208B4A6DB652` remains rejected because it +captured 29 generated `urllib3` bytecode files in the release penv. Exact cache-only restoration +returned that penv to its previously reviewed identity before the accepted candidate was created. +Promotion approved only the byte policy; release eligibility still requires the packager/verifier +record and artifact gates. ## Portability and CI limit diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index ad9acaae..3220e905 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -75,8 +75,8 @@ if ($unexpectedGitOverrides.Count -gt 0) { } $releaseBootstrapNullAttributes = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } -$releaseToolchainAllowlistSha256 = '30607EB46546E49CB72A231C98CDB62FE5987D24252237821F344C1E1B797A5D' # reviewed allowlist SHA-256 -$releaseToolchainIdentityHelperSha256 = '35D688C55E3CF7694B8E8644813A5C8658E2D26DE78DCF8331E62445DDC49BE4' # reviewed identity helper SHA-256 +$releaseToolchainAllowlistSha256 = '149BC9DC713E2550C3EA199337F4BC7F095A1B1497935000F27539EDE16B7CEB' # reviewed allowlist SHA-256 +$releaseToolchainIdentityHelperSha256 = 'D63A93F4E9C3CFE057B59F963FCFF2C7CAF293300FF572E04F4B22608BD368A9' # reviewed identity helper SHA-256 $releaseToolchainSemanticVerifierSha256 = '649DE0BBF4A966ADF389A4C2F98190B87958E2ECCC1DF15A6E6FE04D86A4BEBA' # reviewed semantic verifier SHA-256 $releaseToolchainPreBuild = $null $script:releaseToolchainIdentityRecords = [System.Collections.Generic.List[object]]::new() diff --git a/tools/release_dependency_evidence.ps1 b/tools/release_dependency_evidence.ps1 index a59fa8cf..7d7c4e5e 100644 --- a/tools/release_dependency_evidence.ps1 +++ b/tools/release_dependency_evidence.ps1 @@ -23,6 +23,23 @@ function Convert-StackchanPioPackageList { return @($entries) } +function Assert-StackchanSingleResolvedPackageVersion { + param( + [Parameter(Mandatory = $true)][object[]]$ResolvedPackages, + [Parameter(Mandatory = $true)][string]$Environment, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$ExpectedVersion + ) + + $matches = @($ResolvedPackages | Where-Object { + [string]$_.kind -ieq 'package' -and [string]$_.name -ieq $Name + }) + if ($matches.Count -ne 1 -or [string]$matches[0].version -cne $ExpectedVersion) { + $versions = @($matches | ForEach-Object { [string]$_.version }) -join ', ' + throw "Resolved package must appear exactly once at the reviewed version: $Environment/$Name expected=$ExpectedVersion observed=[$versions]" + } +} + function Get-StackchanResolvedCorePackageNames { param( [Parameter(Mandatory = $true)][object[]]$ResolvedPackages, diff --git a/tools/release_toolchain_identity.ps1 b/tools/release_toolchain_identity.ps1 index 4efbe2df..b1dc168f 100644 --- a/tools/release_toolchain_identity.ps1 +++ b/tools/release_toolchain_identity.ps1 @@ -1380,11 +1380,59 @@ function Get-StackchanCanonicalGitLibraryTreeIdentity { return $identity } +function Assert-StackchanReviewedRegistryLibraryVersions { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][object]$Policy, + [Parameter(Mandatory = $true)][string]$Environment + ) + + if ($null -eq $Policy.PSObject.Properties['registryPackages']) { + throw "Libdeps policy is missing reviewed registry package versions: $Environment" + } + foreach ($leaf in @($Policy.registryPackages.Keys | Sort-Object)) { + $expected = $Policy.registryPackages[$leaf] + if ([string]$leaf -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or + [string]$expected.name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or + [string]::IsNullOrWhiteSpace([string]$expected.version)) { + throw "Invalid reviewed registry package policy: $Environment/$leaf" + } + $manifestPath = Join-Path (Join-Path $Root ([string]$leaf)) 'library.json' + $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop + if ($manifestItem.PSIsContainer -or + ($manifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Reviewed registry package manifest is not one real file: $Environment/$leaf" + } + try { + $manifest = [IO.File]::ReadAllText($manifestItem.FullName) | ConvertFrom-Json + } catch { + throw "Reviewed registry package manifest is malformed: $Environment/$leaf" + } + if ($null -eq $manifest -or + $null -eq $manifest.PSObject.Properties['name'] -or + $null -eq $manifest.PSObject.Properties['version'] -or + [string]$manifest.name -cne [string]$expected.name -or + [string]$manifest.version -cne [string]$expected.version) { + throw "Reviewed registry package name/version does not match exact policy: $Environment/$leaf" + } + } +} + function Get-StackchanExpectedLibdepsPolicy { param([Parameter(Mandatory = $true)][string]$Environment) + $reviewedRegistryPackages = @{ + 'M5GFX' = [pscustomobject][ordered]@{ + name = 'M5GFX' + version = '0.2.24' + } + 'M5Unified' = [pscustomobject][ordered]@{ + name = 'M5Unified' + version = '0.2.17' + } + } $legacyRequirements = @( - 'M5GFX@0.2.24', + 'M5Stack/M5GFX@0.2.24', 'M5Stack/M5Unified@0.2.17', 'https://github.com/mongonta0716/SCServo.git#ee6ee4a', 'arminjo/ServoEasing@3.1.0', @@ -1397,12 +1445,12 @@ function Get-StackchanExpectedLibdepsPolicy { if ($Environment -in @('stackchan', 'stackchan_servo_calibration')) { return [pscustomobject][ordered]@{ leaves = @( - 'ArduinoJson', 'Dynamixel2Arduino', 'ESP32Servo', 'M5GFX', 'M5GFX@0.2.24', 'M5Unified', - 'M5Unified@0.2.17', 'SCServo', - 'SCServo@src-8a1b26565e1a43aa7e250db85a311724', 'ServoEasing', + 'ArduinoJson', 'Dynamixel2Arduino', 'ESP32Servo', 'M5GFX', 'M5Unified', + 'SCServo', 'SCServo@src-8a1b26565e1a43aa7e250db85a311724', 'ServoEasing', 'stackchan-arduino', 'YAMLDuino' ) requirements = $legacyRequirements + registryPackages = $reviewedRegistryPackages gitSources = @{ 'SCServo' = [pscustomobject]@{ packageName = 'SCServo' @@ -1425,17 +1473,17 @@ function Get-StackchanExpectedLibdepsPolicy { if ($Environment -ceq 'stackchan_release_full') { return [pscustomobject][ordered]@{ leaves = @( - 'ArduinoJson', 'esp-micro-speech-features', 'M5GFX', 'M5GFX@0.2.24', - 'M5Unified', 'SCServo', 'YAMLDuino' + 'ArduinoJson', 'esp-micro-speech-features', 'M5GFX', 'M5Unified', 'SCServo', 'YAMLDuino' ) requirements = @( 'bblanchon/ArduinoJson@7.4.3', + 'M5Stack/M5GFX@0.2.24', 'M5Stack/M5Unified@0.2.17', 'tobozo/YAMLDuino@1.5.0', - 'M5GFX@0.2.24', 'https://github.com/esphome-libs/esp-micro-speech-features.git#351c4c69530f5a802da5433581c4863afadf0a00', 'https://github.com/mongonta0716/SCServo.git#ee6ee4a' ) + registryPackages = $reviewedRegistryPackages gitSources = @{ 'esp-micro-speech-features' = [pscustomobject]@{ packageName = 'esp-micro-speech-features' @@ -1476,6 +1524,8 @@ function Get-StackchanCanonicalLibdepsIdentity { ($topLeaves -join "`n") -cne ($expectedLeaves -join "`n")) { throw "Libdeps tree is stale or has unexpected packages/files: $Environment" } + Assert-StackchanReviewedRegistryLibraryVersions ` + -Root $rootItem.FullName -Policy $policy -Environment $Environment $integrityLines = @([IO.File]::ReadAllLines($topFiles[0].FullName) | ForEach-Object { $_.Trim() }) if ($integrityLines.Count -ne @($policy.requirements).Count -or @($integrityLines | Where-Object { [string]::IsNullOrWhiteSpace($_) -or $_ -match '[\x00-\x1F\x7F]' }).Count -ne 0 -or diff --git a/tools/release_toolchain_identity_allowlist.json b/tools/release_toolchain_identity_allowlist.json index c22d834b..e9aef613 100644 --- a/tools/release_toolchain_identity_allowlist.json +++ b/tools/release_toolchain_identity_allowlist.json @@ -12,11 +12,11 @@ ], "pythonExecutableRelativePath": "python.exe", "gitExecutableRelativePath": "cmd/git.exe", - "generatedUtc": "2026-08-03T23:41:23Z", + "generatedUtc": "2026-08-04T07:39:28Z", "review": { "status": "reviewed", - "reviewer": "Luna independent pioarduino byte/provenance audit, Luna lifetime-cache/TOCTOU review, and Luna adversarial watcher-forensics review; Codex integration", - "reason": "Independently recomputed the 21 unchanged windows_amd64 exact-host components and reviewed three intentional deltas: the sealed pioarduino release-core penv, the converged pioarduino espressif32 platform bytes, and the policy source that adds process-lifetime leases, namespace watchers, guarded PreBuild caching, fail-fast cache revalidation, exact-once state-wide watcher drains, bounded post-disable/post-unregister quiescence, and cumulative chronological evidence. Adversarial review reproduced and closed duplicate-drain, cross-root ordering, late-after-snapshot cleanup, post-unregister delivery, and cross-batch chronology defects while retaining fail-closed anomaly behavior. Clean canonical dependencies for all three firmware environments remain explicitly bound. The original pioarduino source byte is retained only in ignored private recovery evidence and has no release authority. This review promotes only these exact allowlist bytes; it does not prove the retained runtime guard, establish release eligibility, authorize publication, or authorize physical qualification." + "reviewer": "Luna independent 24-component allowlist audit and corrected governed-environment B/C dependency audit; Codex integration", + "reason": "Independently audited all 24 windows_amd64 exact-host components: 20 remain byte-for-byte unchanged and four intentional deltas are promoted. The policy source now owner-qualifies M5Stack/M5GFX@0.2.24, requires the single reviewed M5GFX 0.2.24 and M5Unified 0.2.17 leaves and exact registry manifests, and fails closed on unexpected dependency topology or metadata. Two isolated fresh B/C roots reproduced the canonical dependency identities for all three governed environments: stackchan and stackchan_servo_calibration 248A6E4A19A7079F920B9C192AE47161377555442A80889742FD6B6FD43B986E (1119 files, 163164344 bytes), and stackchan_release_full AB7DB6C267BF82C5B8AC72624D266CB3A0ABE6D5E227BEF6B4750D52800B760E (866 files, 161476510 bytes). The release-core penv remains the previously reviewed A1E366961C1410F32DD31102F37725B500FF0F3FB8E9604A4BA34715FBC08C84 identity (3154 files, 124599325 bytes) after exact cache-side-effect restoration; the prior contaminated candidate remains rejected. This review promotes only these exact allowlist component bytes; it does not prove the retained runtime guard, establish release eligibility, authorize publication, flashing, or physical qualification." }, "components": [ { @@ -39,9 +39,9 @@ "name": "release-toolchain-identity-policy-source", "phase": "preBuild", "identitySchema": "stackchan.byte-tree.v1", - "treeSha256": "5895FA33E7FFE2E848C86B9FC5DA2A77933D45B423079796EF7C643D45B8CB22", + "treeSha256": "994F5FE80C00577F4D657018D16E1D468E0F91924EE22C43FDCCD4A2F6800F24", "fileCount": 1, - "bytes": 88173 + "bytes": 90344 }, { "name": "git-pack-semantic-verifier-source", @@ -191,25 +191,25 @@ "name": "project-libdeps-stackchan", "phase": "postBuild", "identitySchema": "stackchan.canonical-libdeps.v1", - "treeSha256": "79C18DC5078CAB8A35CCB4DAD385FDCB2BFB11126C778975F74C8F4B7096279B", - "fileCount": 1516, - "bytes": 322711060 + "treeSha256": "248A6E4A19A7079F920B9C192AE47161377555442A80889742FD6B6FD43B986E", + "fileCount": 1119, + "bytes": 163164344 }, { "name": "project-libdeps-stackchan_servo_calibration", "phase": "postBuild", "identitySchema": "stackchan.canonical-libdeps.v1", - "treeSha256": "79C18DC5078CAB8A35CCB4DAD385FDCB2BFB11126C778975F74C8F4B7096279B", - "fileCount": 1516, - "bytes": 322711060 + "treeSha256": "248A6E4A19A7079F920B9C192AE47161377555442A80889742FD6B6FD43B986E", + "fileCount": 1119, + "bytes": 163164344 }, { "name": "project-libdeps-stackchan_release_full", "phase": "postBuild", "identitySchema": "stackchan.canonical-libdeps.v1", - "treeSha256": "74B343038114CC2E90927E1C641B14D47806EA0759BF0FED711235B61C705273", - "fileCount": 1146, - "bytes": 318248745 + "treeSha256": "AB7DB6C267BF82C5B8AC72624D266CB3A0ABE6D5E227BEF6B4750D52800B760E", + "fileCount": 866, + "bytes": 161476510 } ] } diff --git a/tools/test_platformio_utf8_contract.ps1 b/tools/test_platformio_utf8_contract.ps1 index dd41c962..de2f4d2d 100644 --- a/tools/test_platformio_utf8_contract.ps1 +++ b/tools/test_platformio_utf8_contract.ps1 @@ -170,13 +170,19 @@ foreach ($required in @( '^55\.3\.36\+sha\.aa6e97c$', '^3\.3\.6$', 'toolchain-xtensa-esp-elf', - 'knownPinnedM5GfxWithTransitiveCopy', - '0.2.24' + 'M5Stack/M5GFX@0.2.24', + 'Assert-StackchanSingleResolvedPackageVersion', + '-Name "M5GFX" -ExpectedVersion "0.2.24"', + '-Name "M5Unified" -ExpectedVersion "0.2.17"' )) { if (-not $releaseVerifierText.Contains($required)) { throw "Release verifier is missing mixed-toolchain lock coverage: $required" } } +if ($releaseVerifierText.Contains('knownPinnedM5GfxWithTransitiveCopy') -or + $releaseVerifierText.Contains('$duplicateVersions[1] -eq "0.2.26"')) { + throw 'Release verifier still permits the rejected duplicate M5GFX resolution.' +} foreach ($required in @( 'verify_release_package.ps1', 'package-verify.log', diff --git a/tools/test_release_dependency_audit_contract.ps1 b/tools/test_release_dependency_audit_contract.ps1 index a53723e0..1ed0ed26 100644 --- a/tools/test_release_dependency_audit_contract.ps1 +++ b/tools/test_release_dependency_audit_contract.ps1 @@ -3,15 +3,18 @@ $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $source = Get-Content -LiteralPath (Join-Path $RepoRoot "tools\verify_release_package.ps1") -Raw $required = @( + 'Assert-StackchanSingleResolvedPackageVersion', + '-Name "M5GFX" -ExpectedVersion "0.2.24"', + '-Name "M5Unified" -ExpectedVersion "0.2.17"', + '$knownLegacyScServo', + '-not $knownLegacyScServo' +) + +$forbidden = @( + '$knownPinnedM5GfxWithTransitiveCopy', '$knownPinnedM5UnifiedWithTransitiveCopy', - '$duplicate.environment -in @("stackchan", "stackchan_servo_calibration")', - '$duplicate.count -eq 2', - '$duplicateEntries.Count -eq 2', - '$duplicateVersions[0] -eq "0.2.17"', - '$duplicateVersions[1] -eq "0.2.19"', - '$_.required -eq "M5Stack/M5Unified @ 0.2.17"', - '$_.required -eq "M5Stack/M5Unified @ ^0.2.5"', - '-not $knownPinnedM5UnifiedWithTransitiveCopy' + '$duplicateVersions[1] -eq "0.2.26"', + '$duplicateVersions[1] -eq "0.2.19"' ) foreach ($fragment in $required) { @@ -20,4 +23,10 @@ foreach ($fragment in $required) { } } +foreach ($fragment in $forbidden) { + if ($source.Contains($fragment)) { + throw "Release dependency audit contract retains a rejected M5 duplicate policy: $fragment" + } +} + Write-Output "Release dependency audit contract verified." diff --git a/tools/test_release_dependency_evidence_contract.ps1 b/tools/test_release_dependency_evidence_contract.ps1 index d2c999b0..313863f7 100644 --- a/tools/test_release_dependency_evidence_contract.ps1 +++ b/tools/test_release_dependency_evidence_contract.ps1 @@ -1,7 +1,9 @@ $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'release_dependency_evidence.ps1') +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $packageText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'package_release.ps1') -Raw +$verifyText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'verify_release_package.ps1') -Raw foreach ($required in @( "@('pkg', 'list', '-d', `$BuildProjectRoot, '-e', `$Environment, '-v')", 'Get-StackchanVerbosePlatformSource', @@ -16,6 +18,114 @@ if ($packageText.Contains('platform/espressif32/$metadataName') -or $packageText.Contains("-SourceRoot (Join-Path `$coreDir 'packages')")) { throw 'Production dependency evidence still contains a broad or hard-coded source path' } +if (-not $verifyText.Contains('Assert-StackchanSingleResolvedPackageVersion') -or + -not $verifyText.Contains('-Name "M5GFX" -ExpectedVersion "0.2.24"') -or + -not $verifyText.Contains('-Name "M5Unified" -ExpectedVersion "0.2.17"') -or + $verifyText.Contains('$knownPinnedM5GfxWithTransitiveCopy') -or + $verifyText.Contains('$duplicateVersions[1] -eq "0.2.26"')) { + throw 'Release verifier does not require one exact M5GFX 0.2.24 package or still permits the rejected duplicate' +} + +$exactM5Gfx = [pscustomobject]@{ + kind = 'package'; name = 'M5GFX'; version = '0.2.24'; required = 'M5Stack/M5GFX @ 0.2.24' +} +Assert-StackchanSingleResolvedPackageVersion ` + -ResolvedPackages @($exactM5Gfx) -Environment stackchan_release_full ` + -Name M5GFX -ExpectedVersion 0.2.24 +$rejectedResolvedPackageCases = @( + [pscustomobject]@{ packages = @( + $exactM5Gfx, + [pscustomobject]@{ + kind = 'package'; name = 'M5GFX'; version = '0.2.26'; required = 'M5GFX @ >=0.2.22' + } + ) }, + [pscustomobject]@{ packages = @( + $exactM5Gfx, + [pscustomobject]@{ + kind = 'PACKAGE'; name = 'm5gfx'; version = '0.2.26'; required = 'M5GFX @ >=0.2.22' + } + ) }, + [pscustomobject]@{ packages = @( + [pscustomobject]@{ + kind = 'package'; name = 'M5GFX'; version = '0.2.26'; required = 'M5GFX @ >=0.2.22' + } + ) } +) +if ($rejectedResolvedPackageCases.Count -ne 3 -or + @($rejectedResolvedPackageCases[0].packages).Count -ne 2 -or + @($rejectedResolvedPackageCases[1].packages).Count -ne 2 -or + @($rejectedResolvedPackageCases[2].packages).Count -ne 1) { + throw 'Resolved-package rejection fixtures lost their duplicate/wrong-version shapes' +} +foreach ($resolvedPackageCase in $rejectedResolvedPackageCases) { + $rejected = $false + try { + Assert-StackchanSingleResolvedPackageVersion ` + -ResolvedPackages @($resolvedPackageCase.packages) -Environment stackchan_release_full ` + -Name M5GFX -ExpectedVersion 0.2.24 + } catch { + if ($_.Exception.Message -notmatch 'must appear exactly once at the reviewed version') { throw } + $rejected = $true + } + if (-not $rejected) { + throw 'Exact resolved-package contract accepted a wrong or duplicate M5GFX inventory' + } +} +$exactM5Unified = [pscustomobject]@{ + kind = 'package'; name = 'M5Unified'; version = '0.2.17'; required = 'M5Stack/M5Unified @ 0.2.17' +} +Assert-StackchanSingleResolvedPackageVersion ` + -ResolvedPackages @($exactM5Unified) -Environment stackchan ` + -Name M5Unified -ExpectedVersion 0.2.17 +$rejectedUnifiedDuplicate = $false +try { + Assert-StackchanSingleResolvedPackageVersion ` + -ResolvedPackages @( + $exactM5Unified, + [pscustomobject]@{ + kind = 'package'; name = 'm5unified'; version = '0.2.19'; required = 'M5Stack/M5Unified @ ^0.2.5' + }) ` + -Environment stackchan -Name M5Unified -ExpectedVersion 0.2.17 +} catch { + if ($_.Exception.Message -notmatch 'must appear exactly once at the reviewed version') { throw } + $rejectedUnifiedDuplicate = $true +} +if (-not $rejectedUnifiedDuplicate) { + throw 'Exact resolved-package contract accepted a duplicate M5Unified inventory' +} + +$platformioLines = Get-Content -LiteralPath (Join-Path $RepoRoot 'platformio.ini') +$qualifiedM5Gfx = 'M5Stack/M5GFX@0.2.24' +$qualifiedM5Unified = 'M5Stack/M5Unified@0.2.17' +$m5DependencyBlocks = 0 +for ($lineIndex = 0; $lineIndex -lt $platformioLines.Count; $lineIndex++) { + if ($platformioLines[$lineIndex] -notmatch '^\s*lib_deps\s*=\s*$') { continue } + $dependencies = [Collections.Generic.List[string]]::new() + for ($dependencyIndex = $lineIndex + 1; + $dependencyIndex -lt $platformioLines.Count; + $dependencyIndex++) { + $dependencyLine = [string]$platformioLines[$dependencyIndex] + if ($dependencyLine -match '^\s*\[' -or $dependencyLine -match '^\s*\S+\s*=') { break } + if ($dependencyLine -match '^\s+(?\S.*\S|\S)\s*$') { + $dependencies.Add($Matches.dependency) | Out-Null + } + } + $m5GfxEntries = @($dependencies | Where-Object { $_ -match '(?:^|/)M5GFX@' }) + $m5UnifiedEntries = @($dependencies | Where-Object { $_ -match '(?:^|/)M5Unified@' }) + if ($m5GfxEntries.Count -eq 0 -and $m5UnifiedEntries.Count -eq 0) { continue } + $m5DependencyBlocks++ + if ($m5GfxEntries.Count -ne 1 -or $m5GfxEntries[0] -cne $qualifiedM5Gfx -or + $m5UnifiedEntries.Count -ne 1 -or $m5UnifiedEntries[0] -cne $qualifiedM5Unified) { + throw "PlatformIO lib_deps block lacks one owner-qualified exact M5GFX/M5Unified pair at line $($lineIndex + 1)" + } + if ($dependencies.IndexOf($qualifiedM5Gfx) -ne 0 -or + $dependencies.IndexOf($qualifiedM5Unified) -ne 1) { + throw "PlatformIO lib_deps must install the exact M5GFX/M5Unified pair before every transitive dependency at line $($lineIndex + 1)" + } +} +if ($m5DependencyBlocks -ne 8) { + throw "PlatformIO owner-qualified M5 dependency block count changed: $m5DependencyBlocks" +} $root = Join-Path ([System.IO.Path]::GetTempPath()) ( 'stackchan-dependency-evidence-contract-' + [guid]::NewGuid().ToString('N')) diff --git a/tools/test_release_toolchain_identity_contract.ps1 b/tools/test_release_toolchain_identity_contract.ps1 index 31f30503..8be2c14e 100644 --- a/tools/test_release_toolchain_identity_contract.ps1 +++ b/tools/test_release_toolchain_identity_contract.ps1 @@ -744,10 +744,57 @@ try { Assert-True ($beforeCompiler.treeSha256 -cne $afterCompiler.treeSha256) ` 'A compiler-toolchain byte mutation did not change the observed pre-build identity.' + foreach ($environment in @('stackchan', 'stackchan_servo_calibration', 'stackchan_release_full')) { + $environmentPolicy = Get-StackchanExpectedLibdepsPolicy -Environment $environment + Assert-True (@($environmentPolicy.leaves | Where-Object { $_ -ceq 'M5GFX' }).Count -eq 1) ` + "Fresh-libdeps policy does not require one canonical M5GFX leaf: $environment" + Assert-True (@($environmentPolicy.leaves | Where-Object { $_ -match '^M5GFX@' }).Count -eq 0) ` + "Fresh-libdeps policy still permits a duplicate M5GFX version leaf: $environment" + Assert-True (@($environmentPolicy.leaves | Where-Object { $_ -ceq 'M5Unified' }).Count -eq 1 -and + @($environmentPolicy.leaves | Where-Object { $_ -match '^M5Unified@' }).Count -eq 0) ` + "Fresh-libdeps policy does not require one canonical M5Unified leaf: $environment" + Assert-True (@($environmentPolicy.requirements | Where-Object { + $_ -ceq 'M5Stack/M5GFX@0.2.24' + }).Count -eq 1 -and @($environmentPolicy.requirements | Where-Object { + $_ -ceq 'M5GFX@0.2.24' + }).Count -eq 0) ` + "Fresh-libdeps policy does not require the owner-qualified exact M5GFX spec: $environment" + } + + $registryVersionRoot = Join-Path $testRoot 'registry-version-fixture' + New-Item -ItemType Directory -Path (Join-Path $registryVersionRoot 'M5GFX') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $registryVersionRoot 'M5Unified') -Force | Out-Null + $registryVersionPolicy = Get-StackchanExpectedLibdepsPolicy -Environment stackchan_release_full + [IO.File]::WriteAllText( + (Join-Path $registryVersionRoot 'M5GFX/library.json'), + '{"name":"M5GFX","version":"0.2.26"}') + Assert-Throws { + Assert-StackchanReviewedRegistryLibraryVersions ` + -Root $registryVersionRoot -Policy $registryVersionPolicy ` + -Environment stackchan_release_full + } 'name/version does not match exact policy' + [IO.File]::WriteAllText( + (Join-Path $registryVersionRoot 'M5GFX/library.json'), + '{"name":"M5GFX","version":"0.2.24"}') + [IO.File]::WriteAllText( + (Join-Path $registryVersionRoot 'M5Unified/library.json'), + '{"name":"M5Unified","version":"0.2.17"}') + Assert-StackchanReviewedRegistryLibraryVersions ` + -Root $registryVersionRoot -Policy $registryVersionPolicy ` + -Environment stackchan_release_full + [IO.File]::WriteAllText( + (Join-Path $registryVersionRoot 'M5Unified/library.json'), + '{"name":"M5Unified","version":"0.2.19"}') + Assert-Throws { + Assert-StackchanReviewedRegistryLibraryVersions ` + -Root $registryVersionRoot -Policy $registryVersionPolicy ` + -Environment stackchan_release_full + } 'name/version does not match exact policy' + $staleLibdeps = Join-Path $fixtureRoots.projectRoot '.pio/libdeps/stackchan' $stalePolicy = Get-StackchanExpectedLibdepsPolicy -Environment stackchan New-Item -ItemType Directory -Path $staleLibdeps -Force | Out-Null - foreach ($leaf in @($stalePolicy.leaves) + @('unexpected-stale-library')) { + foreach ($leaf in @($stalePolicy.leaves) + @('M5GFX@0.2.24')) { New-Item -ItemType Directory -Path (Join-Path $staleLibdeps $leaf) -Force | Out-Null } [IO.File]::WriteAllLines((Join-Path $staleLibdeps 'integrity.dat'), [string[]]$stalePolicy.requirements) diff --git a/tools/test_release_toolchain_integration_contract.ps1 b/tools/test_release_toolchain_integration_contract.ps1 index 240d600e..10b7097a 100644 --- a/tools/test_release_toolchain_integration_contract.ps1 +++ b/tools/test_release_toolchain_integration_contract.ps1 @@ -26,6 +26,19 @@ function Require-Order { $packageText = Get-Content -LiteralPath $packagePath -Raw $verifierText = Get-Content -LiteralPath $verifierPath -Raw $helperText = Get-Content -LiteralPath $helperPath -Raw +$semanticVerifierPath = Join-Path $PSScriptRoot 'verify_git_pack_semantics.py' +$exactBootstrapPins = [ordered]@{ + 'allowlist' = (Get-FileHash -Algorithm SHA256 -LiteralPath $allowlistPath).Hash + 'identity helper' = (Get-FileHash -Algorithm SHA256 -LiteralPath $helperPath).Hash + 'semantic verifier' = (Get-FileHash -Algorithm SHA256 -LiteralPath $semanticVerifierPath).Hash +} +foreach ($entry in $exactBootstrapPins.GetEnumerator()) { + $literal = "'$([string]$entry.Value)' # reviewed $([string]$entry.Key) SHA-256" + Require-Text $packageText $literal ` + "Release packager has a stale reviewed $([string]$entry.Key) bootstrap pin." + Require-Text $verifierText $literal ` + "Release verifier has a stale reviewed $([string]$entry.Key) bootstrap pin." +} foreach ($parameter in @( 'ToolchainAllowlistPath', 'GitExecutable', 'PythonExecutable', diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 0132423e..e784a400 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -17,8 +17,8 @@ param( $ErrorActionPreference = "Stop" $script:verificationCleanupReady = $false -$verifierToolchainAllowlistSha256 = '30607EB46546E49CB72A231C98CDB62FE5987D24252237821F344C1E1B797A5D' # reviewed allowlist SHA-256 -$verifierToolchainIdentityHelperSha256 = '35D688C55E3CF7694B8E8644813A5C8658E2D26DE78DCF8331E62445DDC49BE4' # reviewed identity helper SHA-256 +$verifierToolchainAllowlistSha256 = '149BC9DC713E2550C3EA199337F4BC7F095A1B1497935000F27539EDE16B7CEB' # reviewed allowlist SHA-256 +$verifierToolchainIdentityHelperSha256 = 'D63A93F4E9C3CFE057B59F963FCFF2C7CAF293300FF572E04F4B22608BD368A9' # reviewed identity helper SHA-256 $verifierToolchainSemanticVerifierSha256 = '649DE0BBF4A966ADF389A4C2F98190B87958E2ECCC1DF15A6E6FE04D86A4BEBA' # reviewed semantic verifier SHA-256 $verifierToolchainPreBuild = $null $script:verifierToolchainIdentityRecords = [System.Collections.Generic.List[object]]::new() @@ -5180,8 +5180,8 @@ $expectedDeclaredLibDeps = @( "bblanchon/ArduinoJson@7.4.3", "robotis-git/Dynamixel2Arduino@0.7.0", "madhephaestus/ESP32Servo@0.13.0", + "M5Stack/M5GFX@0.2.24", "M5Stack/M5Unified@0.2.17", - "M5GFX@0.2.24", "https://github.com/mongonta0716/SCServo.git#ee6ee4a", "arminjo/ServoEasing@3.1.0", "tobozo/YAMLDuino@1.5.0" @@ -5275,7 +5275,12 @@ foreach ($envName in @("stackchan", "stackchan_servo_calibration", "stackchan_re Assert-LockedPackage $packages "stackchan-arduino" "sha\.b7b98f5$" } Assert-LockedPackage $packages "ArduinoJson" "^7\.4\.3$" - Assert-LockedPackage $packages "M5Unified" "^0\.2\.17$" + Assert-StackchanSingleResolvedPackageVersion ` + -ResolvedPackages $packages -Environment $envName ` + -Name "M5GFX" -ExpectedVersion "0.2.24" + Assert-StackchanSingleResolvedPackageVersion ` + -ResolvedPackages $packages -Environment $envName ` + -Name "M5Unified" -ExpectedVersion "0.2.17" Assert-LockedPackage $packages "SCServo" "sha\.ee6ee4a$" } @@ -5302,37 +5307,8 @@ if ($gitResolvedWithoutSha.Count -gt 0) { $duplicateResolvedPackages = ConvertTo-Array $dependencyAudit.duplicateResolvedPackages foreach ($duplicate in $duplicateResolvedPackages) { $knownLegacyScServo = $duplicate.name -eq "SCServo" -and $duplicate.environment -in @("stackchan", "stackchan_servo_calibration") - $duplicateEntries = ConvertTo-Array $duplicate.entries - $duplicateVersions = @($duplicateEntries | ForEach-Object { [string]$_.version } | Sort-Object -Unique) - $knownPinnedM5GfxWithTransitiveCopy = ( - $duplicate.name -eq "M5GFX" -and - $duplicate.environment -in @("stackchan", "stackchan_servo_calibration", "stackchan_release_full") -and - $duplicate.count -eq 2 -and - $duplicateVersions.Count -eq 2 -and - $duplicateVersions[0] -eq "0.2.24" -and - $duplicateVersions[1] -eq "0.2.26" - ) - $knownPinnedM5UnifiedWithTransitiveCopy = ( - $duplicate.name -eq "M5Unified" -and - $duplicate.environment -in @("stackchan", "stackchan_servo_calibration") -and - $duplicate.count -eq 2 -and - $duplicateEntries.Count -eq 2 -and - $duplicateVersions.Count -eq 2 -and - $duplicateVersions[0] -eq "0.2.17" -and - $duplicateVersions[1] -eq "0.2.19" -and - @($duplicateEntries | Where-Object { - $_.version -eq "0.2.17" -and - $_.required -eq "M5Stack/M5Unified @ 0.2.17" - }).Count -eq 1 -and - @($duplicateEntries | Where-Object { - $_.version -eq "0.2.19" -and - $_.required -eq "M5Stack/M5Unified @ ^0.2.5" - }).Count -eq 1 - ) if ( - -not $knownLegacyScServo -and - -not $knownPinnedM5GfxWithTransitiveCopy -and - -not $knownPinnedM5UnifiedWithTransitiveCopy + -not $knownLegacyScServo ) { throw "dependency_lock.json has unexpected duplicate resolved package: $($duplicate.environment)/$($duplicate.name)" } From cf75a8dbddf90c2fcd02558f6ffc76899f34b697 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 03:58:20 -0400 Subject: [PATCH 17/46] Stabilize release authority line endings --- .gitattributes | 3 +++ ...st_release_toolchain_integration_contract.ps1 | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/.gitattributes b/.gitattributes index 786a27a9..4e77c16f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,6 @@ companion/gradlew text eol=lf +tools/release_toolchain_identity_allowlist.json text eol=lf +tools/release_toolchain_identity.ps1 text eol=lf +tools/verify_git_pack_semantics.py text eol=lf media/voice/rvc/model.pth filter=lfs diff=lfs merge=lfs -text media/voice/rvc/model.index filter=lfs diff=lfs merge=lfs -text diff --git a/tools/test_release_toolchain_integration_contract.ps1 b/tools/test_release_toolchain_integration_contract.ps1 index 10b7097a..5558fdb3 100644 --- a/tools/test_release_toolchain_integration_contract.ps1 +++ b/tools/test_release_toolchain_integration_contract.ps1 @@ -27,6 +27,22 @@ $packageText = Get-Content -LiteralPath $packagePath -Raw $verifierText = Get-Content -LiteralPath $verifierPath -Raw $helperText = Get-Content -LiteralPath $helperPath -Raw $semanticVerifierPath = Join-Path $PSScriptRoot 'verify_git_pack_semantics.py' +$authorityRelativePaths = @( + 'tools/release_toolchain_identity_allowlist.json', + 'tools/release_toolchain_identity.ps1', + 'tools/verify_git_pack_semantics.py' +) +$attributeOutput = @(& git -C (Split-Path -Parent $PSScriptRoot) ` + check-attr text eol -- @authorityRelativePaths) +if ($LASTEXITCODE -ne 0) { + throw 'Release authority line-ending policy could not be queried through Git.' +} +foreach ($relativePath in $authorityRelativePaths) { + if ("$relativePath`: text: set" -cnotin $attributeOutput -or + "$relativePath`: eol: lf" -cnotin $attributeOutput) { + throw "Release byte authority is not forced to LF in every checkout: $relativePath" + } +} $exactBootstrapPins = [ordered]@{ 'allowlist' = (Get-FileHash -Algorithm SHA256 -LiteralPath $allowlistPath).Hash 'identity helper' = (Get-FileHash -Algorithm SHA256 -LiteralPath $helperPath).Hash From 76d67273f5a8ec4ecdb603627c99e83f07aec64e Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 10:43:12 -0400 Subject: [PATCH 18/46] Harden pioarduino release-core sealing --- docs/FIRST_DEPLOY_STATUS.md | 32 +- docs/RELEASE_PROCESS.md | 13 +- tools/RELEASE_TOOLCHAIN_IDENTITY.md | 14 +- tools/package_release.ps1 | 2 +- .../release_toolchain_identity_allowlist.json | 10 +- tools/seal_pioarduino_release_core.ps1 | 751 ++++++++++++++++-- ...release_toolchain_integration_contract.ps1 | 108 ++- tools/verify_release_package.ps1 | 2 +- 8 files changed, 864 insertions(+), 68 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 2dbce1db..2e882902 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -1,6 +1,6 @@ # Stackchan First Deploy Status -Status timestamp: 2026-08-03 America/New_York +Status timestamp: 2026-08-04 America/New_York ## Current SEC-002 Qualification Hold (2026-08-03) @@ -30,6 +30,36 @@ Hold all flashing and physical promotion until the exact-host guard and exact cl package pass their two-cycle build and independent rebuild for the committed source, a reviewed rollback path exists, and a fresh passive no-motion preflight is complete. +### Authenticated exact-host retry evidence (2026-08-04) + +Source commit `cf75a8dbddf90c2fcd02558f6ffc76899f34b697` passed all 11 jobs in exact-head +GitHub Firmware run `30890019733`. The second local guarded attempt is preserved under outer run +`output/private/current-head-guard-promotions/20260804-101617-358f1e7e3ec3` and runner run +`output/private/toolchain-guard-smoke/20260804-101639-1304-c17b029421024407978cff288d3480a2`. +It failed closed during build A before packaging. Linking completed, but pioarduino had created the +51,819-byte target-specific `pioarduino-build.py.esp32s3` backup even though the governed profiles +have no `lib_ignore`. Its `checkprogsize` post-action then tried to restore the framework script +while the authenticated lifetime guard correctly held that file read-only. The resulting Windows +sharing violation is evidence of an upstream no-op toolchain write, not a locked `firmware.elf`, +firmware failure, or hardware failure. The contaminated framework-libs tree is not allowlisted. + +The same run exposed a separate evidence-wrapper defect: a collector-status query overlapped +`wpr -stop -compress`, returned duplicate-control-library error `0xc5580601`, and prevented formal +merged-ETL loss proof. Twenty-nine earlier collector samples reported zero loss, and WPR later +reported scoped idle, but those facts do not replace the missing final proof. The raw trace and all +failed-run evidence remain preserved; no package was produced and no bridge, robot, port, flash, +or actuator operation occurred. + +The reviewed source correction is now provisioned on the exact release host. It keeps empty +normalized `lib_ignore` read-only while preserving the backup immediately before a real LTO edit; +the WPR wrapper correction serializes stop/export against every WPR control command. The exact +installed core was sealed transactionally, the failed-run residual backup was archived and removed, +and an independently reviewed 24-component allowlist plus matching packager/verifier pins now cover +the resulting host bytes. The tracked contracts pass, but these corrections do not yet authorize a +retry: the change must be committed and pass exact-head CI, the old failed-run scheduled authority +must be archived and removed under its historical pins, and the corrected private authority chain +must then be rematerialized and pass its self-tests. The SEC-002 hold remains in force. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 1cca6abd..4318cef2 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -28,13 +28,22 @@ Python, PlatformIO, and both core trees before trusted Git/build-tool execution. or same-version installation fails closed. The exact self-hosted pioarduino core must already contain the reviewed sealed -`platforms/espressif32/builder/penv_setup.py` bytes. Provision an original matching core only from +`platforms/espressif32/builder/penv_setup.py` and +`platforms/espressif32/builder/frameworks/component_manager.py` bytes. The first correction binds +the installed distribution name. The second keeps an empty `lib_ignore` configuration genuinely +read-only instead of creating and restoring a target-specific framework backup during every build. +It also creates that backup immediately before a real LTO edit, preserving the upstream +edit/restore contract without reintroducing the empty-configuration side effect. +Provision an original matching core only from the clean trusted source checkout with `tools/seal_pioarduino_release_core.ps1 -ReleaseCoreDir C:\spio\pioarduino`, then regenerate and independently review the installed-byte allowlist. Do not run the seal during packaging and do not treat a packaged copy as authority. Its two-line distribution-name correction prevents the pinned 6.1.18 core from reinstalling itself and mutating the authenticated penv on every build; the -toolchain lifetime guard still rejects any later penv or platform mutation. +component-manager correction prevents the no-op backup side effect without permitting configured +component or library exclusions to mutate under a lease. Configured `lib_ignore` or LTO still +attempts its normal framework edit, so the toolchain lifetime guard rejects it while an authenticated +release lease is held. The seal does not create an exemption from namespace authority. The package is written under `output/release//` and includes safe display-only, servo-calibration, and secret-free full-online firmware binaries; preview media; an expression diff --git a/tools/RELEASE_TOOLCHAIN_IDENTITY.md b/tools/RELEASE_TOOLCHAIN_IDENTITY.md index 28e10103..837f3271 100644 --- a/tools/RELEASE_TOOLCHAIN_IDENTITY.md +++ b/tools/RELEASE_TOOLCHAIN_IDENTITY.md @@ -20,7 +20,19 @@ every build to reinstall the same 6.1.18 core and alternately swap `urllib3` 2.7 seal recognizes both names while retaining the exact v6.1.18 comparison and URL. It accepts only the reviewed original SHA-256, writes and verifies a private original-byte backup, atomically installs only the reviewed patched SHA-256, and also validates the backup on already-sealed runs. -It is a trusted-source provisioning step, not an archive-side authority or a package-time repair. +The same seal also corrects upstream `component_manager.py` so its framework-script backup is +created only when a nonempty `lib_ignore` configuration will actually edit the framework. With no +exclusions, the build is read-only and the authenticated namespace remains stable. Configured +exclusions are not silently exempted: their attempted edits remain visible to the lifetime guard +and fail under its leases. The same exact transform moves the shared backup responsibility to the +start of a real LTO edit, so upstream LTO builds still restore the original framework script after +their post-action. Governed release profiles do not enable LTO; if a future profile does, the +authenticated lifetime guard must observe and reject that framework mutation rather than allowing +it through the seal. Both corrections are exact original-to-sealed byte transformations with +separate private backups. The seal performs all-target path, hash, backup, reparse, and transaction +preflight before its first replacement and rolls back completed replacements if a later install +step fails. It is a trusted-source provisioning step, not an archive-side authority or a +package-time repair. The Python claim additionally requires an exact process isolation state. The caller must set `PYTHONNOUSERSITE=1`, `PYTHONSAFEPATH=1`, `PYTHONDONTWRITEBYTECODE=1`, `PYTHONHASHSEED=0`, diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index 3220e905..219fa6c9 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -75,7 +75,7 @@ if ($unexpectedGitOverrides.Count -gt 0) { } $releaseBootstrapNullAttributes = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } -$releaseToolchainAllowlistSha256 = '149BC9DC713E2550C3EA199337F4BC7F095A1B1497935000F27539EDE16B7CEB' # reviewed allowlist SHA-256 +$releaseToolchainAllowlistSha256 = 'E13A3558AA979DF08CBA42C46B537D32FD3CAF340A4E924A867C6D54B95E1FB7' # reviewed allowlist SHA-256 $releaseToolchainIdentityHelperSha256 = 'D63A93F4E9C3CFE057B59F963FCFF2C7CAF293300FF572E04F4B22608BD368A9' # reviewed identity helper SHA-256 $releaseToolchainSemanticVerifierSha256 = '649DE0BBF4A966ADF389A4C2F98190B87958E2ECCC1DF15A6E6FE04D86A4BEBA' # reviewed semantic verifier SHA-256 $releaseToolchainPreBuild = $null diff --git a/tools/release_toolchain_identity_allowlist.json b/tools/release_toolchain_identity_allowlist.json index e9aef613..28cffbac 100644 --- a/tools/release_toolchain_identity_allowlist.json +++ b/tools/release_toolchain_identity_allowlist.json @@ -12,11 +12,11 @@ ], "pythonExecutableRelativePath": "python.exe", "gitExecutableRelativePath": "cmd/git.exe", - "generatedUtc": "2026-08-04T07:39:28Z", + "generatedUtc": "2026-08-04T14:02:54Z", "review": { "status": "reviewed", - "reviewer": "Luna independent 24-component allowlist audit and corrected governed-environment B/C dependency audit; Codex integration", - "reason": "Independently audited all 24 windows_amd64 exact-host components: 20 remain byte-for-byte unchanged and four intentional deltas are promoted. The policy source now owner-qualifies M5Stack/M5GFX@0.2.24, requires the single reviewed M5GFX 0.2.24 and M5Unified 0.2.17 leaves and exact registry manifests, and fails closed on unexpected dependency topology or metadata. Two isolated fresh B/C roots reproduced the canonical dependency identities for all three governed environments: stackchan and stackchan_servo_calibration 248A6E4A19A7079F920B9C192AE47161377555442A80889742FD6B6FD43B986E (1119 files, 163164344 bytes), and stackchan_release_full AB7DB6C267BF82C5B8AC72624D266CB3A0ABE6D5E227BEF6B4750D52800B760E (866 files, 161476510 bytes). The release-core penv remains the previously reviewed A1E366961C1410F32DD31102F37725B500FF0F3FB8E9604A4BA34715FBC08C84 identity (3154 files, 124599325 bytes) after exact cache-side-effect restoration; the prior contaminated candidate remains rejected. This review promotes only these exact allowlist component bytes; it does not prove the retained runtime guard, establish release eligibility, authorize publication, flashing, or physical qualification." + "reviewer": "Luna independent post-provision 24-component allowlist audit; Codex integration", + "reason": "Independently verified candidate A644395D50BDBDE3FACF88D1553C31996634EB90EA152129CCFADA2EE18D259E against the tracked 24-component windows_amd64 allowlist and current installed governed bytes. Twenty-three components remain exact. The sole promoted delta is release-platform-espressif32 from B524A0DA5B8CF1CB88D82E808584FD6200CB090E41DFB7F6B60405DC119EF401 (616 files, 22522049 bytes) to B13CF308C54F37032645E2F0373E25318F79B24C328661C718491309BD3BD0F9 (616 files, 22522018 bytes). Release-core penv remains A1E366961C1410F32DD31102F37725B500FF0F3FB8E9604A4BA34715FBC08C84, framework libs remain 4C86469E19BD779918E4AF25EAE2A9393A018729492686F3A1A428EBBF08F075, and all three canonical libdeps identities remain unchanged. This review promotes only these exact host-installed bytes; it does not establish release eligibility or authorize packaging, publication, flashing, or physical qualification." }, "components": [ { @@ -135,9 +135,9 @@ "name": "release-platform-espressif32", "phase": "preBuild", "identitySchema": "stackchan.byte-tree.v1", - "treeSha256": "B524A0DA5B8CF1CB88D82E808584FD6200CB090E41DFB7F6B60405DC119EF401", + "treeSha256": "B13CF308C54F37032645E2F0373E25318F79B24C328661C718491309BD3BD0F9", "fileCount": 616, - "bytes": 22522049 + "bytes": 22522018 }, { "name": "release-package-contrib-piohome", diff --git a/tools/seal_pioarduino_release_core.ps1 b/tools/seal_pioarduino_release_core.ps1 index 13da7c6d..80acb9cd 100644 --- a/tools/seal_pioarduino_release_core.ps1 +++ b/tools/seal_pioarduino_release_core.ps1 @@ -1,80 +1,719 @@ +[CmdletBinding(DefaultParameterSetName = 'Seal')] param( - [Parameter(Mandatory = $true)][string]$ReleaseCoreDir + [Parameter(Mandatory = $true, ParameterSetName = 'Seal')][string]$ReleaseCoreDir, + [Parameter(Mandatory = $true, ParameterSetName = 'SelfTest')][switch]$SelfTest ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -$originalSha256 = '6FC4C8912CBB1FA65A84A527EC5A3CB1280BBA399B02D4885C8C1D91AB7CC9D0' -$sealedSha256 = 'D16479CFAD23EF7C392B48C66B9E2422C0294E185746814ED4F7F9E4EFFACB60' -$relativeTarget = 'platforms/espressif32/builder/penv_setup.py' +$utf8 = [Text.UTF8Encoding]::new($false, $true) -$coreItem = Get-Item -LiteralPath $ReleaseCoreDir -Force -ErrorAction Stop -if (-not $coreItem.PSIsContainer -or - ($coreItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { - throw 'ReleaseCoreDir must be one real PlatformIO core directory.' +function Get-NormalizedFullPath { + param([Parameter(Mandatory = $true)][string]$LiteralPath) + $fullPath = [IO.Path]::GetFullPath($LiteralPath) + $volumeRoot = [IO.Path]::GetPathRoot($fullPath) + if ($fullPath.Equals($volumeRoot, [StringComparison]::OrdinalIgnoreCase)) { + return $volumeRoot + } + return $fullPath.TrimEnd('\', '/') } -$coreRoot = $coreItem.FullName.TrimEnd('\', '/') -$targetPath = [IO.Path]::GetFullPath((Join-Path $coreRoot $relativeTarget)) -$targetItem = Get-Item -LiteralPath $targetPath -Force -ErrorAction Stop -if ($targetItem.PSIsContainer -or - ($targetItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { - throw 'The pioarduino penv setup target must be one real file.' + +function Get-PathVolumeRoot { + param([Parameter(Mandatory = $true)][string]$LiteralPath) + $root = [IO.Path]::GetPathRoot((Get-NormalizedFullPath -LiteralPath $LiteralPath)) + if ([string]::IsNullOrWhiteSpace($root)) { + throw "Path has no volume root: $LiteralPath" + } + return Get-NormalizedFullPath -LiteralPath $root } -$actualSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $targetPath).Hash -$backupRoot = Join-Path (Split-Path -Parent $PSScriptRoot) 'output/private/toolchain-backups' -$backupPath = Join-Path $backupRoot "penv_setup.py.$originalSha256.bak" -if ($actualSha256 -ceq $sealedSha256) { - if (-not (Test-Path -LiteralPath $backupPath -PathType Leaf) -or - (Get-FileHash -Algorithm SHA256 -LiteralPath $backupPath).Hash -cne $originalSha256) { - throw 'Sealed pioarduino release core is missing its exact private original-byte backup.' +function Assert-PathContained { + param( + [Parameter(Mandatory = $true)][string]$LiteralPath, + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Label + ) + $fullPath = Get-NormalizedFullPath -LiteralPath $LiteralPath + $fullRoot = Get-NormalizedFullPath -LiteralPath $Root + $rootPrefix = if ($fullRoot.EndsWith([IO.Path]::DirectorySeparatorChar)) { + $fullRoot + } else { + $fullRoot + [IO.Path]::DirectorySeparatorChar } - Write-Output "Pioarduino release core is already sealed: $targetPath" - exit 0 + if (-not $fullPath.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "$Label is outside its reviewed root: $fullPath" + } + return $fullPath } -if ($actualSha256 -cne $originalSha256) { - throw "Refusing unreviewed pioarduino penv setup bytes: $actualSha256" + +function Assert-ExistingPathChainReal { + param( + [Parameter(Mandatory = $true)][string]$LiteralPath, + [Parameter(Mandatory = $true)][string]$StopAt, + [Parameter(Mandatory = $true)][string]$Label + ) + $cursor = Get-NormalizedFullPath -LiteralPath $LiteralPath + $stop = Get-NormalizedFullPath -LiteralPath $StopAt + if ($cursor -cne $stop) { + [void](Assert-PathContained -LiteralPath $cursor -Root $stop -Label $Label) + } + while ($true) { + if (Test-Path -LiteralPath $cursor) { + $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "$Label contains a reparse point: $cursor" + } + } + if ($cursor.Equals($stop, [StringComparison]::OrdinalIgnoreCase)) { break } + $parent = [IO.Path]::GetDirectoryName($cursor) + if ([string]::IsNullOrWhiteSpace($parent) -or $parent -ceq $cursor) { + throw "$Label path chain did not reach its reviewed root." + } + $cursor = Get-NormalizedFullPath -LiteralPath $parent + } } -$originalBytes = [IO.File]::ReadAllBytes($targetPath) -$utf8 = [Text.UTF8Encoding]::new($false, $true) -$originalText = $utf8.GetString($originalBytes) -$oldDependency = ' "platformio": "https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.18.zip",' -$newDependency = ' "pioarduino-core": "https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.18.zip",' -$oldBranch = ' elif name == "platformio":' -$newBranch = ' elif name in ("platformio", "pioarduino-core"):' -if (($originalText.Split($oldDependency).Count - 1) -ne 1 -or - ($originalText.Split($oldBranch).Count - 1) -ne 1) { - throw 'Reviewed pioarduino seal anchors are missing or ambiguous.' +$targets = @( + [ordered]@{ + label = 'pioarduino penv setup' + relative = 'platforms/espressif32/builder/penv_setup.py' + originalSha256 = '6FC4C8912CBB1FA65A84A527EC5A3CB1280BBA399B02D4885C8C1D91AB7CC9D0' + sealedSha256 = 'D16479CFAD23EF7C392B48C66B9E2422C0294E185746814ED4F7F9E4EFFACB60' + backupLeaf = 'penv_setup.py.6FC4C8912CBB1FA65A84A527EC5A3CB1280BBA399B02D4885C8C1D91AB7CC9D0.bak' + mode = 'penv-distribution-name' + }, + [ordered]@{ + label = 'pioarduino component manager' + relative = 'platforms/espressif32/builder/frameworks/component_manager.py' + originalSha256 = '756B5AAF863F0BCC0E7CB88C9DDBA3FCE2055E1DC6A4D7362ADC057F813324F8' + sealedSha256 = 'DCABDC11CA1DEA4FBF3854811BF24CD2ADC9AD692785FB27B1EBA0CF41C924E7' + backupLeaf = 'component_manager.py.756B5AAF863F0BCC0E7CB88C9DDBA3FCE2055E1DC6A4D7362ADC057F813324F8.bak' + mode = 'component-manager-noop-and-lto' + } +) + +function Get-SealedTargetBytes { + param( + [Parameter(Mandatory = $true)][string]$Mode, + [Parameter(Mandatory = $true)][byte[]]$OriginalBytes + ) + + $originalText = $utf8.GetString($OriginalBytes) + if ($Mode -ceq 'penv-distribution-name') { + $oldDependency = ' "platformio": "https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.18.zip",' + $newDependency = ' "pioarduino-core": "https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.18.zip",' + $oldBranch = ' elif name == "platformio":' + $newBranch = ' elif name in ("platformio", "pioarduino-core"):' + foreach ($anchor in @($oldDependency, $oldBranch)) { + if ([regex]::Matches($originalText, [regex]::Escape($anchor)).Count -ne 1) { + throw 'Reviewed pioarduino penv seal anchors are missing or ambiguous.' + } + } + # The reviewed installed identity predates this sealer and has LF only on + # these two corrected lines. Reproduce those exact bytes rather than + # silently promoting a whole-file or all-CRLF variant. + $oldDependencyLine = $oldDependency + "`r`n" + $newDependencyLine = $newDependency + "`n" + $oldBranchLine = $oldBranch + "`r`n" + $newBranchLine = $newBranch + "`n" + if ([regex]::Matches($originalText, [regex]::Escape($oldDependencyLine)).Count -ne 1 -or + [regex]::Matches($originalText, [regex]::Escape($oldBranchLine)).Count -ne 1) { + throw 'Reviewed pioarduino penv line-ending anchors are missing or ambiguous.' + } + return $utf8.GetBytes($originalText.Replace($oldDependencyLine, $newDependencyLine). + Replace($oldBranchLine, $newBranchLine)) + } + + if ($Mode -ceq 'component-manager-noop-and-lto') { + $oldIgnoreBlock = ( + " # Create backup before processing lib_ignore`r`n" + + " if not self.ignored_libs:`r`n" + + " self._backup_pioarduino_build_py()`r`n`r`n" + + " # Get lib_ignore entries from current environment only`r`n" + + " lib_ignore_entries = self._get_lib_ignore_entries()`r`n`r`n" + + " if lib_ignore_entries:`r`n" + + " self.ignored_libs.update(lib_ignore_entries)`r`n") + $newIgnoreBlock = ( + " # Get lib_ignore entries from current environment only`r`n" + + " lib_ignore_entries = self._get_lib_ignore_entries()`r`n`r`n" + + " if lib_ignore_entries:`r`n" + + " self._backup_pioarduino_build_py()`r`n" + + " self.ignored_libs.update(lib_ignore_entries)`r`n") + $oldLtoBlock = ( + ' def remove_no_lto_flags(self) -> bool:' + "`r`n" + + ' """' + "`r`n" + + ' Remove all -fno-lto flags from pioarduino-build.py.' + "`r`n`r`n" + + ' Removes all occurrences of -fno-lto from CCFLAGS, CFLAGS, CXXFLAGS,' + "`r`n" + + ' and LINKFLAGS in the Arduino build script.' + "`r`n`r`n" + + ' Returns:' + "`r`n" + + ' bool: True if successful, False otherwise' + "`r`n" + + ' """' + "`r`n" + + ' build_py_path = str(Path(self.config.arduino_libs_mcu) / "pioarduino-build.py")' + + "`r`n`r`n" + + ' if not os.path.exists(build_py_path):' + "`r`n" + + ' print(f"Warning: pioarduino-build.py not found at {build_py_path}")' + "`r`n" + + ' return False' + "`r`n`r`n" + + ' try:' + "`r`n") + $newLtoBlock = $oldLtoBlock.Replace( + " return False`r`n`r`n try:`r`n", + (" return False`r`n`r`n" + + " self.backup_manager.backup_pioarduino_build_py()`r`n`r`n" + + " try:`r`n")) + if ([regex]::Matches($originalText, [regex]::Escape($oldIgnoreBlock)).Count -ne 1 -or + [regex]::Matches($originalText, [regex]::Escape($newIgnoreBlock)).Count -ne 0 -or + [regex]::Matches($originalText, [regex]::Escape($oldLtoBlock)).Count -ne 1 -or + [regex]::Matches($originalText, [regex]::Escape($newLtoBlock)).Count -ne 0) { + throw 'Reviewed pioarduino component-manager seal anchors are missing or ambiguous.' + } + $sealedText = $originalText.Replace($oldIgnoreBlock, $newIgnoreBlock). + Replace($oldLtoBlock, $newLtoBlock) + return $utf8.GetBytes($sealedText) + } + + throw "Unknown pioarduino seal mode: $Mode" +} + +function Get-BytesSha256 { + param([Parameter(Mandatory = $true)][byte[]]$Bytes) + $hasher = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($hasher.ComputeHash($Bytes)) -replace '-', '') + } finally { + $hasher.Dispose() + } +} + +function New-SealTransactionPlanSet { + param( + [Parameter(Mandatory = $true)][string]$CoreRoot, + [Parameter(Mandatory = $true)][string]$BackupRoot, + [Parameter(Mandatory = $true)][string]$BackupAuthorityRoot, + [Parameter(Mandatory = $true)][object[]]$Targets, + [Parameter(Mandatory = $true)][string]$TransactionId + ) + + $normalizedCoreRoot = Get-NormalizedFullPath -LiteralPath $CoreRoot + $normalizedBackupRoot = Get-NormalizedFullPath -LiteralPath $BackupRoot + $normalizedBackupAuthorityRoot = Get-NormalizedFullPath -LiteralPath $BackupAuthorityRoot + $coreVolumeRoot = Get-PathVolumeRoot -LiteralPath $normalizedCoreRoot + $backupVolumeRoot = Get-PathVolumeRoot -LiteralPath $normalizedBackupAuthorityRoot + [void](Assert-ExistingPathChainReal -LiteralPath $normalizedCoreRoot ` + -StopAt $coreVolumeRoot -Label 'pioarduino release core ancestry') + [void](Assert-ExistingPathChainReal -LiteralPath $normalizedBackupAuthorityRoot ` + -StopAt $backupVolumeRoot -Label 'backup authority ancestry') + [void](Assert-PathContained -LiteralPath $normalizedBackupRoot ` + -Root $normalizedBackupAuthorityRoot -Label 'private toolchain backup root') + [void](Assert-ExistingPathChainReal -LiteralPath $normalizedBackupRoot ` + -StopAt $backupVolumeRoot -Label 'private toolchain backup root ancestry') + + $safeTransactionId = $TransactionId -replace '[^A-Za-z0-9._-]', '-' + if ([string]::IsNullOrWhiteSpace($safeTransactionId)) { + throw 'Seal transaction identifier is empty after normalization.' + } + $plans = [Collections.Generic.List[object]]::new() + $alreadySealed = [Collections.Generic.List[string]]::new() + foreach ($target in $Targets) { + $targetPath = Assert-PathContained ` + -LiteralPath (Join-Path $normalizedCoreRoot ([string]$target.relative)) ` + -Root $normalizedCoreRoot -Label ([string]$target.label) + [void](Assert-ExistingPathChainReal -LiteralPath $targetPath -StopAt $normalizedCoreRoot ` + -Label ([string]$target.label)) + $targetItem = Get-Item -LiteralPath $targetPath -Force -ErrorAction Stop + if ($targetItem.PSIsContainer -or + ($targetItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "$([string]$target.label) target must be one real file." + } + $targetDirectory = Split-Path -Parent $targetPath + $targetLeaf = Split-Path -Leaf $targetPath + foreach ($transactionKind in @('sealed', 'displaced', 'rollback')) { + $staleTransactions = @(Get-ChildItem -LiteralPath $targetDirectory -Force ` + -Filter "$targetLeaf.stackchan-$transactionKind-*.tmp" -ErrorAction Stop) + if ($staleTransactions.Count -gt 0) { + throw "Refusing stale $([string]$target.label) transaction artifact: $($staleTransactions[0].FullName)" + } + } + $backupPath = Assert-PathContained ` + -LiteralPath (Join-Path $normalizedBackupRoot ([string]$target.backupLeaf)) ` + -Root $normalizedBackupRoot -Label "$([string]$target.label) backup" + [void](Assert-ExistingPathChainReal -LiteralPath $backupPath -StopAt $backupVolumeRoot ` + -Label "$([string]$target.label) backup") + $actualSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $targetPath).Hash + if ($actualSha256 -ceq [string]$target.sealedSha256) { + if (-not (Test-Path -LiteralPath $backupPath -PathType Leaf) -or + (Get-FileHash -Algorithm SHA256 -LiteralPath $backupPath).Hash -cne + [string]$target.originalSha256) { + throw "Sealed $([string]$target.label) is missing its exact private original-byte backup." + } + $alreadySealed.Add($targetPath) | Out-Null + continue + } + if ($actualSha256 -cne [string]$target.originalSha256) { + throw "Refusing unreviewed $([string]$target.label) bytes: $actualSha256" + } + + $originalBytes = [IO.File]::ReadAllBytes($targetPath) + if ((Get-BytesSha256 -Bytes $originalBytes) -cne [string]$target.originalSha256) { + throw "$([string]$target.label) changed while its original bytes were read." + } + $sealedBytes = if ($target -is [Collections.IDictionary] -and + $target.Contains('sealedBytes')) { + [byte[]]$target.sealedBytes + } else { + Get-SealedTargetBytes -Mode ([string]$target.mode) -OriginalBytes $originalBytes + } + if ((Get-BytesSha256 -Bytes $sealedBytes) -cne [string]$target.sealedSha256) { + throw "Generated $([string]$target.label) bytes do not match the reviewed sealed identity." + } + if (Test-Path -LiteralPath $backupPath) { + $backupItem = Get-Item -LiteralPath $backupPath -Force -ErrorAction Stop + if ($backupItem.PSIsContainer -or + ($backupItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + (Get-FileHash -Algorithm SHA256 -LiteralPath $backupPath).Hash -cne + [string]$target.originalSha256) { + throw "Private $([string]$target.label) backup is not the reviewed original file." + } + } + $tempPath = Assert-PathContained ` + -LiteralPath "$targetPath.stackchan-sealed-$safeTransactionId.tmp" ` + -Root $normalizedCoreRoot -Label "$([string]$target.label) seal temporary" + $displacedPath = Assert-PathContained ` + -LiteralPath "$targetPath.stackchan-displaced-$safeTransactionId.tmp" ` + -Root $normalizedCoreRoot -Label "$([string]$target.label) displaced temporary" + $rollbackPath = Assert-PathContained ` + -LiteralPath "$targetPath.stackchan-rollback-$safeTransactionId.tmp" ` + -Root $normalizedCoreRoot -Label "$([string]$target.label) rollback temporary" + foreach ($transactionPath in @($tempPath, $displacedPath, $rollbackPath)) { + [void](Assert-ExistingPathChainReal -LiteralPath $transactionPath ` + -StopAt $normalizedCoreRoot -Label "$([string]$target.label) transaction path") + if (Test-Path -LiteralPath $transactionPath) { + throw "Refusing pre-existing seal transaction path for $([string]$target.label): $transactionPath" + } + } + $plans.Add([pscustomobject][ordered]@{ + label = [string]$target.label + targetPath = $targetPath + backupPath = $backupPath + tempPath = $tempPath + displacedPath = $displacedPath + rollbackPath = $rollbackPath + originalBytes = $originalBytes + originalSha256 = [string]$target.originalSha256 + sealedBytes = $sealedBytes + sealedSha256 = [string]$target.sealedSha256 + }) | Out-Null + } + return [pscustomobject][ordered]@{ + coreRoot = $normalizedCoreRoot + backupRoot = $normalizedBackupRoot + backupVolumeRoot = $backupVolumeRoot + plans = @($plans) + alreadySealed = @($alreadySealed) + } +} + +function Invoke-SealTargetTransaction { + param( + [Parameter(Mandatory = $true)]$PlanSet, + [ValidateSet('none', 'prepare-second', 'install-second')] + [string]$FailureInjection = 'none' + ) + + $plans = @($PlanSet.plans) + if ($plans.Count -eq 0) { + return [pscustomobject][ordered]@{ + status = 'already-sealed' + plannedCount = 0 + installedCount = 0 + alreadySealedCount = @($PlanSet.alreadySealed).Count + installedPaths = @() + } + } + + [void][IO.Directory]::CreateDirectory([string]$PlanSet.backupRoot) + [void](Assert-ExistingPathChainReal -LiteralPath ([string]$PlanSet.backupRoot) ` + -StopAt ([string]$PlanSet.backupVolumeRoot) ` + -Label 'private toolchain backup root after creation') + foreach ($plan in $plans) { + if (-not (Test-Path -LiteralPath ([string]$plan.backupPath))) { + [IO.File]::WriteAllBytes([string]$plan.backupPath, [byte[]]$plan.originalBytes) + } + if ((Get-FileHash -Algorithm SHA256 -LiteralPath ([string]$plan.backupPath)).Hash -cne + [string]$plan.originalSha256) { + throw "Private $([string]$plan.label) backup does not match the reviewed original bytes." + } + } + + $preparedPlans = [Collections.Generic.List[object]]::new() + try { + for ($index = 0; $index -lt $plans.Count; $index++) { + $plan = $plans[$index] + if ($FailureInjection -ceq 'prepare-second' -and $index -eq 1) { + throw 'Injected second-target preparation failure.' + } + [IO.File]::WriteAllBytes([string]$plan.tempPath, [byte[]]$plan.sealedBytes) + $preparedPlans.Add($plan) | Out-Null + if ((Get-FileHash -Algorithm SHA256 -LiteralPath ([string]$plan.tempPath)).Hash -cne + [string]$plan.sealedSha256) { + throw "Temporary $([string]$plan.label) seal bytes changed before installation." + } + } + } catch { + foreach ($plan in $plans) { + if (Test-Path -LiteralPath ([string]$plan.tempPath)) { + Remove-Item -LiteralPath ([string]$plan.tempPath) -Force + } + } + throw + } + + $installedPlans = [object[]]::new($plans.Count) + $installedCount = 0 + try { + for ($index = 0; $index -lt $plans.Count; $index++) { + $plan = $plans[$index] + [IO.File]::Replace([string]$plan.tempPath, [string]$plan.targetPath, + [string]$plan.displacedPath, $true) + $installedPlans[$installedCount] = $plan + $installedCount++ + if ($FailureInjection -ceq 'install-second' -and $index -eq 1) { + throw 'Injected second-target installation failure.' + } + if ((Get-FileHash -Algorithm SHA256 -LiteralPath ([string]$plan.targetPath)).Hash -cne + [string]$plan.sealedSha256) { + throw "$([string]$plan.label) seal did not persist the reviewed bytes." + } + if ((Get-FileHash -Algorithm SHA256 -LiteralPath ([string]$plan.displacedPath)).Hash -cne + [string]$plan.originalSha256) { + throw "$([string]$plan.label) displaced bytes do not match the reviewed original." + } + } + } catch { + $installError = $_ + $rollbackFailures = [Collections.Generic.List[string]]::new() + for ($index = $installedCount - 1; $index -ge 0; $index--) { + $installedPlan = $installedPlans[$index] + try { + [IO.File]::Replace([string]$installedPlan.displacedPath, + [string]$installedPlan.targetPath, [string]$installedPlan.rollbackPath, $true) + if ((Get-FileHash -Algorithm SHA256 -LiteralPath ([string]$installedPlan.targetPath)).Hash -cne + [string]$installedPlan.originalSha256) { + throw 'Rollback target does not match the reviewed original bytes.' + } + if ((Get-FileHash -Algorithm SHA256 -LiteralPath ([string]$installedPlan.rollbackPath)).Hash -cne + [string]$installedPlan.sealedSha256) { + throw 'Rollback-displaced target does not match the reviewed sealed bytes.' + } + Remove-Item -LiteralPath ([string]$installedPlan.rollbackPath) -Force + } catch { + $rollbackFailures.Add("$([string]$installedPlan.label): $($_.Exception.Message)") | Out-Null + } + } + if ($rollbackFailures.Count -gt 0) { + throw "Pioarduino seal failed and rollback was incomplete. Original error: $($installError.Exception.Message) Rollback: $($rollbackFailures -join '; ')" + } + throw $installError + } finally { + foreach ($plan in $plans) { + if (Test-Path -LiteralPath ([string]$plan.tempPath)) { + Remove-Item -LiteralPath ([string]$plan.tempPath) -Force + } + } + } + + $installedPaths = [Collections.Generic.List[string]]::new() + for ($index = 0; $index -lt $installedCount; $index++) { + $installedPlan = $installedPlans[$index] + Remove-Item -LiteralPath ([string]$installedPlan.displacedPath) -Force + $installedPaths.Add([string]$installedPlan.targetPath) | Out-Null + } + return [pscustomobject][ordered]@{ + status = 'pass' + plannedCount = $plans.Count + installedCount = $installedCount + alreadySealedCount = @($PlanSet.alreadySealed).Count + installedPaths = @($installedPaths) + } +} + +function Test-NoSealTransactionArtifacts { + param([Parameter(Mandatory = $true)][string]$CoreRoot) + return @(Get-ChildItem -LiteralPath $CoreRoot -Recurse -Force -ErrorAction Stop | + Where-Object { $_.Name -match '\.stackchan-(?:sealed|displaced|rollback)-.+\.tmp$' }).Count -eq 0 } -$sealedText = $originalText.Replace($oldDependency, $newDependency).Replace($oldBranch, $newBranch) -$sealedBytes = $utf8.GetBytes($sealedText) -$tempPath = "$targetPath.stackchan-sealed-$PID.tmp" -if (Test-Path -LiteralPath $tempPath) { - throw "Refusing pre-existing seal temporary path: $tempPath" +function Assert-SelfTestCondition { + param([bool]$Condition, [Parameter(Mandatory = $true)][string]$Message) + if (-not $Condition) { throw $Message } } -New-Item -ItemType Directory -Force -Path $backupRoot | Out-Null -if (-not (Test-Path -LiteralPath $backupPath)) { - [IO.File]::WriteAllBytes($backupPath, $originalBytes) + +function New-SyntheticTargets { + $definitions = [Collections.Generic.List[object]]::new() + foreach ($entry in @( + [ordered]@{ label = 'synthetic one'; relative = 'builder/one.txt'; + original = "synthetic-one-original`n"; sealed = "synthetic-one-sealed`n" }, + [ordered]@{ label = 'synthetic two'; relative = 'builder/frameworks/two.txt'; + original = "synthetic-two-original`n"; sealed = "synthetic-two-sealed`n" } + )) { + $originalBytes = $utf8.GetBytes([string]$entry.original) + $sealedBytes = $utf8.GetBytes([string]$entry.sealed) + $originalSha256 = Get-BytesSha256 -Bytes $originalBytes + $leaf = Split-Path -Leaf ([string]$entry.relative) + $definitions.Add([ordered]@{ + label = [string]$entry.label + relative = [string]$entry.relative + originalSha256 = $originalSha256 + sealedSha256 = Get-BytesSha256 -Bytes $sealedBytes + backupLeaf = "$leaf.$originalSha256.bak" + sealedBytes = $sealedBytes + originalBytes = $originalBytes + }) | Out-Null + } + return @($definitions) +} + +function New-SyntheticScenario { + param( + [Parameter(Mandatory = $true)][string]$SelfTestRoot, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][object[]]$Targets + ) + $root = Join-Path $SelfTestRoot $Name + $coreRoot = Join-Path $root 'core' + $backupAuthorityRoot = Join-Path $root 'authority' + $backupRoot = Join-Path $backupAuthorityRoot 'private/backups' + [void][IO.Directory]::CreateDirectory($coreRoot) + [void][IO.Directory]::CreateDirectory($backupAuthorityRoot) + foreach ($target in $Targets) { + $targetPath = Join-Path $coreRoot ([string]$target.relative) + [void][IO.Directory]::CreateDirectory((Split-Path -Parent $targetPath)) + [IO.File]::WriteAllBytes($targetPath, [byte[]]$target.originalBytes) + } + return [pscustomobject][ordered]@{ + root = $root + coreRoot = $coreRoot + backupAuthorityRoot = $backupAuthorityRoot + backupRoot = $backupRoot + } +} + +function Test-SyntheticTargetHashes { + param( + [Parameter(Mandatory = $true)]$Scenario, + [Parameter(Mandatory = $true)][object[]]$Targets, + [Parameter(Mandatory = $true)][ValidateSet('original', 'sealed')][string]$State + ) + foreach ($target in $Targets) { + $expected = if ($State -ceq 'original') { + [string]$target.originalSha256 + } else { + [string]$target.sealedSha256 + } + $path = Join-Path ([string]$Scenario.coreRoot) ([string]$target.relative) + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash -cne $expected) { + return $false + } + } + return $true } -if ((Get-FileHash -Algorithm SHA256 -LiteralPath $backupPath).Hash -cne $originalSha256) { - throw 'Private pioarduino backup does not match the reviewed original bytes.' + +function Test-SyntheticBackups { + param( + [Parameter(Mandatory = $true)]$Scenario, + [Parameter(Mandatory = $true)][object[]]$Targets + ) + foreach ($target in $Targets) { + $path = Join-Path ([string]$Scenario.backupRoot) ([string]$target.backupLeaf) + if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or + (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash -cne + [string]$target.originalSha256) { + return $false + } + } + return $true } -try { - [IO.File]::WriteAllBytes($tempPath, $sealedBytes) - if ((Get-FileHash -Algorithm SHA256 -LiteralPath $tempPath).Hash -cne $sealedSha256) { - throw 'Generated pioarduino seal bytes do not match the reviewed patched identity.' +function Invoke-SealSelfTest { + $systemTempRoot = Get-NormalizedFullPath -LiteralPath ([IO.Path]::GetTempPath()) + $systemTempVolumeRoot = Get-PathVolumeRoot -LiteralPath $systemTempRoot + [void](Assert-ExistingPathChainReal -LiteralPath $systemTempRoot ` + -StopAt $systemTempVolumeRoot -Label 'system temporary root ancestry') + $selfTestRoot = Join-Path $systemTempRoot ` + "stackchan-pioarduino-seal-selftest-$PID-$([guid]::NewGuid().ToString('N'))" + if (Test-Path -LiteralPath $selfTestRoot) { + throw "Synthetic seal self-test root already exists: $selfTestRoot" } - [IO.File]::Replace($tempPath, $targetPath, $null) -} finally { - if (Test-Path -LiteralPath $tempPath) { - Remove-Item -LiteralPath $tempPath -Force + [void][IO.Directory]::CreateDirectory($selfTestRoot) + $selfTestRoot = Assert-PathContained -LiteralPath $selfTestRoot ` + -Root $systemTempRoot -Label 'synthetic seal self-test root' + [void](Assert-ExistingPathChainReal -LiteralPath $selfTestRoot ` + -StopAt $systemTempVolumeRoot -Label 'synthetic seal self-test root ancestry') + $targets = @(New-SyntheticTargets) + $record = $null + try { + $prep = New-SyntheticScenario -SelfTestRoot $selfTestRoot -Name 'prep-failure' ` + -Targets $targets + $prepPlan = New-SealTransactionPlanSet -CoreRoot $prep.coreRoot ` + -BackupRoot $prep.backupRoot -BackupAuthorityRoot $prep.backupAuthorityRoot ` + -Targets $targets -TransactionId 'selftest-prep' + $prepFailureObserved = $false + try { + [void](Invoke-SealTargetTransaction -PlanSet $prepPlan ` + -FailureInjection 'prepare-second') + } catch { + $prepFailureObserved = $_.Exception.Message -match 'Injected second-target preparation failure' + } + Assert-SelfTestCondition $prepFailureObserved 'Synthetic preparation failure was not observed.' + Assert-SelfTestCondition (Test-SyntheticTargetHashes -Scenario $prep -Targets $targets ` + -State original) 'Preparation failure changed a synthetic target.' + Assert-SelfTestCondition (Test-NoSealTransactionArtifacts -CoreRoot $prep.coreRoot) ` + 'Preparation failure left a transaction artifact.' + + $rollback = New-SyntheticScenario -SelfTestRoot $selfTestRoot -Name 'install-rollback' ` + -Targets $targets + $rollbackPlan = New-SealTransactionPlanSet -CoreRoot $rollback.coreRoot ` + -BackupRoot $rollback.backupRoot -BackupAuthorityRoot $rollback.backupAuthorityRoot ` + -Targets $targets -TransactionId 'selftest-rollback' + $installFailureObserved = $false + try { + [void](Invoke-SealTargetTransaction -PlanSet $rollbackPlan ` + -FailureInjection 'install-second') + } catch { + $installFailureObserved = $_.Exception.Message -match 'Injected second-target installation failure' + } + Assert-SelfTestCondition $installFailureObserved 'Synthetic installation failure was not observed.' + Assert-SelfTestCondition (Test-SyntheticTargetHashes -Scenario $rollback -Targets $targets ` + -State original) 'Installation rollback did not restore both synthetic targets.' + Assert-SelfTestCondition (Test-NoSealTransactionArtifacts -CoreRoot $rollback.coreRoot) ` + 'Installation rollback left a transaction artifact.' + Assert-SelfTestCondition (Test-SyntheticBackups -Scenario $rollback -Targets $targets) ` + 'Installation rollback did not retain exact synthetic original backups.' + + $success = New-SyntheticScenario -SelfTestRoot $selfTestRoot -Name 'success-idempotence' ` + -Targets $targets + $successPlan = New-SealTransactionPlanSet -CoreRoot $success.coreRoot ` + -BackupRoot $success.backupRoot -BackupAuthorityRoot $success.backupAuthorityRoot ` + -Targets $targets -TransactionId 'selftest-success' + $successResult = Invoke-SealTargetTransaction -PlanSet $successPlan + Assert-SelfTestCondition ($successResult.status -ceq 'pass' -and + [int]$successResult.installedCount -eq 2) ` + 'Synthetic success did not install exactly two targets.' + Assert-SelfTestCondition (Test-SyntheticTargetHashes -Scenario $success -Targets $targets ` + -State sealed) 'Synthetic success did not persist both sealed targets.' + Assert-SelfTestCondition (Test-SyntheticBackups -Scenario $success -Targets $targets) ` + 'Synthetic success did not retain exact original backups.' + Assert-SelfTestCondition (Test-NoSealTransactionArtifacts -CoreRoot $success.coreRoot) ` + 'Synthetic success left a transaction artifact.' + $idempotentPlan = New-SealTransactionPlanSet -CoreRoot $success.coreRoot ` + -BackupRoot $success.backupRoot -BackupAuthorityRoot $success.backupAuthorityRoot ` + -Targets $targets -TransactionId 'selftest-idempotent' + $idempotentResult = Invoke-SealTargetTransaction -PlanSet $idempotentPlan + Assert-SelfTestCondition ($idempotentResult.status -ceq 'already-sealed' -and + [int]$idempotentResult.alreadySealedCount -eq 2) ` + 'Synthetic already-sealed run was not exactly idempotent.' + + $mixed = New-SyntheticScenario -SelfTestRoot $selfTestRoot -Name 'mixed-state' ` + -Targets $targets + [void][IO.Directory]::CreateDirectory([string]$mixed.backupRoot) + $firstTarget = $targets[0] + [IO.File]::WriteAllBytes( + (Join-Path $mixed.backupRoot ([string]$firstTarget.backupLeaf)), + [byte[]]$firstTarget.originalBytes) + [IO.File]::WriteAllBytes( + (Join-Path $mixed.coreRoot ([string]$firstTarget.relative)), + [byte[]]$firstTarget.sealedBytes) + $mixedPlan = New-SealTransactionPlanSet -CoreRoot $mixed.coreRoot ` + -BackupRoot $mixed.backupRoot -BackupAuthorityRoot $mixed.backupAuthorityRoot ` + -Targets $targets -TransactionId 'selftest-mixed' + Assert-SelfTestCondition (@($mixedPlan.plans).Count -eq 1 -and + @($mixedPlan.alreadySealed).Count -eq 1) ` + 'Synthetic mixed-state policy did not isolate exactly one unsealed target.' + $mixedResult = Invoke-SealTargetTransaction -PlanSet $mixedPlan + Assert-SelfTestCondition ($mixedResult.status -ceq 'pass' -and + [int]$mixedResult.installedCount -eq 1 -and + [int]$mixedResult.alreadySealedCount -eq 1) ` + 'Synthetic mixed-state run did not seal exactly its original target.' + Assert-SelfTestCondition (Test-SyntheticTargetHashes -Scenario $mixed -Targets $targets ` + -State sealed) 'Synthetic mixed-state run did not finish fully sealed.' + Assert-SelfTestCondition (Test-SyntheticBackups -Scenario $mixed -Targets $targets) ` + 'Synthetic mixed-state run did not retain exact original backups.' + Assert-SelfTestCondition (Test-NoSealTransactionArtifacts -CoreRoot $mixed.coreRoot) ` + 'Synthetic mixed-state run left a transaction artifact.' + + $stale = New-SyntheticScenario -SelfTestRoot $selfTestRoot -Name 'stale-preflight' ` + -Targets $targets + $stalePath = (Join-Path $stale.coreRoot ([string]$targets[0].relative)) + + '.stackchan-displaced-prior.tmp' + [IO.File]::WriteAllBytes($stalePath, $utf8.GetBytes("synthetic-stale`n")) + $staleRejected = $false + try { + [void](New-SealTransactionPlanSet -CoreRoot $stale.coreRoot ` + -BackupRoot $stale.backupRoot -BackupAuthorityRoot $stale.backupAuthorityRoot ` + -Targets $targets -TransactionId 'selftest-stale') + } catch { + $staleRejected = $_.Exception.Message -match 'Refusing stale synthetic one transaction artifact' + } + Assert-SelfTestCondition $staleRejected 'Synthetic stale artifact was not rejected.' + Assert-SelfTestCondition (Test-SyntheticTargetHashes -Scenario $stale -Targets $targets ` + -State original) 'Stale-artifact preflight changed a synthetic target.' + Assert-SelfTestCondition (-not (Test-Path -LiteralPath $stale.backupRoot)) ` + 'Stale-artifact preflight wrote a backup before rejection.' + Assert-SelfTestCondition (@(Get-ChildItem -LiteralPath $stale.coreRoot -Recurse -Force | + Where-Object { $_.Name -match '\.stackchan-(?:sealed|displaced|rollback)-.+\.tmp$' }).Count -eq 1) ` + 'Stale-artifact preflight wrote an additional transaction artifact.' + + $record = [pscustomobject][ordered]@{ + schema = 'stackchan.pioarduino-seal-selftest.v1' + status = 'pass' + usedSystemTemp = $true + tempRootRemoved = $false + scenarios = [ordered]@{ + preparationFailure = 'pass' + secondInstallRollback = 'pass' + successfulCommit = 'pass' + alreadySealedIdempotence = 'pass' + mixedState = 'pass' + staleArtifactPreflight = 'pass' + } + } + } finally { + $validatedSelfTestRoot = Assert-PathContained -LiteralPath $selfTestRoot ` + -Root $systemTempRoot -Label 'synthetic seal self-test cleanup root' + if (Test-Path -LiteralPath $validatedSelfTestRoot) { + [IO.Directory]::Delete($validatedSelfTestRoot, $true) + } } + Assert-SelfTestCondition (-not (Test-Path -LiteralPath $selfTestRoot)) ` + 'Synthetic seal self-test root was not removed.' + $record.tempRootRemoved = $true + return $record +} + +if ($SelfTest) { + $selfTestRecord = Invoke-SealSelfTest + [Console]::Out.WriteLine(($selfTestRecord | ConvertTo-Json -Depth 6 -Compress)) + exit 0 +} + +$coreItem = Get-Item -LiteralPath $ReleaseCoreDir -Force -ErrorAction Stop +if (-not $coreItem.PSIsContainer -or + ($coreItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'ReleaseCoreDir must be one real PlatformIO core directory.' +} +$coreRoot = Get-NormalizedFullPath -LiteralPath $coreItem.FullName +$repoRoot = Get-NormalizedFullPath -LiteralPath (Split-Path -Parent $PSScriptRoot) +$backupRoot = Get-NormalizedFullPath -LiteralPath ( + Join-Path $repoRoot 'output/private/toolchain-backups') +$planSet = New-SealTransactionPlanSet -CoreRoot $coreRoot -BackupRoot $backupRoot ` + -BackupAuthorityRoot $repoRoot -Targets $targets -TransactionId ([string]$PID) +foreach ($sealedPath in @($planSet.alreadySealed)) { + Write-Output "Pioarduino release-core target is already sealed: $sealedPath" +} +$transactionResult = Invoke-SealTargetTransaction -PlanSet $planSet +if ($transactionResult.status -ceq 'already-sealed') { + Write-Output "Pioarduino release core is already sealed: $coreRoot" + exit 0 } -if ((Get-FileHash -Algorithm SHA256 -LiteralPath $targetPath).Hash -cne $sealedSha256) { - throw 'Pioarduino release core seal did not persist the reviewed bytes.' +foreach ($installedPath in @($transactionResult.installedPaths)) { + Write-Output "Sealed pioarduino release-core target: $installedPath" } -Write-Output "Sealed pioarduino release core: $targetPath" diff --git a/tools/test_release_toolchain_integration_contract.ps1 b/tools/test_release_toolchain_integration_contract.ps1 index 5558fdb3..4d43aa34 100644 --- a/tools/test_release_toolchain_integration_contract.ps1 +++ b/tools/test_release_toolchain_integration_contract.ps1 @@ -23,6 +23,12 @@ function Require-Order { } } +function Require-Count { + param([string]$Text, [string]$Needle, [int]$Expected, [string]$Message) + $count = [regex]::Matches($Text, [regex]::Escape($Needle)).Count + if ($count -ne $Expected) { throw "$Message Observed count: $count" } +} + $packageText = Get-Content -LiteralPath $packagePath -Raw $verifierText = Get-Content -LiteralPath $verifierPath -Raw $helperText = Get-Content -LiteralPath $helperPath -Raw @@ -135,10 +141,110 @@ $sealText = Get-Content -LiteralPath $pioarduinoSealPath -Raw foreach ($sealNeedle in @( '6FC4C8912CBB1FA65A84A527EC5A3CB1280BBA399B02D4885C8C1D91AB7CC9D0', 'D16479CFAD23EF7C392B48C66B9E2422C0294E185746814ED4F7F9E4EFFACB60', + '756B5AAF863F0BCC0E7CB88C9DDBA3FCE2055E1DC6A4D7362ADC057F813324F8', + 'DCABDC11CA1DEA4FBF3854811BF24CD2ADC9AD692785FB27B1EBA0CF41C924E7', '"pioarduino-core"', 'name in ("platformio", "pioarduino-core")', - '[IO.File]::Replace', 'output/private/toolchain-backups')) { + 'if lib_ignore_entries:', 'self._backup_pioarduino_build_py()', + 'self.backup_manager.backup_pioarduino_build_py()', + 'component-manager-noop-and-lto', + 'Assert-ExistingPathChainReal', 'Assert-PathContained', + 'Get-PathVolumeRoot', "ParameterSetName = 'SelfTest'", + 'Invoke-SealTargetTransaction', 'stackchan.pioarduino-seal-selftest.v1', + "'prepare-second'", "'install-second'", + 'Refusing stale $([string]$target.label) transaction artifact', + '[IO.File]::Replace', 'stackchan-displaced-', + 'stackchan-rollback-', '$rollbackFailures', + 'output/private/toolchain-backups')) { Require-Text $sealText $sealNeedle "Pioarduino release-core seal is missing: $sealNeedle" } +$newIgnoreStart = $sealText.IndexOf('$newIgnoreBlock = (', [StringComparison]::Ordinal) +$newIgnoreEnd = $sealText.IndexOf('$oldLtoBlock = (', $newIgnoreStart, + [StringComparison]::Ordinal) +if ($newIgnoreStart -lt 0 -or $newIgnoreEnd -le $newIgnoreStart) { + throw 'Pioarduino seal does not expose one reviewable nonempty-lib-ignore transform.' +} +$newIgnoreText = $sealText.Substring($newIgnoreStart, $newIgnoreEnd - $newIgnoreStart) +Require-Order $newIgnoreText 'lib_ignore_entries = self._get_lib_ignore_entries()' ` + 'if lib_ignore_entries:' ` + 'Pioarduino seal does not normalize lib_ignore before deciding to back up.' +Require-Order $newIgnoreText 'if lib_ignore_entries:' 'self._backup_pioarduino_build_py()' ` + 'Pioarduino seal can back up for an empty normalized lib_ignore configuration.' +Require-Order $newIgnoreText 'self._backup_pioarduino_build_py()' ` + 'self.ignored_libs.update(lib_ignore_entries)' ` + 'Pioarduino seal does not back up before its lib_ignore edit.' +Require-Count $newIgnoreText 'self._backup_pioarduino_build_py()' 1 ` + 'Pioarduino nonempty-lib-ignore transform has ambiguous backup behavior.' + +$newLtoStart = $sealText.IndexOf('$newLtoBlock = $oldLtoBlock.Replace(', + [StringComparison]::Ordinal) +$newLtoEnd = $sealText.IndexOf('if ([regex]::Matches($originalText', $newLtoStart, + [StringComparison]::Ordinal) +if ($newLtoStart -lt 0 -or $newLtoEnd -le $newLtoStart) { + throw 'Pioarduino seal does not expose one reviewable LTO-preservation transform.' +} +$newLtoText = $sealText.Substring($newLtoStart, $newLtoEnd - $newLtoStart) +Require-Order $newLtoText 'return False' ` + 'self.backup_manager.backup_pioarduino_build_py()' ` + 'Pioarduino LTO transform backs up before proving its build script exists.' +$ltoBackupIndex = $newLtoText.IndexOf( + 'self.backup_manager.backup_pioarduino_build_py()', [StringComparison]::Ordinal) +$ltoReplacementTryIndex = $newLtoText.LastIndexOf('try:', [StringComparison]::Ordinal) +if ($ltoBackupIndex -lt 0 -or $ltoReplacementTryIndex -lt 0 -or + $ltoBackupIndex -ge $ltoReplacementTryIndex) { + throw 'Pioarduino LTO transform does not back up before its first edit.' +} +Require-Count $newLtoText 'self.backup_manager.backup_pioarduino_build_py()' 1 ` + 'Pioarduino LTO transform has ambiguous backup behavior.' + +Require-Order $sealText 'Refusing pre-existing seal transaction path' ` + '[void][IO.Directory]::CreateDirectory([string]$PlanSet.backupRoot)' ` + 'Pioarduino seal can mutate before every target transaction path passes preflight.' +Require-Order $sealText 'Refusing stale $([string]$target.label) transaction artifact' ` + '[void][IO.Directory]::CreateDirectory([string]$PlanSet.backupRoot)' ` + 'Pioarduino seal can mutate before stale prior-process transaction artifacts are rejected.' +Require-Order $sealText 'Private $([string]$target.label) backup is not the reviewed original file.' ` + '[void][IO.Directory]::CreateDirectory([string]$PlanSet.backupRoot)' ` + 'Pioarduino seal can mutate before every existing backup passes preflight.' +Require-Order $sealText '$preparedPlans = ' '$installedPlans = ' ` + 'Pioarduino seal can install before all candidate files are prepared and verified.' +Require-Order $sealText '$installedPlans = ' '$rollbackFailures = ' ` + 'Pioarduino seal lacks rollback after an installation-stage failure.' + +$powerShellPath = Join-Path $PSHOME 'powershell.exe' +if (-not (Test-Path -LiteralPath $powerShellPath -PathType Leaf)) { + throw "PowerShell executable for pioarduino seal self-test is missing: $powerShellPath" +} +$sealSelfTestOutput = @(& $powerShellPath -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File $pioarduinoSealPath -SelfTest 2>&1 | ForEach-Object { [string]$_ }) +$sealSelfTestExitCode = $LASTEXITCODE +if ($sealSelfTestExitCode -ne 0) { + throw "Pioarduino seal behavioral self-test failed with exit $sealSelfTestExitCode`: $($sealSelfTestOutput -join ' | ')" +} +if ($sealSelfTestOutput.Count -ne 1) { + throw "Pioarduino seal behavioral self-test produced $($sealSelfTestOutput.Count) output lines instead of one." +} +try { + $sealSelfTest = $sealSelfTestOutput[0] | ConvertFrom-Json -ErrorAction Stop +} catch { + throw "Pioarduino seal behavioral self-test output is not exact JSON: $($_.Exception.Message)" +} +$expectedSealScenarios = @( + 'preparationFailure', 'secondInstallRollback', 'successfulCommit', + 'alreadySealedIdempotence', 'mixedState', 'staleArtifactPreflight' +) +$actualSealScenarios = @($sealSelfTest.scenarios.PSObject.Properties.Name | Sort-Object) +if ([string]$sealSelfTest.schema -cne 'stackchan.pioarduino-seal-selftest.v1' -or + [string]$sealSelfTest.status -cne 'pass' -or + -not [bool]$sealSelfTest.usedSystemTemp -or + -not [bool]$sealSelfTest.tempRootRemoved -or + @(Compare-Object ($expectedSealScenarios | Sort-Object) $actualSealScenarios).Count -ne 0) { + throw 'Pioarduino seal behavioral self-test result has the wrong authority shape.' +} +foreach ($scenario in $expectedSealScenarios) { + if ([string]$sealSelfTest.scenarios.$scenario -cne 'pass') { + throw "Pioarduino seal behavioral scenario did not pass: $scenario" + } +} foreach ($packagedSealInput in @( 'tools/test_release_toolchain_cache_contract.ps1', 'tools/seal_pioarduino_release_core.ps1')) { diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index e784a400..625c8a5f 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -17,7 +17,7 @@ param( $ErrorActionPreference = "Stop" $script:verificationCleanupReady = $false -$verifierToolchainAllowlistSha256 = '149BC9DC713E2550C3EA199337F4BC7F095A1B1497935000F27539EDE16B7CEB' # reviewed allowlist SHA-256 +$verifierToolchainAllowlistSha256 = 'E13A3558AA979DF08CBA42C46B537D32FD3CAF340A4E924A867C6D54B95E1FB7' # reviewed allowlist SHA-256 $verifierToolchainIdentityHelperSha256 = 'D63A93F4E9C3CFE057B59F963FCFF2C7CAF293300FF572E04F4B22608BD368A9' # reviewed identity helper SHA-256 $verifierToolchainSemanticVerifierSha256 = '649DE0BBF4A966ADF389A4C2F98190B87958E2ECCC1DF15A6E6FE04D86A4BEBA' # reviewed semantic verifier SHA-256 $verifierToolchainPreBuild = $null From 4590528941eefd919edcb62ecc9aa5bbd4657d51 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 11:45:44 -0400 Subject: [PATCH 19/46] Promote cache-free release platform identity --- docs/FIRST_DEPLOY_STATUS.md | 29 ++++++++++++++----- tools/RELEASE_TOOLCHAIN_IDENTITY.md | 26 ++++++++++++----- tools/package_release.ps1 | 2 +- .../release_toolchain_identity_allowlist.json | 12 ++++---- tools/verify_release_package.ps1 | 2 +- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 2e882902..1ffa97dd 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -52,13 +52,28 @@ or actuator operation occurred. The reviewed source correction is now provisioned on the exact release host. It keeps empty normalized `lib_ignore` read-only while preserving the backup immediately before a real LTO edit; -the WPR wrapper correction serializes stop/export against every WPR control command. The exact -installed core was sealed transactionally, the failed-run residual backup was archived and removed, -and an independently reviewed 24-component allowlist plus matching packager/verifier pins now cover -the resulting host bytes. The tracked contracts pass, but these corrections do not yet authorize a -retry: the change must be committed and pass exact-head CI, the old failed-run scheduled authority -must be archived and removed under its historical pins, and the corrected private authority chain -must then be rematerialized and pass its self-tests. The SEC-002 hold remains in force. +the WPR wrapper correction serializes stop/export against every WPR control command. Source commit +`76d67273f5a8ec4ecdb603627c99e83f07aec64e` passed all 11 jobs in exact-head GitHub Firmware run +`30920597195`. The old failed-run scheduled authority and active-file pair were archived under their +historical pins and removed. State-D finalization is recorded by `removal.final.json` SHA-256 +`DB4693B7BB71BB7C4A6EC90636DFAB3B33063F189C2816EAD5D03ED21CD43CC6`; the task and both active +files remain absent. + +An unisolated validation build refreshed two existing CPython cache files, and four generated cache +files were present when recovery began. A later diagnostic `git status` rewrote the governed +platform `.git/index`; neither event is attributed to firmware or hardware. The four exact cache +files were archived and removed in a sealed transaction from +platform identity `2AD818580622CA4F57C4F480222EAAF1EFA6961A5DD332F3102A669E61B6D55E` (616 files, +22,522,081 bytes) to `9371DF52EF5A5A9A8D3ABF35D1D08F47994FE3929B67B5C94CF24316F3D8738F` +(612 files, 22,355,010 bytes), with zero added or changed retained files. Completion evidence is +SHA-256 `001CBC5D6F511C32B0829F1FB34A0FE851F48846C8290F29C5A09F434920FB67`. +The independently reviewed 24-component allowlist and matching packager/verifier pins now cover +those exact host bytes; the reviewed allowlist SHA-256 is +`8425BFD814AD4395E70DD86AFF7CFD3D9003F3D5E91FBBC1F2F19BAAF0FF1790`, and all three +toolchain/promotion trust contracts pass. This does not prove that a future build will avoid cache +regeneration or pass the runtime guard. The allowlist promotion must still be committed and pass +exact-head CI, then the corrected private authority chain must be rematerialized and pass its +self-tests before another guarded build. The SEC-002 hold remains in force. A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 diff --git a/tools/RELEASE_TOOLCHAIN_IDENTITY.md b/tools/RELEASE_TOOLCHAIN_IDENTITY.md index 837f3271..b440ef78 100644 --- a/tools/RELEASE_TOOLCHAIN_IDENTITY.md +++ b/tools/RELEASE_TOOLCHAIN_IDENTITY.md @@ -176,15 +176,25 @@ these reviewed canonical libdeps identities: `AB7DB6C267BF82C5B8AC72624D266CB3A0ABE6D5E227BEF6B4750D52800B760E`. The failed A root and older candidates remain rejected evidence and are not release inputs. The -reviewed tracked allowlist was derived from candidate -`release_toolchain_identity_allowlist_candidate_20260804-073551.json`, candidate SHA-256 -`2E95CC671A65F9600BDA3D99ABF1FB0BB039CDA71478F621B103B6740FAE899E`, after two fresh isolated -B/C roots reproduced all three dependency identities. The earlier candidate SHA-256 +current reviewed tracked allowlist was derived from candidate +`release_toolchain_identity_allowlist_candidate_20260804-153036.json`, candidate SHA-256 +`9C5DD20DFBFE2873F6B69665B07C7133562DD2043B3BB0BE51BBA70190DC53B0`. Independent review found +23 of 24 component identities unchanged. The sole promoted component is +`release-platform-espressif32`, from tracked identity +`B13CF308C54F37032645E2F0373E25318F79B24C328661C718491309BD3BD0F9` (616 files, 22,522,018 +bytes) to `9371DF52EF5A5A9A8D3ABF35D1D08F47994FE3929B67B5C94CF24316F3D8738F` (612 files, +22,355,010 bytes). This promotion explicitly accepts the diagnostic rewrite of `.git/index` to +SHA-256 `436767ED0D7A257873F68629A62C465100C9810A67997E6679DA62766E55AEBC`; it does not claim that +the earlier B13 identity was restored. + +The sealed recovery transaction itself began at +`2AD818580622CA4F57C4F480222EAAF1EFA6961A5DD332F3102A669E61B6D55E` (616 files, 22,522,081 +bytes) and removed exactly four archived Python 3.12 bytecode files totaling 167,071 bytes. It added +or changed no retained file and produced the reviewed 9371 identity. The earlier candidate SHA-256 `119D97845AA5998CB678AE4299BE248ABF0118C60C1C197CF0D0208B4A6DB652` remains rejected because it -captured 29 generated `urllib3` bytecode files in the release penv. Exact cache-only restoration -returned that penv to its previously reviewed identity before the accepted candidate was created. -Promotion approved only the byte policy; release eligibility still requires the packager/verifier -record and artifact gates. +captured 29 generated `urllib3` bytecode files in the release penv. Promotion approves only these +exact installed bytes; it does not prove future cache non-regeneration, runtime-guard success, or +release eligibility. Those still require the guarded packager/verifier record and artifact gates. ## Portability and CI limit diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index 219fa6c9..0c9af25b 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -75,7 +75,7 @@ if ($unexpectedGitOverrides.Count -gt 0) { } $releaseBootstrapNullAttributes = if ($env:OS -eq 'Windows_NT') { 'NUL' } else { '/dev/null' } -$releaseToolchainAllowlistSha256 = 'E13A3558AA979DF08CBA42C46B537D32FD3CAF340A4E924A867C6D54B95E1FB7' # reviewed allowlist SHA-256 +$releaseToolchainAllowlistSha256 = '8425BFD814AD4395E70DD86AFF7CFD3D9003F3D5E91FBBC1F2F19BAAF0FF1790' # reviewed allowlist SHA-256 $releaseToolchainIdentityHelperSha256 = 'D63A93F4E9C3CFE057B59F963FCFF2C7CAF293300FF572E04F4B22608BD368A9' # reviewed identity helper SHA-256 $releaseToolchainSemanticVerifierSha256 = '649DE0BBF4A966ADF389A4C2F98190B87958E2ECCC1DF15A6E6FE04D86A4BEBA' # reviewed semantic verifier SHA-256 $releaseToolchainPreBuild = $null diff --git a/tools/release_toolchain_identity_allowlist.json b/tools/release_toolchain_identity_allowlist.json index 28cffbac..ffa82857 100644 --- a/tools/release_toolchain_identity_allowlist.json +++ b/tools/release_toolchain_identity_allowlist.json @@ -12,11 +12,11 @@ ], "pythonExecutableRelativePath": "python.exe", "gitExecutableRelativePath": "cmd/git.exe", - "generatedUtc": "2026-08-04T14:02:54Z", + "generatedUtc": "2026-08-04T15:35:37Z", "review": { "status": "reviewed", - "reviewer": "Luna independent post-provision 24-component allowlist audit; Codex integration", - "reason": "Independently verified candidate A644395D50BDBDE3FACF88D1553C31996634EB90EA152129CCFADA2EE18D259E against the tracked 24-component windows_amd64 allowlist and current installed governed bytes. Twenty-three components remain exact. The sole promoted delta is release-platform-espressif32 from B524A0DA5B8CF1CB88D82E808584FD6200CB090E41DFB7F6B60405DC119EF401 (616 files, 22522049 bytes) to B13CF308C54F37032645E2F0373E25318F79B24C328661C718491309BD3BD0F9 (616 files, 22522018 bytes). Release-core penv remains A1E366961C1410F32DD31102F37725B500FF0F3FB8E9604A4BA34715FBC08C84, framework libs remain 4C86469E19BD779918E4AF25EAE2A9393A018729492686F3A1A428EBBF08F075, and all three canonical libdeps identities remain unchanged. This review promotes only these exact host-installed bytes; it does not establish release eligibility or authorize packaging, publication, flashing, or physical qualification." + "reviewer": "Luna independent 24-component no-bytecode candidate audit; Codex integration", + "reason": "Independently reviewed candidate 9C5DD20DFBFE2873F6B69665B07C7133562DD2043B3BB0BE51BBA70190DC53B0 against the tracked 24-component windows_amd64 allowlist and sealed recovery evidence. Twenty-three components remain exact. The sole promoted governed component is release-platform-espressif32 from B13CF308C54F37032645E2F0373E25318F79B24C328661C718491309BD3BD0F9 (616 files, 22522018 bytes) to 9371DF52EF5A5A9A8D3ABF35D1D08F47994FE3929B67B5C94CF24316F3D8738F (612 files, 22355010 bytes). Relative to tracked B13, the promotion also accepts the diagnostic-git-status rewrite of .git/index to SHA-256 436767ED0D7A257873F68629A62C465100C9810A67997E6679DA62766E55AEBC (63620 bytes) and the post-B13 refreshed CPython cache-byte provenance that is now absent. The sealed recovery transaction itself was 2AD818580622CA4F57C4F480222EAAF1EFA6961A5DD332F3102A669E61B6D55E (616 files, 22522081 bytes) to 9371DF52EF5A5A9A8D3ABF35D1D08F47994FE3929B67B5C94CF24316F3D8738F by removing exactly four archived Python 3.12 .pyc files, with no additions or changed files during that transaction, forbidden residue zero, and sealed source hashes unchanged. This promotes only these exact host-installed bytes; it does not prove future cache non-regeneration, a successful build/WPR, runtime-guard success, release eligibility, or authorize packaging, publication, flashing, or physical qualification." }, "components": [ { @@ -135,9 +135,9 @@ "name": "release-platform-espressif32", "phase": "preBuild", "identitySchema": "stackchan.byte-tree.v1", - "treeSha256": "B13CF308C54F37032645E2F0373E25318F79B24C328661C718491309BD3BD0F9", - "fileCount": 616, - "bytes": 22522018 + "treeSha256": "9371DF52EF5A5A9A8D3ABF35D1D08F47994FE3929B67B5C94CF24316F3D8738F", + "fileCount": 612, + "bytes": 22355010 }, { "name": "release-package-contrib-piohome", diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 625c8a5f..117bd425 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -17,7 +17,7 @@ param( $ErrorActionPreference = "Stop" $script:verificationCleanupReady = $false -$verifierToolchainAllowlistSha256 = 'E13A3558AA979DF08CBA42C46B537D32FD3CAF340A4E924A867C6D54B95E1FB7' # reviewed allowlist SHA-256 +$verifierToolchainAllowlistSha256 = '8425BFD814AD4395E70DD86AFF7CFD3D9003F3D5E91FBBC1F2F19BAAF0FF1790' # reviewed allowlist SHA-256 $verifierToolchainIdentityHelperSha256 = 'D63A93F4E9C3CFE057B59F963FCFF2C7CAF293300FF572E04F4B22608BD368A9' # reviewed identity helper SHA-256 $verifierToolchainSemanticVerifierSha256 = '649DE0BBF4A966ADF389A4C2F98190B87958E2ECCC1DF15A6E6FE04D86A4BEBA' # reviewed semantic verifier SHA-256 $verifierToolchainPreBuild = $null From 3ace8f63fbd8d546ee9d234138b627badefdae51 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 14:10:43 -0400 Subject: [PATCH 20/46] Record guarded release-host qualification failure --- docs/ARRIVAL_DAY_RUNBOOK.md | 13 ++++++++++++ docs/FIRST_DEPLOY_STATUS.md | 42 +++++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index 0c92e9ec..09004202 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -41,6 +41,19 @@ image was built with motion and autonomous motion enabled at boot. Until the too exact eligible package, and P1 gates close, the physical step is `HOLD`, not a reason to reuse an older package or infer state from USB or ping. +Exact-host update (2026-08-04): the cache-free release-platform identity is committed through +`4590528941eefd919edcb62ecc9aa5bbd4657d51`, and all 11 exact-head Firmware jobs passed in GitHub +run `30925806783`. This still is not an install candidate. Two retained local guarded builds failed +closed during build A because the host WPR evidence recorder lost events; neither produced a +governed package. In the final retry, sample 132 reported zero loss and sample 133 reported 990,278 +lost/dropped events. The runner was terminated, the diagnostic ETL was sealed with 1,265,270 lost +events, storage/journal containment passed, WPR returned idle, and conventional authorities were +restored to `B/B/B`. The failed-run task and active pair were subsequently archived and removed by +an exact state-D cleanup. This is a host recorder failure, not evidence about robot stability or a +hardware root cause. Do not flash, open a robot port, start the bridge for qualification, or move a +motor from this result. P1 remains `HOLD` until an exact guarded two-cycle build has zero recorder +loss and the resulting exact package passes independent rebuild verification. + Current read-only packet: ignored `output/private/p0-live-state-20260803`. Its successful debug JSON is SHA-256 `070CA1CDEA6B78D7C15589559E204330716CB6CAD1542BE4BE6DE56DA5C594FB`. Intermittent timeouts alternated with successful samples and increasing uptime at one boot; do not diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 1ffa97dd..e89fcf34 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -70,10 +70,44 @@ SHA-256 `001CBC5D6F511C32B0829F1FB34A0FE851F48846C8290F29C5A09F434920FB67`. The independently reviewed 24-component allowlist and matching packager/verifier pins now cover those exact host bytes; the reviewed allowlist SHA-256 is `8425BFD814AD4395E70DD86AFF7CFD3D9003F3D5E91FBBC1F2F19BAAF0FF1790`, and all three -toolchain/promotion trust contracts pass. This does not prove that a future build will avoid cache -regeneration or pass the runtime guard. The allowlist promotion must still be committed and pass -exact-head CI, then the corrected private authority chain must be rematerialized and pass its -self-tests before another guarded build. The SEC-002 hold remains in force. +toolchain/promotion trust contracts pass. The cache-free allowlist promotion is committed as +`4590528941eefd919edcb62ecc9aa5bbd4657d51`; all 11 jobs passed in exact-head GitHub Firmware run +`30925806783`. A fresh detached build worktree at that commit and the private guarded authority +chain independently reverified the exact release-platform identity above, 612 files, 22,355,010 +bytes, zero CPython cache files, and the expected clean source/configuration identities. + +Two subsequent local guarded executions failed closed before producing a package. Outer run +`output/private/current-head-guard-promotions/20260804-161747-853de45e39bf` reached build A, then +its recorder jumped from zero loss to 2,622,135 lost events. The runner was contained, the partial +output had no firmware binary, the compiler outcome remained indeterminate, and all available +retained evidence was preserved. The wrapper was then hardened and self-tested with 10-second +collector polling, +nullable-exit handling, bounded diagnostic trace salvage, fresh storage/journal checks, and a +512 x 1 MiB exact diagnostic buffer profile. Those changes improve containment and evidence +quality; they do not make a lossy trace eligible for promotion. + +The one corrected retry is outer run +`output/private/current-head-guard-promotions/20260804-172745-f3d5c167fe8b`, transaction SHA-256 +`18CD37DF9F339BE6BB1CD0235E139FE3AD802A69047BB3A7B67EBDADEAFD43AD`, with runner evidence under +`output/private/toolchain-guard-smoke/20260804-172801-26784-9c832522d58449fc8a26bd1195724780`. +Exact prebuild identity, dependency installation, and clean-before-build-A passed. During build A, +collector sample 132 still reported zero loss; sample 133 reported 990,278 lost/dropped events. +The wrapper immediately terminated the runner, recorded 1,126,928 events lost before cleanup, and +completed diagnostic-only ETL salvage. The sealed ETL header reports 1,265,270 lost events. The +wrapper manifest is SHA-256 +`9D8EBC63AF6D3C78D8F06E4C27EDC91E2F24AEBECF7A7BCE51995BFAEA081105` and remains `status=fail`. +Storage stayed within policy, the durable journal drained 1,450/1,450 samples, final WPR state is +idle, terminal containment is proven, conventional authorities were restored to `B/B/B`, and no +promotion occurred. Exact failed-run cleanup archived the residual authority, removed only the +disabled task and active-file pair, and reached state D; its `removal.final.json` is SHA-256 +`F92ED013CE327081DD7E5A9E2861A8B7A04193168215862DBCEE1B39BB965197`. + +These are host evidence-recorder failures, not firmware, robot, USB, power, thermal, or actuator +failures. In these two guarded executions, no governed package or replacement firmware was +produced, and the guarded workflow did not flash, start a bridge session, access a robot port, +perform no-motion qualification, or move a motor. The SEC-002 hold remains in force until an +exact-host guarded two-cycle build completes with zero recorder loss and the resulting exact +package passes its independent rebuild and passive no-motion preflight. A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 From ce5dc3abdfd390c4b06762a3547b5895b05df681 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 15:14:48 -0400 Subject: [PATCH 21/46] Unblock direct M0 qualification proof --- BRANCH_LEDGER.md | 27 +++++++++++++++++++++++- docs/ARRIVAL_DAY_RUNBOOK.md | 33 ++++++++++++++++++++++++++++-- docs/FIRST_DEPLOY_STATUS.md | 35 +++++++++++++++++++++++++++++--- docs/POWER_BLACKOUT_FORENSICS.md | 16 +++++++++++++++ docs/RELEASE_PROCESS.md | 11 ++++++++-- 5 files changed, 114 insertions(+), 8 deletions(-) diff --git a/BRANCH_LEDGER.md b/BRANCH_LEDGER.md index fcf468b2..b6a75ff0 100644 --- a/BRANCH_LEDGER.md +++ b/BRANCH_LEDGER.md @@ -2,6 +2,30 @@ Audit timestamp: 2026-08-02 America/New_York +## Active Qualification Routing (2026-08-04) + +- **Sole M0/P0 qualification worktree:** + `D:\CodexProjects\stackchan_alive\output\worktrees\aliveness-repository-truth` on + `codex/aliveness-repository-truth`. Verify its exact head and clean state before every + qualification command. No other retained worktree is a qualification input. +- **The primary checkout is not a qualification host.** It is clean `main` at + `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec`; use it only as the current default source checkout. +- **`agent/away-cloudflare-bridge` is quarantined, not merely paused.** Preserve it unchanged at + `269b11beeac788f76fff5d566446a91b8688bf8f`. It predates SEC-001/SEC-002, ships + `data/cert/x509_crt_bundle.bin`, and changes `src/main.cpp`, + `BridgeWiFiProvisioningStore`, and other bridge/network authority paths. Do not merge, rebase, + package, flash, or qualify it because CI is green or the feature diff appears self-contained. + Its release-full profile explicitly restores motion and autonomous motion at boot; its remote + path also predates current pairing/admission and `emergency_stop_only` enforcement. Most of the + unsafe lane would merge without a textual conflict, so mergeability is not containment evidence. + Remote-access approval must first be explicitly opened. Any future implementation must be built + afresh from the then-current qualification head and independently reviewed against + `emergency_stop_only`, pairing/credential boundaries, privacy, protocol ownership, and + motion/rail/torque containment. + +This section is the current routing authority. The original 2026-08-02 checkout and local-`main` +observations below remain historical audit evidence, not current operating instructions. + ## Audit Basis - Repository: `RobVanProd/stackchan_alive` @@ -31,7 +55,8 @@ upstream is not evidence that an attached local worktree is disposable. | Branch | Scope | Merge base | Ahead / behind | Unique files and mechanism | Existing PR | Security implications | Disposition | | --- | --- | --- | ---: | --- | --- | --- | --- | -| `agent/away-cloudflare-bridge` | Local + remote | `36acc0c735132f06dae5d31e5a2cb145db1258b8` | 1 / 75 | 35 files across firmware endpoint/network/provisioning code, Android/desktop endpoint services, `deploy/cloudflare`, a CA bundle, and `docs/AWAY_CLOUDFLARE_BRIDGE.md`; adds Cloudflare-backed Away routing. | None | **High.** Adds Internet reachability, tunnel configuration, certificate material, credential handling, provisioning, and endpoint-ownership changes. It needs a current threat model, credential rotation, recovery, privacy review, and separate physical evidence. | **Archive/quarantine.** Keep separate from the aliveness roadmap. Do not rebase or salvage until the remote-operation approval gates are explicitly opened. Do not remove its active checkout while live services may depend on it. | +| `agent/away-cloudflare-bridge` | Local + remote | `36acc0c735132f06dae5d31e5a2cb145db1258b8` | 1 / 75 | 35 files across firmware endpoint/network/provisioning code, Android/desktop endpoint services, `deploy/cloudflare`, a CA bundle, and `docs/AWAY_CLOUDFLARE_BRIDGE.md`; adds Cloudflare-backed Away routing. | None | **High.** Re-enables release-full motion/autonomous motion at boot, retains pre-SEC-002 unsafe HTTP routes, accepts replayable persistent profile changes without active-owner/freshness checks, terminates the tunnel at a pre-SEC-001 Kotlin server, and stores Wi-Fi/Access credentials as plaintext JSON. | **Archive/quarantine.** Preserve the exact branch as research evidence; do not merge, rebase, or cherry-pick it. No changed file is approved for whole-file salvage. Any future remote-access lane must be implemented afresh from the then-current qualification head after explicit approval and the review gate above. | +| `codex/native-release-guard-fixture` | Local only | `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec` | 21 / 0 | Fixture-only `ReadDirectoryChangesExW` mutation barrier, native P/Invoke source, and adversarial contracts. Production callers remain on `FileSystemWatcher`. | None | Changes the exact release-policy helper bytes and therefore invalidates current bootstrap/allowlist pins. It does not yet cover transient source-root injection, exact build-Job accounting, or root-object ACL/attribute changes. | **Preserve as future hardening; not an M0 input.** Local commit `c8d6be2ef7c8aaa2a4ac3475ac879e369db44763` intentionally remains unpushed and non-promotion-ready. Do not merge until line-ending authority, helper/allowlist/bootstrap pins, missing containment controls, full contracts, and an independent guarded build all pass together. | | `agent/companion-complaints-harness` | Local only; upstream gone | `81147d8e73f861543d7b0813991b2db6674c0301` | 1 / 1 | Host bridge complaint corpus, qualification harness, memory/persona/initiative/failure-recovery hardening, and tests. Its tree is identical to fetched `origin/main`; `git cherry` reports the commit patch-equivalent. | [#219](https://github.com/RobVanProd/stackchan_alive/pull/219), merged | Privacy and relationship-safety controls are material, but the reviewed content is already on `main`. The local worktree runs production voice/bridge support processes. | **Delete only after retirement and preservation review.** Code is merged, but the worktree currently has a tracked modification to `artifacts/face/phase_e_speech_reactive_6s.gif`. Retain it until services are deliberately migrated or stopped, then inventory and preserve all tracked, untracked, and ignored user/runtime data before deleting the redundant branch/worktree. | | `codex/release-integration-preview` | Local only | `329b50c989ed08e582c8f361b903bce9d1a39196` | 3 / 196 | Release archive tooling, private/public evidence distinctions, reproducibility checker changes, package verifier changes, and contracts. All three commits are patch-equivalent to `main`. | None | Release credential hygiene and private diagnostic/public-package separation are security-critical. Those mechanisms are already on `main`. | **Delete after worktree retirement.** No unique patch remains. Preserve tracked, untracked, and ignored release evidence before removing its worktree. | | `codex/release-tooling-final` | Local only | `e6b80f32abcb71a61e1eb8616702e216c33ed3cd` | 2 / 209 | Earlier form of the same release archive and private/public evidence tooling. Both commits are patch-equivalent to `main`. | None | Same release-secret and artifact-integrity boundaries as above; already represented on `main`. | **Delete after worktree retirement.** Superseded and patch-equivalent; inventory tracked, untracked, and ignored evidence first. | diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index 09004202..461e0e3d 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -4,6 +4,28 @@ Use this when bringing up a physical Stackchan device from the public `v0.2.0` r locally rebuilt or post-release firmware as a new candidate until its applicable evidence gates below are complete. +Qualification checkout authority (2026-08-04): run current M0/P0 commands only from +`D:\CodexProjects\stackchan_alive\output\worktrees\aliveness-repository-truth` on +`codex/aliveness-repository-truth`, after verifying its exact head and clean state. The repository's +primary checkout is now clean `main` at `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec`; it is not the +qualification branch. The preserved `agent/away-cloudflare-bridge` branch at +`269b11beeac788f76fff5d566446a91b8688bf8f` is remote-access infrastructure that predates the current +containment lane and is explicitly excluded from packaging, flashing, and physical qualification +without exception. Do not merge, rebase, or cherry-pick that exact branch. Any explicitly approved +future remote-access work must be implemented afresh from the then-current qualification head and +receive a separate security and containment review. + +P1/P2 bench-power rule (operator-supplied 2026-08-04): P1 exact-image no-motion qualification may +use the PC USB connection while motion request, servo rail, and torque are proved off. P2 and every +other test that can enable the servo rail, torque, or an actuator must instead use the validated +dedicated 5 V / 3 A BASE supply. Keep the PC connection data-only when the bench wiring can safely +separate USB power; document the exact power/data topology and do not create a backfeed path. Begin +power and reset telemetry capture before arming motion. A blackout, USB disappearance, or telemetry +cut during a stop test is an inconclusive combined power/containment event: issue `/motion-stop` if +reachable, terminate the motion-refresh runner, capture post-stop `/debug` when it returns, and +preserve the power-forensics state. Do not count loss of power itself as proof that an emergency +stop worked, and do not call it a firmware containment failure without matching telemetry. + Repository-truth warning (2026-08-03): live firmware now self-reports confirmed `app0` and expected SHA-256 `69d3db27...8ebfa8`, matching the historical accepted lead, but current flash bytes have not been independently read back. Dated “installed,” “current,” and “live” notes below remain historical @@ -51,8 +73,15 @@ events, storage/journal containment passed, WPR returned idle, and conventional restored to `B/B/B`. The failed-run task and active pair were subsequently archived and removed by an exact state-D cleanup. This is a host recorder failure, not evidence about robot stability or a hardware root cause. Do not flash, open a robot port, start the bridge for qualification, or move a -motor from this result. P1 remains `HOLD` until an exact guarded two-cycle build has zero recorder -loss and the resulting exact package passes independent rebuild verification. +motor from this result. + +After those two parameter variations, stop tuning WPR. System-wide WPR/ETL is optional +corroborating forensic evidence and is not a package-promotion predicate. If collected, recorder +loss must be reported as `diagnostic-failed` and the recorder must be returned to idle, but the +authorizing reproducibility evidence is the public exact-host guard plus two clean detached +worktree/cache cycles, matching artifact bytes, source-bound package generation, and independent +package rebuild/verification. P1 remains `HOLD` until that direct governed proof passes; it no +longer requires a lossless system-wide WPR trace. Current read-only packet: ignored `output/private/p0-live-state-20260803`. Its successful debug JSON is SHA-256 `070CA1CDEA6B78D7C15589559E204330716CB6CAD1542BE4BE6DE56DA5C594FB`. diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index e89fcf34..d6db0ba3 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -2,6 +2,25 @@ Status timestamp: 2026-08-04 America/New_York +## Qualification Checkout Authority (2026-08-04) + +The authoritative checkout for the current M0/P0 qualification lane is the clean worktree at +`D:\CodexProjects\stackchan_alive\output\worktrees\aliveness-repository-truth` on +`codex/aliveness-repository-truth`. Do not infer qualification state from the repository's default +working directory or from another retained worktree. The pushed evidence checkpoint immediately +before this reconciliation was `3ace8f63fbd8d546ee9d234138b627badefdae51`; it matched its remote +branch and was 20 commits ahead of `origin/main` with no commits behind. + +The primary checkout at `D:\CodexProjects\stackchan_alive` was cleanly moved from +`agent/away-cloudflare-bridge` to current local `main`, and local `main` was fast-forwarded to the +verified remote `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec`. The remote-access branch is preserved unchanged at +`269b11beeac788f76fff5d566446a91b8688bf8f`; it is not a qualification input. It predates the current +containment lane and adds Cloudflare-backed remote access plus firmware network-path changes. +Because remote-access infrastructure requires explicit approval, do not merge, rebase, flash, +package, or qualify that exact branch. Any explicitly approved future remote-access work must be +implemented afresh from the then-current qualification head and receive a separate security, +privacy, protocol-authority, and motion-containment review. + ## Current SEC-002 Qualification Hold (2026-08-03) The clean `SEC-002` package built from `4d31de41` is preserved as source/package evidence only. @@ -105,9 +124,19 @@ disabled task and active-file pair, and reached state D; its `removal.final.json These are host evidence-recorder failures, not firmware, robot, USB, power, thermal, or actuator failures. In these two guarded executions, no governed package or replacement firmware was produced, and the guarded workflow did not flash, start a bridge session, access a robot port, -perform no-motion qualification, or move a motor. The SEC-002 hold remains in force until an -exact-host guarded two-cycle build completes with zero recorder loss and the resulting exact -package passes its independent rebuild and passive no-motion preflight. +perform no-motion qualification, or move a motor. After the two failed recorder parameter +variations, no third WPR tuning run is authorized. WPR/ETL is now optional corroborating forensic +evidence, not a release-promotion predicate; recorder loss must remain visible as +`diagnostic-failed` and any recorder session must return idle, but it does not override the direct +reproducibility proof. + +The M0/P0 build authority remains fail-closed through the reviewed public controls: one exact clean +commit and host-installed toolchain allowlist, retained file leases and namespace mutation guards, +sanitized build environment, two clean cycles from distinct detached worktrees and caches, exact +artifact-byte comparison, package source binding, and independent package rebuild/verification. +Any failure in those controls still rejects the candidate. The SEC-002 hold remains in force until +that governed two-cycle proof succeeds and the resulting exact package passes its independent +rebuild and passive no-motion preflight; lossless system-wide WPR is not additionally required. A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 diff --git a/docs/POWER_BLACKOUT_FORENSICS.md b/docs/POWER_BLACKOUT_FORENSICS.md index dc9547cb..ee31762d 100644 --- a/docs/POWER_BLACKOUT_FORENSICS.md +++ b/docs/POWER_BLACKOUT_FORENSICS.md @@ -6,6 +6,22 @@ completed the full all-feature actuator soak for `28807 s` with `5643/5643` succ `77/77` formal checker result; bounded final stop evidence verified motion, servo rail, torque, and motion power authority off. Historical full-off root cause remains unidentified. +## Qualification Power Topology + +The operator's bench experience adds a hard separation between the next two physical gates. P1 +exact-image no-motion qualification may run from PC USB because motion request, servo rail, and +torque must remain off throughout. P2 supervised emergency-stop proof must use the validated +dedicated 5 V / 3 A BASE supply for the robot and servo load. Where the hardware path permits it, +USB may remain attached only for data with its power conductor safely isolated; record the exact +topology and prevent backfeed. + +Start voltage, reset, rail, torque, motion-session, and host-presence capture before P2 arms the +minimum motion authority. If the robot blacks out or telemetry ends during the stop experiment, +the result is inconclusive until evidence separates power loss from stop-path behavior. Loss of +power is not an emergency-stop pass. It is also not, by itself, a firmware failure or proof that +servo load caused the event. Follow the mandatory stop/runner-termination/post-return snapshot +sequence and preserve the original state before changing cables or power sources. + ## What The Evidence Says The intermittent shutdown is a release blocker, but the current evidence does not identify one diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 4318cef2..b6fde14f 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -97,8 +97,15 @@ commit-pinned detached source worktree retained through final ZIP verification. prior snapshots, and failure metadata are moved under ignored `output/private/reproducibility-failures/`; the complete failed detached worktree, including `.pio/build`, `.pio/libdeps`, and generated state, remains attached at its recorded path. A later -attempt must not delete it. ZIP hashes may -differ because generated reports and archive metadata are time-bearing. `-SkipBuild` is allowed only +attempt must not delete it. + +System-wide WPR/ETL collection is optional corroborating forensic evidence, not part of this +release-authorizing reproducibility claim. Recorder loss must be disclosed when a trace is +collected and the recorder must be returned to idle, but it neither supplies nor overrides the +direct proof above. Promotion depends on the exact-host lifetime guard, the two clean build cycles, +artifact-byte equality, source binding, and independent package verification. + +ZIP hashes may differ because generated reports and archive metadata are time-bearing. `-SkipBuild` is allowed only with `-AllowDirty` for a diagnostic package that is explicitly barred from release and hardware validation; its version must start with `diagnostic-` and it is written under `output/diagnostics/`. Its copied firmware identity is explicitly unknown/unbound, every root From 604cb087250dfdef668061b62a719ec25e9bc70f Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 16:06:46 -0400 Subject: [PATCH 22/46] Fix governed short-path release routing --- docs/FIRST_DEPLOY_STATUS.md | 24 +++++ tools/package_release.ps1 | 83 +++++++++++++++--- ...release_toolchain_integration_contract.ps1 | 87 +++++++++++++++++++ 3 files changed, 183 insertions(+), 11 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index d6db0ba3..8524f816 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -138,6 +138,30 @@ Any failure in those controls still rejects the candidate. The SEC-002 hold rema that governed two-cycle proof succeeds and the resulting exact package passes its independent rebuild and passive no-motion preflight; lossless system-wide WPR is not additionally required. +### Direct package routing failure (2026-08-04) + +Qualification head `ce5dc3abdfd390c4b06762a3547b5895b05df681` passed all 11 jobs in exact-head +GitHub Firmware run `30942401869`. The first direct WPR-free public-packager attempt then failed +closed before cycle A and before release-output creation. The physical checkout was long enough to +require the packager's temporary `R:` mapping. In the short-path child, Windows kept +`Resolve-Path R:\` as the logical alias while the reviewed Git executable canonicalized +`rev-parse --show-toplevel` to the exact physical +`D:\CodexProjects\stackchan_alive\output\worktrees\aliveness-repository-truth` path. The bootstrap +guard compared those equivalent roots as strings and rejected the child with +`Release packaging must start at its exact Git top-level.` + +This is a release-host routing defect, not a toolchain-identity, firmware, robot, USB, power, +thermal, motion, or reproducibility result. The failed attempt produced no cycle A/B build, ZIP, +sidecar, package verification log, flash, bridge session, robot-port access, or actuator command. +WPR remained idle, the temporary mapping was removed, retained packager processes exited, and the +qualification worktree remained clean at the failed head. The source correction preserves exact +Git-top-level validation by requiring an empty Git `--show-prefix`, exactly one mapping for the +governed short drive, exact equality between the mapped physical target and Git's canonical +top-level, a non-reparse target directory, and an unchanged mapping after bootstrap trust. Its +contract executes the production resolver both from the physical checkout and from the actual +short-drive child context. The SEC-002 hold remains in force until that correction is committed, +passes exact-head CI, and a fresh direct two-cycle package plus independent rebuild succeed. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index 0c9af25b..6041e3c1 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -335,14 +335,26 @@ function Resolve-ReleaseBootstrapGitPath { } function Assert-ReleaseBootstrapTrust { - param([Parameter(Mandatory = $true)][string]$Root) + param( + [Parameter(Mandatory = $true)][string]$Root, + [string]$ExpectedGitTopLevel = '' + ) $resolvedRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + $resolvedExpectedGitTopLevel = if ([string]::IsNullOrWhiteSpace($ExpectedGitTopLevel)) { + $resolvedRoot + } else { + [System.IO.Path]::GetFullPath($ExpectedGitTopLevel).TrimEnd('\', '/') + } + $prefix = (Invoke-ReleaseBootstrapGit -Root $resolvedRoot -Arguments @( + 'rev-parse', '--show-prefix') | Out-String).Trim() + $prefixExitCode = $LASTEXITCODE $topLevel = (Invoke-ReleaseBootstrapGit -Root $resolvedRoot -Arguments @( 'rev-parse', '--show-toplevel')).Trim() - if ($LASTEXITCODE -ne 0 -or + if ($prefixExitCode -ne 0 -or -not [string]::IsNullOrEmpty($prefix) -or + $LASTEXITCODE -ne 0 -or -not [System.IO.Path]::GetFullPath($topLevel).TrimEnd('\', '/').Equals( - $resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + $resolvedExpectedGitTopLevel, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'Release packaging must start at its exact Git top-level.' } $attributePaths = New-Object System.Collections.Generic.List[string] @@ -574,9 +586,62 @@ foreach ($systemExecutable in @($releasePowerShellExecutable, $releaseSubstExecu } } +function Get-ReleaseShortPathPhysicalRoot { + param([Parameter(Mandatory = $true)][string]$LogicalRoot) + + $fullLogicalRoot = [System.IO.Path]::GetFullPath($LogicalRoot) + $driveRoot = [System.IO.Path]::GetPathRoot($fullLogicalRoot) + $resolvedLogicalRoot = $fullLogicalRoot.TrimEnd('\', '/') + $allowedShortRoots = @('R:\', 'Q:\', 'P:\', 'O:\') + if ($allowedShortRoots -notcontains $driveRoot -or + -not $resolvedLogicalRoot.Equals( + $driveRoot.TrimEnd('\', '/'), [System.StringComparison]::OrdinalIgnoreCase) -or + $resolvedLogicalRoot.Length -gt 60) { + throw '-ReleaseShortPathChild is internal and requires the verified short subst checkout.' + } + + $mappingLines = @(& $script:releaseSubstExecutable) + if ($LASTEXITCODE -ne 0) { + throw 'Release packaging could not query the temporary subst mapping.' + } + $driveLetter = $driveRoot.Substring(0, 1) + $physicalTargets = @($mappingLines | ForEach-Object { + $match = [regex]::Match( + [string]$_, '^(?[A-Za-z]):\\: => (?.+)$', + [System.Text.RegularExpressions.RegexOptions]::CultureInvariant) + if ($match.Success -and + $match.Groups['drive'].Value.Equals( + $driveLetter, [System.StringComparison]::OrdinalIgnoreCase)) { + $match.Groups['target'].Value + } + }) + if ($physicalTargets.Count -ne 1) { + throw 'Release packaging requires exactly one verified subst mapping for its short checkout.' + } + $physicalRoot = [System.IO.Path]::GetFullPath([string]$physicalTargets[0]).TrimEnd('\', '/') + $physicalItem = Get-Item -LiteralPath $physicalRoot -Force -ErrorAction Stop + if (-not $physicalItem.PSIsContainer -or + ($physicalItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw 'Release packaging refuses a missing, non-directory, or redirected subst target.' + } + return $physicalRoot +} + $physicalRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$bootstrapGitTopLevel = $physicalRepoRoot +if ($ReleaseShortPathChild) { + $bootstrapGitTopLevel = Get-ReleaseShortPathPhysicalRoot -LogicalRoot $physicalRepoRoot +} if (-not $SkipBuild) { - Assert-ReleaseBootstrapTrust -Root $physicalRepoRoot + Assert-ReleaseBootstrapTrust -Root $physicalRepoRoot ` + -ExpectedGitTopLevel $bootstrapGitTopLevel + if ($ReleaseShortPathChild) { + $confirmedPhysicalRoot = Get-ReleaseShortPathPhysicalRoot -LogicalRoot $physicalRepoRoot + if (-not $confirmedPhysicalRoot.Equals( + $bootstrapGitTopLevel, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'Release packaging detected a changed subst mapping during bootstrap trust verification.' + } + } } if ( $env:OS -eq "Windows_NT" -and @@ -617,16 +682,12 @@ if ( } finally { Set-Location $env:TEMP & $script:releaseSubstExecutable $driveName /D | Out-Null + if ($LASTEXITCODE -ne 0 -or (Test-Path -LiteralPath "$driveName\")) { + throw "Could not remove temporary release path $driveName" + } } exit $childExit } -if ($ReleaseShortPathChild) { - $allowedShortRoots = @("R:\", "Q:\", "P:\", "O:\") - $currentRoot = [System.IO.Path]::GetPathRoot($physicalRepoRoot) - if ($allowedShortRoots -notcontains $currentRoot -or $physicalRepoRoot.Length -gt 60) { - throw "-ReleaseShortPathChild is internal and requires the verified short subst checkout." - } -} $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $repoRoot diff --git a/tools/test_release_toolchain_integration_contract.ps1 b/tools/test_release_toolchain_integration_contract.ps1 index 4d43aa34..4878934a 100644 --- a/tools/test_release_toolchain_integration_contract.ps1 +++ b/tools/test_release_toolchain_integration_contract.ps1 @@ -89,6 +89,93 @@ foreach ($text in @($packageText, $verifierText)) { Require-Order $packageText 'Assert-StackchanReleaseToolchainIdentity' ` 'Assert-ReleaseBootstrapTrust -Root' ` 'Packager does not assert exact PreBuild identity before first trusted Git execution.' +foreach ($needle in @( + 'Get-ReleaseShortPathPhysicalRoot', + "'rev-parse', '--show-prefix'", + '-not [string]::IsNullOrEmpty($prefix)', + "'^(?[A-Za-z]):\\: => (?.+)$'", + 'requires exactly one verified subst mapping', + 'refuses a missing, non-directory, or redirected subst target', + '-ExpectedGitTopLevel $bootstrapGitTopLevel', + 'detected a changed subst mapping during bootstrap trust verification')) { + Require-Text $packageText $needle ` + "Packager is missing fail-closed subst/Git top-level reconciliation: $needle" +} +Require-Count $packageText ` + 'Get-ReleaseShortPathPhysicalRoot -LogicalRoot $physicalRepoRoot' 2 ` + 'Packager must verify the subst target both before and after Git bootstrap trust.' +$packageTokens = $null +$packageParseErrors = $null +$packageAst = [System.Management.Automation.Language.Parser]::ParseFile( + $packagePath, [ref]$packageTokens, [ref]$packageParseErrors) +if (@($packageParseErrors).Count -ne 0) { + throw 'Release packager cannot be parsed for short-path regression testing.' +} +$shortPathFunction = $packageAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Get-ReleaseShortPathPhysicalRoot' + }, $true) +if ($null -eq $shortPathFunction) { + throw 'Release packager short-path resolver function is unavailable for regression testing.' +} +. ([scriptblock]::Create($shortPathFunction.Extent.Text)) +$script:releaseSubstExecutable = Join-Path ([Environment]::SystemDirectory) 'subst.exe' +$shortPathRepoRoot = (Resolve-Path (Split-Path -Parent $PSScriptRoot)).Path +$canonicalShortPathRepoRoot = (& git -C $shortPathRepoRoot rev-parse --show-toplevel | + Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($canonicalShortPathRepoRoot)) { + throw 'Release short-path regression test could not resolve the canonical Git top-level.' +} +$canonicalShortPathRepoRoot = [IO.Path]::GetFullPath( + $canonicalShortPathRepoRoot).TrimEnd('\', '/') +$allowedShortPathRoots = @('R:\', 'Q:\', 'P:\', 'O:\') +$shortPathRepoFull = [IO.Path]::GetFullPath($shortPathRepoRoot) +$shortPathRepoDriveRoot = [IO.Path]::GetPathRoot($shortPathRepoFull) +$createdShortPathMapping = $false +if ($allowedShortPathRoots -contains $shortPathRepoDriveRoot -and + $shortPathRepoFull.TrimEnd('\', '/').Equals( + $shortPathRepoDriveRoot.TrimEnd('\', '/'), + [StringComparison]::OrdinalIgnoreCase)) { + $shortPathDrive = $shortPathRepoDriveRoot.TrimEnd('\') + $shortPathLogicalRoot = $shortPathRepoDriveRoot +} else { + $shortPathDrive = @('R:', 'Q:', 'P:', 'O:') | + Where-Object { -not (Test-Path -LiteralPath "$_\") } | + Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($shortPathDrive)) { + throw 'Release short-path regression test requires one free governed subst drive.' + } + & $script:releaseSubstExecutable $shortPathDrive $shortPathRepoRoot + if ($LASTEXITCODE -ne 0) { + throw 'Release short-path regression test could not create its temporary subst mapping.' + } + $createdShortPathMapping = $true + $shortPathLogicalRoot = "$shortPathDrive\" +} +try { + $observedPhysicalRoot = Get-ReleaseShortPathPhysicalRoot -LogicalRoot $shortPathLogicalRoot + if (-not $observedPhysicalRoot.Equals( + $canonicalShortPathRepoRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'Release short-path regression test did not recover the exact physical repository root.' + } + $nestedRejected = $false + try { + Get-ReleaseShortPathPhysicalRoot -LogicalRoot "$shortPathDrive\tools" | Out-Null + } catch { + $nestedRejected = $true + } + if (-not $nestedRejected) { + throw 'Release short-path regression test accepted a nested logical checkout root.' + } +} finally { + if ($createdShortPathMapping) { + & $script:releaseSubstExecutable $shortPathDrive /D | Out-Null + if ($LASTEXITCODE -ne 0 -or (Test-Path -LiteralPath "$shortPathDrive\")) { + throw 'Release short-path regression test did not remove its temporary subst mapping.' + } + } +} Require-Order $verifierText 'Assert-StackchanReleaseToolchainIdentity' ` "Invoke-TrustedVerifierGit -Arguments @('describe'" ` 'Verifier does not assert exact PreBuild identity before first trusted Git execution.' From 1da3c505fd83a664f4d348b84714398f2e941e8f Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 16:43:25 -0400 Subject: [PATCH 23/46] Bound bridge CI dependency stalls --- .github/workflows/firmware.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index f365d725..4da8aba1 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -83,6 +83,7 @@ jobs: bridge-tests: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v7 with: @@ -91,8 +92,11 @@ jobs: - uses: actions/setup-python@v6 with: python-version: "3.12.10" + cache: pip + cache-dependency-path: bridge/requirements-vision.txt - name: Install bridge test dependencies + timeout-minutes: 5 run: | sudo apt-get update sudo apt-get install -y ffmpeg From 16d8f1b0245f829737e406c5a6309d5486249f2a Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 18:17:03 -0400 Subject: [PATCH 24/46] Stabilize release rebuild path topology --- docs/FIRST_DEPLOY_STATUS.md | 33 ++++++++ docs/RELEASE_PROCESS.md | 8 +- tools/firmware_reproducibility_proof.ps1 | 6 +- tools/package_release.ps1 | 42 +++++++--- ...irmware_reproducibility_proof_contract.ps1 | 8 +- ...t_firmware_reproducible_build_contract.ps1 | 79 ++++++++++++++++++- ...release_toolchain_integration_contract.ps1 | 39 ++++++++- tools/verify_release_package.ps1 | 30 +++++-- 8 files changed, 221 insertions(+), 24 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 8524f816..5ea2729e 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -162,6 +162,39 @@ contract executes the production resolver both from the physical checkout and fr short-drive child context. The SEC-002 hold remains in force until that correction is committed, passes exact-head CI, and a fresh direct two-cycle package plus independent rebuild succeed. +### Direct package reproducibility failure (2026-08-04) + +Qualification head `1da3c505fd83a664f4d348b84714398f2e941e8f` passed all 11 jobs on the +first attempt in exact-head GitHub Firmware run `30949170237`. Direct WPR-free packaging for +`sec-002-1da3c505` passed the corrected physical-to-`R:` bootstrap, exact-host authority scans, and +both clean firmware build cycles, then failed closed during artifact comparison. No package ZIP, +sidecar, package verification log, flash, bridge session, robot-port access, or actuator command +was produced. WPR was idle, the temporary mapping was removed, and all packager/PlatformIO +processes exited. The complete failed cycle-B worktree remains attached at +`E:\sc-firmware-b-22576-e1b117f4`; ignored failure evidence is preserved under +`output/private/reproducibility-failures/20260804-213837-22576-9fb7bb93f52f4d9794f4a1bff3f43cd8`. + +Direct hashing found nine matching artifact pairs and six mismatches: `firmware.bin` and +`firmware.elf` for each of `stackchan`, `stackchan_servo_calibration`, and +`stackchan_release_full`. Every bootloader, partition table, and `boot_app0.bin` pair matched. Each +firmware BIN had identical size and differed in exactly 65 bytes: the 32-byte ESP application ELF +hash plus the final checksum and image digest. All runtime/loadable ELF sections matched. Only +non-runtime DWARF `.debug_info`/`.debug_line` metadata differed; cycle B recorded alternate relative +M5GFX include paths containing `../` segments. The source commit/epoch, dependency revisions, +toolchain identities, and runtime bytes were identical, so this is a real exact-byte reproducibility +failure but not evidence of changed firmware logic, robot behavior, power, USB, thermal, or motion. + +The varying input was the packager's intentional unequal scratch-root length (`fw-a` versus +`firmware-b`). A targeted diagnostic rebuilt `stackchan` from two fresh distinct 30-character +detached roots with separate caches, fixed-width names, the same exact commit/epoch, and different +dependency-install timestamps. Both firmware BINs matched at +`6873F6967C5DCD01BC6E7D62C63C48ED2BFF21EAB1FF0FFA4962BDF720DBB22B`; both ELFs matched at +`69D94CE11BF69EA1DE9F13AC03B61D8D6ADC5E5ABDF7F5C2113C25902B2BBA1D`. The release correction +therefore keeps distinct worktrees/caches and exact byte comparison, but requires `fw-a`, `fw-b`, +and independent-verifier `vrfy` roots to use one equal total length with a ten-digit process-ID +field. The SEC-002 hold remains in force until that correction passes contracts, exact-head CI, a +fresh full two-cycle package, and the independent rebuild. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index b6fde14f..2fbf4d4d 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -79,8 +79,12 @@ not a claim that arbitrary operating systems or different toolchain/dependency b A release-grade package performs two clean cycles for all three public environments, waits for at least a 65-second start-time boundary, compares the firmware BIN and ELF, bootloader, and partition-table hashes, and packages only the verified second-cycle artifacts. The cycles use -different short detached clean worktree paths of different lengths, pinned to the captured commit, -plus a distinct initially empty PlatformIO compiled-artifact cache per cycle/environment. The proof +distinct short detached clean worktrees with equal total path lengths, fixed-width process-ID +fields, and different labels, all pinned to the captured commit, plus a distinct initially empty +PlatformIO compiled-artifact cache per cycle/environment. The independent verifier uses the same +fixed-width total path length. This keeps the roots genuinely separate without crossing a legacy +GCC/PlatformIO command-path threshold that changes only DWARF include-path spelling and therefore +the ESP application ELF-hash field. The proof records 13 toolchain observations—one PreBuild record plus pre-execution and post-build records for each environment in both cycles—and six source identity attestations, binding both cycles to the manifest commit and epoch. The process retains the authenticated PreBuild bytes and namespace diff --git a/tools/firmware_reproducibility_proof.ps1 b/tools/firmware_reproducibility_proof.ps1 index fa934d9d..5e153045 100644 --- a/tools/firmware_reproducibility_proof.ps1 +++ b/tools/firmware_reproducibility_proof.ps1 @@ -24,6 +24,8 @@ function Assert-StackchanFirmwareReproducibilityProof { $null -ne $Proof.cycleBSourceEpoch -or $Proof.buildCachePolicy -ne "not-applicable-skip-build" -or $Proof.sourceIsolationPolicy -ne "not-applicable-skip-build" -or + $Proof.sourcePathTopologyPolicy -ne "not-applicable-skip-build" -or + [int]$Proof.sourceRootLength -ne 0 -or @($Proof.identityAttestations).Count -ne 0 -or @($Proof.cycleAArtifacts).Count -ne 0 -or @($Proof.cycleBArtifacts).Count -ne 0 -or @@ -45,7 +47,9 @@ function Assert-StackchanFirmwareReproducibilityProof { $Proof.buildCachePolicy -cne "isolated-empty-per-cycle-environment") { throw "Firmware reproducibility proof is not bound to one manifest Git identity and isolated build-cache policy" } - if ($Proof.sourceIsolationPolicy -cne "distinct-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths") { + if ($Proof.sourceIsolationPolicy -cne "distinct-equal-length-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths" -or + $Proof.sourcePathTopologyPolicy -cne "fixed-width-process-id-and-equal-length-distinct-labels" -or + [int]$Proof.sourceRootLength -le 0) { throw "Firmware reproducibility proof does not use the required distinct detached source policy" } diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index 6041e3c1..84b439c8 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -381,6 +381,7 @@ function Assert-ReleaseBootstrapTrust { } $bootstrapFiles = @( '.gitattributes', 'tools/package_release.ps1', + 'tools/verify_release_package.ps1', 'tools/test_firmware_reproducible_build_contract.ps1', 'tools/test_release_toolchain_integration_contract.ps1', 'tools/test_release_toolchain_documentation_contract.ps1', @@ -632,6 +633,8 @@ $bootstrapGitTopLevel = $physicalRepoRoot if ($ReleaseShortPathChild) { $bootstrapGitTopLevel = Get-ReleaseShortPathPhysicalRoot -LogicalRoot $physicalRepoRoot } +$trustedVerifierScriptPath = [System.IO.Path]::GetFullPath( + (Join-Path $bootstrapGitTopLevel 'tools/verify_release_package.ps1')) if (-not $SkipBuild) { Assert-ReleaseBootstrapTrust -Root $physicalRepoRoot ` -ExpectedGitTopLevel $bootstrapGitTopLevel @@ -910,6 +913,8 @@ $firmwareReproducibilityProof = [ordered]@{ cycleBSourceEpoch = $null buildCachePolicy = "not-applicable-skip-build" sourceIsolationPolicy = "not-applicable-skip-build" + sourcePathTopologyPolicy = "not-applicable-skip-build" + sourceRootLength = 0 identityAttestations = @() cycleAArtifacts = @() cycleBArtifacts = @() @@ -976,9 +981,11 @@ function New-ShortReleaseScratchPath { if ($null -eq $drive) { throw "Could not locate a fixed drive for a short release build worktree." } - $scratch = Join-Path $drive.Root ("sc-$Label-$PID-" + [guid]::NewGuid().ToString("N").Substring(0, 8)) + $fixedWidthProcessId = $PID.ToString('D10', [Globalization.CultureInfo]::InvariantCulture) + $scratch = Join-Path $drive.Root ("sc-$Label-$fixedWidthProcessId-" + [guid]::NewGuid().ToString("N").Substring(0, 8)) } else { - $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("sc-$Label-$PID-" + [guid]::NewGuid().ToString("N").Substring(0, 8)) + $fixedWidthProcessId = $PID.ToString('D10', [Globalization.CultureInfo]::InvariantCulture) + $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("sc-$Label-$fixedWidthProcessId-" + [guid]::NewGuid().ToString("N").Substring(0, 8)) } $scratch = [System.IO.Path]::GetFullPath($scratch) if ($env:OS -eq "Windows_NT" -and $scratch.Length -gt 60) { @@ -990,6 +997,20 @@ function New-ShortReleaseScratchPath { return $scratch } +function Assert-StackchanReleaseCycleSourceTopology { + param( + [Parameter(Mandatory = $true)][string]$CycleASourceRoot, + [Parameter(Mandatory = $true)][string]$CycleBSourceRoot + ) + + if ($CycleASourceRoot -ceq $CycleBSourceRoot -or + $CycleASourceRoot.Length -ne $CycleBSourceRoot.Length -or + (Split-Path -Leaf $CycleASourceRoot) -cnotmatch '^sc-fw-a-[0-9]{10}-[0-9a-f]{8}$' -or + (Split-Path -Leaf $CycleBSourceRoot) -cnotmatch '^sc-fw-b-[0-9]{10}-[0-9a-f]{8}$') { + throw "Firmware reproducibility proof requires distinct equal-length fixed-width source roots." + } +} + function Invoke-LoggedReleasePlatformio { param( [Parameter(Mandatory = $true)][string]$Environment, @@ -1334,11 +1355,10 @@ if (-not $SkipBuild) { $cycleBRoot = Join-Path $firmwareBuildCacheRoot "cycle-b" $firmwareDependencySnapshotRoot = Join-Path $firmwareBuildCacheRoot "cycle-b-dependencies" $cycleASourceRoot = New-ShortReleaseScratchPath -Label 'fw-a' - $cycleBSourceRoot = New-ShortReleaseScratchPath -Label 'firmware-b' - if ($cycleASourceRoot -ceq $cycleBSourceRoot -or - $cycleASourceRoot.Length -eq $cycleBSourceRoot.Length) { - throw "Firmware reproducibility proof requires distinct source roots with different path lengths." - } + $cycleBSourceRoot = New-ShortReleaseScratchPath -Label 'fw-b' + Assert-StackchanReleaseCycleSourceTopology ` + -CycleASourceRoot $cycleASourceRoot ` + -CycleBSourceRoot $cycleBSourceRoot $activeBuildSourceRoot = $null $activeBuildWorktreeAdded = $false $script:firmwareIdentityAttestations = @() @@ -1444,7 +1464,9 @@ if (-not $SkipBuild) { cycleBSourceCommit = $canonicalBuildCommit cycleBSourceEpoch = $canonicalBuildEpoch buildCachePolicy = "isolated-empty-per-cycle-environment" - sourceIsolationPolicy = "distinct-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths" + sourceIsolationPolicy = "distinct-equal-length-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths" + sourcePathTopologyPolicy = "fixed-width-process-id-and-equal-length-distinct-labels" + sourceRootLength = $cycleASourceRoot.Length identityAttestations = @($script:firmwareIdentityAttestations) cycleAArtifacts = @($cycleAArtifacts) cycleBArtifacts = @($cycleBArtifacts) @@ -3065,7 +3087,7 @@ $manifest = [ordered]@{ scope = if ($SkipBuild) { "unknown/unbound-skip-build; copied pre-existing outputs whose source identity is not established" } else { - "same host/core paths and clean commit across distinct prefix-mapped project roots, canonical recorded PlatformIO toolchain/configuration, and no listed ambient build overrides" + "same host/core paths and clean commit across distinct equal-length fixed-width prefix-mapped project roots, canonical recorded PlatformIO toolchain/configuration, and no listed ambient build overrides" } proof = $firmwareReproducibilityProof } @@ -3794,7 +3816,7 @@ $packageVerifyLog = Join-Path $releaseOutputRoot "$Version-package-verify.log" $packageVerifyArgs = @( "-NoProfile", "-ExecutionPolicy", "Bypass", - "-File", (Join-Path $PSScriptRoot "verify_release_package.ps1"), + "-File", $trustedVerifierScriptPath, "-Version", $Version, "-ZipPath", $zipPath, "-ExpectedCommit", $commit, diff --git a/tools/test_firmware_reproducibility_proof_contract.ps1 b/tools/test_firmware_reproducibility_proof_contract.ps1 index 0434bd29..c991a7f9 100644 --- a/tools/test_firmware_reproducibility_proof_contract.ps1 +++ b/tools/test_firmware_reproducibility_proof_contract.ps1 @@ -91,7 +91,9 @@ try { cycleBSourceCommit = $commit cycleBSourceEpoch = $epoch buildCachePolicy = "isolated-empty-per-cycle-environment" - sourceIsolationPolicy = "distinct-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths" + sourceIsolationPolicy = "distinct-equal-length-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths" + sourcePathTopologyPolicy = "fixed-width-process-id-and-equal-length-distinct-labels" + sourceRootLength = 30 identityAttestations = @($attestations) cycleAArtifacts = @(Copy-Proof $records) cycleBArtifacts = @(Copy-Proof $records) @@ -128,6 +130,8 @@ try { Invoke-ExpectedProofFailure { param($p) $p.cycleBSourceEpoch = "1700000001" } "one manifest Git identity" Invoke-ExpectedProofFailure { param($p) $p.buildCachePolicy = "shared-cache" } "isolated build-cache policy" Invoke-ExpectedProofFailure { param($p) $p.sourceIsolationPolicy = "same-worktree" } "distinct detached source policy" + Invoke-ExpectedProofFailure { param($p) $p.sourcePathTopologyPolicy = "varying-length" } "distinct detached source policy" + Invoke-ExpectedProofFailure { param($p) $p.sourceRootLength = 0 } "distinct detached source policy" $packagedArtifact = Join-Path $fixtureRoot "firmware/display_only/firmware.bin" [System.IO.File]::AppendAllText($packagedArtifact, "changed") @@ -145,6 +149,8 @@ try { cycleBSourceEpoch = $null buildCachePolicy = "not-applicable-skip-build" sourceIsolationPolicy = "not-applicable-skip-build" + sourcePathTopologyPolicy = "not-applicable-skip-build" + sourceRootLength = 0 identityAttestations = @() cycleAArtifacts = @() cycleBArtifacts = @() diff --git a/tools/test_firmware_reproducible_build_contract.ps1 b/tools/test_firmware_reproducible_build_contract.ps1 index 6be5eb7f..d72d8d6b 100644 --- a/tools/test_firmware_reproducible_build_contract.ps1 +++ b/tools/test_firmware_reproducible_build_contract.ps1 @@ -138,6 +138,67 @@ $verifyGovernanceText = $verifyText + "`n" + $proofHelperText $workflowText = Get-Content -LiteralPath $workflowPath -Raw $contractText = Get-Content -LiteralPath $PSCommandPath -Raw +$packageTopologyFunctions = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Assert-StackchanReleaseCycleSourceTopology' +}, $true)) +$verifierTopologyFunctions = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Assert-StackchanVerifierSourceTopology' +}, $true)) +Require-ReproAssertion ($packageTopologyFunctions.Count -eq 1) ` + 'package-source-topology-contract: expected one executable cycle-root guard' +Require-ReproAssertion ($verifierTopologyFunctions.Count -eq 1) ` + 'verifier-source-topology-contract: expected one executable rebuild-root guard' +if ($packageTopologyFunctions.Count -eq 1 -and $verifierTopologyFunctions.Count -eq 1) { + . ([scriptblock]::Create($packageTopologyFunctions[0].Extent.Text)) + . ([scriptblock]::Create($verifierTopologyFunctions[0].Extent.Text)) + + $topologyParent = [System.IO.Path]::GetTempPath() + $cycleARootFixture = Join-Path $topologyParent 'sc-fw-a-0000000001-deadbeef' + $cycleBRootFixture = Join-Path $topologyParent 'sc-fw-b-0000000001-feedface' + try { + Assert-StackchanReleaseCycleSourceTopology ` + -CycleASourceRoot $cycleARootFixture -CycleBSourceRoot $cycleBRootFixture + } catch { + $issues.Add("package-source-topology-valid-case: $($_.Exception.Message)") + } + $mismatchedCycleBRoot = Join-Path (Join-Path $topologyParent 'x') ` + 'sc-fw-b-0000000001-feedface' + $cycleMismatchRejected = $false + try { + Assert-StackchanReleaseCycleSourceTopology ` + -CycleASourceRoot $cycleARootFixture -CycleBSourceRoot $mismatchedCycleBRoot + } catch { + $cycleMismatchRejected = $true + } + Require-ReproAssertion $cycleMismatchRejected ` + 'package-source-topology-mutation: unequal total root lengths must fail' + + $verifierRootFixture = Join-Path $topologyParent 'sc-vrfy-0000000001-cafebabe' + try { + Assert-StackchanVerifierSourceTopology ` + -SourceRoot $verifierRootFixture -ExpectedLength $verifierRootFixture.Length + } catch { + $issues.Add("verifier-source-topology-valid-case: $($_.Exception.Message)") + } + foreach ($invalidLength in @( + ([int]$verifierRootFixture.Length - 1), + ([int]$verifierRootFixture.Length + 1))) { + $verifierMismatchRejected = $false + try { + Assert-StackchanVerifierSourceTopology ` + -SourceRoot $verifierRootFixture -ExpectedLength $invalidLength + } catch { + $verifierMismatchRejected = $true + } + Require-ReproAssertion $verifierMismatchRejected ` + "verifier-source-topology-mutation: root length $invalidLength must fail" + } +} + foreach ($workflowCompilerProbeMarker in @( '$pioarduinoCoreDir = Join-Path $env:RUNNER_TEMP "stackchan-pioarduino"', '-CompilerProbeCoreDir $pioarduinoCoreDir' @@ -327,7 +388,12 @@ foreach ($marker in @( "-CycleName 'cycle-a'", "-CycleName 'cycle-b'", 'isolated-empty-per-cycle-environment', - 'distinct-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths', + 'distinct-equal-length-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths', + 'fixed-width-process-id-and-equal-length-distinct-labels', + "New-ShortReleaseScratchPath -Label 'fw-a'", + "New-ShortReleaseScratchPath -Label 'fw-b'", + "'D10'", + 'distinct equal-length fixed-width source roots', 'STACKCHAN_EXPECTED_BUILD_COMMIT', 'STACKCHAN_EXPECTED_BUILD_EPOCH', 'output/private/reproducibility-failures', @@ -562,13 +628,22 @@ foreach ($marker in @( "unexpected or duplicate artifact", "cycleASourceCommit", "identityAttestations", - "distinct-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths", + "distinct-equal-length-short-detached-clean-worktrees-pinned-to-source-commit-with-prefix-mapped-paths", + "fixed-width-process-id-and-equal-length-distinct-labels", + "sourceRootLength", "DIAGNOSTIC_PACKAGE_DO_NOT_FLASH.txt", "Diagnostic archive integrity verified; release and hardware use forbidden:", "RequireReleaseEligible" )) { Require-ReproAssertion ($verifyGovernanceText.Contains($marker)) "package-verifier-missing: $marker" } +foreach ($marker in @( + "'sc-vrfy-'", + "'^sc-vrfy-[0-9]{10}-[0-9a-f]{8}$'", + "fixed-width equal-length source topology" +)) { + Require-ReproAssertion ($verifyText.Contains($marker)) "package-verifier-topology-missing: $marker" +} $workflowStep = "Run firmware reproducibility contract" Require-ReproAssertion ($workflowText.Contains($workflowStep) -and diff --git a/tools/test_release_toolchain_integration_contract.ps1 b/tools/test_release_toolchain_integration_contract.ps1 index 4878934a..7779efda 100644 --- a/tools/test_release_toolchain_integration_contract.ps1 +++ b/tools/test_release_toolchain_integration_contract.ps1 @@ -97,7 +97,9 @@ foreach ($needle in @( 'requires exactly one verified subst mapping', 'refuses a missing, non-directory, or redirected subst target', '-ExpectedGitTopLevel $bootstrapGitTopLevel', - 'detected a changed subst mapping during bootstrap trust verification')) { + 'detected a changed subst mapping during bootstrap trust verification', + "Join-Path `$bootstrapGitTopLevel 'tools/verify_release_package.ps1'", + '"-File", $trustedVerifierScriptPath')) { Require-Text $packageText $needle ` "Packager is missing fail-closed subst/Git top-level reconciliation: $needle" } @@ -111,6 +113,29 @@ $packageAst = [System.Management.Automation.Language.Parser]::ParseFile( if (@($packageParseErrors).Count -ne 0) { throw 'Release packager cannot be parsed for short-path regression testing.' } +$bootstrapTrustFunctions = @($packageAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Assert-ReleaseBootstrapTrust' +}, $true)) +if ($bootstrapTrustFunctions.Count -ne 1) { + throw 'Release packager must define one bootstrap-trust function.' +} +$bootstrapFileAssignments = @($bootstrapTrustFunctions[0].Body.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$bootstrapFiles' +}, $true)) +$bootstrapVerifierEntries = @(if ($bootstrapFileAssignments.Count -eq 1) { + $bootstrapFileAssignments[0].Right.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.StringConstantExpressionAst] -and + $node.Value -ceq 'tools/verify_release_package.ps1' + }, $true) +}) +if ($bootstrapFileAssignments.Count -ne 1 -or $bootstrapVerifierEntries.Count -ne 1) { + throw 'Release packager bootstrap trust must authenticate the physical verifier exactly once.' +} $shortPathFunction = $packageAst.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and @@ -159,6 +184,18 @@ try { $canonicalShortPathRepoRoot, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'Release short-path regression test did not recover the exact physical repository root.' } + $physicalVerifierScript = [IO.Path]::GetFullPath( + (Join-Path $observedPhysicalRoot 'tools/verify_release_package.ps1')) + $expectedPhysicalVerifierScript = [IO.Path]::GetFullPath( + (Join-Path $canonicalShortPathRepoRoot 'tools/verify_release_package.ps1')) + $logicalVerifierScript = [IO.Path]::GetFullPath( + (Join-Path "$shortPathDrive\tools" 'verify_release_package.ps1')) + if (-not $physicalVerifierScript.Equals( + $expectedPhysicalVerifierScript, [System.StringComparison]::OrdinalIgnoreCase) -or + $physicalVerifierScript.Equals( + $logicalVerifierScript, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'Release short-path regression test did not select the physical verifier checkout.' + } $nestedRejected = $false try { Get-ReleaseShortPathPhysicalRoot -LogicalRoot "$shortPathDrive\tools" | Out-Null diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 117bd425..8d8893c5 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -1003,6 +1003,18 @@ function Assert-OperationalPackageGitBindings { } } +function Assert-StackchanVerifierSourceTopology { + param( + [Parameter(Mandatory = $true)][string]$SourceRoot, + [Parameter(Mandatory = $true)][int]$ExpectedLength + ) + + if ((Split-Path -Leaf $SourceRoot) -cnotmatch '^sc-vrfy-[0-9]{10}-[0-9a-f]{8}$' -or + $SourceRoot.Length -ne $ExpectedLength) { + throw "Operational independent rebuild requires the proof's fixed-width equal-length source topology." + } +} + function Assert-OperationalFirmwareMatchesTrustedRebuild { if (-not $RequireReleaseEligible) { return } @@ -1022,13 +1034,17 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { $rebuildCacheRoot = Join-Path $rebuildEvidenceRoot 'build-cache' New-Item -ItemType Directory -Path $rebuildCacheRoot | Out-Null - $rebuildWorktree = if ($env:OS -eq 'Windows_NT') { - Join-Path ([System.IO.Path]::GetPathRoot($resolvedVerifierRoot)) ( - 'sc-vr-' + $PID + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) + $rebuildScratchParent = if ($env:OS -eq 'Windows_NT') { + [System.IO.Path]::GetPathRoot($resolvedVerifierRoot) } else { - Join-Path ([System.IO.Path]::GetTempPath()) ( - 'sc-vr-' + $PID + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) - } + [System.IO.Path]::GetTempPath() + } + $fixedWidthProcessId = $PID.ToString('D10', [Globalization.CultureInfo]::InvariantCulture) + $rebuildWorktree = Join-Path $rebuildScratchParent ( + 'sc-vrfy-' + $fixedWidthProcessId + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-StackchanVerifierSourceTopology ` + -SourceRoot $rebuildWorktree ` + -ExpectedLength ([int]$reproducibilityProof.sourceRootLength) if (Test-Path -LiteralPath $rebuildWorktree) { throw "Operational rebuild scratch path unexpectedly exists: $rebuildWorktree" } @@ -4432,7 +4448,7 @@ if ([bool]$manifest.diagnosticPackage) { $firmwareReproducibility.contract -ne "tools/test_firmware_reproducible_build_contract.ps1" -or $firmwareReproducibility.hookCoverage -ne "exactly-one-effective-hook" -or $firmwareReproducibility.releaseOverridePolicy -ne "release-overrides-fail-closed" -or - $firmwareReproducibility.scope -ne "same host/core paths and clean commit across distinct prefix-mapped project roots, canonical recorded PlatformIO toolchain/configuration, and no listed ambient build overrides" -or + $firmwareReproducibility.scope -ne "same host/core paths and clean commit across distinct equal-length fixed-width prefix-mapped project roots, canonical recorded PlatformIO toolchain/configuration, and no listed ambient build overrides" -or [string]$toolchainIdentity.status -cne 'verified-reviewed-toolchain-and-two-cycle-dependencies' -or [string]$toolchainIdentity.allowlistSha256 -cne $verifierToolchainAllowlistSha256 -or [string]$toolchainIdentity.identityHelperSha256 -cne $verifierToolchainIdentityHelperSha256 -or From df8e74694b5beda356ddd6f92837e4b64b70aca2 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 19:43:22 -0400 Subject: [PATCH 25/46] Bind release RVC assets to exact LFS pointers --- docs/FIRST_DEPLOY_STATUS.md | 52 +++ docs/RELEASE_PROCESS.md | 10 + tools/package_release.ps1 | 95 +++++- tools/release_source_binding.ps1 | 310 ++++++++++++++++++ tools/test_release_command_trust_contract.ps1 | 10 +- ...elease_package_verifier_trust_contract.ps1 | 32 +- .../test_release_source_binding_contract.ps1 | 196 ++++++++++- tools/verify_release_package.ps1 | 77 ++++- 8 files changed, 746 insertions(+), 36 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 5ea2729e..d27636f5 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -195,6 +195,40 @@ and independent-verifier `vrfy` roots to use one equal total length with a ten-d field. The SEC-002 hold remains in force until that correction passes contracts, exact-head CI, a fresh full two-cycle package, and the independent rebuild. +### Equal-length proof and commit-bound RVC packaging hold (2026-08-04) + +Qualification head `16d8f1b0245f829737e406c5a6309d5486249f2a` passed all 11 jobs on the +first attempt in exact-head GitHub Firmware run `30955812308`. Direct WPR-free packaging for +`sec-002-16d8f1b0` then built both release cycles from distinct equal-length 30-character source +roots, passed exact comparison of all 15 firmware artifacts, removed both clean cycle worktrees, +and advanced into the detached release-source staging phase. This closes the prior unequal-root +DWARF/ELF-hash failure in the actual governed path; it does not by itself create an eligible +package. + +The same run failed closed before ZIP creation and independent verification when the RVC verifier +read the canonical 133-byte Git LFS pointer for `model.pth` as though it were the 57,577,722-byte +payload. Hardened Git intentionally disables LFS filters, so the detached commit-bound worktree +correctly remains pointer-only. Both referenced objects are already present in the local Git LFS +object cache and independently match the committed OIDs, declared sizes, and reviewed production +SHA-256 values. No download, smudge, source-worktree hydration, flash, COM access, bridge session, +robot-port access, or actuator command occurred during the package run. The partial output has no +manifest, checksum inventory, ZIP, or verifier result and is not a candidate. + +The release correction keeps Git LFS execution and network access disabled. It requires ordinary +`H` index state and exact commit-pointer blob bytes, parses only the canonical three-line LFS v1 +pointer, selects the matching content-addressed object under the non-reparse local Git common +directory, copies it through one held read-only handle to a temporary package file, verifies size +and SHA-256, and only then promotes it to its destination. The independent verifier separately +binds the packaged bytes and manifest record back to the exact expected commit pointer, then applies +the existing production RVC hash allowlist. The source-binding, command-trust, verifier-trust, and +full 22-environment reproducibility contract bundle pass with this correction. The SEC-002 hold +remains in force until exact-head CI passes and a fresh governed package completes its independent +rebuild. +A focused real-cache probe used the retained pointer-only worktree at +`E:\sc-firmware-b-22576-e1b117f4`, streamed both local content-addressed objects through the new +helper, independently rebound the staged files to those pointers, and matched both reviewed hashes; +ignored evidence is under `output/private/manual-lfs-probe-20260804`. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that @@ -225,6 +259,24 @@ entries each hash to `F94C5D786A7A8FAB06AC5D10E33BF37711A6697636DC037559EA19CC41 the non-authorizing verifier passes, the operational flasher rejects it before flash preparation, and all release, hardware-validation, flash, and distribution eligibility flags are false. +### Operator-requested USB motor disable (2026-08-04) + +With Stackchan connected by USB and the area reported clear, an HTTP `/motion-stop` succeeded and +reported motion request/enabled, servo power authority, rail, and torque all false with +`motion_last_reason=manual_stop`. A redundant `motion stop` attempt through the official COM4 +serial helper is preserved under ignored `output/private/manual-motion-stop-20260804`, but opening +COM4 unexpectedly reset the board (`reset_reason` code `11`) before the command was consumed; it is +not counted as a successful serial stop. COM4 was left closed. A bounded post-reset HTTP +`/motion-stop` returned 200 on the first attempt, and a fresh `/debug` snapshot again reported all +motion/servo authorities false, `motion_last_reason=manual_stop`, protected power mode, and VBUS +`4488 mV`. No flash or observed actuator movement occurred. The reset is an observed USB/serial-open +event, not an inferred power, brownout, firmware, or containment root cause. A later read-only +`/debug` check with COM4 still closed again reported motion request/enabled, servo power authority, +rail, and torque all false with `motion_last_reason=manual_stop`; it reported `boot_count=1`, +`reset_reason=poweron` (code `1`), uptime about 577 seconds, idle power mode, VBUS `4639 mV`, and the +bridge offline. This is a subsequent observed boot relative to the earlier code-11 snapshot. Its +cause is unknown and is not attributed to USB, power, firmware, or the packaging work. + ## Last Owner-Accepted Physical Lead — Historical Evidence; Current Installation Unknown This section records the latest owner-accepted physical lead as of 2026-07-13. A fresh device diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 2fbf4d4d..56e5e144 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -348,6 +348,16 @@ To verify the exact production RVC model and index bundled in the release: Published releases include the current production `model.pth` and `model.index`, plus small Stackchan Spark MP3 previews as standalone GitHub assets. The model files are tracked with Git LFS and are accepted only when their exact byte lengths and SHA-256 values match the production record. +Release-grade packaging keeps all Git LFS filters and network retrieval disabled. The detached +commit worktree therefore remains pointer-only. The packager accepts a voice payload only by +strictly binding its canonical three-line LFS v1 pointer to the exact expected commit and ordinary +index state, then streaming the matching content-addressed object from the non-redirected local Git +LFS cache into package output. It hashes and counts the object through a held read-only handle, +promotes only a verified temporary file, and records the commit, pointer blob, OID, size, and offline +materialization policy in `release_manifest.json`. The independent verifier derives the pointer +again from `-ExpectedCommit`, binds the packaged bytes and manifest record to it, and separately +applies the reviewed production hash allowlist. Do not run `git lfs pull`, enable smudge, hydrate the +detached source worktree, or substitute mutable working-tree bytes in a governed package. To scrub restricted model and RVC payloads from a legacy/private ZIP while preserving the approved hash-pinned YuNet detector, write to a new archive: diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index 84b439c8..bc5ca0cb 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -1834,23 +1834,89 @@ foreach ($file in $voiceMediaFiles) { Copy-Item -LiteralPath $file -Destination $voiceMediaDir } -& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $releaseToolsRoot "verify_tracked_rvc_assets.ps1") ` - -VoiceRoot "media/voice/rvc" -if ($LASTEXITCODE -ne 0) { - throw "Tracked RVC audition asset verification failed." -} +$voiceRvcReadme = "media/voice/rvc/README.md" +Copy-StackchanCommitBoundPackageFile ` + -PackageSourceRoot $packageTrackedSourceRoot ` + -RelativePath $voiceRvcReadme ` + -DestinationPath (Join-Path $voiceRvcMediaDir "README.md") -$voiceRvcFiles = @( - "media/voice/rvc/README.md", - "media/voice/rvc/model.pth", - "media/voice/rvc/model.index" +$voiceRvcPayloads = @( + [ordered]@{ + relativePath = "media/voice/rvc/model.pth" + bytes = 57577722 + sha256 = "1A8ADDFD670CD811D1AD1EEB9E9B4FF72C5D795B1123A23E86A0C41C1DD9BF1A" + }, + [ordered]@{ + relativePath = "media/voice/rvc/model.index" + bytes = 99428699 + sha256 = "DA0EDB00FB15E8CEEC135B261F32E5907BA570FF0D213BEF8267EB80AB167DC2" + } ) - -foreach ($file in $voiceRvcFiles) { - if (-not (Test-Path -LiteralPath $file)) { - throw "Missing production RVC release asset: $file" +$voiceRvcSourceBindings = [System.Collections.Generic.List[object]]::new() +$releaseGitCommonDir = $null +if (-not $SkipBuild) { + $releaseGitCommonDirCandidate = (Invoke-ReleaseGit -Arguments @( + '-C', $packageTrackedSourceRoot, 'rev-parse', '--git-common-dir')).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($releaseGitCommonDirCandidate)) { + throw 'Could not resolve the trusted local Git object authority for RVC packaging.' + } + $releaseGitCommonDir = Resolve-ReleaseBootstrapGitPath ` + -Root $packageTrackedSourceRoot -Candidate $releaseGitCommonDirCandidate +} +foreach ($entry in $voiceRvcPayloads) { + $file = [string]$entry.relativePath + $destination = Join-Path $voiceRvcMediaDir ([System.IO.Path]::GetFileName($file)) + if ($SkipBuild) { + Copy-StackchanCommitBoundPackageFile ` + -PackageSourceRoot $packageTrackedSourceRoot ` + -RelativePath $file ` + -DestinationPath $destination + $voiceRvcSourceBindings.Add([ordered]@{ + sourcePath = $file + sourceCommit = $null + pointerBlob = $null + bytes = [int64](Get-Item -LiteralPath $destination).Length + sha256 = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash.ToUpperInvariant() + policy = 'diagnostic-working-tree-unbound' + }) | Out-Null + } else { + $pointerIndexRecord = @(Invoke-ReleaseGit -Arguments @( + '-C', $packageTrackedSourceRoot, 'ls-files', '-v', '--', $file)) + $pointerBlob = (Invoke-ReleaseGit -Arguments @( + '-C', $packageTrackedSourceRoot, 'rev-parse', '--verify', + "${canonicalBuildCommit}:$file")).Trim().ToLowerInvariant() + $pointerWorkingBlob = if ($pointerBlob -match '^[0-9a-f]{40,64}$') { + Get-ReleaseBootstrapCanonicalBlobHash ` + -LiteralPath (Join-Path $packageTrackedSourceRoot $file) ` + -HashLength $pointerBlob.Length + } else { '' } + if ($pointerIndexRecord.Count -ne 1 -or + [string]$pointerIndexRecord[0] -cne "H $file" -or + $pointerWorkingBlob -cne $pointerBlob) { + throw "Release packaging refuses hidden or noncanonical LFS pointer state: $file" + } + $binding = Copy-StackchanCommitBoundLfsPackageFile ` + -CommitPointerRoot $packageTrackedSourceRoot ` + -GitCommonDir $releaseGitCommonDir ` + -RelativePath $file ` + -DestinationPath $destination ` + -ExpectedBytes ([int64]$entry.bytes) ` + -ExpectedSha256 ([string]$entry.sha256) + $voiceRvcSourceBindings.Add([ordered]@{ + sourcePath = [string]$binding.relativePath + sourceCommit = $canonicalBuildCommit + pointerBlob = $pointerBlob + bytes = [int64]$binding.bytes + sha256 = [string]$binding.sha256 + policy = 'offline-local-lfs-object-bound-to-commit-pointer-v1' + }) | Out-Null } - Copy-Item -LiteralPath $file -Destination $voiceRvcMediaDir +} + +& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $releaseToolsRoot "verify_tracked_rvc_assets.ps1") ` + -VoiceRoot $voiceRvcMediaDir +if ($LASTEXITCODE -ne 0) { + throw "Packaged RVC asset verification failed." } Copy-Item -LiteralPath "README.md" -Destination $outDir @@ -3118,6 +3184,7 @@ $manifest = [ordered]@{ packageSourceIsolationPolicy = if ($SkipBuild) { "diagnostic-mutable-source-unbound" } else { "detached-clean-worktree-pinned-to-package-commit" } packageSourceCommit = if ($SkipBuild) { $null } else { $canonicalBuildCommit } packageSourceEpoch = if ($SkipBuild) { $null } else { $canonicalBuildEpoch } + voiceRvcSourceBindings = @($voiceRvcSourceBindings) releaseEligible = ($releaseToolchainEligible -and (-not $SkipBuild)) hardwareValidationEligible = ($releaseToolchainEligible -and (-not $SkipBuild)) distributionEligible = ($releaseToolchainEligible -and (-not $SkipBuild)) diff --git a/tools/release_source_binding.ps1 b/tools/release_source_binding.ps1 index e25a1a18..38b46ea5 100644 --- a/tools/release_source_binding.ps1 +++ b/tools/release_source_binding.ps1 @@ -26,3 +26,313 @@ function Copy-StackchanCommitBoundPackageFile { New-Item -ItemType Directory -Force -Path (Split-Path -Parent $DestinationPath) | Out-Null Copy-Item -LiteralPath $sourcePath -Destination $DestinationPath -Force } + +function Resolve-StackchanCommitBoundPackageLeaf { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$RelativePath, + [Parameter(Mandatory = $true)][string]$Label + ) + + if ([string]::IsNullOrWhiteSpace($RelativePath) -or + [System.IO.Path]::IsPathRooted($RelativePath) -or + $RelativePath.Contains(':')) { + throw "$Label path must be a safe relative path: $RelativePath" + } + $segments = @($RelativePath.Replace('\', '/').Split('/') | Where-Object { $_ -ne '' }) + if ($segments.Count -eq 0 -or $segments -contains '.' -or $segments -contains '..') { + throw "$Label path contains traversal: $RelativePath" + } + + $rootItem = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Root).Path -Force + if (-not $rootItem.PSIsContainer -or + ($rootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "$Label root must be one exact non-redirected directory: $Root" + } + $resolvedRoot = $rootItem.FullName.TrimEnd('\', '/') + $rootPrefix = $resolvedRoot + [System.IO.Path]::DirectorySeparatorChar + $leafPath = [System.IO.Path]::GetFullPath((Join-Path $resolvedRoot $RelativePath)) + if (-not $leafPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "$Label path escapes its root: $RelativePath" + } + + $cursor = $resolvedRoot + for ($index = 0; $index -lt $segments.Count; $index++) { + $cursor = Join-Path $cursor $segments[$index] + $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop + if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "$Label path contains a redirected component: $RelativePath" + } + $isLeaf = $index -eq ($segments.Count - 1) + if (($isLeaf -and $item.PSIsContainer) -or (-not $isLeaf -and -not $item.PSIsContainer)) { + throw "$Label path has an invalid component type: $RelativePath" + } + } + return $leafPath +} + +function Get-StackchanStreamSha256 { + param([Parameter(Mandatory = $true)][System.IO.Stream]$Stream) + + $hasher = [System.Security.Cryptography.SHA256]::Create() + try { + return ([System.BitConverter]::ToString($hasher.ComputeHash($Stream)) -replace '-', '').ToUpperInvariant() + } finally { + $hasher.Dispose() + } +} + +function Get-StackchanUtf8GitBlobHash { + param( + [Parameter(Mandatory = $true)][string]$Text, + [Parameter(Mandatory = $true)][ValidateSet(40, 64)][int]$HashLength + ) + + $utf8 = New-Object System.Text.UTF8Encoding($false, $true) + $contentBytes = $utf8.GetBytes($Text) + $headerBytes = [System.Text.Encoding]::ASCII.GetBytes("blob $($contentBytes.Length)`0") + $objectBytes = New-Object byte[] ($headerBytes.Length + $contentBytes.Length) + [System.Array]::Copy($headerBytes, 0, $objectBytes, 0, $headerBytes.Length) + [System.Array]::Copy($contentBytes, 0, $objectBytes, $headerBytes.Length, $contentBytes.Length) + $hasher = if ($HashLength -eq 40) { + [System.Security.Cryptography.SHA1]::Create() + } else { + [System.Security.Cryptography.SHA256]::Create() + } + try { + return (($hasher.ComputeHash($objectBytes) | ForEach-Object { $_.ToString('x2') }) -join '') + } finally { + $hasher.Dispose() + } +} + +function ConvertTo-StackchanCanonicalLfsPointerRecord { + param( + [Parameter(Mandatory = $true)][string]$PointerText, + [Parameter(Mandatory = $true)][string]$RelativePath + ) + + $pointerMatch = [regex]::Match( + $PointerText, + '\Aversion https://git-lfs\.github\.com/spec/v1\noid sha256:([0-9a-f]{64})\nsize (0|[1-9][0-9]*)\n\z', + [System.Text.RegularExpressions.RegexOptions]::CultureInvariant) + if (-not $pointerMatch.Success) { + throw "Commit-bound LFS pointer is not canonical: $RelativePath" + } + $expectedSha256 = $pointerMatch.Groups[1].Value.ToUpperInvariant() + [int64]$expectedLength = 0 + if (-not [int64]::TryParse( + $pointerMatch.Groups[2].Value, + [System.Globalization.NumberStyles]::None, + [System.Globalization.CultureInfo]::InvariantCulture, + [ref]$expectedLength)) { + throw "Commit-bound LFS pointer size is outside Int64 range: $RelativePath" + } + + return [pscustomobject]@{ + relativePath = $RelativePath.Replace('\', '/') + bytes = $expectedLength + sha256 = $expectedSha256 + } +} + +function Get-StackchanCommitBoundLfsPointerRecord { + param( + [Parameter(Mandatory = $true)][string]$CommitPointerRoot, + [Parameter(Mandatory = $true)][string]$RelativePath + ) + + $pointerPath = Resolve-StackchanCommitBoundPackageLeaf ` + -Root $CommitPointerRoot -RelativePath $RelativePath -Label 'Commit-bound LFS pointer' + $pointerBytes = [System.IO.File]::ReadAllBytes($pointerPath) + if ($pointerBytes.Length -eq 0 -or $pointerBytes.Length -gt 1024) { + throw "Commit-bound LFS pointer has an invalid byte count: $RelativePath" + } + $strictUtf8 = New-Object System.Text.UTF8Encoding($false, $true) + try { + $pointerText = $strictUtf8.GetString($pointerBytes) + } catch { + throw "Commit-bound LFS pointer is not strict UTF-8: $RelativePath" + } + return ConvertTo-StackchanCanonicalLfsPointerRecord ` + -PointerText $pointerText -RelativePath $RelativePath +} + +function Copy-StackchanCommitBoundLfsPackageFile { + param( + [Parameter(Mandatory = $true)][string]$CommitPointerRoot, + [Parameter(Mandatory = $true)][string]$GitCommonDir, + [Parameter(Mandatory = $true)][string]$RelativePath, + [Parameter(Mandatory = $true)][string]$DestinationPath, + [Parameter(Mandatory = $true)][int64]$ExpectedBytes, + [Parameter(Mandatory = $true)][string]$ExpectedSha256 + ) + + $pointer = Get-StackchanCommitBoundLfsPointerRecord ` + -CommitPointerRoot $CommitPointerRoot -RelativePath $RelativePath + $normalizedExpectedSha256 = $ExpectedSha256.ToUpperInvariant() + if ($ExpectedBytes -lt 0 -or $normalizedExpectedSha256 -notmatch '^[0-9A-F]{64}$' -or + [int64]$pointer.bytes -ne $ExpectedBytes -or + [string]$pointer.sha256 -cne $normalizedExpectedSha256) { + throw "Commit-bound LFS pointer does not match the reviewed release asset: $RelativePath" + } + $commonDirItem = Get-Item -LiteralPath (Resolve-Path -LiteralPath $GitCommonDir).Path -Force + if (-not $commonDirItem.PSIsContainer -or + ($commonDirItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "Git common directory must be one exact non-redirected directory: $GitCommonDir" + } + $localLfsRoot = Join-Path $commonDirItem.FullName 'lfs' + $localLfsRootItem = Get-Item -LiteralPath $localLfsRoot -Force -ErrorAction Stop + if (-not $localLfsRootItem.PSIsContainer -or + ($localLfsRootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "Local LFS directory must be one exact non-redirected directory: $localLfsRoot" + } + $localLfsObjectRoot = Join-Path $localLfsRootItem.FullName 'objects' + $localLfsObjectRootItem = Get-Item -LiteralPath $localLfsObjectRoot -Force -ErrorAction Stop + if (-not $localLfsObjectRootItem.PSIsContainer -or + ($localLfsObjectRootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "Local LFS object root must be one exact non-redirected directory: $localLfsObjectRoot" + } + $objectRelativePath = Join-Path ` + (Join-Path $normalizedExpectedSha256.Substring(0, 2).ToLowerInvariant() ` + $normalizedExpectedSha256.Substring(2, 2).ToLowerInvariant()) ` + $normalizedExpectedSha256.ToLowerInvariant() + $materializedPath = Resolve-StackchanCommitBoundPackageLeaf ` + -Root $localLfsObjectRootItem.FullName -RelativePath $objectRelativePath -Label 'Local LFS object' + + $destinationFullPath = [System.IO.Path]::GetFullPath($DestinationPath) + if (Test-Path -LiteralPath $destinationFullPath) { + throw "Commit-bound LFS package destination already exists: $destinationFullPath" + } + $destinationParent = Split-Path -Parent $destinationFullPath + New-Item -ItemType Directory -Force -Path $destinationParent | Out-Null + $destinationParentItem = Get-Item -LiteralPath $destinationParent -Force + if (-not $destinationParentItem.PSIsContainer -or + ($destinationParentItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "Commit-bound LFS package destination parent is redirected: $destinationParent" + } + + $temporaryPath = $destinationFullPath + '.lfs-stage-' + [guid]::NewGuid().ToString('N') + + try { + $sourceStream = [System.IO.File]::Open( + $materializedPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read) + try { + if ($sourceStream.Length -ne $ExpectedBytes) { + throw "Materialized LFS source byte count does not match the commit pointer: $RelativePath" + } + $sourceSha256 = Get-StackchanStreamSha256 -Stream $sourceStream + if ($sourceSha256 -cne $normalizedExpectedSha256) { + throw "Materialized LFS source SHA-256 does not match the commit pointer: $RelativePath" + } + $sourceStream.Position = 0 + $destinationStream = [System.IO.File]::Open( + $temporaryPath, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None) + try { + $sourceStream.CopyTo($destinationStream) + $destinationStream.Flush() + } finally { + $destinationStream.Dispose() + } + } finally { + $sourceStream.Dispose() + } + $packagedStream = [System.IO.File]::Open( + $temporaryPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read) + try { + if ($packagedStream.Length -ne $ExpectedBytes -or + (Get-StackchanStreamSha256 -Stream $packagedStream) -cne $normalizedExpectedSha256) { + throw "Packaged LFS payload does not match the commit pointer: $RelativePath" + } + } finally { + $packagedStream.Dispose() + } + [System.IO.File]::Move($temporaryPath, $destinationFullPath) + } finally { + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force + } + } + + return [pscustomobject]@{ + relativePath = $RelativePath.Replace('\', '/') + bytes = $ExpectedBytes + sha256 = $normalizedExpectedSha256 + } +} + +function Assert-StackchanPackageLfsPayloadMatchesCommitPointer { + param( + [Parameter(Mandatory = $true)][string]$CommitPointerRoot, + [Parameter(Mandatory = $true)][string]$RelativePath, + [Parameter(Mandatory = $true)][string]$PackagePath + ) + + $pointer = Get-StackchanCommitBoundLfsPointerRecord ` + -CommitPointerRoot $CommitPointerRoot -RelativePath $RelativePath + Assert-StackchanPackageLfsPayloadMatchesPointerRecord ` + -Pointer $pointer -PackagePath $PackagePath +} + +function Assert-StackchanPackageLfsPayloadMatchesPointerRecord { + param( + [Parameter(Mandatory = $true)][object]$Pointer, + [Parameter(Mandatory = $true)][string]$PackagePath + ) + + if ([string]$Pointer.relativePath -notmatch '^[^:]+$' -or + [int64]$Pointer.bytes -lt 0 -or + [string]$Pointer.sha256 -notmatch '^[0-9A-F]{64}$') { + throw 'Packaged LFS payload received an invalid trusted pointer record.' + } + $packageItem = Get-Item -LiteralPath $PackagePath -Force -ErrorAction Stop + if ($packageItem.PSIsContainer -or + ($packageItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "Packaged LFS payload must be one exact non-redirected file: $PackagePath" + } + $packageStream = [System.IO.File]::Open( + $packageItem.FullName, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read) + try { + if ($packageStream.Length -ne [int64]$pointer.bytes -or + (Get-StackchanStreamSha256 -Stream $packageStream) -cne [string]$pointer.sha256) { + throw "Packaged LFS payload does not match the trusted commit pointer: $([string]$pointer.relativePath)" + } + } finally { + $packageStream.Dispose() + } +} + +function Assert-StackchanLfsSourceBindingRecord { + param( + [Parameter(Mandatory = $true)][object[]]$ManifestBindings, + [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string]$ExpectedCommit, + [Parameter(Mandatory = $true)][string]$PointerBlob, + [Parameter(Mandatory = $true)][object]$Pointer + ) + + $manifestBinding = @($ManifestBindings | Where-Object { + [string]$_.sourcePath -ceq $SourcePath + }) + if ($manifestBinding.Count -ne 1 -or + [string]$manifestBinding[0].sourceCommit -cne $ExpectedCommit -or + [string]$manifestBinding[0].pointerBlob -cne $PointerBlob -or + [int64]$manifestBinding[0].bytes -ne [int64]$Pointer.bytes -or + [string]$manifestBinding[0].sha256 -cne [string]$Pointer.sha256 -or + [string]$manifestBinding[0].policy -cne + 'offline-local-lfs-object-bound-to-commit-pointer-v1') { + throw "Operational package manifest RVC LFS binding is invalid: $SourcePath" + } +} diff --git a/tools/test_release_command_trust_contract.ps1 b/tools/test_release_command_trust_contract.ps1 index 1d8a3dfd..1a7b5f25 100644 --- a/tools/test_release_command_trust_contract.ps1 +++ b/tools/test_release_command_trust_contract.ps1 @@ -3,16 +3,18 @@ $ErrorActionPreference = 'Stop' $packagePath = Join-Path $PSScriptRoot 'package_release.ps1' $verifyPath = Join-Path $PSScriptRoot 'verify_release_package.ps1' $gitTrustPath = Join-Path $PSScriptRoot 'release_git_trust.ps1' +$sourceBindingPath = Join-Path $PSScriptRoot 'release_source_binding.ps1' $platformioResolverPath = Join-Path $PSScriptRoot 'platformio_resolver.ps1' $previewPythonResolverPath = Join-Path $PSScriptRoot 'preview_python_resolver.ps1' $packageText = Get-Content -LiteralPath $packagePath -Raw $verifyText = Get-Content -LiteralPath $verifyPath -Raw $gitTrustText = Get-Content -LiteralPath $gitTrustPath -Raw +$sourceBindingText = Get-Content -LiteralPath $sourceBindingPath -Raw $platformioResolverText = Get-Content -LiteralPath $platformioResolverPath -Raw $previewPythonResolverText = Get-Content -LiteralPath $previewPythonResolverPath -Raw foreach ($scriptPath in @( - $packagePath, $verifyPath, $gitTrustPath, $platformioResolverPath, + $packagePath, $verifyPath, $gitTrustPath, $sourceBindingPath, $platformioResolverPath, $previewPythonResolverPath )) { $tokens = $null @@ -70,6 +72,12 @@ foreach ($required in @( if ($gitTrustText.Contains('Get-Command') -or $gitTrustText.Contains('PinLfsFilter')) { throw 'Trusted Git wrapper must not re-resolve Git or optionally enable LFS.' } +foreach ($forbiddenLfsAuthority in @('& git-lfs', '& git lfs', 'git lfs pull', 'git lfs checkout', + 'filter.lfs.process=', 'filter.lfs.smudge=')) { + if ($sourceBindingText.Contains($forbiddenLfsAuthority)) { + throw "Release source binding must not execute or enable Git LFS authority: $forbiddenLfsAuthority" + } +} foreach ($resolver in @( [pscustomobject]@{ label = 'PlatformIO'; text = $platformioResolverText }, [pscustomobject]@{ label = 'preview Python'; text = $previewPythonResolverText } diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 index e04a2b0e..078c01e2 100644 --- a/tools/test_release_package_verifier_trust_contract.ps1 +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -294,6 +294,22 @@ foreach ($required in @( throw "Verifier trust contract is missing: $required" } } +$manifestLoadOffset = $verifyText.LastIndexOf( + '$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json', + [System.StringComparison]::Ordinal) +$operationalBindingOffset = if ($manifestLoadOffset -ge 0) { + $verifyText.IndexOf( + 'Assert-OperationalPackageGitBindings -Manifest $manifest', + $manifestLoadOffset, + [System.StringComparison]::Ordinal) +} else { -1 } +$firstPackageHelperOffset = $verifyText.IndexOf( + '& (Join-Path $PSScriptRoot "verify_voice_samples.ps1")', + [System.StringComparison]::Ordinal) +if ($manifestLoadOffset -lt 0 -or $operationalBindingOffset -le $manifestLoadOffset -or + $firstPackageHelperOffset -le $operationalBindingOffset) { + throw 'Operational package Git binding must authenticate packaged helper bytes before their first execution.' +} $verifyTokens = $null $verifyParseErrors = $null $verifyAst = [System.Management.Automation.Language.Parser]::ParseFile( @@ -413,6 +429,7 @@ foreach ($helper in @( 'firmware_reproducibility_proof.ps1', 'release_zip_safety.ps1', 'release_dependency_evidence.ps1', + 'release_source_binding.ps1', 'release_git_trust.ps1', 'platformio_resolver.ps1' )) { @@ -426,6 +443,13 @@ foreach ($helper in @( throw "Operational verifier loads $helper before its trusted checkout gate" } } +$previewResolverLoad = @($verifyAst.EndBlock.Statements | Where-Object { + $_.Extent.Text -eq '. (Join-Path $PSScriptRoot "preview_python_resolver.ps1")' +}) +if ($previewResolverLoad.Count -ne 1 -or + $previewResolverLoad[0].Extent.StartOffset -le $releaseEligibilityGate.Extent.EndOffset) { + throw 'Operational verifier preview resolver is not uniquely loaded after its trusted bootstrap gate.' +} $bootstrapGitFunctions = @($verifyAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and @@ -1205,9 +1229,11 @@ try { 'firmware_reproducibility_proof.ps1', 'release_zip_safety.ps1', 'release_dependency_evidence.ps1', + 'release_source_binding.ps1', 'release_git_trust.ps1', 'release_ota_selector_policy.ps1', - 'platformio_resolver.ps1' + 'platformio_resolver.ps1', + 'preview_python_resolver.ps1' )) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot $relative) -Destination $epochTools } @@ -1324,9 +1350,11 @@ if (-not [string]::IsNullOrWhiteSpace($PackageRoot)) { 'firmware_reproducibility_proof.ps1', 'release_zip_safety.ps1', 'release_dependency_evidence.ps1', + 'release_source_binding.ps1', 'release_git_trust.ps1', 'release_ota_selector_policy.ps1', - 'platformio_resolver.ps1' + 'platformio_resolver.ps1', + 'preview_python_resolver.ps1' )) { $preGateMarker = Join-Path ([System.IO.Path]::GetTempPath()) ( 'stackchan-dirty-helper-marker-' + [guid]::NewGuid().ToString('N') + '.txt') diff --git a/tools/test_release_source_binding_contract.ps1 b/tools/test_release_source_binding_contract.ps1 index d556ae2d..3ed8a6b7 100644 --- a/tools/test_release_source_binding_contract.ps1 +++ b/tools/test_release_source_binding_contract.ps1 @@ -159,6 +159,10 @@ foreach ($required in @( 'Push-Location $releaseSourceRoot', '$packageTrackedSourceRoot = if ($SkipBuild) { [string]$repoRoot } else { [string]$releaseSourceRoot }', 'Copy-StackchanCommitBoundPackageFile', + 'Copy-StackchanCommitBoundLfsPackageFile', + '-GitCommonDir $releaseGitCommonDir', + 'offline-local-lfs-object-bound-to-commit-pointer-v1', + 'voiceRvcSourceBindings = @($voiceRvcSourceBindings)', '$releaseToolsRoot = if ($SkipBuild)', '-Phase "final commit-bound package source staging"', '-Phase "final release checkout audit"', @@ -195,12 +199,22 @@ if (-not $sourceCleanupText.Contains('full-failed-worktree-retained-attached') - foreach ($required in @( 'packageSourceIsolationPolicy -ne "detached-clean-worktree-pinned-to-package-commit"', 'packageSourceCommit -cne $ExpectedCommit', - 'packageSourceEpoch' + 'packageSourceEpoch', + "'cat-file', 'blob', `$pointerBlob", + 'Get-StackchanUtf8GitBlobHash', + 'LFS pointer reconstruction does not match the exact commit blob', + 'ConvertTo-StackchanCanonicalLfsPointerRecord', + 'Assert-StackchanPackageLfsPayloadMatchesPointerRecord', + 'Assert-StackchanLfsSourceBindingRecord' )) { if (-not $verifyText.Contains($required)) { throw "Release source-binding verifier is missing: $required" } } +if ($verifyText.Contains('-SourcePaths $lfsMediaSources') -or + $verifyText.Contains('-CommitPointerRoot $resolvedVerifierRoot')) { + throw 'Operational verifier still depends on hydrated working-tree bytes for LFS binding.' +} $sourceCreationIndex = $packageText.IndexOf("New-ShortReleaseScratchPath -Label 'release-src'") $sourcePushIndex = $packageText.IndexOf('Push-Location $releaseSourceRoot') $firstTrackedCopyIndex = $packageText.IndexOf('Copy-Item -LiteralPath "README.md"') @@ -279,6 +293,186 @@ if ($zipCreationCommands.Count -ne 1 -or throw 'Final ignored-file audit does not govern the package hash, ZIP, and final verifier in one ordered chain' } +$lfsFixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'stackchan-lfs-source-binding-contract-' + [guid]::NewGuid().ToString('N')) +try { + $pointerRoot = Join-Path $lfsFixtureRoot 'pointer' + $pointerRelative = 'media/voice/rvc/model.bin' + $pointerPath = Join-Path $pointerRoot $pointerRelative + $gitCommonDir = Join-Path $lfsFixtureRoot 'git-common' + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $pointerPath), $gitCommonDir | Out-Null + $payload = [byte[]](0..255) + $payloadHasher = [System.Security.Cryptography.SHA256]::Create() + try { + $payloadHash = [System.BitConverter]::ToString( + $payloadHasher.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } finally { + $payloadHasher.Dispose() + } + $pointerText = "version https://git-lfs.github.com/spec/v1`noid sha256:$payloadHash`nsize $($payload.Length)`n" + $canonicalPointerBlob = Get-StackchanUtf8GitBlobHash -Text $pointerText -HashLength 40 + $crlfPointerBlob = Get-StackchanUtf8GitBlobHash ` + -Text $pointerText.Replace("`n", "`r`n") -HashLength 40 + $missingFinalLfBlob = Get-StackchanUtf8GitBlobHash ` + -Text $pointerText.TrimEnd("`n") -HashLength 40 + if ($canonicalPointerBlob -eq $crlfPointerBlob -or + $canonicalPointerBlob -eq $missingFinalLfBlob) { + throw 'Canonical pointer Git-blob proof did not distinguish exact line-ending bytes.' + } + [System.IO.File]::WriteAllText( + $pointerPath, $pointerText, (New-Object System.Text.UTF8Encoding($false))) + $objectPath = Join-Path $gitCommonDir ( + "lfs/objects/$($payloadHash.Substring(0, 2))/$($payloadHash.Substring(2, 2))/$payloadHash") + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $objectPath) | Out-Null + [System.IO.File]::WriteAllBytes($objectPath, $payload) + + $destination = Join-Path $lfsFixtureRoot 'package/model.bin' + $binding = Copy-StackchanCommitBoundLfsPackageFile ` + -CommitPointerRoot $pointerRoot ` + -GitCommonDir $gitCommonDir ` + -RelativePath $pointerRelative ` + -DestinationPath $destination ` + -ExpectedBytes $payload.Length ` + -ExpectedSha256 $payloadHash + if ([string]$binding.sha256 -cne $payloadHash.ToUpperInvariant() -or + [int64]$binding.bytes -ne $payload.Length -or + -not [System.Linq.Enumerable]::SequenceEqual( + [byte[]][System.IO.File]::ReadAllBytes($destination), $payload)) { + throw 'Commit-bound LFS copy did not produce the exact pointer-bound payload.' + } + Assert-StackchanPackageLfsPayloadMatchesCommitPointer ` + -CommitPointerRoot $pointerRoot -RelativePath $pointerRelative -PackagePath $destination + + foreach ($case in @( + 'wrong-reviewed-hash', 'corrupt-object', 'malformed-pointer', + 'missing-final-lf', 'missing-object')) { + $casePointerRoot = Join-Path $lfsFixtureRoot "pointer-$case" + $casePointerPath = Join-Path $casePointerRoot $pointerRelative + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $casePointerPath) | Out-Null + $casePointerText = if ($case -eq 'malformed-pointer') { + $pointerText + "extension unreviewed`n" + } elseif ($case -eq 'missing-final-lf') { + $pointerText.TrimEnd("`n") + } else { + $pointerText + } + [System.IO.File]::WriteAllText( + $casePointerPath, $casePointerText, (New-Object System.Text.UTF8Encoding($false))) + $caseCommonDir = if ($case -eq 'missing-object') { + $missingCommon = Join-Path $lfsFixtureRoot 'missing-git-common' + New-Item -ItemType Directory -Force -Path (Join-Path $missingCommon 'lfs/objects') | Out-Null + $missingCommon + } else { + $gitCommonDir + } + if ($case -eq 'corrupt-object') { + [System.IO.File]::WriteAllBytes($objectPath, [byte[]](255..0)) + } + $caseDestination = Join-Path $lfsFixtureRoot "package-$case/model.bin" + try { + Copy-StackchanCommitBoundLfsPackageFile ` + -CommitPointerRoot $casePointerRoot ` + -GitCommonDir $caseCommonDir ` + -RelativePath $pointerRelative ` + -DestinationPath $caseDestination ` + -ExpectedBytes $payload.Length ` + -ExpectedSha256 $(if ($case -eq 'wrong-reviewed-hash') { '0' * 64 } else { $payloadHash }) | Out-Null + throw "Commit-bound LFS copy accepted invalid fixture: $case" + } catch { + if ($_.Exception.Message -eq "Commit-bound LFS copy accepted invalid fixture: $case") { throw } + } finally { + if ($case -eq 'corrupt-object') { + [System.IO.File]::WriteAllBytes($objectPath, $payload) + } + } + if ((Test-Path -LiteralPath $caseDestination) -or + @(Get-ChildItem -LiteralPath (Split-Path -Parent $caseDestination) ` + -Filter '*.lfs-stage-*' -Force -ErrorAction SilentlyContinue).Count -ne 0) { + throw "Failed commit-bound LFS copy retained output: $case" + } + } + try { + Copy-StackchanCommitBoundLfsPackageFile ` + -CommitPointerRoot $pointerRoot -GitCommonDir $gitCommonDir ` + -RelativePath '../model.bin' -DestinationPath (Join-Path $lfsFixtureRoot 'escape.bin') ` + -ExpectedBytes $payload.Length -ExpectedSha256 $payloadHash | Out-Null + throw 'Commit-bound LFS copy accepted traversal.' + } catch { + if ($_.Exception.Message -eq 'Commit-bound LFS copy accepted traversal.') { throw } + } + + [System.IO.File]::WriteAllBytes($pointerPath, $payload) + $commitPointerRecord = ConvertTo-StackchanCanonicalLfsPointerRecord ` + -PointerText $pointerText -RelativePath $pointerRelative + Assert-StackchanPackageLfsPayloadMatchesPointerRecord ` + -Pointer $commitPointerRecord -PackagePath $destination + try { + Get-StackchanCommitBoundLfsPointerRecord ` + -CommitPointerRoot $pointerRoot -RelativePath $pointerRelative | Out-Null + throw 'Hydrated-checkout regression fixture unexpectedly parsed working payload bytes as a pointer.' + } catch { + if ($_.Exception.Message -eq + 'Hydrated-checkout regression fixture unexpectedly parsed working payload bytes as a pointer.') { + throw + } + } + + $expectedCommit = 'a' * 40 + $expectedPointerBlob = 'b' * 40 + $validManifestBinding = [pscustomobject]@{ + sourcePath = $pointerRelative + sourceCommit = $expectedCommit + pointerBlob = $expectedPointerBlob + bytes = $commitPointerRecord.bytes + sha256 = $commitPointerRecord.sha256 + policy = 'offline-local-lfs-object-bound-to-commit-pointer-v1' + } + Assert-StackchanLfsSourceBindingRecord ` + -ManifestBindings @($validManifestBinding) ` + -SourcePath $pointerRelative ` + -ExpectedCommit $expectedCommit ` + -PointerBlob $expectedPointerBlob ` + -Pointer $commitPointerRecord + $invalidManifestBindings = [System.Collections.Generic.List[object]]::new() + $invalidManifestBindings.Add([object[]]@([pscustomobject]@{ + sourcePath = $pointerRelative; sourceCommit = ('c' * 40); pointerBlob = $expectedPointerBlob + bytes = $commitPointerRecord.bytes; sha256 = $commitPointerRecord.sha256 + policy = 'offline-local-lfs-object-bound-to-commit-pointer-v1' + })) | Out-Null + $invalidManifestBindings.Add([object[]]@([pscustomobject]@{ + sourcePath = $pointerRelative; sourceCommit = $expectedCommit; pointerBlob = ('d' * 40) + bytes = $commitPointerRecord.bytes; sha256 = $commitPointerRecord.sha256 + policy = 'offline-local-lfs-object-bound-to-commit-pointer-v1' + })) | Out-Null + $invalidManifestBindings.Add([object[]]@([pscustomobject]@{ + sourcePath = $pointerRelative; sourceCommit = $expectedCommit; pointerBlob = $expectedPointerBlob + bytes = $commitPointerRecord.bytes; sha256 = ('E' * 64) + policy = 'offline-local-lfs-object-bound-to-commit-pointer-v1' + })) | Out-Null + $invalidManifestBindings.Add( + [object[]]@($validManifestBinding, $validManifestBinding)) | Out-Null + foreach ($invalidBindings in $invalidManifestBindings) { + try { + Assert-StackchanLfsSourceBindingRecord ` + -ManifestBindings @($invalidBindings) ` + -SourcePath $pointerRelative ` + -ExpectedCommit $expectedCommit ` + -PointerBlob $expectedPointerBlob ` + -Pointer $commitPointerRecord + throw 'RVC LFS manifest binding accepted mutated or duplicate evidence.' + } catch { + if ($_.Exception.Message -eq + 'RVC LFS manifest binding accepted mutated or duplicate evidence.') { + throw + } + } + } +} finally { + if (Test-Path -LiteralPath $lfsFixtureRoot) { + [System.IO.Directory]::Delete($lfsFixtureRoot, $true) + } +} + $root = Join-Path ([System.IO.Path]::GetTempPath()) ( "stackchan-source-binding-contract-" + [guid]::NewGuid().ToString("N")) $main = Join-Path $root "main" diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 8d8893c5..997c8223 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -399,9 +399,11 @@ if ($RequireReleaseEligible) { 'tools/firmware_reproducibility_proof.ps1', 'tools/release_zip_safety.ps1', 'tools/release_dependency_evidence.ps1', + 'tools/release_source_binding.ps1', 'tools/release_git_trust.ps1', 'tools/release_ota_selector_policy.ps1', - 'tools/platformio_resolver.ps1' + 'tools/platformio_resolver.ps1', + 'tools/preview_python_resolver.ps1' )) { $indexRecord = @(Invoke-TrustedVerifierGit -Arguments @( '-C', $resolvedVerifierRoot, 'ls-files', '-v', '--', $trustedBootstrapRelative)) @@ -439,6 +441,7 @@ if ($RequireReleaseEligible) { . (Join-Path $PSScriptRoot "firmware_reproducibility_proof.ps1") . (Join-Path $PSScriptRoot "release_zip_safety.ps1") . (Join-Path $PSScriptRoot "release_dependency_evidence.ps1") +. (Join-Path $PSScriptRoot "release_source_binding.ps1") . (Join-Path $PSScriptRoot "release_git_trust.ps1") . (Join-Path $PSScriptRoot "release_ota_selector_policy.ps1") . (Join-Path $PSScriptRoot "platformio_resolver.ps1") @@ -970,10 +973,17 @@ function Assert-OperationalPackageGitBindings { $mediaSourceMappings = [System.Collections.Generic.Dictionary[string,string]]::new( [System.StringComparer]::Ordinal) + $lfsMediaSources = @( + 'media/voice/rvc/model.pth', + 'media/voice/rvc/model.index' + ) $mediaTreeRoot = Join-Path $packageEnumerationRoot 'media' foreach ($item in Get-ChildItem -LiteralPath $mediaTreeRoot -Recurse -File -Force) { $packageRelative = (Get-PackageItemFullName $item).Substring( $packageRootPrefix.Length).Replace('\', '/') + if ($packageRelative -in $lfsMediaSources) { + continue + } $sourceRelative = if ($commitMaps.treeBlobs.ContainsKey($packageRelative)) { $packageRelative } elseif ($commitMaps.treeBlobs.ContainsKey("docs/$packageRelative")) { @@ -990,6 +1000,40 @@ function Assert-OperationalPackageGitBindings { -TrustedSourceRelativePath ([string]$mediaSourceMappings[$packageRelative]) ` -CommitMaps $commitMaps } + $manifestLfsBindings = @($Manifest.voiceRvcSourceBindings) + if ($manifestLfsBindings.Count -ne $lfsMediaSources.Count) { + throw 'Operational package manifest has the wrong RVC LFS source-binding count.' + } + foreach ($sourcePath in $lfsMediaSources) { + if (-not $commitMaps.treeBlobs.ContainsKey($sourcePath) -or + -not $commitMaps.indexStates.ContainsKey($sourcePath) -or + [string]$commitMaps.indexStates[$sourcePath] -cne 'H') { + throw "Operational package policy refuses missing or hidden LFS source state: $sourcePath" + } + $pointerBlob = [string]$commitMaps.treeBlobs[$sourcePath] + $pointerLines = @(Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'cat-file', 'blob', $pointerBlob)) + if ($LASTEXITCODE -ne 0 -or $pointerLines.Count -ne 3) { + throw "Operational package policy could not read a canonical trusted LFS pointer: $sourcePath" + } + $pointerText = ((@($pointerLines | ForEach-Object { [string]$_ }) -join "`n") + "`n") + $pointerTextBytes = [System.Text.Encoding]::UTF8.GetByteCount($pointerText) + if ($pointerTextBytes -eq 0 -or $pointerTextBytes -gt 1024 -or + (Get-StackchanUtf8GitBlobHash ` + -Text $pointerText -HashLength $pointerBlob.Length) -cne $pointerBlob) { + throw "Operational package policy LFS pointer reconstruction does not match the exact commit blob: $sourcePath" + } + $pointer = ConvertTo-StackchanCanonicalLfsPointerRecord ` + -PointerText $pointerText -RelativePath $sourcePath + Assert-StackchanPackageLfsPayloadMatchesPointerRecord ` + -Pointer $pointer -PackagePath (Join-PackagePath $sourcePath) + Assert-StackchanLfsSourceBindingRecord ` + -ManifestBindings $manifestLfsBindings ` + -SourcePath $sourcePath ` + -ExpectedCommit $ExpectedCommit ` + -PointerBlob $pointerBlob ` + -Pointer $pointer + } $allowedTopDirectories = @( 'artifacts', 'bridge', 'character-red-team', 'companion', 'data', 'docs', @@ -4317,6 +4361,20 @@ Assert-Bytes "media/voice/stackchan_spark_thinking.wav" ([byte[]](0x52, 0x49, 0x Assert-Bytes "media/voice/stackchan_spark_safety.wav" ([byte[]](0x52, 0x49, 0x46, 0x46)) Assert-Bytes "media/voice/stackchan_spark_audition_warm_slow_greeting.wav" ([byte[]](0x52, 0x49, 0x46, 0x46)) Assert-Bytes "media/voice/stackchan_spark_audition_bright_robot_greeting.wav" ([byte[]](0x52, 0x49, 0x46, 0x46)) + +$manifestPath = Join-PackagePath "release_manifest.json" +$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +if ($manifest.releaseAssetManifest -ne "release_assets.json") { + throw "Manifest releaseAssetManifest mismatch: $($manifest.releaseAssetManifest)" +} +if ($manifest.version -ne $Version) { + throw "Manifest version mismatch: expected $Version, got $($manifest.version)" +} +if ($manifest.commit -ne $ExpectedCommit) { + throw "Manifest commit mismatch: expected $ExpectedCommit, got $($manifest.commit)" +} +Assert-OperationalPackageGitBindings -Manifest $manifest + & (Join-Path $PSScriptRoot "verify_voice_samples.ps1") -VoiceRoot (Join-PackagePath "media/voice") & (Join-Path $PSScriptRoot "verify_tracked_rvc_assets.ps1") -VoiceRoot (Join-PackagePath "media/voice/rvc") foreach ($asset in @($personaPromptAssets.assets)) { @@ -4330,13 +4388,6 @@ foreach ($asset in @($personaPromptAssets.assets)) { & (Join-Path $PSScriptRoot "verify_face_phase_d.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") & (Join-Path $PSScriptRoot "verify_face_phase_e.ps1") -ArtifactsRoot (Join-PackagePath "artifacts/face") -$manifestPath = Join-PackagePath "release_manifest.json" -$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json - -if ($manifest.releaseAssetManifest -ne "release_assets.json") { - throw "Manifest releaseAssetManifest mismatch: $($manifest.releaseAssetManifest)" -} - $contractZipPath = if ([string]::IsNullOrWhiteSpace($ZipPath)) { Join-Path $repoRoot "output/release/stackchan_alive_$Version.zip" } else { @@ -4352,14 +4403,6 @@ if ($LASTEXITCODE -ne 0) { throw "Release asset contract verification failed." } -if ($manifest.version -ne $Version) { - throw "Manifest version mismatch: expected $Version, got $($manifest.version)" -} - -if ($manifest.commit -ne $ExpectedCommit) { - throw "Manifest commit mismatch: expected $ExpectedCommit, got $($manifest.commit)" -} - if ($manifest.board -ne "m5stack-cores3") { throw "Manifest board mismatch: $($manifest.board)" } @@ -4473,8 +4516,6 @@ foreach ($governanceTool in $m0GovernanceTools) { throw "Manifest includedTools is missing M0 governance input: $governanceTool" } } -Assert-OperationalPackageGitBindings -Manifest $manifest - $reproducibilityHook = "pre:tools/platformio_reproducible_build.py" $reproducibilityRootEnvironments = @( "stackchan", From aa038361105aa29cced9525c17388193f3a59a07 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 21:00:05 -0400 Subject: [PATCH 26/46] Make red-team CLI safe-path compatible --- bridge/character_red_team.py | 9 ++++++++ bridge/test_character_red_team.py | 34 +++++++++++++++++++++++++++++++ docs/FIRST_DEPLOY_STATUS.md | 21 ++++++++++++++++--- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/bridge/character_red_team.py b/bridge/character_red_team.py index 49f61353..1f016f9e 100644 --- a/bridge/character_red_team.py +++ b/bridge/character_red_team.py @@ -5,10 +5,19 @@ import argparse import json +import sys from datetime import datetime, timezone from pathlib import Path from typing import Any +# Release packaging runs Python with PYTHONSAFEPATH=1, which intentionally +# removes the script directory from sys.path. Bind sibling imports to this +# exact tracked bridge directory instead of relying on the caller's cwd or an +# ambient PYTHONPATH. +BRIDGE_MODULE_DIR = Path(__file__).resolve().parent +if str(BRIDGE_MODULE_DIR) not in sys.path: + sys.path.insert(0, str(BRIDGE_MODULE_DIR)) + from character_harness import ( RED_TEAM_SUITE, build_prompt, diff --git a/bridge/test_character_red_team.py b/bridge/test_character_red_team.py index 5eb65e28..91f89110 100644 --- a/bridge/test_character_red_team.py +++ b/bridge/test_character_red_team.py @@ -1,4 +1,7 @@ import json +import os +import subprocess +import sys import tempfile import unittest from pathlib import Path @@ -14,6 +17,37 @@ class CharacterRedTeamTests(unittest.TestCase): + def test_cli_binds_sibling_modules_with_python_safe_path(self): + script = Path(__file__).resolve().with_name("character_red_team.py") + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + environment["PYTHONSAFEPATH"] = "1" + environment["PYTHONNOUSERSITE"] = "1" + with tempfile.TemporaryDirectory() as directory: + output_dir = Path(directory) / "report" + result = subprocess.run( + [ + sys.executable, + str(script), + "--out-dir", + str(output_dir), + "--json", + ], + cwd=directory, + env=environment, + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(0, result.returncode, result.stderr) + report = json.loads(result.stdout) + self.assertEqual("stackchan.character-red-team.v1", report["schema"]) + self.assertEqual( + "dry-run-no-runner-configured", report["summary"]["status"] + ) + self.assertTrue((output_dir / "character_red_team.json").is_file()) + def test_red_team_suite_has_required_size_and_topics(self): names = {case["name"] for case in RED_TEAM_SUITE} diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index d27636f5..b91fdbfc 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -221,14 +221,29 @@ directory, copies it through one held read-only handle to a temporary package fi and SHA-256, and only then promotes it to its destination. The independent verifier separately binds the packaged bytes and manifest record back to the exact expected commit pointer, then applies the existing production RVC hash allowlist. The source-binding, command-trust, verifier-trust, and -full 22-environment reproducibility contract bundle pass with this correction. The SEC-002 hold -remains in force until exact-head CI passes and a fresh governed package completes its independent -rebuild. +full 22-environment reproducibility contract bundle pass with this correction. A focused real-cache probe used the retained pointer-only worktree at `E:\sc-firmware-b-22576-e1b117f4`, streamed both local content-addressed objects through the new helper, independently rebound the staged files to those pointers, and matched both reviewed hashes; ignored evidence is under `output/private/manual-lfs-probe-20260804`. +Qualification head `df8e74694b5beda356ddd6f92837e4b64b70aca2` then passed all 11 jobs on the +first attempt in exact-head GitHub Firmware run `30960949704`. Governed packaging for +`sec-002-df8e7469` again completed both equal-length firmware cycles and exact comparison of all 15 +artifacts. The detached release-source phase successfully materialized and verified both production +RVC payloads from the exact commit pointers. The run then failed closed before manifest, ZIP, or +independent package verification when the Character Lock red-team CLI could not import its sibling +`character_harness` module. Release Python intentionally had `PYTHONSAFEPATH=1`; the CLI had relied +on Python implicitly adding the script directory to `sys.path`, so the isolated package path exposed +the defect. The partial output under `output/release/sec-002-df8e7469` is not a candidate. No flash, +OTA request, COM access, bridge session, robot-port access, or actuator command occurred. + +The correction keeps the global safe-path policy in force and explicitly anchors the red-team CLI's +sibling imports to the resolved directory containing its own exact tracked file. A subprocess +regression launches the CLI from an unrelated directory with `PYTHONSAFEPATH=1` and +`PYTHONNOUSERSITE=1`. The SEC-002 hold remains in force until that correction passes the broad +bridge/release contracts, exact-head CI, and a fresh governed package plus independent rebuild. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that From 332708ef157529eb568f50d1deacb2a7b836d5ee Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 22:03:47 -0400 Subject: [PATCH 27/46] Bind voice status to packaged RVC assets --- docs/FIRST_DEPLOY_STATUS.md | 22 ++++++++- tools/export_voice_source_status.ps1 | 6 ++- tools/package_release.ps1 | 3 +- tools/test_release_command_trust_contract.ps1 | 3 ++ .../test_voice_source_readiness_contract.ps1 | 45 ++++++++++++++++++- tools/verify_release_package.ps1 | 4 +- 6 files changed, 76 insertions(+), 7 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index b91fdbfc..c1e5be8e 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -241,8 +241,26 @@ OTA request, COM access, bridge session, robot-port access, or actuator command The correction keeps the global safe-path policy in force and explicitly anchors the red-team CLI's sibling imports to the resolved directory containing its own exact tracked file. A subprocess regression launches the CLI from an unrelated directory with `PYTHONSAFEPATH=1` and -`PYTHONNOUSERSITE=1`. The SEC-002 hold remains in force until that correction passes the broad -bridge/release contracts, exact-head CI, and a fresh governed package plus independent rebuild. +`PYTHONNOUSERSITE=1`. Qualification head +`aa038361105aa29cced9525c17388193f3a59a07` passed all 11 jobs on the first attempt in exact-head +GitHub Firmware run `30965070918`. + +Governed packaging for `sec-002-aa038361` again completed both equal-length firmware cycles, exact +comparison of all 15 artifacts, commit-bound production RVC materialization, and the previously +failing Character Lock red-team export. It then failed closed before manifest, ZIP, or independent +package verification when `export_voice_source_status.ps1` invoked the production RVC verifier +against its detached source root, where the files are intentionally 133-byte Git LFS pointers, +instead of the already verified package RVC root. The partial output under +`output/release/sec-002-aa038361` is not a candidate. No flash, OTA request, COM access, bridge +session, robot-port access, or actuator command occurred. + +The correction adds an explicit voice root to the exporter, binds the governed package invocation +to `media/voice/rvc` inside that package, and adds a negative contract proving that a pointer-only +override is rejected without producing ready status. A focused read-only probe exported both status +files from the exact materialized RVC directory in the retained partial package; ignored evidence is +under `output/private/manual-voice-source-export-probe-20260805`. The SEC-002 hold remains in force +until this correction passes the broad contracts, exact-head CI, and a fresh governed package plus +independent rebuild. A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 diff --git a/tools/export_voice_source_status.ps1 b/tools/export_voice_source_status.ps1 index 50935142..a79afbef 100644 --- a/tools/export_voice_source_status.ps1 +++ b/tools/export_voice_source_status.ps1 @@ -4,12 +4,16 @@ param( [string]$TemplatePath = "", [string]$TemplateDisplayPath = "", [string]$OutputDir = "", + [string]$VoiceRoot = "", [switch]$FailOnBlocked ) $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") if ([string]::IsNullOrWhiteSpace($OutputDir)) { $OutputDir = $repoRoot } +if ([string]::IsNullOrWhiteSpace($VoiceRoot)) { + $VoiceRoot = Join-Path $repoRoot "media/voice/rvc" +} if ([string]::IsNullOrWhiteSpace($VoiceSourceProvenanceDisplayPath)) { $VoiceSourceProvenanceDisplayPath = "data/voice_source_provenance.yaml" } @@ -17,7 +21,7 @@ if ([string]::IsNullOrWhiteSpace($TemplateDisplayPath)) { $TemplateDisplayPath = "docs/VOICE_SOURCE_PROVENANCE_TEMPLATE.md" } -& (Join-Path $PSScriptRoot "verify_tracked_rvc_assets.ps1") *> $null +& (Join-Path $PSScriptRoot "verify_tracked_rvc_assets.ps1") -VoiceRoot $VoiceRoot *> $null New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null $generatedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index bc5ca0cb..2082f39c 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -2166,7 +2166,8 @@ if ($LASTEXITCODE -ne 0) { -VoiceSourceProvenanceDisplayPath "data/voice_source_provenance.yaml" ` -TemplatePath (Join-Path $docsDir "VOICE_SOURCE_PROVENANCE_TEMPLATE.md") ` -TemplateDisplayPath "docs/VOICE_SOURCE_PROVENANCE_TEMPLATE.md" ` - -OutputDir $outDir + -OutputDir $outDir ` + -VoiceRoot (Join-Path $outDir "media/voice/rvc") if ($LASTEXITCODE -ne 0) { throw "Voice source status export failed." } diff --git a/tools/test_release_command_trust_contract.ps1 b/tools/test_release_command_trust_contract.ps1 index 1a7b5f25..56a49439 100644 --- a/tools/test_release_command_trust_contract.ps1 +++ b/tools/test_release_command_trust_contract.ps1 @@ -56,6 +56,9 @@ foreach ($required in @( throw "Release packaging is missing deterministic command/archive trust: $required" } } +if (-not $packageText.Contains('-VoiceRoot (Join-Path $outDir "media/voice/rvc")')) { + throw 'Release packaging does not bind voice-source status to the materialized package RVC root.' +} if (-not $verifyText.Contains('[Environment]::SystemDirectory') -or -not $verifyText.Contains('& $verifierPowerShellExecutable')) { throw 'Release verification does not pin Windows PowerShell to its validated system path.' diff --git a/tools/test_voice_source_readiness_contract.ps1 b/tools/test_voice_source_readiness_contract.ps1 index 2983ceb0..30c68391 100644 --- a/tools/test_voice_source_readiness_contract.ps1 +++ b/tools/test_voice_source_readiness_contract.ps1 @@ -165,6 +165,7 @@ Run ``tools/verify_tracked_rvc_assets.ps1`` before packaging. try { Set-Location $repoRoot $sourceCommit = "b" * 40 + $productionVoiceRoot = Join-Path $repoRoot "media/voice/rvc" $pendingRoot = New-TempEvidenceRoot Write-PendingVoiceSourceFiles -Root $pendingRoot @@ -186,7 +187,8 @@ try { -VoiceSourceProvenanceDisplayPath "data/voice_source_provenance.yaml" ` -TemplatePath (Join-Path $pendingRoot "VOICE_TEMPLATE.md") ` -TemplateDisplayPath "docs/VOICE_SOURCE_PROVENANCE_TEMPLATE.md" ` - -OutputDir $portableExportRoot *> $null + -OutputDir $portableExportRoot ` + -VoiceRoot $productionVoiceRoot *> $null if ($LASTEXITCODE -ne 0) { throw "Expected portable voice-source status export to succeed." } @@ -199,6 +201,47 @@ try { } Write-Host "[ok] packaged voice-source status paths remain portable" + $pointerVoiceRoot = New-TempEvidenceRoot + $pointerExportRoot = New-TempEvidenceRoot + @" +# Included Stackchan RVC Voice + +- model.pth +- model.index +- install_bundled_rvc_voice.ps1 +"@ | Set-Content -LiteralPath (Join-Path $pointerVoiceRoot "README.md") -Encoding UTF8 + $pointerText = @" +version https://git-lfs.github.com/spec/v1 +oid sha256:1a8addfd670cd811d1ad1eeb9e9b4ff72c5d795b1123a23e86a0c41c1dd9bf1a +size 57577722 +"@ + [System.IO.File]::WriteAllText( + (Join-Path $pointerVoiceRoot "model.pth"), $pointerText, + [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText( + (Join-Path $pointerVoiceRoot "model.index"), $pointerText, + [System.Text.UTF8Encoding]::new($false)) + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & $powerShellExe -NoProfile -ExecutionPolicy Bypass -File $exportScript ` + -VoiceSourceProvenancePath (Join-Path $pendingRoot "voice_source.yaml") ` + -VoiceSourceProvenanceDisplayPath "data/voice_source_provenance.yaml" ` + -TemplatePath (Join-Path $pendingRoot "VOICE_TEMPLATE.md") ` + -TemplateDisplayPath "docs/VOICE_SOURCE_PROVENANCE_TEMPLATE.md" ` + -OutputDir $pointerExportRoot ` + -VoiceRoot $pointerVoiceRoot *> $null + $pointerExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } + if ($pointerExitCode -eq 0 -or + (Test-Path -LiteralPath (Join-Path $pointerExportRoot "voice_source_status.json")) -or + (Test-Path -LiteralPath (Join-Path $pointerExportRoot "VOICE_SOURCE_STATUS.md"))) { + throw "Pointer-only voice root was not rejected before status export." + } + Write-Host "[ok] pointer-only voice root is rejected" + $pendingStrictResult = Invoke-VoiceSourceCheck -Root $repoRoot -VoiceSourceProvenancePath (Join-Path $pendingRoot "voice_source.yaml") -TemplatePath (Join-Path $pendingRoot "VOICE_TEMPLATE.md") -SourceCommit $sourceCommit -RequireProductionReady if ([int]$pendingStrictResult.exitCode -eq 0) { throw "Expected pending voice-source packet to fail with RequireProductionReady." diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 997c8223..c90997d3 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -3347,7 +3347,7 @@ foreach ($pattern in @("scoop/apps/mingw/current/bin", "scoop/apps/gcc/current/b } $voiceSourceStatusExporterText = Get-Content -LiteralPath (Join-PackagePath "tools/export_voice_source_status.ps1") -Raw -foreach ($pattern in @("stackchan.voice-source-status.v1", "production-source-ready", "production-model-hash", "production-index-hash", "VOICE_SOURCE_STATUS.md", "voice_source_status.json")) { +foreach ($pattern in @("stackchan.voice-source-status.v1", "production-source-ready", "production-model-hash", "production-index-hash", "VOICE_SOURCE_STATUS.md", "voice_source_status.json", "VoiceRoot", "verify_tracked_rvc_assets.ps1", "-VoiceRoot")) { if ($voiceSourceStatusExporterText -notmatch [regex]::Escape($pattern)) { throw "tools/export_voice_source_status.ps1 missing required voice-source status logic: $pattern" } @@ -3361,7 +3361,7 @@ foreach ($pattern in @("stackchan.voice-source-readiness.v1", "pending-productio } $voiceSourceReadinessContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_voice_source_readiness_contract.ps1") -Raw -foreach ($pattern in @("pending production voice source remains pending", "complete production voice source is accepted", "fixed voice-source commit remains valid across later package commits", "missing production voice-source provenance commit is rejected", "unresolved RVC rights review prevents production voice-source readiness", "Voice source readiness contract tests passed")) { +foreach ($pattern in @("pending production voice source remains pending", "packaged voice-source status paths remain portable", "pointer-only voice root is rejected", "complete production voice source is accepted", "fixed voice-source commit remains valid across later package commits", "missing production voice-source provenance commit is rejected", "unresolved RVC rights review prevents production voice-source readiness", "Voice source readiness contract tests passed")) { if ($voiceSourceReadinessContractText -notmatch [regex]::Escape($pattern)) { throw "tools/test_voice_source_readiness_contract.ps1 missing required voice-source readiness contract coverage: $pattern" } From 93bda8cbd59791cf950b554b0fcd46b7eb06b83b Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 4 Aug 2026 23:15:56 -0400 Subject: [PATCH 28/46] Bind RVC base status to packaged assets --- docs/FIRST_DEPLOY_STATUS.md | 18 +++++++++ tools/export_rvc_voice_base_status.ps1 | 12 ++++-- tools/package_release.ps1 | 3 +- tools/test_release_command_trust_contract.ps1 | 7 +++- .../test_voice_source_readiness_contract.ps1 | 40 +++++++++++++++++++ tools/verify_release_package.ps1 | 4 +- 6 files changed, 75 insertions(+), 9 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index c1e5be8e..0d727d34 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -262,6 +262,24 @@ under `output/private/manual-voice-source-export-probe-20260805`. The SEC-002 ho until this correction passes the broad contracts, exact-head CI, and a fresh governed package plus independent rebuild. +Qualification head `332708ef157529eb568f50d1deacb2a7b836d5ee` passed all 11 jobs on the first +attempt in exact-head GitHub Firmware run `30968255009`. Governed packaging for +`sec-002-332708ef` again completed both equal-length firmware cycles, exact comparison of all 15 +artifacts, commit-bound production RVC materialization, Character Lock red-team export, and the +corrected voice-source status export. It then failed closed before manifest, ZIP, or independent +package verification when the adjacent `export_rvc_voice_base_status.ps1` repeated the detached +source-root mistake and read the 133-byte Git LFS pointers instead of the verified package RVC +payloads. The partial output under `output/release/sec-002-332708ef` is not a candidate. No flash, +OTA request, COM access, bridge session, robot-port access, or actuator command occurred. + +The correction gives the RVC base-status exporter the same explicit voice-root boundary, binds its +governed package invocation to the materialized `media/voice/rvc` directory, and adds paired +positive and pointer-only negative contracts for both status exporters. The remaining direct +production RVC verifier invocations in packaging and independent verification were audited and +already target the materialized package directory. The SEC-002 hold remains in force until this +correction passes the broad contracts, exact-head CI, and a fresh governed package plus independent +verification. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/export_rvc_voice_base_status.ps1 b/tools/export_rvc_voice_base_status.ps1 index 8272eb6c..32b1e3f1 100644 --- a/tools/export_rvc_voice_base_status.ps1 +++ b/tools/export_rvc_voice_base_status.ps1 @@ -2,15 +2,19 @@ param( [string]$ManifestPath = "data/voice_rvc_base.yaml", [string]$MetadataPath = "data/voice_rvc_base_metadata.json", [string]$ZipPath = "", - [string]$OutputDir = "." + [string]$OutputDir = ".", + [string]$VoiceRoot = "" ) $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") -& (Join-Path $PSScriptRoot "verify_tracked_rvc_assets.ps1") *> $null +if ([string]::IsNullOrWhiteSpace($VoiceRoot)) { + $VoiceRoot = Join-Path $repoRoot "media/voice/rvc" +} +& (Join-Path $PSScriptRoot "verify_tracked_rvc_assets.ps1") -VoiceRoot $VoiceRoot *> $null -$modelPath = Join-Path $repoRoot "media/voice/rvc/model.pth" -$indexPath = Join-Path $repoRoot "media/voice/rvc/model.index" +$modelPath = Join-Path $VoiceRoot "model.pth" +$indexPath = Join-Path $VoiceRoot "model.index" $model = Get-Item -LiteralPath $modelPath $index = Get-Item -LiteralPath $indexPath $modelHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $modelPath).Hash.ToUpperInvariant() diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index 2082f39c..8199ef40 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -2175,7 +2175,8 @@ if ($LASTEXITCODE -ne 0) { & $windowsPowerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $releaseToolsRoot "export_rvc_voice_base_status.ps1") ` -ManifestPath (Join-Path $dataDir "voice_rvc_base.yaml") ` -MetadataPath (Join-Path $dataDir "voice_rvc_base_metadata.json") ` - -OutputDir $outDir + -OutputDir $outDir ` + -VoiceRoot (Join-Path $outDir "media/voice/rvc") if ($LASTEXITCODE -ne 0) { throw "RVC voice base status export failed." } diff --git a/tools/test_release_command_trust_contract.ps1 b/tools/test_release_command_trust_contract.ps1 index 56a49439..cd68fbb5 100644 --- a/tools/test_release_command_trust_contract.ps1 +++ b/tools/test_release_command_trust_contract.ps1 @@ -56,8 +56,11 @@ foreach ($required in @( throw "Release packaging is missing deterministic command/archive trust: $required" } } -if (-not $packageText.Contains('-VoiceRoot (Join-Path $outDir "media/voice/rvc")')) { - throw 'Release packaging does not bind voice-source status to the materialized package RVC root.' +$packageVoiceRootBindings = [regex]::Matches( + $packageText, + [regex]::Escape('-VoiceRoot (Join-Path $outDir "media/voice/rvc")')) +if ($packageVoiceRootBindings.Count -ne 2) { + throw 'Release packaging does not bind both voice status exporters to the materialized package RVC root.' } if (-not $verifyText.Contains('[Environment]::SystemDirectory') -or -not $verifyText.Contains('& $verifierPowerShellExecutable')) { diff --git a/tools/test_voice_source_readiness_contract.ps1 b/tools/test_voice_source_readiness_contract.ps1 index 30c68391..a38cddf9 100644 --- a/tools/test_voice_source_readiness_contract.ps1 +++ b/tools/test_voice_source_readiness_contract.ps1 @@ -5,6 +5,7 @@ $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $checkScript = Join-Path $PSScriptRoot "check_voice_source_readiness.ps1" $exportScript = Join-Path $PSScriptRoot "export_voice_source_status.ps1" +$rvcBaseExportScript = Join-Path $PSScriptRoot "export_rvc_voice_base_status.ps1" $createdRoots = New-Object System.Collections.Generic.List[string] function New-TempEvidenceRoot { @@ -201,6 +202,25 @@ try { } Write-Host "[ok] packaged voice-source status paths remain portable" + $portableRvcExportRoot = New-TempEvidenceRoot + & $powerShellExe -NoProfile -ExecutionPolicy Bypass -File $rvcBaseExportScript ` + -ManifestPath (Join-Path $repoRoot "data/voice_rvc_base.yaml") ` + -MetadataPath (Join-Path $repoRoot "data/voice_rvc_base_metadata.json") ` + -OutputDir $portableRvcExportRoot ` + -VoiceRoot $productionVoiceRoot *> $null + if ($LASTEXITCODE -ne 0) { + throw "Expected portable RVC base status export to succeed." + } + $portableRvcStatus = Get-Content -LiteralPath (Join-Path $portableRvcExportRoot "rvc_voice_base_status.json") -Raw | ConvertFrom-Json + if ($portableRvcStatus.schema -ne "stackchan.rvc-voice-base-status.v1" -or + $portableRvcStatus.status -ne "production-release-verified" -or + $portableRvcStatus.model.path -ne "media/voice/rvc/model.pth" -or + $portableRvcStatus.index.path -ne "media/voice/rvc/model.index" -or + -not (Test-Path -LiteralPath (Join-Path $portableRvcExportRoot "RVC_VOICE_BASE_STATUS.md"))) { + throw "RVC base status export did not produce the expected portable production report." + } + Write-Host "[ok] packaged RVC base status paths remain portable" + $pointerVoiceRoot = New-TempEvidenceRoot $pointerExportRoot = New-TempEvidenceRoot @" @@ -242,6 +262,26 @@ size 57577722 } Write-Host "[ok] pointer-only voice root is rejected" + $pointerRvcExportRoot = New-TempEvidenceRoot + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & $powerShellExe -NoProfile -ExecutionPolicy Bypass -File $rvcBaseExportScript ` + -ManifestPath (Join-Path $repoRoot "data/voice_rvc_base.yaml") ` + -MetadataPath (Join-Path $repoRoot "data/voice_rvc_base_metadata.json") ` + -OutputDir $pointerRvcExportRoot ` + -VoiceRoot $pointerVoiceRoot *> $null + $pointerRvcExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } + if ($pointerRvcExitCode -eq 0 -or + (Test-Path -LiteralPath (Join-Path $pointerRvcExportRoot "rvc_voice_base_status.json")) -or + (Test-Path -LiteralPath (Join-Path $pointerRvcExportRoot "RVC_VOICE_BASE_STATUS.md"))) { + throw "Pointer-only RVC base root was not rejected before status export." + } + Write-Host "[ok] pointer-only RVC base root is rejected" + $pendingStrictResult = Invoke-VoiceSourceCheck -Root $repoRoot -VoiceSourceProvenancePath (Join-Path $pendingRoot "voice_source.yaml") -TemplatePath (Join-Path $pendingRoot "VOICE_TEMPLATE.md") -SourceCommit $sourceCommit -RequireProductionReady if ([int]$pendingStrictResult.exitCode -eq 0) { throw "Expected pending voice-source packet to fail with RequireProductionReady." diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index c90997d3..3f3dea56 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -3361,14 +3361,14 @@ foreach ($pattern in @("stackchan.voice-source-readiness.v1", "pending-productio } $voiceSourceReadinessContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_voice_source_readiness_contract.ps1") -Raw -foreach ($pattern in @("pending production voice source remains pending", "packaged voice-source status paths remain portable", "pointer-only voice root is rejected", "complete production voice source is accepted", "fixed voice-source commit remains valid across later package commits", "missing production voice-source provenance commit is rejected", "unresolved RVC rights review prevents production voice-source readiness", "Voice source readiness contract tests passed")) { +foreach ($pattern in @("pending production voice source remains pending", "packaged voice-source status paths remain portable", "packaged RVC base status paths remain portable", "pointer-only voice root is rejected", "pointer-only RVC base root is rejected", "complete production voice source is accepted", "fixed voice-source commit remains valid across later package commits", "missing production voice-source provenance commit is rejected", "unresolved RVC rights review prevents production voice-source readiness", "Voice source readiness contract tests passed")) { if ($voiceSourceReadinessContractText -notmatch [regex]::Escape($pattern)) { throw "tools/test_voice_source_readiness_contract.ps1 missing required voice-source readiness contract coverage: $pattern" } } $rvcBaseStatusExporterText = Get-Content -LiteralPath (Join-PackagePath "tools/export_rvc_voice_base_status.ps1") -Raw -foreach ($pattern in @("stackchan.rvc-voice-base-status.v1", "production-release-verified", "distributionApproved", "consumerApproved", "model-hash", "index-hash", "rvc_voice_base_status.json", "RVC_VOICE_BASE_STATUS.md")) { +foreach ($pattern in @("stackchan.rvc-voice-base-status.v1", "production-release-verified", "distributionApproved", "consumerApproved", "model-hash", "index-hash", "rvc_voice_base_status.json", "RVC_VOICE_BASE_STATUS.md", "VoiceRoot", "verify_tracked_rvc_assets.ps1", "-VoiceRoot")) { if ($rvcBaseStatusExporterText -notmatch [regex]::Escape($pattern)) { throw "tools/export_rvc_voice_base_status.ps1 missing required RVC base status logic: $pattern" } From 403073c866b97c597ccffb415a7a89ab8027a75a Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 00:28:17 -0400 Subject: [PATCH 29/46] Align synthetic rollout verification --- docs/FIRST_DEPLOY_STATUS.md | 18 ++++++++ .../generate_synthetic_hardware_evidence.ps1 | 4 +- ...elease_package_verifier_trust_contract.ps1 | 43 +++++++++++++++++++ tools/verify_release_package.ps1 | 9 +++- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 0d727d34..15f30236 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -280,6 +280,24 @@ already target the materialized package directory. The SEC-002 hold remains in f correction passes the broad contracts, exact-head CI, and a fresh governed package plus independent verification. +Qualification head `93bda8cbd59791cf950b554b0fcd46b7eb06b83b` passed all 11 jobs on the first +attempt in exact-head GitHub Firmware run `30971770631`. Governed packaging for +`sec-002-93bda8cb` completed both equal-length firmware cycles, exact artifact comparison, +commit-bound production RVC materialization, both corrected voice status exports, manifest and +acceptance generation, and provisional ZIP creation. Independent ZIP verification then failed +closed because its static synthetic-evidence marker list still required +`export_rollout_status.ps1`. Commit `616424e4` had intentionally replaced that synthetic packet +command with a non-authorizing message requiring the exact trusted source checkout and six exact +host toolchain authorities, but had not updated the verifier marker. The provisional ZIP and +partial output under `output/release/sec-002-93bda8cb` are not candidates. No flash, OTA request, +COM access, bridge session, robot-port access, or actuator command occurred. + +The correction removes the stale mutable-exporter/output expectations, pins the stronger exact +checkout, exact-host toolchain, and archive-authority boundary in both generator and independent +verifier, and adds a regression that rejects reintroducing the exporter into synthetic diagnostic +packets. The SEC-002 hold remains in force until this correction passes the broad contracts, +exact-head CI, and a fresh governed package plus independent verification. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/generate_synthetic_hardware_evidence.ps1 b/tools/generate_synthetic_hardware_evidence.ps1 index 403646c0..8c3cab6f 100644 --- a/tools/generate_synthetic_hardware_evidence.ps1 +++ b/tools/generate_synthetic_hardware_evidence.ps1 @@ -434,7 +434,7 @@ Copy-Item -LiteralPath "docs/PRODUCTION_READINESS.md" -Destination (Join-Path $o "9. Run ``RUN_PLAY_LEAD_VOICE.cmd`` as the playback reference, record the target speaker path, then add the recording with ``RUN_ADD_MEDIA.cmd -Type Audio C:\path\stackchan-speaker.wav``.", "10. Complete ``AUDIO_REVIEW.md`` with real-device speaker results. Generated source WAVs alone do not count.", "11. Run ``RUN_PROGRESS_CHECK.cmd`` to refresh ``BENCH_STATUS.md/json`` and fix every missing field, marker, media file, and unchecked checklist item it reports.", - "12. Run ``RUN_ROLLOUT_STATUS.cmd`` to write ``ROLLOUT_STATUS.md`` and ``ROLLOUT_STATUS.json`` for handoff review.", + "12. ``RUN_ROLLOUT_STATUS.cmd`` is intentionally non-authorizing in a synthetic packet. Generate rollout status only from the exact trusted source checkout with all six exact-host toolchain authorities.", "13. Run ``RUN_EVIDENCE_VERIFY.cmd`` for the strict hardware evidence gate.", "14. Run ``RUN_CONSUMER_PROMOTION_CHECK.cmd`` only after strict evidence verification passes.", "", @@ -443,7 +443,7 @@ Copy-Item -LiteralPath "docs/PRODUCTION_READINESS.md" -Destination (Join-Path $o "- Hardware validation remains pending until this packet has real display, servo, soak, calibration, photo/video, and speaker evidence.", "- Production voice-source provenance remains pending until the owned or licensed source record is completed.", "- RVC voice-base evidence remains review-only until consumer and distribution approvals are explicitly recorded.", - "- GitHub Actions may still be externally blocked; use ``RUN_ROLLOUT_STATUS.cmd`` for the current CI/account state.", + "- The current CI/account state is unavailable from this synthetic packet. Generate it only from the exact trusted source checkout with all six exact-host toolchain authorities.", "- Hosted media or synthetic diagnostic packets are review aids only. They do not replace real-device evidence.", "", "## Hard Stops", diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 index 078c01e2..d505b68e 100644 --- a/tools/test_release_package_verifier_trust_contract.ps1 +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -8,6 +8,49 @@ $packagePath = Join-Path $PSScriptRoot "package_release.ps1" $verifyPath = Join-Path $PSScriptRoot "verify_release_package.ps1" $packageText = Get-Content -LiteralPath $packagePath -Raw $verifyText = Get-Content -LiteralPath $verifyPath -Raw +$syntheticEvidencePath = Join-Path $PSScriptRoot 'generate_synthetic_hardware_evidence.ps1' +$syntheticEvidenceText = Get-Content -LiteralPath $syntheticEvidencePath -Raw +$forbiddenSyntheticRolloutArtifacts = @( + 'export_rollout_status.ps1', 'ROLLOUT_STATUS.md', 'ROLLOUT_STATUS.json' +) +foreach ($forbidden in $forbiddenSyntheticRolloutArtifacts) { + if ($syntheticEvidenceText.Contains($forbidden)) { + throw "Synthetic diagnostic evidence must not claim or invoke operational rollout output: $forbidden" + } +} +foreach ($marker in @( + 'rollout-status generation requires the exact trusted source checkout', + 'six exact-host toolchain authorities', + 'archive does not confer release authority', + 'current CI/account state is unavailable from this synthetic packet' +)) { + if (-not $syntheticEvidenceText.Contains($marker) -or + -not $verifyText.Contains('"' + $marker + '"')) { + throw "Synthetic rollout authority boundary is not pinned by generator and verifier: $marker" + } +} +$syntheticVerifierStart = $verifyText.IndexOf( + '$syntheticEvidenceGeneratorText = Get-Content', [StringComparison]::Ordinal) +if ($syntheticVerifierStart -lt 0) { + throw 'Package verifier synthetic contract boundary is missing.' +} +$syntheticMarkerListStart = $verifyText.IndexOf( + 'foreach ($pattern in @(', $syntheticVerifierStart, [StringComparison]::Ordinal) +if ($syntheticMarkerListStart -lt 0) { + throw 'Package verifier synthetic marker list is missing.' +} +$syntheticMarkerListEnd = $verifyText.IndexOf( + ')) {', $syntheticMarkerListStart, [StringComparison]::Ordinal) +if ($syntheticMarkerListEnd -le $syntheticMarkerListStart) { + throw 'Package verifier synthetic marker-list boundary is ambiguous.' +} +$syntheticMarkerListText = $verifyText.Substring( + $syntheticMarkerListStart, $syntheticMarkerListEnd - $syntheticMarkerListStart) +foreach ($forbidden in $forbiddenSyntheticRolloutArtifacts) { + if ($syntheticMarkerListText.Contains('"' + $forbidden + '"')) { + throw "Package verifier retains a stale synthetic rollout marker: $forbidden" + } +} if ($verifyText.Contains('Release package verified:') -or -not $verifyText.Contains( 'Package integrity verified in non-authorizing mode; release eligibility not established:')) { diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 3f3dea56..9865fc1c 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -2519,11 +2519,18 @@ foreach ($pattern in @("stackchan.hardware-media-manifest.v1", "Test-PhotoEviden } $syntheticEvidenceGeneratorText = Get-Content -LiteralPath (Join-PackagePath "tools/generate_synthetic_hardware_evidence.ps1") -Raw -foreach ($pattern in @("diagnosticOnly", "syntheticEvidence", "AllowSyntheticEvidence", "Synthetic hardware evidence packet", "BENCH_STATUS.md", "BENCH_STATUS.json", "stackchan.bench-status.v1", "benchStatus", "progress_check.log", "NEXT_STEPS.md", "Stackchan Evidence Next Steps", "Copy-VoiceLeadArtifactsFromZip", "Copy-VoiceGateStatusFromZip", "VOICE_SOURCE_STATUS.md", "voice_source_status.json", "RVC_VOICE_BASE_STATUS.md", "rvc_voice_base_status.json", "voiceGateStatus", "export_rollout_status.ps1", "RUN_ROLLOUT_STATUS.cmd", "ROLLOUT_STATUS.md", "RVC_LEAD_AUDITION.md", "RUN_PLAY_LEAD_VOICE.cmd", "media/voice/stackchan_spark_audition_bright_robot_greeting.wav", "stackchan.voice-playback-reference.v1", "playback-aid-only", "RUN_SPEAK_ALL_INTENTS.cmd", "AUDIO_REVIEW.md", "synthetic_speaker_fixture.wav", "must not be used as rollout evidence", "-AllowExternalAccountCiBlock", "completed only in a real evidence packet", "Get-CompactEvidenceTag", "fps_window=30.0", "frame_budget_us=33333", "slow_frames=0", "blink_count=3", "saccade_count=4", "speech_env=0.00", "speech_mouth_demo_serial.log", "Speech mouth demo complete", "speak_all_intents_serial.log", "Speak-all-intents demo complete", "command=speak_intent", "[audio_out]", "command=speech_env", "[control] command=", "button_a_listen", "reduced_motion_on", "safe_stop", "[face] reduced_motion=1", "[speech] seq=", "earcon_delay_ms", "heap_free=243000", "stack_face_hwm=2800")) { +foreach ($pattern in @("diagnosticOnly", "syntheticEvidence", "AllowSyntheticEvidence", "Synthetic hardware evidence packet", "BENCH_STATUS.md", "BENCH_STATUS.json", "stackchan.bench-status.v1", "benchStatus", "progress_check.log", "NEXT_STEPS.md", "Stackchan Evidence Next Steps", "Copy-VoiceLeadArtifactsFromZip", "Copy-VoiceGateStatusFromZip", "VOICE_SOURCE_STATUS.md", "voice_source_status.json", "RVC_VOICE_BASE_STATUS.md", "rvc_voice_base_status.json", "voiceGateStatus", "RUN_ROLLOUT_STATUS.cmd", "rollout-status generation requires the exact trusted source checkout", "six exact-host toolchain authorities", "archive does not confer release authority", "current CI/account state is unavailable from this synthetic packet", "RVC_LEAD_AUDITION.md", "RUN_PLAY_LEAD_VOICE.cmd", "media/voice/stackchan_spark_audition_bright_robot_greeting.wav", "stackchan.voice-playback-reference.v1", "playback-aid-only", "RUN_SPEAK_ALL_INTENTS.cmd", "AUDIO_REVIEW.md", "synthetic_speaker_fixture.wav", "must not be used as rollout evidence", "-AllowExternalAccountCiBlock", "completed only in a real evidence packet", "Get-CompactEvidenceTag", "fps_window=30.0", "frame_budget_us=33333", "slow_frames=0", "blink_count=3", "saccade_count=4", "speech_env=0.00", "speech_mouth_demo_serial.log", "Speech mouth demo complete", "speak_all_intents_serial.log", "Speak-all-intents demo complete", "command=speak_intent", "[audio_out]", "command=speech_env", "[control] command=", "button_a_listen", "reduced_motion_on", "safe_stop", "[face] reduced_motion=1", "[speech] seq=", "earcon_delay_ms", "heap_free=243000", "stack_face_hwm=2800")) { if ($syntheticEvidenceGeneratorText -notmatch [regex]::Escape($pattern)) { throw "tools/generate_synthetic_hardware_evidence.ps1 missing synthetic evidence safety logic: $pattern" } } +foreach ($forbiddenSyntheticRolloutArtifact in @( + "export_rollout_status.ps1", "ROLLOUT_STATUS.md", "ROLLOUT_STATUS.json" +)) { + if ($syntheticEvidenceGeneratorText.Contains($forbiddenSyntheticRolloutArtifact)) { + throw "Synthetic diagnostic evidence must not claim or execute operational rollout output without trusted checkout and toolchain authority: $forbiddenSyntheticRolloutArtifact" + } +} $hardwareProgressText = Get-Content -LiteralPath (Join-PackagePath "tools/check_hardware_evidence_progress.ps1") -Raw foreach ($pattern in @("NEXT_STEPS.md", "Generated source WAVs alone do not count", "OBSERVATIONS.md has blank field", "AUDIO_REVIEW.md has blank field", "No real-device speaker recording found under audio/", "CHECKLIST.md still has unchecked gates", "No photo or video evidence found", "display-only boot marker", "logs/display_only_serial\.log.*display frame-budget telemetry", "display face animator telemetry", "display bench control telemetry", "display speech cue telemetry", "display runtime health telemetry", "speech mouth demo envelope commands", "speech mouth demo clear command", "speech mouth demo completion", "speechMouthFinding", "speakAllFinding", "RUN_SPEECH_MOUTH_DEMO.cmd", "RUN_SPEAK_ALL_INTENTS.cmd", "speak_all_intents_serial.log", "speak-all packaged prompt audio-output handoff", "soak display frame-budget telemetry", "soak face animator telemetry", "soak runtime health telemetry", "reduced_motion_on|reduced_motion_off|safe_stop", "Voice playback reference hash matches metadata", "metadata.json has no shareVerification reference", "Hosted media share verification report matches metadata", "VERIFIED_URL.txt", "metadata.json missing voiceGateStatus reference", "Voice source status report matches metadata", "RVC voice base status report matches metadata", "Test-OptionalAndroidProbeReport", "apkSha256", "valid apkSha256", "sourceCommit", "full sourceCommit SHA", "versionName/versionCode", "-SourceCommit ", "Test-AndroidDashboardManifestEvidence", "androidDashboardFinding", "Import the Android connected-dashboard screenshot", "Android APK install evidence", "Android companion bridge probe", "Android screen-off soak", "Android UDP beacon probe", "stackchan.android-apk-install.v1", "stackchan.android-companion-probe.v1", "stackchan.android-companion-soak.v1", "stackchan.android-udp-beacon-probe.v1", "optional unless Android is the companion bridge host", "media_manifest.json needs a photo/video entry", "Android dashboard connected state; robot identity; firmware/version signal; last bridge frame; active brain owner; foreground service state", "BENCH_STATUS.md", "BENCH_STATUS.json", "stackchan.bench-status.v1", "Get-BenchNextAction", "Write-BenchStatusReport", "nextAction", "nextCommand", "ready-for-strict-evidence-verify", "RUN_PLAY_LEAD_VOICE.cmd", "RUN_EVIDENCE_VERIFY.cmd")) { From 760d94465c2a973b37636d44a9b5546619298c77 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 01:35:24 -0400 Subject: [PATCH 30/46] Initialize verifier commit map cache --- docs/FIRST_DEPLOY_STATUS.md | 16 ++++++++++++++++ ...elease_package_verifier_trust_contract.ps1 | 19 +++++++++++++++++++ tools/verify_release_package.ps1 | 1 + 3 files changed, 36 insertions(+) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 15f30236..fd440f4b 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -298,6 +298,22 @@ verifier, and adds a regression that rejects reintroducing the exporter into syn packets. The SEC-002 hold remains in force until this correction passes the broad contracts, exact-head CI, and a fresh governed package plus independent verification. +Qualification head `403073c866b97c597ccffb415a7a89ab8027a75a` passed all 11 jobs on the first +attempt in exact-head GitHub Firmware run `30975232501`. Governed packaging for +`sec-002-403073c8` completed both equal-length firmware cycles, exact artifact comparison, +package assembly, voice/RVC status export, provisional ZIP creation, and the independent verifier's +exact-commit firmware rebuild. Verification then failed closed before release eligibility when +`Get-OperationalTrustedCommitMaps` read its script-scope cache under strict mode before that cache +had been initialized. The provisional ZIP and partial output under +`output/release/sec-002-403073c8` are not candidates. No flash, OTA request, COM access, bridge +session, robot-port access, or actuator command occurred. + +The correction initializes the operational trusted-commit map cache explicitly before any +strict-mode access and adds a contract requiring that initialization to precede the cache function. +An audit of verifier script-scope cache reads found no second uninitialized cache. The SEC-002 hold +remains in force until this correction passes the broad contracts, exact-head CI, and a fresh +governed package plus independent verification. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 index d505b68e..9826fd72 100644 --- a/tools/test_release_package_verifier_trust_contract.ps1 +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -364,6 +364,25 @@ $packageAst = [System.Management.Automation.Language.Parser]::ParseFile( if ($verifyParseErrors.Count -ne 0 -or $packageParseErrors.Count -ne 0) { throw 'Verifier trust contract could not parse the package/verifier scripts' } +$operationalCommitMapInitializations = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.Left.Extent.Text -ceq '$script:operationalTrustedCommitMaps' -and + $node.Operator -eq [System.Management.Automation.Language.TokenKind]::Equals -and + $node.Right.Extent.Text -ceq '$null' +}, $true)) +$operationalCommitMapFunctions = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Get-OperationalTrustedCommitMaps' +}, $true)) +if ($operationalCommitMapInitializations.Count -ne 1 -or + $operationalCommitMapFunctions.Count -ne 1 -or + $operationalCommitMapInitializations[0].Extent.EndOffset -ge + $operationalCommitMapFunctions[0].Extent.StartOffset) { + throw 'Operational trusted-commit map cache must have one top-level null initialization before strict-mode access.' +} $sidecarCalls = @($verifyAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] -and diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 9865fc1c..ec4ff0f1 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -24,6 +24,7 @@ $verifierToolchainPreBuild = $null $script:verifierToolchainIdentityRecords = [System.Collections.Generic.List[object]]::new() $script:verifierToolchainReadLeases = [System.Collections.Generic.List[IO.FileStream]]::new() $script:verifierToolchainLeaseState = $null +$script:operationalTrustedCommitMaps = $null function Add-VerifierToolchainReadLease { param([Parameter(Mandatory = $true)][string]$LiteralPath) From 72ce564aa8ccb28216a6ae70f52ad186fd04cf33 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 02:47:26 -0400 Subject: [PATCH 31/46] Preserve verifier extended package paths --- docs/FIRST_DEPLOY_STATUS.md | 18 ++++++ ...elease_package_verifier_trust_contract.ps1 | 60 +++++++++++++++++++ tools/verify_release_package.ps1 | 9 +-- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index fd440f4b..dd51a28d 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -314,6 +314,24 @@ An audit of verifier script-scope cache reads found no second uninitialized cach remains in force until this correction passes the broad contracts, exact-head CI, and a fresh governed package plus independent verification. +Qualification head `760d94465c2a973b37636d44a9b5546619298c77` passed all 11 jobs on the first +attempt in exact-head GitHub Firmware run `30978581265`. Governed packaging for +`sec-002-760d9446` completed both equal-length firmware cycles, exact size/SHA-256 comparison of +all 15 artifact pairs, package assembly, voice/RVC status export, provisional ZIP creation, and +launch of the independent release-eligible verifier. Verification then failed closed before +release eligibility at `verify_release_package.ps1:663`: Windows PowerShell 5.1 rejected +`Join-Path` when its valid package enumeration root used the `\\?\` extended-length prefix and +reported the misleading internal error that argument `drive` was null. The provisional ZIP and +partial output under `output/release/sec-002-760d9446` are not candidates. No flash, OTA request, +COM access, bridge session, robot-port access, or actuator command occurred. + +The correction preserves the extended-length enumeration root and replaces all four provider-backed +joins against it with `System.IO.Path.Combine`. The verifier trust contract now rejects any +`Join-Path` use against that root, pins the four governed child joins, and exercises traversal of a +combined Windows extended-length child. The SEC-002 hold remains in force until this correction +passes the broad contracts, exact-head CI, and a fresh governed package plus independent +verification. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 index 9826fd72..4fb3f509 100644 --- a/tools/test_release_package_verifier_trust_contract.ps1 +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -383,6 +383,66 @@ if ($operationalCommitMapInitializations.Count -ne 1 -or $operationalCommitMapFunctions[0].Extent.StartOffset) { throw 'Operational trusted-commit map cache must have one top-level null initialization before strict-mode access.' } +function Test-PackageEnumerationJoinPathCommand { + param([Parameter(Mandatory = $true)][System.Management.Automation.Language.Ast]$Node) + if ($Node -isnot [System.Management.Automation.Language.CommandAst] -or + $Node.GetCommandName() -ine 'Join-Path') { + return $false + } + return @($Node.FindAll({ + param($descendant) + $descendant -is [System.Management.Automation.Language.VariableExpressionAst] -and + $descendant.VariablePath.UserPath -ieq 'packageEnumerationRoot' + }, $true)).Count -gt 0 +} +$packageEnumerationJoinPathCalls = @($verifyAst.FindAll({ + param($node) + Test-PackageEnumerationJoinPathCommand -Node $node +}, $true)) +if ($packageEnumerationJoinPathCalls.Count -ne 0) { + throw 'Operational package enumeration must not pass an extended-length root to provider-backed Join-Path.' +} +$joinPathCanaryTokens = $null +$joinPathCanaryErrors = $null +$joinPathCanaryAst = [System.Management.Automation.Language.Parser]::ParseInput( + 'join-path ([string]$PackageEnumerationRoot) ''tools''', + [ref]$joinPathCanaryTokens, [ref]$joinPathCanaryErrors) +$joinPathCanaryMatches = @($joinPathCanaryAst.FindAll({ + param($node) + Test-PackageEnumerationJoinPathCommand -Node $node +}, $true)) +if ($joinPathCanaryErrors.Count -ne 0 -or $joinPathCanaryMatches.Count -ne 1) { + throw 'Operational package enumeration Join-Path guard missed a mixed-case wrapped-variable mutation canary.' +} +$packageEnumerationCombines = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and + $node.Static -and + $node.Expression -is [System.Management.Automation.Language.TypeExpressionAst] -and + $node.Expression.TypeName.FullName -ceq 'System.IO.Path' -and + $node.Member.Value -ceq 'Combine' -and + $node.Arguments.Count -eq 2 -and + $node.Arguments[0] -is [System.Management.Automation.Language.VariableExpressionAst] -and + $node.Arguments[0].VariablePath.UserPath -ceq 'packageEnumerationRoot' +}, $true)) +if ($packageEnumerationCombines.Count -ne 4) { + throw "Operational package enumeration must contain four extended-root Path.Combine joins; got $($packageEnumerationCombines.Count)." +} +$packageEnumerationCombineChildren = @( + $packageEnumerationCombines | ForEach-Object { $_.Arguments[1].Extent.Text }) +foreach ($expectedChild in @('$PackagePrefix', '$prefix', "'media'", "'third_party_licenses'")) { + if (@($packageEnumerationCombineChildren | Where-Object { $_ -ceq $expectedChild }).Count -ne 1) { + throw "Operational package enumeration is missing its exact extended-root child join: $expectedChild" + } +} +if ($env:OS -eq 'Windows_NT') { + $extendedContractRoot = '\\?\' + $repoRoot + $extendedContractTools = [System.IO.Path]::Combine($extendedContractRoot, 'tools') + if (-not (Test-Path -LiteralPath $extendedContractTools -PathType Container) -or + @(Get-ChildItem -LiteralPath $extendedContractTools -File -Recurse -Force).Count -eq 0) { + throw 'Operational package enumeration cannot traverse a combined Windows extended-length child path.' + } +} $sidecarCalls = @($verifyAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] -and diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index ec4ff0f1..2e0d8b5a 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -660,7 +660,7 @@ function Get-PackagedFileInventory { if ($PackagePrefix -notin @('tools', 'provenance')) { throw "Operational package inventory refuses unsupported prefix: $PackagePrefix" } - $inventoryRoot = Join-Path $packageEnumerationRoot $PackagePrefix + $inventoryRoot = [System.IO.Path]::Combine($packageEnumerationRoot, $PackagePrefix) if (-not (Test-Path -LiteralPath $inventoryRoot -PathType Container)) { throw "Operational package inventory is missing directory: $PackagePrefix" } @@ -932,7 +932,7 @@ function Assert-OperationalPackageGitBindings { $outerCopyMappings = [System.Collections.Generic.Dictionary[string,string]]::new( [System.StringComparer]::Ordinal) foreach ($prefix in @('bridge', 'docs', 'data', 'personas', 'site')) { - $treeRoot = Join-Path $packageEnumerationRoot $prefix + $treeRoot = [System.IO.Path]::Combine($packageEnumerationRoot, $prefix) if (-not (Test-Path -LiteralPath $treeRoot -PathType Container)) { throw "Operational package is missing trusted copy tree: $prefix" } @@ -978,7 +978,7 @@ function Assert-OperationalPackageGitBindings { 'media/voice/rvc/model.pth', 'media/voice/rvc/model.index' ) - $mediaTreeRoot = Join-Path $packageEnumerationRoot 'media' + $mediaTreeRoot = [System.IO.Path]::Combine($packageEnumerationRoot, 'media') foreach ($item in Get-ChildItem -LiteralPath $mediaTreeRoot -Recurse -File -Force) { $packageRelative = (Get-PackageItemFullName $item).Substring( $packageRootPrefix.Length).Replace('\', '/') @@ -6070,7 +6070,8 @@ function Assert-OperationalWholePackageInventory { [void]$allowedThirdParty.Add('third_party_licenses/' + [string]$relative) } $actualThirdParty = @( - Get-ChildItem -LiteralPath (Join-Path $packageEnumerationRoot 'third_party_licenses') ` + Get-ChildItem -LiteralPath ([System.IO.Path]::Combine( + $packageEnumerationRoot, 'third_party_licenses')) ` -File -Recurse -Force | ForEach-Object { (Get-PackageItemFullName $_).Substring($packageRootPrefix.Length).Replace('\', '/') }) From ea392b7020f7b52dd0f09a27890a2d81568b9491 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 04:02:35 -0400 Subject: [PATCH 32/46] Make packaged README bytes deterministic --- docs/FIRST_DEPLOY_STATUS.md | 20 +++++++ tools/package_release.ps1 | 2 +- tools/release_source_binding.ps1 | 8 +++ .../test_release_source_binding_contract.ps1 | 53 +++++++++++++++++++ tools/verify_release_package.ps1 | 9 ++-- 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index dd51a28d..b3068f3d 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -332,6 +332,26 @@ combined Windows extended-length child. The SEC-002 hold remains in force until passes the broad contracts, exact-head CI, and a fresh governed package plus independent verification. +Qualification head `72ce564aa8ccb28216a6ae70f52ad186fd04cf33` passed all 11 jobs on the first +attempt in exact-head GitHub Firmware run `30982583318`. Governed packaging for +`sec-002-72ce564a` completed both equal-length firmware cycles, exact size/SHA-256 comparison of +all 15 artifact pairs, package assembly, provisional ZIP creation, and launch of the independent +release-eligible verifier. The verifier passed the corrected extended-length package inventory and +then failed closed on the deterministic package README check. The trusted qualification worktree +README had 386 CRLF plus 30 LF endings, while the fresh commit-bound producer worktree materialized +all 416 endings as CRLF under the same canonical Git blob. After line-ending normalization, all +text and all nine link rewrites matched exactly. The provisional ZIP and partial output under +`output/release/sec-002-72ce564a` are not candidates. No flash, OTA request, COM access, bridge +session, robot-port access, or actuator command occurred. + +The correction makes the already-trusted source-binding helper produce one deterministic all-CRLF +package README from LF, CRLF, mixed, or bare-CR source materialization before applying the exact +`docs/media` link rewrite. Both producer and verifier use that helper, while the verifier compares +the expected UTF-8/no-BOM bytes to the packaged bytes without normalizing the package. Contract +fixtures reject alternate EOLs, BOM, UTF-16, and semantic mutation. The SEC-002 hold remains in +force until this correction passes the broad contracts, exact-head CI, and a fresh governed package +plus independent verification. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index 8199ef40..0f0d54e7 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -1928,7 +1928,7 @@ Copy-Item -LiteralPath "LICENSE" -Destination $outDir Copy-Item -LiteralPath "docs/README.md" -Destination $docsDir $packageReadmePath = Join-Path $outDir "README.md" $packageReadmeText = [System.IO.File]::ReadAllText($packageReadmePath) -$packageReadmeText = $packageReadmeText.Replace("](docs/media/", "](media/") +$packageReadmeText = ConvertTo-StackchanPackageReadmeText -Text $packageReadmeText [System.IO.File]::WriteAllText( $packageReadmePath, $packageReadmeText, diff --git a/tools/release_source_binding.ps1 b/tools/release_source_binding.ps1 index 38b46ea5..55920d9b 100644 --- a/tools/release_source_binding.ps1 +++ b/tools/release_source_binding.ps1 @@ -1,3 +1,11 @@ +function ConvertTo-StackchanPackageReadmeText { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text) + + $canonicalLf = $Text.Replace("`r`n", "`n").Replace("`r", "`n") + $rewritten = $canonicalLf.Replace('](docs/media/', '](media/') + return $rewritten.Replace("`n", "`r`n") +} + function Copy-StackchanCommitBoundPackageFile { param( [Parameter(Mandatory = $true)][string]$PackageSourceRoot, diff --git a/tools/test_release_source_binding_contract.ps1 b/tools/test_release_source_binding_contract.ps1 index 3ed8a6b7..0dad88b6 100644 --- a/tools/test_release_source_binding_contract.ps1 +++ b/tools/test_release_source_binding_contract.ps1 @@ -6,6 +6,59 @@ $verifyPath = Join-Path $PSScriptRoot "verify_release_package.ps1" . (Join-Path $PSScriptRoot "release_source_binding.ps1") $packageText = Get-Content -LiteralPath $packagePath -Raw $verifyText = Get-Content -LiteralPath $verifyPath -Raw +$readmeLfFixture = "# Stackchan café`n`n![Preview](docs/media/preview.png)`n" +$readmeExpectedFixture = "# Stackchan café`r`n`r`n![Preview](media/preview.png)`r`n" +$readmeSourceFixtures = @( + $readmeLfFixture, + $readmeLfFixture.Replace("`n", "`r`n"), + "# Stackchan café`r`n`n![Preview](docs/media/preview.png)`n", + "# Stackchan café`r`r![Preview](docs/media/preview.png)`r" +) +foreach ($readmeSourceFixture in $readmeSourceFixtures) { + $readmeResult = ConvertTo-StackchanPackageReadmeText -Text $readmeSourceFixture + if ($readmeResult -cne $readmeExpectedFixture) { + throw 'Package README transform does not canonicalize equivalent source EOL materializations.' + } + if ([regex]::IsMatch($readmeResult, '(? Date: Wed, 5 Aug 2026 05:27:04 -0400 Subject: [PATCH 33/46] Preserve verifier dependency collection shape --- docs/FIRST_DEPLOY_STATUS.md | 20 ++++ ...elease_package_verifier_trust_contract.ps1 | 101 ++++++++++++++++++ tools/verify_release_package.ps1 | 22 +++- 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index b3068f3d..6e70b2a7 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -352,6 +352,26 @@ fixtures reject alternate EOLs, BOM, UTF-16, and semantic mutation. The SEC-002 force until this correction passes the broad contracts, exact-head CI, and a fresh governed package plus independent verification. +Qualification head `ea392b7020f7b52dd0f09a27890a2d81568b9491` passed all 11 jobs on the first +attempt in exact-head GitHub Firmware run `30987403879`. Governed packaging for +`sec-002-ea392b70` completed both equal-length firmware cycles and exact size/SHA-256 comparison of +all 15 artifact pairs, package assembly, provisional ZIP creation, and launch of the independent +release-eligible verifier. The verifier passed the trusted package inventory, deterministic README, +voice/RVC material, preview media, and phase A-E face assets. It then failed closed at +`verify_release_package.ps1:5365` because the empty `directGitDepsMissingRef` JSON array was +enumerated away by the `ConvertTo-Array` function boundary and strict mode rejected `.Count` on the +result. The dependency-lock evidence itself contains the required empty collection. The provisional +ZIP and partial output under `output/release/sec-002-ea392b70` are not candidates. No flash, OTA +request, COM access, bridge session, robot-port access, or actuator command occurred. + +The correction returns array objects non-enumerated across the PowerShell function boundary and +requires all four dependency-audit collection fields to be present and non-null before conversion. +The verifier trust contract exercises null, empty, singleton, and multiple-value shapes, rejects +missing or null audit fields, pins all five array consumers, and proves a singleton license index +reaches the intended small-index rejection. The SEC-002 hold remains in force until this correction +passes the broad contracts, exact-head CI, and a fresh governed package plus independent +verification. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 index 4fb3f509..fd348fbe 100644 --- a/tools/test_release_package_verifier_trust_contract.ps1 +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -364,6 +364,107 @@ $packageAst = [System.Management.Automation.Language.Parser]::ParseFile( if ($verifyParseErrors.Count -ne 0 -or $packageParseErrors.Count -ne 0) { throw 'Verifier trust contract could not parse the package/verifier scripts' } +$dependencyArrayFunctionNames = @( + 'ConvertTo-Array', + 'Assert-DependencyAuditCollectionFields' +) +$dependencyArrayFunctions = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -cin $dependencyArrayFunctionNames +}, $true)) +foreach ($functionName in $dependencyArrayFunctionNames) { + $functionMatches = @($dependencyArrayFunctions | Where-Object { $_.Name -ceq $functionName }) + if ($functionMatches.Count -ne 1) { + throw "Verifier trust contract requires one exact $functionName definition." + } + Invoke-Expression $functionMatches[0].Extent.Text +} + +$dependencyArrayCases = @( + [pscustomobject]@{ Name = 'null'; Value = $null; ExpectedCount = 0 }, + [pscustomobject]@{ Name = 'empty'; Value = [object[]]@(); ExpectedCount = 0 }, + [pscustomobject]@{ Name = 'singleton'; Value = [pscustomobject]@{ Id = 1 }; ExpectedCount = 1 }, + [pscustomobject]@{ + Name = 'multiple' + Value = [object[]]@([pscustomobject]@{ Id = 1 }, [pscustomobject]@{ Id = 2 }) + ExpectedCount = 2 + } +) +foreach ($arrayCase in $dependencyArrayCases) { + $actual = ConvertTo-Array -Value $arrayCase.Value + if ($actual.GetType() -ne [object[]] -or $actual.Count -ne $arrayCase.ExpectedCount) { + throw "ConvertTo-Array did not preserve Object[] shape for $($arrayCase.Name)." + } + if ($arrayCase.ExpectedCount -gt 0 -and $actual[0].Id -ne 1) { + throw "ConvertTo-Array changed the first value for $($arrayCase.Name)." + } + if ($arrayCase.ExpectedCount -gt 1 -and $actual[1].Id -ne 2) { + throw "ConvertTo-Array changed value order for $($arrayCase.Name)." + } +} + +$dependencyAuditCollectionFields = @( + 'directGitDepsMissingRef', + 'gitResolvedWithoutSha', + 'duplicateResolvedPackages', + 'unpinnedGitRequirements' +) +$validDependencyAuditValues = [ordered]@{ policy = 'contract-fixture' } +foreach ($field in $dependencyAuditCollectionFields) { + $validDependencyAuditValues[$field] = [object[]]@() +} +$validDependencyAudit = [pscustomobject]$validDependencyAuditValues +Assert-DependencyAuditCollectionFields -DependencyAudit $validDependencyAudit +foreach ($field in $dependencyAuditCollectionFields) { + $requiredFieldError = "dependency_lock.json dependencyAudit requires a non-null collection field: $field" + $missingDependencyAudit = $validDependencyAudit.PSObject.Copy() + $missingDependencyAudit.PSObject.Properties.Remove($field) + $missingError = $null + try { + Assert-DependencyAuditCollectionFields -DependencyAudit $missingDependencyAudit + } catch { + $missingError = $_.Exception.Message + } + if ($missingError -cne $requiredFieldError) { + throw "Dependency-audit collection guard accepted or misclassified a missing field: $field" + } + + $nullDependencyAudit = $validDependencyAudit.PSObject.Copy() + $nullDependencyAudit.$field = $null + $nullError = $null + try { + Assert-DependencyAuditCollectionFields -DependencyAudit $nullDependencyAudit + } catch { + $nullError = $_.Exception.Message + } + if ($nullError -cne $requiredFieldError) { + throw "Dependency-audit collection guard accepted or misclassified a null field: $field" + } +} + +$singletonLicenseIndex = ConvertTo-Array -Value ([pscustomobject]@{ path = 'one-license.txt' }) +if ($singletonLicenseIndex.GetType() -ne [object[]] -or + $singletonLicenseIndex.Count -ne 1 -or + -not ($singletonLicenseIndex.Count -lt 10)) { + throw 'Singleton third-party license index does not reach the verifier small-index rejection shape.' +} + +foreach ($marker in @( + 'Assert-DependencyAuditCollectionFields -DependencyAudit $dependencyAudit', + '$directGitDepsMissingRef = ConvertTo-Array $dependencyAudit.directGitDepsMissingRef', + '$gitResolvedWithoutSha = ConvertTo-Array $dependencyAudit.gitResolvedWithoutSha', + '$duplicateResolvedPackages = ConvertTo-Array $dependencyAudit.duplicateResolvedPackages', + '$unpinnedGitRequirements = ConvertTo-Array $dependencyAudit.unpinnedGitRequirements', + '$thirdPartyLicenseIndex = ConvertTo-Array (' +)) { + if ($verifyText.IndexOf($marker, [StringComparison]::Ordinal) -lt 0 -or + $verifyText.IndexOf($marker, [StringComparison]::Ordinal) -ne + $verifyText.LastIndexOf($marker, [StringComparison]::Ordinal)) { + throw "Verifier dependency-array consumer is missing or ambiguous: $marker" + } +} + $operationalCommitMapInitializations = @($verifyAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index ab70c3df..2fe6ae67 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -5283,9 +5283,25 @@ function Assert-LockedPackage { function ConvertTo-Array { param([object]$Value) if ($null -eq $Value) { - return @() + return ,@() + } + return ,@($Value) +} + +function Assert-DependencyAuditCollectionFields { + param([Parameter(Mandatory = $true)][object]$DependencyAudit) + + foreach ($field in @( + 'directGitDepsMissingRef', + 'gitResolvedWithoutSha', + 'duplicateResolvedPackages', + 'unpinnedGitRequirements' + )) { + $property = $DependencyAudit.PSObject.Properties[$field] + if ($null -eq $property -or $null -eq $property.Value) { + throw "dependency_lock.json dependencyAudit requires a non-null collection field: $field" + } } - return @($Value) } foreach ($envName in @("stackchan", "stackchan_servo_calibration", "stackchan_release_full")) { @@ -5361,6 +5377,8 @@ if ([string]::IsNullOrWhiteSpace([string]$dependencyAudit.policy)) { throw "dependency_lock.json dependencyAudit missing policy" } +Assert-DependencyAuditCollectionFields -DependencyAudit $dependencyAudit + $directGitDepsMissingRef = ConvertTo-Array $dependencyAudit.directGitDepsMissingRef if ($directGitDepsMissingRef.Count -gt 0) { throw "dependency_lock.json has direct Git dependencies without refs: $($directGitDepsMissingRef -join ', ')" From 619ff544039020041f09f9aeee3aa26d4382ae6f Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 06:40:25 -0400 Subject: [PATCH 34/46] Bind readiness authority across package formats --- docs/FIRST_DEPLOY_STATUS.md | 22 ++++++ tools/package_release.ps1 | 9 +-- tools/release_source_binding.ps1 | 10 +++ .../test_release_source_binding_contract.ps1 | 79 +++++++++++++++++++ ...lease_toolchain_documentation_contract.ps1 | 8 +- tools/verify_release_package.ps1 | 14 ++-- 6 files changed, 131 insertions(+), 11 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 6e70b2a7..be62b73b 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -372,6 +372,28 @@ reaches the intended small-index rejection. The SEC-002 hold remains in force un passes the broad contracts, exact-head CI, and a fresh governed package plus independent verification. +Qualification head `494a6e2d69ba562e6c4a76cf6f348e44422b763e` passed all 11 jobs on the first +attempt in exact-head GitHub Firmware run `30993330105`. Governed packaging for +`sec-002-494a6e2d` completed two clean cycles, exact size/SHA-256 comparison of all 15 artifact +pairs, package assembly, and provisional ZIP creation. The independent verifier passed package +inventory, deterministic README, voice/RVC, preview media, phase A-E face assets, and the release +asset contract. It then failed closed at `verify_release_package.ps1:5919` because the generated +Markdown wrapped `exact clean trusted source checkout` and `archive does not confer release +authority` across line boundaries while the verifier required literal spaces. The complete +authority guidance is present contiguously in `readiness_report.json`; this is producer/verifier +formatting drift, not missing authority policy. The provisional ZIP and output under +`output/release/sec-002-494a6e2d` are not candidates. No flash, OTA request, COM access, bridge +session, robot-port access, or actuator command occurred. + +The correction defines one canonical arrival-authority sentence in the trusted source-binding +helper, uses it for both generated readiness formats, compares the JSON value exactly, and checks a +whitespace-semantic Markdown view without changing case, punctuation, filenames, or token order. +The production readiness report is also included in the contradictory extracted-archive authority +scan. Contract fixtures accept LF, CRLF, tabs, and repeated formatting spaces while rejecting +changes to exact-clean checkout authority, the six-value toolchain, the trusted arrival helper, or +the archive boundary. The SEC-002 hold remains in force until this correction passes the broad +contracts, exact-head CI, and a fresh governed package plus independent verification. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index 0f0d54e7..743204b9 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -3431,6 +3431,7 @@ if ($ObserveCandidateActions) { } } +$arrivalAuthorityGuidance = Get-StackchanArrivalAuthorityGuidance $readinessReport = [ordered]@{ schema = "stackchan.readiness-report.v1" version = $Version @@ -3479,7 +3480,7 @@ $readinessReport = [ordered]@{ nextOperatorGuidance = if ($SkipBuild) { 'Diagnostic packages have no arrival or hardware authority.' } else { - 'Return to the exact clean trusted source checkout, define the six-value releaseToolchain splat from docs/RELEASE_PROCESS.md, and pass this ZIP to tools/prepare_device_arrival.ps1. The archive does not confer release authority.' + $arrivalAuthorityGuidance } } @@ -3690,10 +3691,8 @@ Owner approval has not been recorded for this candidate. Promotion requires sour supervised hardware qualification, bridge AI qualification, the required soak, successful release checks, and explicit owner approval. -Arrival authority is intentionally not embedded in this archive. Return to the exact clean trusted -source checkout, define the six-value ``releaseToolchain`` splat from ``docs/RELEASE_PROCESS.md``, -and pass this ZIP to ``tools/prepare_device_arrival.ps1``. The archive does not confer release -authority. +Arrival authority is intentionally not embedded in this archive. +$arrivalAuthorityGuidance "@ | Set-Content -Path (Join-Path $outDir "READINESS_REPORT.md") -Encoding UTF8 } diff --git a/tools/release_source_binding.ps1 b/tools/release_source_binding.ps1 index 55920d9b..06dcfacb 100644 --- a/tools/release_source_binding.ps1 +++ b/tools/release_source_binding.ps1 @@ -1,3 +1,13 @@ +function Get-StackchanArrivalAuthorityGuidance { + return 'Return to the exact clean trusted source checkout, define the six-value releaseToolchain splat from docs/RELEASE_PROCESS.md, and pass this ZIP to tools/prepare_device_arrival.ps1. The archive does not confer release authority.' +} + +function ConvertTo-StackchanMarkdownSemanticText { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text) + + return [regex]::Replace($Text, '[\t\r\n ]+', ' ').Trim() +} + function ConvertTo-StackchanPackageReadmeText { param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text) diff --git a/tools/test_release_source_binding_contract.ps1 b/tools/test_release_source_binding_contract.ps1 index 0dad88b6..47560797 100644 --- a/tools/test_release_source_binding_contract.ps1 +++ b/tools/test_release_source_binding_contract.ps1 @@ -6,6 +6,85 @@ $verifyPath = Join-Path $PSScriptRoot "verify_release_package.ps1" . (Join-Path $PSScriptRoot "release_source_binding.ps1") $packageText = Get-Content -LiteralPath $packagePath -Raw $verifyText = Get-Content -LiteralPath $verifyPath -Raw +$expectedArrivalAuthorityGuidance = 'Return to the exact clean trusted source checkout, define the six-value releaseToolchain splat from docs/RELEASE_PROCESS.md, and pass this ZIP to tools/prepare_device_arrival.ps1. The archive does not confer release authority.' +$arrivalAuthorityGuidance = Get-StackchanArrivalAuthorityGuidance +if ($arrivalAuthorityGuidance -cne $expectedArrivalAuthorityGuidance) { + throw 'Shared arrival-authority guidance changed unexpectedly.' +} +foreach ($authorityFixture in @( + $arrivalAuthorityGuidance, + $arrivalAuthorityGuidance.Replace(' ', "`n"), + $arrivalAuthorityGuidance.Replace(' ', "`r`n"), + $arrivalAuthorityGuidance.Replace(' ', "`t"), + $arrivalAuthorityGuidance.Replace(' ', ' ') +)) { + $normalizedAuthorityFixture = ConvertTo-StackchanMarkdownSemanticText -Text $authorityFixture + if ($normalizedAuthorityFixture -cne $arrivalAuthorityGuidance) { + throw 'Arrival-authority Markdown normalization changed formatting-equivalent semantics.' + } +} +foreach ($authorityMutation in @( + $arrivalAuthorityGuidance.Replace('exact clean trusted source checkout', 'trusted source checkout'), + $arrivalAuthorityGuidance.Replace('six-value', 'five-value'), + $arrivalAuthorityGuidance.Replace('prepare_device_arrival.ps1', 'run_device_preflight.ps1'), + $arrivalAuthorityGuidance.Replace('does not confer', 'confers') +)) { + if ((ConvertTo-StackchanMarkdownSemanticText -Text $authorityMutation) -ceq + $arrivalAuthorityGuidance) { + throw 'Arrival-authority Markdown normalization accepted a semantic mutation.' + } +} +foreach ($requiredArrivalAuthorityProducerBinding in @( + '$arrivalAuthorityGuidance = Get-StackchanArrivalAuthorityGuidance', + 'nextOperatorGuidance = if ($SkipBuild)', + 'Arrival authority is intentionally not embedded in this archive.' +)) { + if (-not $packageText.Contains($requiredArrivalAuthorityProducerBinding)) { + throw "Arrival-authority producer binding is missing: $requiredArrivalAuthorityProducerBinding" + } +} +foreach ($requiredArrivalAuthorityVerifierBinding in @( + '$arrivalAuthorityGuidance = Get-StackchanArrivalAuthorityGuidance', + '$readinessSemanticMarkdown = ConvertTo-StackchanMarkdownSemanticText -Text $readinessMarkdown', + '[string]$readinessJson.nextOperatorGuidance -cne $arrivalAuthorityGuidance', + '$packageAuthorityDocPaths += ''READINESS_REPORT.md''' +)) { + if (-not $verifyText.Contains($requiredArrivalAuthorityVerifierBinding)) { + throw "Arrival-authority verifier binding is missing: $requiredArrivalAuthorityVerifierBinding" + } +} +if (@([regex]::Matches( + $packageText, [regex]::Escape('$arrivalAuthorityGuidance'))).Count -ne 3 -or + @([regex]::Matches( + $packageText, [regex]::Escape('Get-StackchanArrivalAuthorityGuidance'))).Count -ne 1 -or + @([regex]::Matches( + $verifyText, [regex]::Escape('$arrivalAuthorityGuidance'))).Count -ne 3 -or + @([regex]::Matches( + $verifyText, [regex]::Escape('Get-StackchanArrivalAuthorityGuidance'))).Count -ne 1) { + throw 'Arrival-authority producer/verifier binding count is ambiguous.' +} +if (@([regex]::Matches( + $verifyText, + [regex]::Escape('$readinessSemanticMarkdown -notmatch [regex]::Escape($pattern)'))).Count -ne 2) { + throw 'Readiness Markdown checks do not consistently use the semantic whitespace view.' +} +$readinessTemplateStart = $packageText.IndexOf( + 'Arrival authority is intentionally not embedded in this archive.', + [System.StringComparison]::Ordinal) +$readinessTemplateEnd = $packageText.IndexOf( + '"@ | Set-Content -Path (Join-Path $outDir "READINESS_REPORT.md") -Encoding UTF8', + $readinessTemplateStart, + [System.StringComparison]::Ordinal) +if ($readinessTemplateStart -lt 0 -or $readinessTemplateEnd -le $readinessTemplateStart) { + throw 'Release readiness Markdown authority template is missing or ambiguous.' +} +$readinessTemplateFixture = $packageText.Substring( + $readinessTemplateStart, $readinessTemplateEnd - $readinessTemplateStart).Replace( + '$arrivalAuthorityGuidance', $arrivalAuthorityGuidance) +$readinessTemplateSemanticText = ConvertTo-StackchanMarkdownSemanticText -Text $readinessTemplateFixture +if (-not $readinessTemplateSemanticText.Contains($arrivalAuthorityGuidance)) { + throw 'Release readiness Markdown is not bound to the canonical arrival-authority guidance.' +} $readmeLfFixture = "# Stackchan café`n`n![Preview](docs/media/preview.png)`n" $readmeExpectedFixture = "# Stackchan café`r`n`r`n![Preview](media/preview.png)`r`n" $readmeSourceFixtures = @( diff --git a/tools/test_release_toolchain_documentation_contract.ps1 b/tools/test_release_toolchain_documentation_contract.ps1 index 3d9b7f98..18144be9 100644 --- a/tools/test_release_toolchain_documentation_contract.ps1 +++ b/tools/test_release_toolchain_documentation_contract.ps1 @@ -52,9 +52,15 @@ foreach ($relative in $operatorDocs) { } $packageText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'package_release.ps1') -Raw +$sourceBindingText = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'release_source_binding.ps1') -Raw +Require-Text $sourceBindingText ` + 'Return to the exact clean trusted source checkout, define the six-value releaseToolchain splat from docs/RELEASE_PROCESS.md, and pass this ZIP to tools/prepare_device_arrival.ps1. The archive does not confer release authority.' ` + 'Trusted source-binding helper is missing the canonical arrival-authority guidance.' foreach ($needle in @( 'nextOperatorCommand = $null', 'nextOperatorGuidance', - 'The archive does not confer release authority', + '$arrivalAuthorityGuidance = Get-StackchanArrivalAuthorityGuidance', + '$arrivalAuthorityGuidance', 'completed governed release package')) { Require-Text $packageText $needle "Package handoff is missing non-authorizing guidance: $needle" } diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 2fe6ae67..5c67475c 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -2433,6 +2433,9 @@ $packageAuthorityDocPaths = @( 'README.md', 'QUICKSTART.md', 'ARRIVAL_DAY_RUNBOOK.md', 'docs/RELEASE_PROCESS.md', 'docs/DEVICE_BRINGUP.md', 'docs/ROLLOUT_CHECKLIST.md', 'docs/BRIDGE_AI_QUALIFICATION.md', 'docs/COMPANION_APP_GAP_ANALYSIS.md') +if (-not [bool]$manifest.diagnosticPackage) { + $packageAuthorityDocPaths += 'READINESS_REPORT.md' +} $packageAuthorityToolPattern = '(?im)(?:^|[\\/])(?:package_release|verify_release_package|run_device_preflight|flash_release_firmware|start_hardware_evidence|prepare_device_arrival|start_bridge_ai_supervised_qualification|verify_consumer_promotion|publish_release|audit_published_release|verify_published_release|share_release|export_rollout_status)\.(?:cmd|ps1)' $packageAuthorityCmdPattern = '(?im)(?:^|[\\/])(?:package_release|verify_release_package|run_device_preflight|flash_release_firmware|start_hardware_evidence|prepare_device_arrival|start_bridge_ai_supervised_qualification|verify_consumer_promotion|publish_release|audit_published_release|verify_published_release|share_release|export_rollout_status)\.cmd' foreach ($relativePath in $packageAuthorityDocPaths) { @@ -5904,9 +5907,11 @@ foreach ($pattern in $actionsStatusPatterns) { } $readinessMarkdown = Get-Content -LiteralPath (Join-PackagePath "READINESS_REPORT.md") -Raw +$readinessSemanticMarkdown = ConvertTo-StackchanMarkdownSemanticText -Text $readinessMarkdown +$arrivalAuthorityGuidance = Get-StackchanArrivalAuthorityGuidance if ([bool]$manifest.diagnosticPackage) { foreach ($pattern in @($Version, $ExpectedCommit, "Diagnostic package:", "Status: diagnostic-only unqualified", "Consumer rollout: forbidden diagnostic package", "Release and hardware use: forbidden", "source identity is not established", "Do not flash it")) { - if ($readinessMarkdown -notmatch [regex]::Escape($pattern)) { + if ($readinessSemanticMarkdown -notmatch [regex]::Escape($pattern)) { throw "Diagnostic READINESS_REPORT.md missing prohibition: $pattern" } } @@ -5914,8 +5919,8 @@ if ([bool]$manifest.diagnosticPackage) { throw "Diagnostic READINESS_REPORT.md contains readiness claims" } } else { - foreach ($pattern in @($Version, $ExpectedCommit, "Status: test-ready prerelease", "Consumer rollout: blocked pending hardware validation", "Proven Without Hardware", "Required Physical Qualification", "Historical private paired-reference evidence", "source commit and firmware SHA-256", "recipient's assembled hardware", "GITHUB_ACTIONS_STATUS.md", "VOICE_SOURCE_STATUS.md", "Character red-team dry-run evidence", "Companion C6 brain-supervision evidence", "companion/evidence/", "configured local model", "add_hardware_evidence_media.cmd", "verify_hardware_evidence.cmd", "Speech-mouth demo evidence", "speech_mouth_demo_serial.log", "speak_all_intents_serial.log", "Power-cycle recovery", "USB power-cycle observation marked pass", "Production voice metadata", "Owner approval has not been recorded for this candidate", "exact clean trusted source checkout", "archive does not confer release authority")) { - if ($readinessMarkdown -notmatch [regex]::Escape($pattern)) { + foreach ($pattern in @($Version, $ExpectedCommit, "Status: test-ready prerelease", "Consumer rollout: blocked pending hardware validation", "Proven Without Hardware", "Required Physical Qualification", "Historical private paired-reference evidence", "source commit and firmware SHA-256", "recipient's assembled hardware", "GITHUB_ACTIONS_STATUS.md", "VOICE_SOURCE_STATUS.md", "Character red-team dry-run evidence", "Companion C6 brain-supervision evidence", "companion/evidence/", "configured local model", "add_hardware_evidence_media.cmd", "verify_hardware_evidence.cmd", "Speech-mouth demo evidence", "speech_mouth_demo_serial.log", "speak_all_intents_serial.log", "Power-cycle recovery", "USB power-cycle observation marked pass", "Production voice metadata", "Owner approval has not been recorded for this candidate", $arrivalAuthorityGuidance)) { + if ($readinessSemanticMarkdown -notmatch [regex]::Escape($pattern)) { throw "READINESS_REPORT.md missing expected text: $pattern" } } @@ -5955,8 +5960,7 @@ if ([bool]$manifest.diagnosticPackage) { if ($readinessJson.diagnosticPackage -eq $true -or $readinessJson.status -ne "test-ready-prerelease" -or $null -ne $readinessJson.nextOperatorCommand -or - [string]$readinessJson.nextOperatorGuidance -notmatch 'trusted source checkout' -or - [string]$readinessJson.nextOperatorGuidance -notmatch 'archive does not confer release authority') { + [string]$readinessJson.nextOperatorGuidance -cne $arrivalAuthorityGuidance) { throw "readiness_report.json status mismatch: $($readinessJson.status)" } if ($readinessJson.consumerRollout -ne "blocked-pending-hardware-validation") { From 612ef7beb24bfcfc4ad33eae31e41c3b7900d2bc Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 08:04:09 -0400 Subject: [PATCH 35/46] Classify package authority before readiness scan --- docs/FIRST_DEPLOY_STATUS.md | 21 +++ ...elease_package_verifier_trust_contract.ps1 | 151 ++++++++++++++++++ .../test_release_source_binding_contract.ps1 | 30 ++++ tools/verify_release_package.ps1 | 30 +++- 4 files changed, 230 insertions(+), 2 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index be62b73b..0f931a10 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -394,6 +394,27 @@ changes to exact-clean checkout authority, the six-value toolchain, the trusted the archive boundary. The SEC-002 hold remains in force until this correction passes the broad contracts, exact-head CI, and a fresh governed package plus independent verification. +Qualification head `619ff544039020041f09f9aeee3aa26d4382ae6f` passed all 11 jobs on the first +attempt in exact-head GitHub Firmware run `30998405766`. Governed packaging for +`sec-002-619ff544` completed two clean cycles and exact size/SHA-256 comparison of all 15 artifact +pairs. The public `stackchan_release_full` firmware from both cycles is 2,803,504 bytes with +SHA-256 `FFFA8F159DBEE5C4700075D88A6F24607100A5A1BE85BF15EAD845DBC049552F`. +The provisional 189,653,086-byte ZIP has SHA-256 +`C12917BF98DA88853C1B2CCE263AC5B1028DF1173A98C67E5A90BB648C03411F`. Its independent verifier +failed closed at `verify_release_package.ps1:2436` during the archive-authority content scan, +before the full manifest load and validation at line 4380, because that scan referenced `$manifest` +before assignment. The provisional ZIP and output under `output/release/sec-002-619ff544` are not +candidates. No flash, OTA request, COM access, bridge session, robot-port access, or actuator +command occurred. + +The correction derives one exactly typed JSON-boolean diagnostic classification from the existing +early eligibility manifest gate, rejects a missing, null, or non-boolean classification, uses that +classification for the production-only readiness authority scan, and requires the later full +manifest parse to retain the same classification. A source-binding contract also rejects any +`$manifest` use before its assignment. The SEC-002 hold remains in force until this correction +passes the broad contracts, exact-head CI, and a fresh governed package plus independent +verification. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 index fd348fbe..af391cee 100644 --- a/tools/test_release_package_verifier_trust_contract.ps1 +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -696,6 +696,50 @@ $earlyDiagnosticGates = @($verifyAst.EndBlock.Statements | Where-Object { if ($canonicalBlobHashFunctions.Count -ne 1 -or $earlyDiagnosticGates.Count -ne 1) { throw 'Operational verifier checkout-gate harness inputs are ambiguous' } +$manifestAssignments = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and + $node.Left.VariablePath.UserPath -ceq 'manifest' +}, $true)) +$eligibilityClassificationAssignments = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and + $node.Left.VariablePath.UserPath -ceq 'eligibilityDiagnosticPackage' +}, $true)) +$readinessAuthorityClassificationBranches = @($verifyAst.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.IfStatementAst] -and + $_.Clauses.Count -eq 1 -and + $_.Clauses[0].Item1.Extent.Text -ceq '$eligibilityDiagnosticPackage -eq $false' -and + $_.Extent.Text.Contains("`$packageAuthorityDocPaths += 'READINESS_REPORT.md'") +}) +if ($manifestAssignments.Count -ne 1 -or + $eligibilityClassificationAssignments.Count -ne 1 -or + $readinessAuthorityClassificationBranches.Count -ne 1 -or + $eligibilityClassificationAssignments[0].Extent.EndOffset -ge + $readinessAuthorityClassificationBranches[0].Extent.StartOffset) { + throw 'Operational verifier manifest classification and readiness authority ordering is ambiguous.' +} +$earlyManifestVariableUses = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.VariableExpressionAst] -and + $node.VariablePath.UserPath -ceq 'manifest' -and + $node.Extent.StartOffset -lt $manifestAssignments[0].Extent.StartOffset +}, $true)) +$lateClassificationGuardOffset = $verifyText.IndexOf( + 'Release manifest diagnosticPackage changed or became invalid during verification.', + $manifestAssignments[0].Extent.EndOffset, + [System.StringComparison]::Ordinal) +$firstLaterManifestBranchOffset = $verifyText.IndexOf( + '[bool]$manifest.diagnosticPackage', + $manifestAssignments[0].Extent.EndOffset, + [System.StringComparison]::Ordinal) +if ($earlyManifestVariableUses.Count -ne 0 -or + $lateClassificationGuardOffset -le $manifestAssignments[0].Extent.EndOffset -or + $firstLaterManifestBranchOffset -le $lateClassificationGuardOffset) { + throw 'Operational verifier uses full manifest classification before assignment or exact late validation.' +} $bootstrapGitText = $bootstrapGitFunctions[0].Extent.Text foreach ($requiredBootstrap in @( 'core.hooksPath=', @@ -1569,6 +1613,113 @@ if (-not [string]::IsNullOrWhiteSpace($PackageRoot)) { throw 'Operational verifier did not reject a diagnostic manifest at the early eligibility gate' } + $eligibilityShapeRoot = Join-Path $epochTrustRoot 'ignored-verifier/eligibility-shapes' + New-Item -ItemType Directory -Path $eligibilityShapeRoot -Force | Out-Null + $invalidEligibilityShapes = @( + [pscustomobject]@{ + name = 'missing'; writeManifest = $false; propertyName = $null; value = $null + expected = 'Release package is missing required release_manifest.json.' + }, + [pscustomobject]@{ + name = 'null'; writeManifest = $true; propertyName = 'diagnosticPackage'; value = $null + expected = 'Release manifest diagnosticPackage must be one JSON boolean.' + }, + [pscustomobject]@{ + name = 'string-false'; writeManifest = $true; propertyName = 'diagnosticPackage'; value = 'false' + expected = 'Release manifest diagnosticPackage must be one JSON boolean.' + }, + [pscustomobject]@{ + name = 'string-true'; writeManifest = $true; propertyName = 'diagnosticPackage'; value = 'true' + expected = 'Release manifest diagnosticPackage must be one JSON boolean.' + }, + [pscustomobject]@{ + name = 'numeric-zero'; writeManifest = $true; propertyName = 'diagnosticPackage'; value = 0 + expected = 'Release manifest diagnosticPackage must be one JSON boolean.' + }, + [pscustomobject]@{ + name = 'numeric-one'; writeManifest = $true; propertyName = 'diagnosticPackage'; value = 1 + expected = 'Release manifest diagnosticPackage must be one JSON boolean.' + }, + [pscustomobject]@{ + name = 'wrong-case'; writeManifest = $true; propertyName = 'DiagnosticPackage'; value = $false + expected = 'Release manifest diagnosticPackage must be one JSON boolean.' + } + ) + foreach ($shape in $invalidEligibilityShapes) { + $shapeRoot = Join-Path $eligibilityShapeRoot ([string]$shape.name) + New-Item -ItemType Directory -Path $shapeRoot | Out-Null + if ([bool]$shape.writeManifest) { + $shapeManifest = [ordered]@{ version = 'eligibility-shape-contract' } + $shapeManifest[[string]$shape.propertyName] = $shape.value + $shapeManifest | ConvertTo-Json | Set-Content ` + -LiteralPath (Join-Path $shapeRoot 'release_manifest.json') -Encoding UTF8 + } + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $shapeOutput = @(& powershell.exe @gateBaseArgs ` + -PackageRoot $shapeRoot ` + -ExpectedSourceEpoch $epochValue ` + -AllowDirtyPackage ` + 2>&1) + $shapeExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($shapeExit -eq 0 -or + ($shapeOutput | Out-String) -notmatch [regex]::Escape([string]$shape.expected)) { + throw "Operational verifier accepted invalid diagnosticPackage shape '$($shape.name)': $($shapeOutput | Out-String)" + } + } + + foreach ($rawShape in @( + [pscustomobject]@{ name = 'whole-document-null'; text = 'null' }, + [pscustomobject]@{ name = 'malformed-json'; text = '{"diagnosticPackage":' } + )) { + $shapeRoot = Join-Path $eligibilityShapeRoot ([string]$rawShape.name) + New-Item -ItemType Directory -Path $shapeRoot | Out-Null + [IO.File]::WriteAllText( + (Join-Path $shapeRoot 'release_manifest.json'), [string]$rawShape.text, + (New-Object Text.UTF8Encoding($false))) + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $shapeOutput = @(& powershell.exe @gateBaseArgs ` + -PackageRoot $shapeRoot ` + -ExpectedSourceEpoch $epochValue ` + -AllowDirtyPackage ` + 2>&1) + $shapeExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($shapeExit -eq 0) { + throw "Operational verifier accepted invalid release manifest '$($rawShape.name)'." + } + } + + $operationalEligibilityRoot = Join-Path $eligibilityShapeRoot 'boolean-false' + New-Item -ItemType Directory -Path $operationalEligibilityRoot | Out-Null + [ordered]@{ + version = 'eligibility-shape-contract' + diagnosticPackage = $false + } | ConvertTo-Json | Set-Content ` + -LiteralPath (Join-Path $operationalEligibilityRoot 'release_manifest.json') -Encoding UTF8 + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $operationalEligibilityOutput = @(& powershell.exe @gateBaseArgs ` + -PackageRoot $operationalEligibilityRoot ` + -ExpectedSourceEpoch $epochValue ` + 2>&1) + $operationalEligibilityExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + if ($operationalEligibilityExit -ne 0) { + throw "Operational verifier rejected a JSON boolean false diagnosticPackage classification: $($operationalEligibilityOutput | Out-String)" + } + foreach ($dirtyHelperName in @( 'firmware_reproducibility_proof.ps1', 'release_zip_safety.ps1', diff --git a/tools/test_release_source_binding_contract.ps1 b/tools/test_release_source_binding_contract.ps1 index 47560797..2a4aa8b7 100644 --- a/tools/test_release_source_binding_contract.ps1 +++ b/tools/test_release_source_binding_contract.ps1 @@ -47,12 +47,42 @@ foreach ($requiredArrivalAuthorityVerifierBinding in @( '$arrivalAuthorityGuidance = Get-StackchanArrivalAuthorityGuidance', '$readinessSemanticMarkdown = ConvertTo-StackchanMarkdownSemanticText -Text $readinessMarkdown', '[string]$readinessJson.nextOperatorGuidance -cne $arrivalAuthorityGuidance', + '$eligibilityDiagnosticPackage = [bool]$diagnosticPackageProperties[0].Value', + 'if ($eligibilityDiagnosticPackage -eq $false)', '$packageAuthorityDocPaths += ''READINESS_REPORT.md''' )) { if (-not $verifyText.Contains($requiredArrivalAuthorityVerifierBinding)) { throw "Arrival-authority verifier binding is missing: $requiredArrivalAuthorityVerifierBinding" } } +$eligibilityClassificationOffset = $verifyText.IndexOf( + '$eligibilityDiagnosticPackage = [bool]$diagnosticPackageProperties[0].Value', + [System.StringComparison]::Ordinal) +$readinessAuthorityScanOffset = $verifyText.IndexOf( + 'if ($eligibilityDiagnosticPackage -eq $false)', + [System.StringComparison]::Ordinal) +$manifestAssignmentOffset = $verifyText.IndexOf( + '$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json', + [System.StringComparison]::Ordinal) +$verifyTokens = $null +$verifyParseErrors = $null +$verifyAst = [Management.Automation.Language.Parser]::ParseFile( + $verifyPath, [ref]$verifyTokens, [ref]$verifyParseErrors) +if ($verifyParseErrors.Count -ne 0) { + throw "Release verifier does not parse: $($verifyParseErrors | Out-String)" +} +$earlyManifestUses = @($verifyAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.VariableExpressionAst] -and + $node.VariablePath.UserPath -ceq 'manifest' -and + $node.Extent.StartOffset -lt $manifestAssignmentOffset +}, $true)) +if ($eligibilityClassificationOffset -lt 0 -or + $readinessAuthorityScanOffset -le $eligibilityClassificationOffset -or + $manifestAssignmentOffset -le $readinessAuthorityScanOffset -or + $earlyManifestUses.Count -ne 0) { + throw 'Readiness authority scanning uses release-manifest state before its trusted early classification.' +} if (@([regex]::Matches( $packageText, [regex]::Escape('$arrivalAuthorityGuidance'))).Count -ne 3 -or @([regex]::Matches( diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 5c67475c..47c559af 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -537,7 +537,19 @@ function Get-PackageItemFullName { $eligibilityManifestPath = Join-Path $packageRootPath "release_manifest.json" if (Test-Path -LiteralPath $eligibilityManifestPath -PathType Leaf) { $eligibilityManifest = Get-Content -LiteralPath $eligibilityManifestPath -Raw | ConvertFrom-Json - if ([bool]$eligibilityManifest.diagnosticPackage) { + $diagnosticPackageProperties = if ($null -eq $eligibilityManifest) { + @() + } else { + @($eligibilityManifest.PSObject.Properties | Where-Object { + $_.Name -ceq 'diagnosticPackage' + }) + } + if ($diagnosticPackageProperties.Count -ne 1 -or + $diagnosticPackageProperties[0].Value -isnot [bool]) { + throw "Release manifest diagnosticPackage must be one JSON boolean." + } + $eligibilityDiagnosticPackage = [bool]$diagnosticPackageProperties[0].Value + if ($eligibilityDiagnosticPackage) { if ($RequireReleaseEligible) { throw "Operational release verification refuses diagnostic packages." } @@ -545,6 +557,8 @@ if (Test-Path -LiteralPath $eligibilityManifestPath -PathType Leaf) { throw "Diagnostic archive inspection requires -AllowDirtyPackage" } } +} else { + throw "Release package is missing required release_manifest.json." } $generatedPythonArtifacts = @( Get-ChildItem -LiteralPath $packageEnumerationRoot -Recurse -Force | Where-Object { @@ -2433,7 +2447,7 @@ $packageAuthorityDocPaths = @( 'README.md', 'QUICKSTART.md', 'ARRIVAL_DAY_RUNBOOK.md', 'docs/RELEASE_PROCESS.md', 'docs/DEVICE_BRINGUP.md', 'docs/ROLLOUT_CHECKLIST.md', 'docs/BRIDGE_AI_QUALIFICATION.md', 'docs/COMPANION_APP_GAP_ANALYSIS.md') -if (-not [bool]$manifest.diagnosticPackage) { +if ($eligibilityDiagnosticPackage -eq $false) { $packageAuthorityDocPaths += 'READINESS_REPORT.md' } $packageAuthorityToolPattern = '(?im)(?:^|[\\/])(?:package_release|verify_release_package|run_device_preflight|flash_release_firmware|start_hardware_evidence|prepare_device_arrival|start_bridge_ai_supervised_qualification|verify_consumer_promotion|publish_release|audit_published_release|verify_published_release|share_release|export_rollout_status)\.(?:cmd|ps1)' @@ -4378,6 +4392,18 @@ Assert-Bytes "media/voice/stackchan_spark_audition_bright_robot_greeting.wav" ([ $manifestPath = Join-PackagePath "release_manifest.json" $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +$manifestDiagnosticPackageProperties = if ($null -eq $manifest) { + @() +} else { + @($manifest.PSObject.Properties | Where-Object { + $_.Name -ceq 'diagnosticPackage' + }) +} +if ($manifestDiagnosticPackageProperties.Count -ne 1 -or + $manifestDiagnosticPackageProperties[0].Value -isnot [bool] -or + [bool]$manifestDiagnosticPackageProperties[0].Value -ne $eligibilityDiagnosticPackage) { + throw "Release manifest diagnosticPackage changed or became invalid during verification." +} if ($manifest.releaseAssetManifest -ne "release_assets.json") { throw "Manifest releaseAssetManifest mismatch: $($manifest.releaseAssetManifest)" } From f012271a3b616e5adac19cf8203810fb1282b4b6 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 09:26:19 -0400 Subject: [PATCH 36/46] Preserve manifest property collection shape --- docs/FIRST_DEPLOY_STATUS.md | 19 +++++ ...elease_package_verifier_trust_contract.ps1 | 80 ++++++++++++++++++- tools/verify_release_package.ps1 | 28 +++---- 3 files changed, 112 insertions(+), 15 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 0f931a10..ea08b0b3 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -415,6 +415,25 @@ manifest parse to retain the same classification. A source-binding contract also passes the broad contracts, exact-head CI, and a fresh governed package plus independent verification. +Qualification head `612ef7beb24bfcfc4ad33eae31e41c3b7900d2bc` passed all 11 jobs on the first +attempt in exact-head GitHub Firmware run `31004056148`. Governed packaging for +`sec-002-612ef7be` completed two clean cycles and exact size/SHA-256 comparison of all 15 artifact +pairs. The public `stackchan_release_full` firmware from both cycles is 2,803,504 bytes with +SHA-256 `67CD01A6300D1E4FCFE654C8743C200B882F142906D8D237F43A4D3DF7A1C622`. +The provisional 189,655,016-byte ZIP has SHA-256 +`20FF0870A3C6FBF7FC03AFCC07EDEBA79FBDB54EA4192E9F8D65D62E8538A208`. Its independent verifier +failed closed before reaching the corrected archive-authority scan, at +`verify_release_package.ps1:547`: PowerShell unrolled the one matching `diagnosticPackage` +property emitted through the conditional assignment, so the resulting scalar did not expose the +required collection `Count`. The provisional ZIP and output under +`output/release/sec-002-612ef7be` are not candidates. No flash, OTA request, COM access, bridge +session, robot-port access, or actuator command occurred. + +The follow-up correction wraps the complete early and late property-selection expressions in +array subexpressions, preserving exact parsed-property zero/one/many cardinality before the type +and equality checks. The SEC-002 hold remains in force until this correction passes the broad +contracts, exact-head CI, and a fresh governed package plus independent verification. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 index af391cee..20b4be95 100644 --- a/tools/test_release_package_verifier_trust_contract.ps1 +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -714,6 +714,35 @@ $readinessAuthorityClassificationBranches = @($verifyAst.EndBlock.Statements | W $_.Clauses[0].Item1.Extent.Text -ceq '$eligibilityDiagnosticPackage -eq $false' -and $_.Extent.Text.Contains("`$packageAuthorityDocPaths += 'READINESS_REPORT.md'") }) +$diagnosticPropertyCollectionAssignments = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and + $node.Left.VariablePath.UserPath -cin @( + 'diagnosticPackageProperties', + 'manifestDiagnosticPackageProperties' + ) +}, $true)) +foreach ($propertyCollectionName in @( + 'diagnosticPackageProperties', + 'manifestDiagnosticPackageProperties' +)) { + $propertyCollectionAssignments = @($diagnosticPropertyCollectionAssignments | Where-Object { + $_.Left.VariablePath.UserPath -ceq $propertyCollectionName + }) + $propertyCollectionExpression = if ( + $propertyCollectionAssignments.Count -eq 1 -and + $propertyCollectionAssignments[0].Right -is + [System.Management.Automation.Language.CommandExpressionAst]) { + $propertyCollectionAssignments[0].Right.Expression + } else { $null } + if ($null -eq $propertyCollectionExpression -or + $propertyCollectionExpression -isnot + [System.Management.Automation.Language.ArrayExpressionAst] -or + -not $propertyCollectionExpression.Extent.Text.Contains('.PSObject.Properties | Where-Object')) { + throw "Operational verifier $propertyCollectionName must capture the complete property-selection expression as an array." + } +} if ($manifestAssignments.Count -ne 1 -or $eligibilityClassificationAssignments.Count -ne 1 -or $readinessAuthorityClassificationBranches.Count -ne 1 -or @@ -738,7 +767,53 @@ $firstLaterManifestBranchOffset = $verifyText.IndexOf( if ($earlyManifestVariableUses.Count -ne 0 -or $lateClassificationGuardOffset -le $manifestAssignments[0].Extent.EndOffset -or $firstLaterManifestBranchOffset -le $lateClassificationGuardOffset) { - throw 'Operational verifier uses full manifest classification before assignment or exact late validation.' + throw 'Operational verifier uses full manifest classification before assignment or exact late validation.' +} +$lateDiagnosticPropertyAssignments = @($diagnosticPropertyCollectionAssignments | Where-Object { + $_.Left.VariablePath.UserPath -ceq 'manifestDiagnosticPackageProperties' +}) +$lateDiagnosticClassificationGuards = @($verifyAst.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.IfStatementAst] -and + $_.Extent.Text.Contains( + 'Release manifest diagnosticPackage changed or became invalid during verification.') +}) +if ($lateDiagnosticPropertyAssignments.Count -ne 1 -or + $lateDiagnosticClassificationGuards.Count -ne 1) { + throw 'Operational verifier late diagnosticPackage classifier is ambiguous.' +} +$lateDiagnosticClassifierText = @( + 'param([AllowNull()][object]$manifest, [bool]$eligibilityDiagnosticPackage)', + '$ErrorActionPreference = "Stop"', + 'Set-StrictMode -Version Latest', + $lateDiagnosticPropertyAssignments[0].Extent.Text, + $lateDiagnosticClassificationGuards[0].Extent.Text, + '[pscustomobject]@{ count = $manifestDiagnosticPackageProperties.Count; value = [bool]$manifestDiagnosticPackageProperties[0].Value }' +) -join "`r`n" +$lateDiagnosticClassifier = [scriptblock]::Create($lateDiagnosticClassifierText) +foreach ($validLateDiagnosticValue in @($false, $true)) { + $validLateDiagnosticManifest = [pscustomobject]@{ + diagnosticPackage = $validLateDiagnosticValue + } + $validLateDiagnosticResult = & $lateDiagnosticClassifier ` + $validLateDiagnosticManifest $validLateDiagnosticValue + if ($validLateDiagnosticResult.count -ne 1 -or + $validLateDiagnosticResult.value -ne $validLateDiagnosticValue) { + throw "Operational verifier late diagnosticPackage classifier lost its singleton Boolean array shape." + } + try { + [void](& $lateDiagnosticClassifier ` + $validLateDiagnosticManifest (-not $validLateDiagnosticValue)) + throw 'Late diagnosticPackage classifier accepted early/late Boolean drift.' + } catch { + if ($_.Exception.Message -eq + 'Late diagnosticPackage classifier accepted early/late Boolean drift.') { + throw + } + if ($_.Exception.Message -notmatch + 'Release manifest diagnosticPackage changed or became invalid during verification') { + throw "Late diagnosticPackage drift failed for the wrong reason: $($_.Exception.Message)" + } + } } $bootstrapGitText = $bootstrapGitFunctions[0].Extent.Text foreach ($requiredBootstrap in @( @@ -1527,7 +1602,10 @@ param( [switch]$AllowDirtyPackage ) $ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest $RequireReleaseEligible = $true +$script:verifierToolchainLeaseState = $null +$script:operationalTrustedCommitMaps = $null $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path Set-Location $repoRoot $script:trustedGitExecutable = (Get-Item -LiteralPath $GitExecutable -Force -ErrorAction Stop).FullName diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 47c559af..7b2a9e68 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -537,13 +537,13 @@ function Get-PackageItemFullName { $eligibilityManifestPath = Join-Path $packageRootPath "release_manifest.json" if (Test-Path -LiteralPath $eligibilityManifestPath -PathType Leaf) { $eligibilityManifest = Get-Content -LiteralPath $eligibilityManifestPath -Raw | ConvertFrom-Json - $diagnosticPackageProperties = if ($null -eq $eligibilityManifest) { - @() - } else { - @($eligibilityManifest.PSObject.Properties | Where-Object { - $_.Name -ceq 'diagnosticPackage' - }) - } + $diagnosticPackageProperties = @( + if ($null -ne $eligibilityManifest) { + $eligibilityManifest.PSObject.Properties | Where-Object { + $_.Name -ceq 'diagnosticPackage' + } + } + ) if ($diagnosticPackageProperties.Count -ne 1 -or $diagnosticPackageProperties[0].Value -isnot [bool]) { throw "Release manifest diagnosticPackage must be one JSON boolean." @@ -4392,13 +4392,13 @@ Assert-Bytes "media/voice/stackchan_spark_audition_bright_robot_greeting.wav" ([ $manifestPath = Join-PackagePath "release_manifest.json" $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -$manifestDiagnosticPackageProperties = if ($null -eq $manifest) { - @() -} else { - @($manifest.PSObject.Properties | Where-Object { - $_.Name -ceq 'diagnosticPackage' - }) -} +$manifestDiagnosticPackageProperties = @( + if ($null -ne $manifest) { + $manifest.PSObject.Properties | Where-Object { + $_.Name -ceq 'diagnosticPackage' + } + } +) if ($manifestDiagnosticPackageProperties.Count -ne 1 -or $manifestDiagnosticPackageProperties[0].Value -isnot [bool] -or [bool]$manifestDiagnosticPackageProperties[0].Value -ne $eligibilityDiagnosticPackage) { From 611f609f8df1e4608a9249314eeff9ae3e8845c2 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 10:52:05 -0400 Subject: [PATCH 37/46] Admit companion gap document to package policy --- docs/FIRST_DEPLOY_STATUS.md | 20 ++++++ ...elease_package_verifier_trust_contract.ps1 | 71 +++++++++++++++++++ tools/verify_release_package.ps1 | 1 + 3 files changed, 92 insertions(+) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index ea08b0b3..e5121d17 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -434,6 +434,26 @@ array subexpressions, preserving exact parsed-property zero/one/many cardinality and equality checks. The SEC-002 hold remains in force until this correction passes the broad contracts, exact-head CI, and a fresh governed package plus independent verification. +Qualification head `f012271a3b616e5adac19cf8203810fb1282b4b6` passed all 11 jobs on the first +attempt in exact-head GitHub Firmware run `31010210311`. Governed packaging for +`sec-002-f012271a` completed two clean cycles and exact size/SHA-256 comparison of all 15 artifact +pairs. The public `stackchan_release_full` firmware from both cycles is 2,803,504 bytes with +SHA-256 `802232F72456D0BAC928AF9A1C6636B28AE1541C4D283A7EDFEB574ACE00E944`. The provisional +189,656,098-byte ZIP has SHA-256 +`063F4F8A13D2C660DBCEA47BE3BDF7F59C817FDC5EA08347E797257C72EC5EB7`. Its independent verifier +passed voice/RVC, preview media, face phases A-E, release assets, checksums, and the third-party +inventory, then failed closed at `verify_release_package.ps1:6155`: the physical archive contains +the intentionally copied and later validated `docs/COMPANION_APP_GAP_ANALYSIS.md`, but the trusted +whole-package required-file set omitted that path (1,570 actual files versus 1,569 admitted files). +The provisional ZIP and output under `output/release/sec-002-f012271a` are not candidates. No +flash, OTA request, COM access, bridge session, robot-port access, or actuator command occurred. + +The correction admits that exact companion document into the required-file policy and adds a +producer/verifier regression whose mutation canary removes only the policy entry while preserving +the copy. The count and path allowlists remain fail closed for undeclared files. The SEC-002 hold +remains in force until this correction passes the broad contracts, exact-head CI, and a fresh +governed package plus independent verification. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 index 20b4be95..8db6c6a1 100644 --- a/tools/test_release_package_verifier_trust_contract.ps1 +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -594,6 +594,77 @@ foreach ($canary in @( if ($_.Exception.Message -like 'Inventory governance mutation canary survived:*') { throw } } } +function Assert-AuthorityDocumentsAdmitted { + param([Parameter(Mandatory = $true)][System.Management.Automation.Language.ScriptBlockAst]$Ast) + + $requiredFileAssignments = @($Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.Operator -eq [System.Management.Automation.Language.TokenKind]::Equals -and + $node.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and + $node.Left.VariablePath.UserPath -ceq 'requiredFiles' + }, $true)) + if ($requiredFileAssignments.Count -ne 1) { + throw 'Package verifier required-file policy assignment is ambiguous.' + } + $requiredFileEntries = @($requiredFileAssignments[0].Right.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.StringConstantExpressionAst] + }, $true)) + $requiredFileSet = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal) + foreach ($entry in $requiredFileEntries) { + [void]$requiredFileSet.Add([string]$entry.Value) + } + + $authorityDocumentAssignments = @($Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + -not (Test-WithinFunctionDefinition -Ast $node) -and + $node.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and + $node.Left.VariablePath.UserPath -ceq 'packageAuthorityDocPaths' + }, $true)) + if ($authorityDocumentAssignments.Count -ne 2) { + throw 'Package verifier authority-document policy assignments are ambiguous.' + } + $authorityDocumentEntries = @($authorityDocumentAssignments | ForEach-Object { + $_.Right.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.StringConstantExpressionAst] + }, $true) + }) + foreach ($entry in $authorityDocumentEntries) { + if (-not $requiredFileSet.Contains([string]$entry.Value)) { + throw "Authority document is outside the required-file policy: $($entry.Value)" + } + } +} +if (-not $packageText.Contains( + 'Copy-Item -LiteralPath "docs/COMPANION_APP_GAP_ANALYSIS.md" -Destination $docsDir')) { + throw 'Package producer no longer copies the governed companion gap document.' +} +Assert-AuthorityDocumentsAdmitted -Ast $verifyAst +$companionGapPolicyLinePattern = + '(?m)^ "docs/COMPANION_APP_GAP_ANALYSIS\.md",\r?\n' +$companionGapPolicyLineRegex = [regex]::new($companionGapPolicyLinePattern) +if ($companionGapPolicyLineRegex.Matches($verifyText).Count -ne 1) { + throw 'Companion gap required-file policy line is ambiguous.' +} +$companionGapMutationTokens = $null +$companionGapMutationErrors = $null +$companionGapMutationAst = [System.Management.Automation.Language.Parser]::ParseInput( + $companionGapPolicyLineRegex.Replace($verifyText, '', 1), + [ref]$companionGapMutationTokens, [ref]$companionGapMutationErrors) +if ($companionGapMutationErrors.Count -ne 0) { + throw 'Companion gap required-file mutation did not remain parseable.' +} +try { + Assert-AuthorityDocumentsAdmitted -Ast $companionGapMutationAst + throw 'Companion gap required-file mutation canary survived.' +} catch { + if ($_.Exception.Message -eq 'Companion gap required-file mutation canary survived.') { throw } +} $releaseEligibleIfStatements = @($verifyAst.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.IfStatementAst] -and $_.Clauses.Count -eq 1 -and diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 7b2a9e68..596ae360 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -1575,6 +1575,7 @@ $requiredFiles = @( "provenance/pages.yml", "docs/BRAIN_MODEL.md", "docs/COMPANION_CROSS_PLATFORM_PLAN.md", + "docs/COMPANION_APP_GAP_ANALYSIS.md", "docs/CONVERSATION_V2_ROADMAP.md", "docs/CHARACTER_LOCK.md", "docs/CREATING_PERSONAS.md", From 96d5c3693560719bc625c741599e74cf2c37805a Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 12:43:47 -0400 Subject: [PATCH 38/46] Preserve native verifier exit evidence --- docs/FIRST_DEPLOY_STATUS.md | 36 ++ ...elease_package_verifier_trust_contract.ps1 | 529 +++++++++++++++++- ...release_toolchain_integration_contract.ps1 | 139 ++++- tools/verify_release_package.ps1 | 177 +++++- 4 files changed, 843 insertions(+), 38 deletions(-) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index e5121d17..bdf950fd 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -454,6 +454,42 @@ the copy. The count and path allowlists remain fail closed for undeclared files. remains in force until this correction passes the broad contracts, exact-head CI, and a fresh governed package plus independent verification. +Qualification head `611f609f8df1e4608a9249314eeff9ae3e8845c2` passed all 11 jobs on the first +attempt in exact-head GitHub Firmware run `31017406734`. Governed packaging for +`sec-002-611f609f` completed two clean cycles and exact size/SHA-256 comparison of all 15 artifact +pairs. The public `stackchan_release_full` firmware from both cycles is 2,803,504 bytes with +SHA-256 `8FF304E88F7B03A114D96B2ADB16D3475B7508148FAF3A025524916935CC2E76`. The provisional +189,657,088-byte ZIP has SHA-256 +`18530C8AC81FEC49618BD56BA734EBC0F6D088DCBBA85C6A42969A4177B8F2A1`. Packaging passed the broad +contracts and credential hygiene before package assembly. The independent verifier passed +voice/RVC, preview media, face phases A-E, and release assets, then failed closed at +`verify_release_package.ps1:1177` during the fresh exact-commit rebuild's first dependency-staging +command. Windows PowerShell 5.1 promoted +Git's ordinary `Cloning into ...` stderr progress to a terminating `RemoteException` under the +verifier's global `Stop` policy, before PlatformIO's native exit code or dependency-stage log could +be captured. The PlatformIO result is therefore unknown and must not be classified as a dependency +or network failure. + +Failure evidence under +`output/private/operational-firmware-rebuilds/20260805-160150-528-146af296067c` also incorrectly +recorded `failed-worktree-not-preserved`: Git listed the generated worktree with forward slashes, +while the catch path compared it literally and case-sensitively against the backslash path. The +clean detached worktree actually remains registered at `D:\sc-vrfy-0000000528-c19e1066` on exact +head `611f609f8df1e4608a9249314eeff9ae3e8845c2`. The provisional ZIP and output under +`output/release/sec-002-611f609f` are not candidates. No flash, OTA request, COM access, bridge +session, robot-port access, or actuator command occurred. + +The correction routes all five captured PlatformIO sites through one narrowly scoped native +stderr collector, restores the global `Stop` policy in `finally`, and keeps native exit codes as the +only process authority. Captured process logs are persisted before post-command lease assertions, +and AST plus behavioral mutation contracts bind all five call sites to the reviewed executable, +argument, result, output consumer, exit, and unconditional-failure topology. It canonicalizes Git worktree paths +with OS-appropriate comparison, requires one exact registration, records an explicit +preservation-unknown state when the probe itself cannot be trusted, retains richer exception +evidence, and uses a context-preserving bare rethrow. The SEC-002 hold remains in force until this +correction passes the broad contracts, exact-head CI, and a fresh governed package plus independent +verification. + A private full-SPI-flash backup captured on 2026-08-02 is preserved under ignored `output/private/firmware-backups/20260802-233346-COM4`. Three 16 MiB reads match at SHA-256 `036828305B8204A73205143591CB5029B0177A0C9E62050D3A7A8C8D3A9538AE`. Offline parsing shows that diff --git a/tools/test_release_package_verifier_trust_contract.ps1 b/tools/test_release_package_verifier_trust_contract.ps1 index 8db6c6a1..1b3cd469 100644 --- a/tools/test_release_package_verifier_trust_contract.ps1 +++ b/tools/test_release_package_verifier_trust_contract.ps1 @@ -912,6 +912,108 @@ foreach ($gitBootstrapMarker in @( } } +$nativeCaptureFunctions = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Invoke-StackchanVerifierNativeCapture' +}, $true)) +if ($nativeCaptureFunctions.Count -ne 1) { + throw 'Operational verifier native-capture helper is ambiguous.' +} +$nativeCaptureText = $nativeCaptureFunctions[0].Extent.Text +foreach ($marker in @( + '$previousErrorActionPreference = $ErrorActionPreference', + '$ErrorActionPreference = ''Continue''', + '$global:LASTEXITCODE = $nativeExitSentinel', + '$nativeExitCode = [int]$global:LASTEXITCODE', + '$ErrorActionPreference = $previousErrorActionPreference', + '$nativeExitCode -eq $nativeExitSentinel', + 'output = @($nativeOutput | ForEach-Object { [string]$_ })', + 'exitCode = $nativeExitCode' +)) { + if (-not $nativeCaptureText.Contains($marker)) { + throw "Operational verifier native capture is missing: $marker" + } +} +. ([scriptblock]::Create($nativeCaptureText)) +$savedNativeCaptureErrorPreference = $ErrorActionPreference +try { + $ErrorActionPreference = 'Stop' + $nativeFixtureExecutable = (Get-Process -Id $PID).Path + $nativeSuccess = Invoke-StackchanVerifierNativeCapture ` + -Executable $nativeFixtureExecutable ` + -Arguments @('-NoProfile', '-NonInteractive', '-Command', + "[Console]::Out.WriteLine('native-stdout'); [Console]::Error.WriteLine('Cloning into fixture...'); exit 0") + $nativeSuccessText = @($nativeSuccess.output) -join "`n" + if ([int]$nativeSuccess.exitCode -ne 0 -or + $nativeSuccessText -notmatch 'native-stdout' -or + $nativeSuccessText -notmatch 'Cloning into fixture' -or + $ErrorActionPreference -cne 'Stop') { + throw 'Operational verifier native capture did not preserve successful stderr and exit authority.' + } + $nativeFailure = Invoke-StackchanVerifierNativeCapture ` + -Executable $nativeFixtureExecutable ` + -Arguments @('-NoProfile', '-NonInteractive', '-Command', + "[Console]::Error.WriteLine('native-failure-stderr'); exit 23") + if ([int]$nativeFailure.exitCode -ne 23 -or + (@($nativeFailure.output) -join "`n") -notmatch 'native-failure-stderr' -or + $ErrorActionPreference -cne 'Stop') { + throw 'Operational verifier native capture lost nonzero exit or stderr evidence.' + } +} finally { + $ErrorActionPreference = $savedNativeCaptureErrorPreference + Remove-Item Function:\Invoke-StackchanVerifierNativeCapture -ErrorAction SilentlyContinue +} + +$worktreeListFunctions = @($verifyAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Test-StackchanVerifierWorktreeListContains' +}, $true)) +if ($worktreeListFunctions.Count -ne 1) { + throw 'Operational verifier worktree-list helper is ambiguous.' +} +$worktreeListText = $worktreeListFunctions[0].Extent.Text +foreach ($marker in @( + '[System.IO.Path]::GetFullPath($ExpectedPath)', + "StartsWith('worktree ', [System.StringComparison]::Ordinal)", + '[System.StringComparison]::OrdinalIgnoreCase', + 'return $matches -eq 1' +)) { + if (-not $worktreeListText.Contains($marker)) { + throw "Operational verifier worktree-list normalization is missing: $marker" + } +} +. ([scriptblock]::Create($worktreeListText)) +try { + $worktreeFixturePath = if ($env:OS -eq 'Windows_NT') { + Join-Path ([System.IO.Path]::GetPathRoot($repoRoot)) 'sc-vrfy-path-contract' + } else { + Join-Path ([System.IO.Path]::GetTempPath()) 'sc-vrfy-path-contract' + } + $worktreeFixtureGitPath = $worktreeFixturePath.Replace('\', '/') + if ($env:OS -eq 'Windows_NT') { + $worktreeFixtureGitPath = $worktreeFixtureGitPath.ToUpperInvariant() + } + if (-not (Test-StackchanVerifierWorktreeListContains ` + -PorcelainLines @("worktree $worktreeFixtureGitPath", 'HEAD 1111111') ` + -ExpectedPath $worktreeFixturePath)) { + throw 'Operational verifier did not match an equivalent Git worktree path.' + } + if (Test-StackchanVerifierWorktreeListContains ` + -PorcelainLines @("worktree $worktreeFixtureGitPath-neighbor") ` + -ExpectedPath $worktreeFixturePath) { + throw 'Operational verifier matched a neighboring Git worktree path.' + } + if (Test-StackchanVerifierWorktreeListContains ` + -PorcelainLines @("worktree $worktreeFixtureGitPath", "worktree $worktreeFixtureGitPath") ` + -ExpectedPath $worktreeFixturePath) { + throw 'Operational verifier accepted an ambiguous duplicate worktree registration.' + } +} finally { + Remove-Item Function:\Test-StackchanVerifierWorktreeListContains -ErrorAction SilentlyContinue +} + $operationalRebuildFunctions = @($verifyAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and @@ -922,6 +1024,259 @@ if ($operationalRebuildFunctions.Count -ne 1) { } $operationalRebuild = $operationalRebuildFunctions[0] $operationalRebuildText = $operationalRebuild.Extent.Text + +function ConvertTo-OperationalContractText { + param([Parameter(Mandatory = $true)][string]$Text) + return [regex]::Replace($Text.Trim(), '\s+', ' ') +} + +function Get-OperationalCaptureSpecifications { + return @( + [ordered]@{ + result = '$pioVersionResult'; arguments = "@('--version')" + consumer = '$pioVersion' + consumerRhs = '((@($pioVersionResult.output) | Out-String).Trim())' + exit = '$pioVersionExit'; condition = + '$pioVersionExit -ne 0 -or $pioVersion -cne ''PlatformIO Core, version 6.1.19'' -or [string]$dependencyLock.platformioCore -cne $pioVersion' + throwText = 'throw "Operational independent rebuild requires the packaged PlatformIO Core 6.1.19 identity."' + output = $null; logLeaf = $null + postContext = '-Context ''after PlatformIO version execution''' + }, + [ordered]@{ + result = '$dependencyStageResult' + arguments = "@('pkg', 'install', '-d', `$rebuildWorktree, '-e', `$environment)" + consumer = '$dependencyStageOutput' + consumerRhs = '@($dependencyStageResult.output)' + exit = '$dependencyStageExit'; condition = '$dependencyStageExit -ne 0' + throwText = 'throw "Operational dependency staging failed: $environment (exit $dependencyStageExit)."' + output = '$dependencyStageOutput'; logLeaf = '"$environment-dependency-stage.log"' + postContext = '-Context "after $environment dependency staging"' + }, + [ordered]@{ + result = '$phaseResult'; arguments = '$pioArguments' + consumer = '$phaseOutput'; consumerRhs = '@($phaseResult.output)' + exit = '$phaseExit'; condition = '$phaseExit -ne 0' + throwText = 'throw "Operational independent firmware rebuild failed: $environment/$phase (exit $phaseExit)."' + output = '$phaseOutput'; logLeaf = '"$environment-$phase.log"' + postContext = '-Context "after $environment $phase"' + }, + [ordered]@{ + result = '$packageListResult' + arguments = "@('pkg', 'list', '-d', `$rebuildWorktree, '-e', `$environment)" + consumer = '$packageListOutput' + consumerRhs = '@($packageListResult.output)' + exit = '$packageListExit'; condition = '$packageListExit -ne 0' + throwText = 'throw "Operational independent dependency inventory failed: $environment (exit $packageListExit)."' + output = '$packageListOutput'; logLeaf = '"$environment-pkg-list.log"' + postContext = '-Context "after $environment dependency inventory"' + }, + [ordered]@{ + result = '$verbosePackageResult' + arguments = "@('pkg', 'list', '-d', `$rebuildWorktree, '-e', `$environment, '-v')" + consumer = '$verbosePackageOutput' + consumerRhs = '@($verbosePackageResult.output)' + exit = '$verbosePackageExit'; condition = '$verbosePackageExit -ne 0' + throwText = 'throw "Operational independent verbose dependency inventory failed: $environment (exit $verbosePackageExit)."' + output = '$verbosePackageOutput'; logLeaf = '"$environment-pkg-list-verbose.log"' + postContext = '-Context "after $environment verbose dependency inventory"' + } + ) +} + +function Assert-OperationalPioCaptureTopology { + param( + [Parameter(Mandatory = $true)] + [System.Management.Automation.Language.FunctionDefinitionAst]$FunctionAst + ) + + $rawPioInvocations = @($FunctionAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.InvocationOperator -eq [System.Management.Automation.Language.TokenKind]::Ampersand -and + $node.CommandElements.Count -gt 0 -and + $node.CommandElements[0].Extent.Text -ceq '$pioExecutable' + }, $true)) + if ($rawPioInvocations.Count -ne 0) { + throw 'Operational independent rebuild bypasses the governed native-capture helper.' + } + $captureCalls = @($FunctionAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-StackchanVerifierNativeCapture' + }, $true)) + $specifications = @(Get-OperationalCaptureSpecifications) + if ($captureCalls.Count -ne $specifications.Count) { + throw "Operational independent rebuild must contain five governed PlatformIO capture sites; got $($captureCalls.Count)." + } + + $allAssignments = @($FunctionAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] + }, $true)) + $allIfStatements = @($FunctionAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.IfStatementAst] + }, $true)) + $allCommands = @($FunctionAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] + }, $true)) + $allPipelines = @($FunctionAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.PipelineAst] + }, $true)) + + foreach ($specification in $specifications) { + $boundCalls = @($captureCalls | Where-Object { + $_.Parent -is [System.Management.Automation.Language.PipelineAst] -and + $_.Parent.Parent -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $_.Parent.Parent.Left.Extent.Text -ceq [string]$specification.result + }) + if ($boundCalls.Count -ne 1) { + throw "Operational capture is not bound to its reviewed result: $($specification.result)" + } + $call = $boundCalls[0] + $assignment = $call.Parent.Parent + $elements = @($call.CommandElements) + if ($assignment.Operator -ne [System.Management.Automation.Language.TokenKind]::Equals -or + $assignment.Right -ne $call.Parent -or $elements.Count -ne 5 -or + $elements[1] -isnot [System.Management.Automation.Language.CommandParameterAst] -or + $elements[1].ParameterName -cne 'Executable' -or + $elements[2] -isnot [System.Management.Automation.Language.VariableExpressionAst] -or + $elements[2].VariablePath.UserPath -cne 'pioExecutable' -or + $elements[3] -isnot [System.Management.Automation.Language.CommandParameterAst] -or + $elements[3].ParameterName -cne 'Arguments' -or + (ConvertTo-OperationalContractText $elements[4].Extent.Text) -cne + (ConvertTo-OperationalContractText ([string]$specification.arguments))) { + throw "Operational capture executable/arguments differ from reviewed topology: $($specification.result)" + } + + $resultAssignments = @($allAssignments | Where-Object { + $_.Left.Extent.Text -ceq [string]$specification.result + }) + $consumerAssignments = @($allAssignments | Where-Object { + $_.Left.Extent.Text -ceq [string]$specification.consumer + }) + if ($resultAssignments.Count -ne 1 -or + $resultAssignments[0].Extent.StartOffset -ne $assignment.Extent.StartOffset -or + $consumerAssignments.Count -ne 1 -or + $consumerAssignments[0].Operator -ne + [System.Management.Automation.Language.TokenKind]::Equals -or + (ConvertTo-OperationalContractText $consumerAssignments[0].Right.Extent.Text) -cne + [string]$specification.consumerRhs -or + $assignment.Extent.StartOffset -ge $consumerAssignments[0].Extent.StartOffset) { + throw "Operational capture output is not uniquely bound to its reviewed consumer: $($specification.result)" + } + + $exitAssignments = @($allAssignments | Where-Object { + $_.Left.Extent.Text -ceq [string]$specification.exit + }) + $expectedExitExpression = "[int]$([string]$specification.result).exitCode" + if ($exitAssignments.Count -ne 1 -or + $exitAssignments[0].Operator -ne [System.Management.Automation.Language.TokenKind]::Equals -or + (ConvertTo-OperationalContractText $exitAssignments[0].Right.Extent.Text) -cne + $expectedExitExpression -or + $consumerAssignments[0].Extent.StartOffset -ge $exitAssignments[0].Extent.StartOffset) { + throw "Operational capture exit is not bound to its reviewed result: $($specification.exit)" + } + + $gates = @($allIfStatements | Where-Object { + $_.Clauses.Count -eq 1 -and + (ConvertTo-OperationalContractText $_.Clauses[0].Item1.Extent.Text) -ceq + [string]$specification.condition + }) + if ($gates.Count -ne 1 -or $null -ne $gates[0].ElseClause -or + $gates[0].Clauses[0].Item2.Statements.Count -ne 1 -or + $gates[0].Clauses[0].Item2.Statements[0] -isnot + [System.Management.Automation.Language.ThrowStatementAst] -or + (ConvertTo-OperationalContractText ` + $gates[0].Clauses[0].Item2.Statements[0].Extent.Text) -cne + [string]$specification.throwText -or + $exitAssignments[0].Extent.StartOffset -ge $gates[0].Extent.StartOffset) { + throw "Operational capture nonzero result is not guarded by its reviewed unconditional throw: $($specification.exit)" + } + + $postLeaseCommands = @($allCommands | Where-Object { + $_.GetCommandName() -ceq 'Assert-StackchanToolchainLeaseStateUnchanged' -and + (ConvertTo-OperationalContractText $_.Extent.Text).Contains( + [string]$specification.postContext) + }) + if ($postLeaseCommands.Count -ne 1 -or + $exitAssignments[0].Extent.StartOffset -ge $postLeaseCommands[0].Extent.StartOffset -or + $postLeaseCommands[0].Extent.StartOffset -ge $gates[0].Extent.StartOffset) { + throw "Operational capture post-command lease/gate ordering is not reviewed: $($specification.result)" + } + + if ($null -ne $specification.output) { + $logPipelines = @($allPipelines | Where-Object { + $_.PipelineElements.Count -eq 2 -and + $_.PipelineElements[0].Extent.Text -ceq [string]$specification.output -and + $_.PipelineElements[1] -is [System.Management.Automation.Language.CommandAst] -and + $_.PipelineElements[1].GetCommandName() -ceq 'Set-Content' + }) + if ($logPipelines.Count -ne 1) { + throw "Operational capture log pipeline is not uniquely bound: $($specification.result)" + } + $setContentElements = @($logPipelines[0].PipelineElements[1].CommandElements) + $joinPathCommands = @(if ($setContentElements.Count -eq 5) { + $setContentElements[2].FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Join-Path' + }, $true) + }) + if ($setContentElements.Count -ne 5 -or + $setContentElements[1] -isnot + [System.Management.Automation.Language.CommandParameterAst] -or + $setContentElements[1].ParameterName -cne 'LiteralPath' -or + $setContentElements[3] -isnot + [System.Management.Automation.Language.CommandParameterAst] -or + $setContentElements[3].ParameterName -cne 'Encoding' -or + $setContentElements[4].Extent.Text -cne 'UTF8' -or + $joinPathCommands.Count -ne 1 -or + $joinPathCommands[0].CommandElements.Count -ne 3 -or + $joinPathCommands[0].CommandElements[1] -isnot + [System.Management.Automation.Language.VariableExpressionAst] -or + $joinPathCommands[0].CommandElements[1].VariablePath.UserPath -cne + 'rebuildEvidenceRoot' -or + $joinPathCommands[0].CommandElements[2].Extent.Text -cne + [string]$specification.logLeaf -or + $exitAssignments[0].Extent.StartOffset -ge $logPipelines[0].Extent.StartOffset -or + $logPipelines[0].Extent.StartOffset -ge $postLeaseCommands[0].Extent.StartOffset) { + throw "Operational capture log is not retained before the post-command lease assertion: $($specification.result)" + } + } + } + + $pioArgumentAssignments = @($allAssignments | Where-Object { + $_.Left.Extent.Text -ceq '$pioArguments' + }) + $pioArgumentBase = @($pioArgumentAssignments | Where-Object { + $_.Operator -eq [System.Management.Automation.Language.TokenKind]::Equals -and + (ConvertTo-OperationalContractText $_.Right.Extent.Text) -ceq + "@('run', '-d', `$rebuildWorktree, '-e', `$environment)" + }) + $pioArgumentClean = @($pioArgumentAssignments | Where-Object { + $_.Operator -eq [System.Management.Automation.Language.TokenKind]::PlusEquals -and + (ConvertTo-OperationalContractText $_.Right.Extent.Text) -ceq "@('-t', 'clean')" + }) + $phaseLoops = @($FunctionAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.ForEachStatementAst] -and + $node.Variable.VariablePath.UserPath -ceq 'phase' -and + (ConvertTo-OperationalContractText $node.Condition.Extent.Text) -ceq "@('clean', 'build')" + }, $true)) + if ($pioArgumentAssignments.Count -ne 2 -or $pioArgumentBase.Count -ne 1 -or + $pioArgumentClean.Count -ne 1 -or $phaseLoops.Count -ne 1 -or + $pioArgumentBase[0].Extent.StartOffset -lt $phaseLoops[0].Extent.StartOffset -or + $pioArgumentBase[0].Extent.EndOffset -gt $phaseLoops[0].Extent.EndOffset -or + $pioArgumentClean[0].Extent.StartOffset -lt $phaseLoops[0].Extent.StartOffset -or + $pioArgumentClean[0].Extent.EndOffset -gt $phaseLoops[0].Extent.EndOffset) { + throw 'Operational clean/build PlatformIO argument construction is not the reviewed loop topology.' + } +} + +Assert-OperationalPioCaptureTopology -FunctionAst $operationalRebuild $expectedOperationalArtifacts = @( 'firmware.bin', 'firmware.elf', 'bootloader.bin', 'partitions.bin', 'boot_app0.bin' ) @@ -953,13 +1308,13 @@ foreach ($requiredRebuildMarker in @( '`$pioExecutable = `$resolvedPlatformioExecutable', "`$pioVersion -cne 'PlatformIO Core, version 6.1.19'", '[string]$dependencyLock.platformioCore -cne $pioVersion', - "@(& `$pioExecutable 'pkg' 'list' '-d' `$rebuildWorktree '-e' `$environment 2>&1)", + '$packageListResult = Invoke-StackchanVerifierNativeCapture', 'Compare-Object -ReferenceObject $expectedDependencyIdentity', '-DifferenceObject $actualDependencyIdentity -CaseSensitive', 'Get-StackchanVerbosePlatformSource', '[string]$spec.coreDir', '[string]$expectedEnvironmentLock.platformSourceLeaf', - "@(& `$pioExecutable 'pkg' 'install' '-d' `$rebuildWorktree '-e' `$environment 2>&1)", + '$dependencyStageResult = Invoke-StackchanVerifierNativeCapture', 'Assert-StackchanReleaseToolchainIdentity', '-Phase PostBuild -Environment $environment', 'Assert-StackchanReleaseBuildPythonEnvironment' @@ -970,6 +1325,159 @@ foreach ($requiredRebuildMarker in @( } } +function New-OperationalAstMutation { + param( + [Parameter(Mandatory = $true)][string]$SourceText, + [Parameter(Mandatory = $true)] + [System.Management.Automation.Language.FunctionDefinitionAst]$SourceFunction, + [Parameter(Mandatory = $true)] + [System.Management.Automation.Language.IScriptExtent]$TargetExtent, + [AllowEmptyString()][string]$Replacement + ) + + $relativeStart = $TargetExtent.StartOffset - $SourceFunction.Extent.StartOffset + if ($relativeStart -lt 0 -or + $relativeStart + $TargetExtent.Text.Length -gt $SourceText.Length) { + throw 'Operational mutation target is outside the independent-rebuild function.' + } + return $SourceText.Substring(0, $relativeStart) + $Replacement + + $SourceText.Substring($relativeStart + $TargetExtent.Text.Length) +} + +function Assert-OperationalMutationRejected { + param( + [Parameter(Mandatory = $true)][string]$Text, + [Parameter(Mandatory = $true)][string]$Label + ) + + $mutationTokens = $null + $mutationParseErrors = $null + $mutationAst = [System.Management.Automation.Language.Parser]::ParseInput( + $Text, [ref]$mutationTokens, [ref]$mutationParseErrors) + if (@($mutationParseErrors).Count -ne 0) { + throw "Operational mutation fixture is not valid PowerShell: $Label" + } + $mutationFunctions = @($mutationAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Assert-OperationalFirmwareMatchesTrustedRebuild' + }, $true)) + if ($mutationFunctions.Count -ne 1) { + throw "Operational mutation fixture lost its independent-rebuild function: $Label" + } + $mutationAccepted = $false + try { + Assert-OperationalPioCaptureTopology -FunctionAst $mutationFunctions[0] + $mutationAccepted = $true + } catch { + $mutationAccepted = $false + } + if ($mutationAccepted) { + throw "Operational PlatformIO authority mutation survived: $Label" + } +} + +$baselineCaptureCalls = @($operationalRebuild.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-StackchanVerifierNativeCapture' +}, $true)) +$baselineAssignments = @($operationalRebuild.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] +}, $true)) +$baselineIfStatements = @($operationalRebuild.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.IfStatementAst] +}, $true)) +$baselinePipelines = @($operationalRebuild.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.PipelineAst] +}, $true)) +foreach ($specification in @(Get-OperationalCaptureSpecifications)) { + $call = @($baselineCaptureCalls | Where-Object { + $_.Parent.Parent -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $_.Parent.Parent.Left.Extent.Text -ceq [string]$specification.result + })[0] + $exitAssignment = @($baselineAssignments | Where-Object { + $_.Left.Extent.Text -ceq [string]$specification.exit + })[0] + $consumerAssignment = @($baselineAssignments | Where-Object { + $_.Left.Extent.Text -ceq [string]$specification.consumer + })[0] + $gate = @($baselineIfStatements | Where-Object { + $_.Clauses.Count -eq 1 -and + (ConvertTo-OperationalContractText $_.Clauses[0].Item1.Extent.Text) -ceq + [string]$specification.condition + })[0] + $throwStatement = $gate.Clauses[0].Item2.Statements[0] + $mutations = @( + [ordered]@{ label = "$($specification.result) executable"; extent = $call.CommandElements[2].Extent; replacement = '$resolvedPythonExecutable' }, + [ordered]@{ label = "$($specification.result) arguments"; extent = $call.CommandElements[4].Extent; replacement = "@('contract-mutation')" }, + [ordered]@{ label = "$($specification.result) binding"; extent = $call.Parent.Parent.Left.Extent; replacement = '$contractMutationResult' }, + [ordered]@{ label = "$($specification.result) consumer"; extent = $consumerAssignment.Right.Extent; replacement = "@('contract-mutation')" }, + [ordered]@{ label = "$($specification.exit) binding"; extent = $exitAssignment.Right.Extent; replacement = '0' }, + [ordered]@{ label = "$($specification.exit) throw replacement"; extent = $throwStatement.Extent; replacement = "`$null = 'contract-mutation'" }, + [ordered]@{ label = "$($specification.exit) throw removal"; extent = $throwStatement.Extent; replacement = '' } + ) + foreach ($mutation in $mutations) { + $mutationText = New-OperationalAstMutation ` + -SourceText $operationalRebuildText -SourceFunction $operationalRebuild ` + -TargetExtent $mutation.extent -Replacement ([string]$mutation.replacement) + Assert-OperationalMutationRejected -Text $mutationText -Label ([string]$mutation.label) + } + $duplicateResultReplacement = + ([string]$specification.result) + " = `$null`r`n" + $call.Parent.Parent.Extent.Text + $mutationText = New-OperationalAstMutation ` + -SourceText $operationalRebuildText -SourceFunction $operationalRebuild ` + -TargetExtent $call.Parent.Parent.Extent -Replacement $duplicateResultReplacement + Assert-OperationalMutationRejected -Text $mutationText ` + -Label "$($specification.result) duplicate preinitialization" + + $moveStart = $call.Parent.Parent.Extent.StartOffset - + $operationalRebuild.Extent.StartOffset + $moveEnd = $exitAssignment.Extent.EndOffset - $operationalRebuild.Extent.StartOffset + $movedExitReplacement = $exitAssignment.Extent.Text + "`r`n" + + $call.Parent.Parent.Extent.Text + "`r`n" + $consumerAssignment.Extent.Text + $mutationText = $operationalRebuildText.Substring(0, $moveStart) + + $movedExitReplacement + $operationalRebuildText.Substring($moveEnd) + Assert-OperationalMutationRejected -Text $mutationText ` + -Label "$($specification.exit) before capture" + + if ($null -ne $specification.output) { + $logPipeline = @($baselinePipelines | Where-Object { + $_.PipelineElements.Count -eq 2 -and + $_.PipelineElements[0].Extent.Text -ceq [string]$specification.output -and + $_.PipelineElements[1] -is [System.Management.Automation.Language.CommandAst] -and + $_.PipelineElements[1].GetCommandName() -ceq 'Set-Content' + })[0] + $mutationText = New-OperationalAstMutation ` + -SourceText $operationalRebuildText -SourceFunction $operationalRebuild ` + -TargetExtent $logPipeline.Extent -Replacement "`$null = 'contract-mutation'" + Assert-OperationalMutationRejected -Text $mutationText ` + -Label "$($specification.result) pre-lease log" + } +} + +$phaseLoop = @($operationalRebuild.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.ForEachStatementAst] -and + $node.Variable.VariablePath.UserPath -ceq 'phase' +}, $true))[0] +$pioArgumentAssignments = @($baselineAssignments | Where-Object { + $_.Left.Extent.Text -ceq '$pioArguments' +}) +foreach ($mutation in @( + [ordered]@{ label = 'phase inventory'; extent = $phaseLoop.Condition.Extent; replacement = "@('build')" }, + [ordered]@{ label = 'run arguments'; extent = $pioArgumentAssignments[0].Right.Extent; replacement = "@('run')" }, + [ordered]@{ label = 'clean arguments'; extent = $pioArgumentAssignments[1].Right.Extent; replacement = "@('-t', 'upload')" } +)) { + $mutationText = New-OperationalAstMutation ` + -SourceText $operationalRebuildText -SourceFunction $operationalRebuild ` + -TargetExtent $mutation.extent -Replacement ([string]$mutation.replacement) + Assert-OperationalMutationRejected -Text $mutationText -Label ([string]$mutation.label) +} + $evidenceParentAssignments = @($operationalRebuild.FindAll({ param($node) $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and @@ -1025,14 +1533,25 @@ if ($operationalReturns.Count -ne 1 -or foreach ($failureProbeMarker in @( '$worktreePathExists = Test-Path -LiteralPath $rebuildWorktree -PathType Container', "'worktree', 'list', '--porcelain'", - '$_ -ceq "worktree $rebuildWorktree"', - '$worktreePreserved = $worktreePathExists -and $worktreeAttached', - "status = if (`$worktreePreserved) { 'failed-full-worktree-preserved' } else { 'failed-worktree-not-preserved' }" + '$ErrorActionPreference = ''Continue''', + '$worktreeProbeExitCode = [int]$global:LASTEXITCODE', + 'Test-StackchanVerifierWorktreeListContains', + '$worktreePreserved = $worktreePathExists -and $worktreeAttached -eq $true', + "'failed-worktree-preservation-unknown'", + 'worktreeProbeExitCode = $worktreeProbeExitCode', + 'worktreeProbeError = $worktreeProbeError', + 'exceptionType = [string]$failure.Exception.GetType().FullName', + 'fullyQualifiedErrorId = [string]$failure.FullyQualifiedErrorId', + 'scriptStackTrace = [string]$failure.ScriptStackTrace' )) { if (-not $operationalRebuildText.Contains($failureProbeMarker)) { throw "Operational rebuild failure status is not derived from actual worktree probes: $failureProbeMarker" } } +if ($operationalRebuildText.Contains('$_ -ceq "worktree $rebuildWorktree"') -or + $operationalRebuildText.Contains('throw $failure.Exception')) { + throw 'Operational rebuild retains separator-sensitive probing or context-stripping rethrow.' +} $publicVerifierGuards = @($packageAst.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.IfStatementAst] -and $_.Clauses.Count -eq 1 -and $_.Clauses[0].Item1.Extent.Text -eq '-not $SkipBuild' -and diff --git a/tools/test_release_toolchain_integration_contract.ps1 b/tools/test_release_toolchain_integration_contract.ps1 index 7779efda..8576940b 100644 --- a/tools/test_release_toolchain_integration_contract.ps1 +++ b/tools/test_release_toolchain_integration_contract.ps1 @@ -241,11 +241,140 @@ foreach ($needle in @( 'Assert-OperationalFirmwareMatchesTrustedRebuild')) { Require-Text $verifierText $needle "Verifier does not independently enforce toolchain proof: $needle" } -Require-Order $verifierText 'Assert-StackchanReleaseBuildPythonEnvironment -ProjectRoot $rebuildWorktree' ` - '$pioExecutable --version' ` - 'Verifier executes PlatformIO before establishing the approved build-Python environment.' -Require-Order $verifierText '$pioExecutable --version' "& `$pioExecutable 'pkg' 'install'" ` - 'Verifier does not validate the exact PlatformIO launcher before dependency staging.' +$verifierTokens = $null +$verifierParseErrors = $null +$verifierAst = [System.Management.Automation.Language.Parser]::ParseFile( + $verifierPath, [ref]$verifierTokens, [ref]$verifierParseErrors) +if (@($verifierParseErrors).Count -ne 0) { + throw 'Release verifier cannot be parsed for PlatformIO execution-order testing.' +} +$operationalRebuildFunctions = @($verifierAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Assert-OperationalFirmwareMatchesTrustedRebuild' +}, $true)) +if ($operationalRebuildFunctions.Count -ne 1) { + throw 'Release verifier must define one operational independent-rebuild function.' +} +$operationalCommands = @($operationalRebuildFunctions[0].Body.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] +}, $true)) +$buildPythonAssertions = @($operationalCommands | Where-Object { + $_.GetCommandName() -ceq 'Assert-StackchanReleaseBuildPythonEnvironment' +}) +$nativeCaptureCalls = @($operationalCommands | Where-Object { + $_.GetCommandName() -ceq 'Invoke-StackchanVerifierNativeCapture' +}) +$captureSpecifications = @( + [ordered]@{ + result = '$pioVersionResult'; arguments = "@('--version')" + consumer = '$pioVersion' + consumerRhs = '((@($pioVersionResult.output) | Out-String).Trim())' + }, + [ordered]@{ + result = '$dependencyStageResult' + arguments = "@('pkg', 'install', '-d', `$rebuildWorktree, '-e', `$environment)" + consumer = '$dependencyStageOutput'; consumerRhs = '@($dependencyStageResult.output)' + }, + [ordered]@{ + result = '$phaseResult'; arguments = '$pioArguments' + consumer = '$phaseOutput'; consumerRhs = '@($phaseResult.output)' + }, + [ordered]@{ + result = '$packageListResult' + arguments = "@('pkg', 'list', '-d', `$rebuildWorktree, '-e', `$environment)" + consumer = '$packageListOutput'; consumerRhs = '@($packageListResult.output)' + }, + [ordered]@{ + result = '$verbosePackageResult' + arguments = "@('pkg', 'list', '-d', `$rebuildWorktree, '-e', `$environment, '-v')" + consumer = '$verbosePackageOutput'; consumerRhs = '@($verbosePackageResult.output)' + } +) +if ($buildPythonAssertions.Count -ne 1 -or + $nativeCaptureCalls.Count -ne $captureSpecifications.Count) { + throw 'Verifier operational PlatformIO execution topology is not the reviewed five-site shape.' +} +$boundCaptureCalls = @{} +$operationalAssignments = @($operationalRebuildFunctions[0].Body.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] +}, $true)) +foreach ($specification in $captureSpecifications) { + $matches = @($nativeCaptureCalls | Where-Object { + $_.Parent -is [System.Management.Automation.Language.PipelineAst] -and + $_.Parent.Parent -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $_.Parent.Parent.Left.Extent.Text -ceq [string]$specification.result + }) + if ($matches.Count -ne 1) { + throw "Verifier PlatformIO capture is not bound to its reviewed result: $($specification.result)" + } + $call = $matches[0] + $elements = @($call.CommandElements) + if ($call.Parent.Parent.Operator -ne + [System.Management.Automation.Language.TokenKind]::Equals -or + $call.Parent.Parent.Right -ne $call.Parent -or $elements.Count -ne 5 -or + $elements[1] -isnot [System.Management.Automation.Language.CommandParameterAst] -or + $elements[1].ParameterName -cne 'Executable' -or + $elements[2] -isnot [System.Management.Automation.Language.VariableExpressionAst] -or + $elements[2].VariablePath.UserPath -cne 'pioExecutable' -or + $elements[3] -isnot [System.Management.Automation.Language.CommandParameterAst] -or + $elements[3].ParameterName -cne 'Arguments' -or + [regex]::Replace($elements[4].Extent.Text.Trim(), '\s+', ' ') -cne + [string]$specification.arguments) { + throw "Verifier PlatformIO executable/arguments differ from reviewed topology: $($specification.result)" + } + $resultAssignments = @($operationalAssignments | Where-Object { + $_.Left.Extent.Text -ceq [string]$specification.result + }) + $consumerAssignments = @($operationalAssignments | Where-Object { + $_.Left.Extent.Text -ceq [string]$specification.consumer + }) + if ($resultAssignments.Count -ne 1 -or + $resultAssignments[0].Extent.StartOffset -ne $call.Parent.Parent.Extent.StartOffset -or + $consumerAssignments.Count -ne 1 -or + $consumerAssignments[0].Operator -ne + [System.Management.Automation.Language.TokenKind]::Equals -or + [regex]::Replace($consumerAssignments[0].Right.Extent.Text.Trim(), '\s+', ' ') -cne + [string]$specification.consumerRhs -or + $call.Extent.StartOffset -ge $consumerAssignments[0].Extent.StartOffset) { + throw "Verifier PlatformIO output is not uniquely bound to its reviewed consumer: $($specification.result)" + } + $boundCaptureCalls[[string]$specification.result] = $call +} +$pioArgumentAssignments = @($operationalRebuildFunctions[0].Body.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$pioArguments' +}, $true)) +$phaseLoops = @($operationalRebuildFunctions[0].Body.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.ForEachStatementAst] -and + $node.Variable.VariablePath.UserPath -ceq 'phase' -and + [regex]::Replace($node.Condition.Extent.Text.Trim(), '\s+', ' ') -ceq + "@('clean', 'build')" +}, $true)) +if ($pioArgumentAssignments.Count -ne 2 -or $phaseLoops.Count -ne 1 -or + @($pioArgumentAssignments | Where-Object { + $_.Operator -eq [System.Management.Automation.Language.TokenKind]::Equals -and + [regex]::Replace($_.Right.Extent.Text.Trim(), '\s+', ' ') -ceq + "@('run', '-d', `$rebuildWorktree, '-e', `$environment)" + }).Count -ne 1 -or + @($pioArgumentAssignments | Where-Object { + $_.Operator -eq [System.Management.Automation.Language.TokenKind]::PlusEquals -and + [regex]::Replace($_.Right.Extent.Text.Trim(), '\s+', ' ') -ceq "@('-t', 'clean')" + }).Count -ne 1) { + throw 'Verifier clean/build PlatformIO argument construction is not the reviewed loop topology.' +} +$pioVersionCall = $boundCaptureCalls['$pioVersionResult'] +$dependencyStageCall = $boundCaptureCalls['$dependencyStageResult'] +if ($buildPythonAssertions[0].Extent.StartOffset -ge $pioVersionCall.Extent.StartOffset) { + throw 'Verifier executes PlatformIO before establishing the approved build-Python environment.' +} +if ($pioVersionCall.Extent.StartOffset -ge $dependencyStageCall.Extent.StartOffset) { + throw 'Verifier does not validate the exact PlatformIO launcher before dependency staging.' +} foreach ($needle in @( 'release-toolchain-identity-policy-source', 'Environment', diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 596ae360..fe14cb13 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -1077,6 +1077,78 @@ function Assert-StackchanVerifierSourceTopology { } } +function Invoke-StackchanVerifierNativeCapture { + param( + [Parameter(Mandatory = $true)][string]$Executable, + [string[]]$Arguments = @() + ) + + if ([string]::IsNullOrWhiteSpace($Executable)) { + throw 'Operational verifier refuses an empty native executable path.' + } + $previousErrorActionPreference = $ErrorActionPreference + $nativeExitSentinel = [int]::MinValue + $nativeExitCode = $nativeExitSentinel + $nativeOutput = @() + try { + # Windows PowerShell surfaces redirected native stderr as ErrorRecord + # objects. Capture those records without letting ordinary progress text + # unwind the verifier, then authorize only from the native exit code. + $ErrorActionPreference = 'Continue' + $global:LASTEXITCODE = $nativeExitSentinel + $nativeOutput = @(& $Executable @Arguments 2>&1) + $nativeExitCode = [int]$global:LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($nativeExitCode -eq $nativeExitSentinel) { + throw "Operational verifier native command did not report an exit code: $Executable" + } + return [pscustomobject]@{ + output = @($nativeOutput | ForEach-Object { [string]$_ }) + exitCode = $nativeExitCode + } +} + +function Test-StackchanVerifierWorktreeListContains { + param( + [Parameter(Mandatory = $true)][string[]]$PorcelainLines, + [Parameter(Mandatory = $true)][string]$ExpectedPath + ) + + try { + $expectedFullPath = [System.IO.Path]::GetFullPath($ExpectedPath).TrimEnd( + [char[]]@([System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar)) + } catch { + return $false + } + $comparison = if ($env:OS -eq 'Windows_NT') { + [System.StringComparison]::OrdinalIgnoreCase + } else { + [System.StringComparison]::Ordinal + } + $matches = 0 + foreach ($line in $PorcelainLines) { + $text = [string]$line + if (-not $text.StartsWith('worktree ', [System.StringComparison]::Ordinal)) { + continue + } + try { + $listedFullPath = [System.IO.Path]::GetFullPath( + $text.Substring('worktree '.Length)).TrimEnd( + [char[]]@([System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar)) + } catch { + continue + } + if ([string]::Equals($listedFullPath, $expectedFullPath, $comparison)) { + $matches++ + } + } + return $matches -eq 1 +} + function Assert-OperationalFirmwareMatchesTrustedRebuild { if (-not $RequireReleaseEligible) { return } @@ -1149,8 +1221,10 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { Assert-StackchanReleaseBuildPythonEnvironment -ProjectRoot $rebuildWorktree Assert-StackchanToolchainLeaseStateUnchanged ` -LeaseState $script:verifierToolchainLeaseState -Context 'before PlatformIO version execution' - $pioVersion = ((@(& $pioExecutable --version 2>&1) | Out-String).Trim()) - $pioVersionExit = $LASTEXITCODE + $pioVersionResult = Invoke-StackchanVerifierNativeCapture ` + -Executable $pioExecutable -Arguments @('--version') + $pioVersion = ((@($pioVersionResult.output) | Out-String).Trim()) + $pioVersionExit = [int]$pioVersionResult.exitCode Assert-StackchanToolchainLeaseStateUnchanged ` -LeaseState $script:verifierToolchainLeaseState -Context 'after PlatformIO version execution' if ($pioVersionExit -ne 0 -or $pioVersion -cne 'PlatformIO Core, version 6.1.19' -or @@ -1174,12 +1248,15 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { New-Item -ItemType Directory -Path $env:PLATFORMIO_BUILD_CACHE_DIR | Out-Null Assert-StackchanToolchainLeaseStateUnchanged ` -LeaseState $script:verifierToolchainLeaseState -Context "before $environment dependency staging" - $dependencyStageOutput = @(& $pioExecutable 'pkg' 'install' '-d' $rebuildWorktree '-e' $environment 2>&1) - $dependencyStageExit = $LASTEXITCODE - Assert-StackchanToolchainLeaseStateUnchanged ` - -LeaseState $script:verifierToolchainLeaseState -Context "after $environment dependency staging" + $dependencyStageResult = Invoke-StackchanVerifierNativeCapture ` + -Executable $pioExecutable ` + -Arguments @('pkg', 'install', '-d', $rebuildWorktree, '-e', $environment) + $dependencyStageOutput = @($dependencyStageResult.output) + $dependencyStageExit = [int]$dependencyStageResult.exitCode $dependencyStageOutput | Set-Content -LiteralPath ( Join-Path $rebuildEvidenceRoot "$environment-dependency-stage.log") -Encoding UTF8 + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "after $environment dependency staging" if ($dependencyStageExit -ne 0) { throw "Operational dependency staging failed: $environment (exit $dependencyStageExit)." } @@ -1207,12 +1284,14 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { if ($phase -eq 'clean') { $pioArguments += @('-t', 'clean') } Assert-StackchanToolchainLeaseStateUnchanged ` -LeaseState $script:verifierToolchainLeaseState -Context "before $environment $phase" - $phaseOutput = @(& $pioExecutable @pioArguments 2>&1) - $phaseExit = $LASTEXITCODE - Assert-StackchanToolchainLeaseStateUnchanged ` - -LeaseState $script:verifierToolchainLeaseState -Context "after $environment $phase" + $phaseResult = Invoke-StackchanVerifierNativeCapture ` + -Executable $pioExecutable -Arguments $pioArguments + $phaseOutput = @($phaseResult.output) + $phaseExit = [int]$phaseResult.exitCode $phaseOutput | Set-Content -LiteralPath ( Join-Path $rebuildEvidenceRoot "$environment-$phase.log") -Encoding UTF8 + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "after $environment $phase" if ($phaseExit -ne 0) { throw "Operational independent firmware rebuild failed: $environment/$phase (exit $phaseExit)." } @@ -1277,12 +1356,15 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { } Assert-StackchanToolchainLeaseStateUnchanged ` -LeaseState $script:verifierToolchainLeaseState -Context "before $environment dependency inventory" - $packageListOutput = @(& $pioExecutable 'pkg' 'list' '-d' $rebuildWorktree '-e' $environment 2>&1) - $packageListExit = $LASTEXITCODE - Assert-StackchanToolchainLeaseStateUnchanged ` - -LeaseState $script:verifierToolchainLeaseState -Context "after $environment dependency inventory" + $packageListResult = Invoke-StackchanVerifierNativeCapture ` + -Executable $pioExecutable ` + -Arguments @('pkg', 'list', '-d', $rebuildWorktree, '-e', $environment) + $packageListOutput = @($packageListResult.output) + $packageListExit = [int]$packageListResult.exitCode $packageListOutput | Set-Content -LiteralPath ( Join-Path $rebuildEvidenceRoot "$environment-pkg-list.log") -Encoding UTF8 + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "after $environment dependency inventory" if ($packageListExit -ne 0) { throw "Operational independent dependency inventory failed: $environment (exit $packageListExit)." } @@ -1306,12 +1388,15 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { } Assert-StackchanToolchainLeaseStateUnchanged ` -LeaseState $script:verifierToolchainLeaseState -Context "before $environment verbose dependency inventory" - $verbosePackageOutput = @(& $pioExecutable 'pkg' 'list' '-d' $rebuildWorktree '-e' $environment '-v' 2>&1) - $verbosePackageExit = $LASTEXITCODE - Assert-StackchanToolchainLeaseStateUnchanged ` - -LeaseState $script:verifierToolchainLeaseState -Context "after $environment verbose dependency inventory" + $verbosePackageResult = Invoke-StackchanVerifierNativeCapture ` + -Executable $pioExecutable ` + -Arguments @('pkg', 'list', '-d', $rebuildWorktree, '-e', $environment, '-v') + $verbosePackageOutput = @($verbosePackageResult.output) + $verbosePackageExit = [int]$verbosePackageResult.exitCode $verbosePackageOutput | Set-Content -LiteralPath ( Join-Path $rebuildEvidenceRoot "$environment-pkg-list-verbose.log") -Encoding UTF8 + Assert-StackchanToolchainLeaseStateUnchanged ` + -LeaseState $script:verifierToolchainLeaseState -Context "after $environment verbose dependency inventory" if ($verbosePackageExit -ne 0) { throw "Operational independent verbose dependency inventory failed: $environment (exit $verbosePackageExit)." } @@ -1395,32 +1480,68 @@ function Assert-OperationalFirmwareMatchesTrustedRebuild { } catch { $failure = $_ $worktreePathExists = Test-Path -LiteralPath $rebuildWorktree -PathType Container - $worktreeList = @(Invoke-TrustedVerifierGit -Arguments @( - '-C', $resolvedVerifierRoot, 'worktree', 'list', '--porcelain') 2>$null) - $worktreeAttached = $false - if ($LASTEXITCODE -eq 0) { - $worktreeAttached = @($worktreeList | Where-Object { - $_ -ceq "worktree $rebuildWorktree" - }).Count -eq 1 + $worktreeProbeExitCode = $null + $worktreeProbeError = $null + $worktreeAttached = $null + try { + $previousProbeErrorActionPreference = $ErrorActionPreference + $worktreeProbeExitSentinel = [int]::MinValue + try { + $ErrorActionPreference = 'Continue' + $global:LASTEXITCODE = $worktreeProbeExitSentinel + $worktreeList = @(Invoke-TrustedVerifierGit -Arguments @( + '-C', $resolvedVerifierRoot, 'worktree', 'list', '--porcelain') 2>&1 | + ForEach-Object { [string]$_ }) + $worktreeProbeExitCode = [int]$global:LASTEXITCODE + } finally { + $ErrorActionPreference = $previousProbeErrorActionPreference + } + if ($worktreeProbeExitCode -eq $worktreeProbeExitSentinel) { + throw 'Operational rebuild worktree attachment probe did not report an exit code.' + } + if ($worktreeProbeExitCode -eq 0) { + $worktreeAttached = Test-StackchanVerifierWorktreeListContains ` + -PorcelainLines $worktreeList -ExpectedPath $rebuildWorktree + } else { + $worktreeProbeError = + "Trusted Git worktree attachment probe exited $worktreeProbeExitCode." + } + } catch { + $worktreeProbeError = [string]$_.Exception.Message + } + $worktreePreserved = $worktreePathExists -and $worktreeAttached -eq $true + $worktreePreservationKnown = -not $worktreePathExists -or $null -ne $worktreeAttached + $failureStatus = if ($worktreePreserved) { + 'failed-full-worktree-preserved' + } elseif ($worktreePreservationKnown) { + 'failed-worktree-not-preserved' + } else { + 'failed-worktree-preservation-unknown' } - $worktreePreserved = $worktreePathExists -and $worktreeAttached [ordered]@{ schema = 'stackchan.operational-firmware-rebuild-failure.v1' - status = if ($worktreePreserved) { 'failed-full-worktree-preserved' } else { 'failed-worktree-not-preserved' } + status = $failureStatus version = $Version sourceCommit = $ExpectedCommit sourceEpoch = $ExpectedSourceEpoch rebuildWorktree = $rebuildWorktree worktreePathExists = $worktreePathExists worktreeStillAttached = $worktreeAttached + worktreeProbeExitCode = $worktreeProbeExitCode + worktreeProbeError = $worktreeProbeError capturedUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') message = [string]$failure.Exception.Message + exceptionType = [string]$failure.Exception.GetType().FullName + fullyQualifiedErrorId = [string]$failure.FullyQualifiedErrorId + scriptStackTrace = [string]$failure.ScriptStackTrace } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath ( Join-Path $rebuildEvidenceRoot 'FAILURE_EVIDENCE.json') -Encoding UTF8 if ($worktreePreserved) { Write-Warning "Operational firmware rebuild failed; exact worktree remains attached at $rebuildWorktree; evidence: $rebuildEvidenceRoot" + } elseif (-not $worktreePreservationKnown) { + Write-Warning "Operational firmware rebuild failed and worktree preservation could not be proven; evidence: $rebuildEvidenceRoot" } - throw $failure.Exception + throw } finally { foreach ($environmentName in $environmentNames) { $savedValue = $savedEnvironment[$environmentName] From 1362453cdd136b4a74297b045055a5114226b814 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 17:32:28 -0400 Subject: [PATCH 39/46] Stop wake capture at expired uplink authority --- .github/workflows/firmware.yml | 4 + src/io/BridgeWakeGate.hpp | 10 + src/main.cpp | 65 ++++- test/test_native_logic/test_main.cpp | 270 +++++++++++++++++- .../test_dedicated_wake_capture_contract.ps1 | 149 ++++++++++ 5 files changed, 478 insertions(+), 20 deletions(-) create mode 100644 tools/test_dedicated_wake_capture_contract.ps1 diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 4da8aba1..4ed69577 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -706,6 +706,10 @@ jobs: - name: Run native logic tests run: pio test -e native_logic + - name: Verify dedicated wake-capture release boundary + shell: pwsh + run: ./tools/test_dedicated_wake_capture_contract.ps1 + - name: Compile native logic with Glow persona run: pio test -e native_logic --without-testing env: diff --git a/src/io/BridgeWakeGate.hpp b/src/io/BridgeWakeGate.hpp index 5f761b62..63409d10 100644 --- a/src/io/BridgeWakeGate.hpp +++ b/src/io/BridgeWakeGate.hpp @@ -21,6 +21,16 @@ constexpr uint32_t kBridgeWakeGateOpenMs = 6000; constexpr uint32_t kBridgeWakeGateMaxTurnMs = 15000; constexpr size_t kBridgeWakeGateErrorMax = kBridgeErrorMax; +// Dedicated capture may publish PCM only while all three owners still agree +// that the wake-gated turn is live. Keeping this policy pure lets native tests +// exercise the physical capture boundary without reopening or bypassing the +// gate owned by BridgeWakeGate. +constexpr bool dedicatedWakeCaptureMaySubmit(bool gateOpen, + bool gateTurnActive, + bool uplinkActive) { + return gateOpen && gateTurnActive && uplinkActive; +} + struct BridgeWakeGateConfig { bool enabled = true; bool speechStartsTurn = STACKCHAN_BRIDGE_WAKE_ON_SPEECH != 0; diff --git a/src/main.cpp b/src/main.cpp index f4a082e7..340be405 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -989,11 +989,10 @@ volatile bool gWakeMwwStereoDirectionPendingReady = false; #endif #if STACKCHAN_ENABLE_BRIDGE_AUDIO_UPLINK && STACKCHAN_MWW_DEDICATED_WAKE_CAPTURE constexpr uint32_t kWakeMwwCueCompletionTimeoutMs = 120; -// Hard ceiling on one capture, in 100 ms chunks. This is the backstop behind the -// voice-activity endpoint, which normally ends capture as soon as the speaker -// stops. 130 chunks is 13 s, or 416 KB of 16 kHz mono PCM, inside the 512 KB -// uplink limit. Was 96 (9.6 s), and the endpoint's own 4.8 s cap fired first, -// which is what truncated longer sentences. +// Hard ceiling on one capture, in compile-time-sized chunks. This is the +// backstop behind the voice-activity endpoint, which normally ends capture as +// soon as the speaker stops. The release profile uses 800-sample chunks, so 130 +// chunks is 6.5 s (208 KB of 16 kHz mono PCM), inside the 512 KB uplink limit. constexpr uint16_t kWakeMwwDedicatedCaptureChunks = 130; // One chunk is STACKCHAN_MWW_WAKE_UPLINK_CHUNK_SAMPLES at the capture rate. constexpr uint32_t kWakeMwwDedicatedCaptureCeilingMs = @@ -4628,10 +4627,17 @@ void drainWakeMwwUplinkQueue(uint32_t nowMs) { #endif #if STACKCHAN_HAS_MWW_WAKE_PROBE && STACKCHAN_ENABLE_BRIDGE_AUDIO_UPLINK && STACKCHAN_MWW_DEDICATED_WAKE_CAPTURE -bool submitDedicatedWakeCaptureChunk(uint32_t seq, - const int16_t* samples, - uint16_t sampleCount, - uint32_t nowMs) { +enum class DedicatedWakeCaptureSubmitResult : uint8_t { + Submitted, + AuthorityExpired, + Failed, +}; + +DedicatedWakeCaptureSubmitResult submitDedicatedWakeCaptureChunk(uint32_t seq, + const int16_t* samples, + uint16_t sampleCount, + uint32_t nowMs) { + (void)nowMs; constexpr uint16_t kSubmitAttempts = STACKCHAN_MWW_WAKE_UPLINK_SUBMIT_RETRY_ATTEMPTS > 0 ? STACKCHAN_MWW_WAKE_UPLINK_SUBMIT_RETRY_ATTEMPTS @@ -4641,11 +4647,19 @@ bool submitDedicatedWakeCaptureChunk(uint32_t seq, for (uint16_t attempt = 0; attempt < kSubmitAttempts; ++attempt) { const uint32_t attemptMs = millis(); gBridgeNetworkSession.update(attemptMs); + const uint32_t authorityNowMs = millis(); + const BridgeWakeGateTelemetry& gateBeforeAttempt = gBridgeWakeGate.telemetry(); + if (!dedicatedWakeCaptureMaySubmit( + gBridgeWakeGate.isGateOpen(authorityNowMs), + gateBeforeAttempt.turnActive, + gBridgeAudioUplink.telemetry().active)) { + return DedicatedWakeCaptureSubmitResult::AuthorityExpired; + } const BridgeSocketWriterTelemetry& writer = gBridgeNetworkSession.writer().telemetry(); if (!writer.frameBuffered && !writer.binaryFrameQueued && - gBridgeAudioUplink.submitPcmChunk(seq, samples, sampleCount, attemptMs)) { + gBridgeAudioUplink.submitPcmChunk(seq, samples, sampleCount, authorityNowMs)) { gBridgeNetworkSession.update(millis()); - return true; + return DedicatedWakeCaptureSubmitResult::Submitted; } gBridgeNetworkSession.update(millis()); if (kSubmitDelayMs > 0) { @@ -4654,7 +4668,7 @@ bool submitDedicatedWakeCaptureChunk(uint32_t seq, taskYIELD(); } } - return false; + return DedicatedWakeCaptureSubmitResult::Failed; } void finishDedicatedWakeCaptureTurn(uint32_t seq, uint32_t nowMs) { @@ -4761,6 +4775,16 @@ void serviceDedicatedWakeCaptureChunk() { return; } + const uint32_t serviceNowMs = millis(); + const BridgeWakeGateTelemetry& gateBeforeCapture = gBridgeWakeGate.telemetry(); + if (!dedicatedWakeCaptureMaySubmit( + gBridgeWakeGate.isGateOpen(serviceNowMs), + gateBeforeCapture.turnActive, + gBridgeAudioUplink.telemetry().active)) { + finishDedicatedWakeCaptureSession(gWakeMwwDedicatedCapture.chunksSubmitted > 0); + return; + } + const uint32_t serviceStartUs = micros(); constexpr bool kRecordStereo = STACKCHAN_MWW_WAKE_RECORD_STEREO != 0; @@ -4819,9 +4843,22 @@ void serviceDedicatedWakeCaptureChunk() { } } - if (submitDedicatedWakeCaptureChunk( - gWakeMwwDedicatedCapture.seq, monoBuf, kMonoSamples, gWakeSrProbe.lastRecordMs)) { + const BridgeWakeGateTelemetry& gateBeforeSubmit = gBridgeWakeGate.telemetry(); + if (!dedicatedWakeCaptureMaySubmit( + gBridgeWakeGate.isGateOpen(gWakeSrProbe.lastRecordMs), + gateBeforeSubmit.turnActive, + gBridgeAudioUplink.telemetry().active)) { + finishDedicatedWakeCaptureSession(gWakeMwwDedicatedCapture.chunksSubmitted > 0); + return; + } + + const DedicatedWakeCaptureSubmitResult submitResult = submitDedicatedWakeCaptureChunk( + gWakeMwwDedicatedCapture.seq, monoBuf, kMonoSamples, gWakeSrProbe.lastRecordMs); + if (submitResult == DedicatedWakeCaptureSubmitResult::Submitted) { ++gWakeMwwDedicatedCapture.chunksSubmitted; + } else if (submitResult == DedicatedWakeCaptureSubmitResult::AuthorityExpired) { + finishDedicatedWakeCaptureSession(gWakeMwwDedicatedCapture.chunksSubmitted > 0); + return; } else { gWakeMwwUplinkSubmitFailed = gWakeMwwUplinkSubmitFailed + 1u; submitFailed = true; diff --git a/test/test_native_logic/test_main.cpp b/test/test_native_logic/test_main.cpp index e724c632..f21bad9d 100644 --- a/test/test_native_logic/test_main.cpp +++ b/test/test_native_logic/test_main.cpp @@ -7192,15 +7192,16 @@ void test_bridge_wake_gate_survives_a_long_utterance() { } void test_bridge_wake_gate_max_turn_outlasts_the_capture_ceiling() { - // The privacy guard must be the last thing to fire, after the capture has - // already ended on its own. When these were equal they raced, and the guard - // could close the turn while the microphone was still recording. - const uint32_t captureCeilingMs = 13000; // 130 chunks x 100 ms in main.cpp + // Release uses 130 x 800-sample chunks at 16 kHz: a 6.5 s capture ceiling. + // The max-turn privacy guard remains the final absolute bound. + constexpr uint32_t captureCeilingMs = (130u * 800u * 1000u) / 16000u; + static_assert(captureCeilingMs == 6500, "release capture ceiling changed"); TEST_ASSERT_GREATER_THAN_UINT32(captureCeilingMs, kBridgeWakeGateMaxTurnMs); - // And the endpoint's own ceiling ends capture before the chunk ceiling does. + // With the release chunk size, the chunk ceiling ends capture before the + // endpoint's generic 12 s maximum. VoiceActivityEndpointConfig endpointConfig; - TEST_ASSERT_LESS_THAN_UINT32(captureCeilingMs, endpointConfig.maximumCaptureMs); + TEST_ASSERT_GREATER_THAN_UINT32(captureCeilingMs, endpointConfig.maximumCaptureMs); } void test_bridge_wake_gate_renews_on_speech_and_expires() { @@ -8085,6 +8086,259 @@ void test_bridge_endpoint_control_persists_pairing_and_forget_when_store_attache TEST_ASSERT_EQUAL_UINT32(3, store.telemetry().saves); } +void test_dedicated_wake_capture_submission_requires_all_owners() { + TEST_ASSERT_TRUE(dedicatedWakeCaptureMaySubmit(true, true, true)); + TEST_ASSERT_FALSE(dedicatedWakeCaptureMaySubmit(false, true, true)); + TEST_ASSERT_FALSE(dedicatedWakeCaptureMaySubmit(true, false, true)); + TEST_ASSERT_FALSE(dedicatedWakeCaptureMaySubmit(true, true, false)); + TEST_ASSERT_FALSE(dedicatedWakeCaptureMaySubmit(false, false, true)); + TEST_ASSERT_FALSE(dedicatedWakeCaptureMaySubmit(false, true, false)); + TEST_ASSERT_FALSE(dedicatedWakeCaptureMaySubmit(true, false, false)); + TEST_ASSERT_FALSE(dedicatedWakeCaptureMaySubmit(false, false, false)); +} + +void test_dedicated_wake_capture_keeps_queue_failures_visible_while_authorized() { + BridgeClient bridge; + FakeBridgeNetworkSocket socket; + BridgeNetworkSession session; + connectBridgeNetworkSession(bridge, socket, session, 1220); + + BridgeAudioUplinkConfig uplinkConfig; + uplinkConfig.enabled = true; + BridgeAudioUplink uplink; + TEST_ASSERT_TRUE(uplink.begin(uplinkConfig, &session)); + + BridgeWakeGate gate; + TEST_ASSERT_TRUE(gate.begin(BridgeWakeGateConfig {}, &uplink)); + RobotEvent wake; + wake.type = EventType::WakeWord; + gate.applyEvent(wake, 2000); + session.update(2001); + socket.clearOutgoing(); + + const int16_t samples[] = {100, -200, 300, -400}; + TEST_ASSERT_TRUE(dedicatedWakeCaptureMaySubmit( + gate.isGateOpen(2050), gate.telemetry().turnActive, uplink.telemetry().active)); + TEST_ASSERT_TRUE(uplink.submitPcmChunk(gate.telemetry().lastSeq, samples, 4, 2050)); + // The single pending binary slot is still occupied. This is a real queue + // failure while every capture owner remains live, so it must remain visible. + TEST_ASSERT_FALSE(uplink.submitPcmChunk(gate.telemetry().lastSeq, samples, 4, 2051)); + TEST_ASSERT_EQUAL_UINT32(1, uplink.telemetry().errors); + TEST_ASSERT_EQUAL_UINT32(1, uplink.telemetry().queueFailures); + TEST_ASSERT_EQUAL_STRING("audio_chunk_queue_failed", uplink.telemetry().lastError); + + session.update(2052); + socket.clearOutgoing(); + RobotEvent ended; + ended.type = EventType::SpeechEnded; + gate.applyEvent(ended, 2053); + session.update(2054); +} + +void test_dedicated_wake_capture_retry_stops_when_backpressure_reaches_gate_edge() { + BridgeClient bridge; + FakeBridgeNetworkSocket socket; + BridgeNetworkSession session; + connectBridgeNetworkSession(bridge, socket, session, 1220); + + BridgeAudioUplinkConfig uplinkConfig; + uplinkConfig.enabled = true; + BridgeAudioUplink uplink; + TEST_ASSERT_TRUE(uplink.begin(uplinkConfig, &session)); + + BridgeWakeGate gate; + TEST_ASSERT_TRUE(gate.begin(BridgeWakeGateConfig {}, &uplink)); + constexpr uint32_t kWakeAtMs = 2000; + constexpr uint32_t kGateEdgeMs = kWakeAtMs + 6000u; + RobotEvent wake; + wake.type = EventType::WakeWord; + gate.applyEvent(wake, kWakeAtMs); + session.update(kWakeAtMs + 1u); + socket.clearOutgoing(); + + const int16_t samples[] = {100, -200, 300, -400}; + TEST_ASSERT_TRUE(dedicatedWakeCaptureMaySubmit( + gate.isGateOpen(kGateEdgeMs - 1u), + gate.telemetry().turnActive, + uplink.telemetry().active)); + TEST_ASSERT_TRUE( + uplink.submitPcmChunk(gate.telemetry().lastSeq, samples, 4, kGateEdgeMs - 1u)); + + // This models the retry loop's network drain advancing from gate-1 to the + // exact edge. Rechecking authority after the drain must suppress a new PCM + // submission without turning expiry into an uplink/queue failure. + session.update(kGateEdgeMs); + socket.clearOutgoing(); + bool submittedAfterEdge = false; + if (dedicatedWakeCaptureMaySubmit( + gate.isGateOpen(kGateEdgeMs), + gate.telemetry().turnActive, + uplink.telemetry().active)) { + submittedAfterEdge = uplink.submitPcmChunk( + gate.telemetry().lastSeq, samples, 4, kGateEdgeMs); + } + TEST_ASSERT_FALSE(submittedAfterEdge); + TEST_ASSERT_EQUAL_UINT32(0, uplink.telemetry().errors); + TEST_ASSERT_EQUAL_UINT32(0, uplink.telemetry().queueFailures); + TEST_ASSERT_EQUAL_UINT32(1, uplink.telemetry().chunksQueued); + + RobotEvent ended; + ended.type = EventType::SpeechEnded; + gate.applyEvent(ended, kGateEdgeMs); + session.update(kGateEdgeMs + 1u); + char decodedEnd[kBridgeEndpointControlResponseMax] = {}; + TEST_ASSERT_TRUE(decodeMaskedClientTextFrame(socket.outgoing, decodedEnd, sizeof(decodedEnd))); + TEST_ASSERT_NOT_NULL(std::strstr(decodedEnd, "\"type\":\"utterance_end\"")); + TEST_ASSERT_NOT_NULL(std::strstr(decodedEnd, "\"audio_bytes\":8")); + TEST_ASSERT_NOT_NULL(std::strstr(decodedEnd, "\"chunks\":1")); +} + +void test_dedicated_wake_capture_stops_cleanly_at_release_gate_boundary() { + BridgeClient bridge; + FakeBridgeNetworkSocket socket; + BridgeNetworkSession session; + connectBridgeNetworkSession(bridge, socket, session, 1220); + + BridgeAudioUplinkConfig uplinkConfig; + uplinkConfig.enabled = true; + BridgeAudioUplink uplink; + TEST_ASSERT_TRUE(uplink.begin(uplinkConfig, &session)); + + // These are the actual release-profile values. The 800-sample override makes + // each chunk 50 ms; chunk 120 therefore crosses the 6000 ms wake-gate edge. + constexpr uint16_t kReleaseChunkSamples = 800; + constexpr uint32_t kReleaseSampleRate = 16000; + constexpr uint32_t kReleaseGateOpenMs = 6000; + constexpr uint16_t kReleaseCaptureChunks = 130; + constexpr uint32_t kReleaseChunkMs = + (static_cast(kReleaseChunkSamples) * 1000u) / kReleaseSampleRate; + static_assert(kReleaseChunkMs == 50, "release wake chunk duration changed"); + static_assert(kReleaseCaptureChunks * kReleaseChunkMs == 6500, + "release dedicated-capture ceiling changed"); + + BridgeWakeGateConfig gateConfig; + gateConfig.gateOpenMs = kReleaseGateOpenMs; + gateConfig.maxTurnMs = 15000; + BridgeWakeGate gate; + TEST_ASSERT_TRUE(gate.begin(gateConfig, &uplink)); + + VoiceActivityEndpointConfig endpointConfig; + endpointConfig.enabled = true; + VoiceActivityEndpoint endpoint; + TEST_ASSERT_TRUE(endpoint.begin(endpointConfig, 0)); + + constexpr uint32_t kWakeAtMs = 2000; + RobotEvent wake; + wake.type = EventType::WakeWord; + gate.applyEvent(wake, kWakeAtMs); + session.update(kWakeAtMs + 1u); + uint32_t startFrames = 0; + char decodedStart[kBridgeEndpointControlResponseMax] = {}; + TEST_ASSERT_TRUE(decodeMaskedClientTextFrame(socket.outgoing, decodedStart, sizeof(decodedStart))); + TEST_ASSERT_NOT_NULL(std::strstr(decodedStart, "\"type\":\"utterance_start\"")); + TEST_ASSERT_NOT_NULL(std::strstr(decodedStart, "\"seq\":1")); + ++startFrames; + socket.clearOutgoing(); + + int16_t silence[kReleaseChunkSamples] = {}; + uint16_t chunksAttempted = 0; + uint16_t chunksSubmitted = 0; + uint32_t submitFailures = 0; + uint32_t binaryFrames = 0; + uint32_t binaryBytes = 0; + uint32_t endFrames = 0; + uint32_t captureEndedAtMs = 0; + bool expiredDuringCapture = false; + + for (uint16_t chunk = 1; chunk <= kReleaseCaptureChunks; ++chunk) { + const uint32_t serviceStartMs = + kWakeAtMs + static_cast(chunk - 1u) * kReleaseChunkMs; + gate.update(serviceStartMs); + if (!dedicatedWakeCaptureMaySubmit( + gate.isGateOpen(serviceStartMs), + gate.telemetry().turnActive, + uplink.telemetry().active)) { + expiredDuringCapture = true; + break; + } + + ++chunksAttempted; + const uint32_t capturedAtMs = + kWakeAtMs + static_cast(chunk) * kReleaseChunkMs; + TEST_ASSERT_EQUAL( + static_cast(VoiceActivityEndpointReason::None), + static_cast(endpoint.process(silence, kReleaseChunkSamples, capturedAtMs))); + + // Recording can cross the exact expiry boundary even when the pre-capture + // check passed. The production loop checks again here and ends the turn + // without calling submitPcmChunk on an already-expired gate. + if (!dedicatedWakeCaptureMaySubmit( + gate.isGateOpen(capturedAtMs), + gate.telemetry().turnActive, + uplink.telemetry().active)) { + RobotEvent ended; + ended.type = EventType::SpeechEnded; + gate.applyEvent(ended, capturedAtMs); + endpoint.cancel(); + captureEndedAtMs = capturedAtMs; + expiredDuringCapture = true; + break; + } + + if (uplink.submitPcmChunk( + gate.telemetry().lastSeq, silence, kReleaseChunkSamples, capturedAtMs)) { + ++chunksSubmitted; + } else { + ++submitFailures; + } + session.update(capturedAtMs); + std::vector decodedAudio; + TEST_ASSERT_TRUE(decodeMaskedClientBinaryFrame(socket.outgoing, decodedAudio)); + TEST_ASSERT_EQUAL_UINT32(kReleaseChunkSamples * sizeof(int16_t), decodedAudio.size()); + ++binaryFrames; + binaryBytes += static_cast(decodedAudio.size()); + socket.clearOutgoing(); + } + + // Clean expiry still emits the one closing control frame, after all accepted + // binary frames. Its declaration must match exactly what crossed the wire. + session.update(captureEndedAtMs + 1u); + char decodedEnd[kBridgeEndpointControlResponseMax] = {}; + TEST_ASSERT_TRUE(decodeMaskedClientTextFrame(socket.outgoing, decodedEnd, sizeof(decodedEnd))); + TEST_ASSERT_NOT_NULL(std::strstr(decodedEnd, "\"type\":\"utterance_end\"")); + TEST_ASSERT_NOT_NULL(std::strstr(decodedEnd, "\"seq\":1")); + TEST_ASSERT_NOT_NULL(std::strstr(decodedEnd, "\"audio_bytes\":190400")); + TEST_ASSERT_NOT_NULL(std::strstr(decodedEnd, "\"chunks\":119")); + ++endFrames; + socket.clearOutgoing(); + session.update(captureEndedAtMs + 2u); + TEST_ASSERT_TRUE(socket.outgoing.empty()); + + TEST_ASSERT_TRUE(expiredDuringCapture); + TEST_ASSERT_EQUAL_UINT16(120, chunksAttempted); + TEST_ASSERT_EQUAL_UINT16(119, chunksSubmitted); + TEST_ASSERT_EQUAL_UINT32(0, submitFailures); + TEST_ASSERT_EQUAL_UINT32(0, uplink.telemetry().errors); + TEST_ASSERT_EQUAL_UINT32(0, uplink.telemetry().queueFailures); + TEST_ASSERT_EQUAL_UINT32(119, uplink.telemetry().chunksQueued); + TEST_ASSERT_EQUAL_UINT32(1, startFrames); + TEST_ASSERT_EQUAL_UINT32(119, binaryFrames); + TEST_ASSERT_EQUAL_UINT32(190400, binaryBytes); + TEST_ASSERT_EQUAL_UINT32(1, endFrames); + TEST_ASSERT_EQUAL_UINT32(2, session.telemetry().writerTextFrames); + TEST_ASSERT_EQUAL_UINT32(119, session.telemetry().writerBinaryFrames); + TEST_ASSERT_EQUAL_UINT32( + static_cast(119u * kReleaseChunkSamples * sizeof(int16_t)), + uplink.telemetry().bytesQueued); + TEST_ASSERT_FALSE(gate.telemetry().gateOpen); + TEST_ASSERT_FALSE(gate.telemetry().turnActive); + TEST_ASSERT_FALSE(uplink.telemetry().active); + TEST_ASSERT_EQUAL_UINT32(1, gate.telemetry().turnsStarted); + TEST_ASSERT_EQUAL_UINT32(1, gate.telemetry().turnsCompleted); + TEST_ASSERT_FALSE(endpoint.telemetry().speechSeen); + TEST_ASSERT_EQUAL_UINT32(0, endpoint.telemetry().speechChunks); +} + BridgeDebugHttpDecision evaluateDebugHttpFixture(const char* requestLine, bool lineComplete = true, bool lineOverflow = false, @@ -8651,6 +8905,10 @@ int main() { RUN_TEST(test_bridge_wake_gate_can_start_uplink_turn_from_speech_when_enabled); RUN_TEST(test_bridge_wake_gate_renews_on_speech_and_expires); RUN_TEST(test_bridge_wake_gate_survives_a_long_utterance); + RUN_TEST(test_dedicated_wake_capture_submission_requires_all_owners); + RUN_TEST(test_dedicated_wake_capture_keeps_queue_failures_visible_while_authorized); + RUN_TEST(test_dedicated_wake_capture_retry_stops_when_backpressure_reaches_gate_edge); + RUN_TEST(test_dedicated_wake_capture_stops_cleanly_at_release_gate_boundary); RUN_TEST(test_bridge_wake_gate_max_turn_outlasts_the_capture_ceiling); RUN_TEST(test_bridge_network_session_reconnects_after_socket_disconnect); RUN_TEST(test_bridge_network_session_clears_stale_error_after_reconnect_handshake); diff --git a/tools/test_dedicated_wake_capture_contract.ps1 b/tools/test_dedicated_wake_capture_contract.ps1 new file mode 100644 index 00000000..1645d2be --- /dev/null +++ b/tools/test_dedicated_wake_capture_contract.ps1 @@ -0,0 +1,149 @@ +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$platformioPath = Join-Path $repoRoot "platformio.ini" +$mainPath = Join-Path $repoRoot "src\main.cpp" +$gateHeaderPath = Join-Path $repoRoot "src\io\BridgeWakeGate.hpp" +$nativeTestPath = Join-Path $repoRoot "test\test_native_logic\test_main.cpp" + +$platformio = Get-Content -LiteralPath $platformioPath -Raw +$main = Get-Content -LiteralPath $mainPath -Raw +$gateHeader = Get-Content -LiteralPath $gateHeaderPath -Raw +$nativeTest = Get-Content -LiteralPath $nativeTestPath -Raw + +function Get-IniSection([string]$Text, [string]$Name) { + $escaped = [regex]::Escape($Name) + $match = [regex]::Match($Text, "(?ms)^\[$escaped\]\s*(.*?)(?=^\[|\z)") + if (-not $match.Success) { + throw "Missing platformio.ini section [$Name]." + } + return $match.Groups[1].Value +} + +function Require-Contains([string]$Text, [string]$Needle, [string]$Message) { + if (-not $Text.Contains($Needle)) { + throw $Message + } +} + +$uplinkSection = Get-IniSection $platformio "env:stackchan_wake_mww_uplink" +Require-Contains $uplinkSection '-D STACKCHAN_MWW_WAKE_UPLINK_CHUNK_SAMPLES=800' ` + "Release inheritance root must keep the reviewed 800-sample uplink chunk." +Require-Contains $uplinkSection '-D STACKCHAN_MWW_WAKE_CAPTURE_SAMPLE_RATE=16000' ` + "Release inheritance root must keep the reviewed 16000 Hz capture rate." +foreach ($macro in @( + "STACKCHAN_MWW_WAKE_UPLINK_CHUNK_SAMPLES", + "STACKCHAN_MWW_WAKE_CAPTURE_SAMPLE_RATE" +)) { + $definitions = [regex]::Matches( + $uplinkSection, + "(?m)^\s*-[DU]\s+$macro(?:=|\s)[^\r\n]*") + if ($definitions.Count -ne 1) { + throw "[env:stackchan_wake_mww_uplink] must define $macro exactly once." + } +} + +$inheritance = [ordered]@{ + "env:stackchan_wake_mww_uplink_servos" = "env:stackchan_wake_mww_uplink" + "env:stackchan_wake_mww_uplink_servos_m5" = "env:stackchan_wake_mww_uplink_servos" + "env:stackchan_wake_mww_uplink_servos_m5_voiceout" = "env:stackchan_wake_mww_uplink_servos_m5" + "env:stackchan_voice_v2" = "env:stackchan_wake_mww_uplink_servos_m5_voiceout" + "env:stackchan_release_forensics" = "env:stackchan_voice_v2" + "env:stackchan_release_full" = "env:stackchan_release_forensics" +} +foreach ($entry in $inheritance.GetEnumerator()) { + $section = Get-IniSection $platformio $entry.Key + if ($section -notmatch "(?m)^\s*extends\s*=\s*$([regex]::Escape($entry.Value))\s*$") { + throw "[$($entry.Key)] no longer inherits [$($entry.Value)]." + } + foreach ($macro in @( + "STACKCHAN_MWW_WAKE_UPLINK_CHUNK_SAMPLES", + "STACKCHAN_MWW_WAKE_CAPTURE_SAMPLE_RATE" + )) { + if ($section -match "(?m)^\s*-[DU]\s+$macro(?:=|\s|$)") { + throw "[$($entry.Key)] must not override or unflag release timing macro $macro." + } + } +} + +$releaseChunkSamples = 800 +$releaseSampleRate = 16000 +$releaseGateOpenMs = 6000 +$releaseCaptureChunks = 130 +$chunkMs = [int](($releaseChunkSamples * 1000) / $releaseSampleRate) +if ($chunkMs -ne 50 -or (120 * $chunkMs) -ne $releaseGateOpenMs -or + ($releaseCaptureChunks * $chunkMs) -ne 6500) { + throw "Release boundary arithmetic changed: chunkMs=$chunkMs gate=$releaseGateOpenMs ceiling=$($releaseCaptureChunks * $chunkMs)." +} + +Require-Contains $gateHeader 'constexpr uint32_t kBridgeWakeGateOpenMs = 6000;' ` + "Bridge wake-gate open duration must stay bound to the reviewed 6000 ms value." +Require-Contains $gateHeader 'constexpr bool dedicatedWakeCaptureMaySubmit(' ` + "Pure dedicated-capture submission policy is missing." +Require-Contains $gateHeader 'return gateOpen && gateTurnActive && uplinkActive;' ` + "Dedicated capture must require all wake-gate and uplink authorities." +Require-Contains $main 'constexpr uint16_t kWakeMwwDedicatedCaptureChunks = 130;' ` + "Dedicated capture chunk ceiling must stay bound to the reviewed 130 chunks." + +$retryStart = $main.IndexOf('DedicatedWakeCaptureSubmitResult submitDedicatedWakeCaptureChunk(') +$retryEnd = $main.IndexOf('void finishDedicatedWakeCaptureTurn(', $retryStart) +if ($retryStart -lt 0 -or $retryEnd -le $retryStart) { + throw "Dedicated capture retry function could not be isolated." +} +$retry = $main.Substring($retryStart, $retryEnd - $retryStart) +$retryUpdateIndex = $retry.IndexOf('gBridgeNetworkSession.update(attemptMs);') +$retryGuardIndex = $retry.IndexOf('dedicatedWakeCaptureMaySubmit(') +$retrySubmitIndex = $retry.IndexOf('gBridgeAudioUplink.submitPcmChunk(') +if ($retryUpdateIndex -lt 0 -or $retryGuardIndex -lt $retryUpdateIndex -or + $retrySubmitIndex -lt 0 -or $retryGuardIndex -gt $retrySubmitIndex) { + throw "Each retry must revalidate capture authority after network drain and before PCM submission." +} +Require-Contains $retry 'return DedicatedWakeCaptureSubmitResult::AuthorityExpired;' ` + "Retry authority loss must return a distinct clean-stop result." + +$serviceStart = $main.IndexOf('void serviceDedicatedWakeCaptureChunk()') +$serviceEnd = $main.IndexOf('void serviceDedicatedWakeCapture(uint32_t nowMs)', $serviceStart) +if ($serviceStart -lt 0 -or $serviceEnd -le $serviceStart) { + throw "Dedicated capture service function could not be isolated." +} +$service = $main.Substring($serviceStart, $serviceEnd - $serviceStart) +$guardMatches = [regex]::Matches($service, 'dedicatedWakeCaptureMaySubmit\s*\(') +if ($guardMatches.Count -ne 2) { + throw "Dedicated capture must have exactly two submission-authority guards; found $($guardMatches.Count)." +} +$attemptIndex = $service.IndexOf('++gWakeMwwDedicatedCapture.chunksAttempted;') +$endpointIndex = $service.IndexOf('gWakeMwwDedicatedCapture.endpoint.process(') +$submitIndex = $service.IndexOf('submitDedicatedWakeCaptureChunk(') +if ($guardMatches[0].Index -gt $attemptIndex) { + throw "Pre-capture authority guard must precede the attempted-chunk counter." +} +if ($endpointIndex -lt 0 -or $guardMatches[1].Index -lt $endpointIndex -or + $submitIndex -lt 0 -or $guardMatches[1].Index -gt $submitIndex) { + throw "Post-record authority guard must sit between VAD processing and PCM submission." +} +$authorityBranch = [regex]::Match( + $service, + '(?s)else\s+if\s*\(\s*submitResult\s*==\s*DedicatedWakeCaptureSubmitResult::AuthorityExpired\s*\)\s*\{\s*finishDedicatedWakeCaptureSession\s*\([^;]+;\s*return\s*;\s*\}\s*else\s*\{\s*gWakeMwwUplinkSubmitFailed\s*=') +if (-not $authorityBranch.Success) { + throw "Authority expiry must clean-finish and return before the real submit-failure counter branch." +} + +foreach ($needle in @( + 'test_dedicated_wake_capture_submission_requires_all_owners', + 'test_dedicated_wake_capture_keeps_queue_failures_visible_while_authorized', + 'test_dedicated_wake_capture_retry_stops_when_backpressure_reaches_gate_edge', + 'test_dedicated_wake_capture_stops_cleanly_at_release_gate_boundary', + 'constexpr uint16_t kReleaseChunkSamples = 800;', + 'constexpr uint32_t kReleaseSampleRate = 16000;', + 'constexpr uint32_t kReleaseGateOpenMs = 6000;', + 'TEST_ASSERT_EQUAL_UINT16(120, chunksAttempted);', + 'TEST_ASSERT_EQUAL_UINT16(119, chunksSubmitted);', + 'TEST_ASSERT_EQUAL_UINT32(0, submitFailures);', + 'TEST_ASSERT_EQUAL_UINT32(0, uplink.telemetry().errors);', + '\"audio_bytes\":190400', + 'TEST_ASSERT_EQUAL_UINT32(119, session.telemetry().writerBinaryFrames);' +)) { + Require-Contains $nativeTest $needle "Native release-boundary regression is missing: $needle" +} + +Write-Output "dedicated-wake-capture-contract-passed" From a8e4bffa5af70a52d089f2409c72a194440e51b2 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 17:59:24 -0400 Subject: [PATCH 40/46] Preserve natural pauses during voice capture --- src/io/VoiceActivityEndpoint.hpp | 4 +- test/test_native_logic/test_main.cpp | 180 +++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) diff --git a/src/io/VoiceActivityEndpoint.hpp b/src/io/VoiceActivityEndpoint.hpp index 2ce8af20..f19f51dd 100644 --- a/src/io/VoiceActivityEndpoint.hpp +++ b/src/io/VoiceActivityEndpoint.hpp @@ -20,7 +20,9 @@ struct VoiceActivityEndpointConfig { uint32_t sampleRate = 16000; uint32_t minimumCaptureMs = 600; uint32_t minimumSpeechMs = 150; - uint32_t trailingSilenceMs = 550; + // Preserve natural clause pauses. The former 550 ms tail intermittently + // endpointed a speaker mid-sentence during physical conversation testing. + uint32_t trailingSilenceMs = 1200; // Ceiling, not the normal path. Capture ends on trailing silence as soon as // the speaker stops; this only catches the case where silence is never // detected. It used to be 4800 ms, which truncated any sentence longer than diff --git a/test/test_native_logic/test_main.cpp b/test/test_native_logic/test_main.cpp index f21bad9d..3e50bdbd 100644 --- a/test/test_native_logic/test_main.cpp +++ b/test/test_native_logic/test_main.cpp @@ -1736,6 +1736,79 @@ void test_voice_activity_endpoint_default_ceiling_fits_a_long_sentence() { TEST_ASSERT_LESS_THAN_UINT32(512u * 1024u, bytes); } +void test_voice_activity_endpoint_default_preserves_one_second_natural_pause() { + VoiceActivityEndpointConfig config; + config.enabled = true; + TEST_ASSERT_EQUAL_UINT32(1200, config.trailingSilenceMs); + TEST_ASSERT_LESS_THAN_UINT32(kBridgeWakeGateOpenMs, config.trailingSilenceMs); + + VoiceActivityEndpoint endpoint; + TEST_ASSERT_TRUE(endpoint.begin(config, 0)); + int16_t speech[800] = {}; + int16_t silence[800] = {}; + fillVoiceEndpointSpeech(speech, 800); + + VoiceActivityEndpointReason reason = VoiceActivityEndpointReason::None; + for (uint32_t nowMs = 50; nowMs <= 200; nowMs += 50) { + reason = endpoint.process(speech, 800, nowMs); + TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::None), + static_cast(reason)); + } + TEST_ASSERT_TRUE(endpoint.telemetry().speechSeen); + + // A full second of silence is a natural clause pause, not an utterance end. + for (uint32_t nowMs = 250; nowMs <= 1200; nowMs += 50) { + reason = endpoint.process(silence, 800, nowMs); + TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::None), + static_cast(reason)); + } + TEST_ASSERT_TRUE(endpoint.telemetry().active); + TEST_ASSERT_EQUAL_UINT32(0, endpoint.telemetry().endpointsDetected); + + for (uint32_t nowMs = 1250; nowMs <= 1400; nowMs += 50) { + reason = endpoint.process(speech, 800, nowMs); + TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::None), + static_cast(reason)); + } + for (uint32_t nowMs = 1450; nowMs <= 2550; nowMs += 50) { + reason = endpoint.process(silence, 800, nowMs); + TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::None), + static_cast(reason)); + } + TEST_ASSERT_TRUE(endpoint.telemetry().active); + TEST_ASSERT_EQUAL( + static_cast(VoiceActivityEndpointReason::TrailingSilence), + static_cast(endpoint.process(silence, 800, 2600))); + TEST_ASSERT_FALSE(endpoint.telemetry().active); + TEST_ASSERT_EQUAL_UINT32(1, endpoint.telemetry().endpointsDetected); + TEST_ASSERT_EQUAL_UINT32(0, endpoint.telemetry().maxDurationFallbacks); +} + +void test_voice_activity_endpoint_default_continuous_speech_still_hits_maximum() { + VoiceActivityEndpointConfig config; + config.enabled = true; + TEST_ASSERT_LESS_THAN_UINT32(kBridgeWakeGateMaxTurnMs, config.maximumCaptureMs); + + VoiceActivityEndpoint endpoint; + TEST_ASSERT_TRUE(endpoint.begin(config, 0)); + int16_t speech[800] = {}; + fillVoiceEndpointSpeech(speech, 800); + + VoiceActivityEndpointReason reason = VoiceActivityEndpointReason::None; + for (uint32_t nowMs = 50; nowMs <= config.maximumCaptureMs; nowMs += 50) { + reason = endpoint.process(speech, 800, nowMs); + if (nowMs < config.maximumCaptureMs) { + TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::None), + static_cast(reason)); + } + } + TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::MaxDuration), + static_cast(reason)); + TEST_ASSERT_FALSE(endpoint.telemetry().active); + TEST_ASSERT_EQUAL_UINT32(1, endpoint.telemetry().endpointsDetected); + TEST_ASSERT_EQUAL_UINT32(1, endpoint.telemetry().maxDurationFallbacks); +} + void test_voice_activity_endpoint_disabled_path_preserves_fixed_capture() { VoiceActivityEndpointConfig config; config.enabled = false; @@ -8339,6 +8412,110 @@ void test_dedicated_wake_capture_stops_cleanly_at_release_gate_boundary() { TEST_ASSERT_EQUAL_UINT32(0, endpoint.telemetry().speechChunks); } +void test_dedicated_wake_capture_natural_pause_keeps_one_authorized_turn() { + BridgeClient bridge; + FakeBridgeNetworkSocket socket; + BridgeNetworkSession session; + connectBridgeNetworkSession(bridge, socket, session, 1230); + + BridgeAudioUplinkConfig uplinkConfig; + uplinkConfig.enabled = true; + BridgeAudioUplink uplink; + TEST_ASSERT_TRUE(uplink.begin(uplinkConfig, &session)); + + BridgeWakeGate gate; + TEST_ASSERT_TRUE(gate.begin(BridgeWakeGateConfig {}, &uplink)); + VoiceActivityEndpointConfig endpointConfig; + endpointConfig.enabled = true; + VoiceActivityEndpoint endpoint; + TEST_ASSERT_TRUE(endpoint.begin(endpointConfig, 1000)); + + RobotEvent wake; + wake.type = EventType::WakeWord; + gate.applyEvent(wake, 1000); + session.update(1001); + char decodedStart[kBridgeEndpointControlResponseMax] = {}; + TEST_ASSERT_TRUE(decodeMaskedClientTextFrame(socket.outgoing, decodedStart, + sizeof(decodedStart))); + TEST_ASSERT_NOT_NULL(std::strstr(decodedStart, "\"type\":\"utterance_start\"")); + socket.clearOutgoing(); + + constexpr uint16_t kChunkSamples = 800; + constexpr uint32_t kChunkMs = 50; + int16_t speech[kChunkSamples] = {}; + int16_t silence[kChunkSamples] = {}; + fillVoiceEndpointSpeech(speech, kChunkSamples); + + uint32_t binaryFrames = 0; + uint32_t endFrames = 0; + VoiceActivityEndpointReason finalReason = VoiceActivityEndpointReason::None; + for (uint16_t chunk = 1; chunk <= 52; ++chunk) { + const uint32_t capturedAtMs = 1000u + static_cast(chunk) * kChunkMs; + const bool speaking = chunk <= 4u || (chunk >= 25u && chunk <= 28u); + const uint32_t speechAtBefore = endpoint.telemetry().lastSpeechAtMs; + finalReason = endpoint.process( + speaking ? speech : silence, kChunkSamples, capturedAtMs); + if (endpoint.telemetry().lastSpeechAtMs != speechAtBefore) { + RobotEvent speakingEvent; + speakingEvent.type = EventType::UserSpeaking; + speakingEvent.timestampMs = capturedAtMs; + gate.applyEvent(speakingEvent, capturedAtMs); + } + + if (chunk == 24u) { + TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::None), + static_cast(finalReason)); + TEST_ASSERT_TRUE(endpoint.telemetry().active); + TEST_ASSERT_TRUE(gate.telemetry().turnActive); + TEST_ASSERT_TRUE(uplink.telemetry().active); + } + + TEST_ASSERT_TRUE(dedicatedWakeCaptureMaySubmit( + gate.isGateOpen(capturedAtMs), gate.telemetry().turnActive, + uplink.telemetry().active)); + TEST_ASSERT_TRUE(uplink.submitPcmChunk( + gate.telemetry().lastSeq, speaking ? speech : silence, + kChunkSamples, capturedAtMs)); + session.update(capturedAtMs); + std::vector decodedAudio; + TEST_ASSERT_TRUE(decodeMaskedClientBinaryFrame(socket.outgoing, decodedAudio)); + TEST_ASSERT_EQUAL_UINT32(kChunkSamples * sizeof(int16_t), decodedAudio.size()); + ++binaryFrames; + socket.clearOutgoing(); + + if (finalReason != VoiceActivityEndpointReason::None) { + RobotEvent ended; + ended.type = EventType::SpeechEnded; + ended.timestampMs = capturedAtMs; + gate.applyEvent(ended, capturedAtMs); + session.update(capturedAtMs + 1u); + char decodedEnd[kBridgeEndpointControlResponseMax] = {}; + TEST_ASSERT_TRUE(decodeMaskedClientTextFrame(socket.outgoing, decodedEnd, + sizeof(decodedEnd))); + TEST_ASSERT_NOT_NULL(std::strstr(decodedEnd, "\"type\":\"utterance_end\"")); + ++endFrames; + socket.clearOutgoing(); + session.update(capturedAtMs + 2u); + TEST_ASSERT_TRUE(socket.outgoing.empty()); + break; + } + } + + TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::TrailingSilence), + static_cast(finalReason)); + TEST_ASSERT_EQUAL_UINT32(52, binaryFrames); + TEST_ASSERT_EQUAL_UINT32(1, endFrames); + TEST_ASSERT_EQUAL_UINT32(52, uplink.telemetry().chunksQueued); + TEST_ASSERT_EQUAL_UINT32(0, uplink.telemetry().errors); + TEST_ASSERT_EQUAL_UINT32(0, uplink.telemetry().queueFailures); + TEST_ASSERT_EQUAL_UINT32(1, gate.telemetry().turnsStarted); + TEST_ASSERT_EQUAL_UINT32(1, gate.telemetry().turnsCompleted); + TEST_ASSERT_FALSE(gate.telemetry().turnActive); + TEST_ASSERT_FALSE(uplink.telemetry().active); + TEST_ASSERT_EQUAL_UINT32(2, session.telemetry().writerTextFrames); + TEST_ASSERT_EQUAL_UINT32(52, session.telemetry().writerBinaryFrames); +} + BridgeDebugHttpDecision evaluateDebugHttpFixture(const char* requestLine, bool lineComplete = true, bool lineOverflow = false, @@ -8670,6 +8847,8 @@ int main() { RUN_TEST(test_voice_activity_endpoint_rejects_short_noise_and_uses_maximum_fallback); RUN_TEST(test_voice_activity_endpoint_capture_tracks_speech_length); RUN_TEST(test_voice_activity_endpoint_default_ceiling_fits_a_long_sentence); + RUN_TEST(test_voice_activity_endpoint_default_preserves_one_second_natural_pause); + RUN_TEST(test_voice_activity_endpoint_default_continuous_speech_still_hits_maximum); RUN_TEST(test_voice_activity_endpoint_disabled_path_preserves_fixed_capture); RUN_TEST(test_audio_capture_adapter_disabled_default_is_ready_without_source); RUN_TEST(test_audio_capture_adapter_rejects_oversized_window); @@ -8909,6 +9088,7 @@ int main() { RUN_TEST(test_dedicated_wake_capture_keeps_queue_failures_visible_while_authorized); RUN_TEST(test_dedicated_wake_capture_retry_stops_when_backpressure_reaches_gate_edge); RUN_TEST(test_dedicated_wake_capture_stops_cleanly_at_release_gate_boundary); + RUN_TEST(test_dedicated_wake_capture_natural_pause_keeps_one_authorized_turn); RUN_TEST(test_bridge_wake_gate_max_turn_outlasts_the_capture_ceiling); RUN_TEST(test_bridge_network_session_reconnects_after_socket_disconnect); RUN_TEST(test_bridge_network_session_clears_stale_error_after_reconnect_handshake); From ef868a87ec88a5da102e3c0c4c502603769b9a34 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 18:12:18 -0400 Subject: [PATCH 41/46] Recover conversations after interrupted playback --- bridge/lan_service.py | 92 +++++++++++++++--------- bridge/test_lan_service.py | 65 +++++++++++++++++ src/io/BridgeAudioDownlink.cpp | 28 ++++++-- src/io/BridgeAudioDownlink.hpp | 8 ++- src/main.cpp | 34 ++++++--- test/test_native_logic/test_main.cpp | 101 +++++++++++++++++++++++++++ 6 files changed, 279 insertions(+), 49 deletions(-) diff --git a/bridge/lan_service.py b/bridge/lan_service.py index f0ed3328..35f74fc8 100644 --- a/bridge/lan_service.py +++ b/bridge/lan_service.py @@ -2261,6 +2261,9 @@ def handle_text( seq = max(0, int(message.get("seq", 0))) except (TypeError, ValueError): return [error_frame("playback_complete_seq_invalid")] + interrupted = message.get("interrupted", False) + if not isinstance(interrupted, bool): + return [error_frame("playback_complete_interrupted_invalid")] frame: dict[str, object] = {"type": "heartbeat", "playback_complete_seq": seq} if self.conversation is not None: if seq == 0 or seq != self.playback_response_seq: @@ -2273,55 +2276,67 @@ def handle_text( return [error_frame("playback_complete_seq_mismatch", str(seq))] if seq == self.conversation_playback_complete_seq: frame["playback_complete_duplicate"] = True + if interrupted: + frame["playback_interrupted"] = True frame.update(self._conversation_payload()) return [frame] if ( seq == self.conversation_response_seq and self.conversation.phase == ConversationPhase.SPEAKING ): - transition = self.conversation.playback_completed(now_ms()) - committed_plan, research_succeeded = ( - self.conversation.take_committed_task() - ) - committed_state = ( - committed_plan.next_state - if committed_plan is not None - else None - ) - research_attempted = bool( - committed_plan is not None - and committed_plan.request is not None - ) - if ( - research_attempted - and committed_state is not None - and committed_state.domain == "weather" - ): - self._session_research_turns += 1 - if "weather" not in self._session_topics: - self._session_topics.append("weather") - elif research_attempted and committed_state is not None: - self._session_research_turns += 1 - if "web research" not in self._session_topics: - self._session_topics.append("web research") - if "playback_complete" in transition.actions: - frame = { - "type": "conversation_reply_window", - "seq": seq, - "open_after_ms": self.config.conversation_acoustic_tail_ms, - "window_ms": self.conversation.current_reply_window_ms(), - } - else: + if interrupted: + transition = self.conversation.cancel( + now_ms(), "playback_interrupted" + ) frame["playback_complete_terminal"] = True + frame["playback_interrupted"] = True + else: + transition = self.conversation.playback_completed(now_ms()) + committed_plan, research_succeeded = ( + self.conversation.take_committed_task() + ) + committed_state = ( + committed_plan.next_state + if committed_plan is not None + else None + ) + research_attempted = bool( + committed_plan is not None + and committed_plan.request is not None + ) + if ( + research_attempted + and committed_state is not None + and committed_state.domain == "weather" + ): + self._session_research_turns += 1 + if "weather" not in self._session_topics: + self._session_topics.append("weather") + elif research_attempted and committed_state is not None: + self._session_research_turns += 1 + if "web research" not in self._session_topics: + self._session_topics.append("web research") + if "playback_complete" in transition.actions: + frame = { + "type": "conversation_reply_window", + "seq": seq, + "open_after_ms": self.config.conversation_acoustic_tail_ms, + "window_ms": self.conversation.current_reply_window_ms(), + } + else: + frame["playback_complete_terminal"] = True frame.update(self._conversation_payload(transition)) else: frame["playback_complete_terminal"] = True + if interrupted: + frame["playback_interrupted"] = True frame.update(self._conversation_payload()) self.conversation_playback_complete_seq = seq if self.dashboard_runtime is not None: self.dashboard_runtime.note_pipeline_result( "playback", - ok=True, + ok=not interrupted, + error_code="playback_interrupted" if interrupted else "", ) self.dashboard_runtime.note_pipeline_stage( "reply_window" @@ -4035,6 +4050,15 @@ def run_initiative_turn(decision: InitiativeDecision) -> None: discard_pending_audio() if deferred_response_end is not None: close_interrupted_response("barge_in") + if ( + text_message_type == "playback_complete" + and isinstance(parsed_text, dict) + and parsed_text.get("interrupted") is True + ): + session.cancel_active_turn("playback_interrupted") + discard_pending_audio() + if deferred_response_end is None: + close_interrupted_response("playback_interrupted") if text_message_type == "utterance_end": if turn_thread is not None and turn_thread.is_alive(): diff --git a/bridge/test_lan_service.py b/bridge/test_lan_service.py index 3a159efa..c1a9e159 100644 --- a/bridge/test_lan_service.py +++ b/bridge/test_lan_service.py @@ -3416,6 +3416,71 @@ def test_playback_complete_is_acknowledged_without_opening_capture(self): self.assertEqual([{"type": "heartbeat", "playback_complete_seq": 44}], frames) self.assertFalse(session.audio.active) + def test_interrupted_playback_cancels_staged_turn_without_opening_capture(self): + session = LanBridgeSession( + LanBridgeConfig( + conversation_v2_enabled=True, + tts_command="fixture-tts", + ) + ) + conversation = session.conversation + self.assertIsNotNone(conversation) + assert conversation is not None + conversation.wake(10) + conversation.utterance_started(20) + conversation.utterance_committed(30, "Please keep talking") + conversation.response_started(40) + conversation.stage_turn("Please keep talking", "This reply was interrupted") + session.playback_response_seq = 45 + session.conversation_response_seq = 45 + + invalid = session.handle_text( + json.dumps( + { + "type": "playback_complete", + "seq": 45, + "interrupted": "true", + } + ) + ) + interrupted = session.handle_text( + json.dumps( + { + "type": "playback_complete", + "seq": 45, + "at_ms": 1234, + "interrupted": True, + } + ) + ) + duplicate = session.handle_text( + json.dumps( + { + "type": "playback_complete", + "seq": 45, + "at_ms": 1235, + "interrupted": True, + } + ) + ) + + self.assertEqual("error", invalid[0]["type"]) + self.assertEqual("playback_complete_interrupted_invalid", invalid[0]["code"]) + self.assertEqual("heartbeat", interrupted[0]["type"]) + self.assertEqual(45, interrupted[0]["playback_complete_seq"]) + self.assertTrue(interrupted[0]["playback_complete_terminal"]) + self.assertTrue(interrupted[0]["playback_interrupted"]) + self.assertEqual("cooldown", interrupted[0]["conversation_state"]) + self.assertEqual("playback_interrupted", interrupted[0]["conversation_reason"]) + self.assertFalse(interrupted[0]["conversation_capture_open"]) + self.assertNotIn("open_after_ms", interrupted[0]) + self.assertEqual((), conversation.context_lines()) + self.assertEqual((None, False), conversation.take_committed_task()) + self.assertEqual("heartbeat", duplicate[0]["type"]) + self.assertTrue(duplicate[0]["playback_complete_duplicate"]) + self.assertTrue(duplicate[0]["playback_interrupted"]) + self.assertEqual(ConversationPhase.COOLDOWN, conversation.phase) + def test_conversation_v2_requires_confirmable_audio_downlink(self): with self.assertRaisesRegex(ValueError, "requires configured TTS"): LanBridgeSession(LanBridgeConfig(conversation_v2_enabled=True)) diff --git a/src/io/BridgeAudioDownlink.cpp b/src/io/BridgeAudioDownlink.cpp index 533fbf29..475800b3 100644 --- a/src/io/BridgeAudioDownlink.cpp +++ b/src/io/BridgeAudioDownlink.cpp @@ -125,25 +125,43 @@ void BridgeAudioDownlink::update(uint32_t nowMs) { completePlayback(); } -void BridgeAudioDownlink::abort(uint32_t nowMs, uint32_t reasonCode) { +void BridgeAudioDownlink::abort(uint32_t nowMs, + uint32_t reasonCode, + bool signalPlaybackTerminal) { (void)nowMs; + const bool preserveCompletion = signalPlaybackTerminal && telemetry_.playbackCompletionPending; + const bool interruptPlayback = + signalPlaybackTerminal && !preserveCompletion && telemetry_.lastSeq != 0 && + (telemetry_.active || telemetry_.playbackActive || telemetry_.playbackAwaitingDrain); + const uint32_t interruptedSeq = telemetry_.lastSeq; if (telemetry_.active) { telemetry_.streamsAborted++; } stopPlayback(nowMs); clearActive(); - telemetry_.playbackCompletionPending = false; - telemetry_.playbackCompletionSeq = 0; + if (interruptPlayback) { + telemetry_.playbackCompletionPending = true; + telemetry_.playbackCompletionInterrupted = true; + telemetry_.playbackCompletionSeq = interruptedSeq; + telemetry_.playbackInterruptions++; + } else if (!preserveCompletion) { + telemetry_.playbackCompletionPending = false; + telemetry_.playbackCompletionInterrupted = false; + telemetry_.playbackCompletionSeq = 0; + } telemetry_.lastErrorCode = reasonCode; } -bool BridgeAudioDownlink::peekPlaybackCompletion(uint32_t* seqOut) const { +bool BridgeAudioDownlink::peekPlaybackCompletion(uint32_t* seqOut, bool* interruptedOut) const { if (!telemetry_.playbackCompletionPending) { return false; } if (seqOut != nullptr) { *seqOut = telemetry_.playbackCompletionSeq; } + if (interruptedOut != nullptr) { + *interruptedOut = telemetry_.playbackCompletionInterrupted; + } return true; } @@ -152,6 +170,7 @@ bool BridgeAudioDownlink::consumePlaybackCompletion() { return false; } telemetry_.playbackCompletionPending = false; + telemetry_.playbackCompletionInterrupted = false; telemetry_.playbackCompletionSignals++; return true; } @@ -241,6 +260,7 @@ void BridgeAudioDownlink::completePlayback() { telemetry_.playbackStops++; telemetry_.playbackCompletions++; telemetry_.playbackCompletionPending = true; + telemetry_.playbackCompletionInterrupted = false; telemetry_.playbackCompletionSeq = telemetry_.lastSeq; } diff --git a/src/io/BridgeAudioDownlink.hpp b/src/io/BridgeAudioDownlink.hpp index f940b03d..e42e79e8 100644 --- a/src/io/BridgeAudioDownlink.hpp +++ b/src/io/BridgeAudioDownlink.hpp @@ -14,6 +14,7 @@ struct BridgeAudioDownlinkTelemetry { bool playbackActive = false; bool playbackAwaitingDrain = false; bool playbackCompletionPending = false; + bool playbackCompletionInterrupted = false; uint32_t streamsStarted = 0; uint32_t streamsCompleted = 0; uint32_t streamsAborted = 0; @@ -25,6 +26,7 @@ struct BridgeAudioDownlinkTelemetry { uint32_t playbackBytes = 0; uint32_t playbackStops = 0; uint32_t playbackCompletions = 0; + uint32_t playbackInterruptions = 0; uint32_t playbackCompletionSignals = 0; uint32_t playbackCompletionSeq = 0; uint32_t playbackUnsupported = 0; @@ -62,8 +64,10 @@ class BridgeAudioDownlink { bool submitChunk(const BridgeAudioStreamChunk& chunk, uint32_t nowMs); bool end(const BridgeAudioStream& stream, uint32_t nowMs); void update(uint32_t nowMs); - void abort(uint32_t nowMs, uint32_t reasonCode = 0); - bool peekPlaybackCompletion(uint32_t* seqOut) const; + void abort(uint32_t nowMs, + uint32_t reasonCode = 0, + bool signalPlaybackTerminal = false); + bool peekPlaybackCompletion(uint32_t* seqOut, bool* interruptedOut = nullptr) const; bool consumePlaybackCompletion(); const BridgeAudioDownlinkTelemetry& telemetry() const { diff --git a/src/main.cpp b/src/main.cpp index 340be405..1759f898 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2027,14 +2027,18 @@ const char* bridgeAudioSafetyStopReasonName(BridgeAudioSafetyStopReason reason) bool bridgeAudioRuntimeHeld() { const BridgeAudioDownlinkTelemetry& downlink = gBridgeAudioDownlink.telemetry(); - return downlink.active || downlink.playbackActive || gSpeakerSink.speakerPowerActive() != 0 || - gSpeakerSink.speakerRunning() != 0; + return downlink.active || downlink.playbackActive || downlink.playbackCompletionPending || + gSpeakerSink.speakerPowerActive() != 0 || gSpeakerSink.speakerRunning() != 0; } bool stopBridgeAudioRuntime(uint32_t nowMs, BridgeAudioSafetyStopReason reason) { const bool held = bridgeAudioRuntimeHeld(); + const bool signalPlaybackTerminal = + reason != BridgeAudioSafetyStopReason::TransportDisconnected && + gBridgeNetworkSession.telemetry().state == BridgeNetworkSessionState::Connected; gConversationReplyWindow.cancel(nowMs); - gBridgeAudioDownlink.abort(nowMs, 100u + static_cast(reason)); + gBridgeAudioDownlink.abort( + nowMs, 100u + static_cast(reason), signalPlaybackTerminal); gAudioOut.cancel(); gSpeakerSink.stop(nowMs); gBridgeSpeechCuePending = false; @@ -2073,15 +2077,23 @@ void serviceBridgeAudioTransportSafety(uint32_t nowMs) { void queueBridgePlaybackCompletion(uint32_t nowMs) { uint32_t seq = 0; - if (!gBridgeAudioDownlink.peekPlaybackCompletion(&seq)) { + bool interrupted = false; + if (!gBridgeAudioDownlink.peekPlaybackCompletion(&seq, &interrupted)) { return; } char frame[112] = {}; - const int written = snprintf(frame, - sizeof(frame), - "{\"type\":\"playback_complete\",\"seq\":%lu,\"at_ms\":%lu}", - static_cast(seq), - static_cast(nowMs)); + const int written = interrupted + ? snprintf(frame, + sizeof(frame), + "{\"type\":\"playback_complete\",\"seq\":%lu," + "\"at_ms\":%lu,\"interrupted\":true}", + static_cast(seq), + static_cast(nowMs)) + : snprintf(frame, + sizeof(frame), + "{\"type\":\"playback_complete\",\"seq\":%lu,\"at_ms\":%lu}", + static_cast(seq), + static_cast(nowMs)); if (written > 0 && static_cast(written) < sizeof(frame) && gBridgeNetworkSession.queueTextFrame(frame)) { gBridgeAudioDownlink.consumePlaybackCompletion(); @@ -8433,8 +8445,12 @@ void serveBridgeLeanStatusJson(WiFiClient& client, gBridgeAudioDownlink.telemetry().playbackAwaitingDrain ? "true" : "false"); append(",\"bridge_downlink_playback_completion_pending\":%s", gBridgeAudioDownlink.telemetry().playbackCompletionPending ? "true" : "false"); + append(",\"bridge_downlink_playback_completion_interrupted\":%s", + gBridgeAudioDownlink.telemetry().playbackCompletionInterrupted ? "true" : "false"); append(",\"bridge_downlink_playback_completions\":%lu", static_cast(gBridgeAudioDownlink.telemetry().playbackCompletions)); + append(",\"bridge_downlink_playback_interruptions\":%lu", + static_cast(gBridgeAudioDownlink.telemetry().playbackInterruptions)); append(",\"bridge_downlink_playback_completion_signals\":%lu", static_cast(gBridgeAudioDownlink.telemetry().playbackCompletionSignals)); append(",\"bridge_downlink_playback_completion_seq\":%lu", diff --git a/test/test_native_logic/test_main.cpp b/test/test_native_logic/test_main.cpp index 3e50bdbd..a79cb672 100644 --- a/test/test_native_logic/test_main.cpp +++ b/test/test_native_logic/test_main.cpp @@ -5481,6 +5481,104 @@ void test_bridge_audio_downlink_waits_for_physical_speaker_drain() { TEST_ASSERT_TRUE(downlink.start(stream, 1160)); } +void test_bridge_audio_downlink_emergency_abort_queues_interrupted_terminal() { + CountingBridgeDownlinkSink sink; + sink.drained = false; + BridgeAudioDownlink downlink; + TEST_ASSERT_TRUE(downlink.begin(true, &sink)); + + BridgeAudioStream stream; + stream.seq = 24; + stream.sampleRate = 16000; + stream.audioBytes = 4; + stream.chunkBytes = 4; + stream.chunks = 1; + strncpy(stream.format, "pcm16", sizeof(stream.format) - 1); + TEST_ASSERT_TRUE(downlink.start(stream, 1200)); + + downlink.abort(1210, 103, true); + + const BridgeAudioDownlinkTelemetry& telemetry = downlink.telemetry(); + TEST_ASSERT_FALSE(telemetry.active); + TEST_ASSERT_FALSE(telemetry.playbackActive); + TEST_ASSERT_FALSE(telemetry.playbackAwaitingDrain); + TEST_ASSERT_TRUE(telemetry.playbackCompletionPending); + TEST_ASSERT_TRUE(telemetry.playbackCompletionInterrupted); + TEST_ASSERT_EQUAL_UINT32(1, telemetry.playbackInterruptions); + TEST_ASSERT_EQUAL_UINT32(0, telemetry.playbackCompletions); + TEST_ASSERT_EQUAL_UINT32(1, telemetry.playbackStops); + TEST_ASSERT_EQUAL_UINT32(1, telemetry.streamsAborted); + TEST_ASSERT_EQUAL_UINT32(1, sink.stopCalls); + + uint32_t terminalSeq = 0; + bool interrupted = false; + TEST_ASSERT_TRUE(downlink.peekPlaybackCompletion(&terminalSeq, &interrupted)); + TEST_ASSERT_EQUAL_UINT32(24, terminalSeq); + TEST_ASSERT_TRUE(interrupted); + TEST_ASSERT_FALSE(downlink.start(stream, 1220)); + TEST_ASSERT_TRUE(downlink.consumePlaybackCompletion()); + TEST_ASSERT_FALSE(downlink.telemetry().playbackCompletionInterrupted); + TEST_ASSERT_TRUE(downlink.start(stream, 1230)); +} + +void test_bridge_audio_downlink_emergency_abort_preserves_natural_completion() { + CountingBridgeDownlinkSink sink; + BridgeAudioDownlink downlink; + TEST_ASSERT_TRUE(downlink.begin(true, &sink)); + + BridgeAudioStream stream; + stream.seq = 25; + stream.sampleRate = 16000; + stream.audioBytes = 4; + stream.chunkBytes = 4; + stream.chunks = 1; + strncpy(stream.format, "pcm16", sizeof(stream.format) - 1); + TEST_ASSERT_TRUE(downlink.start(stream, 1240)); + + const uint8_t payload[] = {0x00, 0x00, 0x01, 0x00}; + BridgeAudioStreamChunk chunk; + chunk.seq = 25; + chunk.index = 1; + chunk.bytes = sizeof(payload); + chunk.payloadBytes = sizeof(payload); + chunk.receivedBytes = sizeof(payload); + chunk.finalChunk = true; + chunk.payload = payload; + TEST_ASSERT_TRUE(downlink.submitChunk(chunk, 1250)); + TEST_ASSERT_TRUE(downlink.end(stream, 1260)); + TEST_ASSERT_TRUE(downlink.telemetry().playbackCompletionPending); + TEST_ASSERT_FALSE(downlink.telemetry().playbackCompletionInterrupted); + + downlink.abort(1270, 103, true); + + uint32_t terminalSeq = 0; + bool interrupted = true; + TEST_ASSERT_TRUE(downlink.peekPlaybackCompletion(&terminalSeq, &interrupted)); + TEST_ASSERT_EQUAL_UINT32(25, terminalSeq); + TEST_ASSERT_FALSE(interrupted); + TEST_ASSERT_EQUAL_UINT32(1, downlink.telemetry().playbackCompletions); + TEST_ASSERT_EQUAL_UINT32(0, downlink.telemetry().playbackInterruptions); +} + +void test_bridge_audio_downlink_plain_abort_does_not_signal_terminal() { + CountingBridgeDownlinkSink sink; + BridgeAudioDownlink downlink; + TEST_ASSERT_TRUE(downlink.begin(true, &sink)); + + BridgeAudioStream stream; + stream.seq = 26; + stream.sampleRate = 16000; + strncpy(stream.format, "pcm16", sizeof(stream.format) - 1); + TEST_ASSERT_TRUE(downlink.start(stream, 1280)); + downlink.abort(1290, 101); + + uint32_t terminalSeq = 0; + bool interrupted = false; + TEST_ASSERT_FALSE(downlink.peekPlaybackCompletion(&terminalSeq, &interrupted)); + TEST_ASSERT_FALSE(downlink.telemetry().playbackCompletionPending); + TEST_ASSERT_EQUAL_UINT32(0, downlink.telemetry().playbackInterruptions); +} + void test_bridge_audio_downlink_counts_unsupported_playback_format_without_failing_stream() { CountingBridgeDownlinkSink sink; BridgeAudioDownlink downlink; @@ -9037,6 +9135,9 @@ int main() { RUN_TEST(test_bridge_audio_downlink_consumes_bridge_payload_output); RUN_TEST(test_bridge_audio_downlink_hands_pcm16_chunks_to_playback_sink); RUN_TEST(test_bridge_audio_downlink_waits_for_physical_speaker_drain); + RUN_TEST(test_bridge_audio_downlink_emergency_abort_queues_interrupted_terminal); + RUN_TEST(test_bridge_audio_downlink_emergency_abort_preserves_natural_completion); + RUN_TEST(test_bridge_audio_downlink_plain_abort_does_not_signal_terminal); RUN_TEST(test_bridge_audio_downlink_counts_unsupported_playback_format_without_failing_stream); RUN_TEST(test_bridge_audio_downlink_stops_playback_on_end_mismatch); RUN_TEST(test_bridge_audio_downlink_rejects_invalid_payload_and_aborts); From 6e9096d59c160e0a86fc6904afc76478fa275506 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 18:12:19 -0400 Subject: [PATCH 42/46] Collect current physical playback evidence --- tools/check_companion_v1_readiness.ps1 | 4 +- tools/check_pc_brain_deploy_evidence.ps1 | 146 +++++++--- tools/collect_pc_brain_deploy_evidence.ps1 | 109 +++++--- ...test_pc_brain_deploy_evidence_contract.cmd | 2 + ...test_pc_brain_deploy_evidence_contract.ps1 | 250 ++++++++++++++++++ tools/verify_release_package.ps1 | 4 +- 6 files changed, 430 insertions(+), 85 deletions(-) create mode 100644 tools/test_pc_brain_deploy_evidence_contract.cmd create mode 100644 tools/test_pc_brain_deploy_evidence_contract.ps1 diff --git a/tools/check_companion_v1_readiness.ps1 b/tools/check_companion_v1_readiness.ps1 index 6002ab5a..470882ff 100644 --- a/tools/check_companion_v1_readiness.ps1 +++ b/tools/check_companion_v1_readiness.ps1 @@ -816,13 +816,13 @@ Test-TextEvidence ` -Id "pc-brain-deploy-evidence-helper" ` -Name "PC Brain deploy evidence collector" ` -RelativePaths @("tools/collect_pc_brain_deploy_evidence.ps1") ` - -Patterns @("stackchan.pc-brain-deploy-evidence.v1", "sourceCommit", "SourceCommit", "Source commit:", "stackchan_debug.json", "PC_BRAIN_DEPLOY_EVIDENCE.json", "PC_BRAIN_DEPLOY_EVIDENCE.md", "bridge_downlink_playback_errors", "audio_stream_not_started", "audio_stream_chunk_mismatch", "playback_chunk_mismatch") + -Patterns @("stackchan.pc-brain-deploy-evidence.v1", "sourceCommit", "SourceCommit", "Source commit:", "stackchan_debug.json", "PC_BRAIN_DEPLOY_EVIDENCE.json", "PC_BRAIN_DEPLOY_EVIDENCE.md", "device_debug_schema_invalid", "device_debug_route_invalid", "bridge_downlink_playback_not_started", "bridge_downlink_playback_not_completed", "bridge_downlink_playback_not_drained", "speaker_playback_chunk_mismatch") Test-TextEvidence ` -Id "pc-brain-deploy-evidence-check" ` -Name "PC Brain deploy evidence checker" ` -RelativePaths @("tools/check_pc_brain_deploy_evidence.ps1") ` - -Patterns @("stackchan.pc-brain-deploy-evidence-check.v1", "stackchan.pc-brain-deploy-evidence.v1", "pc-brain-deploy-ready", "sourceCommit", "Get-ReviewSourceCommit", "source-commit", "human-review-source-commit-match", "audio-stream-started", "playback-started", "speaker-task-bytes-match", "RequireTests", "RequireReady") + -Patterns @("stackchan.pc-brain-deploy-evidence-check.v1", "stackchan.pc-brain-deploy-evidence.v1", "pc-brain-deploy-ready", "sourceCommit", "Get-ReviewSourceCommit", "source-commit", "human-review-source-commit-match", "playback-started", "playback-completed", "playback-drained", "speaker-playback-chunks-match", "RequireTests", "RequireReady") Test-TextEvidence ` -Id "pc-brain-quiet-soak-runner" ` diff --git a/tools/check_pc_brain_deploy_evidence.ps1 b/tools/check_pc_brain_deploy_evidence.ps1 index 3ea2e9d9..fd7bc85e 100644 --- a/tools/check_pc_brain_deploy_evidence.ps1 +++ b/tools/check_pc_brain_deploy_evidence.ps1 @@ -35,6 +35,50 @@ function Get-IntValue { return [int]$property.Value } +function Test-HasProperty { + param( + $Object, + [string]$Name + ) + if ($null -eq $Object) { return $false } + return $null -ne $Object.PSObject.Properties[$Name] +} + +function Normalize-CommandLine { + param([string]$CommandLine) + return ($CommandLine -replace "\\", "/" -replace "\s+", " ").Trim().ToLowerInvariant() +} + +function Test-ExactFlag { + param( + [string]$NormalizedCommandLine, + [string]$Flag + ) + $pattern = "(?:^|\s)" + [regex]::Escape($Flag.ToLowerInvariant()) + "(?:\s|$)" + return [regex]::IsMatch($NormalizedCommandLine, $pattern) +} + +function Test-ExactTtsCommand { + param( + [string]$NormalizedCommandLine, + [string]$Script + ) + $expected = '--tts-command\s+"python\s+' + + [regex]::Escape(($Script -replace "\\", "/").ToLowerInvariant()) + '"' + return [regex]::IsMatch($NormalizedCommandLine, "(?:^|\s)" + $expected + "(?:\s|$)") +} + +function Test-ExactOptionValue { + param( + [string]$NormalizedCommandLine, + [string]$Option, + [string]$Value + ) + $pattern = "(?:^|\s)" + [regex]::Escape($Option.ToLowerInvariant()) + + "\s+" + [regex]::Escape($Value.ToLowerInvariant()) + "(?:\s|$)" + return [regex]::IsMatch($NormalizedCommandLine, $pattern) +} + function Test-ZeroCounter { param( $Object, @@ -96,10 +140,19 @@ if ($evidence) { if ($null -ne $evidence.pc_brain_process -and -not [string]::IsNullOrWhiteSpace([string]$evidence.pc_brain_process.command_line)) { $commandLine = [string]$evidence.pc_brain_process.command_line + $normalizedCommandLine = Normalize-CommandLine $commandLine Add-Check "pc-brain-process" "pass" "pid=$($evidence.pc_brain_process.pid)" - foreach ($pattern in @("lan_service.py", "ollama_stackchan_runner.py", "selected_voice_tts.py", "--require-runner")) { + foreach ($pattern in @("lan_service.py", "ollama_stackchan_runner.py", "--require-runner")) { Add-Check "pc-brain-command-$pattern" ($(if ($commandLine -match [regex]::Escape($pattern)) { "pass" } else { "fail" })) "command includes $pattern" } + $selectedVoiceTts = Test-ExactTtsCommand $normalizedCommandLine "bridge/selected_voice_tts.py" + $productionDirectMlTts = + (Test-ExactTtsCommand $normalizedCommandLine "bridge/rvc_production_tts_client.py") -and + (Test-ExactFlag $normalizedCommandLine "--in-process-directml-tts") -and + (Test-ExactOptionValue $normalizedCommandLine "--tts-voice" "stackchan-rvc-directml-v2") + Add-Check "pc-brain-command-tts" ` + ($(if ($selectedVoiceTts -or $productionDirectMlTts) { "pass" } else { "fail" })) ` + "TTS is exact selected-voice smoke or production in-process DirectML command." } else { Add-Check "pc-brain-process" "fail" "PC brain process details are missing." } @@ -110,55 +163,64 @@ if ($evidence) { } else { Add-Check "device-debug" "pass" "Device debug payload is present." Add-Check "debug-schema" ($(if ($debug.schema -eq "stackchan.bridge-debug.v1") { "pass" } else { "fail" })) "schema=$($debug.schema)" + Add-Check "debug-route" ($(if ($debug.debug_request_route -eq "debug") { "pass" } else { "fail" })) "debug_request_route=$($debug.debug_request_route)" + Add-Check "debug-result" ($(if ($debug.debug_request_result -eq "debug") { "pass" } else { "fail" })) "debug_request_result=$($debug.debug_request_result)" Add-Check "wifi-connected" ($(if ($debug.wifi_connected -eq $true) { "pass" } else { "fail" })) "wifi_connected=$($debug.wifi_connected)" Add-Check "network-connected" ($(if ($debug.network_state -eq "connected") { "pass" } else { "fail" })) "network_state=$($debug.network_state)" Add-Check "bridge-ready" ($(if ($debug.bridge_state -eq "ready") { "pass" } else { "fail" })) "bridge_state=$($debug.bridge_state)" - Add-Check "playback-ready" ($(if ($debug.bridge_downlink_playback_ready -eq $true) { "pass" } else { "fail" })) "bridge_downlink_playback_ready=$($debug.bridge_downlink_playback_ready)" Add-Check "speaker-enabled" ($(if ((Get-IntValue $debug "speaker_enabled" 0) -eq 1) { "pass" } else { "fail" })) "speaker_enabled=$($debug.speaker_enabled)" Add-Check "speaker-volume-safe" ($(if ((Get-IntValue $debug "speaker_volume" 0) -gt 0 -and (Get-IntValue $debug "speaker_volume" 0) -le 180) { "pass" } else { "fail" })) "speaker_volume=$($debug.speaker_volume)" - foreach ($counter in @( - "bridge_outputs_dropped", - "bridge_parse_errors", - "bridge_timeouts", - "audio_stream_errors", - "bridge_downlink_errors", + $requiredPlaybackFields = @( + "audio_stream_active", + "bridge_downlink_playback_starts", + "bridge_downlink_playback_chunks", + "bridge_downlink_playback_bytes", + "bridge_downlink_playback_stops", + "bridge_downlink_playback_awaiting_drain", + "bridge_downlink_playback_completion_pending", + "bridge_downlink_playback_completions", + "bridge_downlink_playback_completion_signals", "bridge_downlink_playback_errors", - "bridge_downlink_playback_unsupported", - "speaker_stream_play_raw_failed" - )) { - Test-ZeroCounter $debug $counter + "speaker_stream_play_raw_ok", + "speaker_stream_play_raw_failed", + "speaker_stream_forced_stops", + "speaker_stream_orphan_stops", + "speaker_running", + "speaker_channel_state" + ) + $missingPlaybackFields = @($requiredPlaybackFields | Where-Object { -not (Test-HasProperty $debug $_) }) + foreach ($field in $missingPlaybackFields) { + Add-Check "device-debug-field-$field" "fail" "Required current /debug field is missing: $field" } - $audioStarts = Get-IntValue $debug "audio_streams_started" 0 - $audioEnds = Get-IntValue $debug "audio_streams_ended" 0 - $expectedBytes = Get-IntValue $debug "audio_stream_bytes_expected" 0 - $receivedBytes = Get-IntValue $debug "audio_stream_bytes_received" 0 - $expectedChunks = Get-IntValue $debug "audio_stream_chunks_expected" 0 - $receivedChunks = Get-IntValue $debug "audio_stream_chunks_received" 0 - $downlinkStreams = Get-IntValue $debug "bridge_downlink_streams" 0 - $downlinkCompleted = Get-IntValue $debug "bridge_downlink_completed" 0 - $downlinkBytes = Get-IntValue $debug "bridge_downlink_bytes" 0 - $downlinkChunks = Get-IntValue $debug "bridge_downlink_chunks" 0 - $playbackStarts = Get-IntValue $debug "bridge_downlink_playback_starts" 0 - $playbackBytes = Get-IntValue $debug "bridge_downlink_playback_bytes" 0 - $playbackChunks = Get-IntValue $debug "bridge_downlink_playback_chunks" 0 - $speakerTaskBytes = Get-IntValue $debug "speaker_stream_task_bytes" 0 - $speakerTaskChunks = Get-IntValue $debug "speaker_stream_task_chunks" 0 - - Add-Check "audio-stream-started" ($(if ($audioStarts -ge 1) { "pass" } else { "fail" })) "audio_streams_started=$audioStarts" - Add-Check "audio-stream-ended" ($(if ($audioEnds -ge 1) { "pass" } else { "fail" })) "audio_streams_ended=$audioEnds" - Add-Check "audio-stream-inactive" ($(if ($debug.audio_stream_active -eq $false) { "pass" } else { "fail" })) "audio_stream_active=$($debug.audio_stream_active)" - Add-Check "audio-stream-bytes-match" ($(if ($expectedBytes -gt 0 -and $expectedBytes -eq $receivedBytes) { "pass" } else { "fail" })) "bytes=$receivedBytes/$expectedBytes" - Add-Check "audio-stream-chunks-match" ($(if ($expectedChunks -gt 0 -and $expectedChunks -eq $receivedChunks) { "pass" } else { "fail" })) "chunks=$receivedChunks/$expectedChunks" - Add-Check "downlink-stream-completed" ($(if ($downlinkStreams -ge 1 -and $downlinkCompleted -ge 1) { "pass" } else { "fail" })) "streams=$downlinkStreams completed=$downlinkCompleted" - Add-Check "downlink-bytes-match" ($(if ($downlinkBytes -eq $expectedBytes -and $downlinkBytes -gt 0) { "pass" } else { "fail" })) "downlink_bytes=$downlinkBytes expected=$expectedBytes" - Add-Check "downlink-chunks-match" ($(if ($downlinkChunks -eq $expectedChunks -and $downlinkChunks -gt 0) { "pass" } else { "fail" })) "downlink_chunks=$downlinkChunks expected=$expectedChunks" - Add-Check "playback-started" ($(if ($playbackStarts -ge 1) { "pass" } else { "fail" })) "bridge_downlink_playback_starts=$playbackStarts" - Add-Check "playback-bytes-match" ($(if ($playbackBytes -eq $expectedBytes -and $playbackBytes -gt 0) { "pass" } else { "fail" })) "playback_bytes=$playbackBytes expected=$expectedBytes" - Add-Check "playback-chunks-match" ($(if ($playbackChunks -eq $expectedChunks -and $playbackChunks -gt 0) { "pass" } else { "fail" })) "playback_chunks=$playbackChunks expected=$expectedChunks" - Add-Check "speaker-task-bytes-match" ($(if ($speakerTaskBytes -eq $expectedBytes -and $speakerTaskBytes -gt 0) { "pass" } else { "fail" })) "speaker_task_bytes=$speakerTaskBytes expected=$expectedBytes" - Add-Check "speaker-task-chunks-match" ($(if ($speakerTaskChunks -eq $expectedChunks -and $speakerTaskChunks -gt 0) { "pass" } else { "fail" })) "speaker_task_chunks=$speakerTaskChunks expected=$expectedChunks" + if ($missingPlaybackFields.Count -eq 0) { + foreach ($counter in @( + "bridge_downlink_playback_errors", + "speaker_stream_play_raw_failed", + "speaker_stream_forced_stops", + "speaker_stream_orphan_stops" + )) { + Test-ZeroCounter $debug $counter + } + + $playbackStarts = Get-IntValue $debug "bridge_downlink_playback_starts" -1 + $playbackBytes = Get-IntValue $debug "bridge_downlink_playback_bytes" -1 + $playbackChunks = Get-IntValue $debug "bridge_downlink_playback_chunks" -1 + $playbackStops = Get-IntValue $debug "bridge_downlink_playback_stops" -1 + $playbackCompletions = Get-IntValue $debug "bridge_downlink_playback_completions" -1 + $playbackSignals = Get-IntValue $debug "bridge_downlink_playback_completion_signals" -1 + $speakerRawOk = Get-IntValue $debug "speaker_stream_play_raw_ok" -1 + + Add-Check "playback-started" ($(if ($playbackStarts -ge 1) { "pass" } else { "fail" })) "starts=$playbackStarts" + Add-Check "playback-payload-present" ($(if ($playbackBytes -gt 0 -and $playbackChunks -gt 0) { "pass" } else { "fail" })) "chunks=$playbackChunks bytes=$playbackBytes" + Add-Check "playback-completed" ($(if ($playbackStarts -ge 1 -and $playbackStops -eq $playbackStarts -and $playbackCompletions -eq $playbackStarts) { "pass" } else { "fail" })) "starts=$playbackStarts stops=$playbackStops completions=$playbackCompletions" + Add-Check "playback-completion-signaled" ($(if ($playbackSignals -eq $playbackCompletions -and $playbackSignals -gt 0) { "pass" } else { "fail" })) "signals=$playbackSignals completions=$playbackCompletions" + Add-Check "playback-drained" ($(if (-not [bool]$debug.bridge_downlink_playback_awaiting_drain -and -not [bool]$debug.bridge_downlink_playback_completion_pending) { "pass" } else { "fail" })) "awaiting_drain=$($debug.bridge_downlink_playback_awaiting_drain) completion_pending=$($debug.bridge_downlink_playback_completion_pending)" + Add-Check "audio-stream-inactive" ($(if (-not [bool]$debug.audio_stream_active) { "pass" } else { "fail" })) "audio_stream_active=$($debug.audio_stream_active)" + Add-Check "speaker-playback-chunks-match" ($(if ($speakerRawOk -eq $playbackChunks -and $speakerRawOk -gt 0) { "pass" } else { "fail" })) "raw_ok=$speakerRawOk playback_chunks=$playbackChunks" + Add-Check "speaker-playback-idle" ($(if (-not [bool]$debug.speaker_running -and (Get-IntValue $debug "speaker_channel_state" -1) -eq 0) { "pass" } else { "fail" })) "speaker_running=$($debug.speaker_running) channel_state=$($debug.speaker_channel_state)" + } } if ($RequireTests) { @@ -175,7 +237,7 @@ if (-not [string]::IsNullOrWhiteSpace($EvidenceMarkdownPath)) { Add-Check "evidence-markdown" "fail" "Missing evidence markdown: $EvidenceMarkdownPath" } else { $markdown = Get-Content -LiteralPath $EvidenceMarkdownPath -Raw - foreach ($pattern in @("Stackchan PC Brain Deploy Evidence", "Status: ``pass``", "Audio streams:", "Playback:")) { + foreach ($pattern in @("Stackchan PC Brain Deploy Evidence", "Status: ``pass``", "Playback:", "Playback payload:", "Speaker sink:")) { Add-Check "evidence-markdown-$pattern" ($(if ($markdown -match [regex]::Escape($pattern)) { "pass" } else { "fail" })) "markdown includes $pattern" } Add-Check "evidence-markdown-source-commit" ($(if ($markdown -match "Source commit:\s*``[a-fA-F0-9]{40}``") { "pass" } else { "fail" })) "markdown includes source commit" diff --git a/tools/collect_pc_brain_deploy_evidence.ps1 b/tools/collect_pc_brain_deploy_evidence.ps1 index 62dbcda3..2a1c6658 100644 --- a/tools/collect_pc_brain_deploy_evidence.ps1 +++ b/tools/collect_pc_brain_deploy_evidence.ps1 @@ -31,6 +31,13 @@ function Get-IntValue($Object, [string]$Name, [int]$DefaultValue) { return [int]$Property.Value } +function Test-HasProperty($Object, [string]$Name) { + if ($null -eq $Object) { + return $false + } + return $null -ne $Object.PSObject.Properties[$Name] +} + function Resolve-SourceCommit { param([string]$Value) @@ -105,8 +112,8 @@ foreach ($Name in @("lan_service.out.log", "lan_service.err.log", "lan_service.p } } +$DebugUri = "http://$DeviceHost`:$DebugPort/debug" try { - $DebugUri = "http://$DeviceHost`:$DebugPort/" $DebugResponse = Invoke-WebRequest -Uri $DebugUri -UseBasicParsing -TimeoutSec 8 $DebugBody = [string]$DebugResponse.Content $DebugPath = Join-Path $OutDir "stackchan_debug.json" @@ -147,43 +154,66 @@ if ($RunTests) { if ($summary.device_debug) { $Debug = $summary.device_debug + if ($Debug.schema -ne "stackchan.bridge-debug.v1") { $summary.issues += "device_debug_schema_invalid" } + if ($Debug.debug_request_route -ne "debug") { $summary.issues += "device_debug_route_invalid" } + if ($Debug.debug_request_result -ne "debug") { $summary.issues += "device_debug_result_invalid" } if ($Debug.network_state -ne "connected") { $summary.issues += "device_network_not_connected" } if ($Debug.bridge_state -ne "ready") { $summary.issues += "bridge_not_ready" } - if ((Get-IntValue $Debug "bridge_outputs_dropped" 0) -ne 0) { $summary.issues += "bridge_outputs_dropped" } - if ((Get-IntValue $Debug "bridge_parse_errors" 0) -ne 0) { $summary.issues += "bridge_parse_errors" } - if ((Get-IntValue $Debug "bridge_timeouts" 0) -ne 0) { $summary.issues += "bridge_timeouts" } - if ((Get-IntValue $Debug "audio_stream_errors" 0) -ne 0) { $summary.issues += "audio_stream_errors" } - if ((Get-IntValue $Debug "bridge_downlink_errors" 0) -ne 0) { $summary.issues += "bridge_downlink_errors" } - if ((Get-IntValue $Debug "bridge_downlink_playback_errors" 0) -ne 0) { $summary.issues += "bridge_downlink_playback_errors" } - if ((Get-IntValue $Debug "bridge_downlink_playback_unsupported" 0) -ne 0) { $summary.issues += "bridge_downlink_playback_unsupported" } - if ((Get-IntValue $Debug "speaker_stream_play_raw_failed" 0) -ne 0) { $summary.issues += "speaker_stream_play_raw_failed" } - if ((Get-IntValue $Debug "audio_streams_started" 0) -lt 1) { $summary.issues += "audio_stream_not_started" } - if ((Get-IntValue $Debug "audio_streams_ended" 0) -lt 1) { $summary.issues += "audio_stream_not_ended" } - if ((Get-IntValue $Debug "bridge_downlink_streams" 0) -lt 1) { $summary.issues += "bridge_downlink_stream_missing" } - if ((Get-IntValue $Debug "bridge_downlink_completed" 0) -lt 1) { $summary.issues += "bridge_downlink_not_completed" } - if ((Get-IntValue $Debug "bridge_downlink_playback_starts" 0) -lt 1) { $summary.issues += "bridge_downlink_playback_not_started" } - if ((Get-IntValue $Debug "audio_stream_bytes_expected" 0) -le 0) { $summary.issues += "audio_stream_bytes_missing" } - if ((Get-IntValue $Debug "audio_stream_chunks_expected" 0) -le 0) { $summary.issues += "audio_stream_chunks_missing" } - if ((Get-IntValue $Debug "bridge_downlink_bytes" 0) -ne (Get-IntValue $Debug "audio_stream_bytes_expected" -1)) { - $summary.issues += "bridge_downlink_byte_mismatch" - } - if ((Get-IntValue $Debug "bridge_downlink_chunks" 0) -ne (Get-IntValue $Debug "audio_stream_chunks_expected" -1)) { - $summary.issues += "bridge_downlink_chunk_mismatch" - } - if ((Get-IntValue $Debug "bridge_downlink_playback_bytes" 0) -ne (Get-IntValue $Debug "audio_stream_bytes_expected" -1)) { - $summary.issues += "playback_byte_mismatch" - } - if ((Get-IntValue $Debug "audio_stream_chunks_expected" 0) -ne (Get-IntValue $Debug "audio_stream_chunks_received" -1)) { - $summary.issues += "audio_stream_chunk_mismatch" - } - if ((Get-IntValue $Debug "bridge_downlink_playback_chunks" 0) -ne (Get-IntValue $Debug "audio_stream_chunks_expected" -1)) { - $summary.issues += "playback_chunk_mismatch" - } - if ((Get-IntValue $Debug "speaker_stream_task_bytes" 0) -ne (Get-IntValue $Debug "audio_stream_bytes_expected" -1)) { - $summary.issues += "speaker_task_byte_mismatch" + + $RequiredPlaybackFields = @( + "audio_stream_active", + "bridge_downlink_playback_starts", + "bridge_downlink_playback_chunks", + "bridge_downlink_playback_bytes", + "bridge_downlink_playback_stops", + "bridge_downlink_playback_awaiting_drain", + "bridge_downlink_playback_completion_pending", + "bridge_downlink_playback_completions", + "bridge_downlink_playback_completion_signals", + "bridge_downlink_playback_errors", + "speaker_stream_play_raw_ok", + "speaker_stream_play_raw_failed", + "speaker_stream_forced_stops", + "speaker_stream_orphan_stops", + "speaker_running", + "speaker_channel_state" + ) + $MissingPlaybackFields = @($RequiredPlaybackFields | Where-Object { -not (Test-HasProperty $Debug $_) }) + foreach ($Field in $MissingPlaybackFields) { + $summary.issues += "device_debug_field_missing:$Field" } - if ((Get-IntValue $Debug "speaker_stream_task_chunks" 0) -ne (Get-IntValue $Debug "audio_stream_chunks_expected" -1)) { - $summary.issues += "speaker_task_chunk_mismatch" + + if ($MissingPlaybackFields.Count -eq 0) { + $PlaybackStarts = Get-IntValue $Debug "bridge_downlink_playback_starts" -1 + $PlaybackChunks = Get-IntValue $Debug "bridge_downlink_playback_chunks" -1 + $PlaybackBytes = Get-IntValue $Debug "bridge_downlink_playback_bytes" -1 + $PlaybackStops = Get-IntValue $Debug "bridge_downlink_playback_stops" -1 + $PlaybackCompletions = Get-IntValue $Debug "bridge_downlink_playback_completions" -1 + $PlaybackSignals = Get-IntValue $Debug "bridge_downlink_playback_completion_signals" -1 + $SpeakerRawOk = Get-IntValue $Debug "speaker_stream_play_raw_ok" -1 + + if ($PlaybackStarts -lt 1) { $summary.issues += "bridge_downlink_playback_not_started" } + if ($PlaybackChunks -lt 1) { $summary.issues += "bridge_downlink_playback_chunks_missing" } + if ($PlaybackBytes -lt 1) { $summary.issues += "bridge_downlink_playback_bytes_missing" } + if ($PlaybackStops -ne $PlaybackStarts -or $PlaybackCompletions -ne $PlaybackStarts) { + $summary.issues += "bridge_downlink_playback_not_completed" + } + if ($PlaybackSignals -ne $PlaybackCompletions) { + $summary.issues += "bridge_downlink_playback_completion_not_signaled" + } + if ([bool]$Debug.bridge_downlink_playback_awaiting_drain -or + [bool]$Debug.bridge_downlink_playback_completion_pending) { + $summary.issues += "bridge_downlink_playback_not_drained" + } + if ([bool]$Debug.audio_stream_active) { $summary.issues += "audio_stream_active" } + if ((Get-IntValue $Debug "bridge_downlink_playback_errors" -1) -ne 0) { $summary.issues += "bridge_downlink_playback_errors" } + if ((Get-IntValue $Debug "speaker_stream_play_raw_failed" -1) -ne 0) { $summary.issues += "speaker_stream_play_raw_failed" } + if ((Get-IntValue $Debug "speaker_stream_forced_stops" -1) -ne 0) { $summary.issues += "speaker_stream_forced_stops" } + if ((Get-IntValue $Debug "speaker_stream_orphan_stops" -1) -ne 0) { $summary.issues += "speaker_stream_orphan_stops" } + if ($SpeakerRawOk -ne $PlaybackChunks) { $summary.issues += "speaker_playback_chunk_mismatch" } + if ([bool]$Debug.speaker_running -or (Get-IntValue $Debug "speaker_channel_state" -1) -ne 0) { + $summary.issues += "speaker_playback_not_idle" + } } } @@ -201,7 +231,7 @@ $lines = @( "- Status: ``$($summary.status)``", "- Generated: ``$($summary.generated_at)``", "- Source commit: ``$($summary.sourceCommit)``", - "- Device debug: ``http://$DeviceHost`:$DebugPort/``", + "- Device debug: ``$DebugUri``", "- PC brain PID: ``$(if ($summary.pc_brain_process) { $summary.pc_brain_process.pid } else { 'missing' })``", "- Copied logs: ``$($summary.copied_logs -join ', ')``" ) @@ -210,9 +240,10 @@ if ($summary.device_debug) { $lines += @( "- Network state: ``$($Debug.network_state)``", "- Bridge state: ``$($Debug.bridge_state)``", - "- Bridge errors: dropped=``$($Debug.bridge_outputs_dropped)`` parse=``$($Debug.bridge_parse_errors)`` timeouts=``$($Debug.bridge_timeouts)``", - "- Audio streams: started=``$($Debug.audio_streams_started)`` ended=``$($Debug.audio_streams_ended)`` chunks=``$($Debug.audio_stream_chunks_received)/$($Debug.audio_stream_chunks_expected)``", - "- Playback: chunks=``$($Debug.bridge_downlink_playback_chunks)`` bytes=``$($Debug.bridge_downlink_playback_bytes)`` errors=``$($Debug.bridge_downlink_playback_errors)``" + "- Debug route: ``$($Debug.debug_request_route)`` result=``$($Debug.debug_request_result)``", + "- Playback: starts=``$($Debug.bridge_downlink_playback_starts)`` stops=``$($Debug.bridge_downlink_playback_stops)`` completions=``$($Debug.bridge_downlink_playback_completions)`` signals=``$($Debug.bridge_downlink_playback_completion_signals)``", + "- Playback payload: chunks=``$($Debug.bridge_downlink_playback_chunks)`` bytes=``$($Debug.bridge_downlink_playback_bytes)`` errors=``$($Debug.bridge_downlink_playback_errors)``", + "- Speaker sink: raw_ok=``$($Debug.speaker_stream_play_raw_ok)`` raw_failed=``$($Debug.speaker_stream_play_raw_failed)`` running=``$($Debug.speaker_running)``" ) } if ($summary.tests.Count -gt 0) { diff --git a/tools/test_pc_brain_deploy_evidence_contract.cmd b/tools/test_pc_brain_deploy_evidence_contract.cmd new file mode 100644 index 00000000..2ade246c --- /dev/null +++ b/tools/test_pc_brain_deploy_evidence_contract.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0test_pc_brain_deploy_evidence_contract.ps1" %* diff --git a/tools/test_pc_brain_deploy_evidence_contract.ps1 b/tools/test_pc_brain_deploy_evidence_contract.ps1 new file mode 100644 index 00000000..d2406eb2 --- /dev/null +++ b/tools/test_pc_brain_deploy_evidence_contract.ps1 @@ -0,0 +1,250 @@ +param() + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$collectorPath = Join-Path $PSScriptRoot "collect_pc_brain_deploy_evidence.ps1" +$checkerPath = Join-Path $PSScriptRoot "check_pc_brain_deploy_evidence.ps1" +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-pc-brain-deploy-contract-" + [guid]::NewGuid().ToString("N")) + +function Write-JsonFile { + param( + [string]$Path, + [object]$Value + ) + $Value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $Path -Encoding UTF8 +} + +function Copy-Value { + param([object]$Value) + return ($Value | ConvertTo-Json -Depth 12 | ConvertFrom-Json) +} + +function Invoke-EvidenceCheck { + param( + [object]$Evidence, + [string]$Name + ) + $caseDir = Join-Path $tempRoot $Name + New-Item -ItemType Directory -Force -Path $caseDir | Out-Null + $jsonPath = Join-Path $caseDir "PC_BRAIN_DEPLOY_EVIDENCE.json" + $markdownPath = Join-Path $caseDir "PC_BRAIN_DEPLOY_EVIDENCE.md" + Write-JsonFile -Path $jsonPath -Value $Evidence + @" +# Stackchan PC Brain Deploy Evidence + +- Status: ``pass`` +- Source commit: ``$($Evidence.sourceCommit)`` +- Playback: starts=``2`` stops=``2`` completions=``2`` signals=``2`` +- Playback payload: chunks=``8`` bytes=``32768`` errors=``0`` +- Speaker sink: raw_ok=``8`` raw_failed=``0`` running=``False`` +"@ | Set-Content -LiteralPath $markdownPath -Encoding UTF8 + + $powerShellExe = (Get-Process -Id $PID).Path + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & $powerShellExe -NoProfile -ExecutionPolicy Bypass -File $checkerPath ` + -EvidenceJsonPath $jsonPath -EvidenceMarkdownPath $markdownPath -RequireReady -Json 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } + $text = ($output | Out-String).Trim() + $report = if ([string]::IsNullOrWhiteSpace($text)) { $null } else { $text | ConvertFrom-Json } + return [pscustomobject]@{ + exitCode = $exitCode + report = $report + text = $text + } +} + +function Assert-CheckStatus { + param( + [object]$Result, + [string]$Id, + [string]$Status + ) + $checks = @($Result.report.checks | Where-Object { $_.id -eq $Id }) + if ($checks.Count -ne 1) { + throw "Expected exactly one '$Id' check, found $($checks.Count). Output:`n$($Result.text)" + } + if ($checks[0].status -ne $Status) { + throw "Expected '$Id'=$Status, got $($checks[0].status). Detail: $($checks[0].detail)" + } +} + +try { + New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null + Set-Location $repoRoot + + $collectorText = Get-Content -LiteralPath $collectorPath -Raw + if (-not $collectorText.Contains('$DebugUri = "http://$DeviceHost`:$DebugPort/debug"')) { + throw "Collector does not fetch the exact /debug route." + } + if ($collectorText.Contains('$DebugUri = "http://$DeviceHost`:$DebugPort/"')) { + throw "Collector still contains the obsolete root status route." + } + if (-not $collectorText.Contains('"- Device debug: ``$DebugUri``"')) { + throw "Collector Markdown does not record its exact /debug URI." + } + Write-Host "[ok] collector freezes the exact /debug route in capture and Markdown" + + foreach ($staticContractPath in @( + (Join-Path $PSScriptRoot "check_companion_v1_readiness.ps1"), + (Join-Path $PSScriptRoot "verify_release_package.ps1") + )) { + $staticContractText = Get-Content -LiteralPath $staticContractPath -Raw + foreach ($marker in @("playback-started", "playback-completed", "playback-drained", "speaker-playback-chunks-match")) { + if (-not $staticContractText.Contains($marker)) { + throw "$staticContractPath does not preserve the current deploy evidence marker '$marker'." + } + } + foreach ($collectorMarker in @( + "device_debug_schema_invalid", + "device_debug_route_invalid", + "bridge_downlink_playback_not_started", + "bridge_downlink_playback_not_completed", + "bridge_downlink_playback_not_drained", + "speaker_playback_chunk_mismatch" + )) { + if (-not $staticContractText.Contains($collectorMarker)) { + throw "$staticContractPath does not preserve the current collector marker '$collectorMarker'." + } + } + } + Write-Host "[ok] readiness and release-package static contracts require current collector and checker markers" + + $productionCommand = '"C:\Python310\python.exe" bridge\lan_service.py --tts-command "python bridge\rvc_production_tts_client.py" --tts-voice stackchan-rvc-directml-v2 --runner-command "python bridge\ollama_stackchan_runner.py" --require-runner --in-process-directml-tts' + $debug = [ordered]@{ + schema = "stackchan.bridge-debug.v1" + debug_request_route = "debug" + debug_request_result = "debug" + wifi_connected = $true + network_state = "connected" + bridge_state = "ready" + speaker_enabled = $true + speaker_volume = 96 + audio_stream_active = $false + bridge_downlink_playback_starts = 2 + bridge_downlink_playback_chunks = 8 + bridge_downlink_playback_bytes = 32768 + bridge_downlink_playback_stops = 2 + bridge_downlink_playback_awaiting_drain = $false + bridge_downlink_playback_completion_pending = $false + bridge_downlink_playback_completions = 2 + bridge_downlink_playback_completion_signals = 2 + bridge_downlink_playback_errors = 0 + speaker_stream_play_raw_ok = 8 + speaker_stream_play_raw_failed = 0 + speaker_stream_forced_stops = 0 + speaker_stream_orphan_stops = 0 + speaker_running = $false + speaker_channel_state = 0 + } + $evidence = [ordered]@{ + schema = "stackchan.pc-brain-deploy-evidence.v1" + status = "pass" + sourceCommit = "1362453cdd136b4a74297b045055a5114226b814" + issues = @() + pc_brain_process = [ordered]@{ + pid = 1234 + command_line = $productionCommand + } + device_debug = $debug + tests = @() + } + + $passing = Invoke-EvidenceCheck -Evidence $evidence -Name "passing-production-directml" + if ($passing.exitCode -ne 0 -or $passing.report.status -ne "pc-brain-deploy-ready") { + throw "Expected current production DirectML evidence without legacy counters to pass. Output:`n$($passing.text)" + } + foreach ($id in @( + "pc-brain-command-tts", + "debug-schema", + "debug-route", + "debug-result", + "playback-started", + "playback-completed", + "playback-completion-signaled", + "playback-drained", + "speaker-playback-chunks-match", + "speaker-playback-idle" + )) { + Assert-CheckStatus -Result $passing -Id $id -Status "pass" + } + Write-Host "[ok] current production DirectML command and current /debug telemetry pass without legacy fields" + + $selectedVoiceEvidence = Copy-Value $evidence + $selectedVoiceEvidence.pc_brain_process.command_line = '"C:\Python310\python.exe" bridge\lan_service.py --tts-command "python bridge\selected_voice_tts.py" --runner-command "python bridge\ollama_stackchan_runner.py" --require-runner' + $selectedVoice = Invoke-EvidenceCheck -Evidence $selectedVoiceEvidence -Name "passing-selected-voice-smoke" + if ($selectedVoice.exitCode -ne 0) { + throw "Expected exact selected-voice smoke command to remain accepted. Output:`n$($selectedVoice.text)" + } + Assert-CheckStatus -Result $selectedVoice -Id "pc-brain-command-tts" -Status "pass" + Write-Host "[ok] exact selected-voice smoke command remains accepted" + + $missingModeEvidence = Copy-Value $evidence + $missingModeEvidence.pc_brain_process.command_line = $productionCommand -replace " --in-process-directml-tts", "" + $missingMode = Invoke-EvidenceCheck -Evidence $missingModeEvidence -Name "missing-directml-mode" + if ($missingMode.exitCode -eq 0) { throw "Production TTS without --in-process-directml-tts unexpectedly passed." } + Assert-CheckStatus -Result $missingMode -Id "pc-brain-command-tts" -Status "fail" + Write-Host "[ok] production TTS without in-process DirectML mode is rejected" + + $spoofedTtsEvidence = Copy-Value $evidence + $spoofedTtsEvidence.pc_brain_process.command_line = '"C:\Python310\python.exe" bridge\lan_service.py --tts-command "python bridge\unknown_tts.py" --tts-voice stackchan-rvc-directml-v2 --runner-command "python bridge\ollama_stackchan_runner.py" --require-runner --in-process-directml-tts --note ''--tts-command "python bridge\rvc_production_tts_client.py"''' + $spoofedTts = Invoke-EvidenceCheck -Evidence $spoofedTtsEvidence -Name "spoofed-tts-substring" + if ($spoofedTts.exitCode -eq 0) { throw "An allowed TTS filename outside --tts-command unexpectedly passed." } + Assert-CheckStatus -Result $spoofedTts -Id "pc-brain-command-tts" -Status "fail" + Write-Host "[ok] an allowed filename outside the exact --tts-command value cannot spoof the TTS gate" + + $rootRouteEvidence = Copy-Value $evidence + $rootRouteEvidence.device_debug.schema = "stackchan.bridge-status.v1" + $rootRouteEvidence.device_debug.debug_request_route = "root" + $rootRouteEvidence.device_debug.debug_request_result = "status" + $rootRoute = Invoke-EvidenceCheck -Evidence $rootRouteEvidence -Name "root-route" + if ($rootRoute.exitCode -eq 0) { throw "Root status evidence unexpectedly passed as /debug evidence." } + foreach ($id in @("debug-schema", "debug-route", "debug-result")) { + Assert-CheckStatus -Result $rootRoute -Id $id -Status "fail" + } + Write-Host "[ok] root bridge-status evidence is rejected" + + $missingFieldEvidence = Copy-Value $evidence + $missingFieldEvidence.device_debug.PSObject.Properties.Remove("bridge_downlink_playback_completions") + $missingField = Invoke-EvidenceCheck -Evidence $missingFieldEvidence -Name "missing-required-field" + if ($missingField.exitCode -eq 0) { throw "Missing required playback telemetry unexpectedly passed." } + Assert-CheckStatus -Result $missingField -Id "device-debug-field-bridge_downlink_playback_completions" -Status "fail" + Write-Host "[ok] missing required fields fail instead of defaulting to zero" + + $incompleteEvidence = Copy-Value $evidence + $incompleteEvidence.device_debug.bridge_downlink_playback_completions = 0 + $incompleteEvidence.device_debug.bridge_downlink_playback_completion_signals = 0 + $incomplete = Invoke-EvidenceCheck -Evidence $incompleteEvidence -Name "incomplete-playback" + if ($incomplete.exitCode -eq 0) { throw "Incomplete playback unexpectedly passed." } + Assert-CheckStatus -Result $incomplete -Id "playback-completed" -Status "fail" + Write-Host "[ok] incomplete playback is rejected" + + $errorEvidence = Copy-Value $evidence + $errorEvidence.device_debug.bridge_downlink_playback_errors = 1 + $playbackError = Invoke-EvidenceCheck -Evidence $errorEvidence -Name "playback-error" + if ($playbackError.exitCode -eq 0) { throw "Nonzero playback errors unexpectedly passed." } + Assert-CheckStatus -Result $playbackError -Id "bridge_downlink_playback_errors" -Status "fail" + Write-Host "[ok] explicit playback errors are rejected" + + $mismatchEvidence = Copy-Value $evidence + $mismatchEvidence.device_debug.speaker_stream_play_raw_ok = 7 + $mismatch = Invoke-EvidenceCheck -Evidence $mismatchEvidence -Name "speaker-chunk-mismatch" + if ($mismatch.exitCode -eq 0) { throw "Speaker/playback chunk mismatch unexpectedly passed." } + Assert-CheckStatus -Result $mismatch -Id "speaker-playback-chunks-match" -Status "fail" + Write-Host "[ok] speaker/playback chunk mismatch is rejected" + + Write-Host "PC Brain deploy evidence contract tests passed." +} finally { + Set-Location $repoRoot + if (Test-Path -LiteralPath $tempRoot) { + $resolvedTempRoot = (Resolve-Path -LiteralPath $tempRoot).Path + if ($resolvedTempRoot.StartsWith([System.IO.Path]::GetTempPath(), [System.StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $resolvedTempRoot -Recurse -Force + } + } +} diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index fe14cb13..a62ad3f8 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -4078,14 +4078,14 @@ foreach ($pattern in @("stackchan.voice-v2-supervised-check.v1", "voice-v2-super } $collectPcBrainEvidenceText = Get-Content -LiteralPath (Join-PackagePath "tools/collect_pc_brain_deploy_evidence.ps1") -Raw -foreach ($pattern in @("stackchan.pc-brain-deploy-evidence.v1", "sourceCommit", "SourceCommit", "Source commit:", "stackchan_debug.json", "PC_BRAIN_DEPLOY_EVIDENCE.json", "PC_BRAIN_DEPLOY_EVIDENCE.md", "bridge_downlink_playback_errors", "audio_stream_not_started", "bridge_downlink_playback_not_started", "audio_stream_chunk_mismatch", "playback_chunk_mismatch", "RunTests")) { +foreach ($pattern in @("stackchan.pc-brain-deploy-evidence.v1", "sourceCommit", "SourceCommit", "Source commit:", "stackchan_debug.json", "PC_BRAIN_DEPLOY_EVIDENCE.json", "PC_BRAIN_DEPLOY_EVIDENCE.md", "device_debug_schema_invalid", "device_debug_route_invalid", "bridge_downlink_playback_not_started", "bridge_downlink_playback_not_completed", "bridge_downlink_playback_not_drained", "speaker_playback_chunk_mismatch", "RunTests")) { if ($collectPcBrainEvidenceText -notmatch [regex]::Escape($pattern)) { throw "tools/collect_pc_brain_deploy_evidence.ps1 missing PC brain deploy evidence support: $pattern" } } $checkPcBrainEvidenceText = Get-Content -LiteralPath (Join-PackagePath "tools/check_pc_brain_deploy_evidence.ps1") -Raw -foreach ($pattern in @("stackchan.pc-brain-deploy-evidence-check.v1", "stackchan.pc-brain-deploy-evidence.v1", "pc-brain-deploy-ready", "sourceCommit", "Get-ReviewSourceCommit", "source-commit", "human-review-source-commit-match", "audio-stream-started", "playback-started", "speaker-task-bytes-match", "RequireTests", "RequireReady")) { +foreach ($pattern in @("stackchan.pc-brain-deploy-evidence-check.v1", "stackchan.pc-brain-deploy-evidence.v1", "pc-brain-deploy-ready", "sourceCommit", "Get-ReviewSourceCommit", "source-commit", "human-review-source-commit-match", "playback-started", "playback-completed", "playback-drained", "speaker-playback-chunks-match", "RequireTests", "RequireReady")) { if ($checkPcBrainEvidenceText -notmatch [regex]::Escape($pattern)) { throw "tools/check_pc_brain_deploy_evidence.ps1 missing PC brain deploy evidence check support: $pattern" } From a0f56b76f0bece2f4f732f70d3115bc6800c843d Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 19:05:31 -0400 Subject: [PATCH 43/46] Preserve complete supervised speech turns --- bridge/README.md | 8 +- bridge/lan_service.py | 54 +++++++++++ bridge/test_lan_service.py | 83 ++++++++++++++++ bridge/test_transcript_diagnostics.py | 84 ++++++++++++++++ bridge/transcript_diagnostics.py | 97 +++++++++++++++++++ docs/BRIDGE_AI_HANDOFF.md | 26 ++++- docs/BRIDGE_PROTOCOL.md | 7 +- docs/CONVERSATION_V2_ROADMAP.md | 6 +- docs/JOHNNY_ALIVE_PATHWAY.md | 7 +- src/io/VoiceActivityEndpoint.hpp | 8 +- src/main.cpp | 11 ++- test/test_native_logic/test_main.cpp | 63 +++++++----- tools/start_pc_brain.ps1 | 20 ++++ tools/start_pc_brain_directml.ps1 | 23 +++++ .../test_start_pc_brain_directml_contract.ps1 | 9 ++ 15 files changed, 457 insertions(+), 49 deletions(-) create mode 100644 bridge/test_transcript_diagnostics.py create mode 100644 bridge/transcript_diagnostics.py diff --git a/bridge/README.md b/bridge/README.md index afae4a40..24ed1114 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -284,10 +284,10 @@ listener progressively less patient. The bridge rejects values outside the firmw acoustic-tail and reply-window bounds instead of silently correcting them. Sessions remain bounded to 24 user turns by default. Reply-window capture uses a deterministic local endpoint with sustained-speech and trailing-silence -hysteresis. The accepted firmware currently ends a reply after 550 ms of trailing silence and -always stops by 4.8 seconds. Those device-owned endpoint values can truncate a thoughtful pause or -long sentence even though the host lease remains open; changing them requires a separately -qualified firmware candidate. Initial v1 capture remains fixed-length. Exit phrases, turn limits, +hysteresis. The currently installed diagnostic image was physically observed ending a turn after +only 1.2 seconds of trailing silence and has an independent 6.5-second capture ceiling. The current +qualification source instead waits 2.0 seconds and aligns both endpoint and dedicated capture at +12 seconds; it remains unqualified until exact-image supervised evidence passes. Exit phrases, turn limits, bridge loss, cancellation, TTS failure, and model failure close through a typed cooldown. Host/companion cancellation is implemented; physical over-speaker barge-in and exact-image hardware qualification remain promotion gates. diff --git a/bridge/lan_service.py b/bridge/lan_service.py index 35f74fc8..04395de4 100644 --- a/bridge/lan_service.py +++ b/bridge/lan_service.py @@ -59,6 +59,11 @@ transcribe_pcm, ) from stt_supervisor import SttServerSupervisor, SttSupervisorConfig +from transcript_diagnostics import ( + expected_transcript_metrics, + validate_critical_tokens, + validate_expected_text, +) from tts_adapter import ( DEFAULT_TTS_TIMEOUT_MS, DEFAULT_TTS_VOICE, @@ -771,6 +776,8 @@ class LanBridgeConfig: stt_health_interval_s: float = 2.0 stt_timeout_ms: int = DEFAULT_STT_TIMEOUT_MS stt_min_confidence: float = 0.45 + stt_diagnostic_expected_text: str = "" + stt_diagnostic_critical_tokens: tuple[str, ...] = () require_audio_wake_phrase: bool = False tts_command: str = "" in_process_directml_tts: bool = False @@ -844,6 +851,20 @@ def __post_init__(self) -> None: ) if not 0.0 <= float(self.stt_min_confidence) <= 1.0: raise ValueError("stt_min_confidence must be between zero and one") + if self.stt_diagnostic_expected_text: + validate_expected_text(self.stt_diagnostic_expected_text) + if self.turn_log_file is None: + raise ValueError("STT expected-utterance diagnostics require a turn log") + if not self.redact_turn_text: + raise ValueError("STT expected-utterance diagnostics require redacted turn logs") + if self.audio_evidence_dir is not None: + raise ValueError("STT expected-utterance diagnostics forbid PCM evidence persistence") + validate_critical_tokens( + self.stt_diagnostic_expected_text, + self.stt_diagnostic_critical_tokens, + ) + elif self.stt_diagnostic_critical_tokens: + raise ValueError("critical STT tokens require an expected diagnostic utterance") if not bridge_bind_is_loopback(self.host) and not self.robot_host.strip(): raise ValueError("robot_host is required when the bridge bind is not loopback") @@ -1499,6 +1520,7 @@ def __init__( self.playback_response_seq = 0 self.conversation_playback_complete_seq = 0 self.audio_protocol_errors = 0 + self._stt_diagnostic_pending = bool(config.stt_diagnostic_expected_text) saved_initiative = self.memory.fact_value("user.initiative_enabled") if self.initiative_policy is not None and saved_initiative == "false": self.initiative_policy.set_enabled(False) @@ -1924,6 +1946,16 @@ def _append_turn_log(self, record: dict[str, object]) -> None: with self.config.turn_log_file.open("a", encoding="utf-8") as handle: handle.write(json.dumps(serialized, separators=(",", ":"), ensure_ascii=True) + "\n") + def _take_stt_diagnostic_metrics(self, recognized_text: str) -> dict[str, object]: + if not self._stt_diagnostic_pending: + return {} + self._stt_diagnostic_pending = False + return expected_transcript_metrics( + self.config.stt_diagnostic_expected_text, + recognized_text, + critical_tokens=self.config.stt_diagnostic_critical_tokens, + ) + def _write_audio_evidence( self, *, @@ -2938,6 +2970,7 @@ def _run_utterance_end( ), } ) + stt_log.update(self._take_stt_diagnostic_metrics("")) except (SttExecutionError, ValueError) as exc: self._append_audio_error_log( seq=seq, @@ -2961,6 +2994,13 @@ def _run_utterance_end( stt_log["stt_raw_transcript"] = stt.raw_transcript if stt.transcript_normalized: stt_log["stt_transcript_normalized"] = True + diagnostic_text = stt.raw_transcript or stt.transcript + diagnostic_metrics = self._take_stt_diagnostic_metrics(diagnostic_text) + if diagnostic_metrics: + diagnostic_metrics["stt_expected_diagnostic_used_raw_transcript"] = bool( + stt.raw_transcript + ) + stt_log.update(diagnostic_metrics) stt_confidence = getattr(stt, "confidence", None) if stt_confidence is not None: stt_log["stt_confidence"] = round(stt_confidence, 4) @@ -4325,6 +4365,8 @@ def build_arg_parser() -> argparse.ArgumentParser: parser.add_argument("--stt-health-interval-s", type=float, default=2.0) parser.add_argument("--stt-timeout-ms", type=int, default=DEFAULT_STT_TIMEOUT_MS) parser.add_argument("--stt-min-confidence", type=float, default=0.45) + parser.add_argument("--stt-diagnostic-expected-file", type=Path) + parser.add_argument("--stt-diagnostic-critical-token", action="append", default=[]) parser.add_argument("--require-audio-wake-phrase", action="store_true") parser.add_argument("--tts-command", default="") parser.add_argument("--in-process-directml-tts", action="store_true") @@ -4402,6 +4444,16 @@ def main() -> int: parser.error("--room-observation-interval-seconds must be between 120 and 1800") if args.reset_memory and args.memory_file: reset_bridge_memory(args.memory_file) + diagnostic_expected_text = "" + if args.stt_diagnostic_expected_file is not None: + try: + diagnostic_expected_text = args.stt_diagnostic_expected_file.read_text( + encoding="utf-8" + ).strip() + except OSError as exc: + parser.error(f"could not read --stt-diagnostic-expected-file: {exc}") + if not diagnostic_expected_text or len(diagnostic_expected_text) > 500: + parser.error("--stt-diagnostic-expected-file must contain 1 to 500 characters") conversation_max_turns = max(1, min(50, args.conversation_max_turns)) config = LanBridgeConfig( host=args.host, @@ -4420,6 +4472,8 @@ def main() -> int: stt_health_interval_s=args.stt_health_interval_s, stt_timeout_ms=args.stt_timeout_ms, stt_min_confidence=args.stt_min_confidence, + stt_diagnostic_expected_text=diagnostic_expected_text, + stt_diagnostic_critical_tokens=tuple(args.stt_diagnostic_critical_token), require_audio_wake_phrase=args.require_audio_wake_phrase, tts_command=args.tts_command, in_process_directml_tts=args.in_process_directml_tts, diff --git a/bridge/test_lan_service.py b/bridge/test_lan_service.py index c1a9e159..85c76d38 100644 --- a/bridge/test_lan_service.py +++ b/bridge/test_lan_service.py @@ -1314,6 +1314,89 @@ def test_production_log_redaction_keeps_metrics_without_turn_text(self): self.assertEqual("identity", record["runner_case"]) self.assertIn("latency_turn_total_ms", record) + def test_expected_utterance_diagnostic_logs_metrics_without_pcm_or_transcript(self): + with tempfile.TemporaryDirectory() as temp_dir: + script = Path(temp_dir) / "fake_stt.py" + script.write_text( + "import json,sys\n" + "sys.stdin.buffer.read()\n" + "print(json.dumps({'transcript':'what is your name'," + "'raw_transcript':'what is your fame','transcript_normalized':True}))\n", + encoding="utf-8", + ) + turn_log = Path(temp_dir) / "turns.jsonl" + session = LanBridgeSession( + LanBridgeConfig( + stt_command=f'"{sys.executable}" "{script}"', + turn_log_file=turn_log, + redact_turn_text=True, + stt_diagnostic_expected_text="What is your name?", + stt_diagnostic_critical_tokens=("name",), + ) + ) + session.handle_text( + json.dumps({"type": "utterance_start", "sample_rate": 16000}) + ) + session.handle_binary(b"\x01\x00\x02\x00") + session.handle_text(json.dumps({"type": "utterance_end", "seq": 13})) + session.handle_text( + json.dumps({"type": "utterance_start", "sample_rate": 16000}) + ) + session.handle_binary(b"\x01\x00\x02\x00") + session.handle_text(json.dumps({"type": "utterance_end", "seq": 14})) + records = [ + json.loads(line) + for line in turn_log.read_text(encoding="utf-8").splitlines() + ] + record = records[0] + + self.assertNotIn("transcript", record) + self.assertNotIn("stt_transcript", record) + self.assertNotIn("stt_raw_transcript", record) + self.assertNotIn("audio_evidence_file", record) + serialized_record = json.dumps(record).lower() + self.assertNotIn("what is your name", serialized_record) + self.assertNotIn("what is your fame", serialized_record) + self.assertTrue(record["transcript_present"]) + self.assertTrue(record["stt_transcript_present"]) + self.assertTrue(record["stt_expected_diagnostic"]) + self.assertFalse(record["stt_expected_exact_match"]) + self.assertFalse(record["stt_expected_normalized_match"]) + self.assertTrue(record["stt_expected_diagnostic_used_raw_transcript"]) + self.assertEqual(4, record["stt_expected_token_count"]) + self.assertEqual(4, record["stt_recognized_token_count"]) + self.assertEqual(1, record["stt_word_edit_distance"]) + self.assertEqual(0.25, record["stt_word_error_rate"]) + self.assertEqual(0.0, record["stt_critical_expected_token_coverage"]) + self.assertNotIn("stt_expected_diagnostic", records[1]) + + def test_expected_utterance_diagnostic_requires_redaction_and_forbids_pcm_evidence(self): + for invalid in (" ", "...", "x" * 501): + with self.subTest(invalid=invalid[:10]): + with self.assertRaisesRegex(ValueError, "1 to 500"): + LanBridgeConfig( + stt_diagnostic_expected_text=invalid, + redact_turn_text=True, + turn_log_file=Path("turns.jsonl"), + ) + with self.assertRaisesRegex(ValueError, "require a turn log"): + LanBridgeConfig( + stt_diagnostic_expected_text="known test phrase", + redact_turn_text=True, + ) + with self.assertRaisesRegex(ValueError, "redacted turn logs"): + LanBridgeConfig( + stt_diagnostic_expected_text="known test phrase", + turn_log_file=Path("turns.jsonl"), + ) + with self.assertRaisesRegex(ValueError, "forbid PCM"): + LanBridgeConfig( + stt_diagnostic_expected_text="known test phrase", + redact_turn_text=True, + turn_log_file=Path("turns.jsonl"), + audio_evidence_dir=Path("private-audio"), + ) + def test_local_time_and_memory_recall_bypass_the_model(self): with tempfile.TemporaryDirectory() as temp_dir: turn_log = Path(temp_dir) / "turns.jsonl" diff --git a/bridge/test_transcript_diagnostics.py b/bridge/test_transcript_diagnostics.py new file mode 100644 index 00000000..a108d59e --- /dev/null +++ b/bridge/test_transcript_diagnostics.py @@ -0,0 +1,84 @@ +import unittest +import sys +from pathlib import Path + +BRIDGE_DIR = Path(__file__).resolve().parent +if str(BRIDGE_DIR) not in sys.path: + sys.path.insert(0, str(BRIDGE_DIR)) + +from transcript_diagnostics import ( + expected_transcript_metrics, + normalized_tokens, + validate_critical_tokens, + validate_expected_text, + word_edit_distance, +) + + +class TranscriptDiagnosticTests(unittest.TestCase): + def test_normalized_match_ignores_case_spacing_and_punctuation(self): + metrics = expected_transcript_metrics( + "A dog and a cat: are they the same animal?", + "a DOG and a cat are they the same animal", + critical_tokens=("dog", "cat", "same", "animal"), + ) + + self.assertFalse(metrics["stt_expected_exact_match"]) + self.assertTrue(metrics["stt_expected_normalized_match"]) + self.assertEqual(10, metrics["stt_expected_token_count"]) + self.assertEqual(10, metrics["stt_recognized_token_count"]) + self.assertEqual(0, metrics["stt_word_edit_distance"]) + self.assertEqual(0.0, metrics["stt_word_error_rate"]) + self.assertEqual(4, metrics["stt_critical_expected_token_hits"]) + self.assertEqual(1.0, metrics["stt_critical_expected_token_coverage"]) + self.assertNotIn("dog", str(metrics).lower()) + + def test_partial_transcript_reports_wer_and_critical_coverage_without_text(self): + metrics = expected_transcript_metrics( + "wait until I finish then compare dog and cat", + "wait until I finish", + critical_tokens=("finish", "dog", "cat"), + ) + + self.assertEqual(9, metrics["stt_expected_token_count"]) + self.assertEqual(4, metrics["stt_recognized_token_count"]) + self.assertEqual(5, metrics["stt_word_edit_distance"]) + self.assertEqual(0.5556, metrics["stt_word_error_rate"]) + self.assertEqual(1, metrics["stt_critical_expected_token_hits"]) + self.assertEqual(0.3333, metrics["stt_critical_expected_token_coverage"]) + self.assertFalse(any(isinstance(value, (list, tuple, set)) for value in metrics.values())) + + def test_no_transcript_is_full_deletion(self): + metrics = expected_transcript_metrics("one two three", "") + + self.assertEqual(3, metrics["stt_word_edit_distance"]) + self.assertEqual(1.0, metrics["stt_word_error_rate"]) + self.assertEqual(0, metrics["stt_recognized_token_count"]) + self.assertIsNone(metrics["stt_critical_expected_token_coverage"]) + + def test_critical_tokens_must_be_single_tokens_present_in_expectation(self): + self.assertEqual( + ("dog", "cat"), + validate_critical_tokens("Dog and cat", ("DOG", "cat", "dog")), + ) + with self.assertRaises(ValueError): + validate_critical_tokens("Dog and cat", ("same animal",)) + with self.assertRaises(ValueError): + validate_critical_tokens("Dog and cat", ("bird",)) + + def test_expected_text_requires_a_bounded_word_bearing_phrase(self): + self.assertEqual("say this", validate_expected_text("say this")) + for invalid in ("", " ", "...", "x" * 501): + with self.subTest(invalid=invalid[:10]): + with self.assertRaises(ValueError): + validate_expected_text(invalid) + + def test_word_edit_distance_covers_insert_delete_and_replace(self): + self.assertEqual(1, word_edit_distance(("a", "b"), ("a", "x", "b"))) + self.assertEqual(1, word_edit_distance(("a", "b"), ("a",))) + self.assertEqual(1, word_edit_distance(("a", "b"), ("a", "x"))) + self.assertEqual(("can't", "stop"), normalized_tokens("CAN\u2019T_stop")) + + +if __name__ == "__main__": + unittest.main() diff --git a/bridge/transcript_diagnostics.py b/bridge/transcript_diagnostics.py new file mode 100644 index 00000000..3e7933a8 --- /dev/null +++ b/bridge/transcript_diagnostics.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Privacy-safe metrics for a supervised, known-utterance STT check.""" + +from __future__ import annotations + +import hashlib +import re +import unicodedata +from collections import Counter + + +_WORD_RE = re.compile(r"[^\W_]+(?:['\u2019][^\W_]+)?", re.UNICODE) + + +def normalized_tokens(text: object) -> tuple[str, ...]: + normalized = unicodedata.normalize("NFKC", str(text or "")).casefold() + return tuple(match.group(0).replace("\u2019", "'") for match in _WORD_RE.finditer(normalized)) + + +def validate_expected_text(expected_text: str) -> str: + expected_text = str(expected_text) + if not expected_text.strip() or len(expected_text) > 500 or not normalized_tokens(expected_text): + raise ValueError("expected diagnostic utterance must contain 1 to 500 characters and a word") + return expected_text + + +def word_edit_distance(expected: tuple[str, ...], recognized: tuple[str, ...]) -> int: + previous = list(range(len(recognized) + 1)) + for expected_index, expected_token in enumerate(expected, start=1): + current = [expected_index] + for recognized_index, recognized_token in enumerate(recognized, start=1): + current.append( + min( + current[-1] + 1, + previous[recognized_index] + 1, + previous[recognized_index - 1] + + (expected_token != recognized_token), + ) + ) + previous = current + return previous[-1] + + +def validate_critical_tokens( + expected_text: str, + critical_tokens: tuple[str, ...], +) -> tuple[str, ...]: + expected = set(normalized_tokens(expected_text)) + normalized: list[str] = [] + for raw_token in critical_tokens: + tokens = normalized_tokens(raw_token) + if len(tokens) != 1: + raise ValueError("each critical expected token must normalize to exactly one token") + token = tokens[0] + if token not in expected: + raise ValueError("each critical expected token must occur in the expected utterance") + if token not in normalized: + normalized.append(token) + return tuple(normalized) + + +def expected_transcript_metrics( + expected_text: str, + recognized_text: str, + *, + critical_tokens: tuple[str, ...] = (), +) -> dict[str, object]: + """Return comparison metrics without returning either input or any token text.""" + + expected_text = validate_expected_text(expected_text) + recognized_text = str(recognized_text) + expected = normalized_tokens(expected_text) + recognized = normalized_tokens(recognized_text) + critical = validate_critical_tokens(expected_text, critical_tokens) + distance = word_edit_distance(expected, recognized) + recognized_counts = Counter(recognized) + critical_counts = Counter(critical) + critical_hits = sum( + min(count, recognized_counts[token]) + for token, count in critical_counts.items() + ) + critical_count = len(critical) + return { + "stt_expected_diagnostic": True, + "stt_expected_sha256": hashlib.sha256(expected_text.encode("utf-8")).hexdigest(), + "stt_expected_exact_match": recognized_text == expected_text, + "stt_expected_normalized_match": recognized == expected, + "stt_expected_token_count": len(expected), + "stt_recognized_token_count": len(recognized), + "stt_word_edit_distance": distance, + "stt_word_error_rate": round(distance / len(expected), 4), + "stt_critical_expected_token_count": critical_count, + "stt_critical_expected_token_hits": critical_hits, + "stt_critical_expected_token_coverage": ( + round(critical_hits / critical_count, 4) if critical_count else None + ), + } diff --git a/docs/BRIDGE_AI_HANDOFF.md b/docs/BRIDGE_AI_HANDOFF.md index 9adad2d8..e4e81d7f 100644 --- a/docs/BRIDGE_AI_HANDOFF.md +++ b/docs/BRIDGE_AI_HANDOFF.md @@ -110,6 +110,22 @@ when behaviour looks wrong. `CharacterMode` values are `0 Boot, 1 Idle, 2 Attend is not promotion evidence. Use explicit supervised qualification and do not infer hardware readiness from source tests. +## Physical Endpoint Failure and Candidate Correction (2026-08-05) + +- The installed `6e9096d5` image with a 1.2-second trailing-silence tail failed a supervised + sentence check: the user was cut off after "wait until I finish". The private diagnostic + transcript contained only 4 of 17 expected words, so this is observed premature device + endpointing rather than a completed-turn recognition result. +- The current qualification source raises the tail to 2.0 seconds and makes the dedicated + capture ceiling match the endpoint ceiling: 240 50-ms chunks / 12 seconds / 384 KB. The + 15-second wake-gate privacy guard remains authoritative. Native coverage carries an eight-second + turn with a 1.5-second clause pause across the former 6.5-second ceiling. +- The host has an opt-in, one-shot expected-utterance diagnostic. It records only expected-text + hash, word counts, edit distance/WER, and configured critical-token coverage; it requires + redacted logs and forbids PCM persistence. This source is not physically qualified until the + exact candidate is installed and the user confirms the complete sentence was spoken before the + response. + ## Fault-Fix Candidate Update (2026-07-25) - F1 has a source-level wire guard. Once `response_start` is sent, cancellation and worker-error @@ -274,11 +290,11 @@ Do not shorten the listening lease merely because several turns completed. That progressively less patient during an active exchange. The host default is now a constant ten seconds and a 24-turn safety bound. -PR #216 replaced the former 4.8-second endpoint with a 12-second maximum and moved the dedicated -capture ceiling to 13 seconds. Both initial and follow-up capture now end on 550 ms of trailing -silence. PR #217 closes the equal-threshold F2 race by renewing from device VAD speech and placing -the hard privacy guard at 15 seconds. This bridge PR must not alter or flash the accepted firmware; -promotion still requires exact-image physical evidence with zero new uplink errors. +The current qualification source keeps the 12-second endpoint maximum, uses a matching 240-chunk +12-second dedicated capture ceiling, and ends initial and follow-up capture after 2.0 seconds of +trailing silence. PR #217's device-VAD renewal keeps the hard privacy guard at 15 seconds. This is +candidate behavior, not accepted firmware behavior; promotion still requires exact-image physical +evidence with zero new uplink errors and a completed supervised sentence. --- diff --git a/docs/BRIDGE_PROTOCOL.md b/docs/BRIDGE_PROTOCOL.md index 48d74986..e024f3e6 100644 --- a/docs/BRIDGE_PROTOCOL.md +++ b/docs/BRIDGE_PROTOCOL.md @@ -269,9 +269,10 @@ Example: deadline, and cancels on bridge loss. The frame carries no actuator or power authority. In `stackchan_voice_v2` and the derived full release source, an accepted reply-window capture uses a local voice-activity endpoint for both initial and follow-up capture: at least 150 ms of speech - must be observed, capture remains open for at least 600 ms, and 550 ms of trailing silence ends - the utterance. The endpoint ceiling is 12 seconds, the dedicated capture ceiling is 13 seconds, - and the wake-gate privacy guard is 15 seconds. The current host capture commitment is only 10 + must be observed, capture remains open for at least 600 ms, and 2.0 seconds of trailing silence + ends the utterance. The endpoint and dedicated-capture ceilings are both 12 seconds (240 50-ms + chunks in the release profile), and the wake-gate privacy guard is 15 seconds. The current host + capture commitment is only 10 seconds and can reject a valid later device end; that mismatch must be fixed and source/physical qualified before promotion. - `endpoint_hello_result`: endpoint trust/capability registration result. diff --git a/docs/CONVERSATION_V2_ROADMAP.md b/docs/CONVERSATION_V2_ROADMAP.md index cabb4304..91d1d9b1 100644 --- a/docs/CONVERSATION_V2_ROADMAP.md +++ b/docs/CONVERSATION_V2_ROADMAP.md @@ -101,9 +101,9 @@ firmware to its normal local face and wake behavior. closes. Each side of a turn is capped at 160 characters so the complete default lease remains inside the local model context budget. - Initial and reply-window firmware capture now use the deterministic local endpoint detector. It - requires at least 150 ms of speech, waits through a 550 ms trailing pause, and never closes before - 600 ms. The endpoint ceiling is 12 seconds, dedicated capture ceiling 13 seconds, and wake-gate - privacy guard 15 seconds. The host capture commitment remains 10 seconds, so a valid long device + requires at least 150 ms of speech, waits through a 2.0-second trailing pause, and never closes + before 600 ms. The endpoint and dedicated-capture ceilings are both 12 seconds (240 release + chunks), and the wake-gate privacy guard is 15 seconds. The host capture commitment remains 10 seconds, so a valid long device utterance can be rejected; this is an open blocker. Native tests and the public full build pass, but real-room and exact-image evidence are still required before promotion. - The LAN bridge now keeps its socket reader responsive while one serialized turn worker owns diff --git a/docs/JOHNNY_ALIVE_PATHWAY.md b/docs/JOHNNY_ALIVE_PATHWAY.md index e252dbaf..4e28cd9a 100644 --- a/docs/JOHNNY_ALIVE_PATHWAY.md +++ b/docs/JOHNNY_ALIVE_PATHWAY.md @@ -74,9 +74,10 @@ Working on real hardware: - Done in post-release source: authoritative speaker-drain evidence produces a bounded firmware reply-window command; parser limits, wrap-safe scheduling, expiry, and bridge-loss cancellation are covered by native and host tests. - - Done in post-release source: completed turns enter a bounded 24-turn, non-persistent session ring - only after authoritative playback completion; reply capture ends after sustained speech and - trailing silence, with the old 4.8-second maximum as fallback. + - Done in current qualification source: completed turns enter a bounded 24-turn, non-persistent + session ring only after authoritative playback completion; reply capture ends after sustained + speech and 2.0 seconds of trailing silence, with matching 12-second endpoint and dedicated + capture ceilings. Exact-image physical qualification is still pending. - Done in post-release source: the LAN reader remains responsive during Gemma/TTS; explicit cancel and companion barge-in terminate the process tree, drop unsent audio, and leave cancelled model memory/session history uncommitted. diff --git a/src/io/VoiceActivityEndpoint.hpp b/src/io/VoiceActivityEndpoint.hpp index f19f51dd..ca1827b7 100644 --- a/src/io/VoiceActivityEndpoint.hpp +++ b/src/io/VoiceActivityEndpoint.hpp @@ -20,9 +20,11 @@ struct VoiceActivityEndpointConfig { uint32_t sampleRate = 16000; uint32_t minimumCaptureMs = 600; uint32_t minimumSpeechMs = 150; - // Preserve natural clause pauses. The former 550 ms tail intermittently - // endpointed a speaker mid-sentence during physical conversation testing. - uint32_t trailingSilenceMs = 1200; + // Preserve natural clause pauses. Physical qualification proved that a + // 1200 ms tail still endpointed a deliberate mid-sentence pause after + // "wait until I finish". Two seconds keeps normal hesitation inside the + // same turn while retaining the 12 second hard capture ceiling. + uint32_t trailingSilenceMs = 2000; // Ceiling, not the normal path. Capture ends on trailing silence as soon as // the speaker stops; this only catches the case where silence is never // detected. It used to be 4800 ms, which truncated any sentence longer than diff --git a/src/main.cpp b/src/main.cpp index 1759f898..a25dc00e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -991,9 +991,10 @@ volatile bool gWakeMwwStereoDirectionPendingReady = false; constexpr uint32_t kWakeMwwCueCompletionTimeoutMs = 120; // Hard ceiling on one capture, in compile-time-sized chunks. This is the // backstop behind the voice-activity endpoint, which normally ends capture as -// soon as the speaker stops. The release profile uses 800-sample chunks, so 130 -// chunks is 6.5 s (208 KB of 16 kHz mono PCM), inside the 512 KB uplink limit. -constexpr uint16_t kWakeMwwDedicatedCaptureChunks = 130; +// soon as the speaker stops. The release profile uses 800-sample chunks, so 240 +// chunks is 12 s (384 KB of 16 kHz mono PCM), inside the 512 KB uplink limit and +// equal to VoiceActivityEndpointConfig::maximumCaptureMs. +constexpr uint16_t kWakeMwwDedicatedCaptureChunks = 240; // One chunk is STACKCHAN_MWW_WAKE_UPLINK_CHUNK_SAMPLES at the capture rate. constexpr uint32_t kWakeMwwDedicatedCaptureCeilingMs = (static_cast(kWakeMwwDedicatedCaptureChunks) * @@ -1003,6 +1004,10 @@ constexpr uint32_t kWakeMwwDedicatedCaptureCeilingMs = // it closes the turn mid-utterance and the remaining chunks are rejected. static_assert(kWakeMwwDedicatedCaptureCeilingMs < kBridgeWakeGateMaxTurnMs, "wake gate max turn must exceed the dedicated capture ceiling"); +static_assert(static_cast(kWakeMwwDedicatedCaptureChunks) * + STACKCHAN_MWW_WAKE_UPLINK_CHUNK_SAMPLES * sizeof(int16_t) <= + kBridgeAudioUplinkMaxBytes, + "dedicated capture must fit the bridge audio uplink limit"); RobotEvent gWakeMwwPendingCaptureEvent {}; bool gWakeMwwPendingCaptureEventReady = false; bool gWakeMwwPendingCaptureIsConversationReply = false; diff --git a/test/test_native_logic/test_main.cpp b/test/test_native_logic/test_main.cpp index a79cb672..e01f7795 100644 --- a/test/test_native_logic/test_main.cpp +++ b/test/test_native_logic/test_main.cpp @@ -1718,9 +1718,10 @@ void test_voice_activity_endpoint_capture_tracks_speech_length() { // Ended because the speaker stopped, not because time ran out. TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::TrailingSilence), static_cast(reason)); - // Kept everything that was said, and did not linger long after. + // Kept everything that was said, and stayed within the configured + // two-second natural-pause tail plus one scheduler chunk. TEST_ASSERT_GREATER_OR_EQUAL_UINT32(speakLengths[i], captureMs); - TEST_ASSERT_LESS_THAN_UINT32(speakLengths[i] + 1500u, captureMs); + TEST_ASSERT_LESS_THAN_UINT32(speakLengths[i] + 2100u, captureMs); // Longer utterances yield longer captures. TEST_ASSERT_GREATER_THAN_UINT32(previousCaptureMs, captureMs); previousCaptureMs = captureMs; @@ -1736,10 +1737,10 @@ void test_voice_activity_endpoint_default_ceiling_fits_a_long_sentence() { TEST_ASSERT_LESS_THAN_UINT32(512u * 1024u, bytes); } -void test_voice_activity_endpoint_default_preserves_one_second_natural_pause() { +void test_voice_activity_endpoint_default_preserves_one_and_a_half_second_natural_pause() { VoiceActivityEndpointConfig config; config.enabled = true; - TEST_ASSERT_EQUAL_UINT32(1200, config.trailingSilenceMs); + TEST_ASSERT_EQUAL_UINT32(2000, config.trailingSilenceMs); TEST_ASSERT_LESS_THAN_UINT32(kBridgeWakeGateOpenMs, config.trailingSilenceMs); VoiceActivityEndpoint endpoint; @@ -1756,8 +1757,8 @@ void test_voice_activity_endpoint_default_preserves_one_second_natural_pause() { } TEST_ASSERT_TRUE(endpoint.telemetry().speechSeen); - // A full second of silence is a natural clause pause, not an utterance end. - for (uint32_t nowMs = 250; nowMs <= 1200; nowMs += 50) { + // A 1.5 second silence is a natural clause pause, not an utterance end. + for (uint32_t nowMs = 250; nowMs <= 1700; nowMs += 50) { reason = endpoint.process(silence, 800, nowMs); TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::None), static_cast(reason)); @@ -1765,12 +1766,12 @@ void test_voice_activity_endpoint_default_preserves_one_second_natural_pause() { TEST_ASSERT_TRUE(endpoint.telemetry().active); TEST_ASSERT_EQUAL_UINT32(0, endpoint.telemetry().endpointsDetected); - for (uint32_t nowMs = 1250; nowMs <= 1400; nowMs += 50) { + for (uint32_t nowMs = 1750; nowMs <= 1900; nowMs += 50) { reason = endpoint.process(speech, 800, nowMs); TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::None), static_cast(reason)); } - for (uint32_t nowMs = 1450; nowMs <= 2550; nowMs += 50) { + for (uint32_t nowMs = 1950; nowMs <= 3850; nowMs += 50) { reason = endpoint.process(silence, 800, nowMs); TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::None), static_cast(reason)); @@ -1778,7 +1779,7 @@ void test_voice_activity_endpoint_default_preserves_one_second_natural_pause() { TEST_ASSERT_TRUE(endpoint.telemetry().active); TEST_ASSERT_EQUAL( static_cast(VoiceActivityEndpointReason::TrailingSilence), - static_cast(endpoint.process(silence, 800, 2600))); + static_cast(endpoint.process(silence, 800, 3900))); TEST_ASSERT_FALSE(endpoint.telemetry().active); TEST_ASSERT_EQUAL_UINT32(1, endpoint.telemetry().endpointsDetected); TEST_ASSERT_EQUAL_UINT32(0, endpoint.telemetry().maxDurationFallbacks); @@ -7363,16 +7364,24 @@ void test_bridge_wake_gate_survives_a_long_utterance() { } void test_bridge_wake_gate_max_turn_outlasts_the_capture_ceiling() { - // Release uses 130 x 800-sample chunks at 16 kHz: a 6.5 s capture ceiling. + // Release uses 240 x 800-sample chunks at 16 kHz: a 12 s capture ceiling. // The max-turn privacy guard remains the final absolute bound. - constexpr uint32_t captureCeilingMs = (130u * 800u * 1000u) / 16000u; - static_assert(captureCeilingMs == 6500, "release capture ceiling changed"); + constexpr uint16_t captureChunks = 240; + constexpr uint16_t chunkSamples = 800; + constexpr uint32_t sampleRate = 16000; + constexpr uint32_t captureCeilingMs = + (static_cast(captureChunks) * chunkSamples * 1000u) / sampleRate; + constexpr uint32_t captureBytes = + static_cast(captureChunks) * chunkSamples * sizeof(int16_t); + static_assert(captureCeilingMs == 12000, "release capture ceiling changed"); + static_assert(captureBytes == 384000, "release capture byte budget changed"); TEST_ASSERT_GREATER_THAN_UINT32(captureCeilingMs, kBridgeWakeGateMaxTurnMs); + TEST_ASSERT_LESS_OR_EQUAL_UINT32(kBridgeAudioUplinkMaxBytes, captureBytes); - // With the release chunk size, the chunk ceiling ends capture before the - // endpoint's generic 12 s maximum. + // The compile-time chunk ceiling and endpoint fallback describe one bound, + // so neither can silently truncate an utterance before the other. VoiceActivityEndpointConfig endpointConfig; - TEST_ASSERT_GREATER_THAN_UINT32(captureCeilingMs, endpointConfig.maximumCaptureMs); + TEST_ASSERT_EQUAL_UINT32(captureCeilingMs, endpointConfig.maximumCaptureMs); } void test_bridge_wake_gate_renews_on_speech_and_expires() { @@ -8376,15 +8385,16 @@ void test_dedicated_wake_capture_stops_cleanly_at_release_gate_boundary() { TEST_ASSERT_TRUE(uplink.begin(uplinkConfig, &session)); // These are the actual release-profile values. The 800-sample override makes - // each chunk 50 ms; chunk 120 therefore crosses the 6000 ms wake-gate edge. + // each chunk 50 ms. With no detected speech to renew authority, chunk 120 + // still crosses the 6000 ms wake-gate edge before the 12 s capture ceiling. constexpr uint16_t kReleaseChunkSamples = 800; constexpr uint32_t kReleaseSampleRate = 16000; constexpr uint32_t kReleaseGateOpenMs = 6000; - constexpr uint16_t kReleaseCaptureChunks = 130; + constexpr uint16_t kReleaseCaptureChunks = 240; constexpr uint32_t kReleaseChunkMs = (static_cast(kReleaseChunkSamples) * 1000u) / kReleaseSampleRate; static_assert(kReleaseChunkMs == 50, "release wake chunk duration changed"); - static_assert(kReleaseCaptureChunks * kReleaseChunkMs == 6500, + static_assert(kReleaseCaptureChunks * kReleaseChunkMs == 12000, "release dedicated-capture ceiling changed"); BridgeWakeGateConfig gateConfig; @@ -8547,9 +8557,12 @@ void test_dedicated_wake_capture_natural_pause_keeps_one_authorized_turn() { uint32_t binaryFrames = 0; uint32_t endFrames = 0; VoiceActivityEndpointReason finalReason = VoiceActivityEndpointReason::None; - for (uint16_t chunk = 1; chunk <= 52; ++chunk) { + // A production-realistic eight-second turn: two seconds of speech, a 1.5 s + // clause pause, another 2.5 s of speech, and the 2 s trailing-silence tail. + // It deliberately crosses the former 130-chunk / 6.5 s ceiling. + for (uint16_t chunk = 1; chunk <= 160; ++chunk) { const uint32_t capturedAtMs = 1000u + static_cast(chunk) * kChunkMs; - const bool speaking = chunk <= 4u || (chunk >= 25u && chunk <= 28u); + const bool speaking = chunk <= 40u || (chunk >= 71u && chunk <= 120u); const uint32_t speechAtBefore = endpoint.telemetry().lastSpeechAtMs; finalReason = endpoint.process( speaking ? speech : silence, kChunkSamples, capturedAtMs); @@ -8560,7 +8573,7 @@ void test_dedicated_wake_capture_natural_pause_keeps_one_authorized_turn() { gate.applyEvent(speakingEvent, capturedAtMs); } - if (chunk == 24u) { + if (chunk == 70u || chunk == 130u) { TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::None), static_cast(finalReason)); TEST_ASSERT_TRUE(endpoint.telemetry().active); @@ -8601,9 +8614,9 @@ void test_dedicated_wake_capture_natural_pause_keeps_one_authorized_turn() { TEST_ASSERT_EQUAL(static_cast(VoiceActivityEndpointReason::TrailingSilence), static_cast(finalReason)); - TEST_ASSERT_EQUAL_UINT32(52, binaryFrames); + TEST_ASSERT_EQUAL_UINT32(160, binaryFrames); TEST_ASSERT_EQUAL_UINT32(1, endFrames); - TEST_ASSERT_EQUAL_UINT32(52, uplink.telemetry().chunksQueued); + TEST_ASSERT_EQUAL_UINT32(160, uplink.telemetry().chunksQueued); TEST_ASSERT_EQUAL_UINT32(0, uplink.telemetry().errors); TEST_ASSERT_EQUAL_UINT32(0, uplink.telemetry().queueFailures); TEST_ASSERT_EQUAL_UINT32(1, gate.telemetry().turnsStarted); @@ -8611,7 +8624,7 @@ void test_dedicated_wake_capture_natural_pause_keeps_one_authorized_turn() { TEST_ASSERT_FALSE(gate.telemetry().turnActive); TEST_ASSERT_FALSE(uplink.telemetry().active); TEST_ASSERT_EQUAL_UINT32(2, session.telemetry().writerTextFrames); - TEST_ASSERT_EQUAL_UINT32(52, session.telemetry().writerBinaryFrames); + TEST_ASSERT_EQUAL_UINT32(160, session.telemetry().writerBinaryFrames); } BridgeDebugHttpDecision evaluateDebugHttpFixture(const char* requestLine, @@ -8945,7 +8958,7 @@ int main() { RUN_TEST(test_voice_activity_endpoint_rejects_short_noise_and_uses_maximum_fallback); RUN_TEST(test_voice_activity_endpoint_capture_tracks_speech_length); RUN_TEST(test_voice_activity_endpoint_default_ceiling_fits_a_long_sentence); - RUN_TEST(test_voice_activity_endpoint_default_preserves_one_second_natural_pause); + RUN_TEST(test_voice_activity_endpoint_default_preserves_one_and_a_half_second_natural_pause); RUN_TEST(test_voice_activity_endpoint_default_continuous_speech_still_hits_maximum); RUN_TEST(test_voice_activity_endpoint_disabled_path_preserves_fixed_capture); RUN_TEST(test_audio_capture_adapter_disabled_default_is_ready_without_source); diff --git a/tools/start_pc_brain.ps1 b/tools/start_pc_brain.ps1 index 5ab0e424..116699de 100644 --- a/tools/start_pc_brain.ps1 +++ b/tools/start_pc_brain.ps1 @@ -8,6 +8,8 @@ param( [string]$SttServerUrl = "", [string]$SttRestartCommand = "", [double]$SttHealthIntervalSeconds = 2.0, + [string]$SttDiagnosticExpectedFile = "", + [string[]]$SttDiagnosticCriticalToken = @(), [string]$TtsCommand = "python bridge\selected_voice_tts.py", [switch]$InProcessDirectMlTts, [string]$TtsVoice = "stackchan-rvc-bright-robot", @@ -107,6 +109,15 @@ if ($EnablePrivateTurnEvidence -and [string]::IsNullOrWhiteSpace($AudioEvidenceD if (-not [string]::IsNullOrWhiteSpace($AudioEvidenceDir)) { New-Item -ItemType Directory -Force -Path $AudioEvidenceDir | Out-Null } +if (-not [string]::IsNullOrWhiteSpace($SttDiagnosticExpectedFile)) { + if ($EnablePrivateTurnEvidence) { + throw "STT expected-utterance diagnostics require redacted logs and forbid private PCM evidence." + } + if (-not (Test-Path -LiteralPath $SttDiagnosticExpectedFile -PathType Leaf)) { + throw "SttDiagnosticExpectedFile does not exist: $SttDiagnosticExpectedFile" + } + $SttDiagnosticExpectedFile = (Resolve-Path -LiteralPath $SttDiagnosticExpectedFile).Path +} if ($StopExisting) { $Connections = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue @@ -268,6 +279,15 @@ if ($EnableDashboard) { ) } +if (-not [string]::IsNullOrWhiteSpace($SttDiagnosticExpectedFile)) { + $ArgsList += @("--stt-diagnostic-expected-file", $SttDiagnosticExpectedFile) + foreach ($CriticalToken in $SttDiagnosticCriticalToken) { + if (-not [string]::IsNullOrWhiteSpace($CriticalToken)) { + $ArgsList += @("--stt-diagnostic-critical-token", $CriticalToken) + } + } +} + if (-not [string]::IsNullOrWhiteSpace($RobotHost)) { $ArgsList += @("--robot-host", $RobotHost) } diff --git a/tools/start_pc_brain_directml.ps1 b/tools/start_pc_brain_directml.ps1 index 5385b454..3e044acb 100644 --- a/tools/start_pc_brain_directml.ps1 +++ b/tools/start_pc_brain_directml.ps1 @@ -10,6 +10,8 @@ param( [ValidateSet("auto", "cpu", "vulkan")] [string]$SttBackend = "auto", [string]$SttWarmupWavPath = "docs\media\voice\stackchan_spark_greeting.wav", + [string]$SttDiagnosticExpectedFile = "", + [string[]]$SttDiagnosticCriticalToken = @(), [int]$ReconnectTimeoutSeconds = 90, [string]$MemoryFile = "output\pc-brain\latest\memory.json", [switch]$EnableResearch, @@ -59,6 +61,12 @@ if (-not [string]::IsNullOrWhiteSpace($CameraPairingCodeFile) -and if ($SttThreads -lt 1 -or $SttThreads -gt 32) { throw "SttThreads must be between 1 and 32." } +if (-not [string]::IsNullOrWhiteSpace($SttDiagnosticExpectedFile)) { + if (-not (Test-Path -LiteralPath $SttDiagnosticExpectedFile -PathType Leaf)) { + throw "SttDiagnosticExpectedFile does not exist: $SttDiagnosticExpectedFile" + } + $SttDiagnosticExpectedFile = (Resolve-Path -LiteralPath $SttDiagnosticExpectedFile).Path +} function Stop-ExistingBridge { $listeners = @(Get-NetTCPConnection -LocalPort $BridgePort -State Listen -ErrorAction SilentlyContinue) @@ -343,6 +351,21 @@ if (-not [string]::IsNullOrWhiteSpace($CameraPairingCodeFile)) { $escapedPairingCodeFile = $CameraPairingCodeFile.Replace("'", "''") $bridgeScript += " -CameraPairingCodeFile '$escapedPairingCodeFile'" } +if (-not [string]::IsNullOrWhiteSpace($SttDiagnosticExpectedFile)) { + $escapedDiagnosticExpectedFile = $SttDiagnosticExpectedFile.Replace("'", "''") + $bridgeScript += " -SttDiagnosticExpectedFile '$escapedDiagnosticExpectedFile'" + $quotedCriticalTokens = @() + foreach ($criticalToken in $SttDiagnosticCriticalToken) { + if (-not [string]::IsNullOrWhiteSpace($criticalToken)) { + $escapedCriticalToken = $criticalToken.Replace("'", "''") + $quotedCriticalTokens += "'$escapedCriticalToken'" + } + } + if ($quotedCriticalTokens.Count -gt 0) { + $bridgeScript += " -SttDiagnosticCriticalToken @(" + + ($quotedCriticalTokens -join ",") + ")" + } +} $BridgeStartupReady = $false try { $bridgeChild = Invoke-EncodedChildPowerShell -ScriptBody $bridgeScript ` diff --git a/tools/test_start_pc_brain_directml_contract.ps1 b/tools/test_start_pc_brain_directml_contract.ps1 index 0b1eb9de..27407733 100644 --- a/tools/test_start_pc_brain_directml_contract.ps1 +++ b/tools/test_start_pc_brain_directml_contract.ps1 @@ -114,6 +114,11 @@ foreach ($required in @( "-SttServerUrl '`$SttServerUrl'", "-SttRestartCommand '`$escapedSttRestartCommand'", "-SttHealthIntervalSeconds 2", + "[string]`$SttDiagnosticExpectedFile", + "[string[]]`$SttDiagnosticCriticalToken", + "-SttDiagnosticExpectedFile '`$escapedDiagnosticExpectedFile'", + '($quotedCriticalTokens -join ",")', + '" -SttDiagnosticCriticalToken @("', "STACKCHAN_WHISPER_CPP_EXE", "STACKCHAN_WHISPER_MODEL", "STACKCHAN_WHISPER_THREADS", @@ -242,6 +247,10 @@ foreach ($required in @( "[string]`$SttRestartCommand", '"--stt-restart-command", $SttRestartCommand', '"--stt-health-interval-s", "$SttHealthIntervalSeconds"', + "[string]`$SttDiagnosticExpectedFile", + "[string[]]`$SttDiagnosticCriticalToken", + '"--stt-diagnostic-expected-file", $SttDiagnosticExpectedFile', + '"--stt-diagnostic-critical-token", $CriticalToken', "[switch]`$InProcessOllamaRunner", "[switch]`$InProcessDirectMlTts", '"--in-process-ollama-runner"', From edd519f9faacf56d84f01fdc2a248521ef26ef85 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 20:34:30 -0400 Subject: [PATCH 44/46] Harden physical no-motion evidence --- AGENTS.md | 1 + BRANCH_LEDGER.md | 12 +- PROJECT_STATE.md | 87 +- docs/ARRIVAL_DAY_RUNBOOK.md | 66 +- docs/BRIDGE_AI_HANDOFF.md | 14 + docs/FIRST_DEPLOY_STATUS.md | 48 +- tools/check_passive_no_motion_evidence.ps1 | 392 +++++++++ tools/run_passive_no_motion_evidence.ps1 | 817 ++++++++++++++++++ ..._firmware_http_control_policy_contract.ps1 | 68 +- ...st_passive_no_motion_evidence_contract.ps1 | 493 +++++++++++ 10 files changed, 1951 insertions(+), 47 deletions(-) create mode 100644 tools/check_passive_no_motion_evidence.ps1 create mode 100644 tools/run_passive_no_motion_evidence.ps1 create mode 100644 tools/test_passive_no_motion_evidence_contract.ps1 diff --git a/AGENTS.md b/AGENTS.md index 14b06d37..25f37413 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,7 @@ pio run -e stackchan_release_full powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test_full_system_soak_evidence_contract.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test_current_lead_reproducibility_contract.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test_archive_current_lead_contract.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test_passive_no_motion_evidence_contract.ps1 ``` Use `tools/check_full_system_soak_evidence.ps1` for completed hardware runs. A build or short diff --git a/BRANCH_LEDGER.md b/BRANCH_LEDGER.md index b6a75ff0..e8515226 100644 --- a/BRANCH_LEDGER.md +++ b/BRANCH_LEDGER.md @@ -1,14 +1,18 @@ # Branch Ledger -Audit timestamp: 2026-08-02 America/New_York +Audit timestamp: 2026-08-05 America/New_York -## Active Qualification Routing (2026-08-04) +## Active Qualification Routing (2026-08-05) - **Sole M0/P0 qualification worktree:** `D:\CodexProjects\stackchan_alive\output\worktrees\aliveness-repository-truth` on `codex/aliveness-repository-truth`. Verify its exact head and clean state before every - qualification command. No other retained worktree is a qualification input. -- **The primary checkout is not a qualification host.** It is clean `main` at + qualification command, and require it to match `origin/codex/aliveness-repository-truth`. Do not + hard-code the qualification-tooling head: it advances when reviewed evidence tooling is committed. + The currently installed firmware and running host were both launched from source checkpoint + `a0f56b76f0bece2f4f732f70d3115bc6800c843d`; that installed-source identity is distinct from the + recorder/checker source identity. No other retained worktree is a qualification input. +- **The primary checkout is not a qualification host.** As of this audit it is clean `main` at `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec`; use it only as the current default source checkout. - **`agent/away-cloudflare-bridge` is quarantined, not merely paused.** Preserve it unchanged at `269b11beeac788f76fff5d566446a91b8688bf8f`. It predates SEC-001/SEC-002, ships diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 37e7d2b2..59ffad3b 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,8 +1,50 @@ # Project State -State timestamp: 2026-08-03 America/New_York - -## Current Objective +State timestamp: 2026-08-05 America/New_York + +## Current Physical Qualification Checkpoint (2026-08-05) + +- The sole qualification checkout is `output/worktrees/aliveness-repository-truth` on + `codex/aliveness-repository-truth`. Before qualification, require its current HEAD to be clean, + pushed, and identical to `origin/codex/aliveness-repository-truth`; do not hard-code a tooling + head. The primary checkout remains non-authoritative for qualification. The installed firmware + and running host source checkpoint is separately fixed at + `a0f56b76f0bece2f4f732f70d3115bc6800c843d`. +- Private candidate `stackchan_release_forensics` SHA-256 + `2e9924e621e305b10642c2a0db395ed6aee7bdbd9766ea90faca7760a971fb62` was installed by LAN OTA, + confirmed on `app0`, and reports motion request, autonomous motion, servo rail, and torque off. + Its source archive is SHA-256 + `884ce08be8f61e4e53c7330f0ea4f1be77606d1aa5a50e95ca0c2e09d469949a`. +- The first candidate boot reports ESP `reset_reason=panic`, code `4`; the PMIC boot event is `none`. + This is a preserved P1 hold. No crash, firmware, power, USB, or board cause is assigned without + matching evidence. Do not reboot, reflash, or discard the boot merely to see whether it repeats. +- Exact-source gates completed before installation: native logic 304/304, bridge 577/577, focused + transcript/LAN tests 124/124, silent trusted-facts smoke with zero model invocations and audio, + the DirectML launcher contract, public build, and one clean private build. One private build is + not two-cycle reproducibility and none of these source gates substitutes for P1 hardware proof. +- The candidate intentionally has camera and host vision compiled out. It can gather focused audio + evidence but cannot satisfy full P1. Its two-second speech tail and 12-second cap are deployed. + The 2026-08-05 23:55Z armed physical attempt failed: firmware reported 81 uplink chunks / 129,600 + bytes (about 4.05 seconds of 16 kHz PCM) across an 18.436-second capture interval, while the host + received no completed `utterance_end`, invoked no STT, and produced no reply. The operator reported + that they had not finished the sentence. The one-shot expected-versus-Whisper diagnostic therefore + remains unconsumed; this attempt has no honest WER and is an endpoint/turn-delivery failure, not an + STT pass or STT-only failure. P2 remains prohibited until a complete exact-image P1 packet passes + independent review. +- A reviewer-approved four-second physical recorder diagnostic is sealed at + `output/pc-brain/passive-no-motion-diagnostic-a0f56b76-20260805-202408`. It recorded 6/6 exact + firmware/host polls, one stable boot, zero motion breaches, unchanged zero motion-stop, enable, + refresh, rail-enable, power-grant, and actuator-write counters, VBUS minimum 4,959 mV (reported + boot minimum 4,947 mV), maximum 60.5 C, and maximum display frame 20,422 us. It correctly failed + only `runner_source_dirty` and `reset_reason_not_clean`; it validates the recorder against the + real robot but is not qualification evidence. +- The preserved `4d31de41` / `4256F2E5...B31055` image remains historical evidence only. It requests + motion and autonomous refresh at boot and must never be used for P1. + +Older dated sections below are historical design and evidence records. When they conflict with this +checkpoint, this checkpoint and live exact-image evidence control. + +## Historical M0 Objective Snapshot (2026-08-03) Keep stop-ship security and release-truth work ahead of aliveness features. Milestone 0, the independently verified `SEC-001` host admission repair, and the public boot-motion correction are @@ -44,7 +86,7 @@ ordered behind truthful presence; durable recognition is ordered behind memory s owner-admin consent, and verified deletion; motion styling is limited to a deterministic low- dimensional projection behind controlled-source final-actuator and physical-safety gates. -## Source Identity +## Historical Source Identity Snapshot (2026-08-03) - Repository: `RobVanProd/stackchan_alive` - Working branch: `codex/aliveness-repository-truth` @@ -553,20 +595,29 @@ qualifies the installed firmware or authorizes a service restart. ## Exact Next Action -Keep `SEC-002`/`PRIV-001` and release truth ahead of the queued aliveness lanes. Reconcile the M0 -scope/state record, independently close command and toolchain trust, and keep release-grade paths -blocked until a reviewed exact allowlist can authorize them. Then commit the governance slice and -produce two clean identical builds for all three packaged environments, add and verify the OTA- -selector-safe installer and guarded private rollback helper, and build a clean package bound to its -exact source and application SHA-256. Only that replacement may enter the dedicated passive no- -motion qualification; the old `4d31de41` / `4256F2E5...B31055` package and every diagnostic package -must be refused. -After a passing passive gate, conduct the separately reviewed supervised emergency-stop proof and -final release gates. Do not design credentials or read a pairing file. -`PERCEPT-002`, `IDENT-001`, and `MOTION-001` remain preregistration/research only until their ordered -dependencies, expected-red tests, and explicit recognition/physical promotion checkpoints pass. - -## Unauthorized Actions +Complete the armed physical speech turn on the installed exact image and preserve the privacy-safe +expected-versus-Whisper metrics plus the operator's endpoint observation. Do not tune from source or +simulation alone. In parallel, add a strictly passive `emergency_stop_only` evidence path and seal +the current focused-audio evidence without claiming full P1. + +Then produce a reproducible, private, motion-off candidate with camera, host vision, and the required +sensors enabled. Run the complete P1 matrix: exact identity, display, wake/capture, bridge, camera, +sensors, conversation, playback, memory, dashboard, OTA health, reconnect, bridge restart, host-loss +fallback, stale-command rejection, thermal/power stability, and continuous proof that no motion +request, rail, torque, following, or identity recognition occurred. The reset-panic hold must remain +visible until evidence resolves it. Only a passing, independently reviewed P1 packet authorizes the +operator-present P2 emergency-stop proof. The historical `4d31de41` image and diagnostic packages +remain refused. + +After P2, advance the typed hardware/model integration and measured performance baseline, then the +person/pet shadow-perception, consented identity/removal, following, and personality-shaped emotional +motion milestones under their preregistered safety and privacy gates. + +## Historical Authorization Snapshot (2026-08-03) + +The bullets below record the authority boundary at that dated checkpoint. They are retained as +history, not as current-state evidence or a substitute for the repository's hardware safety, +privacy, containment, and release gates. - No firmware flash, OTA, reboot, recovery, wake reset, serial command, robot endpoint write, motion resume, motion refresh, or actuator test. diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index 461e0e3d..b9bc321b 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -4,9 +4,69 @@ Use this when bringing up a physical Stackchan device from the public `v0.2.0` r locally rebuilt or post-release firmware as a new candidate until its applicable evidence gates below are complete. -Qualification checkout authority (2026-08-04): run current M0/P0 commands only from +Current exact-image checkpoint (2026-08-05): the installed firmware and running host source is +`a0f56b76f0bece2f4f732f70d3115bc6800c843d`. Private `stackchan_release_forensics` candidate +SHA-256 `2e9924e621e305b10642c2a0db395ed6aee7bdbd9766ea90faca7760a971fb62` is confirmed on `app0` with +motion request, autonomous motion, servo rail, and torque off. Its first candidate boot reports ESP +panic code `4`; PMIC boot event is `none`. Assign no cause, preserve the boot, and keep P1/P2 on hold. +The image has camera and host vision compiled out and cannot earn full P1. Its deployed two-second +speech tail and 12-second cap failed the 2026-08-05 23:55Z armed physical attempt: 81 chunks / 129,600 +bytes reached the host, but no completed `utterance_end`, STT invocation, or reply followed, and the +operator had not finished speaking. Do not assign a WER when the expected diagnostic was never +consumed; preserve this as an endpoint/turn-delivery failure. Older dated statements that no +replacement image or bridge exists are historical and are superseded by this checkpoint; their +physical qualification evidence does not transfer to the current SHA. + +Passive P1 recorder rule: do not use `run_full_system_soak_http_motion.ps1` as a no-motion +workaround under `emergency_stop_only`. Use the dedicated read-only recorder, which performs only +`GET /debug` during an eligible run. Its sole robot-write path is the mandatory safety exception +`GET /motion-stop` after an observed motion/rail/torque authority breach; that event fails the run +and is preserved. The recorder never refreshes motion, changes a network adapter, starts/stops the +bridge, reboots, or reflashes the device. Run it only from a committed, clean qualification head: + +```powershell +$firmwareSource = "" +$firmwareSha = "" +$candidateManifest = "" +$runnerSource = (& git rev-parse HEAD).Trim() +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$evidence = "output\pc-brain\passive-no-motion-$stamp" + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` + tools\run_passive_no_motion_evidence.ps1 ` + -EvidenceRoot $evidence ` + -FirmwareSourceCommit $firmwareSource ` + -ExpectedFirmwareSha256 $firmwareSha ` + -CandidateManifestPath $candidateManifest ` + -DurationSeconds 600 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` + tools\check_passive_no_motion_evidence.ps1 ` + -SummaryJsonPath "$evidence\summary.json" ` + -FirmwareSourceCommit $firmwareSource ` + -ExpectedFirmwareSha256 $firmwareSha ` + -RunnerSourceCommit $runnerSource ` + -MinDurationSeconds 600 -Json +``` + +For a complete P1 candidate, add `-RequireObservedTurn` and `-RequireCameraHostVision` to both +commands. Do not add those flags to a profile that compiled camera/host vision out, and do not omit +them to relabel focused evidence as full P1. A non-software reset reason, source drift, missing +socket/readiness sample, active motion authority, nonzero motion counter, voltage/temperature/frame +breach, absent turn, or absent camera activity must remain a failed check. + +The physical four-second diagnostic at +`output/pc-brain/passive-no-motion-diagnostic-a0f56b76-20260805-202408` is the recorder-mechanics +reference: 6/6 successful polls, no motion breach or stop call, all motion/rail/write counters still +zero, and a deliberate fail for dirty runner source plus panic reset. Do not promote or reuse it as +P1 evidence. + +Qualification checkout authority (2026-08-05): run current M0/P0 commands only from `D:\CodexProjects\stackchan_alive\output\worktrees\aliveness-repository-truth` on -`codex/aliveness-repository-truth`, after verifying its exact head and clean state. The repository's +`codex/aliveness-repository-truth`. Require `git status --short` to be empty and require +`git rev-parse HEAD` to equal `git rev-parse origin/codex/aliveness-repository-truth` immediately +before the command. Do not hard-code the tooling head: the installed firmware/host source remains +bound separately in the candidate and runtime manifests. The repository's primary checkout is now clean `main` at `39b750e6c354d1c4721c70bf20fba98b8ce5c3ec`; it is not the qualification branch. The preserved `agent/away-cloudflare-bridge` branch at `269b11beeac788f76fff5d566446a91b8688bf8f` is remote-access infrastructure that predates the current @@ -26,7 +86,7 @@ reachable, terminate the motion-refresh runner, capture post-stop `/debug` when preserve the power-forensics state. Do not count loss of power itself as proof that an emergency stop worked, and do not call it a firmware containment failure without matching telemetry. -Repository-truth warning (2026-08-03): live firmware now self-reports confirmed `app0` and expected +Historical repository-truth warning (2026-08-03): live firmware then self-reported confirmed `app0` and expected SHA-256 `69d3db27...8ebfa8`, matching the historical accepted lead, but current flash bytes have not been independently read back. Dated “installed,” “current,” and “live” notes below remain historical evidence and cannot replace exact source/binary identity or qualification of the image under test. diff --git a/docs/BRIDGE_AI_HANDOFF.md b/docs/BRIDGE_AI_HANDOFF.md index e4e81d7f..469c6ce2 100644 --- a/docs/BRIDGE_AI_HANDOFF.md +++ b/docs/BRIDGE_AI_HANDOFF.md @@ -125,6 +125,20 @@ when behaviour looks wrong. `CharacterMode` values are `0 Boot, 1 Idle, 2 Attend redacted logs and forbids PCM persistence. This source is not physically qualified until the exact candidate is installed and the user confirms the complete sentence was spoken before the response. +- The installed corrected image's 2026-08-05 23:55Z physical attempt failed before STT. Firmware + emitted 81 x 800-sample chunks (4.05 seconds PCM) across 18.436 seconds of wall capture, one + serialized capture-service call took 7.504 seconds, and VAD reported zero endpoints and zero max + fallbacks. The host received no `utterance_end`, correctly ran no STT/model/TTS, and later closed + the lease as `reply_timeout`; the expected diagnostic remains armed. Do not tune the silence tail + from this attempt or assign a WER. +- The correction order is now: bound and separately time mic wait/socket drain/chunk submit; cancel + rather than semantically end any discontinuous or hard-wall-expired capture; retain and retry one + explicit end/cancel terminal until writer drain or bounded socket close; and give the host a + capture-commit lease longer than the 12-second firmware ceiling while retaining a non-refreshable + absolute privacy cap. Binary chunks may refresh only a short inactivity deadline. Timeout/cancel + clears partial PCM and permits zero STT/model/TTS. Physical acceptance requires continuous PCM, + exactly one terminal marker, the operator finishing first, and only then the expected-vs-Whisper + metrics. ## Fault-Fix Candidate Update (2026-07-25) diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index bdf950fd..20d5b168 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -1,15 +1,49 @@ # Stackchan First Deploy Status -Status timestamp: 2026-08-04 America/New_York - -## Qualification Checkout Authority (2026-08-04) +Status timestamp: 2026-08-05 America/New_York + +## Current Exact-Image Physical Checkpoint (2026-08-05) + +The installed firmware and running host source checkpoint is +`a0f56b76f0bece2f4f732f70d3115bc6800c843d`. Private candidate +`stackchan_release_forensics` SHA-256 +`2e9924e621e305b10642c2a0db395ed6aee7bdbd9766ea90faca7760a971fb62` was installed by LAN OTA and +confirmed on `app0`. Live evidence reports motion request, autonomous motion, servo rail, and torque +off. The first candidate boot reports ESP panic code `4` with PMIC boot event `none`; no cause is +assigned. Preserve the boot and hold P1/P2 rather than rebooting, reflashing, or discarding evidence. + +The candidate passed 304/304 native tests, 577/577 bridge tests, 124/124 focused transcript/LAN tests, +the silent trusted-facts smoke, the DirectML launcher contract, a public build, and one clean private +build. It has not passed two-cycle reproducibility or full physical P1. Camera and host vision are +compiled out. The 2026-08-05 23:55Z armed physical speech attempt failed: firmware reported 81 +uplink chunks / 129,600 bytes (about 4.05 seconds of 16 kHz PCM) over an 18.436-second capture +interval, but the host received no completed `utterance_end`, invoked no STT, and produced no reply. +The operator reported that they had not finished the sentence. The one-shot expected-versus-Whisper +diagnostic remains unconsumed, so this trial has no valid WER and must be classified as endpoint/turn +delivery failure. This image can support focused audio diagnosis only; it cannot earn full P1. + +A reviewer-approved four-second physical recorder diagnostic is sealed at +`output/pc-brain/passive-no-motion-diagnostic-a0f56b76-20260805-202408`. It recorded 6/6 successful +exact-image/host polls, a stable boot count, zero motion breaches, no safety-stop call, and unchanged +zero motion-enable, refresh, rail-enable, power-grant, and actuator-write counters. VBUS stayed at or +above 4,959 mV during the sample (reported boot minimum 4,947 mV), chip temperature reached 60.5 C, +and the maximum display frame was 20,422 us. It correctly failed only because the evidence tooling +tree was dirty and the preserved reset reason is panic. This validates recorder mechanics against +the real robot; it is not P1 evidence. + +The dated records below remain evidence. This checkpoint supersedes their former claims that no +replacement was installed or that the live bridge was absent. It does not transfer old qualification +or soak evidence to the current SHA. + +## Qualification Checkout Authority (2026-08-05) The authoritative checkout for the current M0/P0 qualification lane is the clean worktree at `D:\CodexProjects\stackchan_alive\output\worktrees\aliveness-repository-truth` on `codex/aliveness-repository-truth`. Do not infer qualification state from the repository's default -working directory or from another retained worktree. The pushed evidence checkpoint immediately -before this reconciliation was `3ace8f63fbd8d546ee9d234138b627badefdae51`; it matched its remote -branch and was 20 commits ahead of `origin/main` with no commits behind. +working directory or from another retained worktree. At this checkpoint the exact clean head and +remote branch must be checked immediately before every qualification command with `git status`, +`git rev-parse HEAD`, and `git rev-parse origin/codex/aliveness-repository-truth`. The tooling head +is expected to advance when reviewed evidence code lands; it is not the installed firmware identity. The primary checkout at `D:\CodexProjects\stackchan_alive` was cleanly moved from `agent/away-cloudflare-bridge` to current local `main`, and local `main` was fast-forwarded to the @@ -21,7 +55,7 @@ package, or qualify that exact branch. Any explicitly approved future remote-acc implemented afresh from the then-current qualification head and receive a separate security, privacy, protocol-authority, and motion-containment review. -## Current SEC-002 Qualification Hold (2026-08-03) +## Historical SEC-002 Qualification Hold (2026-08-03) The clean `SEC-002` package built from `4d31de41` is preserved as source/package evidence only. Its public `full_online` image is SHA-256 diff --git a/tools/check_passive_no_motion_evidence.ps1 b/tools/check_passive_no_motion_evidence.ps1 new file mode 100644 index 00000000..b89c655a --- /dev/null +++ b/tools/check_passive_no_motion_evidence.ps1 @@ -0,0 +1,392 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SummaryJsonPath, + [Parameter(Mandatory = $true)] + [string]$ExpectedFirmwareSha256, + [Parameter(Mandatory = $true)] + [string]$FirmwareSourceCommit, + [Parameter(Mandatory = $true)] + [string]$RunnerSourceCommit, + [int]$MinDurationSeconds = 600, + [int]$MinPowerVbusMv = 4400, + [double]$MaxChipTempC = 70.0, + [int]$MaxDisplayFrameUs = 50000, + [switch]$RequireObservedTurn, + [switch]$RequireCameraHostVision, + [switch]$Json +) + +$ErrorActionPreference = "Stop" +$ExpectedFirmwareSha256 = $ExpectedFirmwareSha256.Trim().ToLowerInvariant() +$FirmwareSourceCommit = $FirmwareSourceCommit.Trim().ToLowerInvariant() +$RunnerSourceCommit = $RunnerSourceCommit.Trim().ToLowerInvariant() +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +if ($ExpectedFirmwareSha256 -notmatch "^[0-9a-f]{64}$") { throw "ExpectedFirmwareSha256 must be a full SHA-256." } +if ($FirmwareSourceCommit -notmatch "^[0-9a-f]{40}$") { throw "FirmwareSourceCommit must be a full Git commit SHA." } +if ($RunnerSourceCommit -notmatch "^[0-9a-f]{40}$") { throw "RunnerSourceCommit must be a full Git commit SHA." } + +$checks = New-Object System.Collections.Generic.List[object] + +function Add-Check { + param([string]$Id, [bool]$Passed, [string]$Detail) + $script:checks.Add([ordered]@{ + id = $Id + status = $(if ($Passed) { "pass" } else { "fail" }) + detail = $Detail + }) +} + +function Read-JsonWithRetry { + param([string]$Path, [int]$Attempts = 6, [int]$DelayMilliseconds = 100) + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + try { return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } catch { + if ($attempt -eq $Attempts) { throw } + Start-Sleep -Milliseconds $DelayMilliseconds + } + } +} + +function Get-PropertyValue { + param($Object, [string]$Name, $DefaultValue = $null) + if ($null -eq $Object) { return $DefaultValue } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return $DefaultValue } + return $property.Value +} + +function Get-IntValue { + param($Object, [string]$Name, [int64]$DefaultValue = 0) + $value = Get-PropertyValue $Object $Name $null + if ($null -eq $value) { return $DefaultValue } + try { return [int64]$value } catch { return $DefaultValue } +} + +function Get-Sha256 { + param([string]$Path) + return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() +} + +function Test-JsonBoolean { + param($Object, [string]$Name, [bool]$Expected) + $property = if ($null -ne $Object) { $Object.PSObject.Properties[$Name] } else { $null } + return $null -ne $property -and $property.Value -is [bool] -and $property.Value -eq $Expected +} + +function Test-MotionBreach { + param($Sample) + foreach ($field in @( + "motionRequested", "motionAutonomous", "motionEnabled", "servoPowerAllowed", + "servoRailEnabled", "servoTorqueEnabled", "powerMotionRequested", "powerMotionAllowed", + "powerServoRailAllowed", "motionActuatorReady", "cameraGazeMotionOutput" + )) { + $property = if ($null -ne $Sample) { $Sample.PSObject.Properties[$field] } else { $null } + if ($null -eq $property -or $property.Value -isnot [bool] -or $property.Value) { return $true } + } + return $false +} + +$summary = $null +$run = $null +$polls = $null +$seal = $null +$candidate = $null +$hostRuntime = $null +$preflight = $null +$evidenceRoot = "" + +if (-not (Test-Path -LiteralPath $SummaryJsonPath -PathType Leaf)) { + Add-Check "summary-json" $false "Missing summary: $SummaryJsonPath" +} else { + $SummaryJsonPath = (Resolve-Path -LiteralPath $SummaryJsonPath).Path + $evidenceRoot = Split-Path -Parent $SummaryJsonPath + try { + $summary = Read-JsonWithRetry $SummaryJsonPath + $run = Read-JsonWithRetry (Join-Path $evidenceRoot "run.json") + $polls = Read-JsonWithRetry (Join-Path $evidenceRoot "polls.json") + $seal = Read-JsonWithRetry (Join-Path $evidenceRoot "seal.json") + $candidate = Read-JsonWithRetry (Join-Path $evidenceRoot "candidate-manifest.json") + $hostRuntime = Read-JsonWithRetry (Join-Path $evidenceRoot "host-runtime-manifest.json") + $preflight = Read-JsonWithRetry (Join-Path $evidenceRoot "preflight.json") + Add-Check "packet-json" $true "Parsed summary, run, polls, seal, candidate, and host runtime JSON." + } catch { + Add-Check "packet-json" $false "Packet JSON is missing or invalid after bounded retries." + } +} + +if ($null -ne $summary -and $null -ne $run -and $null -ne $polls -and $null -ne $seal -and + $null -ne $candidate -and $null -ne $hostRuntime -and $null -ne $preflight) { + Add-Check "summary-schema" ($summary.schema -ceq "stackchan.passive-no-motion-summary.v1") "schema=$($summary.schema)" + Add-Check "run-schema" ($run.schema -ceq "stackchan.passive-no-motion-run.v1") "schema=$($run.schema)" + Add-Check "polls-schema" ($polls.schema -ceq "stackchan.passive-no-motion-polls.v1") "schema=$($polls.schema)" + Add-Check "seal-schema" ($seal.schema -ceq "stackchan.passive-no-motion-seal.v1") "schema=$($seal.schema)" + $runIds = @(@([string]$summary.runId, [string]$run.runId, [string]$polls.runId, [string]$seal.runId) | + Sort-Object -Unique) + Add-Check "run-id" ($runIds.Count -eq 1 -and $runIds[0] -match "^[0-9a-f]{32}$") "runIds=$($runIds -join ',')" + + $requiredPacketFiles = @( + "run.json", "polls.json", "summary.json", "preflight.json", "runner.ps1", "checker.ps1", + "candidate-manifest.json", "host-runtime-manifest.json" + ) + $sealedFilesValid = $true + foreach ($name in $requiredPacketFiles) { + $path = Join-Path $evidenceRoot $name + $declared = [string](Get-PropertyValue $seal.files $name "") + $valid = (Test-Path -LiteralPath $path -PathType Leaf) -and + $declared -match "^[0-9a-f]{64}$" -and (Get-Sha256 $path) -ceq $declared + Add-Check ("sealed-" + $name) $valid "declared=$declared" + if (-not $valid) { $sealedFilesValid = $false } + } + $safetyStopPath = Join-Path $evidenceRoot "safety-stop.json" + $safetyStopDeclared = [string](Get-PropertyValue $seal.files "safety-stop.json" "") + $safetyStopPacket = $null + $safetyStopEvidenceValid = $false + if ($null -eq $summary.safetyStop) { + $safetyStopEvidenceValid = -not (Test-Path -LiteralPath $safetyStopPath) -and + [string]::IsNullOrWhiteSpace($safetyStopDeclared) + } elseif ((Test-Path -LiteralPath $safetyStopPath -PathType Leaf) -and + $safetyStopDeclared -match "^[0-9a-f]{64}$" -and (Get-Sha256 $safetyStopPath) -ceq $safetyStopDeclared) { + try { + $safetyStopPacket = Read-JsonWithRetry $safetyStopPath + $safetyStopEvidenceValid = [string]$safetyStopPacket.runId -ceq [string]$summary.runId -and + (Test-JsonBoolean $safetyStopPacket.result "stopRequestTransportOk" $true) -and + (Test-JsonBoolean $safetyStopPacket.result "stopRequestAccepted" $true) -and + (Test-JsonBoolean $safetyStopPacket.result "postStopDebugAvailable" $true) -and + (Test-JsonBoolean $safetyStopPacket.result "verifiedOff" $true) -and + $null -ne $safetyStopPacket.result.postStopDebug + } catch { $safetyStopEvidenceValid = $false } + } + Add-Check "safety-stop-evidence" $safetyStopEvidenceValid "no breach requires no stop file; a breach requires a sealed accepted stop and complete post-stop debug snapshot" + Add-Check "seal-summary-status" ([string]$seal.summaryStatus -ceq [string]$summary.status) "seal=$($seal.summaryStatus) summary=$($summary.status)" + + Add-Check "firmware-source" ([string]$run.firmwareSourceCommit -ceq $FirmwareSourceCommit -and + [string]$summary.firmwareSourceCommit -ceq $FirmwareSourceCommit -and + [string]$candidate.sourceCommit -ceq $FirmwareSourceCommit -and + [string]$hostRuntime.sourceCommit -ceq $FirmwareSourceCommit) "expected=$FirmwareSourceCommit" + Add-Check "firmware-sha" ([string]$run.expectedFirmwareSha256 -ceq $ExpectedFirmwareSha256 -and + [string]$summary.installedFirmwareSha256 -ceq $ExpectedFirmwareSha256 -and + [string]$candidate.firmwareSha256 -ceq $ExpectedFirmwareSha256) "expected=$ExpectedFirmwareSha256" + Add-Check "candidate-installed" (Test-JsonBoolean $candidate.installation "confirmed" $true) "confirmed=$($candidate.installation.confirmed)" + Add-Check "runner-source" ([string]$run.runnerSourceCommit -ceq $RunnerSourceCommit -and + [string]$summary.runnerSourceCommit -ceq $RunnerSourceCommit -and + [string]$summary.runnerSourceCommitEnd -ceq $RunnerSourceCommit) "expected=$RunnerSourceCommit" + $expectedRunnerBlob = ((& git rev-parse "$RunnerSourceCommit`:tools/run_passive_no_motion_evidence.ps1" 2>$null) -join "").Trim().ToLowerInvariant() + $expectedCheckerBlob = ((& git rev-parse "$RunnerSourceCommit`:tools/check_passive_no_motion_evidence.ps1" 2>$null) -join "").Trim().ToLowerInvariant() + $packetRunnerBlob = ((& git hash-object --path=tools/run_passive_no_motion_evidence.ps1 (Join-Path $evidenceRoot "runner.ps1")) -join "").Trim().ToLowerInvariant() + $packetCheckerBlob = ((& git hash-object --path=tools/check_passive_no_motion_evidence.ps1 (Join-Path $evidenceRoot "checker.ps1")) -join "").Trim().ToLowerInvariant() + Add-Check "runner-git-blobs" ($expectedRunnerBlob -match "^[0-9a-f]{40}$" -and + $expectedCheckerBlob -match "^[0-9a-f]{40}$" -and $packetRunnerBlob -ceq $expectedRunnerBlob -and + $packetCheckerBlob -ceq $expectedCheckerBlob -and [string]$run.runnerSourceBlob -ceq $expectedRunnerBlob -and + [string]$run.checkerSourceBlob -ceq $expectedCheckerBlob) "runner=$packetRunnerBlob checker=$packetCheckerBlob" + Add-Check "runner-clean" ((Test-JsonBoolean $run "runnerSourceDirty" $false) -and + (Test-JsonBoolean $summary "runnerSourceDirty" $false) -and + (Test-JsonBoolean $summary "runnerSourceDirtyEnd" $false)) "start=$($summary.runnerSourceDirty) end=$($summary.runnerSourceDirtyEnd)" + Add-Check "host-runtime-binding" ($hostRuntime.schema -ceq "stackchan.pc-brain-runtime.v1" -and + (Test-JsonBoolean $hostRuntime "sourceWorktreeClean" $true) -and [int]$hostRuntime.bridgePid -eq [int]$run.hostRuntimePid -and + [int]$summary.hostRuntimePid -eq [int]$run.hostRuntimePid) "pid=$($run.hostRuntimePid) sourceClean=$($hostRuntime.sourceWorktreeClean)" + $hostProcessStartedAt = try { [DateTime]::Parse([string]$run.hostProcessStartedAt).ToUniversalTime() } catch { [DateTime]::MaxValue } + $hostRuntimeGeneratedAt = try { [DateTime]::Parse([string]$hostRuntime.generatedAt).ToUniversalTime() } catch { [DateTime]::MinValue } + $hostTimeDeltaSeconds = ($hostRuntimeGeneratedAt - $hostProcessStartedAt).TotalSeconds + Add-Check "host-runtime-provenance" ([System.IO.Path]::GetFullPath([string]$run.qualificationRoot) -ceq + [System.IO.Path]::GetFullPath($RepoRoot) -and [System.IO.Path]::GetFullPath([string]$hostRuntime.sourceRoot) -ceq + [System.IO.Path]::GetFullPath([string]$run.qualificationRoot) -and + [string]$run.hostRuntimeGeneratedAt -ceq $hostRuntimeGeneratedAt.ToString("o") -and + $hostTimeDeltaSeconds -ge 0 -and $hostTimeDeltaSeconds -le 120 -and + [string]$run.hostCommandLineSha256 -match "^[0-9a-f]{64}$") "root=$($run.qualificationRoot) processToManifestSeconds=$hostTimeDeltaSeconds" + Add-Check "control-policy" ([string]$run.controlPolicy -ceq "emergency_stop_only" -and + [string]$summary.controlPolicy -ceq "emergency_stop_only") "run=$($run.controlPolicy) summary=$($summary.controlPolicy)" + Add-Check "ordinary-methods" (@($run.ordinaryRobotMethods).Count -eq 1 -and + [string]$run.ordinaryRobotMethods[0] -ceq "GET /debug" -and + @($summary.ordinaryRobotMethods).Count -eq 1 -and + [string]$summary.ordinaryRobotMethods[0] -ceq "GET /debug") "ordinary robot method must be exactly GET /debug" + + $externalFirmwarePath = [string]$seal.externalBindings.candidateFirmwarePath + $externalSourcePath = [string]$seal.externalBindings.candidateSourcePath + $externalFirmwareValid = (Test-Path -LiteralPath $externalFirmwarePath -PathType Leaf) -and + (Get-Sha256 $externalFirmwarePath) -ceq $ExpectedFirmwareSha256 -and + [string]$seal.externalBindings.candidateFirmwareSha256 -ceq $ExpectedFirmwareSha256 + $externalSourceHash = [string]$seal.externalBindings.candidateSourceSha256 + $externalSourceValid = (Test-Path -LiteralPath $externalSourcePath -PathType Leaf) -and + $externalSourceHash -match "^[0-9a-f]{64}$" -and (Get-Sha256 $externalSourcePath) -ceq $externalSourceHash -and + [string]$candidate.sourceArchiveSha256 -ceq $externalSourceHash + Add-Check "external-firmware" $externalFirmwareValid "path=$externalFirmwarePath" + Add-Check "external-source" $externalSourceValid "path=$externalSourcePath" + + $records = @($polls.records) + $sequenceValid = $records.Count -gt 0 + $timeValid = $true + $elapsedValid = $true + $maxPollGapMs = 0 + $previousTime = $null + $previousElapsedMs = $null + for ($index = 0; $index -lt $records.Count; $index++) { + if ((Get-IntValue $records[$index] "sequence" -1) -ne $index) { $sequenceValid = $false } + try { + $timestamp = [DateTime]::Parse([string]$records[$index].generatedAt).ToUniversalTime() + if ($null -ne $previousTime -and $timestamp -lt $previousTime) { $timeValid = $false } + $previousTime = $timestamp + } catch { $timeValid = $false } + $elapsedMs = Get-IntValue $records[$index] "elapsedMs" -1 + if ($elapsedMs -lt 0 -or ($null -ne $previousElapsedMs -and $elapsedMs -lt $previousElapsedMs)) { + $elapsedValid = $false + } + if ($null -ne $previousElapsedMs) { $maxPollGapMs = [math]::Max($maxPollGapMs, $elapsedMs - $previousElapsedMs) } + $previousElapsedMs = $elapsedMs + } + Add-Check "poll-sequence" $sequenceValid "records=$($records.Count)" + Add-Check "poll-time-order" $timeValid "timestamps must be parseable and nondecreasing" + Add-Check "poll-monotonic" ($elapsedValid -and $maxPollGapMs -le 8000) "maxGapMs=$maxPollGapMs limit=8000" + $preflightMatches = $records.Count -gt 0 -and [string]$preflight.runId -ceq [string]$run.runId -and + (($preflight.sample | ConvertTo-Json -Depth 14 -Compress) -ceq + ($records[0] | ConvertTo-Json -Depth 14 -Compress)) + Add-Check "preflight-record" $preflightMatches "preflight runId/sample must exactly match polls.records[0]" + + $qualificationPollContract = if ($MinDurationSeconds -ge 600) { + [int]$run.pollMilliseconds -eq 2000 -and [int]$run.pollTimeoutSeconds -eq 4 + } else { + [int]$run.pollMilliseconds -ge 50 -and [int]$run.pollMilliseconds -le 2000 -and + [int]$run.pollTimeoutSeconds -ge 1 -and [int]$run.pollTimeoutSeconds -le 4 + } + Add-Check "poll-contract" $qualificationPollContract "pollMs=$($run.pollMilliseconds) timeoutS=$($run.pollTimeoutSeconds) qualification=$($MinDurationSeconds -ge 600)" + + $okRecords = @($records | Where-Object { $_.ok -eq $true }) + $failedRecords = @($records | Where-Object { $_.ok -ne $true }) + $failedRatio = if ($records.Count -gt 0) { $failedRecords.Count / [double]$records.Count } else { 1.0 } + $failedPolicyPassed = if ($records.Count -lt 100) { $failedRecords.Count -eq 0 } else { $failedRatio -le 0.01 } + $maxFailureStreak = 0 + $failureStreak = 0 + foreach ($record in $records) { + if ($record.ok -eq $true) { $failureStreak = 0 } else { + $failureStreak += 1 + $maxFailureStreak = [math]::Max($maxFailureStreak, $failureStreak) + } + } + Add-Check "poll-counts" ($records.Count -eq [int]$summary.records -and $okRecords.Count -eq [int]$summary.okPolls -and + $failedRecords.Count -eq [int]$summary.failedPolls) "packet=$($records.Count) ok=$($okRecords.Count) failed=$($failedRecords.Count)" + Add-Check "failed-poll-policy" ($failedPolicyPassed -and $maxFailureStreak -le 1) "ratio=$failedRatio streak=$maxFailureStreak" + $failedContinuity = @($failedRecords | Where-Object { + -not (Test-JsonBoolean $_ "socketPresent" $true) -or + -not (Test-JsonBoolean $_ "hostOperational" $true) -or + -not (Test-JsonBoolean $_ "hostRuntimePidAlive" $true) -or + (Get-IntValue $_ "bridgePid" 0) -ne [int]$run.hostRuntimePid + }).Count -eq 0 + Add-Check "failed-poll-continuity" $failedContinuity "isolated debug failures must retain socket, host, and PID evidence" + + $bindingPassed = $okRecords.Count -gt 0 -and @($okRecords | Where-Object { + -not (Test-JsonBoolean $_ "debugResponseTruncated" $false) -or + [string]$_.controlPolicy -cne "emergency_stop_only" -or + [string]$_.firmwareSha256 -cne $ExpectedFirmwareSha256 -or + -not (Test-JsonBoolean $_ "appConfirmed" $true) -or + (Get-IntValue $_ "bridgePid" 0) -ne [int]$run.hostRuntimePid + }).Count -eq 0 + Add-Check "per-poll-binding" $bindingPassed "okPolls=$($okRecords.Count)" + + $motionBreaches = @($okRecords | Where-Object { Test-MotionBreach $_ }).Count + Add-Check "no-motion-authority" ($motionBreaches -eq 0 -and $null -eq $summary.safetyStop) "breaches=$motionBreaches safetyStop=$($null -ne $summary.safetyStop)" + foreach ($counter in @("motionEnableRequests", "motionSessionRefreshes", "motionLastWriteMs", "servoRailEnableEntries", "powerMotionGrants")) { + $counterPassed = $okRecords.Count -gt 0 -and @($okRecords | Where-Object { (Get-IntValue $_ $counter -1) -ne 0 }).Count -eq 0 + Add-Check ("zero-" + $counter) $counterPassed "all successful polls must report zero" + } + + foreach ($field in @( + "networkConnected", "bridgeReady", "bridgeUplinkReady", "socketPresent", "hostRuntimePidAlive", + "wakeReady", "speakerReady", "hostOperational", "hostSpeechReady", + "hostSttHealthy", "hostVoiceConfigured" + )) { + $readyPassed = $okRecords.Count -gt 0 -and @($okRecords | Where-Object { + -not (Test-JsonBoolean $_ $field $true) + }).Count -eq 0 + Add-Check ("ready-" + $field) $readyPassed "all successful polls must be ready" + } + $micReadyPassed = $okRecords.Count -gt 0 -and @($okRecords | Where-Object { + if (Test-JsonBoolean $_ "micReady" $true) { return $false } + $transient = (Test-JsonBoolean $_ "wakeAudioPauseRequested" $true) -or + (Test-JsonBoolean $_ "wakeAudioPaused" $true) -or + (Test-JsonBoolean $_ "audioStreamActive" $true) -or + (Test-JsonBoolean $_ "speakerRunning" $true) -or + ([string]$_.wakeCuePhase -notin @("", "idle", "unknown")) + return -not $transient + }).Count -eq 0 + Add-Check "ready-mic-lifecycle" $micReadyPassed "mic must be ready except during an explicit cue/capture/playback transition" + + $resetReasons = @($okRecords | ForEach-Object { [string]$_.resetReason } | Sort-Object -Unique) + $bootCounts = @($okRecords | ForEach-Object { [int64]$_.bootCount } | Sort-Object -Unique) + $uptimeRegressions = 0 + for ($index = 1; $index -lt $okRecords.Count; $index++) { + if ([int64]$okRecords[$index].uptimeMs -lt [int64]$okRecords[$index - 1].uptimeMs) { $uptimeRegressions += 1 } + } + Add-Check "clean-reset" ($resetReasons.Count -eq 1 -and $resetReasons[0] -in @("software", "poweron", "power-on")) "reasons=$($resetReasons -join ',')" + Add-Check "stable-boot" ($bootCounts.Count -eq 1 -and $uptimeRegressions -eq 0) "bootCounts=$($bootCounts -join ',') uptimeRegressions=$uptimeRegressions" + + $first = $okRecords | Select-Object -First 1 + $last = $okRecords | Select-Object -Last 1 + $coverageMs = if ($null -ne $first -and $null -ne $last) { + [int64]$last.elapsedMs - [int64]$first.elapsedMs + } else { 0 } + $pollMilliseconds = [int]$run.pollMilliseconds + $minimumCoverageMs = [math]::Max(0, ($MinDurationSeconds * 1000) - [math]::Max(2000, 2 * $pollMilliseconds)) + $minimumOkPolls = [math]::Max(1, [math]::Floor(($MinDurationSeconds * 1000 / [double]$pollMilliseconds) * 0.8)) + Add-Check "duration" ([int]$summary.durationSeconds -ge $MinDurationSeconds -and + [int64]$summary.elapsedDurationMs -ge ($MinDurationSeconds * 1000) -and + $coverageMs -ge $minimumCoverageMs -and $okRecords.Count -ge $minimumOkPolls -and + [int64]$summary.maxPollGapMs -eq $maxPollGapMs -and $maxPollGapMs -le 8000) "duration=$($summary.durationSeconds) elapsedMs=$($summary.elapsedDurationMs) coverageMs=$coverageMs ok=$($okRecords.Count) requiredOk=$minimumOkPolls maxGapMs=$maxPollGapMs" + + $minVbus = if ($okRecords.Count -gt 0) { [int](($okRecords | Measure-Object -Property powerVbusMv -Minimum).Minimum) } else { -1 } + $minReportedVbus = if ($okRecords.Count -gt 0) { [int](($okRecords | Measure-Object -Property powerVbusReportedMinMv -Minimum).Minimum) } else { -1 } + $maxTemp = if ($okRecords.Count -gt 0) { [double](($okRecords | Measure-Object -Property chipTempC -Maximum).Maximum) } else { -1 } + $maxFrame = if ($okRecords.Count -gt 0) { [int](($okRecords | Measure-Object -Property displayMaxFrameUs -Maximum).Maximum) } else { -1 } + Add-Check "vbus-floor" ($minVbus -ge $MinPowerVbusMv -and $minReportedVbus -ge $MinPowerVbusMv) "sample=$minVbus reported=$minReportedVbus min=$MinPowerVbusMv" + Add-Check "chip-temperature" ($maxTemp -ge 0 -and $maxTemp -le $MaxChipTempC) "observed=$maxTemp max=$MaxChipTempC" + Add-Check "display-frame" ($maxFrame -gt 0 -and $maxFrame -le $MaxDisplayFrameUs) "observed=$maxFrame max=$MaxDisplayFrameUs" + + $powerReady = $okRecords.Count -gt 0 -and @($okRecords | Where-Object { + [string]$_.powerForensicsSchema -cne "axp2101-v2" -or + -not (Test-JsonBoolean $_ "powerForensicsEnabled" $true) -or + -not (Test-JsonBoolean $_ "powerForensicsIrqEnabled" $true) -or + -not (Test-JsonBoolean $_ "powerForensicsBootStatusValid" $true) -or + [int64]$_.powerForensicsBootEventMask -ne 0 -or [string]$_.powerForensicsBootEvent -cne "none" -or + -not (Test-JsonBoolean $_ "powerForensicsBootProtective" $false) + }).Count -eq 0 + Add-Check "power-forensics-ready" $powerReady "all successful polls must report valid event-free boot forensics" + foreach ($counter in @( + "powerForensicsRuntimeEvents", "powerForensicsProtectiveEvents", "powerForensicsReadFailures", + "powerForensicsClearFailures", "pmicVbusLossEntries", "vbusHardFloorEntries", "powerReadFailures", + "pmicInputReadFailures", "pmicConfigReadFailures", "powerVsysReadFailures", "chipTempReadFailures" + )) { + $values = @($okRecords | ForEach-Object { Get-IntValue $_ $counter -1 } | Sort-Object -Unique) + Add-Check ("stable-" + $counter) ($values.Count -eq 1 -and $values[0] -ge 0) "values=$($values -join ',')" + } + + if ($RequireObservedTurn -or [bool]$summary.requireObservedTurn) { + $captureDelta = [int64]$last.wakeCapturesCompleted - [int64]$first.wakeCapturesCompleted + $turnDelta = [int64]$last.uplinkTurns - [int64]$first.uplinkTurns + $playbackDelta = [int64]$last.playbackCompletions - [int64]$first.playbackCompletions + $playbackErrorDelta = [int64]$last.playbackErrors - [int64]$first.playbackErrors + Add-Check "conversation-lifecycle" ($captureDelta -ge 1 -and $turnDelta -ge 1 -and + $playbackDelta -ge 1 -and $playbackErrorDelta -eq 0) "captures=$captureDelta turns=$turnDelta playback=$playbackDelta errors=$playbackErrorDelta; this does not prove STT correctness" + } + if ($RequireCameraHostVision -or [bool]$summary.requireCameraHostVision) { + $cameraReady = $okRecords.Count -gt 0 -and @($okRecords | Where-Object { + [int]$_.compiledCamera -ne 1 -or [int]$_.compiledCameraHostVision -ne 1 -or + -not (Test-JsonBoolean $_ "cameraReady" $true) -or + -not (Test-JsonBoolean $_ "cameraActive" $true) + }).Count -eq 0 + $cameraFrameDelta = [int64]$last.cameraFrames - [int64]$first.cameraFrames + $cameraRequestDelta = [int64]$last.cameraHostFrameRequests - [int64]$first.cameraHostFrameRequests + Add-Check "camera-host-vision" ($cameraReady -and $cameraFrameDelta -ge 1 -and $cameraRequestDelta -ge 1) "frames=$cameraFrameDelta requests=$cameraRequestDelta" + } + + Add-Check "summary-status" ([string]$summary.status -ceq "pass") "status=$($summary.status)" + Add-Check "summary-issues" (@($summary.issues).Count -eq 0) "issues=$(@($summary.issues) -join ',')" + Add-Check "fatal-error" ([string]::IsNullOrWhiteSpace([string]$summary.fatalError)) "fatalError=$($summary.fatalError)" +} + +$failed = @($checks | Where-Object { $_.status -eq "fail" }) +$result = [ordered]@{ + schema = "stackchan.passive-no-motion-check.v2" + status = $(if ($failed.Count -eq 0) { "pass" } else { "fail" }) + passed = @($checks | Where-Object { $_.status -eq "pass" }).Count + failed = $failed.Count + checks = @($checks | ForEach-Object { $_ }) +} +if ($Json) { $result | ConvertTo-Json -Depth 8 } else { $result } +if ($failed.Count -gt 0) { exit 1 } diff --git a/tools/run_passive_no_motion_evidence.ps1 b/tools/run_passive_no_motion_evidence.ps1 new file mode 100644 index 00000000..c1c64aee --- /dev/null +++ b/tools/run_passive_no_motion_evidence.ps1 @@ -0,0 +1,817 @@ +param( + [string]$DeviceHost = "192.168.1.238", + [int]$DevicePort = 8789, + [int]$BridgeLocalPort = 8765, + [int]$DashboardPort = 8766, + [string]$EvidenceRoot = "", + [int]$DurationSeconds = 600, + [int]$PollMilliseconds = 2000, + [int]$PollTimeoutSeconds = 4, + [string]$ExpectedFirmwareSha256 = "", + [string]$FirmwareSourceCommit = "", + [Parameter(Mandatory = $false)] + [string]$CandidateManifestPath = "", + [string]$HostRuntimeManifestPath = "output\pc-brain\latest\runtime_manifest.json", + [int]$MinPowerVbusMv = 4400, + [double]$MaxChipTempC = 70.0, + [int]$MaxDisplayFrameUs = 50000, + [switch]$RequireCameraHostVision, + [switch]$RequireObservedTurn, + [switch]$ContractProbe +) + +$ErrorActionPreference = "Stop" + +function Get-PropertyValue { + param($Object, [string]$Name, $DefaultValue = $null) + if ($null -eq $Object) { return $DefaultValue } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return $DefaultValue } + return $property.Value +} + +function Test-TrueValue { + param($Value, [bool]$FailClosed = $false) + if ($Value -is [bool]) { return $Value } + if ($Value -is [ValueType]) { + try { + $numeric = [double]$Value + if ($numeric -eq 0) { return $false } + if ($numeric -eq 1) { return $true } + } catch {} + return $FailClosed + } + $normalized = ([string]$Value).Trim().ToLowerInvariant() + if ($normalized -in @("true", "1", "yes", "on")) { return $true } + if ($normalized -in @("false", "0", "no", "off")) { return $false } + return $FailClosed +} + +function Get-IntValue { + param($Object, [string]$Name, [int64]$DefaultValue = 0) + $value = Get-PropertyValue $Object $Name $null + if ($null -eq $value) { return $DefaultValue } + try { return [int64]$value } catch { return $DefaultValue } +} + +function Get-DoubleValue { + param($Object, [string]$Name, [double]$DefaultValue = 0.0) + $value = Get-PropertyValue $Object $Name $null + if ($null -eq $value) { return $DefaultValue } + try { return [double]$value } catch { return $DefaultValue } +} + +function Get-Sha256 { + param([string]$Path) + return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() +} + +function Get-TextSha256 { + param([string]$Text) + $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } +} + +function Write-JsonAtomic { + param([string]$Path, $Value) + + $absolutePath = [System.IO.Path]::GetFullPath($Path) + $directory = [System.IO.Path]::GetDirectoryName($absolutePath) + [System.IO.Directory]::CreateDirectory($directory) | Out-Null + $tempPath = Join-Path $directory (([System.IO.Path]::GetFileName($absolutePath)) + ".tmp." + [guid]::NewGuid().ToString("N")) + $backupPath = Join-Path $directory (([System.IO.Path]::GetFileName($absolutePath)) + ".bak." + [guid]::NewGuid().ToString("N")) + $json = $Value | ConvertTo-Json -Depth 10 + $encoding = [System.Text.UTF8Encoding]::new($false) + + try { + [System.IO.File]::WriteAllText($tempPath, $json, $encoding) + if ([System.IO.File]::Exists($absolutePath)) { + [System.IO.File]::Replace($tempPath, $absolutePath, $backupPath, $true) + if ([System.IO.File]::Exists($backupPath)) { + [System.IO.File]::Delete($backupPath) + } + } else { + [System.IO.File]::Move($tempPath, $absolutePath) + } + } finally { + foreach ($candidate in @($tempPath, $backupPath)) { + try { + if ([System.IO.File]::Exists($candidate)) { + [System.IO.File]::Delete($candidate) + } + } catch {} + } + } +} + +function Invoke-JsonGet { + param([string]$Uri, [int]$TimeoutSeconds) + try { + return [pscustomobject]@{ + ok = $true + json = Invoke-RestMethod -Method Get -Uri $Uri -TimeoutSec $TimeoutSeconds + errorCode = "" + } + } catch { + return [pscustomobject]@{ + ok = $false + json = $null + errorCode = $_.Exception.GetType().Name + } + } +} + +function Get-BridgeSocketEvidence { + try { + $matches = @(Get-NetTCPConnection -State Established -LocalPort $BridgeLocalPort -ErrorAction Stop | + Where-Object { $_.RemoteAddress -eq $DeviceHost }) + if ($matches.Count -ne 1) { + return [pscustomobject]@{ present = $false; owningPid = 0 } + } + return [pscustomobject]@{ + present = $true + owningPid = [int]$matches[0].OwningProcess + } + } catch { + return [pscustomobject]@{ present = $false; owningPid = 0 } + } +} + +function Get-HostEvidence { + $status = Invoke-JsonGet -Uri "http://127.0.0.1`:$DashboardPort/api/status" -TimeoutSeconds $PollTimeoutSeconds + if (-not $status.ok) { + return [pscustomobject]@{ + ok = $false + operational = $false + speechReady = $false + sttHealthy = $false + voiceConfigured = $false + voiceSuccesses = 0 + playbackSuccesses = 0 + } + } + $bridge = Get-PropertyValue $status.json "bridge" $null + $services = Get-PropertyValue $status.json "services" $null + $stt = Get-PropertyValue $services "speechRecognition" $null + $voice = Get-PropertyValue $services "voice" $null + $playback = Get-PropertyValue $services "playback" $null + return [pscustomobject]@{ + ok = $true + operational = Test-TrueValue (Get-PropertyValue $bridge "operational" $false) + speechReady = Test-TrueValue (Get-PropertyValue $bridge "speechReady" $false) + sttHealthy = Test-TrueValue (Get-PropertyValue $stt "healthy" $false) + voiceConfigured = Test-TrueValue (Get-PropertyValue $voice "configured" $false) + voiceSuccesses = Get-IntValue $voice "successes" 0 + playbackSuccesses = Get-IntValue $playback "successes" 0 + } +} + +function ConvertTo-BoundedSample { + param($Debug, $Socket, $HostStatus, [int]$Sequence) + return [pscustomobject][ordered]@{ + sequence = $Sequence + generatedAt = [DateTime]::UtcNow.ToString("o") + elapsedMs = [int64]$RunStopwatch.ElapsedMilliseconds + ok = $true + errorCode = "" + uptimeMs = Get-IntValue $Debug "uptime_ms" -1 + bootCount = Get-IntValue $Debug "boot_count" -1 + resetReason = [string](Get-PropertyValue $Debug "reset_reason" "unknown") + resetReasonCode = Get-IntValue $Debug "reset_reason_code" -1 + debugResponseTruncated = Test-TrueValue (Get-PropertyValue $Debug "debug_response_truncated" $true) $true + controlPolicy = [string](Get-PropertyValue $Debug "debug_http_control_policy" "unknown") + firmwareSha256 = ([string](Get-PropertyValue $Debug "ota_expected_sha256" "")).ToLowerInvariant() + appConfirmed = Test-TrueValue (Get-PropertyValue $Debug "ota_current_app_confirmed" $false) + motionRequested = Test-TrueValue (Get-PropertyValue $Debug "motion_requested" $true) $true + motionAutonomous = Test-TrueValue (Get-PropertyValue $Debug "motion_autonomous" $true) $true + motionEnabled = Test-TrueValue (Get-PropertyValue $Debug "motion_enabled" $true) $true + servoPowerAllowed = Test-TrueValue (Get-PropertyValue $Debug "servo_power_allowed" $true) $true + servoRailEnabled = Test-TrueValue (Get-PropertyValue $Debug "servo_rail_enabled" $true) $true + servoTorqueEnabled = Test-TrueValue (Get-PropertyValue $Debug "servo_torque_enabled" $true) $true + powerMotionRequested = Test-TrueValue (Get-PropertyValue $Debug "power_motion_requested" $true) $true + powerMotionAllowed = Test-TrueValue (Get-PropertyValue $Debug "power_motion_allowed" $true) $true + powerServoRailAllowed = Test-TrueValue (Get-PropertyValue $Debug "power_servo_rail_allowed" $true) $true + motionActuatorReady = Test-TrueValue (Get-PropertyValue $Debug "motion_actuator_ready" $true) $true + motionEnableRequests = Get-IntValue $Debug "motion_enable_requests" -1 + motionSessionRefreshes = Get-IntValue $Debug "motion_session_refreshes" -1 + motionLastWriteMs = Get-IntValue $Debug "motion_last_write_ms" -1 + servoRailEnableEntries = Get-IntValue $Debug "servo_rail_enable_entries" -1 + powerMotionGrants = Get-IntValue $Debug "power_motion_grants" -1 + powerForensicsSchema = [string](Get-PropertyValue $Debug "power_forensics_schema" "unknown") + powerForensicsEnabled = Test-TrueValue (Get-PropertyValue $Debug "power_forensics_enabled" $false) + powerForensicsIrqEnabled = Test-TrueValue (Get-PropertyValue $Debug "power_forensics_irq_enable_succeeded" $false) + powerForensicsBootStatusValid = Test-TrueValue (Get-PropertyValue $Debug "power_forensics_boot_status_valid" $false) + powerForensicsBootEventMask = Get-IntValue $Debug "power_forensics_boot_event_mask" -1 + powerForensicsBootEvent = [string](Get-PropertyValue $Debug "power_forensics_boot_event" "unknown") + powerForensicsBootProtective = Test-TrueValue (Get-PropertyValue $Debug "power_forensics_boot_protective" $true) + powerForensicsRuntimeEvents = Get-IntValue $Debug "power_forensics_runtime_event_polls" -1 + powerForensicsProtectiveEvents = Get-IntValue $Debug "power_forensics_runtime_protective_event_polls" -1 + powerForensicsReadFailures = Get-IntValue $Debug "power_forensics_read_failures" -1 + powerForensicsClearFailures = Get-IntValue $Debug "power_forensics_clear_failures" -1 + pmicVbusLossEntries = Get-IntValue $Debug "power_pmic_vbus_loss_entries" -1 + vbusHardFloorEntries = Get-IntValue $Debug "power_vbus_hard_floor_entries" -1 + powerReadFailures = Get-IntValue $Debug "power_read_failures" -1 + pmicInputReadFailures = Get-IntValue $Debug "power_pmic_input_state_read_failures" -1 + pmicConfigReadFailures = Get-IntValue $Debug "power_pmic_config_read_failures" -1 + powerVsysReadFailures = Get-IntValue $Debug "power_vsys_read_failures" -1 + chipTempReadFailures = Get-IntValue $Debug "chip_temp_read_failures" -1 + networkConnected = ([string](Get-PropertyValue $Debug "network_state" "")) -ceq "connected" + bridgeReady = ([string](Get-PropertyValue $Debug "bridge_state" "")) -ceq "ready" + bridgeUplinkReady = Test-TrueValue (Get-PropertyValue $Debug "bridge_uplink_ready" $false) + socketPresent = [bool]$Socket.present + bridgePid = [int]$Socket.owningPid + hostRuntimePidAlive = $HostRuntimePid -gt 0 -and $null -ne (Get-Process -Id $HostRuntimePid -ErrorAction SilentlyContinue) + wakeReady = (Test-TrueValue (Get-PropertyValue $Debug "sr_wake_task_started" $false)) -and + (Test-TrueValue (Get-PropertyValue $Debug "sr_wake_sr_ready" $false)) + micReady = Test-TrueValue (Get-PropertyValue $Debug "sr_wake_mic_ready" $false) + wakeAudioPauseRequested = Test-TrueValue (Get-PropertyValue $Debug "sr_wake_audio_pause_requested" $false) + wakeAudioPaused = Test-TrueValue (Get-PropertyValue $Debug "sr_wake_audio_paused" $false) + wakeCuePhase = [string](Get-PropertyValue $Debug "wake_cue_phase" "unknown") + speakerReady = (Get-IntValue $Debug "compiled_enable_speaker" 0) -eq 1 -and + (Test-TrueValue (Get-PropertyValue $Debug "speaker_enabled" $false)) + hostOperational = [bool]$HostStatus.operational + hostSpeechReady = [bool]$HostStatus.speechReady + hostSttHealthy = [bool]$HostStatus.sttHealthy + hostVoiceConfigured = [bool]$HostStatus.voiceConfigured + hostVoiceSuccesses = [int64]$HostStatus.voiceSuccesses + hostPlaybackSuccesses = [int64]$HostStatus.playbackSuccesses + powerVbusMv = Get-IntValue $Debug "power_vbus_mv" -1 + powerVbusReportedMinMv = Get-IntValue $Debug "power_vbus_min_mv" -1 + chipTempC = Get-DoubleValue $Debug "chip_temp_c" -1.0 + displayMaxFrameUs = Get-IntValue $Debug "display_window_max_frame_us" -1 + displaySlowFrames = Get-IntValue $Debug "display_window_slow_frames" -1 + heapFree = Get-IntValue $Debug "heap_free" -1 + heapMinFree = Get-IntValue $Debug "heap_min_free" -1 + wakeDetections = Get-IntValue $Debug "wake_cue_detections" -1 + wakeCapturesCompleted = Get-IntValue $Debug "wake_cue_captures_completed" -1 + uplinkTurns = Get-IntValue $Debug "bridge_uplink_turns" -1 + playbackStarts = Get-IntValue $Debug "bridge_downlink_playback_starts" -1 + playbackCompletions = Get-IntValue $Debug "bridge_downlink_playback_completions" -1 + playbackErrors = Get-IntValue $Debug "bridge_downlink_playback_errors" -1 + compiledCamera = Get-IntValue $Debug "compiled_enable_camera" -1 + compiledCameraHostVision = Get-IntValue $Debug "compiled_enable_camera_host_vision" -1 + cameraReady = Test-TrueValue (Get-PropertyValue $Debug "camera_ready" $false) + cameraActive = Test-TrueValue (Get-PropertyValue $Debug "camera_active" $false) + cameraFrames = Get-IntValue $Debug "camera_frames_captured" -1 + cameraHostFrameRequests = Get-IntValue $Debug "camera_host_frame_requests" -1 + cameraGazeTracking = Test-TrueValue (Get-PropertyValue $Debug "camera_gaze_tracking" $false) + cameraGazeMotionOutput = Test-TrueValue (Get-PropertyValue $Debug "camera_gaze_motion_output_active" $true) $true + audioStreamActive = Test-TrueValue (Get-PropertyValue $Debug "audio_stream_active" $false) + speakerRunning = Test-TrueValue (Get-PropertyValue $Debug "speaker_running" $false) + } +} + +function Test-MotionBreach { + param($Sample) + return [bool]($Sample.motionRequested -or $Sample.motionAutonomous -or $Sample.motionEnabled -or + $Sample.servoPowerAllowed -or $Sample.servoRailEnabled -or $Sample.servoTorqueEnabled -or + $Sample.powerMotionRequested -or $Sample.powerMotionAllowed -or $Sample.powerServoRailAllowed -or + $Sample.motionActuatorReady -or $Sample.cameraGazeMotionOutput) +} + +function Invoke-SafetyMotionStop { + $stop = Invoke-JsonGet -Uri "http://$DeviceHost`:$DevicePort/motion-stop" -TimeoutSeconds $PollTimeoutSeconds + Start-Sleep -Milliseconds 350 + $verify = Invoke-JsonGet -Uri "http://$DeviceHost`:$DevicePort/debug" -TimeoutSeconds $PollTimeoutSeconds + $verified = $false + if ($verify.ok) { + $verified = -not (Test-TrueValue (Get-PropertyValue $verify.json "motion_requested" $true) $true) -and + -not (Test-TrueValue (Get-PropertyValue $verify.json "motion_autonomous" $true) $true) -and + -not (Test-TrueValue (Get-PropertyValue $verify.json "motion_enabled" $true) $true) -and + -not (Test-TrueValue (Get-PropertyValue $verify.json "servo_power_allowed" $true) $true) -and + -not (Test-TrueValue (Get-PropertyValue $verify.json "servo_rail_enabled" $true) $true) -and + -not (Test-TrueValue (Get-PropertyValue $verify.json "servo_torque_enabled" $true) $true) -and + -not (Test-TrueValue (Get-PropertyValue $verify.json "power_motion_requested" $true) $true) -and + -not (Test-TrueValue (Get-PropertyValue $verify.json "power_motion_allowed" $true) $true) -and + -not (Test-TrueValue (Get-PropertyValue $verify.json "power_servo_rail_allowed" $true) $true) -and + -not (Test-TrueValue (Get-PropertyValue $verify.json "motion_actuator_ready" $true) $true) -and + -not (Test-TrueValue (Get-PropertyValue $verify.json "camera_gaze_motion_output_active" $true) $true) + } + return [ordered]@{ + generatedAt = [DateTime]::UtcNow.ToString("o") + reason = "observed_motion_breach" + stopRequestTransportOk = [bool]$stop.ok + stopRequestAccepted = [bool]($stop.ok -and (Test-TrueValue (Get-PropertyValue $stop.json "accepted" $false))) + postStopDebugAvailable = [bool]$verify.ok + verifiedOff = [bool]$verified + postStopDebug = $(if ($verify.ok) { $verify.json } else { $null }) + postStop = $(if ($verify.ok) { + [ordered]@{ + motionRequested = Test-TrueValue (Get-PropertyValue $verify.json "motion_requested" $true) $true + motionAutonomous = Test-TrueValue (Get-PropertyValue $verify.json "motion_autonomous" $true) $true + motionEnabled = Test-TrueValue (Get-PropertyValue $verify.json "motion_enabled" $true) $true + servoPowerAllowed = Test-TrueValue (Get-PropertyValue $verify.json "servo_power_allowed" $true) $true + servoRailEnabled = Test-TrueValue (Get-PropertyValue $verify.json "servo_rail_enabled" $true) $true + servoTorqueEnabled = Test-TrueValue (Get-PropertyValue $verify.json "servo_torque_enabled" $true) $true + powerMotionRequested = Test-TrueValue (Get-PropertyValue $verify.json "power_motion_requested" $true) $true + powerMotionAllowed = Test-TrueValue (Get-PropertyValue $verify.json "power_motion_allowed" $true) $true + powerServoRailAllowed = Test-TrueValue (Get-PropertyValue $verify.json "power_servo_rail_allowed" $true) $true + motionActuatorReady = Test-TrueValue (Get-PropertyValue $verify.json "motion_actuator_ready" $true) $true + cameraGazeMotionOutput = Test-TrueValue (Get-PropertyValue $verify.json "camera_gaze_motion_output_active" $true) $true + } + } else { $null }) + } +} + +function Test-SampleBinding { + param($Sample) + if ($Sample.debugResponseTruncated) { return "debug_response_truncated" } + if ($Sample.controlPolicy -cne "emergency_stop_only") { return "control_policy_mismatch" } + if ($Sample.firmwareSha256 -cne $ExpectedFirmwareSha256) { return "firmware_sha_mismatch" } + if (-not $Sample.appConfirmed) { return "app_not_confirmed" } + return "" +} + +if ($ContractProbe) { + [ordered]@{ + schema = "stackchan.passive-no-motion-contract-probe.v1" + ordinaryRobotMethods = @("GET /debug") + safetyException = "GET /motion-stop only after an observed motion breach" + motionRefresh = $false + networkMutation = $false + } | ConvertTo-Json -Depth 4 + exit 0 +} + +if ($DurationSeconds -lt 1) { throw "DurationSeconds must be at least 1." } +if ($PollMilliseconds -lt 50 -or $PollMilliseconds -gt 2000) { throw "PollMilliseconds must be 50 through 2000." } +if ($PollTimeoutSeconds -lt 1 -or $PollTimeoutSeconds -gt 4) { throw "PollTimeoutSeconds must be 1 through 4." } +$ExpectedFirmwareSha256 = $ExpectedFirmwareSha256.Trim().ToLowerInvariant() +$FirmwareSourceCommit = $FirmwareSourceCommit.Trim().ToLowerInvariant() +if ($ExpectedFirmwareSha256 -notmatch "^[0-9a-f]{64}$") { throw "ExpectedFirmwareSha256 must be a full SHA-256." } +if ($FirmwareSourceCommit -notmatch "^[0-9a-f]{40}$") { throw "FirmwareSourceCommit must be a full Git commit SHA." } +if ([string]::IsNullOrWhiteSpace($CandidateManifestPath)) { throw "CandidateManifestPath is required." } + +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $RepoRoot +$RunnerSourceCommit = (& git rev-parse HEAD).Trim().ToLowerInvariant() +$RunnerSourceDirty = -not [string]::IsNullOrWhiteSpace(((& git status --porcelain=v1 --untracked-files=normal) -join "`n")) +$RunnerSourceHash = Get-Sha256 $PSCommandPath +$CheckerSourcePath = Join-Path $PSScriptRoot "check_passive_no_motion_evidence.ps1" +$CheckerSourceHash = Get-Sha256 $CheckerSourcePath +$RunnerSourceBlob = ((& git rev-parse "$RunnerSourceCommit`:tools/run_passive_no_motion_evidence.ps1" 2>$null) -join "").Trim().ToLowerInvariant() +$CheckerSourceBlob = ((& git rev-parse "$RunnerSourceCommit`:tools/check_passive_no_motion_evidence.ps1" 2>$null) -join "").Trim().ToLowerInvariant() +if ($RunnerSourceBlob -notmatch "^[0-9a-f]{40}$" -or $CheckerSourceBlob -notmatch "^[0-9a-f]{40}$") { + throw "Runner/checker are not tracked at the runner source commit." +} +& git cat-file -e "$FirmwareSourceCommit`^{commit}" 2>$null +if ($LASTEXITCODE -ne 0) { throw "FirmwareSourceCommit is unavailable in this repository." } + +$CandidateManifestPath = (Resolve-Path -LiteralPath $CandidateManifestPath).Path +$CandidateManifest = Get-Content -Raw -LiteralPath $CandidateManifestPath | ConvertFrom-Json +$CandidateRoot = Split-Path -Parent $CandidateManifestPath +$CandidateFirmwarePath = Join-Path $CandidateRoot "firmware.bin" +$CandidateSourcePath = Join-Path $CandidateRoot ("source-" + $FirmwareSourceCommit.Substring(0, 8) + ".zip") +if (-not (Test-Path -LiteralPath $CandidateFirmwarePath -PathType Leaf) -or + -not (Test-Path -LiteralPath $CandidateSourcePath -PathType Leaf)) { + throw "Candidate packet is missing firmware.bin or its exact source archive." +} +$CandidateManifestHash = Get-Sha256 $CandidateManifestPath +$CandidateFirmwareHash = Get-Sha256 $CandidateFirmwarePath +$CandidateSourceHash = Get-Sha256 $CandidateSourcePath +if ([string](Get-PropertyValue $CandidateManifest "sourceCommit" "") -cne $FirmwareSourceCommit -or + [string](Get-PropertyValue $CandidateManifest "firmwareSha256" "") -cne $ExpectedFirmwareSha256 -or + $CandidateFirmwareHash -cne $ExpectedFirmwareSha256 -or + [string](Get-PropertyValue $CandidateManifest "sourceArchiveSha256" "") -cne $CandidateSourceHash -or + -not (Test-TrueValue (Get-PropertyValue (Get-PropertyValue $CandidateManifest "installation" $null) "confirmed" $false))) { + throw "Candidate manifest/artifact/source binding failed." +} + +$HostRuntimeManifestPath = if ([System.IO.Path]::IsPathRooted($HostRuntimeManifestPath)) { + (Resolve-Path -LiteralPath $HostRuntimeManifestPath).Path +} else { + (Resolve-Path -LiteralPath (Join-Path $RepoRoot $HostRuntimeManifestPath)).Path +} +$HostRuntimeManifest = Get-Content -Raw -LiteralPath $HostRuntimeManifestPath | ConvertFrom-Json +$HostRuntimeManifestHash = Get-Sha256 $HostRuntimeManifestPath +$HostRuntimePid = Get-IntValue $HostRuntimeManifest "bridgePid" 0 +$HostSourceRoot = [System.IO.Path]::GetFullPath([string](Get-PropertyValue $HostRuntimeManifest "sourceRoot" "")) +if ([string](Get-PropertyValue $HostRuntimeManifest "schema" "") -cne "stackchan.pc-brain-runtime.v1" -or + [string](Get-PropertyValue $HostRuntimeManifest "sourceCommit" "") -cne $FirmwareSourceCommit -or + -not (Test-TrueValue (Get-PropertyValue $HostRuntimeManifest "sourceWorktreeClean" $false)) -or + $HostRuntimePid -le 0 -or $HostSourceRoot -cne [System.IO.Path]::GetFullPath([string]$RepoRoot)) { + throw "Host runtime manifest does not bind the exact firmware source and qualification root." +} +if ($null -eq (Get-Process -Id $HostRuntimePid -ErrorAction SilentlyContinue)) { + throw "Host runtime PID is not alive." +} +$HostProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$HostRuntimePid" -ErrorAction Stop +$HostProcessStartedAt = $HostProcess.CreationDate.ToUniversalTime() +$HostRuntimeGeneratedAt = [DateTime]::Parse([string](Get-PropertyValue $HostRuntimeManifest "generatedAt" "")).ToUniversalTime() +$HostCommandLine = [string]$HostProcess.CommandLine +if ($HostRuntimeGeneratedAt -lt $HostProcessStartedAt -or + ($HostRuntimeGeneratedAt - $HostProcessStartedAt).TotalSeconds -gt 120 -or + $HostCommandLine -notmatch '(?i)bridge[\\/]lan_service\.py' -or + $HostCommandLine -notmatch ('(?i)--robot-host\s+' + [regex]::Escape($DeviceHost) + '(?:\s|$)')) { + throw "Host runtime process creation/command line does not match its manifest and robot binding." +} +$HostCommandLineSha256 = Get-TextSha256 $HostCommandLine +& git diff --quiet $FirmwareSourceCommit -- bridge +if ($LASTEXITCODE -ne 0) { throw "Tracked bridge source differs from the host runtime commit." } + +$EvidenceRoot = if ([System.IO.Path]::IsPathRooted($EvidenceRoot)) { + [System.IO.Path]::GetFullPath($EvidenceRoot) +} elseif ([string]::IsNullOrWhiteSpace($EvidenceRoot)) { + $generatedId = [guid]::NewGuid().ToString("N") + [System.IO.Path]::GetFullPath((Join-Path $RepoRoot ("output\pc-brain\passive-no-motion-" + (Get-Date -Format "yyyyMMdd-HHmmss") + "-" + $generatedId.Substring(0, 8)))) +} else { + [System.IO.Path]::GetFullPath((Join-Path $RepoRoot $EvidenceRoot)) +} +[string]$RunId = [guid]::NewGuid().ToString("N") +if (Test-Path -LiteralPath $EvidenceRoot) { + if (@(Get-ChildItem -LiteralPath $EvidenceRoot -Force).Count -gt 0) { throw "EvidenceRoot must be new or empty." } +} else { + [System.IO.Directory]::CreateDirectory($EvidenceRoot) | Out-Null +} +[System.IO.Directory]::CreateDirectory($EvidenceRoot) | Out-Null +[System.IO.File]::Copy($PSCommandPath, (Join-Path $EvidenceRoot "runner.ps1"), $false) +[System.IO.File]::Copy($CheckerSourcePath, (Join-Path $EvidenceRoot "checker.ps1"), $false) +[System.IO.File]::Copy($CandidateManifestPath, (Join-Path $EvidenceRoot "candidate-manifest.json"), $false) +[System.IO.File]::Copy($HostRuntimeManifestPath, (Join-Path $EvidenceRoot "host-runtime-manifest.json"), $false) +$startUtc = [DateTime]::UtcNow +$RunStopwatch = [System.Diagnostics.Stopwatch]::StartNew() +$deadline = $startUtc.AddSeconds($DurationSeconds) +$records = New-Object System.Collections.Generic.List[object] +$failedPolls = 0 +$consecutiveFailedPolls = 0 +$maxConsecutiveObserved = 0 +$fatalError = "" +$safetyStop = $null +$preflightSample = $null +$MaxFailedPollRatio = 0.01 +$MaxConsecutiveFailedPolls = 1 + +Write-JsonAtomic (Join-Path $EvidenceRoot "run.json") ([ordered]@{ + schema = "stackchan.passive-no-motion-run.v1" + runId = $RunId + startedAt = $startUtc.ToString("o") + evidenceRoot = $EvidenceRoot + qualificationRoot = [string]$RepoRoot + deviceHost = $DeviceHost + devicePort = $DevicePort + durationSeconds = $DurationSeconds + pollMilliseconds = $PollMilliseconds + pollTimeoutSeconds = $PollTimeoutSeconds + expectedFirmwareSha256 = $ExpectedFirmwareSha256 + firmwareSourceCommit = $FirmwareSourceCommit + runnerSourceCommit = $RunnerSourceCommit + runnerSourceDirty = $RunnerSourceDirty + runnerSourceSha256 = $RunnerSourceHash + runnerSourceBlob = $RunnerSourceBlob + checkerSourceSha256 = $CheckerSourceHash + checkerSourceBlob = $CheckerSourceBlob + candidateManifestPath = $CandidateManifestPath + candidateManifestSha256 = $CandidateManifestHash + candidateFirmwarePath = $CandidateFirmwarePath + candidateFirmwareSha256 = $CandidateFirmwareHash + candidateSourcePath = $CandidateSourcePath + candidateSourceSha256 = $CandidateSourceHash + hostRuntimeManifestPath = $HostRuntimeManifestPath + hostRuntimeManifestSha256 = $HostRuntimeManifestHash + hostRuntimePid = $HostRuntimePid + hostProcessStartedAt = $HostProcessStartedAt.ToString("o") + hostRuntimeGeneratedAt = $HostRuntimeGeneratedAt.ToString("o") + hostCommandLineSha256 = $HostCommandLineSha256 + controlPolicy = "preflight_pending" + ordinaryRobotMethods = @("GET /debug") + safetyException = "GET /motion-stop only after an observed motion breach" + requireCameraHostVision = [bool]$RequireCameraHostVision + requireObservedTurn = [bool]$RequireObservedTurn + }) + +try { + $debugUri = "http://$DeviceHost`:$DevicePort/debug" + $preflight = Invoke-JsonGet -Uri $debugUri -TimeoutSeconds $PollTimeoutSeconds + if (-not $preflight.ok) { + $fatalError = "preflight_debug_unavailable" + } else { + $preflightSample = ConvertTo-BoundedSample $preflight.json (Get-BridgeSocketEvidence) (Get-HostEvidence) 0 + $records.Add($preflightSample) + Write-JsonAtomic (Join-Path $EvidenceRoot "preflight.json") ([ordered]@{ runId = $RunId; sample = $preflightSample }) + if (Test-MotionBreach $preflightSample) { + $safetyStop = Invoke-SafetyMotionStop + Write-JsonAtomic (Join-Path $EvidenceRoot "safety-stop.json") ([ordered]@{ runId = $RunId; result = $safetyStop }) + $fatalError = "observed_motion_breach" + } else { + $preflightBindingIssue = Test-SampleBinding $preflightSample + if (-not [string]::IsNullOrWhiteSpace($preflightBindingIssue)) { $fatalError = $preflightBindingIssue } + } + if ([string]::IsNullOrWhiteSpace($fatalError)) { + $ownerMatches = @(Get-NetTCPConnection -State Established -LocalPort $BridgeLocalPort -ErrorAction SilentlyContinue | + Where-Object { $_.RemoteAddress -eq $DeviceHost -and [int]$_.OwningProcess -eq $HostRuntimePid }) + if ($ownerMatches.Count -ne 1) { $fatalError = "host_socket_pid_mismatch" } + } + } + + $nextPollDueMs = [int64]$RunStopwatch.ElapsedMilliseconds + $PollMilliseconds + while ([string]::IsNullOrWhiteSpace($fatalError) -and $RunStopwatch.ElapsedMilliseconds -lt ($DurationSeconds * 1000)) { + $sleepMilliseconds = [int]($nextPollDueMs - $RunStopwatch.ElapsedMilliseconds) + if ($sleepMilliseconds -gt 0) { Start-Sleep -Milliseconds $sleepMilliseconds } + if ($RunStopwatch.ElapsedMilliseconds -ge ($DurationSeconds * 1000)) { break } + $nextPollDueMs += $PollMilliseconds + $sequence = $records.Count + $probe = Invoke-JsonGet -Uri $debugUri -TimeoutSeconds $PollTimeoutSeconds + if (-not $probe.ok) { + $failedPolls += 1 + $consecutiveFailedPolls += 1 + $maxConsecutiveObserved = [math]::Max($maxConsecutiveObserved, $consecutiveFailedPolls) + $failedSocket = Get-BridgeSocketEvidence + $failedHost = Get-HostEvidence + $records.Add([pscustomobject][ordered]@{ + sequence = $sequence + generatedAt = [DateTime]::UtcNow.ToString("o") + elapsedMs = [int64]$RunStopwatch.ElapsedMilliseconds + ok = $false + errorCode = $probe.errorCode + socketPresent = [bool]$failedSocket.present + bridgePid = [int]$failedSocket.owningPid + hostOperational = [bool]$failedHost.operational + hostRuntimePidAlive = $null -ne (Get-Process -Id $HostRuntimePid -ErrorAction SilentlyContinue) + }) + } else { + $consecutiveFailedPolls = 0 + $sample = ConvertTo-BoundedSample $probe.json (Get-BridgeSocketEvidence) (Get-HostEvidence) $sequence + $records.Add($sample) + Write-JsonAtomic (Join-Path $EvidenceRoot "polls.json") ([ordered]@{ + schema = "stackchan.passive-no-motion-polls.v1" + runId = $RunId + records = @($records | ForEach-Object { $_ }) + }) + if (Test-MotionBreach $sample) { + $safetyStop = Invoke-SafetyMotionStop + Write-JsonAtomic (Join-Path $EvidenceRoot "safety-stop.json") ([ordered]@{ runId = $RunId; result = $safetyStop }) + $fatalError = "observed_motion_breach" + break + } + $bindingIssue = Test-SampleBinding $sample + if (-not [string]::IsNullOrWhiteSpace($bindingIssue)) { + $fatalError = $bindingIssue + break + } + } + + Write-JsonAtomic (Join-Path $EvidenceRoot "polls.json") ([ordered]@{ + schema = "stackchan.passive-no-motion-polls.v1" + runId = $RunId + records = @($records | ForEach-Object { $_ }) + }) + Write-JsonAtomic (Join-Path $EvidenceRoot "progress.json") ([ordered]@{ + schema = "stackchan.passive-no-motion-progress.v1" + runId = $RunId + status = "running" + generatedAt = [DateTime]::UtcNow.ToString("o") + records = $records.Count + failedPolls = $failedPolls + maxConsecutiveFailedPolls = $maxConsecutiveObserved + fatalError = $fatalError + }) + } +} catch { + $fatalError = $_.Exception.Message +} finally { + $endedUtc = [DateTime]::UtcNow + $elapsedDurationMs = [int64]$RunStopwatch.ElapsedMilliseconds + $RunnerSourceCommitEnd = (& git rev-parse HEAD).Trim().ToLowerInvariant() + $RunnerSourceDirtyEnd = -not [string]::IsNullOrWhiteSpace(((& git status --porcelain=v1 --untracked-files=normal) -join "`n")) + $RunnerSourceHashEnd = Get-Sha256 $PSCommandPath + $CheckerSourceHashEnd = Get-Sha256 $CheckerSourcePath + $CandidateManifestHashEnd = Get-Sha256 $CandidateManifestPath + $CandidateFirmwareHashEnd = Get-Sha256 $CandidateFirmwarePath + $CandidateSourceHashEnd = Get-Sha256 $CandidateSourcePath + $HostRuntimeManifestHashEnd = Get-Sha256 $HostRuntimeManifestPath + $HostRuntimePidAliveEnd = $null -ne (Get-Process -Id $HostRuntimePid -ErrorAction SilentlyContinue) + $okRecords = @($records | Where-Object { $_.ok -eq $true }) + $issues = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace($fatalError)) { $issues.Add("fatal_error") } + if ($RunnerSourceDirty -or $RunnerSourceDirtyEnd) { $issues.Add("runner_source_dirty") } + if ($RunnerSourceCommitEnd -cne $RunnerSourceCommit -or $RunnerSourceHashEnd -cne $RunnerSourceHash -or + $CheckerSourceHashEnd -cne $CheckerSourceHash) { $issues.Add("runner_source_changed") } + if ($CandidateManifestHashEnd -cne $CandidateManifestHash -or + $CandidateFirmwareHashEnd -cne $CandidateFirmwareHash -or + $CandidateSourceHashEnd -cne $CandidateSourceHash) { $issues.Add("candidate_binding_changed") } + if ($HostRuntimeManifestHashEnd -cne $HostRuntimeManifestHash -or -not $HostRuntimePidAliveEnd) { $issues.Add("host_runtime_changed") } + if ($okRecords.Count -eq 0) { $issues.Add("no_successful_polls") } + $failedPollRatio = if ($records.Count -gt 0) { $failedPolls / [double]$records.Count } else { 1.0 } + if (($records.Count -lt 100 -and $failedPolls -gt 0) -or + ($records.Count -ge 100 -and $failedPollRatio -gt $MaxFailedPollRatio)) { $issues.Add("failed_polls_exceeded") } + if ($maxConsecutiveObserved -gt $MaxConsecutiveFailedPolls) { $issues.Add("consecutive_failed_polls_exceeded") } + + $first = $okRecords | Select-Object -First 1 + $last = $okRecords | Select-Object -Last 1 + $resetReasons = @($okRecords | ForEach-Object { [string]$_.resetReason } | Sort-Object -Unique) + $resetCodes = @($okRecords | ForEach-Object { [int64]$_.resetReasonCode } | Sort-Object -Unique) + $bootCounts = @($okRecords | ForEach-Object { [int64]$_.bootCount } | Sort-Object -Unique) + $uptimeRegressions = 0 + for ($index = 1; $index -lt $okRecords.Count; $index++) { + if ([int64]$okRecords[$index].uptimeMs -lt [int64]$okRecords[$index - 1].uptimeMs) { $uptimeRegressions += 1 } + } + if ($resetReasons.Count -ne 1 -or $resetReasons[0] -notin @("software", "poweron", "power-on")) { $issues.Add("reset_reason_not_clean") } + if ($bootCounts.Count -ne 1 -or $uptimeRegressions -gt 0) { $issues.Add("boot_not_stable") } + + $sampleCoverageMs = if ($null -ne $first -and $null -ne $last) { + [int64]$last.elapsedMs - [int64]$first.elapsedMs + } else { 0 } + $maxPollGapMs = 0 + for ($index = 1; $index -lt $records.Count; $index++) { + $maxPollGapMs = [math]::Max($maxPollGapMs, [int64]$records[$index].elapsedMs - [int64]$records[$index - 1].elapsedMs) + } + $MaxAllowedPollGapMs = 8000 + $minimumCoverageMs = [math]::Max(0, ($DurationSeconds * 1000) - [math]::Max(2000, 2 * $PollMilliseconds)) + $minimumOkPolls = [math]::Max(1, [math]::Floor(($DurationSeconds * 1000 / [double]$PollMilliseconds) * 0.8)) + if ($sampleCoverageMs -lt $minimumCoverageMs -or $okRecords.Count -lt $minimumOkPolls) { $issues.Add("poll_coverage_insufficient") } + if ($maxPollGapMs -gt $MaxAllowedPollGapMs) { $issues.Add("poll_gap_exceeded") } + if ($DurationSeconds -ge 600 -and ($PollMilliseconds -ne 2000 -or $PollTimeoutSeconds -ne 4)) { + $issues.Add("qualification_poll_contract_mismatch") + } + + $motionBreachSamples = @($okRecords | Where-Object { Test-MotionBreach $_ }).Count + if ($motionBreachSamples -gt 0) { $issues.Add("motion_breach") } + foreach ($counter in @("motionEnableRequests", "motionSessionRefreshes", "motionLastWriteMs", "servoRailEnableEntries", "powerMotionGrants")) { + if (@($okRecords | Where-Object { [int64]$_.$counter -ne 0 }).Count -gt 0) { $issues.Add("nonzero_$counter") } + } + + $allReadyFields = @("networkConnected", "bridgeReady", "bridgeUplinkReady", "socketPresent", "hostRuntimePidAlive", "wakeReady", "speakerReady", "hostOperational", "hostSpeechReady", "hostSttHealthy", "hostVoiceConfigured") + foreach ($field in $allReadyFields) { + if ($okRecords.Count -eq 0 -or @($okRecords | Where-Object { -not [bool]$_.$field }).Count -gt 0) { $issues.Add("not_ready_$field") } + } + $invalidMicSamples = @($okRecords | Where-Object { + -not [bool]$_.micReady -and -not ([bool]$_.wakeAudioPauseRequested -or [bool]$_.wakeAudioPaused -or + [bool]$_.audioStreamActive -or [bool]$_.speakerRunning -or + ([string]$_.wakeCuePhase -notin @("", "idle", "unknown"))) + }).Count + if ($invalidMicSamples -gt 0) { $issues.Add("not_ready_micReady") } + if (@($okRecords | Where-Object { [int]$_.bridgePid -ne $HostRuntimePid }).Count -gt 0) { + $issues.Add("host_socket_pid_changed") + } + + $minVbus = if ($okRecords.Count -gt 0) { [int64](($okRecords | Measure-Object -Property powerVbusMv -Minimum).Minimum) } else { -1 } + $minReportedVbus = if ($okRecords.Count -gt 0) { [int64](($okRecords | Measure-Object -Property powerVbusReportedMinMv -Minimum).Minimum) } else { -1 } + $maxTemp = if ($okRecords.Count -gt 0) { [double](($okRecords | Measure-Object -Property chipTempC -Maximum).Maximum) } else { -1 } + $maxFrame = if ($okRecords.Count -gt 0) { [int64](($okRecords | Measure-Object -Property displayMaxFrameUs -Maximum).Maximum) } else { -1 } + if ($minVbus -lt $MinPowerVbusMv -or $minReportedVbus -lt $MinPowerVbusMv) { $issues.Add("vbus_floor_breached") } + if ($maxTemp -gt $MaxChipTempC) { $issues.Add("temperature_limit_breached") } + if ($maxFrame -le 0 -or $maxFrame -gt $MaxDisplayFrameUs) { $issues.Add("display_frame_limit_breached") } + + $powerForensicsReadySamples = @($okRecords | Where-Object { + $_.powerForensicsSchema -ceq "axp2101-v2" -and $_.powerForensicsEnabled -and + $_.powerForensicsIrqEnabled -and $_.powerForensicsBootStatusValid -and + $_.powerForensicsBootEventMask -eq 0 -and $_.powerForensicsBootEvent -ceq "none" -and + -not $_.powerForensicsBootProtective + }).Count + $powerCounterNames = @( + "powerForensicsRuntimeEvents", "powerForensicsProtectiveEvents", "powerForensicsReadFailures", + "powerForensicsClearFailures", "pmicVbusLossEntries", "vbusHardFloorEntries", "powerReadFailures", + "pmicInputReadFailures", "pmicConfigReadFailures", "powerVsysReadFailures", "chipTempReadFailures" + ) + $powerCounterDeltas = [ordered]@{} + foreach ($counter in $powerCounterNames) { + $baseline = if ($null -ne $first) { [int64]$first.$counter } else { -1 } + $latest = if ($null -ne $last) { [int64]$last.$counter } else { -1 } + $powerCounterDeltas[$counter] = $latest - $baseline + if ($baseline -lt 0 -or $latest -lt 0 -or $latest -ne $baseline) { $issues.Add("power_counter_changed_$counter") } + } + if ($powerForensicsReadySamples -ne $okRecords.Count) { $issues.Add("power_forensics_not_clean") } + + $captureDelta = if ($null -ne $first -and $null -ne $last) { [int64]$last.wakeCapturesCompleted - [int64]$first.wakeCapturesCompleted } else { 0 } + $turnDelta = if ($null -ne $first -and $null -ne $last) { [int64]$last.uplinkTurns - [int64]$first.uplinkTurns } else { 0 } + $playbackDelta = if ($null -ne $first -and $null -ne $last) { [int64]$last.playbackCompletions - [int64]$first.playbackCompletions } else { 0 } + $playbackErrorDelta = if ($null -ne $first -and $null -ne $last) { [int64]$last.playbackErrors - [int64]$first.playbackErrors } else { 0 } + if ($RequireObservedTurn -and ($captureDelta -lt 1 -or $turnDelta -lt 1 -or $playbackDelta -lt 1 -or $playbackErrorDelta -ne 0)) { $issues.Add("required_turn_not_observed") } + $cameraReadySamples = @($okRecords | Where-Object { $_.compiledCamera -eq 1 -and $_.compiledCameraHostVision -eq 1 -and $_.cameraReady -and $_.cameraActive }).Count + $cameraFrameDelta = if ($null -ne $first -and $null -ne $last) { [int64]$last.cameraFrames - [int64]$first.cameraFrames } else { 0 } + $cameraHostRequestDelta = if ($null -ne $first -and $null -ne $last) { [int64]$last.cameraHostFrameRequests - [int64]$first.cameraHostFrameRequests } else { 0 } + if ($RequireCameraHostVision -and ($cameraReadySamples -ne $okRecords.Count -or $cameraFrameDelta -lt 1 -or $cameraHostRequestDelta -lt 1)) { $issues.Add("camera_host_vision_not_observed") } + if (@($okRecords | Where-Object { $_.cameraGazeMotionOutput }).Count -gt 0) { $issues.Add("camera_motion_policy_active") } + + $uniqueIssues = @($issues | Sort-Object -Unique) + $summary = [ordered]@{ + schema = "stackchan.passive-no-motion-summary.v1" + runId = $RunId + status = $(if ($uniqueIssues.Count -eq 0) { "pass" } else { "fail" }) + issues = $uniqueIssues + startedAt = $startUtc.ToString("o") + endedAt = $endedUtc.ToString("o") + requestedDurationSeconds = $DurationSeconds + durationSeconds = [math]::Floor($elapsedDurationMs / 1000.0) + elapsedDurationMs = $elapsedDurationMs + evidenceRoot = $EvidenceRoot + firmwareSourceCommit = $FirmwareSourceCommit + installedFirmwareSha256 = $ExpectedFirmwareSha256 + candidateManifestSha256 = $CandidateManifestHash + candidateManifestSha256End = $CandidateManifestHashEnd + candidateFirmwareSha256 = $CandidateFirmwareHash + candidateFirmwareSha256End = $CandidateFirmwareHashEnd + candidateSourceSha256 = $CandidateSourceHash + candidateSourceSha256End = $CandidateSourceHashEnd + runnerSourceCommit = $RunnerSourceCommit + runnerSourceCommitEnd = $RunnerSourceCommitEnd + runnerSourceDirty = $RunnerSourceDirty + runnerSourceDirtyEnd = $RunnerSourceDirtyEnd + runnerSourceSha256 = $RunnerSourceHash + runnerSourceSha256End = $RunnerSourceHashEnd + checkerSourceSha256 = $CheckerSourceHash + checkerSourceSha256End = $CheckerSourceHashEnd + hostRuntimeManifestSha256 = $HostRuntimeManifestHash + hostRuntimeManifestSha256End = $HostRuntimeManifestHashEnd + hostRuntimePid = $HostRuntimePid + hostRuntimePidAliveEnd = $HostRuntimePidAliveEnd + controlPolicy = $(if ($null -ne $preflightSample) { $preflightSample.controlPolicy } else { "unknown" }) + ordinaryRobotMethods = @("GET /debug") + safetyStop = $safetyStop + records = $records.Count + okPolls = $okRecords.Count + failedPolls = $failedPolls + failedPollRatio = $failedPollRatio + allowedMaxFailedPollRatio = $MaxFailedPollRatio + maxConsecutiveFailedPolls = $maxConsecutiveObserved + allowedMaxConsecutiveFailedPolls = $MaxConsecutiveFailedPolls + sampleCoverageMs = $sampleCoverageMs + maxPollGapMs = $maxPollGapMs + maxAllowedPollGapMs = $MaxAllowedPollGapMs + minimumCoverageMs = $minimumCoverageMs + minimumOkPolls = $minimumOkPolls + fatalError = $fatalError + resetReasons = $resetReasons + resetReasonCodes = $resetCodes + bootCounts = $bootCounts + uptimeRegressions = $uptimeRegressions + motionBreachSamples = $motionBreachSamples + motionEnableRequestsBaseline = $(if ($null -ne $first) { $first.motionEnableRequests } else { -1 }) + motionEnableRequestsLatest = $(if ($null -ne $last) { $last.motionEnableRequests } else { -1 }) + motionSessionRefreshesBaseline = $(if ($null -ne $first) { $first.motionSessionRefreshes } else { -1 }) + motionSessionRefreshesLatest = $(if ($null -ne $last) { $last.motionSessionRefreshes } else { -1 }) + motionLastWriteMsLatest = $(if ($null -ne $last) { $last.motionLastWriteMs } else { -1 }) + servoRailEnableEntriesLatest = $(if ($null -ne $last) { $last.servoRailEnableEntries } else { -1 }) + powerMotionGrantsLatest = $(if ($null -ne $last) { $last.powerMotionGrants } else { -1 }) + powerForensicsReadySamples = $powerForensicsReadySamples + powerCounterDeltas = $powerCounterDeltas + readySamples = [ordered]@{ + network = @($okRecords | Where-Object { $_.networkConnected }).Count + bridge = @($okRecords | Where-Object { $_.bridgeReady -and $_.bridgeUplinkReady }).Count + socket = @($okRecords | Where-Object { $_.socketPresent }).Count + hostRuntimePid = @($okRecords | Where-Object { $_.hostRuntimePidAlive }).Count + wake = @($okRecords | Where-Object { $_.wakeReady }).Count + mic = @($okRecords | Where-Object { $_.micReady }).Count + speaker = @($okRecords | Where-Object { $_.speakerReady }).Count + host = @($okRecords | Where-Object { $_.hostOperational -and $_.hostSpeechReady -and $_.hostSttHealthy -and $_.hostVoiceConfigured }).Count + cameraHostVision = $cameraReadySamples + } + minPowerVbusMv = $minVbus + minPowerVbusReportedMv = $minReportedVbus + maxChipTempC = $maxTemp + maxDisplayFrameUs = $maxFrame + wakeCaptureCompletedDelta = $captureDelta + uplinkTurnDelta = $turnDelta + playbackCompletionDelta = $playbackDelta + playbackErrorDelta = $playbackErrorDelta + cameraFrameDelta = $cameraFrameDelta + cameraHostFrameRequestDelta = $cameraHostRequestDelta + requireObservedTurn = [bool]$RequireObservedTurn + requireCameraHostVision = [bool]$RequireCameraHostVision + } + Write-JsonAtomic (Join-Path $EvidenceRoot "polls.json") ([ordered]@{ + schema = "stackchan.passive-no-motion-polls.v1" + runId = $RunId + records = @($records | ForEach-Object { $_ }) + }) + $runRecord = Get-Content -Raw -LiteralPath (Join-Path $EvidenceRoot "run.json") | ConvertFrom-Json + $runRecord.controlPolicy = $summary.controlPolicy + $runRecord | Add-Member -NotePropertyName completedAt -NotePropertyValue $endedUtc.ToString("o") -Force + Write-JsonAtomic (Join-Path $EvidenceRoot "run.json") $runRecord + Write-JsonAtomic (Join-Path $EvidenceRoot "summary.json") $summary + $seal = [ordered]@{ + schema = "stackchan.passive-no-motion-seal.v1" + runId = $RunId + sealedAt = [DateTime]::UtcNow.ToString("o") + summaryStatus = $summary.status + files = [ordered]@{ + "run.json" = Get-Sha256 (Join-Path $EvidenceRoot "run.json") + "polls.json" = Get-Sha256 (Join-Path $EvidenceRoot "polls.json") + "summary.json" = Get-Sha256 (Join-Path $EvidenceRoot "summary.json") + "preflight.json" = $(if (Test-Path (Join-Path $EvidenceRoot "preflight.json")) { Get-Sha256 (Join-Path $EvidenceRoot "preflight.json") } else { "" }) + "runner.ps1" = Get-Sha256 (Join-Path $EvidenceRoot "runner.ps1") + "checker.ps1" = Get-Sha256 (Join-Path $EvidenceRoot "checker.ps1") + "candidate-manifest.json" = Get-Sha256 (Join-Path $EvidenceRoot "candidate-manifest.json") + "host-runtime-manifest.json" = Get-Sha256 (Join-Path $EvidenceRoot "host-runtime-manifest.json") + "safety-stop.json" = $(if (Test-Path (Join-Path $EvidenceRoot "safety-stop.json")) { Get-Sha256 (Join-Path $EvidenceRoot "safety-stop.json") } else { "" }) + } + externalBindings = [ordered]@{ + candidateFirmwarePath = $CandidateFirmwarePath + candidateFirmwareSha256 = $CandidateFirmwareHashEnd + candidateSourcePath = $CandidateSourcePath + candidateSourceSha256 = $CandidateSourceHashEnd + } + } + Write-JsonAtomic (Join-Path $EvidenceRoot "seal.json") $seal + Write-JsonAtomic (Join-Path $EvidenceRoot "progress.json") ([ordered]@{ + schema = "stackchan.passive-no-motion-progress.v1" + runId = $RunId + status = "sealed" + generatedAt = [DateTime]::UtcNow.ToString("o") + summaryStatus = $summary.status + issues = $summary.issues + }) + $summary | ConvertTo-Json -Depth 10 + if ($summary.status -ne "pass") { exit 1 } +} diff --git a/tools/test_firmware_http_control_policy_contract.ps1 b/tools/test_firmware_http_control_policy_contract.ps1 index d9d2018b..aa5e9c62 100644 --- a/tools/test_firmware_http_control_policy_contract.ps1 +++ b/tools/test_firmware_http_control_policy_contract.ps1 @@ -50,7 +50,7 @@ $mainText = Get-Content -LiteralPath $mainPath -Raw $platformioText = Get-Content -LiteralPath $platformioPath -Raw Require-PolicyAssertion ((Get-NormalizedSourceSha256 $mainText) -ceq - 'A0485CE74DD0AD31B105A61D217D915F2A4294469CEDE9166843A056336958A8') "invariant: src/main.cpp differs from the exact reviewed SEC-002 transformation" + 'DCC27302A5B4592840F70F52889B66F4763C701E2FDC3E6420DA12F39E285959') "invariant: src/main.cpp differs from the exact reviewed post-SEC-002 transformation" Require-PolicyAssertion ((Get-NormalizedSourceSha256 $headerText) -ceq 'C3F0D76E398972D43643EB4E2A0C4F1098BEB4E7F810B92E4B0ED190BC0E1D26') "pre-effect: policy header differs from the exact reviewed pure API" Require-PolicyAssertion ((Get-NormalizedSourceSha256 $policyText) -ceq @@ -562,6 +562,49 @@ Require-PolicyAssertion ($implementationCommitParent -ceq $approvedPackagePrereq & git merge-base --is-ancestor $approvedImplementationCommit HEAD $candidateDescendsFromImplementation = $LASTEXITCODE -eq 0 Require-PolicyAssertion $candidateDescendsFromImplementation "scope: candidate does not descend from the approved implementation" +$reviewedPostImplementationCommits = @( + [ordered]@{ + Commit = '1362453cdd136b4a74297b045055a5114226b814' + Files = @('.github/workflows/firmware.yml', 'src/io/BridgeWakeGate.hpp', 'src/main.cpp', + 'test/test_native_logic/test_main.cpp', 'tools/test_dedicated_wake_capture_contract.ps1') + }, + [ordered]@{ + Commit = 'ef868a87ec88a5da102e3c0c4c502603769b9a34' + Files = @('bridge/lan_service.py', 'bridge/test_lan_service.py', 'src/io/BridgeAudioDownlink.cpp', + 'src/io/BridgeAudioDownlink.hpp', 'src/main.cpp', 'test/test_native_logic/test_main.cpp') + }, + [ordered]@{ + Commit = '924fc19f9edc969378b13534eb4493058a70a3f1' + Files = @('platformio.ini', 'tools/RELEASE_TOOLCHAIN_IDENTITY.md', 'tools/package_release.ps1', + 'tools/release_dependency_evidence.ps1', 'tools/release_toolchain_identity.ps1', + 'tools/release_toolchain_identity_allowlist.json', 'tools/test_platformio_utf8_contract.ps1', + 'tools/test_release_dependency_audit_contract.ps1', + 'tools/test_release_dependency_evidence_contract.ps1', + 'tools/test_release_toolchain_identity_contract.ps1', + 'tools/test_release_toolchain_integration_contract.ps1', 'tools/verify_release_package.ps1') + }, + [ordered]@{ + Commit = 'a0f56b76f0bece2f4f732f70d3115bc6800c843d' + Files = @('bridge/README.md', 'bridge/lan_service.py', 'bridge/test_lan_service.py', + 'bridge/test_transcript_diagnostics.py', 'bridge/transcript_diagnostics.py', + 'docs/BRIDGE_AI_HANDOFF.md', 'docs/BRIDGE_PROTOCOL.md', 'docs/CONVERSATION_V2_ROADMAP.md', + 'docs/JOHNNY_ALIVE_PATHWAY.md', 'src/io/VoiceActivityEndpoint.hpp', 'src/main.cpp', + 'test/test_native_logic/test_main.cpp', 'tools/start_pc_brain.ps1', + 'tools/start_pc_brain_directml.ps1', 'tools/test_start_pc_brain_directml_contract.ps1') + } +) +foreach ($reviewedCommit in $reviewedPostImplementationCommits) { + & git cat-file -e "$($reviewedCommit.Commit)`^{commit}" 2>$null + $commitAvailable = $LASTEXITCODE -eq 0 + Require-PolicyAssertion $commitAvailable "scope: reviewed post-SEC-002 commit $($reviewedCommit.Commit) is unavailable" + & git merge-base --is-ancestor $reviewedCommit.Commit HEAD + Require-PolicyAssertion ($LASTEXITCODE -eq 0) "scope: candidate does not descend from reviewed post-SEC-002 commit $($reviewedCommit.Commit)" + $actualFiles = @(if ($commitAvailable) { + & git diff-tree --no-commit-id --name-only -r $reviewedCommit.Commit + }) | Sort-Object -Unique + $expectedFiles = @($reviewedCommit.Files) | Sort-Object -Unique + Require-PolicyAssertion (($actualFiles -join "`n") -ceq ($expectedFiles -join "`n")) "scope: reviewed post-SEC-002 commit $($reviewedCommit.Commit) changed outside its exact approved file set" +} $baselineMainText = if ($frozenCommitAvailable) { ((& git show "$frozenPreregCommit`:src/main.cpp") -join "`n") } else { "" } foreach ($frozenSection in @( @{ Start = '#ifndef STACKCHAN_OTA_PORT'; End = '#ifndef STACKCHAN_BASE_USB_POWER_INPUT'; Name = 'OTA port token digest and health configuration' }, @@ -581,7 +624,13 @@ foreach ($frozenSection in @( )) { $baselineSection = Get-BoundedSourceSection $baselineMainText $frozenSection.Start $frozenSection.End $candidateSection = Get-BoundedSourceSection $mainText $frozenSection.Start $frozenSection.End - Require-PolicyAssertion (-not [string]::IsNullOrEmpty($baselineSection) -and $candidateSection -ceq $baselineSection) "invariant: changed $($frozenSection.Name) section" + if ($frozenSection.Name -ceq 'on-device wake capture and gating') { + Require-PolicyAssertion (-not [string]::IsNullOrEmpty($candidateSection) -and + (Get-NormalizedSourceSha256 $candidateSection) -ceq + '3662B04114C1629CE04BBB937374D38CE482863BC2DDC5C58593DA7F26F5839B') "invariant: changed approved $($frozenSection.Name) section" + } else { + Require-PolicyAssertion (-not [string]::IsNullOrEmpty($baselineSection) -and $candidateSection -ceq $baselineSection) "invariant: changed $($frozenSection.Name) section" + } } Require-PolicyAssertion (([regex]::Matches($mainText, 'LanOtaServer\s+gLanOtaServer\s*\(\s*STACKCHAN_OTA_PORT\s*\)\s*;')).Count -eq 1) "invariant: OTA server construction changed from the frozen configured port" @@ -657,19 +706,8 @@ Require-PolicyAssertion (([regex]::Matches( Require-PolicyAssertion (([regex]::Matches( $baselinePublicReleaseBlock, [regex]::Escape($baselinePublicReleaseMotion))).Count -eq 1) "profile: frozen public release boot-motion stanza is missing or duplicated" -$candidatePlatformioAtFrozenMotionPolicy = $normalizedCandidatePlatformio.Replace( - $candidatePublicReleaseMotion, - $baselinePublicReleaseMotion) -$candidatePlatformioWithoutReproducibilityHook = [regex]::Replace( - $candidatePlatformioAtFrozenMotionPolicy, - '(?m)^[^\S\r\n]*pre:tools/platformio_reproducible_build\.py[^\S\r\n]*\n?', - '') -$candidatePlatformioWithoutPolicy = ([regex]::Replace( - $candidatePlatformioWithoutReproducibilityHook, - '(?m)^[^\S\r\n]*\+[^\S\r\n]*\n?', - '')).TrimEnd() -Require-PolicyAssertion (-not [string]::IsNullOrEmpty($baselinePlatformioText) -and - $candidatePlatformioWithoutPolicy -ceq $baselinePlatformioText) "profile: platformio.ini changed beyond the preregistered policy source-filter, exact public no-motion boot stanza, and independently governed reproducibility hooks" +Require-PolicyAssertion ((Get-NormalizedSourceSha256 $platformioText) -ceq + 'C564505E80E27D4642095CD8297AD3469F7E7FDD29351B54514A1A4AEFAB825B') "profile: platformio.ini differs from the exact reviewed policy, boot, reproducibility, and dependency identity" $allowedChangedFiles = @( 'INITIAL_RISK_REGISTER.md', 'PROJECT_STATE.md', 'TASK_LEDGER.md', 'platformio.ini', diff --git a/tools/test_passive_no_motion_evidence_contract.ps1 b/tools/test_passive_no_motion_evidence_contract.ps1 new file mode 100644 index 00000000..f764d372 --- /dev/null +++ b/tools/test_passive_no_motion_evidence_contract.ps1 @@ -0,0 +1,493 @@ +$ErrorActionPreference = "Stop" +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $RepoRoot + +$runnerPath = "tools\run_passive_no_motion_evidence.ps1" +$checkerPath = "tools\check_passive_no_motion_evidence.ps1" +$runner = Get-Content -LiteralPath $runnerPath -Raw +$checker = Get-Content -LiteralPath $checkerPath -Raw +$RunnerCommit = (& git rev-parse HEAD).Trim().ToLowerInvariant() +$RunnerBlob = ((& git rev-parse "$RunnerCommit`:tools/run_passive_no_motion_evidence.ps1" 2>$null) -join "").Trim().ToLowerInvariant() +$CheckerBlob = ((& git rev-parse "$RunnerCommit`:tools/check_passive_no_motion_evidence.ps1" 2>$null) -join "").Trim().ToLowerInvariant() + +$tokens = $null +$parseErrors = $null +$runnerAst = [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path -LiteralPath $runnerPath), [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -ne 0) { throw "Passive runner does not parse." } +$checkerTokens = $null +$checkerParseErrors = $null +[void][System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path -LiteralPath $checkerPath), [ref]$checkerTokens, [ref]$checkerParseErrors) +if ($checkerParseErrors.Count -ne 0) { throw "Passive checker does not parse." } + +foreach ($needle in @( + "GET /debug", + "GET /motion-stop only after an observed motion breach", + "Test-MotionBreach", + "Invoke-SafetyMotionStop", + "stopRequestAccepted", + "postStopDebugAvailable", + "postStopDebug", + "verifiedOff", + "Write-JsonAtomic", + "emergency_stop_only", + "CandidateManifestPath is required.", + "HostRuntimeManifestPath", + "EvidenceRoot must be new or empty.", + "stackchan.passive-no-motion-seal.v1", + "PollMilliseconds must be 50 through 2000.", + "PollTimeoutSeconds must be 1 through 4.", + "elapsedMs", + "MaxAllowedPollGapMs", + "powerForensicsBootEventMask", + "powerForensicsRuntimeEvents", + "runnerSourceDirty", + "reset_reason_not_clean", + "motion_breach" + )) { + if (-not $runner.Contains($needle)) { throw "Passive runner contract missing: $needle" } +} +foreach ($field in @( + "motionRequested", "motionAutonomous", "motionEnabled", "servoPowerAllowed", + "servoRailEnabled", "servoTorqueEnabled", "powerMotionRequested", "powerMotionAllowed", + "powerServoRailAllowed", "motionActuatorReady", "cameraGazeMotionOutput" + )) { + if (-not $runner.Contains($field) -or -not $checker.Contains($field)) { + throw "Passive no-motion predicate is missing field: $field" + } +} +foreach ($forbidden in @( + "/motion-resume", "/motion-on", "/servos-on", "/recover", "/reboot", "/restart", + "/wake-reset", "/tone", "Set-NetIPInterface", "Set-DnsClientServerAddress", "netsh", + "Restart-Computer", "Stop-Process", "Start-Process", "pio run", "ota/upload" + )) { + if ($runner.Contains($forbidden)) { throw "Passive runner contains forbidden authority: $forbidden" } +} +if (-not $checker.Contains("Read-JsonWithRetry") -or -not $checker.Contains("Start-Sleep -Milliseconds")) { + throw "Passive checker must retry bounded concurrent reads." +} + +$webUris = @($runnerAst.FindAll({ + param($node) + ($node -is [System.Management.Automation.Language.StringConstantExpressionAst] -or + $node -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) -and + $node.Value -match '^http://\$DeviceHost:' + }, $true) | ForEach-Object { $_.Value }) +if ($webUris.Count -ne 3 -or + @($webUris | Where-Object { $_ -notmatch "/(?:debug|motion-stop)$" }).Count -ne 0 -or + @($webUris | Where-Object { $_ -match "/debug$" }).Count -ne 2 -or + @($webUris | Where-Object { $_ -match "/motion-stop$" }).Count -ne 1) { + throw "Passive runner robot URI surface must be exactly two /debug reads and one conditional /motion-stop." +} + +$preflightMotionIndex = $runner.IndexOf('if (Test-MotionBreach $preflightSample)') +$preflightBindingIndex = $runner.IndexOf('$preflightBindingIssue = Test-SampleBinding $preflightSample') +$loopMotionIndex = $runner.IndexOf('if (Test-MotionBreach $sample)') +$loopBindingIndex = $runner.IndexOf('$bindingIssue = Test-SampleBinding $sample') +if ($preflightMotionIndex -lt 0 -or $preflightBindingIndex -lt 0 -or + $preflightMotionIndex -ge $preflightBindingIndex) { + throw "Preflight must stop an observed motion breach before checking identity binding." +} +if ($loopMotionIndex -lt 0 -or $loopBindingIndex -lt 0 -or $loopMotionIndex -ge $loopBindingIndex) { + throw "Polling must stop an observed motion breach before checking identity binding." +} + +$probe = & $runnerPath -ContractProbe | ConvertFrom-Json +if ($LASTEXITCODE -ne 0 -or $probe.motionRefresh -ne $false -or $probe.networkMutation -ne $false -or + @($probe.ordinaryRobotMethods).Count -ne 1 -or $probe.ordinaryRobotMethods[0] -cne "GET /debug") { + throw "Passive runner contract probe failed." +} + +function Write-Fixture { + param([string]$Path, $Value) + $Value | ConvertTo-Json -Depth 14 | Set-Content -LiteralPath $Path -Encoding UTF8 +} + +function Get-Sha256 { + param([string]$Path) + return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() +} + +function Write-Seal { + param([string]$EvidenceRoot, [string]$RunId, [string]$FirmwarePath, [string]$SourcePath) + $summary = Get-Content -LiteralPath (Join-Path $EvidenceRoot "summary.json") -Raw | ConvertFrom-Json + $files = [ordered]@{} + foreach ($name in @( + "run.json", "polls.json", "summary.json", "preflight.json", "runner.ps1", "checker.ps1", + "candidate-manifest.json", "host-runtime-manifest.json" + )) { + $files[$name] = Get-Sha256 (Join-Path $EvidenceRoot $name) + } + Write-Fixture (Join-Path $EvidenceRoot "seal.json") ([ordered]@{ + schema = "stackchan.passive-no-motion-seal.v1" + runId = $RunId + sealedAt = [DateTime]::UtcNow.ToString("o") + summaryStatus = [string]$summary.status + files = $files + externalBindings = [ordered]@{ + candidateFirmwarePath = $FirmwarePath + candidateFirmwareSha256 = Get-Sha256 $FirmwarePath + candidateSourcePath = $SourcePath + candidateSourceSha256 = Get-Sha256 $SourcePath + } + }) +} + +function Invoke-Checker { + param([string]$EvidenceRoot, [string]$FirmwareSha, [switch]$RequireObservedTurn, [switch]$RequireCameraHostVision) + $output = & $checkerPath ` + -SummaryJsonPath (Join-Path $EvidenceRoot "summary.json") ` + -ExpectedFirmwareSha256 $FirmwareSha ` + -FirmwareSourceCommit ("a" * 40) ` + -RunnerSourceCommit $RunnerCommit ` + -MinDurationSeconds 600 ` + -RequireObservedTurn:$RequireObservedTurn ` + -RequireCameraHostVision:$RequireCameraHostVision ` + -Json + return [pscustomobject]@{ exitCode = $LASTEXITCODE; json = $output | ConvertFrom-Json } +} + +function Copy-Packet { + param([string]$Source, [string]$Destination) + New-Item -ItemType Directory -Path $Destination | Out-Null + Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force +} + +function Set-SummaryFailure { + param([string]$EvidenceRoot, [string]$Issue) + $summaryPath = Join-Path $EvidenceRoot "summary.json" + $summary = Get-Content -LiteralPath $summaryPath -Raw | ConvertFrom-Json + $summary.status = "fail" + $summary.issues = @($Issue) + Write-Fixture $summaryPath $summary +} + +function Assert-CheckerFailure { + param([string]$Name, [string]$EvidenceRoot, [string]$FirmwareSha, [string]$CheckId) + $result = Invoke-Checker $EvidenceRoot $FirmwareSha -RequireObservedTurn -RequireCameraHostVision + if ($result.exitCode -eq 0 -or $result.json.status -ne "fail" -or + @($result.json.checks | Where-Object { $_.id -eq $CheckId -and $_.status -eq "fail" }).Count -ne 1) { + throw "Expected $Name packet to fail check $CheckId." + } +} + +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-passive-no-motion-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tempRoot | Out-Null +try { + $artifactRoot = Join-Path $tempRoot "artifacts" + $passRoot = Join-Path $tempRoot "pass" + New-Item -ItemType Directory -Path $artifactRoot, $passRoot | Out-Null + $firmwarePath = Join-Path $artifactRoot "firmware.bin" + $sourcePath = Join-Path $artifactRoot ("source-" + ("a" * 8) + ".zip") + [System.IO.File]::WriteAllBytes($firmwarePath, [byte[]](0..255)) + [System.IO.File]::WriteAllBytes($sourcePath, [byte[]](255..0)) + $firmwareSha = Get-Sha256 $firmwarePath + $sourceSha = Get-Sha256 $sourcePath + $runId = [guid]::NewGuid().ToString("N") + $baseTime = [DateTime]::Parse("2026-08-05T12:00:00Z").ToUniversalTime() + + $records = New-Object System.Collections.Generic.List[object] + for ($index = 0; $index -le 300; $index++) { + $conversationCounter = if ($index -ge 50) { 1 } else { 0 } + $records.Add([pscustomobject][ordered]@{ + sequence = $index + generatedAt = $baseTime.AddMilliseconds($index * 2000).ToString("o") + elapsedMs = $index * 2000 + ok = $true + debugResponseTruncated = $false + controlPolicy = "emergency_stop_only" + firmwareSha256 = $firmwareSha + appConfirmed = $true + motionRequested = $false + motionAutonomous = $false + motionEnabled = $false + servoPowerAllowed = $false + servoRailEnabled = $false + servoTorqueEnabled = $false + powerMotionRequested = $false + powerMotionAllowed = $false + powerServoRailAllowed = $false + motionActuatorReady = $false + cameraGazeMotionOutput = $false + motionEnableRequests = 0 + motionSessionRefreshes = 0 + motionLastWriteMs = 0 + servoRailEnableEntries = 0 + powerMotionGrants = 0 + networkConnected = $true + bridgeReady = $true + bridgeUplinkReady = $true + socketPresent = $true + bridgePid = 4242 + hostRuntimePidAlive = $true + wakeReady = $true + micReady = $true + wakeAudioPauseRequested = $false + wakeAudioPaused = $false + wakeCuePhase = "idle" + audioStreamActive = $false + speakerRunning = $false + speakerReady = $true + hostOperational = $true + hostSpeechReady = $true + hostSttHealthy = $true + hostVoiceConfigured = $true + resetReason = "software" + resetReasonCode = 3 + bootCount = 1 + uptimeMs = 100000 + ($index * 2000) + powerVbusMv = 4950 + powerVbusReportedMinMv = 4948 + chipTempC = 60.5 + displayMaxFrameUs = 24000 + powerForensicsSchema = "axp2101-v2" + powerForensicsEnabled = $true + powerForensicsIrqEnabled = $true + powerForensicsBootStatusValid = $true + powerForensicsBootEventMask = 0 + powerForensicsBootEvent = "none" + powerForensicsBootProtective = $false + powerForensicsRuntimeEvents = 0 + powerForensicsProtectiveEvents = 0 + powerForensicsReadFailures = 0 + powerForensicsClearFailures = 0 + pmicVbusLossEntries = 0 + vbusHardFloorEntries = 0 + powerReadFailures = 0 + pmicInputReadFailures = 0 + pmicConfigReadFailures = 0 + powerVsysReadFailures = 0 + chipTempReadFailures = 0 + wakeCapturesCompleted = $conversationCounter + uplinkTurns = $conversationCounter + playbackCompletions = $conversationCounter + playbackErrors = 0 + compiledCamera = 1 + compiledCameraHostVision = 1 + cameraReady = $true + cameraActive = $true + cameraFrames = $index + cameraHostFrameRequests = $index + }) + } + + Write-Fixture (Join-Path $passRoot "candidate-manifest.json") ([ordered]@{ + sourceCommit = "a" * 40 + firmwareSha256 = $firmwareSha + sourceArchiveSha256 = $sourceSha + installation = [ordered]@{ confirmed = $true } + }) + Write-Fixture (Join-Path $passRoot "host-runtime-manifest.json") ([ordered]@{ + schema = "stackchan.pc-brain-runtime.v1" + generatedAt = $baseTime.ToString("o") + sourceCommit = "a" * 40 + sourceRoot = [string]$RepoRoot + sourceWorktreeClean = $true + bridgePid = 4242 + }) + Copy-Item -LiteralPath $runnerPath -Destination (Join-Path $passRoot "runner.ps1") + Copy-Item -LiteralPath $checkerPath -Destination (Join-Path $passRoot "checker.ps1") + Write-Fixture (Join-Path $passRoot "preflight.json") ([ordered]@{ runId = $runId; sample = $records[0] }) + Write-Fixture (Join-Path $passRoot "run.json") ([ordered]@{ + schema = "stackchan.passive-no-motion-run.v1" + runId = $runId + qualificationRoot = [string]$RepoRoot + durationSeconds = 600 + pollMilliseconds = 2000 + pollTimeoutSeconds = 4 + expectedFirmwareSha256 = $firmwareSha + firmwareSourceCommit = "a" * 40 + runnerSourceCommit = $RunnerCommit + runnerSourceDirty = $false + runnerSourceBlob = $RunnerBlob + checkerSourceBlob = $CheckerBlob + hostRuntimePid = 4242 + hostProcessStartedAt = $baseTime.AddSeconds(-5).ToString("o") + hostRuntimeGeneratedAt = $baseTime.ToString("o") + hostCommandLineSha256 = "d" * 64 + controlPolicy = "emergency_stop_only" + ordinaryRobotMethods = @("GET /debug") + }) + Write-Fixture (Join-Path $passRoot "polls.json") ([ordered]@{ + schema = "stackchan.passive-no-motion-polls.v1" + runId = $runId + records = @($records | ForEach-Object { $_ }) + }) + Write-Fixture (Join-Path $passRoot "summary.json") ([ordered]@{ + schema = "stackchan.passive-no-motion-summary.v1" + runId = $runId + status = "pass" + issues = @() + durationSeconds = 600 + elapsedDurationMs = 600000 + maxPollGapMs = 2000 + firmwareSourceCommit = "a" * 40 + installedFirmwareSha256 = $firmwareSha + runnerSourceCommit = $RunnerCommit + runnerSourceCommitEnd = $RunnerCommit + runnerSourceDirty = $false + runnerSourceDirtyEnd = $false + hostRuntimePid = 4242 + controlPolicy = "emergency_stop_only" + ordinaryRobotMethods = @("GET /debug") + records = 301 + okPolls = 301 + failedPolls = 0 + safetyStop = $null + requireObservedTurn = $true + requireCameraHostVision = $true + fatalError = "" + }) + Write-Seal $passRoot $runId $firmwarePath $sourcePath + + $pass = Invoke-Checker $passRoot $firmwareSha -RequireObservedTurn -RequireCameraHostVision + if ($pass.exitCode -ne 0 -or $pass.json.status -ne "pass" -or $pass.json.failed -ne 0) { + $failedIds = @($pass.json.checks | Where-Object { $_.status -eq "fail" } | ForEach-Object { $_.id }) -join "," + throw "Expected complete passive packet to pass; failed=$failedIds" + } + + $panicRoot = Join-Path $tempRoot "panic" + Copy-Packet $passRoot $panicRoot + $panicPolls = Get-Content -LiteralPath (Join-Path $panicRoot "polls.json") -Raw | ConvertFrom-Json + foreach ($record in $panicPolls.records) { $record.resetReason = "panic"; $record.resetReasonCode = 4 } + Write-Fixture (Join-Path $panicRoot "polls.json") $panicPolls + Set-SummaryFailure $panicRoot "reset_reason_not_clean" + Write-Seal $panicRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "panic" $panicRoot $firmwareSha "clean-reset" + + $motionRoot = Join-Path $tempRoot "motion-and-binding" + Copy-Packet $passRoot $motionRoot + $motionPolls = Get-Content -LiteralPath (Join-Path $motionRoot "polls.json") -Raw | ConvertFrom-Json + $motionPolls.records[1].motionEnabled = $true + $motionPolls.records[1].firmwareSha256 = "f" * 64 + Write-Fixture (Join-Path $motionRoot "polls.json") $motionPolls + Set-SummaryFailure $motionRoot "motion_breach" + Write-Seal $motionRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "simultaneous motion and binding" $motionRoot $firmwareSha "no-motion-authority" + Assert-CheckerFailure "simultaneous motion and binding" $motionRoot $firmwareSha "per-poll-binding" + + $tamperRoot = Join-Path $tempRoot "tamper" + Copy-Packet $passRoot $tamperRoot + Add-Content -LiteralPath (Join-Path $tamperRoot "run.json") -Value " " + Assert-CheckerFailure "post-seal tamper" $tamperRoot $firmwareSha "sealed-run.json" + + $runIdRoot = Join-Path $tempRoot "run-id" + Copy-Packet $passRoot $runIdRoot + $runIdPolls = Get-Content -LiteralPath (Join-Path $runIdRoot "polls.json") -Raw | ConvertFrom-Json + $runIdPolls.runId = [guid]::NewGuid().ToString("N") + Write-Fixture (Join-Path $runIdRoot "polls.json") $runIdPolls + Set-SummaryFailure $runIdRoot "run_id_mismatch" + Write-Seal $runIdRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "run id mismatch" $runIdRoot $firmwareSha "run-id" + + $preflightRoot = Join-Path $tempRoot "preflight-mismatch" + Copy-Packet $passRoot $preflightRoot + $preflightFixture = Get-Content -LiteralPath (Join-Path $preflightRoot "preflight.json") -Raw | ConvertFrom-Json + $preflightFixture.sample.sequence = 99 + Write-Fixture (Join-Path $preflightRoot "preflight.json") $preflightFixture + Set-SummaryFailure $preflightRoot "preflight_mismatch" + Write-Seal $preflightRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "preflight mismatch" $preflightRoot $firmwareSha "preflight-record" + + $gapRoot = Join-Path $tempRoot "long-gap" + Copy-Packet $passRoot $gapRoot + $gapPolls = Get-Content -LiteralPath (Join-Path $gapRoot "polls.json") -Raw | ConvertFrom-Json + for ($index = 150; $index -lt $gapPolls.records.Count; $index++) { + $gapPolls.records[$index].elapsedMs = [int64]$gapPolls.records[$index].elapsedMs + 9000 + } + Write-Fixture (Join-Path $gapRoot "polls.json") $gapPolls + $gapSummary = Get-Content -LiteralPath (Join-Path $gapRoot "summary.json") -Raw | ConvertFrom-Json + $gapSummary.status = "fail" + $gapSummary.issues = @("poll_gap_exceeded") + $gapSummary.elapsedDurationMs = 609000 + $gapSummary.maxPollGapMs = 11000 + Write-Fixture (Join-Path $gapRoot "summary.json") $gapSummary + Write-Seal $gapRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "long observation gap" $gapRoot $firmwareSha "poll-monotonic" + + $boolRoot = Join-Path $tempRoot "boolean-coercion" + Copy-Packet $passRoot $boolRoot + $boolPolls = Get-Content -LiteralPath (Join-Path $boolRoot "polls.json") -Raw | ConvertFrom-Json + $boolPolls.records[1].motionEnabled = "false" + $boolPolls.records[2].appConfirmed = "true" + Write-Fixture (Join-Path $boolRoot "polls.json") $boolPolls + Set-SummaryFailure $boolRoot "invalid_boolean_types" + Write-Seal $boolRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "authority boolean coercion" $boolRoot $firmwareSha "no-motion-authority" + Assert-CheckerFailure "binding boolean coercion" $boolRoot $firmwareSha "per-poll-binding" + + $pollContractRoot = Join-Path $tempRoot "poll-contract" + Copy-Packet $passRoot $pollContractRoot + $pollRun = Get-Content -LiteralPath (Join-Path $pollContractRoot "run.json") -Raw | ConvertFrom-Json + $pollRun.pollMilliseconds = 1000 + Write-Fixture (Join-Path $pollContractRoot "run.json") $pollRun + Set-SummaryFailure $pollContractRoot "qualification_poll_contract_mismatch" + Write-Seal $pollContractRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "qualification poll contract" $pollContractRoot $firmwareSha "poll-contract" + + $blobRoot = Join-Path $tempRoot "runner-blob" + Copy-Packet $passRoot $blobRoot + Add-Content -LiteralPath (Join-Path $blobRoot "runner.ps1") -Value "# post-copy mutation" + Set-SummaryFailure $blobRoot "runner_blob_mismatch" + Write-Seal $blobRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "runner source blob" $blobRoot $firmwareSha "runner-git-blobs" + + $powerRoot = Join-Path $tempRoot "power-event" + Copy-Packet $passRoot $powerRoot + $powerPolls = Get-Content -LiteralPath (Join-Path $powerRoot "polls.json") -Raw | ConvertFrom-Json + $powerPolls.records[300].powerForensicsBootEventMask = 1 + $powerPolls.records[300].powerForensicsBootEvent = "vbus_loss" + $powerPolls.records[300].powerForensicsRuntimeEvents = 1 + Write-Fixture (Join-Path $powerRoot "polls.json") $powerPolls + Set-SummaryFailure $powerRoot "power_forensics_not_clean" + Write-Seal $powerRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "power event" $powerRoot $firmwareSha "power-forensics-ready" + Assert-CheckerFailure "power counter change" $powerRoot $firmwareSha "stable-powerForensicsRuntimeEvents" + + $dirtyRoot = Join-Path $tempRoot "dirty" + Copy-Packet $passRoot $dirtyRoot + $dirtyRun = Get-Content -LiteralPath (Join-Path $dirtyRoot "run.json") -Raw | ConvertFrom-Json + $dirtyRun.runnerSourceDirty = $true + Write-Fixture (Join-Path $dirtyRoot "run.json") $dirtyRun + $dirtySummary = Get-Content -LiteralPath (Join-Path $dirtyRoot "summary.json") -Raw | ConvertFrom-Json + $dirtySummary.runnerSourceDirty = $true + $dirtySummary.status = "fail" + $dirtySummary.issues = @("runner_source_dirty") + Write-Fixture (Join-Path $dirtyRoot "summary.json") $dirtySummary + Write-Seal $dirtyRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "dirty runner" $dirtyRoot $firmwareSha "runner-clean" + + $turnRoot = Join-Path $tempRoot "turn" + Copy-Packet $passRoot $turnRoot + $turnPolls = Get-Content -LiteralPath (Join-Path $turnRoot "polls.json") -Raw | ConvertFrom-Json + foreach ($record in $turnPolls.records) { + $record.wakeCapturesCompleted = 0 + $record.uplinkTurns = 0 + $record.playbackCompletions = 0 + } + Write-Fixture (Join-Path $turnRoot "polls.json") $turnPolls + Set-SummaryFailure $turnRoot "required_turn_not_observed" + Write-Seal $turnRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "missing conversation lifecycle" $turnRoot $firmwareSha "conversation-lifecycle" + + $cameraRoot = Join-Path $tempRoot "camera" + Copy-Packet $passRoot $cameraRoot + $cameraPolls = Get-Content -LiteralPath (Join-Path $cameraRoot "polls.json") -Raw | ConvertFrom-Json + foreach ($record in $cameraPolls.records) { + $record.compiledCamera = 0 + $record.compiledCameraHostVision = 0 + $record.cameraReady = $false + $record.cameraActive = $false + $record.cameraFrames = 0 + $record.cameraHostFrameRequests = 0 + } + Write-Fixture (Join-Path $cameraRoot "polls.json") $cameraPolls + Set-SummaryFailure $cameraRoot "camera_host_vision_not_observed" + Write-Seal $cameraRoot $runId $firmwarePath $sourcePath + Assert-CheckerFailure "missing camera host vision" $cameraRoot $firmwareSha "camera-host-vision" +} finally { + Remove-Item -LiteralPath $tempRoot -Recurse -Force +} + +Write-Host "Passive no-motion evidence contract: PASS" From 6de75980a6fc8ad443ec2bc0e1fe973fd707d821 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 20:39:06 -0400 Subject: [PATCH 45/46] Record physical recorder diagnostics --- PROJECT_STATE.md | 7 +++++++ docs/ARRIVAL_DAY_RUNBOOK.md | 6 ++++++ docs/FIRST_DEPLOY_STATUS.md | 8 ++++++++ 3 files changed, 21 insertions(+) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 59ffad3b..cf1c491d 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -38,6 +38,13 @@ State timestamp: 2026-08-05 America/New_York boot minimum 4,947 mV), maximum 60.5 C, and maximum display frame 20,422 us. It correctly failed only `runner_source_dirty` and `reset_reason_not_clean`; it validates the recorder against the real robot but is not qualification evidence. +- The committed follow-up at + `output/pc-brain/passive-no-motion-diagnostic-edd519f9-20260805-203820` bound the recorder and + checker to exact source `edd519f9faacf56d84f01fdc2a248521ef26ef85`. It recorded 10/10 real + polls over 6.058 seconds, 5.172 seconds of monotonic sample coverage, maximum gap 1.157 seconds, + zero motion breaches, unchanged counters, and motion/rail/torque off afterward. The independent + checker passed every gate except the preserved panic reset and the resulting summary status. This + validates the committed cadence/provenance path but remains a short expected-fail diagnostic. - The preserved `4d31de41` / `4256F2E5...B31055` image remains historical evidence only. It requests motion and autonomous refresh at boot and must never be used for P1. diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index b9bc321b..edce1ad2 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -61,6 +61,12 @@ reference: 6/6 successful polls, no motion breach or stop call, all motion/rail/ zero, and a deliberate fail for dirty runner source plus panic reset. Do not promote or reuse it as P1 evidence. +The exact-source follow-up at +`output/pc-brain/passive-no-motion-diagnostic-edd519f9-20260805-203820` is the committed scheduler/ +provenance reference: 10/10 real polls, 5.172 seconds monotonic coverage in 6.058 seconds, maximum +gap 1.157 seconds, zero motion breach, unchanged counters, and only the preserved panic reset plus +its summary propagation failed. It is still a short expected-fail diagnostic, not P1 evidence. + Qualification checkout authority (2026-08-05): run current M0/P0 commands only from `D:\CodexProjects\stackchan_alive\output\worktrees\aliveness-repository-truth` on `codex/aliveness-repository-truth`. Require `git status --short` to be empty and require diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 20d5b168..1529a225 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -31,6 +31,14 @@ and the maximum display frame was 20,422 us. It correctly failed only because th tree was dirty and the preserved reset reason is panic. This validates recorder mechanics against the real robot; it is not P1 evidence. +The exact committed follow-up at +`output/pc-brain/passive-no-motion-diagnostic-edd519f9-20260805-203820` bound recorder and checker +bytes to source `edd519f9faacf56d84f01fdc2a248521ef26ef85`. It recorded 10/10 real polls over +6.058 seconds, 5.172 seconds of monotonic coverage, a maximum 1.157-second gap, zero motion breaches, +unchanged motion/rail/write counters, and motion/rail/torque off afterward. The checker passed every +gate except clean reset and the summary fields that truthfully carry that panic hold. This proves the +committed recorder's real-device mechanics, not P1 or long-term stability. + The dated records below remain evidence. This checkpoint supersedes their former claims that no replacement was installed or that the live bridge was absent. It does not transfer old qualification or soak evidence to the current SHA. From 2c7125c8b24310fa01d2239cb0da5fd2e93cfea7 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Wed, 5 Aug 2026 20:53:37 -0400 Subject: [PATCH 46/46] Record ten-minute physical no-motion evidence --- PROJECT_STATE.md | 9 +++++++++ docs/ARRIVAL_DAY_RUNBOOK.md | 10 ++++++++++ docs/FIRST_DEPLOY_STATUS.md | 11 +++++++++++ 3 files changed, 30 insertions(+) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index cf1c491d..ecfa78b8 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -45,6 +45,15 @@ State timestamp: 2026-08-05 America/New_York zero motion breaches, unchanged counters, and motion/rail/torque off afterward. The independent checker passed every gate except the preserved panic reset and the resulting summary status. This validates the committed cadence/provenance path but remains a short expected-fail diagnostic. +- The completed 600-second focused run at + `output/pc-brain/passive-no-motion-600s-6de75980-20260805-204103` used clean, pushed recorder + source `6de75980a6fc8ad443ec2bc0e1fe973fd707d821`. It produced 300/300 successful physical polls, + 598.767 seconds of monotonic coverage in 601.219 seconds, maximum gap 2.773 seconds, zero motion + breaches, no safety-stop call, unchanged motion/power counters, host/bridge/socket ready on every + sample, minimum sampled VBUS 4,950 mV, maximum chip temperature 60.5 C, and maximum frame time + 34,820 us. It failed only the preserved panic reset and its truthful summary propagation. This is + focused exact-image evidence, not a P1 pass; camera/host vision are compiled out and reset + provenance remains unclean. - The preserved `4d31de41` / `4256F2E5...B31055` image remains historical evidence only. It requests motion and autonomous refresh at boot and must never be used for P1. diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index edce1ad2..60033232 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -67,6 +67,16 @@ provenance reference: 10/10 real polls, 5.172 seconds monotonic coverage in 6.05 gap 1.157 seconds, zero motion breach, unchanged counters, and only the preserved panic reset plus its summary propagation failed. It is still a short expected-fail diagnostic, not P1 evidence. +The 600-second focused reference at +`output/pc-brain/passive-no-motion-600s-6de75980-20260805-204103` ran from clean, pushed source +`6de75980a6fc8ad443ec2bc0e1fe973fd707d821`: 300/300 physical polls, 598.767 seconds monotonic +coverage in 601.219 seconds, maximum 2.773-second gap, zero motion breaches or stop calls, unchanged +motion/power counters, and host/bridge/socket ready in every sample. Sampled VBUS stayed at or above +4,950 mV, chip temperature reached 60.5 C, and display frame time reached 34,820 us. Its only checker +failures were the preserved panic reset and the summary fields that carry that failure. Treat it as +valid focused expected-fail evidence, not full P1: reset provenance is not clean and this installed +image has camera/host vision compiled out. + Qualification checkout authority (2026-08-05): run current M0/P0 commands only from `D:\CodexProjects\stackchan_alive\output\worktrees\aliveness-repository-truth` on `codex/aliveness-repository-truth`. Require `git status --short` to be empty and require diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 1529a225..4ff21234 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -39,6 +39,17 @@ unchanged motion/rail/write counters, and motion/rail/torque off afterward. The gate except clean reset and the summary fields that truthfully carry that panic hold. This proves the committed recorder's real-device mechanics, not P1 or long-term stability. +The focused 600-second physical run at +`output/pc-brain/passive-no-motion-600s-6de75980-20260805-204103` used clean, pushed recorder source +`6de75980a6fc8ad443ec2bc0e1fe973fd707d821`. It completed 300/300 successful hardware polls over +601.219 seconds with 598.767 seconds of monotonic coverage and a maximum 2.773-second gap. It +observed zero motion breaches, no safety-stop call, no motion/rail/torque authority, unchanged +motion and power counters, a live exact host PID/bridge/socket on every sample, minimum sampled VBUS +4,950 mV, maximum chip temperature 60.5 C, and maximum display frame time 34,820 us. The checker +failed only `clean-reset` plus the summary fields that propagate the preserved panic code `4`. +This is valid focused no-motion evidence for the installed image; it is an expected failure, not a +P1 pass, because reset provenance is not clean and camera/host vision are compiled out. + The dated records below remain evidence. This checkpoint supersedes their former claims that no replacement was installed or that the live bridge was absent. It does not transfer old qualification or soak evidence to the current SHA.