Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
184 changes: 184 additions & 0 deletions __tests__/security.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
10 changes: 6 additions & 4 deletions src/genos/GenOS.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
BackHandler,
Easing,
KeyboardAvoidingView,
Linking,
Platform,
Pressable,
ScrollView,
Expand All @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
42 changes: 42 additions & 0 deletions src/genos/safety/genosLink.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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);
}
50 changes: 50 additions & 0 deletions src/genos/safety/redaction.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(formState)) {
out[key] = isSensitiveField(key) ? REDACTED : value;
}
return out;
}
Loading