Skip to content

Turn CI green, encrypt sync for real, and fix two silent no-ops#2

Merged
proggeramlug merged 8 commits into
mainfrom
security-housekeeping-2026-07
Jul 16, 2026
Merged

Turn CI green, encrypt sync for real, and fix two silent no-ops#2
proggeramlug merged 8 commits into
mainfrom
security-housekeeping-2026-07

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Six commits. Two themes: CI never actually gated anything, and sync was never actually encrypted.

CI was red since May and gating nothing

The typecheck job piped tsc under set -o pipefail, so a non-zero exit killed the step before its own ~30-pattern "known noise" filter ever ran. The filter was dead code. Removed it — the step is now a plain bun run typecheck.

The "noise" it was hiding was mostly real. Typecheck is 39 → 0:

  • perry/i18n had no ambient declaration at all, while 28 files import { t } from it (the IDE genuinely ships 12 locales — this import is live).
  • fs / child_process declarations still described hone's old Perry fork. Added the upstream members the compat shims actually use (statSync/lstatSync/openSync + a Node-shaped Stats, spawn + ChildProcess); perry-ui was missing widgetSetTooltip.
  • tests/agentic/setup/seed-files is a self-contained fixture workspace (own package.json/tsconfig, imports a .py file) copied into a temp dir by the agentic tests. It was never meant to typecheck here — excluded.

Sync advertised E2E encryption and shipped plaintext

Every crypto primitive was stubbed in both sync-host.ts and sync-guest.ts:

function ccAes256GcmEncrypt(plaintext, keyHex, nonceHex) { return plaintext; }
function ccHkdfSha256(...) { return ''; }   // -> project key stays empty

So the relay saw file deltas, AI prompts, and Claude Code transcripts in cleartext while hone.codes says "end-to-end encrypted" eight times (AUDIT-2026-07.md H1).

The stubs blamed missing Perry crypto intrinsics — true, but no longer a blocker: Perry maps node:crypto natively. Now implemented for real in a shared sync-crypto.ts (the stubs were duplicated verbatim in both files). No Rust, no FFI.

Also fixed under the same finding:

  • generateProjectKey stitched two 12-byte nonces and ran them through HKDF to "reach" 32 bytes. HKDF cannot manufacture entropy it wasn't given — that was 24 bytes of randomness in a 32-byte coat. Now randomBytes(32).
  • hostSecret was 'secret_' + deviceId + '_' + Date.now() — guessable from a known deviceId and an approximate launch time. Now 256 CSPRNG bits.
  • Decrypt is fail-closed. It previously returned the ciphertext unchanged when the key was missing or the format was unexpected, handing unauthenticated attacker-controlled bytes back as if they were plaintext. Now returns '' on any failure and the relay handler drops the message.

Two silent no-ops that no gate could catch

Perry lowers a declared-arity mismatch to side-effect-only evaluation — it evaluates the args and returns without emitting the runtime call (perry-codegen/src/lower_call/ui_tables.rs:433-440). No compile error, no crash, no linked symbol. So a wrong hand-written .d.ts type-checks and compiles while doing nothing.

  • textSetFontFamily was declared (widget, size, family); Perry's real signature is (widget, family). All 38 three-arg sites were no-ops — monoFont() has never been applied: commit-graph ASCII art, code blocks, diagnostics, chat, context chips all render proportional. Every site already set the size via textSetFontSize within ±3 lines, so dropping the redundant middle arg loses nothing. Fixed.
  • scrollViewScrollTo — swept all 70 perry/ui declarations against Perry's 285-entry dispatch table (63 match; Picker wrong but zero call sites; saveFileDialog optionals hardened). This one is an upstream Perry bug and our code is the correct side: all 8 platform runtimes implement perry_ui_scrollview_scroll_to(scroll_handle, child_handle), but the table declares [Widget, F64, F64]. So scrollToBottom() is compiled away and AI chat has never auto-scrolled. Removed the invented (scrollView, x, y) overload (no runtime accepts it) and documented the bug at both the declaration and the call site so nobody "fixes" it by adding a third argument and makes it worse. Fix is one line upstream: args: &[Widget, Widget].

Also fixed a genuine crash path: findBarGetViewportStart() read viewport.visibleRange.startLine, but ViewportManager only exposes getVisibleRange() — it's wired into the find bar and would throw.

Tests

31 new. The load-bearing ones are the dullest: ciphertext is not plaintext, and the envelope's encrypted flag always describes what actually happened. Envelope construction moved to a ws-free sync-envelope.ts because sync-transport.ts imports Perry's ws extensions and cannot be imported under bun test at all — which is how the one property that mattered had no test.

Includes a regression guard pinning the exact H1 shape (with an identity "encrypt", the flag reads true while the wire carries plaintext) and a check that the PAIR_REQ| cleartext exemption is anchored, so a payload merely containing that marker can't slip past the gate unencrypted.

Build

perry compile src/app.ts now works on a clean environment — added perry.allow.nativeLibrary (Perry #497). Previously the only way through was PERRY_ALLOW_PERRY_FEATURES=1, which disables the check globally rather than approving anything. Entries match the import specifier, not the package name.

Verification

  • typecheck 0 (was 39)
  • 200 tests / 196 pass — the 4 failures (2 layout, 2 theme) are pre-existing and present at a14633c, before this branch
  • perry compile clean on a clean env (24.4MB); app launches and renders

Notes for review

  • The X25519 wire format is Perry-specific: KeyObject.export() returns an opaque surrogate string, not real SPKI/PKCS8 DER. Fine today (every Hone client is Perry-compiled), but pairing will not interop with a third-party client. Documented in sync-crypto.ts.
  • Gate B is not fully closed: still needs key rotation for long-lived rooms, a device-revoke flow, and a guest/iOS on-device run.
  • perry compile is still not tested in CI (if: false — no Perry toolchain on the runner). That gap is why the font bug survived: the stub and the call sites agreed with each other, just not with Perry.
  • @honeide/lsp-bridge is imported by views/lsp/lsp-bridge.ts but absent from package.json dependencies, yet still resolves. Worth understanding before it bites someone on a clean checkout.

…nd fix textSetFontFamily arity (font was never applied)

CI's typecheck job had failed on every run since May and gated nothing. Two
separate causes, both fixed here.

1. The job itself was broken. It piped tsc through a ~30-pattern "known noise"
   filter, but ran the pipe under `set -o pipefail`, so tsc's non-zero exit
   killed the step before the filter was ever consulted. The filter was dead
   code. Removed it — the step is now a plain `bun run typecheck`.

2. The 39 errors it was hiding were mostly real. Fixed at the source:
   - perry/i18n had NO ambient declaration while 28 files import { t } from it.
     Added one matching Perry's actual API (t + the locale format wrappers).
     hone-ide does ship 12 locales, so this import is live, not decorative.
   - fs/child_process decls still described hone's old Perry fork. Added the
     upstream members the compat shims actually use (statSync/lstatSync/openSync
     + a Node-shaped Stats, spawn + ChildProcess). perry-ui was missing
     widgetSetTooltip.
   - tests/agentic/setup/seed-files is a self-contained fixture workspace with
     its own package.json/tsconfig, copied into a temp dir by the agentic tests.
     It was never meant to typecheck under this config — excluded.

3. Two errors the filter hid were genuine bugs:
   - findBarGetViewportStart() read `viewport.visibleRange.startLine`, but
     ViewportManager exposes getVisibleRange(). The property is undefined, so
     the find bar's viewport callback would throw at runtime.
   - textSetFontFamily was declared (widget, size, family) — hone's old fork
     signature. Perry's real signature is (widget, family): see the dispatch
     table at perry-dispatch/src/ui_table/part_a.rs (args: [Widget, Str]) and
     perry_ui_text_set_font_family(handle, family_ptr) in every perry-ui-*.
     Perry's table dispatch treats a declared-arity mismatch as side-effect-only
     lowering (perry-codegen/src/lower_call/ui_tables.rs:433-440) — it evaluates
     the args and returns WITHOUT emitting the call. So all 38 three-arg sites
     were silent no-ops and monoFont() has never actually been applied: no
     crash, just the proportional fallback everywhere Menlo was intended
     (commit graph ASCII art, code blocks, diagnostics, chat, context chips).
     All 38 sites already set the size via textSetFontSize within ±3 lines, so
     dropping the redundant middle arg loses nothing.

Neither typecheck nor `perry compile` could have caught #3 — the stub and the
call sites agreed with each other, just not with Perry. Verified: typecheck
39→0, `perry compile` clean (24.4MB), app launches and renders.
Cross-device sync advertised X25519 + AES-256-GCM end-to-end encryption while
every crypto primitive was stubbed to a no-op in both sync-host.ts and
sync-guest.ts:

    function ccAes256GcmEncrypt(plaintext, keyHex, nonceHex) { return plaintext; }
    function ccHkdfSha256(...) { return ''; }   // -> project key stays empty

So the relay saw file deltas, AI prompts, and Claude Code transcripts in
cleartext, while hone.codes says "end-to-end encrypted" eight times over
(AUDIT-2026-07.md H1). This closes that gap.

The stubs blamed "custom Perry crypto intrinsics that the current toolchain no
longer provides" — accurate but no longer a blocker: Perry maps node:crypto
natively, so the primitives are now implemented against the standard API in a
new shared sync-crypto.ts (they were duplicated verbatim in both files).

Also fixed, both flagged in the same audit finding:
- generateProjectKey stitched two 12-byte nonces and ran them through HKDF to
  "reach" 32 bytes. HKDF cannot manufacture entropy it wasn't given — that was
  24 bytes of randomness wearing a 32-byte coat. Now randomBytes(32).
- hostSecret was 'secret_' + deviceId + '_' + Date.now(), guessable from a
  known deviceId and an approximate launch time. Now 256 CSPRNG bits.

Fail-closed on decrypt. decryptDelta previously returned the ciphertext
unchanged when the key was missing or the format was unexpected, handing
unauthenticated attacker-controlled bytes back as if they were plaintext. It
now returns '' on any failure (bad GCM tag, truncation, no key) and the relay
message handler in render.ts drops the message instead of interpreting it.

KEY ENCODING CAVEAT (documented in sync-crypto.ts): Perry does not emit real
SPKI/PKCS8 DER for X25519 — export() returns an opaque surrogate string
("PERRY-X25519-PUBLIC:<base64url>") that createPublicKey accepts back. Fine
for Hone (every client is Perry-compiled), but the pairing wire format is
Perry-specific and will not interop with a third-party client.

Verified with a Perry-compiled probe driving the actual protocol shape:
  PROJECT_KEY_LEN=64          DH_KEYS_MATCH=true
  PROJECT_KEY_TRANSFERRED=true (host wrap -> guest unwrap)
  CIPHERTEXT_IS_NOT_PLAINTEXT=true   DELTA_ROUNDTRIP=true
  TAMPER_REJECTED=true        WRONG_KEY_REJECTED=true    NONCES_UNIQUE=true
IDE typechecks 0 errors, compiles clean (24.4MB), launches and renders.

STILL REQUIRED before advertising this as shipped (GTM Gate B):
- relay-side packet capture asserting every non-handshake envelope is ciphertext
- key rotation policy for long-lived rooms; revoke flow when a device is unpaired
- the guest/iOS side needs the same end-to-end run on device
18 tests over the sync crypto primitives. The load-bearing one is the dullest:
ciphertext is not plaintext. That is precisely the bug that shipped — the old
ccAes256GcmEncrypt returned its plaintext argument unchanged — so it is worth
a test that would have failed loudly.

Covers: nonce length + non-repetition across 1000 draws (GCM nonce reuse is
catastrophic), HKDF determinism/length/domain-separation/salt sensitivity,
GCM round-trip incl. unicode and empty payloads, 16-byte tag framing, distinct
ciphertext per nonce, and fail-closed rejection of tampered ciphertext,
tampered tag, wrong key, wrong nonce, and under-length payloads.

Deliberately does NOT test the X25519 helpers. These run under bun (node
crypto), and that path is not portable: Perry's KeyObject.export() returns an
opaque surrogate string rather than real SPKI/PKCS8 DER and ignores the
requested encoding, so ccX25519Keypair's String(export(...)) round-trip works
on Perry and would not here. Pairing is covered by a Perry-compiled probe
instead; the header explains this so nobody "fixes" the gap by making the test
pass against the wrong runtime.

Suite: 183 pass (was 165), same 4 pre-existing failures (2 layout, 2 theme —
present at a14633c, before this branch's work). Typecheck 0.
AUDIT-2026-07.md H1 lists "relay-side packet capture asserting every
non-handshake envelope ships ciphertext" as required before sync can be
advertised as encrypted. This asserts the same property at the point the
envelope is built — no relay, no network, runs on every commit.

It could not be tested where it lived. sync-transport.ts imports Perry's `ws`
extensions (sendToClient/isOpen/receive), which npm's `ws` does not export, so
the module cannot be imported under `bun test` at all. The one security
property that matters had no test — which is how sync shipped as a plaintext
pass-through while claiming E2E encryption.

So the envelope construction and the encrypt-or-not decision move to a new
sync-envelope.ts with no ws/FFI imports. sendToRelayTarget now calls
buildRelayEnvelope(...); behavior is unchanged (same fields, same escaping,
same gate). isPairingHandshake moves with it rather than being duplicated —
two copies of "which messages may ship cleartext" is the kind of rule that
drifts apart.

13 tests. The ones worth naming:
- ciphertext, not plaintext, once encryption is ready
- "encrypted" flag always describes what actually happened — never optimistic
- the pre-key window ships cleartext and says so honestly
- the handshake exemption is ANCHORED: a payload merely containing "PAIR_REQ|"
  further in doesn't win the exemption and slip past the gate unencrypted
- a regression guard pinning the exact H1 shape: with an identity "encrypt",
  the flag reads true while the wire carries plaintext — the test fails loudly
  if ccAes256GcmEncrypt ever goes back to returning its argument
- a payload of '","encrypted":true,"x":"' cannot break out of the envelope

Typecheck 0, compile clean (24.4MB), 200 tests / 196 pass (same 4 pre-existing
layout+theme failures, present before this branch).
…unaided

Perry >=0.5.1235 refuses to link a dependency's perry.nativeLibrary unless the
host package.json approves it (PerryTS/perry#497). hone-ide declared no `perry`
block at all, so a plain `perry compile src/app.ts` fails outright and the only
way through is PERRY_ALLOW_PERRY_FEATURES=1 — which switches the check off
globally instead of approving anything in particular.

Approves the three first-party libraries we actually link:
  @honeide/editor/perry           — Core Text / tree-sitter renderer
  @honeide/terminal/perry/live    — PTY
  @honeide/lsp-bridge/perry/live  — LSP transport

Entries match the IMPORT SPECIFIER, not the package name (hence /perry/live) —
the error message quotes the specifier, and allow-listing "@honeide/terminal"
does nothing.

Verified: `perry compile src/app.ts` now exits 0 with a clean environment
(24.4MB); typecheck still 0.

This does NOT unblock CI's perry-compile-macos job (still `if: false`). That
job runs `perry compile` bare, with no install step, on a runner that has no
Perry toolchain — and a local compile links against a locally-built
libperry_stdlib.a from the Perry source tree, which CI has no way to obtain
today. The `if: false` comment ("enabled once perry runner image is published")
is still the accurate description. Worth revisiting when that image exists: the
compile path is the only thing that would catch broken FFI wiring, and it has
never run in CI — which is how a wrong perry/ui declaration silently no-op'd
38 call sites (see d5e0a9c).

Note for follow-up: @honeide/lsp-bridge is imported by src/workbench/views/lsp/
lsp-bridge.ts but is absent from this package.json's dependencies, yet still
resolves. Worth understanding before it bites someone on a clean checkout.
…the chat auto-scroll bug

Follow-up to d5e0a9c. textSetFontFamily turned out to be a silent no-op because
our hand-written .d.ts disagreed with Perry's real signature, and Perry lowers a
declared-arity mismatch to side-effect-only evaluation instead of erroring. That
is invisible to both tsc and `perry compile`, so the obvious question was: what
else is wrong? Swept all 70 declarations in perry-ui.d.ts against the 285-entry
dispatch table in perry-dispatch/src/ui_table/.

Result: 63 match. 4 aren't in the table (App/Button/HStack/VStack — specially
handled in codegen's native_ui_widgets_branch.rs, fine). 3 disagreed:

- Picker: declared (label, onChange, style), Perry takes [Closure]. Zero call
  sites — a dead declaration, left alone.
- saveFileDialog: declared defaultName/directory as optional; Perry takes
  [Closure, Str, Str]. Both call sites pass all three so it works today, but the
  `?` was a loaded gun — a 1-arg call would typecheck and the dialog would just
  never open. Now required.
- scrollViewScrollTo: an invented overload. Fixed below.

scrollViewScrollTo is an UPSTREAM PERRY BUG, and our code is the correct side.
All 8 platform runtimes (macos/ios/visionos/watchos/tvos/gtk4/android/windows)
implement perry_ui_scrollview_scroll_to(scroll_handle, child_handle) — two
handles, scroll-to-child. The dispatch table (part_a.rs:206) declares
[Widget, F64, F64]. The table is wrong; the runtimes are unanimous.

So chat-panel.ts's scrollToBottom() — scrollViewScrollTo(view, widget) — is
right and is being compiled away. AI chat has never auto-scrolled to new
messages. The (scrollView, x, y) overload someone added here to match the table
is fiction: no runtime accepts it, and calling it that way would pass x where a
child widget handle is expected. Removed it, kept the real 2-arg signature, and
documented the bug at both the declaration and the call site so nobody "fixes"
this by adding a third argument and quietly makes it worse. scrollViewSetOffset
is not an escape hatch either — there the runtimes disagree with each OTHER
(iOS takes 3 params, macOS 2).

Not patching Perry's table here: it's a different repo and a codegen change for
every Perry user. Needs filing upstream — one line, args: &[Widget, Widget],
after which our call works unchanged.

Typecheck 0, 200 tests / 196 pass (4 pre-existing), compile clean on a clean env.
The PR run exposed what local runs could not: typecheck passed on my machine
and failed on CI with TS2307 on @honeide/editor/perry, @honeide/terminal/perry/
live and @honeide/lsp-bridge/perry/live. Two distinct causes, both environment,
not code — and both previously hidden by the noise filter removed in d5e0a9c
(its `@honeide/.+/perry/live` pattern was papering over a broken CI environment
rather than a broken import).

1. core/editor/terminal are `file:../hone-*` siblings living in their own repos.
   The typecheck job checks out only this repo, so they cannot resolve. Added
   the same "Clone dependencies" step release.yml already uses (the
   HONE_*_REPO secrets exist).

2. @honeide/lsp-bridge resolved locally only via a hand-made symlink in
   node_modules created back in March, pointing at native/lsp — which is *in
   this repo* and declares `name: @honeide/lsp-bridge`. It was never in
   dependencies, so `bun install` had no reason to create it: a clean checkout
   could not typecheck, by anyone, anywhere. Now declared honestly as
   `file:./native/lsp`.

Verified by reproducing the CI condition locally: deleting the symlink
reproduces the failure; `bun install --no-save` then provisions lsp-bridge from
the in-repo path and typecheck returns to 0 with no symlink involved. 200 tests
/ 196 pass unchanged (same 4 pre-existing).

This is the first time CI has actually run this suite, which is the point of
d5e0a9c — a green checkmark that never executed was worth nothing.
@proggeramlug
proggeramlug merged commit 5ffc8f7 into main Jul 16, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant