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__/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/__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", + ); + }); +}); 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)`. diff --git a/src/genos/GenOS.tsx b/src/genos/GenOS.tsx index 6e6b29e..50a5d03 100644 --- a/src/genos/GenOS.tsx +++ b/src/genos/GenOS.tsx @@ -12,7 +12,6 @@ import { BackHandler, Easing, KeyboardAvoidingView, - Linking, Platform, Pressable, ScrollView, @@ -22,9 +21,14 @@ 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"; +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"; @@ -201,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. */ @@ -422,16 +432,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(); @@ -459,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", () => { @@ -776,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 && ( 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(); 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/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/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 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";