diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be49f52..131e706 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,25 +34,34 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - run: bun install --no-save || true - # `perry/i18n` and `@honeide/*/perry/live` resolution warnings are - # expected (per CLAUDE.md / followup.md) — they're Perry FFI manifest - # imports the typecheck can't resolve. Filter them so the action - # doesn't fail on noise that's been audited. - - name: typecheck (filtered) + # @honeide/core|editor|terminal are `file:../hone-*` siblings that live in + # their own repos, so a lone checkout of this repo cannot resolve them and + # the typecheck fails with TS2307 on CI while passing on a dev machine. + # That mismatch is why the old noise filter listed `@honeide/.+/perry/live` + # as "expected" — it was papering over a broken CI environment, not a + # broken import. release.yml already clones these; do the same here. + - name: Clone dependencies run: | - set -o pipefail - bun run typecheck 2>&1 | tee tc.out - # Count errors that aren't pre-existing noise. - REAL=$(grep -E "error" tc.out | grep -vE "perry/i18n|@honeide/.+/perry/live|widgetSetTooltip|clipboardWrite|editorSet[A-Z]|getContent|setLineBackground|clearLineBackgrounds|clearFindHighlights|setFindHighlights|getCharWidth|visibleRange|getCursorLine|getCursorColumn|setCursorPosition|ViewportManager|executeCommand|clipboardRead|EditorOptions|getCurrentSelection|dispose|setLanguage|getFindMatch|getFindCurrent|getScrollTop|setScrollTop|getCursorPos|Cannot find name 't[0-9]'|setLineDiagnostics|clearDiagnostics|pushDecorations|setThemeMode" | wc -l) - echo "Non-noise errors: $REAL" - # Tolerate up to N pre-existing noise patterns we haven't fully - # categorized. Bump down as the project quiets the rest. - if [ "$REAL" -gt 0 ]; then - echo "::error::New typecheck errors detected outside the noise filter." - grep -E "error" tc.out | grep -vE "perry/i18n|@honeide/.+/perry/live|widgetSetTooltip|clipboardWrite|editorSet[A-Z]|getContent|setLineBackground|clearLineBackgrounds|clearFindHighlights|setFindHighlights|getCharWidth|visibleRange|getCursorLine|getCursorColumn|setCursorPosition|ViewportManager|executeCommand|clipboardRead|EditorOptions|getCurrentSelection|dispose|setLanguage|getFindMatch|getFindCurrent|getScrollTop|setScrollTop|getCursorPos|Cannot find name 't[0-9]'|setLineDiagnostics|clearDiagnostics|pushDecorations|setThemeMode" | head -20 - exit 1 - fi + git clone --depth 1 "$HONE_CORE_REPO" ../hone-core + git clone --depth 1 "$HONE_EDITOR_REPO" ../hone-editor + git clone --depth 1 "$HONE_TERMINAL_REPO" ../hone-terminal + rm -rf ../hone-core/.git ../hone-editor/.git ../hone-terminal/.git + env: + HONE_CORE_REPO: ${{ secrets.HONE_CORE_REPO }} + HONE_EDITOR_REPO: ${{ secrets.HONE_EDITOR_REPO }} + HONE_TERMINAL_REPO: ${{ secrets.HONE_TERMINAL_REPO }} + - run: bun install --no-save || true + # The typecheck is clean and gates for real. The previous version of this + # step piped tsc through a ~30-pattern "known noise" filter, but the pipe + # ran under `set -o pipefail`, so a non-zero tsc exit killed the step + # before the filter was ever consulted — the job had failed on every run + # since May and gated nothing. The noise it filtered is gone: `perry/i18n` + # and `widgetSetTooltip` now have real declarations in src/types/, the + # agentic seed fixtures are excluded in tsconfig, and the ViewportManager / + # textSetFontFamily errors it hid were genuine bugs (now fixed). Keep this + # step plain — if it goes red, something is actually broken. + - name: typecheck + run: bun run typecheck perry-compile-macos: name: Perry compile macOS diff --git a/.gitignore b/.gitignore index 8bc086d..52226f2 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,23 @@ PROJECT_PLAN.md stderr*.log screenshot*.png .perry/ +.perry-cache/ .claude/ + +# Ad-hoc Perry compile outputs (app bundles, web/html, JS bundle, debug PE) +*.app/ +*.html +__perry_js_bundle.js +hone-ide-debug + +# Bare-name one-off Perry compile outputs +HoneIDE +editor-component +hone-ide-baseline +hone-min +paths +render +settings +sidebar-render +telemetry +ui-helpers diff --git a/hone-ide-debug b/hone-ide-debug deleted file mode 100644 index 517f64e..0000000 Binary files a/hone-ide-debug and /dev/null differ diff --git a/package.json b/package.json index a2e19af..7011860 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@honeide/ide", "version": "0.1.0", + "private": true, "bundleId": "com.hone.ide", "description": "Hone IDE — AI-native code editor for all platforms", "main": "src/app.ts", @@ -15,10 +16,21 @@ "dependencies": { "@honeide/core": "file:../hone-core", "@honeide/editor": "file:../hone-editor", + "@honeide/lsp-bridge": "file:./native/lsp", "@honeide/terminal": "file:../hone-terminal" }, "devDependencies": { "@types/bun": "latest", "typescript": "^5.7.0" + }, + "perry": { + "//": "Perry >=0.5.1235 refuses to link a dependency's perry.nativeLibrary unless the host allow-lists it (PerryTS/perry#497). Both of these are our own first-party crates — the editor's Core Text/tree-sitter renderer and the terminal's PTY. Without this, `perry compile` fails and the only way through is PERRY_ALLOW_PERRY_FEATURES=1, which disables the check globally rather than approving these two. Entries must match the IMPORT SPECIFIER, not the package name — hence /perry/live for the terminal.", + "allow": { + "nativeLibrary": [ + "@honeide/editor/perry", + "@honeide/terminal/perry/live", + "@honeide/lsp-bridge/perry/live" + ] + } } } diff --git a/src/types/perry-env.d.ts b/src/types/perry-env.d.ts index be09a3e..381f664 100644 --- a/src/types/perry-env.d.ts +++ b/src/types/perry-env.d.ts @@ -12,13 +12,31 @@ // --------------------------------------------------------------------------- declare module 'fs' { + /** Node-shaped stat result. Perry populates the standard predicate methods + * and *Ms timestamp fields (perry-runtime/src/fs/stats.rs). */ + export interface Stats { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; + size: number; + mtimeMs: number; + ctimeMs: number; + birthtimeMs: number; + mode: number; + } + export function readFileSync(path: string, encoding?: string): string; export function writeFileSync(path: string, data: string, encoding?: string): void; export function readdirSync(path: string): string[]; export function existsSync(path: string): boolean; export function mkdirSync(path: string, options?: { recursive?: boolean }): void; export function unlinkSync(path: string): void; - /** Perry extension — check if path is a directory. */ + export function statSync(path: string, options?: object): Stats; + export function lstatSync(path: string, options?: object): Stats; + /** Open a file and return its fd. Used to redirect background-process output. */ + export function openSync(path: string, flags: string): number; + /** Perry extension — check if path is a directory. Prefer `../fs-compat`'s + * `isDirectory`, which shims this over statSync for the current toolchain. */ export function isDirectory(path: string): boolean; } @@ -54,10 +72,21 @@ declare module 'node:path' { } declare module 'child_process' { + /** Handle returned by `spawn`. Only the members hone actually uses are + * declared — see `../process-compat`'s spawnBackground. */ + export interface ChildProcess { + pid?: number; + unref(): void; + } + export function execSync(command: string, options?: object): string; /** Argv-array spawn — no shell, args passed directly to the executable. Safe for untrusted inputs. */ export function spawnSync(command: string, args: string[], options?: object): { stdout: string; stderr: string; status: number }; - /** Perry extension — spawn a background process. */ + /** Node-standard async spawn. Prefer `../process-compat`'s `spawnBackground` + * wrapper for fire-and-forget launches. */ + export function spawn(command: string, args: string[], options?: object): ChildProcess; + /** Perry extension — spawn a background process. Prefer `../process-compat`'s + * `spawnBackground`, which reimplements this over standard `spawn`. */ export function spawnBackground(command: string, args: string[], options?: string | object): { pid: number; handleId: number }; } @@ -122,6 +151,105 @@ declare module 'perry/thread' { export function parallelFilter(data: T[], predicate: (item: T) => boolean): T[]; } +// --------------------------------------------------------------------------- +// Buffer / node:crypto +// +// Perry maps node:crypto natively (perry-stdlib/src/crypto/*). Only the surface +// hone actually uses is declared. Signatures below were verified empirically +// against Perry-compiled probes, not copied from @types/node — notably, +// X25519 `export()` returns an opaque Perry surrogate string rather than real +// SPKI/PKCS8 DER (see src/workbench/sync-crypto.ts). +// --------------------------------------------------------------------------- + +interface PerryBuffer { + toString(encoding?: string): string; + length: number; +} + +declare var Buffer: { + from(data: string, encoding?: string): PerryBuffer; + from(data: ArrayBuffer | PerryBuffer): PerryBuffer; + alloc(size: number): PerryBuffer; +}; + +declare module 'crypto' { + interface Hmac { + update(data: string | PerryBuffer): Hmac; + digest(encoding: string): string; + } + interface Hash { + update(data: string | PerryBuffer): Hash; + digest(encoding: string): string; + } + interface Cipher { + update(data: string, inputEncoding: string, outputEncoding: string): string; + final(outputEncoding: string): string; + /** GCM only — must be read after final(). */ + getAuthTag(): PerryBuffer; + } + interface Decipher { + update(data: string, inputEncoding: string, outputEncoding: string): string; + /** GCM only — THROWS if the auth tag does not verify. */ + final(outputEncoding: string): string; + setAuthTag(tag: PerryBuffer): void; + } + /** Opaque key handle. Perry's export() yields a surrogate string, not DER. */ + interface KeyObject { + export(options: { type: string; format: string }): PerryBuffer; + } + + export function randomBytes(size: number): PerryBuffer; + export function createHash(algorithm: string): Hash; + export function createHmac(algorithm: string, key: string | PerryBuffer): Hmac; + export function hkdfSync(digest: string, ikm: PerryBuffer, salt: PerryBuffer, info: PerryBuffer, keylen: number): ArrayBuffer; + export function generateKeyPairSync(type: string): { publicKey: KeyObject; privateKey: KeyObject }; + export function diffieHellman(options: { privateKey: KeyObject; publicKey: KeyObject }): PerryBuffer; + export function createCipheriv(algorithm: string, key: PerryBuffer, iv: PerryBuffer): Cipher; + export function createDecipheriv(algorithm: string, key: PerryBuffer, iv: PerryBuffer): Decipher; + export function createPublicKey(options: { key: PerryBuffer; format: string; type: string }): KeyObject; + export function createPrivateKey(options: { key: PerryBuffer; format: string; type: string }): KeyObject; + + const _default: { + randomBytes: typeof randomBytes; + createHash: typeof createHash; + createHmac: typeof createHmac; + hkdfSync: typeof hkdfSync; + generateKeyPairSync: typeof generateKeyPairSync; + diffieHellman: typeof diffieHellman; + createCipheriv: typeof createCipheriv; + createDecipheriv: typeof createDecipheriv; + createPublicKey: typeof createPublicKey; + createPrivateKey: typeof createPrivateKey; + }; + export default _default; +} + +declare module 'node:crypto' { + export * from 'crypto'; +} + +// --------------------------------------------------------------------------- +// Perry i18n (compile-time localization) +// --------------------------------------------------------------------------- + +declare module 'perry/i18n' { + /** Localize a string key. With no [i18n] config in perry.toml the key is + * returned as-is. `{param}` placeholders are filled from `params`; a `count` + * param selects the CLDR plural variant for the active locale. */ + export function t(key: string, params?: { [key: string]: string | number }): string; + + /** Locale-aware format wrappers. Each returns a display string for the + * active locale (default `en`). Date/time wrappers take epoch milliseconds. */ + export function Currency(value: number): string; + export function Percent(value: number): string; + export function FormatNumber(value: number): string; + export function ShortDate(epochMs: number): string; + export function LongDate(epochMs: number): string; + export function FormatTime(epochMs: number): string; + /** Bypass locale formatting — emit the value verbatim. */ + export function Raw(value: number): string; +} + // --------------------------------------------------------------------------- // bun:test (for test files) // --------------------------------------------------------------------------- diff --git a/src/types/perry-ui.d.ts b/src/types/perry-ui.d.ts index 1523e78..066b452 100644 --- a/src/types/perry-ui.d.ts +++ b/src/types/perry-ui.d.ts @@ -38,7 +38,10 @@ declare module 'perry/ui' { export function textSetColor(text: unknown, r: number, g: number, b: number, a: number): void; export function textSetFontSize(widget: unknown, size: number): void; export function textSetFontWeight(widget: unknown, size: number, weight: number): void; - export function textSetFontFamily(widget: unknown, size: number, family: string): void; + /** Set the font family. Size is set separately via textSetFontSize — Perry's + * signature is (widget, family), unlike textSetFontWeight which takes a size. + * Passing an extra arg makes Perry's table dispatch silently skip the call. */ + export function textSetFontFamily(widget: unknown, family: string): void; export function textSetString(widget: unknown, value: string): void; export function textSetWraps(widget: unknown, wraps: number): void; @@ -61,6 +64,8 @@ declare module 'perry/ui' { export function widgetSetHeight(widget: unknown, height: number): void; export function widgetSetHugging(widget: unknown, priority: number): void; export function widgetSetHidden(widget: unknown, hidden: number): void; + /** Native tooltip on hover (NSView.setToolTip on macOS — VoiceOver picks it up). */ + export function widgetSetTooltip(widget: unknown, text: string): void; export function widgetSetContextMenu(widget: unknown, menu: unknown): void; export function widgetMatchParentHeight(widget: unknown): void; export function widgetMatchParentWidth(widget: unknown): void; @@ -72,7 +77,15 @@ declare module 'perry/ui' { // ScrollView mutations export function scrollViewSetChild(scrollView: unknown, child: unknown): void; /** Scroll to coordinates. */ - export function scrollViewScrollTo(scrollView: unknown, x: number, y: number): void; + /** Scroll so `child` is visible. Perry's 8 platform runtimes all implement + * perry_ui_scrollview_scroll_to(scroll_handle, child_handle) — this 2-arg + * form is the real signature. + * + * ⚠️ It does not currently work: Perry's dispatch table wrongly declares + * [Widget, F64, F64], and an arity mismatch lowers to a silent no-op. There + * is no (scrollView, x, y) variant to use instead — the runtime takes two + * handles, so a numeric x would be read as a widget. Blocked upstream; see + * scrollToBottom() in views/ai-chat/chat-panel.ts. */ /** Scroll to make a widget visible. */ export function scrollViewScrollTo(scrollView: unknown, widget: unknown): void; @@ -100,7 +113,11 @@ declare module 'perry/ui' { // Dialogs export function openFolderDialog(callback: (path: string) => void): void; export function openFileDialog(callback: (path: string) => void): void; - export function saveFileDialog(callback: (path: string) => void, defaultName?: string, directory?: string): void; + /** All three args are REQUIRED — Perry's table declares [Closure, Str, Str]. + * They were marked optional here, but omitting one is an arity mismatch, and + * Perry lowers those to a silent no-op rather than an error: the dialog would + * simply never open. Pass '' rather than dropping an argument. */ + export function saveFileDialog(callback: (path: string) => void, defaultName: string, directory: string): void; export function pollOpenFile(): string; // Menu diff --git a/src/window.ts b/src/window.ts deleted file mode 100644 index 43fe78c..0000000 --- a/src/window.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Window management — handles window lifecycle, dimensions, and - * multi-window support (iPad Stage Manager, macOS, etc.). - */ - -import type { Platform, PlatformContext } from './platform'; - -// Perry window management APIs -declare function perry_create_window(title: string, width: number, height: number): number; -declare function perry_close_window(handle: number): void; -declare function perry_set_window_title(handle: number, title: string): void; -declare function perry_set_window_size(handle: number, width: number, height: number): void; -declare function perry_get_window_size(handle: number): { width: number; height: number }; -declare function perry_set_fullscreen(handle: number, fullscreen: boolean): void; -declare function perry_is_fullscreen(handle: number): boolean; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface WindowConfig { - title: string; - width: number; - height: number; - minWidth: number; - minHeight: number; -} - -export interface WindowState { - handle: number; - title: string; - width: number; - height: number; - fullscreen: boolean; -} - -// --------------------------------------------------------------------------- -// Default window sizes per platform -// --------------------------------------------------------------------------- - -export function getDefaultWindowConfig(platform: Platform): WindowConfig { - switch (platform) { - case 'macos': - case 'windows': - case 'linux': - return { title: 'Hone', width: 1400, height: 900, minWidth: 800, minHeight: 600 }; - case 'ipados': - // iPad defaults to full screen; these are for Stage Manager - return { title: 'Hone', width: 1024, height: 768, minWidth: 600, minHeight: 400 }; - case 'ios': - case 'android': - // Mobile uses the full screen — dimensions are informational - return { title: 'Hone', width: 0, height: 0, minWidth: 0, minHeight: 0 }; - case 'web': - return { title: 'Hone', width: 1280, height: 800, minWidth: 600, minHeight: 400 }; - } -} - -// --------------------------------------------------------------------------- -// Window manager -// --------------------------------------------------------------------------- - -const _windows: Map = new Map(); -const _listeners: Set<(windows: WindowState[]) => void> = new Set(); -let _primaryHandle: number = -1; - -/** - * Create the primary application window. - */ -export function createPrimaryWindow(ctx: PlatformContext): WindowState { - const config = getDefaultWindowConfig(ctx.platform); - - // On mobile, use the screen dimensions directly - const width = ctx.deviceClass === 'phone' || ctx.deviceClass === 'tablet' - ? ctx.screen.width - : config.width; - const height = ctx.deviceClass === 'phone' || ctx.deviceClass === 'tablet' - ? ctx.screen.height - : config.height; - - let handle: number; - try { - handle = perry_create_window(config.title, width, height); - } catch { - // Test environment — use a mock handle - handle = 1; - } - - const state: WindowState = { - handle, - title: config.title, - width, - height, - fullscreen: false, - }; - - _windows.set(handle, state); - _primaryHandle = handle; - notifyListeners(); - return state; -} - -export function getPrimaryWindow(): WindowState | null { - return _windows.get(_primaryHandle) ?? null; -} - -export function getAllWindows(): WindowState[] { - return Array.from(_windows.values()); -} - -export function setWindowTitle(handle: number, title: string): void { - const win = _windows.get(handle); - if (!win) return; - win.title = title; - try { perry_set_window_title(handle, title); } catch { /* test env */ } - notifyListeners(); -} - -export function updateWindowSize(handle: number, width: number, height: number): void { - const win = _windows.get(handle); - if (!win) return; - win.width = width; - win.height = height; - notifyListeners(); -} - -export function closeWindow(handle: number): void { - try { perry_close_window(handle); } catch { /* test env */ } - _windows.delete(handle); - notifyListeners(); -} - -export function onWindowsChange(listener: (windows: WindowState[]) => void): () => void { - _listeners.add(listener); - return () => { _listeners.delete(listener); }; -} - -/** Reset all windows. Used in tests. */ -export function clearWindows(): void { - _windows.clear(); - _primaryHandle = -1; -} - -function notifyListeners(): void { - const windows = getAllWindows(); - for (const fn of _listeners) fn(windows); -} diff --git a/src/workbench/layout/index.ts b/src/workbench/layout/index.ts deleted file mode 100644 index 410acab..0000000 --- a/src/workbench/layout/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './grid'; -export * from './tab-manager'; -export * from './panel-registry'; -export * from './activity-bar'; -export * from './status-bar'; diff --git a/src/workbench/render.ts b/src/workbench/render.ts index cb8a964..53562aa 100644 --- a/src/workbench/render.ts +++ b/src/workbench/render.ts @@ -835,7 +835,7 @@ function findBarGetCharWidth(): number { function findBarGetViewportStart(): number { if (editorReady < 1) return 0; - return editorInstance.viewModel.viewport.visibleRange.startLine; + return editorInstance.viewModel.viewport.getVisibleRange().startLine; } function findBarSetLineBg(line: number, r: number, g: number, b: number, a: number): void { @@ -4691,7 +4691,7 @@ function renderEditorArea(): unknown { // indent). Hidden when no scope is detected so the editor reclaims the row. stickyScrollLabel = Text(''); textSetFontSize(stickyScrollLabel, 11); - textSetFontFamily(stickyScrollLabel, 11, monoFont()); + textSetFontFamily(stickyScrollLabel, monoFont()); setFg(stickyScrollLabel, getSecondaryTextColor()); stickyScrollRow = HStackWithInsets(4, 2, 12, 2, 12); setBg(stickyScrollRow, getEditorBackground()); @@ -6429,6 +6429,10 @@ function onRelayMessageImpl(data: string): void { } if (isEncrypted > 0) { payload = decryptIncomingPayload(payload, 1); + // Empty result = the GCM auth tag didn't verify (tampered, truncated, or + // wrong project key), or we have no key at all. Drop the message rather + // than fall through and interpret attacker-controlled bytes. + if (payload.length === 0) return; } // Handle PAIR_REQ|code|deviceId|deviceName|guestPubKey (only from others) diff --git a/src/workbench/sync-crypto.ts b/src/workbench/sync-crypto.ts new file mode 100644 index 0000000..19a793c --- /dev/null +++ b/src/workbench/sync-crypto.ts @@ -0,0 +1,104 @@ +/** + * Sync crypto primitives — X25519 pairing + AES-256-GCM payload encryption. + * + * These six functions back the cross-device sync E2E encryption protocol used by + * sync-host.ts (desktop) and sync-guest.ts (mobile). They were previously stubbed + * to no-ops in BOTH files — ccAes256GcmEncrypt returned its plaintext unchanged — + * because they had been written against custom Perry crypto intrinsics + * (ccX25519Keypair et al) that the toolchain stopped providing. The result was + * that sync shipped as a plaintext pass-through while the product claimed E2E + * encryption (AUDIT-2026-07.md H1). + * + * Perry now maps node:crypto natively, so these are implemented against the + * standard API. Verified under a Perry-compiled probe: DH agreement, HKDF, + * GCM round-trip, and key export→wire→import all behave. + * + * KEY ENCODING — IMPORTANT: + * Perry does not emit real SPKI/PKCS8 DER for X25519. `export()` returns an + * opaque surrogate string ("PERRY-X25519-PUBLIC:"), and + * createPublicKey/createPrivateKey accept that same surrogate back. So the + * public keys on the wire are Perry-specific, NOT interoperable with a + * non-Perry client. That is fine today (every Hone client is Perry-compiled) + * but it means a third-party client cannot pair without matching this format. + * The `*Hex` parameter names below are historical — key material is passed + * through opaquely; only nonces and derived symmetric keys are really hex. + */ + +import crypto from 'crypto'; + +/** 12-byte random nonce as hex (24 chars) — the GCM IV. Never reuse one under a key. */ +export function ccRandomNonce(): string { + return crypto.randomBytes(12).toString('hex'); +} + +/** HKDF-SHA256. ikm/salt are hex; info is a plain domain-separation label. + * Returns `length` bytes as hex. */ +export function ccHkdfSha256(ikmHex: string, saltHex: string, info: string, length: number): string { + const ikm = Buffer.from(ikmHex, 'hex'); + let salt = Buffer.alloc(0); + if (saltHex.length > 0) salt = Buffer.from(saltHex, 'hex'); + const okm = crypto.hkdfSync('sha256', ikm, salt, Buffer.from(info), length); + return Buffer.from(okm).toString('hex'); +} + +/** Fresh ephemeral X25519 keypair as {"publicKey":"...","secretKey":"..."}. + * Both values are opaque Perry surrogates (see KEY ENCODING above). */ +export function ccX25519Keypair(): string { + const kp: any = crypto.generateKeyPairSync('x25519'); + const pub = String(kp.publicKey.export({ type: 'spki', format: 'der' })); + const sec = String(kp.privateKey.export({ type: 'pkcs8', format: 'der' })); + let out = '{"publicKey":"'; + out += pub; + out += '","secretKey":"'; + out += sec; + out += '"}'; + return out; +} + +/** X25519 ECDH. Returns the 32-byte shared secret as hex. + * Raw ECDH output — always run it through ccHkdfSha256 before use as a key. */ +export function ccX25519SharedSecret(secretKey: string, publicKey: string): string { + const priv: any = crypto.createPrivateKey({ key: Buffer.from(secretKey), format: 'der', type: 'pkcs8' }); + const pub: any = crypto.createPublicKey({ key: Buffer.from(publicKey), format: 'der', type: 'spki' }); + const shared: any = crypto.diffieHellman({ privateKey: priv, publicKey: pub }); + return Buffer.from(shared).toString('hex'); +} + +/** AES-256-GCM. Returns hex(ciphertext) || hex(16-byte auth tag). + * The tag is appended rather than returned separately so the wire format stays + * a single string — decrypt splits the last 32 hex chars back off. */ +export function ccAes256GcmEncrypt(plaintext: string, keyHex: string, nonceHex: string): string { + const key = Buffer.from(keyHex, 'hex'); + const iv = Buffer.from(nonceHex, 'hex'); + const c: any = crypto.createCipheriv('aes-256-gcm', key, iv); + let ct = c.update(plaintext, 'utf8', 'hex'); + ct += c.final('hex'); + const tag: any = c.getAuthTag(); + let out = ct; + out += Buffer.from(tag).toString('hex'); + return out; +} + +/** AES-256-GCM open. THROWS if the auth tag does not verify. + * + * Fail-closed is the whole point of GCM: a tampered or truncated payload must + * not be handed back to the caller as if it were plaintext. Callers decrypting + * untrusted relay traffic must catch and DROP the message — never fall back to + * treating the ciphertext as cleartext. */ +export function ccAes256GcmDecrypt(encrypted: string, keyHex: string, nonceHex: string): string { + if (encrypted.length < 32) throw new Error('ciphertext shorter than auth tag'); + const tagHex = encrypted.slice(encrypted.length - 32); + const ctHex = encrypted.slice(0, encrypted.length - 32); + const key = Buffer.from(keyHex, 'hex'); + const iv = Buffer.from(nonceHex, 'hex'); + const d: any = crypto.createDecipheriv('aes-256-gcm', key, iv); + d.setAuthTag(Buffer.from(tagHex, 'hex')); + let pt = d.update(ctHex, 'hex', 'utf8'); + pt += d.final('utf8'); + return pt; +} + +/** 32 bytes of CSPRNG output as hex — for project keys and host secrets. */ +export function ccRandomKeyHex(): string { + return crypto.randomBytes(32).toString('hex'); +} diff --git a/src/workbench/sync-envelope.ts b/src/workbench/sync-envelope.ts new file mode 100644 index 0000000..152e51b --- /dev/null +++ b/src/workbench/sync-envelope.ts @@ -0,0 +1,102 @@ +/** + * Relay envelope construction — the single point where sync decides whether a + * payload goes out encrypted. + * + * Split out of sync-transport.ts so this decision is testable. sync-transport + * imports Perry's `ws` module (sendToClient/isOpen/receive — Perry extensions + * that npm's `ws` doesn't export), so it cannot be imported under `bun test` at + * all. That meant the one security property that actually matters — "payloads + * leave this device as ciphertext" — had no test, which is exactly how sync + * shipped as a plaintext pass-through while claiming E2E encryption + * (AUDIT-2026-07.md H1). + * + * Keep this file free of `ws`/FFI imports so it stays testable. + */ + +/** + * Pairing handshake messages bootstrap the project key, so they are the one + * category that legitimately ships cleartext. Everything else must be encrypted + * once the key exchange has completed. + * + * Anchored with indexOf(...) === 0 rather than a substring search: a payload + * that merely *contains* "PAIR_REQ|" further in must not win the exemption. + */ +export function isPairingHandshake(payload: string): number { + if (payload.length < 7) return 0; + if (payload.indexOf('PAIR_REQ|') === 0) return 1; + if (payload.indexOf('PAIR_OK|') === 0) return 1; + if (payload.indexOf('PAIR_NO|') === 0) return 1; + return 0; +} + +/** JSON-escape the payload for embedding in the envelope string. */ +function escapePayload(txPayload: string): string { + let needsEscape = 0; + for (let i = 0; i < txPayload.length; i++) { + const ch = txPayload.charCodeAt(i); + if (ch === 34 || ch === 92 || ch === 10 || ch === 13) { + needsEscape = 1; + break; + } + } + if (needsEscape < 1) return txPayload; + + let out = ''; + for (let i = 0; i < txPayload.length; i++) { + const ch = txPayload.charCodeAt(i); + if (ch === 34) { + out += '\\"'; + } else if (ch === 92) { + out += '\\\\'; + } else if (ch === 10) { + out += '\\n'; + } else if (ch === 13) { + out += '\\r'; + } else { + out += txPayload.charAt(i); + } + } + return out; +} + +/** + * Build the wire envelope for one outbound message. + * + * `encryptReady` is the gate: while it is 0 (before the key exchange completes) + * payloads ship cleartext with "encrypted":false. The flag always describes what + * actually happened to the payload — it is never set optimistically. + */ +export function buildRelayEnvelope( + from: string, + to: string, + room: string, + seq: number, + ts: number, + payload: string, + encryptReady: number, + encrypt: (s: string) => string +): string { + let txPayload = payload; + let encryptedFlag = 'false'; + if (encryptReady > 0 && isPairingHandshake(payload) < 1) { + txPayload = encrypt(payload); + encryptedFlag = 'true'; + } + + let msg = '{"from":"'; + msg += from; + msg += '","to":"'; + msg += to; + msg += '","room":"'; + msg += room; + msg += '","seq":'; + msg += String(seq); + msg += ',"ts":'; + msg += String(ts); + msg += ',"encrypted":'; + msg += encryptedFlag; + msg += ',"payload":"'; + msg += escapePayload(txPayload); + msg += '"}'; + return msg; +} diff --git a/src/workbench/sync-guest.ts b/src/workbench/sync-guest.ts index bb90779..539a187 100644 --- a/src/workbench/sync-guest.ts +++ b/src/workbench/sync-guest.ts @@ -10,16 +10,14 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { getHomeDir, getAppDataDir } from './paths'; -// Cross-device sync used custom Perry crypto intrinsics that the current -// toolchain no longer provides. Stubbed inline so the IDE builds; sync -// (pairing / E2E encryption) is disabled. ccHkdfSha256 returns '' so the -// project key stays empty and encrypt/decrypt pass through as plaintext. -function ccRandomNonce(): string { return ''; } -function ccHkdfSha256(ikmHex: string, saltHex: string, info: string, length: number): string { return ''; } -function ccX25519Keypair(): string { return '{"publicKey":"","secretKey":""}'; } -function ccX25519SharedSecret(secretKeyHex: string, publicKeyHex: string): string { return ''; } -function ccAes256GcmEncrypt(plaintext: string, keyHex: string, nonceHex: string): string { return plaintext; } -function ccAes256GcmDecrypt(encrypted: string, keyHex: string, nonceHex: string): string { return encrypted; } +import { + ccRandomNonce, + ccHkdfSha256, + ccX25519Keypair, + ccX25519SharedSecret, + ccAes256GcmEncrypt, + ccAes256GcmDecrypt, +} from './sync-crypto'; // --- Module-level state --- @@ -431,14 +429,20 @@ export function encryptDelta(plaintext: string): string { return result; } +// Returns '' when the payload cannot be authenticated — callers MUST treat an +// empty result as "drop this message". See the matching note in sync-host.ts. export function decryptDelta(ciphertext: string): string { - if (_projectKey.length === 0) return ciphertext; + if (_projectKey.length === 0) return ''; const colonIdx = ciphertext.indexOf(':'); - if (colonIdx < 0) return ciphertext; + if (colonIdx < 0) return ''; const nonce = ciphertext.slice(0, colonIdx); const encrypted = ciphertext.slice(colonIdx + 1); - const decrypted = ccAes256GcmDecrypt(encrypted, _projectKey, nonce); - return decrypted; + try { + return ccAes256GcmDecrypt(encrypted, _projectKey, nonce); + } catch (e: any) { + // Bad auth tag / truncated payload — tampered or wrong key. Drop it. + return ''; + } } // --- Sequence tracking --- diff --git a/src/workbench/sync-host.ts b/src/workbench/sync-host.ts index b146539..3cbd0bf 100644 --- a/src/workbench/sync-host.ts +++ b/src/workbench/sync-host.ts @@ -7,16 +7,15 @@ * All state is module-level (Perry closures capture by value). */ -// Cross-device sync used custom Perry crypto intrinsics that the current -// toolchain no longer provides. Stubbed inline so the IDE builds; sync -// (pairing / E2E encryption) is disabled. ccHkdfSha256 returns '' so the -// project key stays empty and encrypt/decrypt pass through as plaintext. -function ccRandomNonce(): string { return ''; } -function ccHkdfSha256(ikmHex: string, saltHex: string, info: string, length: number): string { return ''; } -function ccX25519Keypair(): string { return '{"publicKey":"","secretKey":""}'; } -function ccX25519SharedSecret(secretKeyHex: string, publicKeyHex: string): string { return ''; } -function ccAes256GcmEncrypt(plaintext: string, keyHex: string, nonceHex: string): string { return plaintext; } -function ccAes256GcmDecrypt(encrypted: string, keyHex: string, nonceHex: string): string { return encrypted; } +import { + ccRandomNonce, + ccHkdfSha256, + ccX25519Keypair, + ccX25519SharedSecret, + ccAes256GcmEncrypt, + ccAes256GcmDecrypt, + ccRandomKeyHex, +} from './sync-crypto'; // --- Module-level state --- @@ -62,7 +61,10 @@ export function initSyncHost(deviceId: string, deviceName: string): void { hostDeviceName = deviceName; // Random room ID — opaque, not derived from device identity hostRoomId = makeRoomId(); - hostSecret = 'secret_' + deviceId + '_' + Date.now(); + // 256 bits of CSPRNG. This was 'secret_' + deviceId + '_' + Date.now(), + // which is guessable: deviceId is known to any peer and Date.now() at init + // is bounded by when the app launched (audit H1). + hostSecret = ccRandomKeyHex(); hostActive = 1; } @@ -139,13 +141,11 @@ export function validatePairingAttempt(code: string): number { // --- E2E Encryption --- export function generateProjectKey(): void { - // Generate random 32-byte project encryption key - const nonce = ccRandomNonce(); // 12 bytes hex = 24 chars - // Use two nonces + hkdf to get 32 bytes - const nonce2 = ccRandomNonce(); - let ikm = nonce; - ikm += nonce2; - _projectKey = ccHkdfSha256(ikm, '', 'hone-project-key', 32); + // 32 bytes straight from the CSPRNG. This used to stitch two 12-byte nonces + // together and run them through HKDF to reach 32 bytes — HKDF cannot add + // entropy it wasn't given, so that was 24 bytes of randomness dressed up as + // 32. randomBytes(32) is both simpler and stronger. + _projectKey = ccRandomKeyHex(); } export function getProjectKeyHex(): string { @@ -209,15 +209,24 @@ export function encryptDelta(plaintext: string): string { return result; } +// Returns '' when the payload cannot be authenticated — callers MUST treat an +// empty result as "drop this message". Fail closed: these paths used to return +// the ciphertext unchanged when the key was missing or the format was wrong, +// which hands unauthenticated attacker-controlled bytes to the caller as if +// they were plaintext. export function decryptDelta(ciphertext: string): string { - if (_projectKey.length === 0) return ciphertext; + if (_projectKey.length === 0) return ''; // Parse nonce:encrypted const colonIdx = ciphertext.indexOf(':'); - if (colonIdx < 0) return ciphertext; + if (colonIdx < 0) return ''; const nonce = ciphertext.slice(0, colonIdx); const encrypted = ciphertext.slice(colonIdx + 1); - const decrypted = ccAes256GcmDecrypt(encrypted, _projectKey, nonce); - return decrypted; + try { + return ccAes256GcmDecrypt(encrypted, _projectKey, nonce); + } catch (e: any) { + // Bad auth tag / truncated payload — tampered or wrong key. Drop it. + return ''; + } } // --- Guest tracking --- diff --git a/src/workbench/sync-transport.ts b/src/workbench/sync-transport.ts index 85c2c39..9a2daed 100644 --- a/src/workbench/sync-transport.ts +++ b/src/workbench/sync-transport.ts @@ -9,6 +9,7 @@ import WebSocket from 'ws'; import { sendToClient, closeClient, isOpen, messageCount, receive } from 'ws'; +import { buildRelayEnvelope, isPairingHandshake } from './sync-envelope'; // --- Module-level state --- @@ -111,56 +112,13 @@ export function sendToRelayTarget(to: string, payload: string): void { // E2E: encrypt payload once the key exchange has completed. // Pairing handshake messages (PAIR_REQ / PAIR_OK / PAIR_NO) ship cleartext — // they're the channel that bootstraps the project key. - let txPayload = payload; - let encryptedFlag = 'false'; - if (_encryptReady > 0 && !isPairingHandshake(payload)) { - txPayload = _encryptPayload(payload); - encryptedFlag = 'true'; - } - - let msg = '{"from":"'; - msg += relayDeviceId; - msg += '","to":"'; - msg += to; - msg += '","room":"'; - msg += relayRoomId; - msg += '","seq":'; - msg += String(seqCounter); - msg += ',"ts":'; - msg += String(Date.now()); - msg += ',"encrypted":'; - msg += encryptedFlag; - msg += ',"payload":"'; - // Check if txPayload needs escaping (contains " \ \n \r) - let needsEscape = 0; - for (let i = 0; i < txPayload.length; i++) { - const ch = txPayload.charCodeAt(i); - if (ch === 34 || ch === 92 || ch === 10 || ch === 13) { - needsEscape = 1; - break; - } - } - if (needsEscape < 1) { - // Fast path: no escaping needed, direct concat - msg += txPayload; - } else { - // Slow path: escape character by character - for (let i = 0; i < txPayload.length; i++) { - const ch = txPayload.charCodeAt(i); - if (ch === 34) { - msg += '\\"'; - } else if (ch === 92) { - msg += '\\\\'; - } else if (ch === 10) { - msg += '\\n'; - } else if (ch === 13) { - msg += '\\r'; - } else { - msg += txPayload.charAt(i); - } - } - } - msg += '"}'; + // Envelope construction (incl. the encrypt-or-not decision) lives in + // sync-envelope.ts so it can be unit-tested — this module can't be imported + // under bun because of Perry's `ws` extensions. + const msg = buildRelayEnvelope( + relayDeviceId, to, relayRoomId, seqCounter, Date.now(), + payload, _encryptReady, _encryptPayload + ); _onDebug('sendToClient msgLen=' + String(msg.length) + ' handle=' + String(wsHandle)); sendToClient(wsHandle, msg); _onDebug('sendToClient DONE'); @@ -207,17 +165,9 @@ export function decryptIncomingPayload(payload: string, isEncrypted: number): st return _decryptPayload(payload); } -/** - * Pairing handshake messages (PAIR_REQ / PAIR_OK / PAIR_NO) are exempt from encryption — - * they bootstrap the project key. Once both sides have the key, everything else is encrypted. - */ -function isPairingHandshake(payload: string): number { - if (payload.length < 7) return 0; - if (payload.indexOf('PAIR_REQ|') === 0) return 1; - if (payload.indexOf('PAIR_OK|') === 0) return 1; - if (payload.indexOf('PAIR_NO|') === 0) return 1; - return 0; -} +// isPairingHandshake now lives in sync-envelope.ts alongside the envelope +// builder that consumes it — two copies of the "which messages may ship +// cleartext" rule is exactly the kind of thing that drifts apart. export function setOnTransportDebug(fn: (msg: string) => void): void { _onDebug = fn; diff --git a/src/workbench/ui-helpers.ts b/src/workbench/ui-helpers.ts index 5d3c995..8b7b3cb 100644 --- a/src/workbench/ui-helpers.ts +++ b/src/workbench/ui-helpers.ts @@ -21,7 +21,9 @@ declare const __platform__: number; * - Windows: Consolas (ships with every Windows since Vista) * - Linux / web / Android: monospace (CSS-family fallback chain) * - * Callers do `textSetFontFamily(widget, sz, monoFont())`. + * Callers do `textSetFontSize(widget, sz); textSetFontFamily(widget, monoFont());` + * — Perry's textSetFontFamily takes (widget, family) only. Passing a size as a + * third arg makes the table dispatch skip the call entirely (silent no-op). */ export function monoFont(): string { if (__platform__ === 3) return 'Consolas'; diff --git a/src/workbench/views/ai-chat/chat-panel.ts b/src/workbench/views/ai-chat/chat-panel.ts index 1ce23b3..5324af5 100644 --- a/src/workbench/views/ai-chat/chat-panel.ts +++ b/src/workbench/views/ai-chat/chat-panel.ts @@ -471,7 +471,7 @@ export function handleClaudeRelayEvent(operation: string, data: string): void { } claudeCostLabel = Text(costLabel); textSetFontSize(claudeCostLabel, 10); - textSetFontFamily(claudeCostLabel, 10, monoFont()); + textSetFontFamily(claudeCostLabel, monoFont()); setFg(claudeCostLabel, getSideBarForeground()); widgetAddChild(chatMessagesContainer, claudeCostLabel); lastAddedWidget = claudeCostLabel; @@ -618,6 +618,25 @@ function decodeContent(s: string): string { // Auto-scroll helper // --------------------------------------------------------------------------- +// KNOWN BROKEN — blocked on an upstream Perry bug, do not "fix" by adding a +// third argument. +// +// This call is correct: every one of Perry's 8 platform runtimes implements +// perry_ui_scrollview_scroll_to(scroll_handle, child_handle) — scroll-to-child, +// exactly what's written here. But Perry's dispatch table +// (perry-dispatch/src/ui_table/part_a.rs:206) wrongly declares the signature as +// [Widget, F64, F64], and codegen lowers a declared-arity mismatch to +// side-effect-only evaluation — the runtime call is never emitted +// (perry-codegen/src/lower_call/ui_tables.rs:433-440). So this silently does +// nothing and the chat does not auto-scroll to new messages. +// +// Passing (scrollView, x, y) to satisfy the table would compile, but the runtime +// takes 2 params — x would be reinterpreted as a child widget handle. Worse. +// scrollViewSetOffset is not a workaround either: its table entry and the +// platform runtimes disagree with each other (iOS takes 3 params, macOS 2). +// +// Fix belongs upstream: table -> args: &[Widget, Widget]. Then this line works +// as written, with no change here. function scrollToBottom(): void { if (!chatScrollView) return; if (!lastAddedWidget) return; @@ -1439,7 +1458,7 @@ function onAgentToolStart(name: string, id: string): void { toolLabel += name; const toolText = Text(toolLabel); textSetFontSize(toolText, 11); - textSetFontFamily(toolText, 11, monoFont()); + textSetFontFamily(toolText, monoFont()); setFg(toolText, getSideBarForeground()); toolDisplayContainer = VStackWithInsets(2, 4, 8, 4, 8); @@ -1460,7 +1479,7 @@ function onAgentToolResult(name: string, result: string): void { } const resultText = Text(displayResult); textSetFontSize(resultText, 10); - textSetFontFamily(resultText, 10, monoFont()); + textSetFontFamily(resultText, monoFont()); textSetWraps(resultText, 300); setFg(resultText, getSideBarForeground()); widgetAddChild(toolDisplayContainer, resultText); @@ -1496,7 +1515,7 @@ function onAgentApprovalNeeded(name: string, args: string): void { if (argsPreview.length > 200) argsPreview = args.slice(0, 200) + '...'; const argsText = Text(argsPreview); textSetFontSize(argsText, 10); - textSetFontFamily(argsText, 10, monoFont()); + textSetFontFamily(argsText, monoFont()); setFg(argsText, getSideBarForeground()); widgetAddChild(approvalContainer, argsText); @@ -1585,7 +1604,7 @@ function onClaudeToolActivity(name: string, status: string, inputDetail: string) } const toolText = Text(toolLabel); textSetFontSize(toolText, 11); - textSetFontFamily(toolText, 11, monoFont()); + textSetFontFamily(toolText, monoFont()); textSetWraps(toolText, 300); setFg(toolText, getSideBarForeground()); @@ -1600,7 +1619,7 @@ function onClaudeToolActivity(name: string, status: string, inputDetail: string) if (claudeToolContainer) { const doneText = Text('\u2713 ' + t('done')); textSetFontSize(doneText, 10); - textSetFontFamily(doneText, 10, monoFont()); + textSetFontFamily(doneText, monoFont()); setFg(doneText, getSideBarForeground()); widgetAddChild(claudeToolContainer, doneText); claudeToolContainer = null; @@ -1616,7 +1635,7 @@ function onClaudeToolResult(output: string, isError: number, filePath: string): if (filePath.length > 0) { let fpLabel = Text(filePath); textSetFontSize(fpLabel, 10); - textSetFontFamily(fpLabel, 10, monoFont()); + textSetFontFamily(fpLabel, monoFont()); setFg(fpLabel, getSecondaryTextColor()); widgetAddChild(claudeToolContainer, fpLabel); } @@ -1634,7 +1653,7 @@ function onClaudeToolResult(output: string, isError: number, filePath: string): if (isCurrentThemeDark() > 0) { widgetSetBackgroundColor(errContainer, 0.3, 0.12, 0.12, 1.0); } else { widgetSetBackgroundColor(errContainer, 1.0, 0.9, 0.9, 1.0); }; let errLabel = Text(displayOutput); textSetFontSize(errLabel, 10); - textSetFontFamily(errLabel, 10, monoFont()); + textSetFontFamily(errLabel, monoFont()); textSetWraps(errLabel, 300); setFg(errLabel, getSideBarForeground()); widgetAddChild(errContainer, errLabel); @@ -1642,7 +1661,7 @@ function onClaudeToolResult(output: string, isError: number, filePath: string): } else { let resultLabel = Text(displayOutput); textSetFontSize(resultLabel, 10); - textSetFontFamily(resultLabel, 10, monoFont()); + textSetFontFamily(resultLabel, monoFont()); textSetWraps(resultLabel, 300); setFg(resultLabel, getSecondaryTextColor()); widgetAddChild(claudeToolContainer, resultLabel); @@ -2194,7 +2213,7 @@ function refreshSessionList(): void { if (smode === 3) badge = 'CC'; const badgeLabel = Text(badge); textSetFontSize(badgeLabel, 9); - textSetFontFamily(badgeLabel, 9, monoFont()); + textSetFontFamily(badgeLabel, monoFont()); setFg(badgeLabel, getSideBarForeground()); // Click handler for the row @@ -2666,7 +2685,7 @@ function handleClaudeLine(line: string): void { if (infoStr.length > 0) { claudeInfoRow = Text(infoStr); textSetFontSize(claudeInfoRow, 10); - textSetFontFamily(claudeInfoRow, 10, monoFont()); + textSetFontFamily(claudeInfoRow, monoFont()); setFg(claudeInfoRow, getSecondaryTextColor()); widgetAddChild(chatMessagesContainer, claudeInfoRow); } @@ -2698,7 +2717,7 @@ function handleClaudeLine(line: string): void { if (isCurrentThemeDark() > 0) { widgetSetBackgroundColor(rlBlock, 0.35, 0.25, 0.05, 1.0); } else { widgetSetBackgroundColor(rlBlock, 1.0, 0.95, 0.85, 1.0); }; claudeRateLimitLabel = Text(t('Rate limited. Waiting...')); textSetFontSize(claudeRateLimitLabel, 12); - textSetFontFamily(claudeRateLimitLabel, 12, monoFont()); + textSetFontFamily(claudeRateLimitLabel, monoFont()); setFg(claudeRateLimitLabel, getSideBarForeground()); widgetAddChild(rlBlock, claudeRateLimitLabel); widgetAddChild(chatMessagesContainer, rlBlock); @@ -2856,7 +2875,7 @@ function handleClaudeLine(line: string): void { if (statsStr.length > 0) { claudeCostLabel = Text(statsStr); textSetFontSize(claudeCostLabel, 10); - textSetFontFamily(claudeCostLabel, 10, monoFont()); + textSetFontFamily(claudeCostLabel, monoFont()); setFg(claudeCostLabel, getSecondaryTextColor()); widgetAddChild(chatMessagesContainer, claudeCostLabel); lastAddedWidget = claudeCostLabel; @@ -3142,7 +3161,7 @@ function showThinkingBlock(text: string): void { // Content — hidden by default let contentLabel = Text(text); textSetFontSize(contentLabel, 10); - textSetFontFamily(contentLabel, 10, monoFont()); + textSetFontFamily(contentLabel, monoFont()); textSetWraps(contentLabel, 300); setFg(contentLabel, getSecondaryTextColor()); widgetSetHidden(contentLabel, 1); @@ -3183,7 +3202,7 @@ function onClaudeToolActivityWithDiff(toolName: string, filePath: string, block: } let toolText = Text(toolLabel); textSetFontSize(toolText, 11); - textSetFontFamily(toolText, 11, monoFont()); + textSetFontFamily(toolText, monoFont()); textSetWraps(toolText, 300); setFg(toolText, getSideBarForeground()); @@ -3202,7 +3221,7 @@ function onClaudeToolActivityWithDiff(toolName: string, filePath: string, block: if (isCurrentThemeDark() > 0) { widgetSetBackgroundColor(oldBlock, 0.25, 0.10, 0.10, 1.0); } else { widgetSetBackgroundColor(oldBlock, 1.0, 0.92, 0.92, 1.0); }; let oldLabel = Text('- ' + oldTrunc); textSetFontSize(oldLabel, 10); - textSetFontFamily(oldLabel, 10, monoFont()); + textSetFontFamily(oldLabel, monoFont()); textSetWraps(oldLabel, 280); setFg(oldLabel, getSideBarForeground()); widgetAddChild(oldBlock, oldLabel); @@ -3215,7 +3234,7 @@ function onClaudeToolActivityWithDiff(toolName: string, filePath: string, block: if (isCurrentThemeDark() > 0) { widgetSetBackgroundColor(newBlock, 0.10, 0.25, 0.10, 1.0); } else { widgetSetBackgroundColor(newBlock, 0.92, 1.0, 0.92, 1.0); }; let newLabel = Text('+ ' + newTrunc); textSetFontSize(newLabel, 10); - textSetFontFamily(newLabel, 10, monoFont()); + textSetFontFamily(newLabel, monoFont()); textSetWraps(newLabel, 280); setFg(newLabel, getSideBarForeground()); widgetAddChild(newBlock, newLabel); @@ -3822,7 +3841,7 @@ function updateMessages(): void { setChatBgCode(toolBlock); const toolLabel = Text('\u2699 ' + t('Tool result')); textSetFontSize(toolLabel, 10); - textSetFontFamily(toolLabel, 10, monoFont()); + textSetFontFamily(toolLabel, monoFont()); setFg(toolLabel, getSideBarForeground()); widgetAddChild(toolBlock, toolLabel); @@ -3839,7 +3858,7 @@ function updateMessages(): void { if (resultPreview.length > 200) resultPreview = resultPreview.slice(0, 200) + '...'; const resultText = Text(resultPreview); textSetFontSize(resultText, 10); - textSetFontFamily(resultText, 10, monoFont()); + textSetFontFamily(resultText, monoFont()); setFg(resultText, getSideBarForeground()); widgetAddChild(toolBlock, resultText); widgetAddChild(chatMessagesContainer, toolBlock); diff --git a/src/workbench/views/ai-chat/context-chips.ts b/src/workbench/views/ai-chat/context-chips.ts index 010c75e..54c10aa 100644 --- a/src/workbench/views/ai-chat/context-chips.ts +++ b/src/workbench/views/ai-chat/context-chips.ts @@ -184,7 +184,7 @@ export function renderChips(container: unknown, colors: ResolvedUIColors): void const chipLabel = Text(label); textSetFontSize(chipLabel, 10); - textSetFontFamily(chipLabel, 10, monoFont()); + textSetFontFamily(chipLabel, monoFont()); setFg(chipLabel, getSideBarForeground()); const removeFn = getRemoveFn(i); diff --git a/src/workbench/views/ai-chat/markdown-render.ts b/src/workbench/views/ai-chat/markdown-render.ts index 18b3aed..362b814 100644 --- a/src/workbench/views/ai-chat/markdown-render.ts +++ b/src/workbench/views/ai-chat/markdown-render.ts @@ -166,7 +166,7 @@ function renderInlineText(text: string, container: unknown, fontSize: number, co const seg = text.slice(segStart, i); if (inCode > 0) { const t = Text(seg); - textSetFontFamily(t, fontSize, monoFont()); + textSetFontFamily(t, monoFont()); textSetFontSize(t, fontSize - 1); setFg(t, getSideBarForeground()); widgetAddChild(container, t); @@ -248,7 +248,7 @@ export function renderMarkdownBlock(content: string, container: unknown, colors: let codeLine = line; if (codeLine.length < 1) codeLine = ' '; const t = Text(codeLine); - textSetFontFamily(t, 11, monoFont()); + textSetFontFamily(t, monoFont()); textSetFontSize(t, 11); setFg(t, getSideBarForeground()); widgetAddChild(codeLines, t); @@ -447,7 +447,7 @@ function renderTable(rows: string[][], container: unknown, _colors: ResolvedUICo row += cells[c]; } const t = Text(row); - textSetFontFamily(t, 11, monoFont()); + textSetFontFamily(t, monoFont()); textSetFontSize(t, 11); setFg(t, getSideBarForeground()); textSetWraps(t, wrapWidth); diff --git a/src/workbench/views/debug/debug-panel.ts b/src/workbench/views/debug/debug-panel.ts index 34957f7..96b2c5a 100644 --- a/src/workbench/views/debug/debug-panel.ts +++ b/src/workbench/views/debug/debug-panel.ts @@ -872,7 +872,7 @@ function updateCallStackUI(): void { const frameBtn = Button(label, () => { onStackFrameClick(frameFile, frameLine); }); buttonSetBordered(frameBtn, 0); textSetFontSize(frameBtn, 11); - textSetFontFamily(frameBtn, 11, monoFont()); + textSetFontFamily(frameBtn, monoFont()); setBtnFg(frameBtn, getSideBarForeground()); widgetAddChild(csContainer, frameBtn); } @@ -901,7 +901,7 @@ function updateOutputUI(): void { for (let i = 0; i < displayCount; i = i + 1) { const lineText = Text(outputLines[i]); textSetFontSize(lineText, 11); - textSetFontFamily(lineText, 11, monoFont()); + textSetFontFamily(lineText, monoFont()); // Color error lines (exit status, error keywords) differently let isErrorLine = 0; diff --git a/src/workbench/views/git/git-log.ts b/src/workbench/views/git/git-log.ts index aa8fae8..0e8b2d9 100644 --- a/src/workbench/views/git/git-log.ts +++ b/src/workbench/views/git/git-log.ts @@ -253,7 +253,7 @@ function updateLogUI(): void { // Hash label (monospace, muted) const hashLabel = Text(hash); textSetFontSize(hashLabel, 10); - textSetFontFamily(hashLabel, 10, monoFont()); + textSetFontFamily(hashLabel, monoFont()); setFg(hashLabel, getSecondaryTextColor()); // Message (primary text) diff --git a/src/workbench/views/git/git-panel.ts b/src/workbench/views/git/git-panel.ts index 82d8a67..e367e8f 100644 --- a/src/workbench/views/git/git-panel.ts +++ b/src/workbench/views/git/git-panel.ts @@ -1070,7 +1070,7 @@ function onHistoryButtonClick(_container: unknown, _colors: ResolvedUIColors): v // monospace font is essential to preserve alignment. const row = Text(lines[i]); textSetFontSize(row, 11); - textSetFontFamily(row, 11, monoFont()); + textSetFontFamily(row, monoFont()); setFg(row, getSideBarForeground()); widgetAddChild(gitHistoryContainer, row); } @@ -1189,7 +1189,7 @@ function updateGitResultsUI(): void { if (status.charCodeAt(0) === 100) indicator = 'D'; const statusLabel = Text(indicator); textSetFontSize(statusLabel, 11); - textSetFontFamily(statusLabel, 11, monoFont()); + textSetFontFamily(statusLabel, monoFont()); if (panelColors) { if (indicator === 'A') { setFg(statusLabel, getStatusAddedColor()); @@ -1232,7 +1232,7 @@ function updateGitResultsUI(): void { if (status.charCodeAt(0) === 100) indicator = 'D'; const statusLabel = Text(indicator); textSetFontSize(statusLabel, 11); - textSetFontFamily(statusLabel, 11, monoFont()); + textSetFontFamily(statusLabel, monoFont()); if (panelColors) { if (indicator === 'D') { setFg(statusLabel, getStatusDeletedColor()); @@ -1275,7 +1275,7 @@ function updateGitResultsUI(): void { const row = HStack(4, []); const statusLabel = Text('U'); textSetFontSize(statusLabel, 11); - textSetFontFamily(statusLabel, 11, monoFont()); + textSetFontFamily(statusLabel, monoFont()); if (panelColors) setFg(statusLabel, getStatusAddedColor()); const fileBtn = Button(fname, () => { onGitFileClick(fpath); }); buttonSetBordered(fileBtn, 0); diff --git a/src/workbench/views/lsp/autocomplete-popup.ts b/src/workbench/views/lsp/autocomplete-popup.ts index 2310c33..288597a 100644 --- a/src/workbench/views/lsp/autocomplete-popup.ts +++ b/src/workbench/views/lsp/autocomplete-popup.ts @@ -45,7 +45,7 @@ export function showAutocomplete(items: string[]): void { const btn = Button(text, () => { onItemClick(text); }); buttonSetBordered(btn, 0); textSetFontSize(btn, 12); - textSetFontFamily(btn, 12, monoFont()); + textSetFontFamily(btn, monoFont()); if (popupColors) setBtnFg(btn, getEditorForeground()); widgetAddChild(popupWidget, btn); } diff --git a/src/workbench/views/lsp/diagnostics-panel.ts b/src/workbench/views/lsp/diagnostics-panel.ts index 666cb3e..91e27a8 100644 --- a/src/workbench/views/lsp/diagnostics-panel.ts +++ b/src/workbench/views/lsp/diagnostics-panel.ts @@ -198,7 +198,7 @@ function refreshDiagnosticsUI(): void { const severityLabel = Text(severityChar); textSetFontSize(severityLabel, 11); - textSetFontFamily(severityLabel, 11, monoFont()); + textSetFontFamily(severityLabel, monoFont()); setFg(severityLabel, severityColor); const fname = getFileName(file); diff --git a/src/workbench/views/lsp/hover-popup.ts b/src/workbench/views/lsp/hover-popup.ts index b330f95..846858f 100644 --- a/src/workbench/views/lsp/hover-popup.ts +++ b/src/workbench/views/lsp/hover-popup.ts @@ -127,7 +127,7 @@ export function showHoverPopup(content: string): void { const typeStripped = stripMarkdown(typeLine); const typeLabel = Text(typeStripped); textSetFontSize(typeLabel, 12); - textSetFontFamily(typeLabel, 12, monoFont()); + textSetFontFamily(typeLabel, monoFont()); textSetFontWeight(typeLabel, 12, 0.5); setFg(typeLabel, '#e0e0e0'); widgetAddChild(hoverWidget, typeLabel); diff --git a/src/workbench/views/lsp/signature-popup.ts b/src/workbench/views/lsp/signature-popup.ts index 2a005ba..19ab1f5 100644 --- a/src/workbench/views/lsp/signature-popup.ts +++ b/src/workbench/views/lsp/signature-popup.ts @@ -45,7 +45,7 @@ export function showSignaturePopup( // Signature label — monospace const sigLabel = Text(label); textSetFontSize(sigLabel, 12); - textSetFontFamily(sigLabel, 12, monoFont()); + textSetFontFamily(sigLabel, monoFont()); setFg(sigLabel, '#e0e0e0'); widgetAddChild(sigWidget, sigLabel); diff --git a/src/workbench/views/search/search-panel.ts b/src/workbench/views/search/search-panel.ts index 022ee17..4b7c2d8 100644 --- a/src/workbench/views/search/search-panel.ts +++ b/src/workbench/views/search/search-panel.ts @@ -647,7 +647,7 @@ function updateSearchResultsUI(): void { const btn = Button(display, () => { onSearchResultClick(resultPath); }); buttonSetBordered(btn, 0); textSetFontSize(btn, 12); - textSetFontFamily(btn, 12, monoFont()); + textSetFontFamily(btn, monoFont()); if (panelColors) { setBtnFg(btn, '#888888'); } diff --git a/src/workbench/views/terminal/terminal-panel.ts b/src/workbench/views/terminal/terminal-panel.ts index dc4e014..e6249c2 100644 --- a/src/workbench/views/terminal/terminal-panel.ts +++ b/src/workbench/views/terminal/terminal-panel.ts @@ -184,7 +184,7 @@ function refreshProblemsView(): void { const sevLabel = Text(severityChar); textSetFontSize(sevLabel, 11); - textSetFontFamily(sevLabel, 11, monoFont()); + textSetFontFamily(sevLabel, monoFont()); setFg(sevLabel, severityColor); const fname = getFileName(files[i]); diff --git a/src/workbench/views/timeline/timeline-panel.ts b/src/workbench/views/timeline/timeline-panel.ts index 3c44baf..ba49150 100644 --- a/src/workbench/views/timeline/timeline-panel.ts +++ b/src/workbench/views/timeline/timeline-panel.ts @@ -134,7 +134,7 @@ function rebuildRows(): void { const meta = Text(hash + ' ' + age + ' ' + author); textSetFontSize(meta, 10); - textSetFontFamily(meta, 10, monoFont()); + textSetFontFamily(meta, monoFont()); setFg(meta, getSecondaryTextColor()); const subjBtn = Button(subject, () => { onRowClick(hash); }); diff --git a/tests/sync-crypto.test.ts b/tests/sync-crypto.test.ts new file mode 100644 index 0000000..b60693a --- /dev/null +++ b/tests/sync-crypto.test.ts @@ -0,0 +1,152 @@ +/** + * Regression tests for the sync E2E crypto primitives. + * + * These exist because sync shipped as a PLAINTEXT PASS-THROUGH while the product + * advertised end-to-end encryption: every primitive in sync-host.ts / + * sync-guest.ts was stubbed, and ccAes256GcmEncrypt literally returned its + * plaintext argument (AUDIT-2026-07.md H1). The single most important assertion + * in this file is therefore the dullest one: that ciphertext is not plaintext. + * + * SCOPE — these run under `bun test`, i.e. against Node/Bun's crypto, not + * Perry's. That is a real limitation to understand rather than paper over: + * + * - The symmetric primitives (nonce/HKDF/AES-256-GCM) are standard node:crypto + * on both runtimes, and Perry's output was verified byte-identical to node's, + * so testing them here is meaningful. + * - The X25519 helpers are NOT portable and are deliberately not tested here. + * Perry's KeyObject.export() returns an opaque surrogate string + * ("PERRY-X25519-PUBLIC:") rather than real SPKI/PKCS8 DER, and + * ignores the requested encoding. Under Bun the same call returns real DER, + * so ccX25519Keypair's String(export(...)) round-trip only works on Perry. + * That path is covered by a Perry-compiled probe instead (see the commit that + * introduced sync-crypto.ts). If X25519 pairing ever needs to interop with a + * non-Perry client, the wire format has to change first. + */ + +import { describe, it, expect } from 'bun:test'; +import { + ccRandomNonce, + ccHkdfSha256, + ccAes256GcmEncrypt, + ccAes256GcmDecrypt, + ccRandomKeyHex, +} from '../src/workbench/sync-crypto'; + +const KEY = 'a'.repeat(64); // 32 bytes hex +const HEX = /^[0-9a-f]+$/; + +describe('ccRandomNonce', () => { + it('is a 12-byte hex nonce', () => { + const n = ccRandomNonce(); + expect(n.length).toBe(24); + expect(HEX.test(n)).toBe(true); + }); + + it('never repeats across many draws (GCM nonce reuse is catastrophic)', () => { + const seen = new Set(); + for (let i = 0; i < 1000; i++) seen.add(ccRandomNonce()); + expect(seen.size).toBe(1000); + }); +}); + +describe('ccRandomKeyHex', () => { + it('is 32 bytes of hex', () => { + const k = ccRandomKeyHex(); + expect(k.length).toBe(64); + expect(HEX.test(k)).toBe(true); + }); + + it('is not the all-zero / constant key', () => { + const a = ccRandomKeyHex(); + const b = ccRandomKeyHex(); + expect(a).not.toBe(b); + expect(a).not.toBe('0'.repeat(64)); + }); +}); + +describe('ccHkdfSha256', () => { + it('derives the requested number of bytes', () => { + expect(ccHkdfSha256('aabb', '', 'info', 32).length).toBe(64); + expect(ccHkdfSha256('aabb', '', 'info', 16).length).toBe(32); + }); + + it('is deterministic for the same inputs', () => { + expect(ccHkdfSha256('aabb', '', 'x', 32)).toBe(ccHkdfSha256('aabb', '', 'x', 32)); + }); + + it('separates domains by info label', () => { + const a = ccHkdfSha256('aabb', '', 'hone-pairing-key', 32); + const b = ccHkdfSha256('aabb', '', 'hone-project-key', 32); + expect(a).not.toBe(b); + }); + + it('honours the salt', () => { + const a = ccHkdfSha256('aabb', '', 'x', 32); + const b = ccHkdfSha256('aabb', 'ccdd', 'x', 32); + expect(a).not.toBe(b); + }); +}); + +describe('ccAes256GcmEncrypt / ccAes256GcmDecrypt', () => { + it('does not return the plaintext — the actual H1 bug', () => { + const pt = 'FILE_EDIT|src/app.ts|secret contents'; + const ct = ccAes256GcmEncrypt(pt, KEY, ccRandomNonce()); + expect(ct).not.toBe(pt); + expect(ct.includes('FILE_EDIT')).toBe(false); + expect(ct.includes('secret contents')).toBe(false); + }); + + it('round-trips', () => { + const pt = 'FILE_EDIT|src/app.ts|hello world'; + const n = ccRandomNonce(); + expect(ccAes256GcmDecrypt(ccAes256GcmEncrypt(pt, KEY, n), KEY, n)).toBe(pt); + }); + + it('round-trips unicode and empty payloads', () => { + const n = ccRandomNonce(); + expect(ccAes256GcmDecrypt(ccAes256GcmEncrypt('日本語 🔐 ok', KEY, n), KEY, n)).toBe('日本語 🔐 ok'); + const n2 = ccRandomNonce(); + expect(ccAes256GcmDecrypt(ccAes256GcmEncrypt('', KEY, n2), KEY, n2)).toBe(''); + }); + + it('appends a 16-byte auth tag (32 hex chars)', () => { + const n = ccRandomNonce(); + const empty = ccAes256GcmEncrypt('', KEY, n); + expect(empty.length).toBe(32); // no ciphertext, tag only + }); + + it('produces different ciphertext per nonce for identical plaintext', () => { + const pt = 'same message'; + expect(ccAes256GcmEncrypt(pt, KEY, ccRandomNonce())) + .not.toBe(ccAes256GcmEncrypt(pt, KEY, ccRandomNonce())); + }); + + it('rejects a tampered ciphertext', () => { + const n = ccRandomNonce(); + const ct = ccAes256GcmEncrypt('hello world', KEY, n); + const tampered = ct.slice(0, ct.length - 4) + 'dead'; + expect(() => ccAes256GcmDecrypt(tampered, KEY, n)).toThrow(); + }); + + it('rejects a tampered auth tag', () => { + const n = ccRandomNonce(); + const ct = ccAes256GcmEncrypt('hello world', KEY, n); + const flipped = ct.slice(0, 2) === 'ff' ? '00' + ct.slice(2) : 'ff' + ct.slice(2); + expect(() => ccAes256GcmDecrypt(flipped, KEY, n)).toThrow(); + }); + + it('rejects the wrong key', () => { + const n = ccRandomNonce(); + const ct = ccAes256GcmEncrypt('hello world', KEY, n); + expect(() => ccAes256GcmDecrypt(ct, ccRandomKeyHex(), n)).toThrow(); + }); + + it('rejects the wrong nonce', () => { + const ct = ccAes256GcmEncrypt('hello world', KEY, ccRandomNonce()); + expect(() => ccAes256GcmDecrypt(ct, KEY, ccRandomNonce())).toThrow(); + }); + + it('rejects a payload too short to carry a tag', () => { + expect(() => ccAes256GcmDecrypt('abcd', KEY, ccRandomNonce())).toThrow(); + }); +}); diff --git a/tests/sync-envelope.test.ts b/tests/sync-envelope.test.ts new file mode 100644 index 0000000..3851829 --- /dev/null +++ b/tests/sync-envelope.test.ts @@ -0,0 +1,123 @@ +/** + * Envelope-level tests: does a payload actually leave this device as ciphertext? + * + * This is the assertion AUDIT-2026-07.md H1 asked for ("relay-side packet + * capture asserting every non-handshake envelope ships ciphertext"), done at the + * point where the envelope is built rather than by capturing packets — same + * property, no relay or network required, and it runs on every commit. + * + * The encrypt function is injected, so these tests assert the *transport's + * decision* (encrypt or not, and whether the flag tells the truth), independently + * of the crypto itself — which sync-crypto.test.ts covers. + */ + +import { describe, it, expect } from 'bun:test'; +import { buildRelayEnvelope, isPairingHandshake } from '../src/workbench/sync-envelope'; + +// Stand-in for real encryption: uppercase + a marker. Deliberately not a no-op — +// a no-op "encrypt" is precisely the bug that shipped. +const fakeEncrypt = (s: string) => 'ENC(' + s.toUpperCase() + ')'; +const identity = (s: string) => s; + +const READY = 1; +const NOT_READY = 0; + +function envelope(payload: string, ready: number, enc = fakeEncrypt): string { + return buildRelayEnvelope('devA', 'devB', 'room1', 7, 1752600000000, payload, ready, enc); +} + +describe('isPairingHandshake', () => { + it('exempts the three handshake messages', () => { + expect(isPairingHandshake('PAIR_REQ|code|d|n|k')).toBe(1); + expect(isPairingHandshake('PAIR_OK|d|n|k|w')).toBe(1); + expect(isPairingHandshake('PAIR_NO|encryption required')).toBe(1); + }); + + it('does not exempt ordinary payloads', () => { + expect(isPairingHandshake('FILE_EDIT|src/app.ts|x')).toBe(0); + expect(isPairingHandshake('')).toBe(0); + }); + + it('is anchored — a payload merely containing the marker is not exempt', () => { + // Otherwise an attacker-influenced payload could smuggle itself past the + // encryption gate by embedding "PAIR_REQ|" anywhere in its body. + expect(isPairingHandshake('FILE_EDIT|notes.md|see PAIR_REQ|for details')).toBe(0); + expect(isPairingHandshake(' PAIR_REQ|x')).toBe(0); + }); +}); + +describe('buildRelayEnvelope — encryption gate', () => { + it('ships ciphertext, not plaintext, once encryption is ready', () => { + const msg = envelope('FILE_EDIT|src/app.ts|secret contents', READY); + expect(msg.includes('secret contents')).toBe(false); + expect(msg.includes('ENC(FILE_EDIT|SRC/APP.TS|SECRET CONTENTS)')).toBe(true); + }); + + it('sets encrypted:true only when it actually encrypted', () => { + expect(envelope('FILE_EDIT|a|b', READY).includes('"encrypted":true')).toBe(true); + }); + + it('lets the pairing handshake through in cleartext — it bootstraps the key', () => { + const msg = envelope('PAIR_REQ|123456789012|devA|Mac|pubkey', READY); + expect(msg.includes('PAIR_REQ|123456789012|devA|Mac|pubkey')).toBe(true); + expect(msg.includes('"encrypted":false')).toBe(true); + }); + + it('flags cleartext honestly before the key exchange completes', () => { + // Documents the pre-key window: payloads DO ship cleartext here. The flag + // must never claim otherwise — a false "encrypted":true would make the + // receiver try to decrypt plaintext, and would misrepresent the wire. + const msg = envelope('FILE_EDIT|a|b', NOT_READY); + expect(msg.includes('"encrypted":false')).toBe(true); + expect(msg.includes('FILE_EDIT|a|b')).toBe(true); + }); + + it('never calls the encrypt fn when not ready', () => { + let called = 0; + envelope('FILE_EDIT|a|b', NOT_READY, (s) => { called++; return s; }); + expect(called).toBe(0); + }); + + it('regression: an identity "encrypt" is visibly plaintext — the H1 shape', () => { + // If ccAes256GcmEncrypt ever regresses to returning its argument, the flag + // says true while the wire carries plaintext. Pin the distinction. + const broken = envelope('FILE_EDIT|src/app.ts|secret', READY, identity); + expect(broken.includes('"encrypted":true')).toBe(true); + expect(broken.includes('secret')).toBe(true); // <- what shipped for months + const fixed = envelope('FILE_EDIT|src/app.ts|secret', READY); + expect(fixed.includes('secret')).toBe(false); + }); +}); + +describe('buildRelayEnvelope — framing', () => { + it('carries the routing fields', () => { + const msg = envelope('x', NOT_READY); + expect(msg.includes('"from":"devA"')).toBe(true); + expect(msg.includes('"to":"devB"')).toBe(true); + expect(msg.includes('"room":"room1"')).toBe(true); + expect(msg.includes('"seq":7')).toBe(true); + expect(msg.includes('"ts":1752600000000')).toBe(true); + }); + + it('produces parseable JSON for both paths', () => { + const clear: any = JSON.parse(envelope('plain payload', NOT_READY)); + expect(clear.payload).toBe('plain payload'); + expect(clear.encrypted).toBe(false); + const enc: any = JSON.parse(envelope('plain payload', READY)); + expect(enc.encrypted).toBe(true); + expect(enc.payload).toBe('ENC(PLAIN PAYLOAD)'); + }); + + it('escapes quotes, backslashes and newlines so the envelope stays valid JSON', () => { + const nasty = 'FILE_EDIT|a.ts|say "hi"\\ n\nnext\rline'; + const parsed: any = JSON.parse(envelope(nasty, NOT_READY)); + expect(parsed.payload).toBe(nasty); + }); + + it('a payload that looks like JSON cannot break out of the envelope', () => { + const inject = '","encrypted":true,"x":"'; + const parsed: any = JSON.parse(envelope(inject, NOT_READY)); + expect(parsed.payload).toBe(inject); + expect(parsed.encrypted).toBe(false); // not overridden by the injected text + }); +}); diff --git a/tsconfig.json b/tsconfig.json index cc5a81d..69f7510 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,8 @@ "types": [] }, "include": ["src/**/*.ts", "tests/**/*.ts"], - "exclude": ["node_modules", "dist"] + // seed-files is a self-contained fixture workspace (own package.json + + // tsconfig) copied into a temp dir by the agentic tests — it is not + // hone-ide source and must not be typechecked against this config. + "exclude": ["node_modules", "dist", "tests/agentic/setup/seed-files"] }