Skip to content
Draft
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.
136 changes: 136 additions & 0 deletions __tests__/actions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
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",
);
});
});
Loading