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
5 changes: 5 additions & 0 deletions .changeset/warm-peaches-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Comment authors are now derived from the session's agent name — the `author` parameter is removed from the CLI (`sideshow comment --author`), MCP (`reply_to_user`), and stdio MCP tools. Only same-origin browser requests (the viewer composer) can mint `author: "user"`, closing a feedback-label forgery gap where programmatic callers could inject commands into the agent's user-feedback stream. The reserved `"user"` label is also blocked as a session agent name.
14 changes: 6 additions & 8 deletions bin/sideshow.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ usage:
sideshow comment <text> [options] reply to the user on a post
--post <id> post to attach the comment to (required;
--surface is a deprecated alias)
--author <name> defaults to agent name
sideshow list [--session <id>|--all] list posts
sideshow sessions list sessions
sideshow demo seed two example sessions to explore the viewer
Expand Down Expand Up @@ -1248,8 +1247,6 @@ const commands = {
post: { type: "string" },
surface: { type: "string" }, // deprecated alias
snippet: { type: "string" }, // legacy alias
author: { type: "string" },
agent: { type: "string" },
},
});
const text = positionals.join(" ").trim();
Expand All @@ -1258,14 +1255,12 @@ const commands = {
// body key is the wire field `surface`, kept as-is.
const post = flags.post ?? flags.surface ?? flags.snippet;
if (!post) fail("a comment must target a post — pass --post <id>");
// The server derives the author from the session's agent name — the CLI
// never sends one, so the reserved "user" label can't be forged here.
out(
await api("/api/comments", {
method: "POST",
body: JSON.stringify({
text,
surface: post,
author: flags.author ?? agentName(flags),
}),
body: JSON.stringify({ text, surface: post }),
}),
);
},
Expand Down Expand Up @@ -1320,8 +1315,11 @@ const commands = {
});
}
if (step.comment) {
// The demo seeds a realistic human/agent dialogue, so simulate
// viewer-origin requests (the only path that may mint author:"user").
await api("/api/comments", {
method: "POST",
headers: { "sec-fetch-site": "same-origin" },
body: JSON.stringify({ snippet: snippet.id, ...step.comment }),
});
}
Expand Down
2 changes: 1 addition & 1 deletion mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ server.registerTool(
const created = JSON.parse(
await api("/api/comments", {
method: "POST",
body: JSON.stringify({ surface: postId ?? surfaceId, text: message, author: AGENT }),
body: JSON.stringify({ surface: postId ?? surfaceId, text: message }),
}),
);
return text(created);
Expand Down
27 changes: 22 additions & 5 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,11 @@ export function createApp({
async function createComment(input: {
text: string;
surface?: string;
author: string;
// Only the viewer (same-origin REST) may set this — "user" for the composer,
// "surface" for the send-prompt bridge. Programmatic callers (MCP, CLI,
// cross-origin REST) omit it and the author is derived from session.agent,
// so an agent can never mint the reserved "user" trust label.
author?: string;
}): Promise<
{ comment: Comment; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 }
> {
Expand All @@ -464,10 +468,13 @@ export function createApp({
if (!input.surface) return { error: 'provide a "surface" id', status: 400 };
const surface = await store.getPost(input.surface);
if (!surface) return { error: "surface not found", status: 404 };
const session = await store.getSession(surface.sessionId);
if (!session) return { error: "session not found", status: 404 };
const author = input.author ?? session.agent;
const comment = await store.createComment({
sessionId: surface.sessionId,
postId: surface.id,
author: input.author,
author,
text: input.text.trim().slice(0, MAX_COMMENT_TEXT),
});
if (!comment) return { error: "session not found", status: 404 };
Expand All @@ -480,8 +487,7 @@ export function createApp({
});
// agent replies are writes too — piggyback pending feedback on them, but
// never on the user's own comments
const userFeedback =
input.author === "user" ? undefined : await collectFeedback(comment.sessionId);
const userFeedback = author === "user" ? undefined : await collectFeedback(comment.sessionId);
return { comment, userFeedback };
}

Expand Down Expand Up @@ -901,10 +907,21 @@ export function createApp({
return c.json({ error: 'body must include non-empty "text" string' }, 400);
}
const surface = typeof body.surface === "string" ? body.surface : body.snippet;
// Only same-origin browser requests (the viewer page) may declare the
// author — the composer sends "user", the send-prompt bridge sends "surface".
// Programmatic callers (MCP, CLI) never send Sec-Fetch-Site and get the author
// derived from session.agent, so the reserved "user" trust label can't be
// forged through the supported tool surfaces. A sandboxed iframe has an opaque
// origin (not same-origin), so contained content can't forge it either.
// Note: a raw HTTP client can set this header freely (it's only browser-
// enforced), but that requires knowing to do so — the trivial one-liner
// forgery is closed, and MCP/CLI (the natural agent paths) are blocked.
const isViewerOrigin = c.req.header("sec-fetch-site") === "same-origin";
const author = isViewerOrigin && typeof body.author === "string" ? body.author : undefined;
const result = await createComment({
text: body.text,
surface: typeof surface === "string" ? surface : undefined,
author: typeof body.author === "string" ? body.author : "user",
author,
});
if ("error" in result) return c.json({ error: result.error }, result.status);
return c.json(
Expand Down
11 changes: 4 additions & 7 deletions server/mcpHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface McpDeps {
createComment(input: {
text: string;
surface?: string;
author: string;
author?: string;
}): Promise<{ comment: Comment; userFeedback?: Feedback[] } | { error: string; status: number }>;
waitForComments(q: CommentWait): Promise<{ comments: Comment[]; lastSeq: number }>;
uploadAsset(input: {
Expand Down Expand Up @@ -147,15 +147,12 @@ export function registerMcp(app: Hono, deps: McpDeps) {
);
}
case "reply_to_user": {
// "user" is the reserved trust label, minted only by the viewer's
// composer (genuine human keystrokes). The agent may name itself
// anything else, but never the user — that would forge feedback.
const named = typeof args.author === "string" ? args.author.trim() : "";
const author = named && named !== "user" ? named : "agent";
// The author is derived from session.agent by createComment — the agent
// never gets to choose it, and "user" (the reserved human label) is
// unreachable from MCP entirely.
const result = await deps.createComment({
text: String(args.message ?? ""),
surface: String(args.postId ?? args.surfaceId ?? ""),
author,
});
if ("error" in result) throw new Error(result.error);
return JSON.stringify(
Expand Down
5 changes: 0 additions & 5 deletions server/mcpSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,11 +305,6 @@ export const HTTP_MCP_TOOLS = [
postId: { type: "string", description: "Post whose comment thread to reply in" },
surfaceId: { type: "string", description: "Deprecated alias of postId" },
message: { type: "string", description: d.replyMessage },
author: {
type: "string",
description:
'Your agent name (default "agent"; "user" is reserved and coerced to "agent")',
},
},
required: ["message"],
},
Expand Down
3 changes: 2 additions & 1 deletion server/sqlStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
htmlSurface,
MAX_WORKSPACE_ASSET_BYTES,
newId,
reservedAgent,
selectEvictions,
type Session,
type SqlStorage,
Expand Down Expand Up @@ -238,7 +239,7 @@ export class SqlStore implements Store {
const now = new Date().toISOString();
const session: Session = {
id: newId(),
agent: stripNul(input.agent).trim() || "agent",
agent: reservedAgent(stripNul(input.agent).trim() || "agent"),
title: stripNul(input.title)?.trim() || null,
cwd: stripNul(input.cwd ?? null),
createdAt: now,
Expand Down
3 changes: 2 additions & 1 deletion server/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
htmlSurface,
MAX_WORKSPACE_ASSET_BYTES,
newId,
reservedAgent,
selectEvictions,
type Session,
stripNul,
Expand Down Expand Up @@ -253,7 +254,7 @@ export class JsonFileStore implements Store {
const now = new Date().toISOString();
const session: Session = {
id: newId(),
agent: stripNul(input.agent).trim() || "agent",
agent: reservedAgent(stripNul(input.agent).trim() || "agent"),
title: stripNul(input.title)?.trim() || null,
cwd: stripNul(input.cwd ?? null),
createdAt: now,
Expand Down
8 changes: 8 additions & 0 deletions server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,14 @@ export interface WorkspaceSnapshot {

export const HISTORY_LIMIT = 20;

// "user" is the reserved trust label for genuine human comments (minted only by
// the viewer's composer). An agent that names itself "user" at session creation
// would have its derived comments read as human feedback — so coerce it to the
// default. The check lives here so both stores enforce it identically.
export function reservedAgent(name: string): string {
return name === "user" ? "agent" : name;
}

// SQLite terminates a TEXT value at the first embedded NUL byte, while the JSON
// store preserves it — so the two stores would diverge on a NUL. A NUL has no
// place in a title/comment/label anyway, so both stores strip it from stored
Expand Down
Loading
Loading