Warn about releases and artifacts by others

- Add an UntrustedWarning banner
- Warn on the Others releases list
- Warn on a release by a non-delegate
- Warn on artifacts by non-delegates on a delegate release
- Warn in the download popover for a non-delegate artifact
- Add E2E tests for both release cases

Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
Daniel Norman
2026-10-08 18:21:48 +02:00
committed by Rūdolfs Ošiņš
co-authored by Rūdolfs Ošiņš
parent e930bfd397
commit f90db0986a
9 changed files with 1460 additions and 1319 deletions
@@ -0,0 +1,82 @@
# 4. Untrusted releases and artifacts
Date: 2026-10-06
## Status
Proposed
## Context
Releases and their artifacts are COBs, so anyone can create a release in a
repo they replicate, and anyone can register an artifact on any release,
including a delegate's. An artifact is a file a user downloads and may run.
A release that sits in a well-known repo gets some of that repo's trust in
the eyes of a user who doesn't know what a delegate is. This makes it easy
to trick a user into downloading something malicious.
Trust is set at two levels:
- **Release:** its creator decides the title, tag and commit.
- **Artifact:** its author decides the bytes behind the CID.
The two can differ. A delegate's release can carry an artifact a
non-delegate registered, and a delegate can register an artifact on a
non-delegate's release.
The app, like radicle-explorer, splits releases and artifacts into two
disjoint scopes by author: Delegates and Others. In the code the scopes are
`trusted` and `untrusted`. The discussion is in the
[Artifacts topic](https://radicle.zulipchat.com/#narrow/channel/444463-Desktop/topic/Artifacts/with/624009755)
on Zulip.
## Decision
**Trust follows delegates.** A release or artifact is trusted when its
creator or author is a delegate of the repo, and untrusted otherwise. The
UI keeps the labels Delegates and Others, as "Untrusted" reads as too
alarming for content that is often harmless.
**Warn where untrusted content shows.** A short warning
(`UntrustedWarning.svelte`) says the content is not from a delegate and to
download it only if the user trusts its author. It stays small, so it
informs without alarm. It shows:
- above the Others releases list;
- on a release by a non-delegate, for the whole release;
- on a delegate's release, while it shows artifacts by non-delegates;
- in the download popover of an artifact by a non-delegate, whatever the
release, because that is where the user acts;
- in the browser tab of a delegate's artifact, while it lists links added
by non-delegates, as those downloads are not checked against the CID.
**Count only delegate attestations.** The attestation badge on an artifact
counts delegates only, as anyone can make keys that attest their own
artifact. The full list still shows every attestation.
**Keep untrusted content visible.** Others' releases and artifacts stay
listed by default, behind their own filter.
## Alternatives considered
- **Hide Others by default**, behind a global setting. This protects users
best, and avoids showing links to malware at all, which is a reputation
risk a warning doesn't remove. It also hides legitimate contributions,
such as builds by a release manager who is not a delegate. It is still
open.
- **Rename Delegates to Official** or Others to Untrusted. This changes
only the wording and leaves the warning to a single label.
- **A list of trusted publishers in the repo identity**, like canonical ref
rules. This lets non-delegates publish trusted releases, but adds a
second trust model. Linking a release to a canonical annotated tag gives
part of this already, so canonical refs are the preferred path.
## Consequences
- Users see a warning before they download an untrusted artifact, also when
it sits on a trusted release.
- A trusted artifact on an untrusted release still shows the release
warning, as its title, tag and commit are not trusted.
- Delegation is the only trust signal. Release managers who are not
delegates show as Others until releases can use canonical ref rules.
- radicle-explorer should show the same warnings so that both clients agree.
+20 -11
View File
@@ -17,14 +17,13 @@
import ExternalLink from "@app/components/ExternalLink.svelte";
import Icon from "@app/components/Icon.svelte";
import Popover from "@app/components/Popover.svelte";
import UntrustedWarning from "@app/components/UntrustedWarning.svelte";
interface Props {
artifact: Artifact;
delegateIds: Set<string>;
releaseId: string;
rid: string;
/// Whether the node already seeds this artifact, asked of the node rather
/// than assumed from what this component did.
seeding: boolean;
onDownloaded: () => void;
}
@@ -40,8 +39,6 @@
let activeTab: "app" | "cli" | "browser" = $state("app");
let expanded = $state(false);
// Which transfer is running: a download writes to disk, a fetch only fills
// the node's store.
let running: "download" | "fetch" | undefined = $state();
let progress: ArtifactProgress | undefined = $state();
let downloadError: string | undefined = $state();
@@ -54,8 +51,6 @@
}
});
// Clear the outcome of a finished download once the popover closes, so a
// later visit starts fresh.
$effect(() => {
if (!expanded && !running) {
finished = undefined;
@@ -63,8 +58,6 @@
}
});
// The node reports byte movement per CID, so a shared event channel
// is filtered down to this artifact.
$effect(() => {
// Events only exist under Tauri, not against the test HTTP API.
if (!window.__TAURI_INTERNALS__) {
@@ -146,12 +139,13 @@
const webLocations = $derived(
releases.webLocations(artifact.locations, delegateIds),
);
// Any location at all can be fetched with the CLI, web locations included.
const authorTrusted = $derived(delegateIds.has(artifact.author.did));
const linksByOthers = $derived(
webLocations.some(l => !delegateIds.has(l.user.did)),
);
const downloadable = $derived(seeding || artifact.locations.length > 0);
const downloadLabel = $derived(seeding ? "Save" : "Download");
// The app and CLI tabs fetch through the artifact node, so they are only
// usable while it answers. The browser tab does not need it.
const nodeRunning = $derived($artifactNodeRunning);
const START_COMMAND = "rad-artifact node start";
const command = $derived(
@@ -171,6 +165,9 @@
.start-command {
margin-bottom: 0.75rem;
}
.untrusted {
margin-bottom: 0.75rem;
}
.tabs {
display: flex;
gap: 2px;
@@ -265,6 +262,12 @@
{#snippet popover()}
<div class="popover">
{#if !authorTrusted}
<div class="untrusted">
<UntrustedWarning
text="Not from a delegate. Only download if you trust the author." />
</div>
{/if}
<div class="tabs">
<Button
styleWidth="100%"
@@ -393,6 +396,12 @@
<Icon name="warning" />
These downloads are not checked against the CID.
</div>
{#if authorTrusted && linksByOthers}
<div class="untrusted">
<UntrustedWarning
text="Links without a delegate badge are not from a delegate. Only use them if you trust who added them." />
</div>
{/if}
<div class="locations">
{#each webLocations as location (`${location.user.did}:${location.url}`)}
<div class="location">
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import Icon from "@app/components/Icon.svelte";
interface Props {
text: string;
}
const { text }: Props = $props();
</script>
<style>
.banner {
display: flex;
align-items: center;
gap: 0.375rem;
color: var(--color-text-secondary);
font: var(--txt-body-s-regular);
}
.icon {
display: flex;
flex-shrink: 0;
color: var(--color-feedback-warning-text);
}
</style>
<div class="banner" role="note">
<span class="icon"><Icon name="warning" /></span>
<span>{text}</span>
</div>
+16 -21
View File
@@ -22,18 +22,12 @@ export function releaseListScope(
export interface ArtifactView {
scope: ReleaseScope;
shown: Artifact[];
// Redacted artifacts within the current author scope.
redactedCount: number;
// What each scope would show, so an artifact hidden as redacted never
// counts towards a scope.
counts: Record<ReleaseScope, number>;
// Filter only when both scopes hold something.
showFilters: boolean;
}
// Split a release's artifacts into two disjoint scopes by author. Show the
// scope asked for unless it is empty, and hide artifacts redacted by their
// author or a delegate unless asked to show them.
// Falls back to the other scope when the one asked for is empty.
export function artifactView(
artifacts: Artifact[],
delegates: Set<string>,
@@ -70,7 +64,6 @@ export function redactedByDelegate(
return artifact.redactions.some(r => delegates.has(r.user.did));
}
// Group locations by the contributing node, preserving order.
export function locationsByNode(
locations: Location[],
): { user: Author; urls: string[] }[] {
@@ -83,9 +76,7 @@ export function locationsByNode(
return [...groups.values()];
}
// Order a node-keyed list so delegates come first. The backend returns these
// sorted by DID (arbitrary to a reader); sort is stable, so the original
// order is preserved within each group.
// The backend sorts by DID, which means nothing to a reader.
export function delegatesFirst<T>(
items: T[],
did: (item: T) => string,
@@ -96,9 +87,6 @@ export function delegatesFirst<T>(
);
}
// Locations a browser can open, as opposed to ones fetched from a seeder
// over the radicle-artifact protocol. Delegate locations come first: they
// are the ones a reader can trust most.
export function webLocations(
locations: Location[],
delegates: Set<string>,
@@ -119,8 +107,6 @@ export function attestedBy(
return attestations.filter(a => delegates.has(a.did)).length;
}
// Only metadata by the author or a delegate is shown, so only they get an
// editor.
export function canEditMetadata(
artifact: Artifact,
ownDid: string,
@@ -140,9 +126,7 @@ export function canAttest(artifact: Artifact, ownDid: string): boolean {
);
}
// The COB stores values as free-form JSON. Text that parses as JSON is sent
// as that value, so numbers and booleans round-trip; anything else is sent
// as a plain string.
// Text that parses as JSON is sent as that value, so numbers round-trip.
export function parseMetadataValue(input: string): unknown {
try {
return JSON.parse(input);
@@ -155,8 +139,6 @@ export function displayMetadataValue(value: unknown): string {
return typeof value === "string" ? value : JSON.stringify(value);
}
// Commits whose SHA starts with, or whose summary contains, the query. A
// pasted commit outside the loaded history still shows as a choice.
export function matchCommits(
commits: Commit[],
query: string,
@@ -179,3 +161,16 @@ export function canonicalTags(refs: RepoRefs): { name: string; tag: Tag }[] {
.map(([name, tag]) => ({ name, tag }))
.sort((a, b) => b.tag.timestamp - a.tag.timestamp);
}
// A release by a non-delegate is untrusted as a whole. On a delegate's
// release, only the artifacts registered by others are.
export function releaseWarning(
creatorDid: string,
delegates: Set<string>,
artifactScope: ReleaseScope,
): "release" | "artifacts" | undefined {
if (!delegates.has(creatorDid)) {
return "release";
}
return artifactScope === "untrusted" ? "artifacts" : undefined;
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -17,6 +17,7 @@
import ReleaseTeaser from "@app/components/ReleaseTeaser.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import Topbar from "@app/components/Topbar.svelte";
import UntrustedWarning from "@app/components/UntrustedWarning.svelte";
import VirtualList from "@app/components/VirtualList.svelte";
import CreateReleaseModal from "@app/modals/CreateRelease.svelte";
@@ -85,6 +86,10 @@
.filter .global-counter-badge {
margin-left: 0.25rem;
}
.warning {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.row {
border-bottom: 1px solid var(--color-border-subtle);
}
@@ -158,6 +163,12 @@
</div>
</div>
{:else}
{#if scope === "untrusted"}
<div class="warning">
<UntrustedWarning
text="Not from delegates. Only download from authors you trust." />
</div>
{/if}
<ScrollArea style="height: 100%; min-width: 0;">
<VirtualList
items={list.items}
+80 -3
View File
@@ -17,6 +17,8 @@ import {
useBackend,
} from "@tests/support/fixtures.js";
const OTHER_DID = "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5";
test("the commit picker lists commits and rejects unknown SHAs", async ({
page,
peer,
@@ -222,9 +224,7 @@ test("a location added by someone else cannot be removed", async ({
const release = await (await route.fetch()).json();
for (const artifact of release.artifacts) {
artifact.locations.push({
user: {
did: "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
},
user: { did: OTHER_DID },
url,
});
}
@@ -270,6 +270,83 @@ test("removing your node's location while seeding stops seeding", async ({
expect((await unseed).postDataJSON()).toMatchObject({ releaseId, cid });
});
test("a release by a non-delegate shows a warning", async ({ page, peer }) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await expect(page.getByRole("note")).toBeHidden();
await page.route(/\/release_by_id$/, async route => {
const release = await (await route.fetch()).json();
release.creator.did = OTHER_DID;
await route.fulfill({ json: release });
});
await reload(page);
await expect(
page.getByRole("note").getByText("Not from a delegate."),
).toBeVisible();
});
test("an artifact by a non-delegate shows a warning", async ({
page,
peer,
}) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
// A location makes the download button usable.
await page.route(/\/release_by_id$/, async route => {
const release = await (await route.fetch()).json();
for (const artifact of release.artifacts) {
artifact.author.did = OTHER_DID;
artifact.locations.push({
user: { did: OTHER_DID },
url: "https://example.com/theirs.tar",
});
}
await route.fulfill({ json: release });
});
await reload(page);
await expect(
page.getByRole("note").getByText("Not from delegates."),
).toBeVisible();
await page.getByRole("button", { name: "icon-download Download" }).click();
await expect(page.getByText("Not from a delegate.")).toBeVisible();
});
test("a link by a non-delegate on a delegate's artifact shows a warning", async ({
page,
peer,
}) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await page.route(/\/release_by_id$/, async route => {
const release = await (await route.fetch()).json();
for (const artifact of release.artifacts) {
artifact.locations.push({
user: { did: OTHER_DID },
url: "https://example.com/theirs.tar",
});
}
await route.fulfill({ json: release });
});
await reload(page);
await page.getByRole("button", { name: "icon-download Download" }).click();
await expect(page.getByText("Not from a delegate.")).toBeHidden();
await page
.getByRole("button", { name: "icon-open-external Browser" })
.click();
await expect(
page.getByRole("note").getByText("Links without a delegate badge"),
).toBeVisible();
});
test("metadata can be added, edited and removed", async ({ page, peer }) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
+16
View File
@@ -19,6 +19,7 @@ import {
parseMetadataValue,
redactedByDelegate,
releaseListScope,
releaseWarning,
webLocations,
} from "@app/lib/releases";
@@ -307,3 +308,18 @@ describe("canonicalTags", () => {
).toBe(canonical);
});
});
describe("releaseWarning", () => {
test("warns about a whole release by a non-delegate", () => {
expect(releaseWarning(them, delegates, "trusted")).toBe("release");
expect(releaseWarning(them, delegates, "untrusted")).toBe("release");
});
test("warns about others' artifacts on a delegate's release", () => {
expect(releaseWarning(delegate, delegates, "untrusted")).toBe("artifacts");
});
test("does not warn about a delegate's release and artifacts", () => {
expect(releaseWarning(delegate, delegates, "trusted")).toBeUndefined();
});
});