From bfdcf0547b219829bfeef5a78a03f172d226a25d Mon Sep 17 00:00:00 2001 From: Vext Labs <261808311+Vext-Labs@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:17:22 -0400 Subject: [PATCH 1/6] feat: add safety guards for URLs, deep links, forms, and image hosts New src/genos/safety modules enforcing at runtime what the system prompt can only request: - urlPolicy: only http(s) links reach Linking; every other scheme blocked - genosLink: genos://open targets validated (catalog ids or safe slug shape), requests capped at 300 chars, toast text at 120 - redaction: credential-shaped form values replaced with [redacted] - tools/images: model-supplied image URLs resolve only on allowlisted https hosts, else fall back to the LoremFlickr semantic path --- src/genos/safety/genosLink.ts | 42 +++++++++++++++++++++++++++++ src/genos/safety/redaction.ts | 50 +++++++++++++++++++++++++++++++++++ src/genos/safety/urlPolicy.ts | 36 +++++++++++++++++++++++++ src/genos/tools/images.ts | 37 ++++++++++++++++++++++++-- 4 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 src/genos/safety/genosLink.ts create mode 100644 src/genos/safety/redaction.ts create mode 100644 src/genos/safety/urlPolicy.ts diff --git a/src/genos/safety/genosLink.ts b/src/genos/safety/genosLink.ts new file mode 100644 index 0000000..674e977 --- /dev/null +++ b/src/genos/safety/genosLink.ts @@ -0,0 +1,42 @@ +/** + * genos:// deep-link hardening. Deep links come from model output, so the + * open target and payloads are validated before the shell acts on them: the + * target must be a known catalog app or the safe summon-id shape, the request + * is length-capped (it is injected verbatim as the user message of a new + * generation - an unbounded one is a cross-app prompt-injection channel), + * and toast text is capped so a poisoned screen can't spoof long system- + * looking messages. + */ +import { APPS } from "../apps"; + +/** Longest request payload a genos://open link may carry. */ +export const GENOS_REQUEST_MAX = 300; +/** Longest text a genos://toast link may show. */ +export const GENOS_TOAST_MAX = 120; + +/** Safe app-id shape: covers every catalog id and summon-* slugs. */ +const APP_ID_RE = /^[a-z0-9-]{1,32}$/; + +/** App ids a genos://open link may target (catalog ids + safe slug shape). */ +export function isAllowedAppId(raw: string): boolean { + const id = raw.trim().toLowerCase(); + return APP_ID_RE.test(id) || APPS.some((a) => a.id === id); +} + +export interface OpenLinkParams { + appId: string; + request: string; +} + +/** Validated genos://open params; null when anything is missing or unsafe. */ +export function parseOpenLink(params: Record): OpenLinkParams | null { + const app = params.app?.trim(); + const request = params.request?.trim(); + if (!app || !request || !isAllowedAppId(app)) return null; + return { appId: app.toLowerCase(), request: request.slice(0, GENOS_REQUEST_MAX) }; +} + +/** Toast text from a genos://toast link, length-capped. */ +export function capToastText(raw: string): string { + return raw.slice(0, GENOS_TOAST_MAX); +} diff --git a/src/genos/safety/redaction.ts b/src/genos/safety/redaction.ts new file mode 100644 index 0000000..5fdb44c --- /dev/null +++ b/src/genos/safety/redaction.ts @@ -0,0 +1,50 @@ +/** + * Form-value redaction. Submitted formState is serialized into the next model + * request and replays as conversation context (it leaves the device, to the + * configured provider, possibly several times), so credential-shaped values + * must be stripped first. Field type metadata does not reach resolveAction, + * so redaction keys off the field NAME the model chose - password-type fields + * follow the obvious naming convention, and common credential words are + * covered too. Whole-word matching keeps "shopping" and "spinner" safe. + */ + +/** Value substituted for a sensitive field. */ +export const REDACTED = "[redacted]"; + +/** Words that mark a field name as credential-shaped. */ +const SENSITIVE_WORDS = new Set([ + "password", + "passwd", + "passphrase", + "passcode", + "pin", + "secret", + "cvv", + "cvc", + "ssn", + "otp", + "token", +]); + +/** Field names arrive camelCase / snake_case / kebab - split into words. */ +function fieldWords(name: string): string[] { + return name + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + +/** True when a field name reads as credential-shaped ("cardCvv", "user_pin"). */ +export function isSensitiveField(name: string): boolean { + return fieldWords(name).some((w) => SENSITIVE_WORDS.has(w)); +} + +/** Copy formState with sensitive values replaced by REDACTED. */ +export function redactFormState(formState: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(formState)) { + out[key] = isSensitiveField(key) ? REDACTED : value; + } + return out; +} diff --git a/src/genos/safety/urlPolicy.ts b/src/genos/safety/urlPolicy.ts new file mode 100644 index 0000000..0415a89 --- /dev/null +++ b/src/genos/safety/urlPolicy.ts @@ -0,0 +1,36 @@ +/** + * Outbound-link policy. A generated screen can attach any URL string to a tap + * (@OpenUrl) and model output is untrusted, so only http(s) links ever reach + * Linking. Everything else (javascript:, intent:, tel:, sms:, file:, third- + * party app schemes, universal links) is blocked and reported via onBlocked. + * The scheme is parsed by hand - Hermes' URL support for odd schemes varies. + */ +import { Linking } from "react-native"; + +/** Schemes a model-supplied link may use. */ +const ALLOWED_SCHEMES = new Set(["https:", "http:"]); + +/** Scheme prefix of a URL, lowercased with trailing colon; null if none. */ +export function urlScheme(raw: string): string | null { + const m = raw.trim().match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/); + return m ? `${m[1].toLowerCase()}:` : null; +} + +/** True when a raw URL is openable under the scheme allowlist. */ +export function isAllowedExternalUrl(raw: string): boolean { + const scheme = urlScheme(raw); + return scheme !== null && ALLOWED_SCHEMES.has(scheme); +} + +/** + * Open an external link when policy allows it; blocked links call onBlocked + * instead (the shell shows a toast). Open failures are swallowed, matching + * the previous behavior - a dead link must not crash the shell. + */ +export function openExternalUrl(raw: string, onBlocked: () => void) { + if (!isAllowedExternalUrl(raw)) { + onBlocked(); + return; + } + Linking.openURL(raw.trim()).catch(() => {}); +} diff --git a/src/genos/tools/images.ts b/src/genos/tools/images.ts index 39edc3a..6c80e57 100644 --- a/src/genos/tools/images.ts +++ b/src/genos/tools/images.ts @@ -48,6 +48,38 @@ export function loremflickrUrl({ q, seed, w, h }: ImgQuery): string { return `https://loremflickr.com/${w}/${h}/${keywords}?lock=${seed}`; } +/** + * A non-semantic src is a URL the model made up: an outbound GET it fully + * controls, and a channel to smuggle conversation context to any host (the + * prompt-injection exfil path). Only https URLs on allowlisted image hosts + * resolve; anything else falls back to the keyless LoremFlickr path below. + */ +export const ALLOWED_IMAGE_HOSTS = new Set([ + "images.unsplash.com", + "loremflickr.com", + "upload.wikimedia.org", +]); + +/** Host of an absolute https URL, lowercased; null for anything else. */ +export function imageUrlHost(src: string): string | null { + const m = src.trim().match(/^https:\/\/([a-z0-9.-]+)(?::\d+)?(?:[/?#]|$)/i); + return m ? (m[1]?.toLowerCase() ?? null) : null; +} + +/** True when a model-supplied URL points at an allowlisted https image host. */ +export function isAllowedImageUrl(src: string): boolean { + const host = imageUrlHost(src); + return host !== null && ALLOWED_IMAGE_HOSTS.has(host); +} + +/** Blocked URLs resolve to this generic semantic image instead. */ +const FALLBACK_QUERY: ImgQuery = { q: "abstract gradient", seed: 1, w: 800, h: 500 }; + +/** What a non-semantic src may load: the URL itself, or the safe fallback. */ +export function resolveExternalImageUrl(src: string): string { + return isAllowedImageUrl(src) ? src : loremflickrUrl(FALLBACK_QUERY); +} + /** query → Unsplash raw URLs; empty array = search failed, use LoremFlickr. */ const unsplashCache = new Map(); const unsplashPending = new Map>(); @@ -79,7 +111,8 @@ function ensureUnsplash(q: string): Promise { } /** - * Resolve a generated src to a loadable URL. Non-semantic srcs pass through. + * Resolve a generated src to a loadable URL. Non-semantic srcs go through the + * image-host policy (allowlisted hosts only, else the LoremFlickr fallback). * With an Unsplash key, returns undefined (placeholder) while the search is * in flight so the image doesn't double-load. */ @@ -107,7 +140,7 @@ export function useSemanticImage(src?: string): string | undefined { }, [q]); if (!src) return undefined; - if (!parsed) return src; + if (!parsed) return resolveExternalImageUrl(src); if (!q) return loremflickrUrl(parsed); const candidates = unsplashCache.get(parsed.q); if (candidates === undefined) return undefined; // still searching From eac3c9ac0dfa5cc83d84110cda165a342c5ceb59 Mon Sep 17 00:00:00 2001 From: Vext Labs <261808311+Vext-Labs@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:17:22 -0400 Subject: [PATCH 2/6] feat: wire safety guards into the shell and store - GenOS.handleAction routes external URLs through openExternalUrl with a blocked-link toast, and genos:// links through parseOpenLink/capToastText - store.resolveAction redacts credential-shaped form values before they are sent to the provider or replayed as context; openDeepLink re-caps the injected request (defense in depth) - KeyGate masks the API-key entry field (secureTextEntry) - media.tsx documents that raw URLs pass the image-host policy first --- src/genos/GenOS.tsx | 10 ++++++---- src/genos/shell/KeyGate.tsx | 1 + src/genos/store.ts | 13 ++++++++++--- src/genos/ui/shared/media.tsx | 4 +++- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/genos/GenOS.tsx b/src/genos/GenOS.tsx index 6e6b29e..11c8ed8 100644 --- a/src/genos/GenOS.tsx +++ b/src/genos/GenOS.tsx @@ -12,7 +12,6 @@ import { BackHandler, Easing, KeyboardAvoidingView, - Linking, Platform, Pressable, ScrollView, @@ -25,6 +24,8 @@ import { cerebrasKey } from "../config"; import type { AppDef } from "./apps"; import { APPS, DEFAULT_TILE, summonApp } from "./apps"; import { genosLibrary } from "./library"; +import { capToastText, parseOpenLink } from "./safety/genosLink"; +import { openExternalUrl } from "./safety/urlPolicy"; import { HomeScreen } from "./shell/HomeScreen"; import { KeyGate } from "./shell/KeyGate"; import { Switcher, type RunningApp } from "./shell/Switcher"; @@ -422,16 +423,17 @@ export default function GenOS() { const parsed = parseGenosUrl(url); if (parsed) { const { cmd, params } = parsed; - if (cmd === "toast") showToast(params.text || "Done ✓"); + if (cmd === "toast") showToast(capToastText(params.text || "Done ✓")); else if (cmd === "open") { - if (params.app && params.request) deepLink(params.app, params.request); + const link = parseOpenLink(params); + if (link) deepLink(link.appId, link.request); } else if (cmd === "back") goBack(); else if (cmd === "home") goHome(); } return; } if (typeof url === "string" && url) { - Linking.openURL(url).catch(() => {}); + openExternalUrl(url, () => showToast("That link type isn't supported")); return; } const message = ev.humanFriendlyMessage?.trim(); diff --git a/src/genos/shell/KeyGate.tsx b/src/genos/shell/KeyGate.tsx index 4a07c34..0f920cc 100644 --- a/src/genos/shell/KeyGate.tsx +++ b/src/genos/shell/KeyGate.tsx @@ -60,6 +60,7 @@ export function KeyGate({ status }: { status: KeyStatus }) { placeholderTextColor={t.ink3} autoCapitalize="none" autoCorrect={false} + secureTextEntry onSubmitEditing={save} style={{ width: "100%", diff --git a/src/genos/store.ts b/src/genos/store.ts index 5d943e1..43c5019 100644 --- a/src/genos/store.ts +++ b/src/genos/store.ts @@ -1,5 +1,7 @@ import type { AppDef } from "./apps"; import { APPS } from "./apps"; +import { GENOS_REQUEST_MAX } from "./safety/genosLink"; +import { redactFormState } from "./safety/redaction"; import type { ChatMessage } from "./stream"; import { streamScreen } from "./stream"; @@ -311,7 +313,10 @@ const deepLinkIndex = new Map(); /** Open a screen in another app via a genos://open deep link. */ export function openDeepLink(appId: string, request: string): string { - const key = `${appId.toLowerCase()} ${request}`; + // Deep links are model output: cap the injected request here too, not only + // at the genos:// callsite, so any future caller gets the same bound. + const capped = request.slice(0, GENOS_REQUEST_MAX); + const key = `${appId.toLowerCase()} ${capped}`; const existing = deepLinkIndex.get(key); if (existing) { const screen = screenStore.get(existing); @@ -325,7 +330,7 @@ export function openDeepLink(appId: string, request: string): string { const id = launchScreen({ appId: app?.id ?? appId.toLowerCase(), appName: app?.name ?? appId.charAt(0).toUpperCase() + appId.slice(1), - request, + request: capped, speculative: false, }); deepLinkIndex.set(key, id); @@ -365,7 +370,9 @@ export function resolveAction( } const request = hasFormValues - ? `${message}\n\nSubmitted form values: ${JSON.stringify(formState)}` + ? // Credential-shaped field values are redacted before they leave the + // device - the request also replays as context on later screens. + `${message}\n\nSubmitted form values: ${JSON.stringify(redactFormState(formState))}` : message; const id = launchScreen({ appId: parent?.appId ?? "unknown", diff --git a/src/genos/ui/shared/media.tsx b/src/genos/ui/shared/media.tsx index c914c47..24b383d 100644 --- a/src/genos/ui/shared/media.tsx +++ b/src/genos/ui/shared/media.tsx @@ -2,7 +2,9 @@ * Semantic image element shared by all design systems: resolves /api/img * queries via useSemanticImage, fades in on load, and shows a themed * placeholder while unresolved. Follows the createMapRenderer pattern - - * design systems supply only their placeholder color. + * design systems supply only their placeholder color. Raw model-supplied + * URLs never reach directly: useSemanticImage filters them through + * the image-host policy in tools/images.ts first. */ import React, { useState } from "react"; import { Image, View } from "react-native"; From 4664036250ea9040650900702f0fda09fcd2cead Mon Sep 17 00:00:00 2001 From: Vext Labs <261808311+Vext-Labs@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:17:22 -0400 Subject: [PATCH 3/6] test+docs: security regression tests and SECURITY.md 12 tests covering the scheme allowlist (javascript:/intent:/sms:/etc blocked), deep-link caps and app-id validation, password redaction into the provider request, and the image-host policy with its LoremFlickr fallback. SECURITY.md adds a reporting policy plus honest hardening notes and limits. --- SECURITY.md | 59 ++++++++++++ __tests__/security.test.ts | 184 +++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 SECURITY.md create mode 100644 __tests__/security.test.ts diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ca4c3ae --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,59 @@ +# Security Policy + +## Reporting a vulnerability + +AppLess is a demo, but it handles API keys and renders model-generated UI, so +vulnerability reports are taken seriously. Please do **not** open a public +issue for a security problem. Report it privately through GitHub's "Report a +vulnerability" flow on the repository (Security tab). Include a repro, the +affected commit, and whether keys or user data are exposed. We aim to +acknowledge reports within 7 days. + +## Scope notes + +- BYOK keys are stored on-device (SecureStore on iOS/Android; `localStorage` + on web, which is readable by any XSS - web is a dev surface). +- `EXPO_PUBLIC_*` env keys are inlined into the JS bundle at build time. + Never ship a production build with them set. + +## Hardening notes + +Everything the model emits is treated as untrusted output. These runtime +guards enforce what the system prompt can only request: + +- **Outbound links** - only `http:`/`https:` URLs reach `Linking`; every other + scheme (`javascript:`, `intent:`, `tel:`, `sms:`, `file:`, app schemes) is + blocked with a toast. See `src/genos/safety/urlPolicy.ts`, wired into + `GenOS.handleAction`. +- **Image hosts** - a model-supplied image URL resolves only when it is an + https URL on `images.unsplash.com`, `loremflickr.com`, or + `upload.wikimedia.org`; anything else falls back to the keyless LoremFlickr + semantic path. This closes the "image URL with encoded conversation + context" exfiltration channel. See `src/genos/tools/images.ts`. +- **`genos://` deep links** - `genos://open` targets must be catalog apps or + the safe summon-id shape; the injected request is capped at 300 chars, and + `genos://toast` text at 120 chars. See `src/genos/safety/genosLink.ts`, + applied at the `parseGenosUrl` callsite and again in `store.openDeepLink`. +- **Form redaction** - credential-shaped field values (`password`, `pin`, + `cvv`, `otp`, `token`, ...) are replaced with `"[redacted]"` before + submitted form state is sent to the model provider or replayed as + conversation context. See `src/genos/safety/redaction.ts`, applied in + `store.resolveAction`. +- **Key entry** - the API-key field is masked (`secureTextEntry`). See + `src/genos/shell/KeyGate.tsx`. + +## Honest limits + +- Redaction keys off field **names**, not field type metadata (the type does + not reach the store): a password field named `favoriteWord` is not + redacted. Blocking credential screens outright is a contract-level decision + left to upstream. +- Web-search tool results are still fed back to the model unmarked as + untrusted data; injection-resistant tool-result handling is roadmap work, + not shipped here. +- Provider error bodies are still surfaced raw in the UI; friendly error + mapping is not part of this pass. +- Telemetry ships with the upstream PostHog key by default (see README); + unchanged here. +- Prompt rules ("https links only", "use the image service", the fixed app + list) remain advisory - the guards above are the enforcement. diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts new file mode 100644 index 0000000..c6023cf --- /dev/null +++ b/__tests__/security.test.ts @@ -0,0 +1,184 @@ +/** + * Security regression tests for the trust layer: outbound URL policy, + * genos:// deep-link validation and caps, credential redaction before form + * values reach the model provider, and the image-host policy. + */ + +jest.mock("expo-secure-store", () => ({ + getItemAsync: async () => null, + setItemAsync: async () => {}, + deleteItemAsync: async () => {}, +})); +jest.mock("expo/fetch", () => ({ fetch: jest.fn() })); + +// Capture stream launches instead of hitting Cerebras (store redaction test). +jest.mock("../src/genos/stream", () => ({ + NEEDS_LIVE_DATA: "needs live data", + streamScreen: jest.fn(), +})); + +import { Linking } from "react-native"; +import { + GENOS_REQUEST_MAX, + GENOS_TOAST_MAX, + capToastText, + isAllowedAppId, + parseOpenLink, +} from "../src/genos/safety/genosLink"; +import { REDACTED, isSensitiveField, redactFormState } from "../src/genos/safety/redaction"; +import { isAllowedExternalUrl, openExternalUrl, urlScheme } from "../src/genos/safety/urlPolicy"; +import { openApp, resolveAction, screenStore } from "../src/genos/store"; +import { + imageUrlHost, + isAllowedImageUrl, + resolveExternalImageUrl, +} from "../src/genos/tools/images"; + +const openURLSpy = jest.spyOn(Linking, "openURL").mockResolvedValue(true); + +beforeEach(() => { + openURLSpy.mockClear(); +}); + +describe("urlPolicy: outbound scheme allowlist", () => { + it("extracts the scheme case-insensitively, null when absent", () => { + expect(urlScheme("https://example.com/x")).toBe("https:"); + expect(urlScheme("HTTP://example.com")).toBe("http:"); + expect(urlScheme("javascript:alert(1)")).toBe("javascript:"); + expect(urlScheme(" tel:+15551234")).toBe("tel:"); + expect(urlScheme("example.com/path")).toBeNull(); + expect(urlScheme("//example.com")).toBeNull(); + }); + + it("opens http and https links", () => { + const onBlocked = jest.fn(); + openExternalUrl("https://example.com/page", onBlocked); + openExternalUrl("http://example.com", onBlocked); + openExternalUrl(" HTTPS://EXAMPLE.COM/Caps ", onBlocked); + expect(openURLSpy).toHaveBeenCalledTimes(3); + expect(openURLSpy).toHaveBeenNthCalledWith(3, "HTTPS://EXAMPLE.COM/Caps"); + expect(onBlocked).not.toHaveBeenCalled(); + expect(isAllowedExternalUrl("https://example.com")).toBe(true); + }); + + it("blocks every other scheme instead of calling Linking", () => { + const blocked = [ + "javascript:alert(document.cookie)", + "intent://scan/#Intent;scheme=zxing;package=com.evil;end", + "file:///etc/passwd", + "sms:+15551234?body=send%20me%20money", + "tel:+15551234", + "mailto:a@b.c?subject=x&body=y", + "genos://toast?text=hi", + "market://details?id=com.evil", + "example.com/no-scheme", + "", + ]; + for (const url of blocked) { + const onBlocked = jest.fn(); + openExternalUrl(url, onBlocked); + expect(onBlocked).toHaveBeenCalledTimes(1); + expect(isAllowedExternalUrl(url)).toBe(false); + } + expect(openURLSpy).not.toHaveBeenCalled(); + }); +}); + +describe("genosLink: deep-link validation and caps", () => { + it("accepts catalog ids and safe summon-slug shapes only", () => { + expect(isAllowedAppId("food")).toBe(true); + expect(isAllowedAppId("Music")).toBe(true); + expect(isAllowedAppId("summon-pizza-tracker")).toBe(true); + expect(isAllowedAppId("a".repeat(32))).toBe(true); + expect(isAllowedAppId("a".repeat(33))).toBe(false); + expect(isAllowedAppId("evil app!")).toBe(false); + expect(isAllowedAppId("../settings")).toBe(false); + expect(isAllowedAppId("")).toBe(false); + }); + + it("validates open params and caps the request at 300 chars", () => { + expect(parseOpenLink({ app: "Food", request: "show lunch spots" })).toEqual({ + appId: "food", + request: "show lunch spots", + }); + const long = parseOpenLink({ app: "food", request: "x".repeat(400) }); + expect(long?.request).toHaveLength(GENOS_REQUEST_MAX); + expect(GENOS_REQUEST_MAX).toBe(300); + expect(parseOpenLink({ app: "evil app!", request: "hi" })).toBeNull(); + expect(parseOpenLink({ app: "food" })).toBeNull(); + expect(parseOpenLink({ request: "hi" })).toBeNull(); + expect(parseOpenLink({})).toBeNull(); + }); + + it("caps toast text at 120 chars", () => { + expect(capToastText("Done ✓")).toBe("Done ✓"); + expect(capToastText("y".repeat(200))).toHaveLength(GENOS_TOAST_MAX); + expect(GENOS_TOAST_MAX).toBe(120); + }); +}); + +describe("redaction: credential-shaped form fields", () => { + it("flags credential words without hitting lookalikes", () => { + for (const name of ["password", "Password", "cardCvv", "user_pin", "otp", "passphrase"]) { + expect(isSensitiveField(name)).toBe(true); + } + for (const name of ["shopping", "email", "username", "spinner", "city"]) { + expect(isSensitiveField(name)).toBe(false); + } + }); + + it("replaces sensitive values, keeps the rest", () => { + const out = redactFormState({ password: "hunter2", cardCvv: "123", city: "Goa" }); + expect(out).toEqual({ password: REDACTED, cardCvv: REDACTED, city: "Goa" }); + expect(redactFormState({})).toEqual({}); + }); + + it("strips credentials from the request sent to the provider", () => { + const app = { + id: "food", + name: "Food", + emoji: "🍜", + tile: ["#000", "#111"] as [string, string], + request: "Open food", + }; + const parentId = openApp(app); + screenStore.patch(parentId, { status: "done" }); + const child = resolveAction(parentId, "Checkout", { + password: "hunter2", + cardCvv: "123", + city: "Goa", + }); + const request = screenStore.get(child)?.request ?? ""; + expect(request).not.toContain("hunter2"); + expect(request).not.toContain("123"); + expect(request).toContain(REDACTED); + expect(request).toContain("Goa"); + }); +}); + +describe("image-host policy", () => { + it("parses https hosts only", () => { + expect(imageUrlHost("https://images.unsplash.com/photo-1?w=80")).toBe("images.unsplash.com"); + expect(imageUrlHost("https://ATTACKER.example.evil/x.png")).toBe("attacker.example.evil"); + expect(imageUrlHost("http://images.unsplash.com/x")).toBeNull(); + expect(imageUrlHost("ftp://images.unsplash.com/x")).toBeNull(); + expect(imageUrlHost("/api/img?q=cats")).toBeNull(); + }); + + it("allows only the three image hosts over https", () => { + expect(isAllowedImageUrl("https://images.unsplash.com/photo-1")).toBe(true); + expect(isAllowedImageUrl("https://loremflickr.com/800/500/cats?lock=1")).toBe(true); + expect(isAllowedImageUrl("https://upload.wikimedia.org/w/a/b.png")).toBe(true); + // The exfil shape: arbitrary host, context encoded in the query. + expect(isAllowedImageUrl("https://attacker.example/c.png?d=secret")).toBe(false); + expect(isAllowedImageUrl("http://images.unsplash.com/photo-1")).toBe(false); + }); + + it("falls back to the LoremFlickr semantic path for blocked URLs", () => { + const good = "https://upload.wikimedia.org/w/a/b.png"; + expect(resolveExternalImageUrl(good)).toBe(good); + expect(resolveExternalImageUrl("https://attacker.example/c.png?d=secret")).toBe( + "https://loremflickr.com/800/500/abstract%2Cgradient?lock=1", + ); + }); +}); From f3d13ae93a7b3d5d9d51f5ec6441747c3bc0bbe2 Mon Sep 17 00:00:00 2001 From: Vext Labs <261808311+Vext-Labs@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:32:31 -0400 Subject: [PATCH 4/6] feat: action risk tiers, receipt log, and pluggable executor Honest Actions foundation: classifyAction splits model-declared actions into auto (navigation/read) vs consequential (order/pay/send/delete- shaped, whole-word heuristic); a KeyStore-shaped receipt log records confirmed-simulated and declined outcomes; a pluggable executor registry ships a default SimulatedExecutor that says so honestly, with registerExecutor() as the seam for the README's real integrations. Executors never run from prefetch/speculative paths. --- src/genos/actions/executor.ts | 53 +++++++++++++++++++++++++++++++++ src/genos/actions/model.ts | 22 ++++++++++++++ src/genos/actions/receipts.ts | 55 +++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/genos/actions/executor.ts create mode 100644 src/genos/actions/model.ts create mode 100644 src/genos/actions/receipts.ts diff --git a/src/genos/actions/executor.ts b/src/genos/actions/executor.ts new file mode 100644 index 0000000..a2c7ea9 --- /dev/null +++ b/src/genos/actions/executor.ts @@ -0,0 +1,53 @@ +/** + * Pluggable action executors. The default SimulatedExecutor keeps today's + * behavior - an action only ever generates the next screen - and records an + * honest receipt saying so. registerExecutor() is the seam the README's + * "make it real" integrations (DoorDash, Plaid, ...) plug into later: a real + * executor performs the integration and returns its own honest outcome note. + * + * Invariant: executors run ONLY from explicit post-confirmation taps in the + * shell. They are never called from prefetch or any speculative generation + * path - a speculative screen must never fire a side effect. + */ +import { SIMULATED_NOTE, receipts } from "./receipts"; + +export interface ConfirmedAction { + appId: string; + /** The action message the user explicitly confirmed. */ + label: string; +} + +export interface ActionExecutor { + /** + * Run a confirmed action. Returns the honest outcome note the shell shows + * as the receipt toast ("Simulated - ..." today, something real later). + */ + run(action: ConfirmedAction): string | void; +} + +/** Default executor: nothing real happens - record the receipt and say so. */ +class SimulatedExecutor implements ActionExecutor { + run(action: ConfirmedAction): string { + receipts.record({ + appId: action.appId, + label: action.label, + tier: "consequential", + status: "confirmed-simulated", + }); + return SIMULATED_NOTE; + } +} + +let current: ActionExecutor = new SimulatedExecutor(); + +/** Swap in another executor (real integration, test spy); returns the old. */ +export function registerExecutor(next: ActionExecutor): ActionExecutor { + const prev = current; + current = next; + return prev; +} + +/** Run a post-confirmation action through the registered executor. */ +export function executeConfirmed(action: ConfirmedAction): string | void { + return current.run(action); +} diff --git a/src/genos/actions/model.ts b/src/genos/actions/model.ts new file mode 100644 index 0000000..ac677d9 --- /dev/null +++ b/src/genos/actions/model.ts @@ -0,0 +1,22 @@ +/** + * Action risk model. Every model-declared action arrives as a free-text + * message (@ToAssistant), so classification is a small documented heuristic + * over its wording: navigation/read-shaped taps auto-execute (today's + * behavior), while consequential verbs - anything that reads as moving + * money, sending messages, or destroying data - pause for an explicit + * confirmation first. False positives only cost a confirmation tap (safe + * direction); false negatives behave exactly as before this layer existed. + */ +export type ActionTier = "auto" | "consequential"; + +/** + * Verbs that make an action consequential, matched as whole words so + * "show my messages", "my books", and "text formatting" stay auto. + */ +const CONSEQUENTIAL_RE = + /\b(order|buy|purchase|pay|payment|charge|book|booking|reserve|reservation|send|transfer|delete|cancel|subscribe|post|share|message|text|call)\b/i; + +/** Classify a tapped action message into a risk tier. */ +export function classifyAction(message: string): ActionTier { + return CONSEQUENTIAL_RE.test(message) ? "consequential" : "auto"; +} diff --git a/src/genos/actions/receipts.ts b/src/genos/actions/receipts.ts new file mode 100644 index 0000000..ef66849 --- /dev/null +++ b/src/genos/actions/receipts.ts @@ -0,0 +1,55 @@ +/** + * Receipt log: every confirmed or declined consequential action leaves a + * durable, honest record. Receipts say "simulated" because nothing real is + * wired up yet - the log exists so the UI never lies about what happened, + * and so a future rollback/history surface has the trail it needs. Shape + * mirrors config.ts's KeyStore, so the shell can subscribe via + * useSyncExternalStore. + */ +import type { ActionTier } from "./model"; + +export type ReceiptStatus = "confirmed-simulated" | "declined"; + +export interface Receipt { + id: string; + appId: string; + /** The action message exactly as the user saw and confirmed/declined it. */ + label: string; + tier: ActionTier; + status: ReceiptStatus; + timestamp: number; +} + +/** Honest one-liner the simulated executor returns for the receipt toast. */ +export const SIMULATED_NOTE = "Simulated - no real order was placed"; + +let counter = 0; + +class ReceiptStore { + private log: Receipt[] = []; + private listeners = new Set<() => void>(); + + subscribe = (fn: () => void) => { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + }; + + /** Newest first - the read shape a receipts surface would render. */ + getReceipts = (): Receipt[] => this.log; + + /** Append a receipt and notify subscribers. */ + record(entry: Omit): Receipt { + const receipt: Receipt = { + ...entry, + id: `receipt-${Date.now()}-${++counter}`, + timestamp: Date.now(), + }; + this.log = [receipt, ...this.log]; + this.listeners.forEach((fn) => fn()); + return receipt; + } +} + +export const receipts = new ReceiptStore(); From b6d94ed01d3c98685e923e376e9a51be0181e88e Mon Sep 17 00:00:00 2001 From: Vext Labs <261808311+Vext-Labs@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:32:31 -0400 Subject: [PATCH 5/6] feat: confirmation sheet for consequential actions Consequential taps pause on a sheet stating exactly what will happen ('AppLess will simulate this action. Nothing real is charged or booked.'). Confirm runs the executor, receipts the action, shows the honest outcome toast, and proceeds; Cancel records a declined receipt and does nothing else. Total GenOS diff across both passes: ~104 lines. --- src/genos/GenOS.tsx | 94 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/genos/GenOS.tsx b/src/genos/GenOS.tsx index 11c8ed8..50a5d03 100644 --- a/src/genos/GenOS.tsx +++ b/src/genos/GenOS.tsx @@ -21,6 +21,9 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cerebrasKey } from "../config"; +import { executeConfirmed } from "./actions/executor"; +import { classifyAction } from "./actions/model"; +import { receipts } from "./actions/receipts"; import type { AppDef } from "./apps"; import { APPS, DEFAULT_TILE, summonApp } from "./apps"; import { genosLibrary } from "./library"; @@ -202,6 +205,12 @@ export default function GenOS() { const [toast, setToast] = useState<{ text: string; key: number } | null>(null); const toastTimer = useRef | null>(null); + /** A consequential tap paused on the confirmation sheet. */ + const [pendingAction, setPendingAction] = useState<{ + message: string; + formState?: Record; + } | null>(null); + /** Apps the user has sent home - only these show as icons on the home screen. */ const [minimizedIds, setMinimizedIds] = useState([]); /** True while the minimize-into-icon animation is playing. */ @@ -461,11 +470,41 @@ export default function GenOS() { goBack(); return; } + // Consequential taps (order/pay/send/delete-shaped) pause on the + // confirmation sheet; the action only runs from its Confirm button - + // never from prefetch or any speculative path. + if (classifyAction(message) === "consequential") { + setPendingAction({ message, formState: ev.formState }); + return; + } pushScreen(activeApp, resolveAction(topId, message, ev.formState)); }, [topId, activeApp, generating, deepLink, goBack, goHome, showToast, pushScreen], ); + /** Confirm: the executor runs + receipts the action, then the flow proceeds. */ + const confirmPendingAction = useCallback(() => { + const pending = pendingAction; + setPendingAction(null); + if (!pending || !topId || !activeApp) return; + const note = executeConfirmed({ appId: activeApp, label: pending.message }); + if (note) showToast(note); + pushScreen(activeApp, resolveAction(topId, pending.message, pending.formState)); + }, [pendingAction, topId, activeApp, pushScreen, showToast]); + + /** Decline: an honest declined receipt, and nothing else happens. */ + const declinePendingAction = useCallback(() => { + const pending = pendingAction; + setPendingAction(null); + if (!pending || !activeApp) return; + receipts.record({ + appId: activeApp, + label: pending.message, + tier: "consequential", + status: "declined", + }); + }, [pendingAction, activeApp]); + /** Android hardware back mirrors the shell's back gesture. */ useEffect(() => { const sub = BackHandler.addEventListener("hardwareBackPress", () => { @@ -778,6 +817,61 @@ export default function GenOS() { )} + {pendingAction && ( + + + {pendingAction.message} + + + AppLess will simulate this action. Nothing real is charged or booked. + + + ({ + paddingVertical: 8, + paddingHorizontal: 18, + borderRadius: 16, + opacity: pressed ? 0.6 : 1, + })} + > + Cancel + + ({ + paddingVertical: 8, + paddingHorizontal: 18, + borderRadius: 16, + backgroundColor: "#5e5ce6", + opacity: pressed ? 0.8 : 1, + })} + > + Confirm + + + + )} + {toast && ( Date: Fri, 17 Jul 2026 11:32:31 -0400 Subject: [PATCH 6/6] test+docs: honest-actions coverage and design note 6 tests: classification both directions, receipt record/decline ordering, default executor honesty, registry swap/restore, and the no-prefetch invariant (spy executor survives speculative prefetch untouched). docs/honest-actions.md covers the tier model, registry API, the D19/D20/D34 mapping, and honest limits. --- __tests__/actions.test.ts | 136 ++++++++++++++++++++++++++++++++++++++ docs/honest-actions.md | 93 ++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 __tests__/actions.test.ts create mode 100644 docs/honest-actions.md diff --git a/__tests__/actions.test.ts b/__tests__/actions.test.ts new file mode 100644 index 0000000..c8a370a --- /dev/null +++ b/__tests__/actions.test.ts @@ -0,0 +1,136 @@ +/** + * Honest Actions tests: risk classification, confirmation gating, receipt + * record/decline, executor registry swap, and the no-prefetch invariant + * (executors never run from speculative generation paths). + */ + +jest.mock("expo-secure-store", () => ({ + getItemAsync: async () => null, + setItemAsync: async () => {}, + deleteItemAsync: async () => {}, +})); +jest.mock("expo/fetch", () => ({ fetch: jest.fn() })); + +// Capture stream launches instead of hitting Cerebras. +const streamCalls: Array<{ messages: unknown }> = []; +jest.mock("../src/genos/stream", () => ({ + NEEDS_LIVE_DATA: "needs live data", + streamScreen: jest.fn((messages: unknown) => { + streamCalls.push({ messages }); + }), +})); + +import { executeConfirmed, registerExecutor, type ActionExecutor } from "../src/genos/actions/executor"; +import { classifyAction } from "../src/genos/actions/model"; +import { SIMULATED_NOTE, receipts } from "../src/genos/actions/receipts"; +import { openApp, resolveAction, screenStore, setActiveScreen } from "../src/genos/store"; + +describe("classifyAction", () => { + it("flags order/book/pay/send/delete-shaped messages as consequential", () => { + for (const m of [ + "Order a margherita pizza", + "Book a flight to Goa", + "Pay my rent", + "Send the money", + "Delete my account", + "Cancel my subscription", + "Text Maya that I'm late", + "Transfer $50 to savings", + ]) { + expect(classifyAction(m)).toBe("consequential"); + } + }); + + it("leaves navigation and read-shaped messages on auto", () => { + for (const m of [ + "Open the Wi-Fi screen", + "Show my messages", + "Show my books", + "What's the weather this week", + "See transaction history", + ]) { + expect(classifyAction(m)).toBe("auto"); + } + }); +}); + +describe("receipts", () => { + it("records confirmed and declined receipts, newest first", () => { + const confirmed = receipts.record({ + appId: "food", + label: "Order a test ramen", + tier: "consequential", + status: "confirmed-simulated", + }); + expect(confirmed.id).toContain("receipt-"); + expect(confirmed.timestamp).toBeGreaterThan(0); + receipts.record({ + appId: "food", + label: "Order a test sushi", + tier: "consequential", + status: "declined", + }); + const [newest, previous] = receipts.getReceipts(); + expect(newest?.label).toBe("Order a test sushi"); + expect(newest?.status).toBe("declined"); + expect(previous?.label).toBe("Order a test ramen"); + }); +}); + +describe("executor registry", () => { + it("default executor receipts honestly and says it simulated", () => { + const note = executeConfirmed({ appId: "flights", label: "Book a test flight" }); + expect(note).toBe(SIMULATED_NOTE); + const newest = receipts.getReceipts()[0]; + expect(newest?.label).toBe("Book a test flight"); + expect(newest?.status).toBe("confirmed-simulated"); + }); + + it("registerExecutor swaps the executor and returns the previous one", () => { + const run = jest.fn(() => "real integration note"); + const prev = registerExecutor({ run }); + const note = executeConfirmed({ appId: "banking", label: "Pay a test bill" }); + expect(run).toHaveBeenCalledWith({ appId: "banking", label: "Pay a test bill" }); + expect(note).toBe("real integration note"); + // Restore the default simulated executor for other tests. + expect(registerExecutor(prev)).toBeDefined(); + }); +}); + +describe("no-prefetch invariant", () => { + const app = { + id: "food", + name: "Food", + emoji: "🍜", + tile: ["#000", "#111"] as [string, string], + request: "Open food", + }; + + it("speculative prefetch and plain resolveAction never run an executor", () => { + const run = jest.fn(); + const prev = registerExecutor({ run }); + try { + // A completed screen with a consequential action prefetches it + // speculatively - generation must not fire any side effect. + const parentId = openApp(app); + screenStore.patch(parentId, { + status: "done", + content: 'b = Button("Order", Action([@ToAssistant("Order the pad thai")]))', + }); + setActiveScreen(parentId); + const prefetched = streamCalls.length; + expect(prefetched).toBeGreaterThan(0); + + // Even resolving the action (what a tap does pre-confirmation) only + // generates the next screen - the executor is a post-confirmation seam. + resolveAction(parentId, "Order the pad thai"); + expect(run).not.toHaveBeenCalled(); + + // Only an explicit confirmation runs it. + executeConfirmed({ appId: "food", label: "Order the pad thai" }); + expect(run).toHaveBeenCalledTimes(1); + } finally { + registerExecutor(prev); + } + }); +}); diff --git a/docs/honest-actions.md b/docs/honest-actions.md new file mode 100644 index 0000000..6f70972 --- /dev/null +++ b/docs/honest-actions.md @@ -0,0 +1,93 @@ +# Honest Actions: confirmation + receipts + +AppLess's README warns that actions are always simulated - "Order placed", +"flight booked", "payment sent" do nothing and reflect no real state. This +layer makes that honesty *structural*: consequential taps pause for an +explicit confirmation, every outcome leaves a receipt that says what actually +happened, and the default executor says "simulated" out loud instead of +letting a toast imply success. + +## Risk tiers + +Every model-declared action arrives as a free-text message (`@ToAssistant`), +so classification is a small documented heuristic over its wording +(`src/genos/actions/model.ts`): + +| Tier | Shape | Behavior | +| --- | --- | --- | +| `auto` | navigation/read ("Open the Wi-Fi screen", "Show my messages") | executes immediately - today's behavior, unchanged | +| `consequential` | order/book/pay/send/delete-shaped verbs, whole-word matched | pauses on the confirmation sheet | + +False positives only cost a confirmation tap (the safe direction); false +negatives behave exactly as before this layer existed. + +## Flow + +``` +tap -> handleAction -> classifyAction + auto -> resolveAction (unchanged) + consequential -> confirmation sheet: + "" + "AppLess will simulate this action. + Nothing real is charged or booked." + [Cancel] [Confirm] + Confirm -> executeConfirmed(...) -> receipt (confirmed-simulated) + -> honest receipt toast -> resolveAction proceeds + Cancel -> receipt (declined) -> nothing else happens +``` + +## Executor registry + +`src/genos/actions/executor.ts` is the seam the README's "make it real" +integrations (DoorDash, Plaid, Spotify, ...) plug into: + +```ts +interface ActionExecutor { + // Returns the honest outcome note shown as the receipt toast. + run(action: { appId: string; label: string }): string | void; +} +registerExecutor(myExecutor); // swap in a real integration; returns the old one +``` + +The default `SimulatedExecutor` records a `confirmed-simulated` receipt and +returns the note `Simulated - no real order was placed`. A real executor +performs its integration and supplies its own honest note - the shell never +hardcodes a success claim. + +## The no-prefetch invariant + +Executors run **only** from explicit post-confirmation taps. They are never +called from prefetch or any speculative generation path - a speculative +screen must never fire a side effect. This is enforced by construction (the +store's prefetch machinery has no reference to the executor registry) and +covered by a regression test that drives prefetch with a consequential +action while a spy executor is registered. + +## Receipts + +`src/genos/actions/receipts.ts` is a `useSyncExternalStore`-shaped log +(mirroring `config.ts`'s KeyStore): `{ id, appId, label, tier, status, +timestamp }`, newest first, where status is `confirmed-simulated` or +`declined`. A declined tap is recorded too - the trail covers what was *not* +done, which is what an audit or undo surface needs. + +## Thesys Agentic Interface Framework mapping + +- **D19/D20 (Keep control: approvals, "show exactly what will happen")** - + consequential actions require explicit approval, and the sheet states the + outcome up front: simulation, nothing charged or booked. +- **D34 (Expect failure: rollback)** - true rollback needs real state to + revert, which does not exist yet; the receipt log is its prerequisite, + giving every confirmed and declined action a durable, inspectable record. + +## Honest limits + +- The tier heuristic is lexical: "show payment history" pauses (false + positive), and a consequential request phrased without the verb list does + not (false negative). Both failure modes are documented, and the safe + direction is the default. +- Typed/ask-bar commands bypass the sheet by design - the user already + typed the intent explicitly; only model-proposed taps are gated. +- Receipts are in-memory for the session; persistence is future work. +- There is no receipts UI yet - the log is consumable via + `useSyncExternalStore(receipts.subscribe, receipts.getReceipts)`.