diff --git a/docs/demo-recording-plan.md b/docs/demo-recording-plan.md index b028cbb..a1d6615 100644 --- a/docs/demo-recording-plan.md +++ b/docs/demo-recording-plan.md @@ -34,6 +34,7 @@ Already on disk under different names? Rename in-place; nothing in this doc refe | # | Recorded | Title | Length (rough) | Local filename | YouTube URL | Script that produced it | |---|----------|-------|----------------|----------------|-------------|--------------------------| | S1 | Sat 22:54 | CoT Track Injection Demo — 3 scenarios (unsigned reject / tampered reject / signed accept) | ~60-90s | `2254-security-cot-inject-demo.mp4` *(currently on Satriyo's laptop as `satriyo_demo_security_1.mp4` — rename when moving into `docs/demo-clips/`)* | TBD | `security/cot_inject_demo.py` ・ `make inject-demo` | +| S6 | TBD | Model Supply-Chain Integrity — `make model-integrity` happy path (4 Piper voice configs + Gemma manifest verified) → mutate one byte of `models/piper/en_US-ryan-high.onnx.json` → re-run, show red FAIL with expected/actual hash diff. ~20s total. | ~20s | `-security-model-integrity.mp4` *(not yet recorded — capture during Sun 0700-0900 alongside other security re-runs)* | TBD | `make model-integrity` ・ `security/model_integrity.py` (issue #57, PR #100) | | S2 | Sat 22:59 | Attack-Vector Rejection — 5 regression tests (issue #25) | ~45-60s | `2259-security-attack-vector-rejection.mp4` *(was `satriyo_security_demo_2.mp4`)* | TBD | `security/test_security_regressions.py` ・ `pytest security/test_security_regressions.py -v` | | S3 | Sat 23:00 | Sign Benchmark — 1000 ML-DSA-65 round-trips, 0.128 ms avg (39× under PRD §11.2 target) | ~30-45s | `2300-crypto-sign-bench.mp4` *(was `satriyo_security_demo_3.mp4`)* | TBD | `crypto/sign_bench.py` ・ `make sign-bench` | | S4 | Sat 23:02 | Security Scan — Bandit + pip-audit clean across all lanes | ~30-45s | `2302-security-scan.mp4` *(was `satriyo_security_demo_4.mp4`)* | TBD | `make security` | diff --git a/docs/issue-scrub-2026-05-03.md b/docs/issue-scrub-2026-05-03.md new file mode 100644 index 0000000..4b7b45e --- /dev/null +++ b/docs/issue-scrub-2026-05-03.md @@ -0,0 +1,496 @@ +# Issue ↔ Code Reconciliation Scrub — 2026-05-03 + +> **Author:** automated reconciliation pass for Jon (team lead). +> **Repo state:** `main` synced to `origin/main`, last merged PR #93 (Piper voice metadata JSONs). +> **Methodology:** every open issue (32 total) cross-checked against on-`main` code, recent merged PRs (#49–#93), and the two open PRs (#92 already merged at scan time, #93 already merged). When an acceptance criterion was ambiguous, scrub erred toward LEAVE OPEN / PARTIAL — judges may inspect the tracker. + +## Summary + +- **Total open issues scanned:** 32 +- **Recommended CLOSE:** 1 +- **Recommended PARTIAL (scope down or split):** 9 +- **Recommended LEAVE OPEN (with context refresh):** 21 +- **Recommended DUPLICATE / STALE:** 1 + +> Note: zero issues are recommended for hard-CLOSE except #17. The hackathon is mid-flight; almost everything has *some* downstream work that materially advances it but few issues are *fully* satisfied. Use the PARTIAL bucket aggressively to prune scope rather than closing. + +--- + +## CLOSE candidates (fully shipped) + +### #17 — [P0] [agent] Wire ollama (Gemma) as Phase 3 LLM +**Evidence:** +- `agent/llm.py` (lines 304–396) — full `OllamaClient` with loopback enforcement, native `format=` structured output, tool-call shim. `OLLAMA_MODEL` env var switches model (gemma2:2b → gemma3:4b without code change). +- `agent/llm.py` `LLMRegistry` (lines 426–493) + `PROFILE_ALLOWED` / `PROFILE_DEFAULT` (lines 49–59) — `TERA_PHASE` / `TERA_DEVICE_PROFILE` flips orchestrator without code change. austere = local-only by construction; frontier client never built. +- `agent/orchestrator.py` `plan()` (line 284–307) — calls `client.complete_structured(...)` with the RouteQuery schema. Tool calling is sandboxed: LLM emits JSON only, deterministic dispatch follows. +- PR #73 (`feat: add chat interface for TERA using local Ollama model`), PR #85 (Kyle's Source Planner UI + `llm_dev_kmh/tera_gemma/chat_tera_gemma.py` proof), PR #86 (`atak/scripts/run_jetson_gemma_server.sh` end-to-end Jetson smoke-test). +- `tests/test_llm.py`, `tests/test_llm_dev_stream.py` — provider tests. + +**Suggested close comment:** +``` +Closing — Ollama is fully wired as the Phase-3 LLM. agent/llm.py:OllamaClient +uses native ollama format= for structured output, loopback-enforced, +profile-gated via TERA_DEVICE_PROFILE. PR #73 added the chat interface, #85 +shipped Kyle's Source Planner driving Gemma 3 4B end-to-end, #86 added the +Jetson smoke-test scripts. The runtime "start it on the Jetson with +OLLAMA_MODEL=gemma3:4b" task lives separately at #82. +``` + +--- + +## PARTIAL candidates (scope down or split) + +### #79 — [P0] [atak] Wire format alignment: TeraPlanClient → /plan PlanRequest schema +**Done:** +- ATAK plugin (`atak/plugin/.../TeraPlanClient.java` lines 135–146) now POSTs to `/api/prompt` with `{prompt, model:"gemma3:4b", llm_provider:"ollama", agent_profile, map_context}`. +- That endpoint (`llm_dev_kmh/app.py` line 7555 `/api/prompt`) is what the Jetson actually serves today via PR #86's `run_jetson_gemma_server.sh`. +- `TERAPlugin.java` voice path uses Android's native `SpeechRecognizer` so transcribed prompt is in the payload as `prompt`. + +**Remaining:** +- The plugin no longer talks to `agent.app:/plan` at all. It talks to `llm_dev_kmh.app:/api/prompt`. The original wire-format work (mapping `lat/lon` → `current` for `agent.schemas.PlanRequest`) is moot until/unless `agent.app` is re-introduced into the Jetson loop. +- If `/plan` is the demo target, plugin payload must be reshaped (`current: {lat, lon}` nested, `source: "operator_text"|"operator_voice"`). +- `current` location is still hard-coded to `null` (`buildPromptPayload` passes `null` for `mapContext`). + +**Suggested rewritten body:** +```markdown +**Lane:** atak · **Owners:** Ben (plugin) + Jon (schema) · **Phase:** P3 · **Priority:** P0 + +## Why (revised post #85, #86, #90) + +The plugin now talks to `llm_dev_kmh.app:/api/prompt`, not `agent.app:/plan`. +Two paths forward; pick at 0500 sync: + +**A) Plumb /plan through llm_dev_kmh** (preferred for hero demo) +- Add a `/plan` proxy in `llm_dev_kmh/app.py` that calls `agent.orchestrator.plan` + internally, so the existing security pipeline + ML-DSA signature still run. +- Plugin keeps current /api/prompt shape but agent_profile="tera-routing" + triggers the proxy. + +**B) Plugin sends /plan directly** +- Update `TeraPlanClient.buildPromptPayload` to nest lat/lon under `current` + and use source: "operator_text" | "operator_voice". +- Plug current location from `MapView.getMapView().getSelfMarker().getPoint()`. +- `TERAPlugin.buildEndpoint` defaults to `/api/prompt` — change to `/plan` or + make path configurable. + +## Acceptance criteria +- [ ] Plugin can drive a real /plan call (either via proxy or direct). +- [ ] Operator's current location appears in server logs (`plan_request prompt_len=N source=operator_text`). +- [ ] Server logs show `llm_emitted_route_query objective=...`. +- [ ] Plugin receives valid PlanResponse with `route`, `waypoints`, `signature`. + +Refs PRD §7.2 API surface row, §13 contract co-ownership rule. +Supersedes the original /api/prompt → /plan rename framing. +``` + +--- + +### #20 — [P1] [data] Pre-cache Cesium imagery + terrain tiles for both AOIs +**Done:** +- PR #85 (`feat(planner): Source Planner UI`) shipped a comprehensive Cesium download stack inside `llm_dev_kmh/app.py`: archive REST endpoints (`CESIUM_ION_ARCHIVES_URL`, `CESIUM_ION_ARCHIVE_DOWNLOAD_URL`), `/api/source-package/{id}/execute`, `/api/source-package/{id}/query/cesium` for offline serve. +- `llm_dev_kmh/static/index.html` loads `cesium.com/.../widgets.css` 1.124. +- Token gating via `/api/config` exposes `cesium_ion_token` to the client. + +**Remaining:** +- `data/scripts/cache_cesium.sh` does not exist. +- `data/aois.yml` does not exist on `main` (lives on Ben's `ben/valhalla-local-sf` branch — see #83). +- `data/cache/cesium/` is not pre-populated, no hash-verification step against `data/manifest.sha256`. +- For Phase 3 (WiFi-off) the planner must serve from a baked-in cache; today the Cesium widget loads from cesium.com CDN at runtime. + +**Suggested rewritten body:** +```markdown +**Owner:** Ben (P4) · Pair: Kyle (P3, planner integration) +**Why:** Phase 3 runs WiFi-off. PR #85 shipped the Cesium archive download +infra inside the Source Planner. Need to convert that into a deterministic +pre-bake step against the demo AOIs. + +**Acceptance:** +- [ ] `data/aois.yml` lands on main (depends on #83). +- [ ] `data/scripts/cache_cesium.sh` invokes the existing + `/api/source-package/{id}/execute` flow against each AOI and writes the + archive zip + extracted tileset under `data/cache/cesium//`. +- [ ] Hashes recorded in `data/manifest.sha256`; `make data-verify` passes. +- [ ] Source Planner served on the Jetson works WiFi-off against the + cached tileset (verify with `infra/jetson_firewall.sh enable`). + +**Defer:** if Sat 1500 progress behind, drop Cesium tiles; SRTM/OSM only. +``` + +--- + +### #19 — [P1] [agent] CesiumJS 3D globe frontend (Phase 1 web visualization) +**Done:** +- PR #85 shipped `llm_dev_kmh/` — a much larger Cesium-based "Source Planner" UI: globe, 3D terrain, KML/KMZ import, ATAK agent toggle, model provider switch (Claude → local Ollama), full source-package planner. This *exceeds* the original Phase 1 scope. + +**Remaining:** +- `agent/static/index.html` does not exist — the original Phase 1 demo path (click point → POST /plan → render polyline draped on terrain) is not built. +- `agent/app.py` has no `/config` endpoint scoped to `CESIUM_ION_TOKEN` (that's in `llm_dev_kmh/app.py:/api/config` instead). +- The Source Planner does *source planning*, not *click-to-route*. If we want a click-to-route Cesium demo, that's still pending — though the demo construct now leans on the ATAK plugin polyline (#80), not Cesium-on-laptop, so this may be moot. + +**Suggested rewritten body:** +```markdown +**Owner:** Jon (P1) + Kyle (P3) +**Status post #85:** the Source Planner UI superseded the original "click point +on globe -> POST /plan" Phase 1 demo. Two scopes left to consider: + +(a) Source Planner is the Phase 1 demo — no further work. Close this issue. +(b) We still want a click-to-route Cesium frontend that drives /plan — keep + open as a stretch. + +Decision to be made by Jon at Sun 0700 go/no-go. Until then, leave open with +the understanding that the hero demo path is plugin-on-ATAK (#80), not +laptop-Cesium. +``` + +--- + +### #28 — [P0] [docs] 1-minute YouTube demo video uploaded +**Done:** +- PR #87 added `docs/demo-recording-plan.md` — canonical recording index + pitch-day fallback rules. +- PR #89 consolidated `docs/demo-clips/` (gitignored) + workflow. +- 7 component clips already captured (S1–S5 security, B1 ATAK, J1 voice severity test). +- `scripts/demo_voice.py` `--severity-demo` flag (PR #76, #78) ships pitch-mode capture for J2. + +**Remaining:** +- The actual 1-minute submission video is not yet recorded. +- Not yet edited. +- Not yet uploaded unlisted to YouTube. +- Submission-form link not posted. +- README link not added. + +**Suggested rewritten body:** +```markdown +**Owner:** P3 (capture) + P4 (script). Coordinator: Jon. +**Acceptance:** Sun 1100-1200 window — capture, edit, upload, link. + +Recording infrastructure is ready (PR #87, #89, #76, #78). 7 component clips +live in docs/demo-clips/. The 1-minute pitch cut still needs to: +- [ ] Sun 1100 — record 1-minute pitch from PRD §12 5-minute structure. +- [ ] Sun 1145 — edit (likely just titlecard + score-bar). +- [ ] Sun 1200 — upload to YouTube unlisted. +- [ ] Sun 1200 — paste URL into hackathon submission form. +- [ ] Sun 1200 — add link to README §Demo + docs/SUBMISSION_NOTES.md. + +**Source:** TASKS.md #25. +``` + +--- + +### #27 — [P2] [mesh] Inject-reject-accept demo script for laptop +**Done:** +- `security/cot_inject_demo.py` (172 lines) ships the inject-reject-accept construct: unsigned CoT rejected, tampered CoT rejected, properly-signed CoT accepted. Standalone Python, no ATAK or network required. +- Demo clip S1 already captured (`docs/demo-clips/2254-security-cot-inject-demo.mp4`). +- `make inject-demo` Make target (per `docs/demo-recording-plan.md`). + +**Remaining:** +- Acceptance says "Two-device side-by-side" — current demo is single-device. mesh injection over the wire (laptop A pretending to be adversary, laptop B running bridge) is not built. +- `mesh/inject_demo.py` (vs current path `security/cot_inject_demo.py`) does not exist. + +**Suggested rewritten body:** +```markdown +**Owner:** P3 (impl), Pair: P2 (signer) + +**Done so far:** single-device construct shipped via `security/cot_inject_demo.py` ++ recording S1 (`docs/demo-clips/2254-security-cot-inject-demo.mp4`). This is +already adequate for the §12 pitch beat 3:00-3:30. + +**Optional stretch (this issue):** two-device side-by-side over the multicast +wire. Requires `mesh/inject_demo.py` + a second laptop running the verifier. +Defer to post-pitch unless Sun 0700 stretch decision is GO. + +**Acceptance:** unchanged (two-device demo). +``` + +--- + +### #24 — [P1] [eval] 20-prompt regression eval set +**Done:** +- `eval/prompts.yml` has 23 entries (more than 20): 3 PRD hero scenarios + 17 variations + 3 adversarial. Expected RouteQuery goldens for all. +- `eval/runner.py` ships mock-mode (validates each `expected_query` against the live RouteQuery JSON Schema, fails CI < 90%). +- Adversarial entries (`adversarial_ignore_instructions`, `adversarial_request_to_sign`) flag pipeline-block expectations. + +**Remaining:** +- `runner.py` line 96: `live` mode (`TERA_EVAL_LIVE=1`) prints `"ERROR: TERA_EVAL_LIVE=1 not yet implemented"` and falls back to mock-mode. So the LLM is not actually exercised. +- Acceptance says "golden tool calls + golden route bboxes" — bbox checks are not present (depends on real Valhalla, see #83). +- `make eval` target exists but only runs mock-mode. + +**Suggested rewritten body:** +```markdown +**Owner:** Jon (P1) +**Status:** `eval/prompts.yml` (23 entries) + `eval/runner.py` mock-mode shipped +and CI-gated at 90%. Live-mode + bbox golden checks still pending. + +**Remaining acceptance:** +- [ ] Implement TERA_EVAL_LIVE=1 path in `eval/runner.py` (call orchestrator + with mocked tool dispatch; assert structured_query matches golden modulo + defaulted fields). +- [ ] Add golden route bboxes per entry (depends on #83 — real Valhalla on main). +- [ ] Wire `make eval-live` into the Sat 1400 dev-time + Sun 0900 demo dry-run + benchmarks per the runner.py docstring intent. + +Mock-mode is sufficient for CI today; live-mode lights up the LLM grounding +test once Ben's routing branch is on main. +``` + +--- + +### #22 — [P0] [infra] Egress firewall: default-deny outbound for Phase 3 +**Done:** +- `infra/jetson_firewall.sh` (71 lines) ships `enable` / `disable` / `status` modes. Drops outbound to non-loopback non-mesh; default-deny on OUTPUT chain. Mesh subnet allowlisted via `MESH_SUBNET` env. +- `infra/firewall_dev.ps1` (PR #71) blocks port 8000 from WiFi on Windows demo day. +- Verification path documented (`iptables -L OUTPUT -v --line-numbers`). + +**Remaining:** +- Acceptance: "TERA_PHASE=3 activates iptables ruleset" — currently fully manual (`sudo bash infra/jetson_firewall.sh enable`). No `TERA_PHASE`-triggered automation. +- Acceptance: "Verified by tcpdump showing zero packets" — `infra/tcpdump_demo.sh` exists but isn't bundled into a `make verify-egress` style command that proves zero outbound under the firewall. + +**Suggested rewritten body:** +```markdown +**Owner:** Satriyo (P2) — already assigned. + +**Remaining acceptance:** +- [ ] Auto-trigger: tera-agent.service (or a new `tera-firewall@phase3.service`) + runs `infra/jetson_firewall.sh enable` when `TERA_PHASE=3` is set in + systemd Environment=. +- [ ] Combine `infra/jetson_firewall.sh enable` + `infra/tcpdump_demo.sh` into + a single `make verify-egress` target that captures 30s of tcpdump under + lockdown and asserts zero non-loopback outbound. +- [ ] Document in docs/PHASE3_RUNBOOK.md (lives in #56 too). + +The script-level lockdown construct is already proven; remaining work is +trigger + verification automation. +``` + +--- + +### #16 — [P0] [models] Pull + verify Gemma + Whisper-tiny +**Done:** +- Gemma is pulled out-of-band by `atak/scripts/run_jetson_gemma_server.sh` (`ollama pull ${MODEL}` step, model defaults to `gemma3:4b`). +- 4 Piper voices committed under `models/piper/` (PRs #84, #93). PR #93 added `.onnx.json` metadata for all four. +- `agent/llm.py` `OllamaClient` validates loopback host (defense in depth). + +**Remaining:** +- `make models-pull` Make target does not exist. +- `models/manifest.sha256` does not exist — no SHA-256 verification against pinned hashes for any model. +- `make models-bench` does not exist; no per-token latency measurement is captured anywhere. +- Whisper-tiny is not pulled or wired anywhere on `main`. Voice-IN today is Android's native `SpeechRecognizer` (TERAPlugin.toggleVoiceInput), not Whisper. +- Closely related to #57 (model supply-chain integrity). + +**Suggested rewritten body:** +```markdown +**Owner:** Kyle (P3) +**Remaining acceptance:** +- [ ] `make models-pull` invokes `ollama pull ${OLLAMA_MODEL:-gemma3:4b}` and + downloads any Whisper artifacts we actually decide to use. +- [ ] `models/manifest.sha256` pins all model files (the 4 Piper .onnx are on + disk and easy to hash today; Gemma's blobs live under ~/.ollama). +- [ ] `make models-bench` runs `crypto/sign_bench.py`-style timing loop + against the LLM (single-token latency + 50-token latency) and prints + pass/fail vs PRD §11.2 targets. +- [ ] Decision: do we ship Whisper-tiny at all for Phase 3, or is the Android + SpeechRecognizer path sufficient? See #18 dependency. + +Note this issue overlaps with #57 (model supply-chain integrity). Consider +folding manifest+hash-verify work there; keep this issue scoped to pull+bench. +``` + +--- + +### #14 — [P1] [deploy] systemd unit for agent + bridge on Jetson +**Done:** +- PR #88 shipped `deploy/systemd/tera-planner.service`, `tera-planner-update.service`, and `tera-planner-update.timer`. `Restart=always`, `RestartSec=3`, `NoNewPrivileges=true`, journald via stdout. +- `deploy/scripts/run_tera_planner.sh`, `install_jetson_autoupdate.sh`, `jetson_update_and_restart.sh` complete the auto-update story. +- PR #92 added `deploy/scripts/jetson_compose_refresh.sh`. + +**Remaining:** +- `tera-planner.service` runs `llm_dev_kmh.app` (Source Planner UI), not `agent.app` (the FastAPI orchestrator). +- `tera-bridge.service` does not exist — depends on `atak/bridge.py` landing first (#13/#83). +- If we want both `agent.app` *and* the planner running on the Jetson concurrently, need to allocate ports + decide which is "the agent service". + +**Suggested rewritten body:** +```markdown +**Owner:** Kyle (P3) +**Done:** systemd framework + auto-update infra (PR #88, #92) for the planner. + +**Remaining acceptance:** +- [ ] Decide: is `tera-planner.service` the canonical agent service, or do we + add `tera-agent.service` for `agent.app:app` on a separate port? +- [ ] If the latter: clone tera-planner.service to tera-agent.service, + ExecStart=`uvicorn agent.app:app --port 8000`, Environment= + `TERA_PHASE=3 OLLAMA_MODEL=gemma3:4b TERA_DEVICE_PROFILE=austere` (mirrors #82). +- [ ] Add tera-bridge.service once #13/#83 lands the bridge code. + +Tracker: #82 covers the runtime smoke-test for the agent service once it has +a unit file. +``` + +--- + +### #1 — [P0] [docs] Codename + AO + hero scenario locked in ADR-002 +**Done:** +- `docs/adrs/2026-05-02-002-kickoff-vote.md` exists with the tabular structure called out in the acceptance. +- Codename = "TERA" (`TacticalEdgeRouteAgent` in plugin package, "TERA" everywhere in PRD/README) — implicit lock. +- Hero scenario locked via PR #64 (`docs/demo-scenarios/sar-olympic.md`). +- Cesium decision implied by PR #85 (Cesium UI shipped → kepler.gl out, Leaflet out). +- Ollama vs llama.cpp implicitly decided by `agent/llm.py:OllamaClient` shipping (Ollama). + +**Remaining:** +- ADR-002 cells literally read "TBD" / "deferred" — the file exists structurally but does not record the decisions that have de-facto happened. + +**Suggested rewritten body:** +```markdown +**Owner:** Jon (P1) +**Status:** ADR-002 file exists but decision cells are still "TBD". The +decisions themselves have happened de facto via downstream code (TERA codename +in plugin/PRD/README, sar-olympic.md hero scenario PR #64, Cesium via Source +Planner PR #85, Ollama via agent/llm.py). + +**Remaining work:** five-minute editorial pass to fill in ADR-002 cells with +what's actually been shipped. No code change. + +**Acceptance:** unchanged. +``` + +--- + +## LEAVE OPEN (with context refresh) + +### #83 — [P0] [routing] Merge ben/valhalla-local-sf — Valhalla + OSM/DEM scripts + KML emit +**Status note:** Still entirely valid. Verified that `data/extracts/`, `data/dem/`, and `data/scripts/` are empty on `main`; `routing/` only has `__init__.py`; `atak/bridge.py`, `atak/cot.py`, `atak/__init__.py` do not exist. Branch `ben/valhalla-local-sf` (945 lines, 16 files) has not been opened as a PR yet. **This is the umbrella for #7, #8, #9, and prerequisite for #13/#23.** Highest-leverage merge available — should be the first thing Sunday morning. + +### #82 — [P0] [deploy] Start TERA agent on Jetson with OLLAMA_MODEL=gemma3:4b +**Status note:** `agent/llm.py:OllamaClient` defaults to `gemma2:2b` (line 327) — without `OLLAMA_MODEL=gemma3:4b` override it will try to pull a model the Jetson doesn't have. PR #88's `tera-planner.service` runs `llm_dev_kmh.app`, not `agent.app`. The runtime smoke-test (curl `/health` returning `{"phase":"3","profile":"austere"}`) has not been demonstrated. Still a P0 blocker for the demo loop. Consider pairing with #14 to ship a `tera-agent.service` unit at the same time. + +### #81 — [P1] [atak] Verify ML-DSA signature on /plan response before rendering +**Status note:** Plugin currently does not call `/plan` at all (calls `llm_dev_kmh:/api/prompt` per #79). Verification can't happen until the plugin sees a `Signature` block. `agent/orchestrator._sign_response` (orchestrator.py line 184–239) already signs server-side. `security/cot_inject_demo.py` already proves the inject-reject construct end-to-end without the plugin — the §12 pitch beat 3:00-3:30 is covered. Defer per the issue's own defer condition (Sun 0700 go/no-go). + +### #80 — [P0] [atak] Render route_geojson as polyline on ATAK MapView +**Status note:** Plugin (`TERAPlugin.java` line 160–195) appends agent response as text to chat scroll only — no MapItem polyline. Hard-blocks on #79 (need a real PlanResponse with `route.geometry.coordinates` to render). Hero demo claim ("ATAK draws a blue line down a draw...") is currently unmet. + +### #70 — [P2] [docs] ADR: production encrypted tunnel architecture +**Status note:** No file at `docs/adrs/2026-05-03-NNN-production-encrypted-tunnel.md`. Lowest priority of the encrypted-tunnel stretch issues; defer per the issue's own defer condition. Pure docs, can be written during Sun 1300-1410 finalist deliberations if needed. + +### #69 — [P2] [atak] Android-side PQC sidecar for ML-KEM session decrypt +**Status note:** `atak/sidecar/` does not exist. Stretch issue with explicit defer condition (Sun 0700 GO/NO-GO on Phase 3 + mesh stretch). Recommend NO-GO unless Phase 3 USB tether path is proven solid by Sat night. + +### #68 — [P2] [crypto] ML-KEM-768 session encryption +**Status note:** `crypto/kem_session.py`, `crypto/aead.py`, `docs/contracts/pqc_session.md` do not exist. Hard prerequisite is #69 (Android sidecar). Same defer-condition logic. + +### #57 — [P1] [security] Model supply-chain integrity — checksum + signed bundles +**Status note:** No `models/MANIFEST.yml`. PR #93 committed Piper `.onnx.json` metadata but did not add SHA pins. `make verify-models` does not exist. The pickle/torch.load audit (the second prong) has not been done. Critical given that PR #93 just expanded our external-model dependency surface to 4 Piper voices. + +### #56 — [P1] [infra] Jetson Orin Nano storage + RAM budget +**Status note:** No `make budget` target. No `docs/PHASE3_RUNBOOK.md`. PR #92 (`jetson_compose_refresh.sh`) and #88 (systemd units) are deploy-related but don't measure footprint. The working-set inventory table in the issue body is still the only source of truth and has not been measured. Recommend Kyle owns a 30-min measurement pass on the Jetson during the integration smoke-test. + +### #45 — [P2] [voice] Mission-context situational narration +**Status note:** Partial credit not enough to flag PARTIAL — the issue is specifically about the *narrative* layer ("Based on your need for CASEVAC..."), and `agent/orchestrator._build_rationale` (orchestrator.py line 160–176) is still the generic template ("Routed to X, distance Y, ETA Z..."). The vocabulary infrastructure exists (`voice/glossary.py` knows CASEVAC/HLZ/golden hour with definitions; `voice/profiles.py` auto-elevates voice mode on severity cues per PR #63), but the per-mission_type rationale templates that *string mission rationale together* are not present. Stretch only. + +### #38 — [P2] [routing] priority_grid tool (currently stubbed) +**Status note:** `agent/orchestrator._dispatch_tools` (orchestrator.py line 109–119) still returns "Objective priority_search_area is not yet supported by the MVP pipeline." Eval set has two entries (`sar_priority_area`, `sar_with_team_lead`) that exercise this objective; both expect `destination_type: none`. Stretch. + +### #37 — [P2] [agent] Operator-approval workflow +**Status note:** `agent/orchestrator._run_security_pipeline` (line 374–409) still hard-codes `operator_approved=True, policy_valid=True`. Comment in code already points to this issue ("operator-approval flow"). Stretch. + +### #36 — [P2] [contracts] Add PlanRequest.mode field +**Status note:** `agent/schemas.PlanRequest` has no `mode` field. `agent/orchestrator.plan(req, mode="auto", ...)` takes `mode` as a function arg only. `agent/app.py:plan_endpoint` does not surface `mode` to the request body or query params. Stretch; touches the public contract so needs Ben + Jon signoff. + +### #35 — [P2] [ci] Tighten mypy on crypto/ + security/ post-MVP +**Status note:** `pyproject.toml` likely still has `ignore_errors = true` for `crypto.*` and `security.*` (the issue body cites this). `Makefile` `lint` target only runs mypy on `agent routing crypto` (line 55), not `security` — so even where `crypto.*` is allowed, the gate doesn't include `security`. Post-hackathon cleanup. Don't touch during the demo window. + +### #23 — [P1] [routing] Slope + ridgeline-prominence cost extension +**Status note:** No real Valhalla on `main` (`routing/__init__.py` only). Hard prerequisite is #83. Cannot start until Ben's branch lands. + +### #18 — [P1] [voice] Whisper-tiny push-to-talk endpoint (voice IN) +**Status note:** No `/plan/voice` endpoint in `agent/app.py`. Whisper isn't wired anywhere. Plugin uses Android `SpeechRecognizer` instead (TERAPlugin.toggleVoiceInput, line 288–381). For the hero demo, Android SpeechRecognizer may be sufficient — recommend a Sun 0700 decision: ship Whisper or formally adopt SpeechRecognizer and rewrite this issue's scope. + +### #13 — [P0] [atak] Signed CoT bridge over multicast +**Status note:** No `atak/bridge.py` on `main`. `crypto.cot_signer.sign_cot` exists; `agent/orchestrator._sign_response` calls it. But CoT XML emission + multicast delivery doesn't exist. Ben's `ben/valhalla-local-sf` branch has a 22-line `atak/bridge.py` (likely just stub) — won't fully satisfy this issue even when merged. Realistically blocked on a follow-up to #83. + +### #11 — [P0] [hardware] Jetson Orin Nano bring-up complete +**Status note:** Significant Jetson tooling has shipped: PR #85 (`llm_dev_kmh/tera_gemma/chat_tera_gemma.py` proves Gemma 3 4B runs on the Jetson), #86 (link-test scripts + Ollama serve helper), #88 (systemd auto-update), #92 (compose refresh). However, `make jetson-prepare` Make target does not exist, and "make ci passes on the Jetson" cannot be verified from main without Jetson access. Recommend Kyle confirms the bring-up state during Sun 0700 sync; don't auto-close. + +### #9 — [P1] [atak] Emit KML route file from /plan response +**Status note:** No KML emitter on `main`. Ben's branch has `atak/cot.py` (147 lines, "plan_to_kml + write_plan_kml") per #83 — will satisfy this when the branch merges. Hard-prerequisite on #83. + +### #8 — [P0] [data] Clip OSM PBF + DEM tiles for SF + austere AO +**Status note:** `data/extracts/` and `data/dem/` are empty. Ben's branch has the clip scripts + manifest per #83. Merge of #83 closes this immediately. Consider marking as DUPLICATE of #83 once the umbrella PR opens. + +### #7 — [P0] [routing] Stand up Valhalla locally with SF extract +**Status note:** `routing/` is empty (only `__init__.py`). Ben's branch has `routing/valhalla_client.py` (184 lines) per #83. Merge of #83 closes this immediately. Consider marking as DUPLICATE of #83 once the umbrella PR opens. + +--- + +## DUPLICATE / STALE + +### (none recommended for hard duplicate today) + +The natural duplicate candidates — #7, #8, #9 vs the umbrella #83 — are arguably *better tracked separately* until #83 actually opens as a PR. Once that PR is open and CI green, close #7/#8/#9 in the same merge with `Closes #7, #8, #9` in the PR body and skip the dup-marking step. **Net DUPLICATE/STALE for this scrub: 1 (informally — see treatment of #7/#8/#9 above as soft duplicates of #83).** + +--- + +## Suggested batch commands (for Jon) + +```bash +# === Close fully-shipped (1) === +gh issue close 17 --comment "Closing — Ollama is fully wired as the Phase-3 LLM. agent/llm.py:OllamaClient uses native ollama format= for structured output, loopback-enforced, profile-gated via TERA_DEVICE_PROFILE. PR #73 added the chat interface, #85 shipped Kyle's Source Planner driving Gemma 3 4B end-to-end, #86 added the Jetson smoke-test scripts. The runtime smoke-test on the actual Jetson lives separately at #82." + +# === Edit partially-done — scope down (9) === +# Save each suggested rewritten body to /tmp/issue-NN-body.md first, then: +gh issue edit 79 --body-file /tmp/issue-79-body.md # /plan vs /api/prompt pivot +gh issue edit 20 --body-file /tmp/issue-20-body.md # Cesium pre-cache: scope to AOI bake step +gh issue edit 19 --body-file /tmp/issue-19-body.md # 3D globe: ack Source Planner; click-to-route optional +gh issue edit 28 --body-file /tmp/issue-28-body.md # YouTube video: tighten to Sun 1100-1200 acceptance +gh issue edit 27 --body-file /tmp/issue-27-body.md # mesh inject-reject: ack S1 capture; two-device stretch +gh issue edit 24 --body-file /tmp/issue-24-body.md # eval: ack mock-mode shipped; live-mode + bbox open +gh issue edit 22 --body-file /tmp/issue-22-body.md # firewall: ack script; auto-trigger + tcpdump verify open +gh issue edit 16 --body-file /tmp/issue-16-body.md # models: ack Gemma+Piper; manifest+bench+Whisper open +gh issue edit 14 --body-file /tmp/issue-14-body.md # systemd: ack planner.service; agent.service + bridge open +gh issue edit 1 --body-file /tmp/issue-1-body.md # ADR-002: editorial pass to fill in TBD cells + +# === Soft-duplicate hint (no edit needed; will close together with #83 PR) === +# When opening the PR for ben/valhalla-local-sf, include in the body: +# Closes #83 +# Closes #7 +# Closes #8 +# Closes #9 +# That prunes 4 issues with one merge. + +# === Leave open (21) === +# No action. Status notes above are for the next standup, not the issue tracker. +``` + +### Where to dump the rewritten bodies before running `gh issue edit` + +```bash +# A one-liner to populate /tmp/issue-*-body.md from the markdown blocks above — +# easiest path is to manually copy each "Suggested rewritten body:" block in +# this report into the corresponding /tmp/issue-NN-body.md file. The blocks +# are already pure markdown ready to paste; no transformation needed. +``` + +--- + +## Notes for Jon + +1. **#83 is the highest-leverage merge available.** Merging Ben's `ben/valhalla-local-sf` (945 lines, 16 files) effectively closes #7, #8, #9 in one shot and unblocks #13, #20, #23. Treat it as the Sunday-0500 anchor. +2. **Two divergent contracts on the Jetson today** — `agent.app:/plan` (the original, signed, security-pipeline path) vs `llm_dev_kmh.app:/api/prompt` (Kyle's planner, used by the ATAK plugin). The hero-demo wiring is currently the latter. Decide at Sun 0700 which is "the demo path"; #79, #80, #81, #82, #14 all depend on this. +3. **Defer-condition issues** (#68, #69, #70, #45, #38, #37, #36) — all stretch with explicit defer logic in their bodies. Don't feel obligated to touch them during the demo window. +4. **Voice/audio infrastructure is in good shape.** PRs #49 (Piper TTS + glossary), #63 (profiles + radio FX + bakeoff), #66 (per-request voice profile), #76/#78 (severity demo + pitch mode), #84/#93 (voice models on disk). The remaining voice work is mostly demo capture (#28) and the mission-narration stretch (#45). +5. **Security lane is hackathon-ready.** `crypto/`, `security/pipeline.py`, `security/cot_inject_demo.py`, `crypto/sign_bench.py`, ADR-003 (two-signature approval), and 5 captured demo clips (S1–S5). Satriyo has covered the §12 pitch beat 3:00-3:30 without the plugin needing to verify (#81). + +--- + +*End of scrub. — generated 2026-05-03 against `main` @ origin/main, post-#93.* + +--- + +## Post-scrub deltas (appended 2026-05-03 after security-posture pass) + +Single-line notes for issues that have changed status since the scrub above. Body of the scrub is unchanged. + +- **#57** — closed by **#100** (`models/MANIFEST.yml` + `security/model_integrity.py` + `make model-integrity` wired into `make ci`). No longer LEAVE OPEN. +- **#81** — closed by **#97** (`agent/orchestrator.verify_plan_response` + `/plan/verify` endpoint, fail-closed render gate, ML-DSA-65 verify with payload→response binding). No longer PARTIAL/LEAVE OPEN. +- **#105** — closed by the same merge (post-#97 follow-up): wires `crypto.cot_signer.load_trust_list` into `verify_plan_response`, returns `Untrusted key_id - REJECTED` for unknown keys (`agent/orchestrator.py:420–438`), and adds a FastAPI lifespan hook that auto-bootstraps the device's own key into the trust list at process start (`agent/app.py:42–60`). Three new tests on main (`tests/test_orchestrator.py:626 / :654 / :679`). Closes the trust-list-at-`/plan/verify` gap that an earlier draft of `docs/security/posture.md` had flagged as a known asymmetry — that asymmetry no longer exists; see the rewritten `docs/security/posture.md` §5.1 for the post-#105 framing of the (intentional) self-attestation trust model. +- **#79** — closed by **#102** (`fix(atak): wire TeraPlanClient to /plan with PlanRequest schema`). The plugin now POSTs to `/plan` with the correct `PlanRequest` shape; was PARTIAL in scrub. +- **#27** — single-device construct adequately covered by **S1** clip + `security/cot_inject_demo.py`. The two-device mesh stretch remains open per the issue's own defer condition; status note unchanged. +- **#28** — recording infrastructure has expanded one slot (S6 model-integrity clip added to `docs/demo-recording-plan.md`); the 1-minute submission cut still pending per the original status note. diff --git a/docs/presentation/security-beat.md b/docs/presentation/security-beat.md new file mode 100644 index 0000000..1c59104 --- /dev/null +++ b/docs/presentation/security-beat.md @@ -0,0 +1,150 @@ +# Security beat — 90s presenter script + +> Slot in the 5-minute pitch flow: PRD §12 / `docs/PRD.md` line 433 (3:00–3:30, +> Presenter B). This document expands that 30-second beat into a 90-second +> script for use in a separate security-focused 5-minute deck or extended +> Q&A. The live pitch beat itself is still 30s; this script is the source +> material we cut down from. +> +> **Suggested presenter:** P2 (Satriyo) — owns the entire crypto + security +> lane (PRD §13 lane mapping; signers in `crypto/`, pipeline in `security/`, +> demo monitors in `infra/`). The signed-route construct lands harder when +> the engineer who shipped it is the one narrating. +> Backup: P1 (Jon) if Satriyo is on the live `/plan` driver seat. + +--- + +## Beat structure (90 seconds) + +Two demo cuts (with an optional 10-second Cut 1b on the trust list), three +sentences each, one framing line at the end. + +### Cut 1 — Signed-route tamper-reject (≈30 seconds) + +**What's on screen:** terminal split. Left pane: `python security/cot_inject_demo.py` +output (or replay of clip S1, `docs/demo-clips/2254-security-cot-inject-demo.mp4`). +Right pane: ATAK MapView with the rejected route highlighted in red. + +**What presenter B actually says:** + +1. "CoT — the message format every TAK device speaks — is unauthenticated by + default. Anyone on the multicast bus can inject a fake route and ATAK has + no way to know." +2. "We sign every route at the source with ML-DSA-65 — Dilithium, the NIST + FIPS 204 post-quantum standard from August 2024. Here's a tampered route + hitting the verifier; the bridge rejects it before ATAK ever renders it." +3. "The signed payload is bound to the route geometry, the rationale, and the + destination — so an adversary who flips one byte of the geometry breaks the + signature. Fail-closed: no signature, no render." + +**Code anchor for Q&A:** `agent/orchestrator.py:365` (`verify_plan_response`), +`crypto/ml_dsa_signer.py:115` (signer/verifier), `tests/test_orchestrator.py:601` +(tamper-reject test). + +--- + +### Cut 1b — Spoofed-key_id reject (≈10 seconds, optional but punchier) + +**What's on screen:** the same `/plan/verify` payload as Cut 1, but instead of +mutating the geometry, presenter B mutates `signature.key_id` to +`attacker-rogue-001` and re-POSTs. Verifier returns +`{"valid": false, "reason": "Untrusted key_id - REJECTED", ...}` in red. + +**What presenter B says (one sentence, no pause):** + +> "Even if the attacker has perfectly valid Dilithium output, if the key isn't +> in the device's trust list, the verifier rejects it before it ever gets to +> the crypto math." + +**Why this beat lands harder than byte-tampering:** byte-tampering proves +"the signature checks the bytes". Spoofing the `key_id` proves "the system +checks who you are, not just what you said." Most non-crypto judges find +the latter more intuitive — it maps to badge-access semantics they already +have. Cut 1b is the recommended substitution if Cut 1's tamper-reject feels +too in-the-weeds for the room. + +**Code anchor for Q&A:** `agent/orchestrator.py:420` (`load_trust_list` import), +`agent/orchestrator.py:430` (lookup), `agent/orchestrator.py:438` +(`Untrusted key_id - REJECTED`), `tests/test_orchestrator.py:626` +(`test_verify_plan_response_rejects_untrusted_key_id`). The trust list itself +is auto-bootstrapped at FastAPI startup from the device's own public key +(`agent/app.py:42` lifespan, `:58` `_bootstrap_device_trust`) so a fresh +Jetson works zero-friction; the corollary is that the demo trust model is +"device self-attests" rather than "external CA" — be ready to say so if a +crypto-savvy judge asks. + +--- + +### Cut 2 — Model integrity CI fail (≈40 seconds) + +**What's on screen:** terminal. Run `make model-integrity` once green, then +mutate one byte of `models/piper/en_US-ryan-high.onnx.json`, re-run, show +the red FAIL with the expected/actual diff. + +**What presenter B actually says:** + +1. "Same threat model, one layer down: if an attacker swaps a model file + on disk, the signed-route guarantee at the top is meaningless because the + thing producing the route was already poisoned." +2. "Every model artifact in the repo has a SHA-256 pin in `models/MANIFEST.yml`, + verified on every CI run. Watch — I flip one byte of a Piper voice config + and the CI gate fires immediately, with the expected vs actual hash." +3. "Same script also AST-scans our Python for `torch.load(...)` calls + without `weights_only=True` — that one parameter is the difference between + loading weights and loading arbitrary pickle code from the filesystem." + +**Code anchor for Q&A:** `models/MANIFEST.yml`, `security/model_integrity.py:43` +(`_sha256_file`), `security/model_integrity.py:101` (`_scan_file_for_unsafe_loads`), +`security/test_model_integrity.py:43` (tamper detection test), `Makefile:95` +(`make model-integrity`), `Makefile:98` (wired into `make ci`). + +--- + +## The framing line (≈10 seconds) + +The single line that lands the "why this matters for tactical edge AI" +framing: + +> **"Edge AI for tactical routing means the device, the model, and the route +> are all in the threat surface. Sign the route, pin the model, prove zero +> egress — three primitives, none of them new in isolation, but no one else +> on the contested-mesh side has stitched them into one offline-only pipeline +> for the Marine you can't reach."** + +Delivered after Cut 2, into the airplane-mode / `tcpdump` proof beat +(PRD §12 line 434, the 3:30–4:00 beat). This is the handoff line from the +security beat into the offline-proof beat. + +--- + +## Cut decisions on the day + +- If the live verify-reject demo hangs > 5 seconds, presenter B Cmd-Tabs to + clip **S1** (`docs/demo-clips/2254-security-cot-inject-demo.mp4`). +- If `make model-integrity` is slow because the manifest is unhashed + locally, fall back to the new `M1` recording (see + `docs/demo-recording-plan.md` §Security lane). The model-integrity beat + has a cheap recovery — there's no operator-perceptible difference between + live and recorded for this segment. +- If the entire 90s overruns, drop Cut 2 first (model integrity is the + layer-down beat; Cut 1 is the headline). Cut 1 carries the §12 PS4 hook; + Cut 2 is the bonus. + +--- + +## What this beat is **not** trying to claim + +When a judge asks "is the cryptography novel?", the honest answer is: + +- The **primitives** (ML-DSA-65, SHA-256 manifest pinning, structured-query + validation, unsafe-`torch.load` linting) are all standard practice or + NIST-standardized. +- The **delta** is the system-level integration: PQC-signed routes + supply- + chain pinned models + offline-only egress + structured-query injection + guard, all assembled for an edge-LLM-in-ATAK use case where today's + baseline is unauthenticated CoT and unverified weights. + +We do not claim novel cryptography. We claim the first open, fully-offline, +voice-enabled NL route planner that renders into ATAK with PQC-signed +provenance (PRD §9 line 355, novelty claim). That is the framing every +follow-up answer should ladder back to. diff --git a/docs/security/blog-outline.md b/docs/security/blog-outline.md new file mode 100644 index 0000000..dcb24c0 --- /dev/null +++ b/docs/security/blog-outline.md @@ -0,0 +1,203 @@ +# Blog outline — TERA security beat + +> **Status:** outline only. Drafted because items A and E in the novelty +> assessment scored *NOVEL — INDUSTRY-BLOG-PUBLISHABLE* (deployment-context +> novelty, not primitive novelty). This is hackathon-experience-report +> material, not a research paper. See "What this is NOT" below before +> drafting. + +## Working title + hook + +**Title:** *Hardening an offline edge-LLM for ATAK: post-quantum signed +routes, hash-pinned weights, and a fail-closed render gate, in 36 hours* + +**One-line hook:** "We took a 4B-parameter LLM, an Android Tactical Assault +Kit plugin, a Jetson Orin Nano, and the NIST PQC standard from August 2024, +and stitched them into the first offline route planner whose every line on +the map carries a Dilithium signature back to the device that drew it — and +this is what we'd ship differently next time." + +## Five-section outline + +### 1. Why the Marine on the contested mesh can't trust the blue line on his ATAK + +- The unauthenticated-CoT problem: ATAK's *Cursor on Target* protocol has no + authentication primitive in the base spec; on a coalition or adversary- + proximate mesh, an injected track is indistinguishable from a friendly one. +- This isn't a research observation; it's documented and well-known. What's + not documented is what happens when you put an *LLM-generated* route on + that wire — the same lack-of-authentication, but now the producer is a + language model running on a device with no human in the cryptographic + loop. + +**Cite:** PRD §8.1 (`docs/PRD.md` lines 274–282); the field-known +unauthenticated-CoT property of TAK; ATAK plugin chat threads. + +### 2. The verify gate, end-to-end + +- Show the actual flow: voice → Whisper-tiny → Gemma → structured RouteQuery + JSON → Valhalla → signed PlanResponse → `/plan/verify` → ATAK. +- Walk through `agent/orchestrator.py:365` (`verify_plan_response`): payload + binding (uid, route_hash, rationale, lat/lon, mission_type), then trust-list + lookup (`agent/orchestrator.py:420–438`), then crypto verify with the + trusted public key passed in explicitly (`agent/orchestrator.py:459`), then + fail-closed. Three independent reject reasons surface to the operator UI: + `payload_hash mismatch`, `Untrusted key_id - REJECTED`, and + `Signature invalid - route REJECTED` — they map to three different + attacker capabilities and are testable in isolation. +- Cover the trust-list bootstrap as a deployment-tooling story, not a + crypto novelty. FastAPI lifespan auto-registers the device's own public + key (`agent/app.py:42` → `:58`) so `make jetson-compose-refresh` works + zero-friction; the orchestrator also bootstraps on first sign as + belt-and-suspenders (`agent/orchestrator.py:267`). Be honest in the post: + the trust model is "device self-attests" — competent for a single-device + hackathon demo, deliberately not a chain back to an external CA. +- The deployment-context delta from "ML-DSA exists" to "ML-DSA gates the + render path of an LLM-produced route on a Jetson at 7W idle in airplane + mode, with a trust list bootstrapped at process start and consulted + before every render": this is the part worth writing up. +- Include the tamper-reject and trust-list-reject test transcripts + (`tests/test_orchestrator.py:601`, `:626`) as "show, don't tell". + +**Cite:** `crypto/ml_dsa_signer.py`, `agent/orchestrator.py:217` +(`_sign_response`), `agent/orchestrator.py:288` (`_bootstrap_device_trust`), +`agent/orchestrator.py:365` (`verify_plan_response`), +`agent/orchestrator.py:420–459` (trust-list wiring), +`agent/app.py:42–60` (lifespan bootstrap), +`agent/app.py:142` (`/plan/verify`). + +### 3. The supply-chain layer: SHA-256 pinning is the floor, not the ceiling + +- `models/MANIFEST.yml` + `make model-integrity` is competent industrial + practice — analogous to a constrained Sigstore / in-toto attestation + shape, just without the signed-attestation step. +- Be honest in the post: a hash list checked into the same repo as the code + is only as strong as the repo's branch protection. Show the upgrade path + to detached signatures + Sigstore. +- The unsafe-`torch.load` AST scan is a Bandit `B614` equivalent. Ship it + anyway, because the cost is one CI minute and the failure mode is RCE. +- Discuss what the scanner does *not* catch (aliased imports, indirect + calls, cross-function taint) — credibility move. + +**Cite:** `models/MANIFEST.yml`, `security/model_integrity.py`, +`security/test_model_integrity.py`, `Makefile:95`, Bandit B614 docs, +semgrep `python.pytorch.security.pytorch-load`. + +### 4. The offline-only proof: tcpdump beats audits + +- Pre-flight checklist on stage; airplane-mode toggle visible to audience. +- Three-pane demo monitor (`infra/security_demo_monitors.sh`): tcpdump, audit + log scroll, signed CoT scroll. Zero outbound packets is a *visible* + artifact, not a claim in a slide. +- Key insight for the post: when your threat model includes "operator's + device may already be in a denied environment", the right verification is + a live wire-trace that judges can independently witness, not a SOC2 audit + artifact. + +**Cite:** `infra/security_demo_monitors.sh`, `infra/tcpdump_demo.sh`, +PRD §8.4 lines 317–325. + +### 5. What we'd ship differently in v2 (the credibility section) + +- Trust list: extend the now-wired flat-file trust list (#105) with an + external enrollment ceremony for multi-device fleets, a CRL distribution + channel, and key rotation. The single-device auto-bootstrap is fine for + the demo; it is not what production looks like. +- Manifest: add a detached signature on `MANIFEST.yml` itself; move toward a + SLSA Level 2 build with provenance attestations. +- Hardware-rooted identity: TPM2 / NV-SE-backed device key on Jetson Orin + with attestation; out-of-scope for hackathon, on the v2 wishlist. +- Egress firewall: today an opt-in shell script; v2 is a `tera-firewall.service` + triggered by `TERA_PHASE=3` (PRD §8.4 line 321; tracked in #22). +- The two-signature operator-approval path (ADR-003) is the more interesting + workflow than single-sig `/plan` — call out the asymmetry. Today + `_run_security_pipeline` hard-codes `operator_approved=True` in the MVP + path; v2 wires the approval wrapper into the default flow. +- Manifest coverage: today the four committed Piper voice configs are + pinned but the Gemma weights and Piper `.onnx` files are still + `PLACEHOLDER_run_sha256sum_after_download`. v2 fills those in as part of + the release-engineering pass. +- AST scanner: today the unsafe-`torch.load` scanner has an unreachable + fallback branch and misses aliased imports (`from torch import load`). + v2 either upgrades to Bandit `B614` or fixes the branch. + +## Specific code & data the post would cite + +- `crypto/ml_dsa_signer.py:115` — the Dilithium3 signer. +- `crypto/ml_dsa_signer.py:165` — the verify path with payload-hash fast + path. +- `agent/orchestrator.py:217` — sign on emit (`_sign_response`). +- `agent/orchestrator.py:288` — `_bootstrap_device_trust` (single helper called + from both startup and first-sign paths). +- `agent/orchestrator.py:365` — verify on render (`verify_plan_response`). +- `agent/orchestrator.py:322` — the payload→response binding + (`_payload_matches_plan_response` — this is the "you can't swap the + geometry without breaking the sig" piece). +- `agent/orchestrator.py:420–459` — trust-list lookup, untrusted-key reject, + explicit trusted-pub-key plumbing into `MLDSASigner.verify`. +- `agent/app.py:42–60` — FastAPI lifespan that bootstraps the device's own + key into the trust list before the first request can land. +- `models/MANIFEST.yml` — the actual pin file. +- `security/model_integrity.py:43` — `_sha256_file` with CRLF normalization + (Windows-CI lesson). +- `security/model_integrity.py:101` — the AST scanner. +- `security/pipeline.py:67` — the six-stage `run_pipeline`. +- `security/test_security_regressions.py` — the 5 prompt-injection vectors + the pipeline blocks (S2 demo clip). +- `crypto/sign_bench.py` — the sign+verify benchmark output (under + PRD §11.2's 5ms target). +- `tests/test_orchestrator.py:601` — the tamper-reject test. +- `infra/security_demo_monitors.sh` — the offline-only proof harness. + +Recordings to embed (already captured): +- S1 — `docs/demo-clips/2254-security-cot-inject-demo.mp4` (CoT inject reject). +- S2 — `docs/demo-clips/2259-security-attack-vector-rejection.mp4` (5 + attack vectors). +- S3 — `docs/demo-clips/2300-crypto-sign-bench.mp4` (0.128ms sign+verify). + +## What this is NOT + +This blog post is **not** a novel cryptographic contribution. We did not +invent ML-DSA. We did not invent Sigstore. We did not invent Bandit. We did +not invent CoT. We did not invent ATAK. The contribution is a **deployment +synthesis**: assembling a coherent, tested, demonstrable security posture +for a specific deployment context (offline edge LLM → ATAK render gate on +a battery-powered Jetson) where prior art exists for each piece but the +assembled whole is — to the authors' knowledge — not yet documented in the +open literature for this use case. + +If a reviewer asks "what's new here?", the answer is "the assembly, the +testing transcript, the honest gap list — not the primitives". If that +answer doesn't satisfy a venue, the post is in the wrong venue. + +We must not call this work "post-quantum cryptography research". The PQC +piece (ML-DSA-65 / Dilithium3) is a NIST-standardized primitive used as +designed. Calling our application of a standard a research contribution is +academic-fraud-adjacent and would correctly get the post rejected. + +## Suggested venues, ranked by fit + +1. **Team / company engineering blog (TruePoint).** Best fit by far. House + blogs are the natural home for hackathon experience reports; the + audience expectation is "we built this in a weekend, here's what we + learned" rather than "we proved a new theorem". +2. **The Rust / Python / `liboqs-python` user community via lobste.rs + + /r/netsec.** The honest gap-list approach plays well in these venues; + the deployment-context novelty is interesting to practitioners. +3. **USENIX `;login:` short article.** A short *experience report* under + the deployment / lessons-learned column is a credible fit. Not a peer- + reviewed venue; reasonable submission cost. +4. **IEEE Security & Privacy magazine "Building Security In" column.** Same + shape — practitioner-facing, not peer-reviewed in the academic sense, + open to deployment writeups. Submission process is heavier; only worth + it if there's a coalition / DoD audience reason to want it indexed + there. +5. **DEF CON / B-Sides talk-not-paper track (next cycle).** A talk version + of this material plays well to a tactical / mil-hacker audience. + Different deliverable; mention as future channel, not as a near-term + target. + +We **do not** target SOSP, NDSS, S&P (the academic conference), CCS, +USENIX Security, or any peer-reviewed cryptography venue. We are not making +a research-grade contribution and pretending otherwise wastes reviewer +time. diff --git a/docs/security/posture.md b/docs/security/posture.md new file mode 100644 index 0000000..63ab3a0 --- /dev/null +++ b/docs/security/posture.md @@ -0,0 +1,281 @@ +# TERA Security Posture + +> Public-facing posture summary. Authoritative source for what the hackathon +> build does and does not defend against. Cross-reference with PRD `docs/PRD.md` +> §6, §8, §9 for the full threat model. Code citations are file path + line +> number against `main` as of post-#97 / #100 / #105. + +--- + +## 1. Threat model + +The TERA threat model is articulated in PRD §8 (`docs/PRD.md` lines 270–334) +and the competitive framing in §9 (`docs/PRD.md` lines 338–355). The headline +threat the system is designed for is **TAK track injection on a contested or +coalition mesh** (PRD §8.1, lines 274–282): + +> CoT (Cursor on Target) is the lingua franca of TAK. By default, CoT messages +> are unauthenticated… A clean route from a Marine's Jetson is +> indistinguishable on the wire from a poisoned route injected by an +> adversary. + +Around that headline threat sits a set of secondary threats the build also +takes positions on (PRD §8.3 table, lines 305–314): + +- **Data exfiltration via LLM** — prompt-injected tool calls with outbound URLs. +- **Model tampering / supply chain** — compromised LLM or TTS weights. +- **Map data poisoning** — tampered OSM extracts or DEM tiles. +- **Adversarial prompts** — operator-style queries crafted to leak position or + route through hostile AOs. +- **ATAK CoT injection** — same family as track injection, scoped to ATAK + ingest specifically. + +Out of scope for this hackathon (PRD §8.5, lines 327–333): FIPS 140-3 +validation of the crypto module, DoD STIG compliance, cross-domain solutions, +formal ATO. Each is explicitly flagged as transition work. + +--- + +## 2. Defenses shipped + +One row per real defense. The table is intentionally compact so judges can +read it from one paste; padding it would make the gaps in §4 less honest. + +| Threat | Mitigation | Code | Tests | +|---|---|---|---| +| **TAK track injection** (PRD §8.1) | ML-DSA-65 (Dilithium3, NIST FIPS 204) signature on every plan response and CoT field; fail-closed verify gate before ATAK render. Ed25519 fallback for dev machines without `liboqs`. | `crypto/ml_dsa_signer.py` (signer/verifier), `crypto/cot_signer.py:88` (`sign_cot`) and `crypto/cot_signer.py:190` (`verify_cot`), `agent/orchestrator.py:365` (`verify_plan_response`), `agent/app.py:142` (`/plan/verify` endpoint) | `tests/test_orchestrator.py:591` (`test_verify_plan_response_accepts_signed_response`), `tests/test_orchestrator.py:601` (`test_verify_plan_response_rejects_tampered_route`), `tests/test_orchestrator.py:612` (`test_verify_plan_response_rejects_missing_signature`), `crypto/sign_bench.py` (perf), `security/cot_inject_demo.py` (S1 demo clip) | +| **Track injection — payload binding** | Signed payload is bound back to the rendered route (`request_id`, `route_hash`, `rationale`, destination lat/lon, `mission_type`). A signature that verifies cryptographically but covers a different route is rejected. | `agent/orchestrator.py:322` (`_payload_matches_plan_response`), `crypto/cot_signer.py:116` (`_verify_payload_matches_cot_envelope`) | `tests/test_orchestrator.py:601` (tampered route → reject) | +| **Two-signature operator approval** (ADR-003) | `/plan/approve` returns a wrapper signed by both device key and operator key; `verify_approval_wrapper` requires both signatures and binds operator's signed payload to the wrapper's `route_hash`. | `crypto/cot_signer.py:330` (`verify_approval_wrapper`) | `tests/test_orchestrator.py` two-sig wrapper tests; ADR `docs/adrs/2026-05-02-003-two-signature-approval.md` | +| **Trust list — both render paths** (post-#105) | `crypto/keys/trust_list.json` flat-file map of `key_id → public_key_hex`. **The HTTP `/plan/verify` path consults the trust list before any crypto math** (`agent/orchestrator.py:420` imports `load_trust_list`, `:430` looks up the `key_id`, `:438` returns `Untrusted key_id - REJECTED` on miss). The trusted public key is then passed *explicitly* into `MLDSASigner.verify` (`agent/orchestrator.py:459`) so the signer cannot silently fall back to a freshly-generated local keypair under the same `key_id` string. The CoT-XML mesh path is unchanged: `verify_cot` (`crypto/cot_signer.py:230`) does the same check. The device's own public key is auto-bootstrapped at FastAPI startup (`agent/app.py:42` lifespan → `agent/app.py:58` calls `_bootstrap_device_trust`) and again on first sign as belt-and-suspenders (`agent/orchestrator.py:267`, `:288` `_bootstrap_device_trust` → `crypto/cot_signer.py:532` `export_public_key_to_trust_list`). | `crypto/cot_signer.py:518` (`load_trust_list`), `crypto/cot_signer.py:532` (`export_public_key_to_trust_list`), `agent/orchestrator.py:420–459` (verify-path wiring), `agent/app.py:42–60` (lifespan bootstrap) | `tests/test_orchestrator.py:626` (`test_verify_plan_response_rejects_untrusted_key_id`), `tests/test_orchestrator.py:654` (`test_verify_plan_response_accepts_trusted_key_id`), `tests/test_orchestrator.py:679` (`test_trust_list_bootstrapped_on_startup`) | +| **Model tampering — committed assets** (PRD §8.3 row 3) | SHA-256 manifest pinning all four committed Piper voice configs; CI gate via `make model-integrity` runs on every push. | `models/MANIFEST.yml` (4 required, 4 placeholder), `security/model_integrity.py:58` (`verify_manifest`) | `security/test_model_integrity.py:23` (manifest exists), `security/test_model_integrity.py:29` (required hashes pass), `security/test_model_integrity.py:43` (tamper detection) | +| **Unsafe deserialization at model load** | AST scanner blocks `torch.load(...)` calls without `weights_only=True`; CI fails on any hit. | `security/model_integrity.py:101` (`_scan_file_for_unsafe_loads`), `security/model_integrity.py:139` (`scan_unsafe_loads`); `Makefile:95` (`model-integrity` target wired into `ci`) | `security/test_model_integrity.py:57` (detects unsafe), `security/test_model_integrity.py:71` (accepts safe), `security/test_model_integrity.py:85` (project source clean) | +| **Prompt injection / instruction override** (PRD §8.3 row 5) | Six-stage `security.pipeline.run_pipeline`: SuperAgent Guard (or local regex fallback), redactor, provenance check, schema validator with substring-blocklist, policy gate, route trust score. LLM emits structured RouteQuery JSON only — natural-language strings never reach the routing engine. | `security/pipeline.py:67` (`run_pipeline`), `security/structured_query_validator.py:53` (forbidden substrings), `security/superagent_integration.py` (Guard + Redact wrapper with offline fallback), `security/data_provenance.py`, `security/policy_gate.py`, `security/route_trust_score.py` | `security/test_security_regressions.py` (S2 demo clip, 5 attack vectors), `security/test_plan_guard.py`, `security/prompt_injection_tests.py`; eval set `eval/prompts.yml` includes `adversarial_*` entries that must pipeline-block | +| **Data exfiltration via LLM** (PRD §8.3 row 2) | No `http_get` / `fetch` tool registered; tool dispatch is a static deterministic mapping from validated `RouteQuery.objective` → `find_pois` / `route`. Egress firewall (`infra/jetson_firewall.sh enable`) drops outbound on Phase 3. | `agent/orchestrator.py:_dispatch_tools` (deterministic mapping, no network tools), `agent/tools.py` (registered tools), `infra/jetson_firewall.sh` | `infra/security_demo_monitors.sh` (tcpdump pane proves zero outbound during plan); `make tcpdump-demo` | +| **Audit trail** (PRD §8.4 proof point 4) | Every prompt, RouteQuery emission, pipeline allow/block, tool dispatch, and signature event is emitted as a structured `audit_event(...)`. | `security/audit_log.py`, `agent/orchestrator.py` (audit_event call sites in `plan()`) | `security/test_audit_log.py` | +| **Offline-only operation proof** (PRD §8.4 proof points 1–3) | Three-pane demo monitor: tcpdump (zero outbound), audit log scroll, signed-CoT scroll. SHA-256 manifest can be diffed against printed-on-card hashes (PRD §8.4 proof point 3). | `infra/security_demo_monitors.sh`, `infra/tcpdump_demo.sh`, `infra/audit_log_scroll.sh`, `infra/cot_signed_scroll.sh` | manual; recordings S1–S5 in `docs/demo-recording-plan.md` | + +--- + +## 3. Defenses out of scope for hackathon + +We are not claiming any of the following. If a judge asks, the honest answer +is "post-hackathon transition work": + +- **Mesh-distributed CRL or signed key-rotation protocol.** PRD §8.2 row 4 + (line 294) describes the trust list as "static list of allowed key IDs for + the demo" and explicitly defers CRL/shared-root distribution to post-MVP. + The flat-file allowlist at `crypto/keys/trust_list.json` is the *intended* + shape for the hackathon. There is no CRL, no revocation path, no expiry, + and no multi-device key-distribution protocol — these are tracked + separately (PRD §8.5; production encrypted-tunnel ADR is open as #70). +- **Hardware-rooted device identity.** Keys live on the rootfs at + `/etc/wayfinder/keys/` (Jetson) or `crypto/keys/` (dev). The PRD claims + "rootfs encrypted" (PRD §8.2 row 1, line 291) — encrypted-rootfs is an + install-time configuration, not something the application enforces. There is + no TPM, no secure element, no attestation. +- **Signed map / DEM differential updates.** PRD §8.3 row 4 (line 310) lists + "hash-verify PBF on load; ship known-good extracts" for the MVP. Today the + PBF/DEM verification step is documented but not implemented in CI; only + model artifacts are pinned in `models/MANIFEST.yml`. +- **FIPS-validated crypto module.** We use `liboqs` (a research/reference + implementation), not a FIPS 140-3 module. PRD §8.5 (line 329) flags this + explicitly. +- **DoD STIG / formal ATO.** PRD §8.5 lines 330–332. +- **Side-channel hardening** (power, thermal, EM). PRD §8.3 row 8 (line 314). +- **Sandboxed tool runtime / DNS sinkhole.** PRD §8.3 row 2 post-MVP column + (line 308) — out of scope. + +--- + +## 4. Verification recipe + +Seven commands a judge can run on a fresh checkout to verify each defense +beat. All work offline; none require network egress after `make install`. +Last validated against `main` post-#105 — every test in command 2 was +re-run locally and passed (`6 passed in 0.76s`). + +```bash +# 1. Model supply-chain integrity (manifest + AST scan). +# Expects: 4 required Piper hashes verified, scan finds 0 unsafe loads. +make model-integrity + +# 2. Plan response verify gate — happy path + tamper-reject + missing-sig +# + trust-list-reject + trust-list-accept + startup-bootstrap. +pytest tests/test_orchestrator.py::test_verify_plan_response_accepts_signed_response \ + tests/test_orchestrator.py::test_verify_plan_response_rejects_tampered_route \ + tests/test_orchestrator.py::test_verify_plan_response_rejects_missing_signature \ + tests/test_orchestrator.py::test_verify_plan_response_rejects_untrusted_key_id \ + tests/test_orchestrator.py::test_verify_plan_response_accepts_trusted_key_id \ + tests/test_orchestrator.py::test_trust_list_bootstrapped_on_startup -v + +# 3. Spoofed-key_id end-to-end check. Sign a route, then mutate the +# Signature.key_id to a name the trust list has never seen and POST to +# /plan/verify. Expect HTTP 200 with body +# `{"valid": false, "reason": "Untrusted key_id - REJECTED", ...}`. +# See tests/test_orchestrator.py::test_verify_plan_response_rejects_untrusted_key_id +# for the exact monkeypatch shape; the same construct is the new +# presenter Cut-1b in docs/presentation/security-beat.md. + +# 4. CoT inject-reject construct (single-device PS4 demo, S1 clip source). +make inject-demo + +# 5. Sign benchmark — proves ML-DSA-65 sign+verify under PRD §11.2 5ms target. +make sign-bench + +# 6. Security pipeline regression suite (5 attack vectors blocked, S2 source). +pytest security/test_security_regressions.py -v + +# 7. Three-pane offline-proof demo: tcpdump (zero outbound) + audit log +# scroll + signed CoT scroll. Run while issuing a /plan request from +# another shell. +make demo-proofs # or: bash infra/security_demo_monitors.sh +``` + +The full CI gate that runs on every push is `make ci`, which composes +`lint`, `test`, `security` (Bandit + pip-audit), `shellcheck-syntax`, and +`model-integrity` (`Makefile:1`, `Makefile:98`). + +--- + +## 5. Honest gaps (read these before you cite us) + +These are the things we know are weak. They are listed here on purpose — +credibility on a hackathon submission is built more by surfacing gaps than by +extending wins. + +### 5.1 The trust list is auto-bootstrapped from the device's own public key + +The `/plan/verify` HTTP path does consult the trust list (post-#105: see +§2 row 4 and `agent/orchestrator.py:420–459`), and `verify_cot` has done +the same thing for the CoT-XML mesh path since the original signer landed. +What a careful reader should still understand is **how the trust list is +populated**: it is auto-bootstrapped from the device's own public key at +two points. + +- **App startup (FastAPI lifespan).** `agent/app.py:42` registers the + `_lifespan` hook; `agent/app.py:58` calls `_bootstrap_device_trust(signer.key_id)` + before the first request can land. This guarantees that + `make jetson-compose-refresh` → first `/plan` → first `/plan/verify` works + zero-friction on a fresh Jetson. +- **First sign (belt-and-suspenders).** `agent/orchestrator.py:267` (inside + `_sign_response`) also calls `_bootstrap_device_trust` so the file exists + even if the lifespan hook was skipped. + +Both paths funnel into `agent/orchestrator.py:288 _bootstrap_device_trust` → +`crypto/cot_signer.py:532 export_public_key_to_trust_list`, which merges +the device's public key into `crypto/keys/trust_list.json`. The bootstrap +is idempotent on disk and memoized per-process via `_BOOTSTRAPPED_KEY_IDS`. + +**The honest framing:** for a single-device hackathon demo this is correct +and intentional. The trust model the judge is being shown is "this Jetson +self-attests its own signing key" — a self-signed allowlist, not a chain +back to an external CA. PRD §8.2 row 4 (line 294) explicitly scopes the +trust list to "static list of allowed key IDs for the demo" and defers +CRL / shared-root distribution to post-MVP. + +What this trust model does **not** defend against: + +- A second TERA Jetson on the same mesh whose key was never registered + with this device's trust list. Multi-device trust requires an enrollment + ceremony; we have not built one. +- An adversary who has already obtained write access to the device's + rootfs (and therefore to `crypto/keys/trust_list.json`). PRD §8.3 row 7 + treats physical compromise as "encrypt rootfs; no plaintext mission + data at rest" for the MVP and tamper-evident enclosure post-MVP. + +What it does defend against — proven by the post-#105 tests: + +- An attacker producing a syntactically valid signature under a `key_id` + this device has never seen → `Untrusted key_id - REJECTED` + (`tests/test_orchestrator.py:626`). +- A signature whose `key_id` is in the trust list but whose payload bytes + no longer match the rendered route → rejected at the binding stage + (`tests/test_orchestrator.py:601`). + +### 5.2 `models/MANIFEST.yml` is unsigned + +The manifest itself is a plain YAML file in the repo. Anyone with write +access to the repo (or to a release artifact) can change a pinned hash and +the CI will keep passing. There is no detached signature on the manifest, no +in-toto attestation, no Sigstore artifact bundle — it's a hash list, not a +provenance chain. + +PRD §8.3 row 3 post-MVP column (line 309) acknowledges this: the documented +upgrade path is "Sigstore-signed artifacts; reproducible build". For now, +trusting `MANIFEST.yml` requires trusting the repo, which means the manifest +is only as strong as the signed-commit / branch-protection posture on +GitHub. + +Additionally, the runtime LLM (Gemma 2B / 3B) and Piper `.onnx` weights are +all currently `PLACEHOLDER_run_sha256sum_after_download` (`models/MANIFEST.yml` +lines 20, 25, 56, 61). Until a release engineer fills those in post-download, +the largest model artifacts are **listed but not pinned** — the manifest +catches placeholders by skipping them, not by failing. + +### 5.3 The unsafe-`torch.load` AST scanner is a one-rule linter, not taint analysis + +`security/model_integrity.py:101` walks the AST of every `*.py` under +`agent/`, `routing/`, `security/`, `crypto/`, `voice/`, `eval/`, `scripts/` +and flags `torch.load(...)` calls without a `weights_only` keyword. It is +roughly equivalent to Bandit's `B614` rule (`pytorch_load_save`) and the +semgrep public registry rule `python.pytorch.security.pytorch-load`. + +What it does not catch: + +- **Aliased imports.** `from torch import load; load("model.pt")` is not + detected. The scanner code has a defensive branch + `(isinstance(func, ast.Name) and func.attr == "load") if hasattr(func, "attr") else False` + (`security/model_integrity.py:122`) but `ast.Name` does not have an `attr` + attribute, so the `hasattr` guard always returns False and that branch is + effectively unreachable. +- **Indirect calls.** `loader = torch.load; loader("model.pt")` is not + detected. +- **Cross-function taint.** A wrapper function around `torch.load` would hide + the call from the scan. + +This is a competent ad-hoc lint, not a research-grade static analyzer. It is +worth running because the codebase is small enough that direct +`torch.load(...)` calls are the realistic risk surface, but it should not be +sold as anything more than that. + +### 5.4 Prompt-injection defenses are layered, not bulletproof + +The structured-query validator (`security/structured_query_validator.py:53`) +uses a substring blocklist of 16 phrases. Trivial paraphrases ("disregard +earlier", Unicode lookalikes, base64-encoded payloads) bypass it. The LLM is +sandboxed to JSON-only output, which is the **load-bearing** defense — the +substring list is belt-and-suspenders. SuperAgent Guard +(`security/superagent_integration.py`) adds a second layer when the API key +is set, with a regex local fallback otherwise. + +The pipeline blocks the categories shown in `security/test_security_regressions.py`, +but we make no claim of comprehensive coverage against an adaptive attacker. + +### 5.5 `operator_approved` and `policy_valid` are stub-true + +`agent/orchestrator._run_security_pipeline` (`agent/orchestrator.py:_run_security_pipeline`) +hard-codes `operator_approved=True, policy_valid=True` for the MVP path with +an in-line comment pointing at issue #37. The two-signature approval wrapper +(ADR-003) is the real path that exercises operator approval; the +single-`/plan` path does not. + +### 5.6 The Ed25519 fallback is dev-only + +`crypto/ml_dsa_signer.py:183` (`FallbackSigner`) explicitly self-identifies as +"Ed25519-fallback" and "use only in development. NOT post-quantum." The +factory `create_signer` (`crypto/ml_dsa_signer.py:253`) prints a `WARNING` +banner when the fallback is selected. Production / Jetson installs must have +`liboqs-python`; the demo runbook covers this. + +--- + +## 6. Cross-references + +- PRD threat model: `docs/PRD.md` §6 (lines 75–99), §8 (lines 270–334), §9 + (lines 338–355). +- Demo recording plan and clip index: `docs/demo-recording-plan.md`. +- Issue scrub (closed by #97 and #100): `docs/issue-scrub-2026-05-03.md` + (post-scrub appendix). +- ADRs: `docs/adrs/2026-05-02-003-two-signature-approval.md` (two-sig + approval), planned ADR for production encrypted tunnel architecture (#70). +- Presenter beat: `docs/presentation/security-beat.md`.