From 439e1ee2f52796683f894bd6708cc21c88009c81 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 09:29:48 -0500 Subject: [PATCH 1/3] =?UTF-8?q?ci:=20build=20hygiene=20=E2=80=94=20webview?= =?UTF-8?q?=20cleanup=20+=20OpenSSF=20Scorecard=20(#46,=20#178)=20(#194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(build-webview): sweep stale hashed chunks before rsync (#46) Vite emits content-hashed asset names (e.g. `index-CXPb0I5z.js`), so the active bundle filename rotates on every source/dependency edit. The existing rsync runs without `--delete` (to preserve `bus-harness.html` and `.gitignore`), so old chunks accumulated in `App/Resources/webview/assets/` — observed 9 stale `index-*.js` / `.js.map` pairs locally before this fix. Add an explicit `rm -f` of the asset glob (JS + JS map + CSS + CSS map) before the rsync drops the fresh bundle. The preserved files (`bus-harness.html`, `index.html`, `.gitignore`) live one level up in `$DEST`, not in `$DEST/assets`, so the rm cannot reach them. Verified locally: `bash scripts/build-webview.sh` now leaves exactly one `index-*.js` + `.js.map` in `assets/` (matching the reference in `index.html`). Refs #46 Co-Authored-By: Claude Opus 4.7 * ci(scorecard): add OpenSSF Scorecard workflow + README badge (#178) Adds `.github/workflows/scorecard.yml` running `ossf/scorecard-action@v2.4.0` on: - Weekly cron (Mondays 06:00 UTC) for posture drift detection - Push to develop for prompt regression visibility - workflow_dispatch for ad-hoc runs Permissions follow the canonical Scorecard set (`id-token: write`, `contents: read`, `security-events: write`) with top-level `read-all` and job-scoped writes. Results are published to the OpenSSF public dataset (powers the README badge URL) and uploaded as SARIF to code-scanning. The workflow consumes no untrusted user inputs in `run:` blocks, so it is not exposed to workflow-injection patterns documented in github.blog/security/.../how-to-catch-github-actions-workflow-injections. README.md gains the standard Scorecard badge next to the existing license/platform/swift badges. Refs #178 Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- .github/workflows/scorecard.yml | 62 +++++++++++++++++++++++++++++++++ README.md | 1 + scripts/build-webview.sh | 11 ++++++ 3 files changed, 74 insertions(+) create mode 100644 .github/workflows/scorecard.yml diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..32cb0cf --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,62 @@ +# OpenSSF Scorecard — security supply-chain posture for KofTwentyTwo/Jarvis. +# +# Issue #178. Scorecard scores 18 security checks (Branch-Protection, Code-Review, +# Pinned-Dependencies, SAST, Token-Permissions, etc.) and publishes results as +# SARIF to GitHub code-scanning. A weekly schedule keeps the score fresh; we +# also re-run on every push to develop so significant posture regressions show +# up promptly. The badge in README.md links back to the public scorecard page. +# +# Permissions are the canonical Scorecard set: +# - id-token: write OIDC for publish-results +# - contents: read read repo tree +# - security-events: write upload SARIF to code-scanning +# +# This workflow runs on github-hosted ubuntu-latest. Self-hosted Grogu is not +# used here -- Scorecard is short, network-bound, and benefits from a clean +# ephemeral environment. +# +# Workflow consumes no untrusted user inputs (no github.event.* interpolation +# in run: blocks), so it is safe from workflow-injection patterns. + +name: OpenSSF Scorecard + +on: + schedule: + # Mondays at 06:00 UTC. Off-peak relative to weekday push spikes. + - cron: "0 6 * * 1" + push: + branches: [develop] + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@v2.4.0 + with: + results_file: results.sarif + results_format: sarif + # publish_results: true uploads anonymized scores to the OpenSSF + # public dataset, which is what powers the README badge URL + # (api.scorecard.dev). Safe for a public repo; would need + # reconsidering if the repo were ever made private. + publish_results: true + + - name: Upload SARIF to code-scanning + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/README.md b/README.md index b8f0e1b..6ed8b28 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![Issues](https://img.shields.io/github/issues/KofTwentyTwo/Jarvis)](https://github.com/KofTwentyTwo/Jarvis/issues) [![Platform: macOS 26](https://img.shields.io/badge/platform-macOS%2026%20Tahoe-blue)](https://www.apple.com/macos/) [![Swift 6](https://img.shields.io/badge/swift-6.0-orange)](https://swift.org) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/KofTwentyTwo/Jarvis/badge)](https://scorecard.dev/viewer/?uri=github.com/KofTwentyTwo/Jarvis) Jarvis is a hybrid native + embedded-web macOS app that runs as an ambient menu-bar presence. A Swift host owns the operating system (mic, camera, diff --git a/scripts/build-webview.sh b/scripts/build-webview.sh index 181c8e2..e2bdf13 100755 --- a/scripts/build-webview.sh +++ b/scripts/build-webview.sh @@ -77,6 +77,16 @@ pnpm --filter @jarvis/hud build DEST="$REPO_ROOT/App/Resources/webview" mkdir -p "$DEST" +mkdir -p "$DEST/assets" + +# Issue #46: Vite emits content-hashed asset names (e.g. `index-CXPb0I5z.js`), +# so the active bundle filename changes on every dependency or source edit. +# Without explicit cleanup, stale chunks accumulate in `assets/` because we +# don't pass `--delete` to rsync below. Sweep the asset glob (JS + maps + CSS) +# before the rsync drops the fresh bundle. `bus-harness.html`, `index.html`, +# and the directory's `.gitignore` live one level up (in $DEST), not in +# $DEST/assets, so they are not at risk from this rm. +rm -f "$DEST"/assets/*.js "$DEST"/assets/*.js.map "$DEST"/assets/*.css "$DEST"/assets/*.css.map echo "[build-webview] rsync dist/ -> $DEST" # --exclude bus-harness.html: keep 02-03's committed copy intact (parity @@ -85,6 +95,7 @@ echo "[build-webview] rsync dist/ -> $DEST" # --exclude .gitignore: preserve the App/Resources/webview/.gitignore that # lets index.html stay source-committed while assets/ regenerates. # We do NOT pass --delete: other vendored assets coexist in this dir. +# (The explicit `rm -f` above handles stale `assets/` chunks; see #46.) rsync -a \ --exclude=bus-harness.html \ --exclude=.gitignore \ -- 2.54.0 From ea8426eefcff813f7f31d9463cfd5ec8703366b0 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 09:33:48 -0500 Subject: [PATCH 2/3] docs(voice): pin STT backend constant identity contract (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime mismatch flagged by #37 was already fixed at the call sites in `App/AppDelegate.swift:1631-1642` and `:2562-2575` — both routes now reference `STTBackendSelector.backendSpeechAnalyzer` / `backendWhisperKit` (snake_case) instead of the pre-2026-05-12 camelCase string literals. The regression test `STTBackendSwitchTests.testB5_canonicalConstantsRoundTripThroughMake` already locks both halves of the contract: 1. The snake_case constants round-trip through `make(backend:)` without hitting the unknown-backend default branch. 2. The pre-fix camelCase shape (`"whisperKit"`) DOES land in the default branch and DOES emit an unknown-backend warning. So the #37 close action is just to make that contract self-documenting at the declaration site rather than relying on readers finding the test. Add a doc-comment to the two `public static let backend*` constants that explains the wire shape, names the regression test, references Issues #29 + #37, and warns against renaming without updating `PerTurnSnapshot` and the test in lockstep. No code change; `swift test --package-path packages/Voice` continues to pass (26 tests). Closes #37 Co-Authored-By: Claude Opus 4.7 --- .../Voice/Sources/Voice/STT/STTBackendSelector.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/Voice/Sources/Voice/STT/STTBackendSelector.swift b/packages/Voice/Sources/Voice/STT/STTBackendSelector.swift index d17bb9a..e573fb9 100644 --- a/packages/Voice/Sources/Voice/STT/STTBackendSelector.swift +++ b/packages/Voice/Sources/Voice/STT/STTBackendSelector.swift @@ -28,6 +28,16 @@ public protocol STTWarningLogger: Sendable { public enum STTBackendSelector { // MARK: - Valid backend strings + // + // Canonical backend identifiers. Snake_case is the wire shape stored in + // `PerTurnSnapshot.stt.backend`. Call sites (`AppDelegate.sttBackend`) + // SHOULD reference these constants rather than the bare string literals; + // `STTBackendSwitchTests.testB5_canonicalConstantsRoundTripThroughMake` + // locks the round-trip contract by asserting both that the constants + // map to the right `STTProvider` AND that camelCase string literals + // (the pre-2026-05-12 anti-pattern from Issue #29 / #37) fall through + // to the default branch with a warning. Do not rename without updating + // `PerTurnSnapshot` and the regression test in lockstep. public static let backendSpeechAnalyzer = "speech_analyzer" public static let backendWhisperKit = "whisperkit" -- 2.54.0 From 6a404e479a44b94503ed6bfdd77488f60df91ffe Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 09:34:02 -0500 Subject: [PATCH 3/3] fix(bus): clamp non-finite audioLevel rms to 0.0 at encode (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BusOutbound.audioLevel` carries a `Float rms` that `AudioLevelEmitter` produces per audio frame (~100 Hz once the consumer is wired up). Foundation's `JSONEncoder.NonConformingFloatEncodingStrategy` defaults to `.throw`, so a single NaN — easy to land via a CoreAudio over-/underflow or an all-zero buffer fed through certain RMS shapes — would throw inside `WebviewBridge.sendRaw` and tear down the per-frame bus path. The HUD-side consumer in `webview/packages/hud/src/bus/ client.ts:135` is currently a no-op (the ring shader is still on the synthetic-sine animation from Plan 03-03), so the bug is LATENT, not actively firing — but the clamp is cheap, defensive, and pins the contract before the consumer activates. Inline clamp at the encode site (`BusOutbound.encode(to:)` switch arm for `.audioLevel`): replace non-finite floats with `0.0` via `rms.isFinite ? rms : 0.0` immediately before `container.encode(_:)`. No refactor of `AudioLevelEmitter` — the per-spec call here is defensive at the serialization boundary, where every encoder-level overflow path converges. Two new unit tests in `CodableRoundTripTests`: - `test_audioLevel_nanIsClampedToZero` — encodes `.audioLevel(rms: .nan)`, decodes, asserts `rms == 0.0` and `rms.isFinite`. - `test_audioLevel_infinityIsClampedToZero` — companion for ±∞ so a future refactor cannot narrow the guard to NaN-only. `swift test --package-path packages/Bus --filter CodableRoundTripTests` passes (30 tests, +2 from this commit). Closes #47 Co-Authored-By: Claude Opus 4.7 --- packages/Bus/Sources/Bus/BusOutbound.swift | 15 +++++++- .../BusTests/CodableRoundTripTests.swift | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/Bus/Sources/Bus/BusOutbound.swift b/packages/Bus/Sources/Bus/BusOutbound.swift index 3bbbd2b..883e076 100644 --- a/packages/Bus/Sources/Bus/BusOutbound.swift +++ b/packages/Bus/Sources/Bus/BusOutbound.swift @@ -159,7 +159,20 @@ extension BusOutbound: Codable { try container.encode(text, forKey: .text) case .audioLevel(let rms): try container.encode(Discriminator.audioLevel, forKey: .type) - try container.encode(rms, forKey: .rms) + // Issue #47: JSON has no canonical encoding for `Float.nan` or + // `±Float.infinity`. Foundation's `JSONEncoder` defaults to + // throwing for non-finite floats (`JSONEncoder.NonConformingFloat- + // EncodingStrategy.throw`), which would crash the per-frame bus + // path the moment `AudioLevelEmitter` produces a NaN — easy to + // hit on a CoreAudio over-/underflow or a buffer of all-zero + // samples. Clamp non-finite to 0.0 inline at the encode site so + // the rms float in the wire JSON is always a finite, ring-shader- + // safe number. The HUD-side consumer (`webview/packages/hud/src/ + // bus/client.ts`) currently no-ops on audioLevel; the clamp is + // defensive against the bug becoming active once the consumer is + // wired up. + let safe = rms.isFinite ? rms : 0.0 + try container.encode(safe, forKey: .rms) case .toolCallStart(let id, let name, let argsPreview): try container.encode(Discriminator.toolCallStart, forKey: .type) try container.encode(id.uuidString.lowercased(), forKey: .id) diff --git a/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift b/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift index 66b3fdf..6a6fc3f 100644 --- a/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift +++ b/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift @@ -123,6 +123,44 @@ final class CodableRoundTripTests: XCTestCase { ) } + /// Issue #47: `BusOutbound.audioLevel` encodes a `Float`, and Foundation's + /// default `JSONEncoder.NonConformingFloatEncodingStrategy` is `.throw`. + /// A non-finite `rms` (NaN from a CoreAudio over-/underflow, or ±∞ from a + /// pathological emitter buffer) would crash the per-frame bus path inside + /// `WebviewBridge.sendRaw`. The encode site in `BusOutbound.swift` clamps + /// non-finite floats to `0.0`; this test pins the contract so the clamp + /// can't silently regress. + func test_audioLevel_nanIsClampedToZero() throws { + let encoded = try BusCoder.makeEncoder().encode(BusOutbound.audioLevel(rms: .nan)) + let decoded = try BusCoder.makeDecoder().decode(BusOutbound.self, from: encoded) + guard case .audioLevel(let rms) = decoded else { + XCTFail("expected .audioLevel after round-trip, got \(decoded)") + return + } + XCTAssertEqual(rms, 0.0, "NaN rms must be clamped to 0.0 at encode time (#47)") + XCTAssertTrue(rms.isFinite, "post-clamp rms must be finite") + } + + /// Companion to `test_audioLevel_nanIsClampedToZero` — covers the ±∞ + /// edge cases. CoreAudio drivers don't normally emit infinities, but the + /// encode-time clamp is sourced from `Float.isFinite`, which covers both + /// NaN and ±∞ via one check; locking the contract here prevents a future + /// refactor from narrowing the guard to NaN-only. + func test_audioLevel_infinityIsClampedToZero() throws { + let encoder = BusCoder.makeEncoder() + let decoder = BusCoder.makeDecoder() + + for value in [Float.infinity, -Float.infinity] { + let encoded = try encoder.encode(BusOutbound.audioLevel(rms: value)) + let decoded = try decoder.decode(BusOutbound.self, from: encoded) + guard case .audioLevel(let rms) = decoded else { + XCTFail("expected .audioLevel for \(value)") + continue + } + XCTAssertEqual(rms, 0.0, "±∞ rms (\(value)) must be clamped to 0.0 (#47)") + } + } + func test_roundTrip_toolCallStart() throws { let id = UUID(uuidString: "550e8400-e29b-41d4-a716-446655440000")! try assertOutboundRoundTrips( -- 2.54.0