mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-10 22:20:27 +00:00
web_search reclassified EGRESS (spec 2.2, decided 2026-08-12): the destination is fixed (the configured provider) but the query is model-chosen free text - the same outbound channel web_fetch's URL is. It ran completely ungated in every mode until now; it gates like any egress from here on, which also puts it in front of the Auto-Approve reviewer. The egress approval cards (spec 1.9): - web_fetch offers "Always allow <host> this session" -> ALWAYS_DOMAIN. Tool-wide "always" is gone from the card AND server-refused (_grant_offered): it would cover every future destination, and the live A/B showed exactly that (one click on a bbc.com card ran promptless fetches to hosts no card ever named). - www. stripped at grant minting (allow_domain_for_session) - pure spelling only, never eTLD+1. The card button shows the exact spelling the grant mints. - web_search offers "Always allow searches this session" -> ALWAYS_TOOL (tool-wide IS provider-wide for a fixed destination), with the card naming the LIVE destination: "Queries go to your configured search provider (currently: <name>)". Provider resolved when the card is raised (engine.approval_extras hook), not at session start. - Provider-change invalidation: set_web_search clears the web_search session grant in every live engine when the provider actually changes - the grant was consent to a named destination. - Auto-Approve fall-through cards hide every session "always" button: grants don't skip the reviewer there (1.5), and a button that lies is worse than none. - scopeNote tells the truth for egress: "leaves this computer -> <host>" replaces "stays on this computer" on fetch/search cards. Corpora gain web_search cases (benign 22 / dangerous 17 / injection 14), including query-borne secret exfiltration and a planted search-the-credentials injection. Tests: test_egress_and_overrides (EGRESS class, gating, www-strip, 1.5 in Auto-Approve), test_approval_integrity (tool-wide refused for URL-carrying egress, kept for web_search; provider-change invalidation), ApprovalCard.test.tsx (domain button + www-strip, provider line, Auto-Approve hides always). Full suites pass; the 22 pre-existing failures (Slack fake-gateway timeouts, a Windows file-lock rename) fail identically on the pre-change tree.
412 lines
16 KiB
TypeScript
412 lines
16 KiB
TypeScript
import { useState } from "react";
|
|
import type { ApprovalDecision, Item } from "../types";
|
|
import { humanizeApprovalTitle, type HumanLine } from "../humanize";
|
|
import { Icon } from "./Icon";
|
|
|
|
export function shortArgs(args: any): string {
|
|
if (!args || typeof args !== "object") return "";
|
|
return Object.entries(args)
|
|
.map(([k, v]) => {
|
|
let s = typeof v === "string" ? v : JSON.stringify(v);
|
|
if (s.length > 96) s = s.slice(0, 95) + "...";
|
|
return `${k}=${s.replace(/\n/g, " ")}`;
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
// Human verbs kept for the §25 grant lines (the card title now comes from humanize.ts).
|
|
const TOOL_VERBS: Record<string, string> = {
|
|
write_file: "Write a file",
|
|
replace_in_file: "Edit a file",
|
|
apply_patch: "Apply a patch",
|
|
apply_unified_diff: "Apply a patch",
|
|
run_shell: "Run a command",
|
|
send_message: "Send a message",
|
|
send_file: "Send a file",
|
|
};
|
|
|
|
// §35: routine workspace writes render as a compact ROW; everything else is a full card.
|
|
const FILE_WRITES = new Set(["write_file", "replace_in_file", "apply_patch", "apply_unified_diff"]);
|
|
// Actions that leave the Mac get the warm border + explicit destination note.
|
|
const EXTERNAL = new Set(["send_message", "send_file"]);
|
|
|
|
type ApprovalItem = Extract<Item, { kind: "approval" }>;
|
|
|
|
// Per-tool button copy (§7): a skill proposal is an "add", not an "allow". Shared with the
|
|
// parked Inbox card so both dialects match.
|
|
export function approvalActionLabels(name?: string): { allow: string; deny: string } {
|
|
return name === "save_skill"
|
|
? { allow: "Add to my skills", deny: "Not now" }
|
|
: { allow: "Allow once", deny: "Deny" };
|
|
}
|
|
|
|
// save_skill's review surface (SKILLS-SPEC §5.2): description, the full instructions
|
|
// (clamped, expandable, scrollable), every bundled file, and the guaranteed footer that
|
|
// answers "added WHERE, available WHEN". Shared verbatim with the parked Inbox card —
|
|
// one decision, one dialect.
|
|
export function SaveSkillPreview({ args }: { args: any }) {
|
|
return (
|
|
<>
|
|
{args?.description && <div className="approval-with">{String(args.description)}</div>}
|
|
{args?.instructions && <PreviewBlock text={String(args.instructions)} mono={false} />}
|
|
{Array.isArray(args?.files) && args.files.length > 0 && (
|
|
<div data-testid="skill-bundle-files">
|
|
{args.files.map((f: unknown, i: number) => (
|
|
<span className="approval-filechip" key={i}>
|
|
<span className="ico">
|
|
<Icon name="file" size={13} />
|
|
</span>
|
|
{String(f).split(/[\\/]/).pop() || String(f)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="approval-with">
|
|
Approving adds it to your skills on this computer — usable in every conversation from
|
|
then on.
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// A `permissions` proposal on the create_scheduled_task consent card (§25): reads are
|
|
// disclosure lines, writes are the standing grants the approval mints.
|
|
interface PermissionLine {
|
|
tool: string;
|
|
target: string;
|
|
access: string;
|
|
}
|
|
|
|
function permissionLines(args: any): PermissionLine[] {
|
|
const raw = args?.permissions;
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw
|
|
.filter((p) => p && typeof p === "object" && p.tool && p.target)
|
|
.map((p) => ({ tool: String(p.tool), target: String(p.target), access: String(p.access || "read") }));
|
|
}
|
|
|
|
export function TitleText({ line }: { line: HumanLine }) {
|
|
return (
|
|
<span className="approval-title">
|
|
{line.pre}
|
|
{line.obj && <b>{line.obj}</b>}
|
|
{line.post}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// The host a fetch-card domain grant would cover (§1.9): lowercased, `www.` stripped —
|
|
// pure spelling only, mirroring the server's minting in `allow_domain_for_session`. The
|
|
// button must name exactly what the grant covers. "" when the URL doesn't parse.
|
|
export function grantHost(url: any): string {
|
|
try {
|
|
const h = new URL(String(url ?? "")).hostname.toLowerCase();
|
|
return h.startsWith("www.") ? h.slice(4) : h;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// Plain-words scope note (replaces the "local action" badge): where does this act?
|
|
// Shared with the parked-approval card (InboxItemCard) so both dialects match (§35).
|
|
export function scopeNote(
|
|
name: string,
|
|
args: any,
|
|
category?: string,
|
|
): { text: string; external: boolean } {
|
|
// save_skill's corner answers WHERE (SKILLS-SPEC §5.2): the exact place to find, edit,
|
|
// or turn off the skill afterwards.
|
|
if (name === "save_skill") return { text: "saves to Settings ▸ Skills", external: false };
|
|
if (category === "connector") return { text: "acts on a connected service", external: true };
|
|
// Egress (§1.9): the request itself reaches the network — never "stays on this computer".
|
|
if (name === "web_fetch")
|
|
return { text: `leaves this computer → ${grantHost(args?.url) || "the web"}`, external: true };
|
|
if (name === "web_search")
|
|
return { text: "leaves this computer → your search provider", external: true };
|
|
if (EXTERNAL.has(name)) {
|
|
const platform = String(args?.target ?? "").split(":")[0];
|
|
const names: Record<string, string> = { slack: "Slack", telegram: "Telegram" };
|
|
return { text: `leaves this computer → ${names[platform] || platform || "a connected chat"}`, external: true };
|
|
}
|
|
const overwrite = name === "write_file" && args?.overwrite;
|
|
return { text: "stays on this computer" + (overwrite ? " · overwrites the existing file" : ""), external: false };
|
|
}
|
|
|
|
// The proposed content/command, straight from the tool call's ARGS — the file/action
|
|
// doesn't exist yet, so no viewer could show it (§35; see UX-018 mock note).
|
|
// Clamps by CHARACTERS as well as lines: a one-paragraph Slack digest has no
|
|
// newlines at all and once ballooned the card to full-transcript height.
|
|
const PREVIEW_LINES = 5;
|
|
const PREVIEW_CHARS = 420;
|
|
|
|
export function PreviewBlock({ text, mono = true }: { text: string; mono?: boolean }) {
|
|
const [all, setAll] = useState(false);
|
|
const lines = text.split("\n");
|
|
const clipped = lines.length > PREVIEW_LINES || text.length > PREVIEW_CHARS;
|
|
let shown = text;
|
|
if (!all && clipped) {
|
|
shown = lines.slice(0, PREVIEW_LINES).join("\n");
|
|
if (shown.length > PREVIEW_CHARS) shown = shown.slice(0, PREVIEW_CHARS).trimEnd() + "…";
|
|
}
|
|
return (
|
|
<div className={"approval-prev" + (mono ? "" : " prose")}>
|
|
{shown}
|
|
{clipped && (
|
|
<button className="approval-prev-more" onClick={() => setAll((v) => !v)}>
|
|
{all
|
|
? "show less"
|
|
: lines.length > PREVIEW_LINES
|
|
? `show all ${lines.length} lines`
|
|
: "show the full message"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Outbound message text: short one-liners keep the cozy inline quote; anything
|
|
// long (or multi-line) gets the clamped preview so the card stays card-sized.
|
|
function MessagePreview({ text, label }: { text: string; label?: string }) {
|
|
if (text.length <= 220 && !text.includes("\n")) {
|
|
return (
|
|
<div className="approval-with">
|
|
{label ? `${label}: ` : ""}“{text}”
|
|
</div>
|
|
);
|
|
}
|
|
return <PreviewBlock text={text} mono={false} />;
|
|
}
|
|
|
|
function Buttons({
|
|
item,
|
|
onApprove,
|
|
runTask,
|
|
primaryLabel,
|
|
denyLabel = "Deny",
|
|
autoApprove = false,
|
|
}: {
|
|
item: ApprovalItem;
|
|
onApprove: (decision: ApprovalDecision) => void;
|
|
runTask?: { id: string; title: string } | null;
|
|
primaryLabel: string;
|
|
denyLabel?: string;
|
|
// Session is in Auto-Approve mode: session grants don't skip the reviewer there (§1.5),
|
|
// so no session-scoped "always" button is shown at all — a button that lies is worse
|
|
// than none. Allow once / Deny only.
|
|
autoApprove?: boolean;
|
|
}) {
|
|
const connector = item.category === "connector";
|
|
const offerStanding = !!(runTask && item.standingTarget);
|
|
// §1.9: egress grants are destination-shaped. web_fetch offers the DOMAIN — tool-wide
|
|
// would cover every future destination, so it's withheld (and server-refused). web_search
|
|
// has a fixed destination (the configured provider), so tool-wide IS provider-wide and
|
|
// the button is labelled by what it actually grants: searches.
|
|
const fetchHost = item.name === "web_fetch" ? grantHost(item.args?.url) : "";
|
|
const noSessionGrant =
|
|
autoApprove ||
|
|
offerStanding ||
|
|
connector ||
|
|
item.name === "run_shell" ||
|
|
item.name === "save_skill" ||
|
|
item.name === "web_fetch" ||
|
|
item.name === "web_search";
|
|
return (
|
|
<div className="approval-btns">
|
|
<button className="btn approval-primary" onClick={() => onApprove("once")}>
|
|
{primaryLabel}
|
|
</button>
|
|
{offerStanding && (
|
|
<button
|
|
className="btn"
|
|
title={`Always allow ${item.name} → ${item.standingTarget} for “${runTask?.title || "this automation"}” — revoke any time on its Automations page`}
|
|
onClick={() => onApprove("always_task")}
|
|
>
|
|
Allow every time
|
|
</button>
|
|
)}
|
|
{/* In a run context the task-persistent grant replaces the session-scoped one —
|
|
a run session is ephemeral, and two adjacent "always" buttons would blur
|
|
exactly the scope distinction §25 exists to draw. Same rule for run_shell:
|
|
the command-scoped button below is the specific (safer) grant, so the
|
|
tool-wide one stays out of the card. */}
|
|
{/* save_skill: no session-wide "always" — every skill proposal gets its own review
|
|
(SKILLS-SPEC §5: one gate, always). */}
|
|
{!noSessionGrant && (
|
|
<button
|
|
className="btn"
|
|
title={`Always allow ${TOOL_VERBS[item.name]?.toLowerCase() || item.name} for this session`}
|
|
onClick={() => onApprove("always_tool")}
|
|
>
|
|
Always allow
|
|
</button>
|
|
)}
|
|
{!autoApprove && !offerStanding && item.name === "web_fetch" && fetchHost && (
|
|
<button
|
|
className="btn"
|
|
title={`Every fetch to ${fetchHost} (and its subdomains) runs without asking for the rest of this session`}
|
|
onClick={() => onApprove("always_domain")}
|
|
>
|
|
Always allow {fetchHost} this session
|
|
</button>
|
|
)}
|
|
{!autoApprove && !offerStanding && item.name === "web_search" && (
|
|
<button
|
|
className="btn"
|
|
title="Every web search runs without asking for the rest of this session — the grant ends if you change the search provider"
|
|
onClick={() => onApprove("always_tool")}
|
|
>
|
|
Always allow searches this session
|
|
</button>
|
|
)}
|
|
{!autoApprove && item.name === "run_shell" && (
|
|
<button className="btn" onClick={() => onApprove("always_command")}>
|
|
Always allow this command
|
|
</button>
|
|
)}
|
|
<span className="spacer" />
|
|
<button className="btn quiet-deny" onClick={() => onApprove("deny")}>
|
|
{denyLabel}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ApprovalCard({
|
|
item,
|
|
onApprove,
|
|
runTask,
|
|
compact = false,
|
|
autoApprove = false,
|
|
}: {
|
|
item: ApprovalItem;
|
|
onApprove: (decision: ApprovalDecision) => void;
|
|
// Present when this approval was raised inside an automation run — unlocks the
|
|
// task-persistent "Allow every time" (in-app only, §25).
|
|
runTask?: { id: string; title: string } | null;
|
|
compact?: boolean;
|
|
// Session is in Auto-Approve mode — this card is a reviewer fall-through, and session
|
|
// grants wouldn't skip the reviewer anyway (§1.5), so the "always" buttons are hidden.
|
|
autoApprove?: boolean;
|
|
}) {
|
|
const [peek, setPeek] = useState(false);
|
|
const title = humanizeApprovalTitle(item.name, item.args);
|
|
const scope = scopeNote(item.name, item.args, item.category);
|
|
const grants = item.name === "create_scheduled_task" ? permissionLines(item.args) : [];
|
|
// "requires approval" is the engine's default boilerplate — only surface a real reason.
|
|
const reason = item.reason && item.reason !== "requires approval" ? item.reason : "";
|
|
const offerStanding = !!(runTask && item.standingTarget);
|
|
const dock = compact ? " approval-dock" : "";
|
|
|
|
// §35 compact row: routine workspace writes — one line, preview expands inline from the
|
|
// tool args. Standing/grant flows keep the full card (they carry §25 consent weight).
|
|
const content = typeof item.args?.content === "string" ? item.args.content : "";
|
|
if (FILE_WRITES.has(item.name) && !offerStanding && !grants.length && !item.resolved) {
|
|
return (
|
|
<div className={"approval approval-row" + dock} data-testid="approval-row">
|
|
<div className="approval-row-line">
|
|
<TitleText line={title} />
|
|
{content && (
|
|
<button className="approval-peek" onClick={() => setPeek((v) => !v)}>
|
|
preview {peek ? "▴" : "▾"}
|
|
</button>
|
|
)}
|
|
<span className="spacer" />
|
|
<Buttons
|
|
item={item}
|
|
onApprove={onApprove}
|
|
runTask={runTask}
|
|
primaryLabel="Allow"
|
|
autoApprove={autoApprove}
|
|
/>
|
|
</div>
|
|
{peek && content && <PreviewBlock text={content} />}
|
|
{reason && <div className="approval-reason">{reason}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={"approval" + (scope.external ? " approval-external" : "") + dock}>
|
|
<div className="approval-top">
|
|
<div className="approval-heading">
|
|
<span className="approval-ico" title={`Tool: ${item.name}`}>
|
|
<Icon name="shield" size={15} />
|
|
</span>
|
|
<TitleText line={title} />
|
|
</div>
|
|
<span className={"approval-scope" + (scope.external ? " out" : "")}>{scope.text}</span>
|
|
</div>
|
|
|
|
{/* Tool-shaped previews — the proposal, not an args dump. */}
|
|
{item.name === "run_shell" && item.args?.command && (
|
|
<PreviewBlock text={String(item.args.command)} />
|
|
)}
|
|
{FILE_WRITES.has(item.name) && content && <PreviewBlock text={content} />}
|
|
{item.name === "send_file" && (
|
|
<>
|
|
<span className="approval-filechip">
|
|
<span className="ico">
|
|
<Icon name="file" size={13} />
|
|
</span>
|
|
{String(item.args?.path ?? "").split("/").pop() || "file"}
|
|
{item.args?.as_screenshot ? " · as a PNG screenshot" : ""}
|
|
</span>
|
|
{item.args?.comment && (
|
|
<MessagePreview text={String(item.args.comment)} label="With the message" />
|
|
)}
|
|
</>
|
|
)}
|
|
{item.name === "send_message" && item.args?.text && (
|
|
<MessagePreview text={String(item.args.text)} />
|
|
)}
|
|
{/* save_skill (SKILLS-SPEC §5.2): the arguments ARE the review surface. */}
|
|
{item.name === "save_skill" && <SaveSkillPreview args={item.args} />}
|
|
{/* web_search (§1.9): name the LIVE destination — "currently", never "default",
|
|
because the card must show the setting as it stands right now. */}
|
|
{item.name === "web_search" && (
|
|
<div className="approval-with">
|
|
Queries go to your configured search provider
|
|
{item.searchProvider ? ` (currently: ${item.searchProvider})` : ""}.
|
|
</div>
|
|
)}
|
|
|
|
{grants.length > 0 && (
|
|
<div className="approval-grants" data-testid="approval-grants">
|
|
{grants.map((g, i) => (
|
|
<div className="approval-grant" key={i} data-access={g.access}>
|
|
<span className={"grant-mark" + (g.access === "write" ? " write" : "")}>
|
|
{g.access === "write" ? "✓" : "·"}
|
|
</span>
|
|
<span className="grant-line">
|
|
{TOOL_VERBS[g.tool] || g.tool} <code className="approval-tool">{g.target}</code>
|
|
<span className="grant-note">
|
|
{g.access === "write" ? " — always allowed once you approve" : " — read-only"}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* Long-tail tools: no bespoke preview — fall back to the compact args line. */}
|
|
{!FILE_WRITES.has(item.name) &&
|
|
!["run_shell", "send_message", "send_file", "save_skill"].includes(item.name) &&
|
|
!grants.length &&
|
|
shortArgs(item.args) && <div className="approval-rest">{shortArgs(item.args)}</div>}
|
|
{reason && <div className="approval-reason">{reason}</div>}
|
|
|
|
{item.resolved ? (
|
|
<div className="resolved">Approved: {item.resolved.replace("_", " ")}</div>
|
|
) : (
|
|
<Buttons
|
|
item={item}
|
|
onApprove={onApprove}
|
|
runTask={runTask}
|
|
primaryLabel={approvalActionLabels(item.name).allow}
|
|
denyLabel={approvalActionLabels(item.name).deny}
|
|
autoApprove={autoApprove}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|