Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
105 changes: 96 additions & 9 deletions desktop/src/apps/SandboxedAppWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,88 @@
// desktop/src/apps/SandboxedAppWindow.tsx
import { useEffect, useRef } from "react";
import { ShieldAlert } from "lucide-react";
import { useThemeStore } from "@/stores/theme-store";
import { ALLOWED_TOKENS } from "@/theme/theme-config";
import { defaultProvenanceForTrust, type AppProvenance } from "@/lib/userspace-apps";

interface Props {
windowId: string;
appId: string;
trust?: "community" | "first-party";
/** Where the app's code came from. Falls back to defaultProvenanceForTrust(trust)
* when omitted, so a caller that only knows the legacy `trust` field still
* gets a sensible classification. */
provenance?: AppProvenance;
/** Capabilities the user has explicitly granted this app -- drives the
* network-blocked badge and mirrors what the broker actually enforces. */
grantedCapabilities?: string[];
}

/** Mirrors tinyagentos/userspace/capabilities.py PROVENANCE_CEILINGS -- the
* default (no-consent) capability ceiling per tier. Only used here to decide
* what the badge shows; the backend broker is the actual enforcement point. */
const PROVENANCE_CEILINGS: Record<AppProvenance, readonly string[]> = {
"first-party": ["app.kv", "app.table", "app.files", "app.notify", "app.window",
"app.net", "app.agent", "app.llm", "app.memory"],
"ai-generated": ["app.notify", "app.window"],
"user-uploaded": ["app.notify", "app.window"],
unknown: [],
};

const PROVENANCE_LABELS: Record<AppProvenance, string> = {
"first-party": "Built-in",
"ai-generated": "AI-generated",
"user-uploaded": "User-uploaded",
unknown: "Unknown origin",
};

/** True if `capability`'s namespace is free for this tier or was explicitly
* granted -- the same "ceiling OR grant" rule the backend enforces. */
function hasCapability(
provenance: AppProvenance,
capability: string,
granted: readonly string[],
): boolean {
return PROVENANCE_CEILINGS[provenance].includes(capability) || granted.includes(capability);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: hasCapability uses an exact-string .includes against PROVENANCE_CEILINGS, but the backend's capability_allowed (tinyagentos/userspace/capabilities.py:176) computes _namespace(capability) (the a.b prefix) and checks that against the ceiling. For a sub-capability like app.kv.get the backend treats it as app.kv and allows it for first-party apps; this frontend check would say false and the badge would report the app as not having the capability it actually has. Either match the namespace semantics (capability.split('.')[0] + '.' + capability.split('.')[1]) or narrow the docstring to "exact match only -- sub-capabilities not shown".


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

}

/**
* The iframe `sandbox` attribute, derived from provenance. Every tier
* resolves to the same minimal "allow-scripts" today: it is already the
* tightest value that still lets the app's own script run, and adding tokens
* like allow-forms/allow-popups/allow-same-origin would LOOSEN the existing
* restriction for whichever tier got them (allow-same-origin in particular
* would let a sandboxed app execute on the core origin -- never acceptable).
* The function exists (rather than a bare constant) so the derivation is a
* single, provenance-aware, testable place to tighten further per tier later.
*/
function computeSandboxAttr(_provenance: AppProvenance): string {
return "allow-scripts";
}

/** Small corner badge showing an app's provenance + whether it can reach the
* network. Skipped for first-party apps (built-in, nothing to warn about). */
function ProvenanceBadge({
provenance,
networkAllowed,
}: {
provenance: AppProvenance;
networkAllowed: boolean;
}) {
if (provenance === "first-party") return null;
return (
<div
data-testid="provenance-badge"
data-provenance={provenance}
className="pointer-events-none absolute right-2 top-2 z-10 flex items-center gap-1 rounded-full bg-black/60 px-2 py-1 text-[10px] font-semibold text-white/90 backdrop-blur-sm"
>
<ShieldAlert size={11} />
<span>{PROVENANCE_LABELS[provenance]}</span>
<span className="text-white/60">
{networkAllowed ? "· Network allowed" : "· Network blocked"}
</span>
</div>
);
}

interface BrokerRequest {
Expand All @@ -28,9 +104,16 @@ function readThemeTokens(): Record<string, string> {
return tokens;
}

export function SandboxedAppWindow({ appId, trust = "community" }: Props) {
export function SandboxedAppWindow({
appId,
trust = "community",
provenance,
grantedCapabilities = [],
}: Props) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const isFirstParty = trust === "first-party";
const resolvedProvenance: AppProvenance = provenance ?? defaultProvenanceForTrust(trust);
const networkAllowed = hasCapability(resolvedProvenance, "app.net", grantedCapabilities);
// Subscribe to scheme changes (which fire on any applyThemeConfig call) so we
// can push updated tokens when the theme changes. The selector is minimal to
// avoid re-renders on unrelated store updates.
Expand Down Expand Up @@ -90,13 +173,17 @@ export function SandboxedAppWindow({ appId, trust = "community" }: Props) {
}, [appId]);

return (
<iframe
ref={iframeRef}
title={appId}
src={`/api/userspace-apps/${encodeURIComponent(appId)}/bundle/index.html?app=${encodeURIComponent(appId)}`}
sandbox="allow-scripts"
className="w-full h-full border-0 bg-white"
onLoad={handleLoad}
/>
<div className="relative h-full w-full">
<ProvenanceBadge provenance={resolvedProvenance} networkAllowed={networkAllowed} />
<iframe
ref={iframeRef}
title={appId}
src={`/api/userspace-apps/${encodeURIComponent(appId)}/bundle/index.html?app=${encodeURIComponent(appId)}`}
sandbox={computeSandboxAttr(resolvedProvenance)}
data-provenance={resolvedProvenance}
className="w-full h-full border-0 bg-white"
onLoad={handleLoad}
/>
</div>
);
}
52 changes: 52 additions & 0 deletions desktop/src/apps/__tests__/SandboxedAppWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,58 @@ describe("SandboxedAppWindow -- theme injection", () => {
});
});

describe("SandboxedAppWindow -- provenance tiers", () => {
it("never widens the sandbox attribute beyond allow-scripts for any tier", () => {
for (const provenance of ["first-party", "ai-generated", "user-uploaded", "unknown"] as const) {
const { unmount } = render(
<SandboxedAppWindow windowId="w1" appId={`app-${provenance}`} provenance={provenance} />
);
const iframe = screen.getByTitle(`app-${provenance}`) as HTMLIFrameElement;
expect(iframe.getAttribute("sandbox")).toBe("allow-scripts");
expect(iframe.getAttribute("data-provenance")).toBe(provenance);
unmount();
}
});

it("shows no badge for a first-party app", () => {
render(<SandboxedAppWindow windowId="w1" appId="fp" provenance="first-party" />);
expect(screen.queryByTestId("provenance-badge")).toBeNull();
});

it("shows an AI-generated badge with network blocked by default", () => {
render(<SandboxedAppWindow windowId="w1" appId="agent-app" provenance="ai-generated" />);
const badge = screen.getByTestId("provenance-badge");
expect(badge.textContent).toContain("AI-generated");
expect(badge.textContent).toContain("Network blocked");
});

it("shows network allowed once app.net is granted", () => {
render(
<SandboxedAppWindow
windowId="w1"
appId="agent-app"
provenance="ai-generated"
grantedCapabilities={["app.net"]}
/>
);
const badge = screen.getByTestId("provenance-badge");
expect(badge.textContent).toContain("Network allowed");
});

it("shows the unknown-origin badge as network blocked", () => {
render(<SandboxedAppWindow windowId="w1" appId="mystery" provenance="unknown" />);
const badge = screen.getByTestId("provenance-badge");
expect(badge.textContent).toContain("Unknown origin");
expect(badge.textContent).toContain("Network blocked");
});

it("falls back to defaultProvenanceForTrust when provenance is omitted", () => {
render(<SandboxedAppWindow windowId="w1" appId="legacy-fp" trust="first-party" />);
expect(screen.getByTitle("legacy-fp").getAttribute("data-provenance")).toBe("first-party");
expect(screen.queryByTestId("provenance-badge")).toBeNull();
});
});

describe("SDK theme API (mirrors taos-app-sdk.js handler)", () => {
// The SDK ships as a plain IIFE loaded inside the sandbox iframe, so it cannot
// be imported here without eval/new-Function (a flagged pattern). These tests
Expand Down
14 changes: 12 additions & 2 deletions desktop/src/apps/appstudio/PublishView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Folder, Bell, Users, Shield, Upload, Share2, Download, CheckSquare } from "lucide-react";
import { Folder, Bell, Users, Shield, Sparkles, Upload, Share2, Download, CheckSquare } from "lucide-react";

/* ------------------------------------------------------------------ */
/* PublishView -- app identity + capabilities + side publish panel */
Expand Down Expand Up @@ -66,7 +66,17 @@ export function PublishView() {
<CheckSquare size={30} />
</div>
<div>
<div className="text-[19px] font-extrabold tracking-[-0.02em]">Chore Quest</div>
<div className="flex items-center gap-[8px]">
<span className="text-[19px] font-extrabold tracking-[-0.02em]">Chore Quest</span>
<span
data-testid="provenance-badge"
data-provenance="ai-generated"
className="flex items-center gap-1 rounded-full border border-shell-border bg-shell-surface px-[8px] py-[2px] text-[10px] font-semibold text-shell-text-secondary"
>
<Sparkles size={11} />
AI-generated
</span>
</div>
<div className="mt-[3px] text-[12.5px] text-shell-text-secondary">
A weekly chore tracker with points and a family leaderboard.
</div>
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/apps/appstudio/__tests__/PublishView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PublishView } from "../PublishView";

describe("PublishView -- provenance badge", () => {
it("shows an AI-generated provenance badge next to the app name", () => {
render(<PublishView />);
const badge = screen.getByTestId("provenance-badge");
expect(badge.getAttribute("data-provenance")).toBe("ai-generated");
expect(badge.textContent).toContain("AI-generated");
});
});
21 changes: 20 additions & 1 deletion desktop/src/lib/userspace-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import type { AppManifest } from "@/registry/app-registry";
*/
export const USERSPACE_APPS_CHANGED = "taos:userspace-apps-changed";

/** Where an app's code came from. See tinyagentos/userspace/capabilities.py
* PROVENANCE_TIERS for the single source of truth this mirrors. */
export type AppProvenance = "first-party" | "ai-generated" | "user-uploaded" | "unknown";

export interface UserspaceAppRow {
app_id: string;
name: string;
Expand All @@ -16,10 +20,19 @@ export interface UserspaceAppRow {
permissions_requested: string[];
permissions_granted: string[];
trust?: "community" | "first-party";
provenance?: AppProvenance;
}

/** Back-compat default for a row with no provenance recorded: legacy
* first-party built-ins classify first-party, everything else user-uploaded
* -- mirrors default_provenance_for_trust in the backend. */
export function defaultProvenanceForTrust(trust?: string): AppProvenance {
return trust === "first-party" ? "first-party" : "user-uploaded";
}

export function toAppManifest(row: UserspaceAppRow): AppManifest {
const trust = row.trust ?? "community";
const provenance = row.provenance ?? defaultProvenanceForTrust(trust);
return {
// Registry id is namespaced (mirrors "service:") so a community app cannot
// shadow a built-in app id. The broker/bundle still use the raw app_id.
Expand All @@ -30,7 +43,13 @@ export function toAppManifest(row: UserspaceAppRow): AppManifest {
component: () =>
import("@/apps/SandboxedAppWindow").then((m) => ({
default: (props: { windowId: string }) =>
m.SandboxedAppWindow({ ...props, appId: row.app_id, trust }),
m.SandboxedAppWindow({
...props,
appId: row.app_id,
trust,
provenance,
grantedCapabilities: row.permissions_granted,
}),
})),
defaultSize: { w: 900, h: 600 },
minSize: { w: 360, h: 280 },
Expand Down
117 changes: 117 additions & 0 deletions tests/test_app_provenance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""The provenance -> capability ceiling model (provenance-keyed sandbox tiers).

Every userspace app is classified into one of four provenance tiers
(first-party/ai-generated/user-uploaded/unknown); the tier is a CEILING --
the capabilities the app holds automatically, before any user consent.
"""
import pytest

from tinyagentos.userspace.capabilities import (
DEFAULT_PROVENANCE,
FREE_CAPS,
GATED_CAPS,
KNOWN_CAPS,
PROVENANCE_CEILINGS,
PROVENANCE_DESCRIPTIONS,
PROVENANCE_TIERS,
capability_allowed,
capability_ceiling,
default_provenance_for_trust,
is_known_provenance,
)


def test_provenance_tiers_are_exactly_four():
assert PROVENANCE_TIERS == ("first-party", "ai-generated", "user-uploaded", "unknown")


def test_every_tier_has_a_ceiling_and_description():
for tier in PROVENANCE_TIERS:
assert tier in PROVENANCE_CEILINGS
assert PROVENANCE_DESCRIPTIONS.get(tier), f"no description for {tier}"


def test_default_provenance_is_unknown_the_most_restricted():
assert DEFAULT_PROVENANCE == "unknown"
assert capability_ceiling(DEFAULT_PROVENANCE) == frozenset()


def test_is_known_provenance():
for tier in PROVENANCE_TIERS:
assert is_known_provenance(tier) is True
assert is_known_provenance("community") is False
assert is_known_provenance("") is False
assert is_known_provenance(None) is False


def test_first_party_ceiling_is_every_known_capability():
assert capability_ceiling("first-party") == KNOWN_CAPS


def test_ai_generated_and_user_uploaded_have_no_network_or_storage_by_default():
for tier in ("ai-generated", "user-uploaded"):
ceiling = capability_ceiling(tier)
# No storage, no network, no cross-app data.
assert "app.kv" not in ceiling
assert "app.table" not in ceiling
assert "app.files" not in ceiling
assert "app.net" not in ceiling
assert "app.agent" not in ceiling
assert "app.llm" not in ceiling
assert "app.memory" not in ceiling
# Only the inert, app-local UI capabilities are free.
assert ceiling == frozenset({"app.notify", "app.window"})


def test_unknown_is_more_restricted_than_ai_generated_or_user_uploaded():
unknown_ceiling = capability_ceiling("unknown")
assert unknown_ceiling == frozenset()
for tier in ("ai-generated", "user-uploaded"):
assert unknown_ceiling < capability_ceiling(tier)


def test_unrecognised_provenance_falls_back_to_default():
assert capability_ceiling("bogus-tier") == capability_ceiling(DEFAULT_PROVENANCE)
assert capability_ceiling(None) == capability_ceiling(DEFAULT_PROVENANCE)


@pytest.mark.parametrize("cap", sorted(GATED_CAPS))
def test_gated_caps_never_free_below_first_party(cap):
for tier in ("ai-generated", "user-uploaded", "unknown"):
assert not capability_allowed(tier, cap, granted=[])


@pytest.mark.parametrize("cap", sorted(FREE_CAPS))
def test_first_party_ceiling_covers_every_free_cap(cap):
assert capability_allowed("first-party", cap, granted=[])


def test_capability_allowed_within_ceiling_needs_no_grant():
assert capability_allowed("ai-generated", "app.notify", granted=[]) is True
assert capability_allowed("ai-generated", "app.window.open", granted=[]) is True


def test_capability_allowed_outside_ceiling_denied_without_grant():
assert capability_allowed("ai-generated", "app.kv", granted=[]) is False
assert capability_allowed("user-uploaded", "app.net", granted=[]) is False
assert capability_allowed("unknown", "app.notify", granted=[]) is False


def test_an_explicit_grant_lets_an_app_exceed_its_ceiling():
# This is the whole point of the ceiling: it is a default, not a cap. An
# app can always exceed it via the existing consent/grant flow.
assert capability_allowed("ai-generated", "app.kv", granted=["app.kv"]) is True
assert capability_allowed("user-uploaded", "app.net", granted=["app.net"]) is True
assert capability_allowed("unknown", "app.memory.search", granted=["app.memory"]) is True
# A grant recorded as the exact sub-capability string also counts.
assert capability_allowed("unknown", "app.memory.search", granted=["app.memory.search"]) is True


def test_default_provenance_for_trust_back_compat_mapping():
# Legacy first-party built-ins classify as first-party; everything else
# (community, missing, or any other value) defaults to user-uploaded --
# the only other way an app reaches the userspace store today.
assert default_provenance_for_trust("first-party") == "first-party"
assert default_provenance_for_trust("community") == "user-uploaded"
assert default_provenance_for_trust(None) == "user-uploaded"
assert default_provenance_for_trust("") == "user-uploaded"
Loading
Loading