mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 06:30:25 +00:00
Step 3b: web_search -> EGRESS + the 1.9 egress cards
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.
This commit is contained in:
@@ -678,6 +678,7 @@ export function App() {
|
||||
reason: d.reason,
|
||||
category: d.category,
|
||||
standingTarget: d.standing_target || undefined,
|
||||
searchProvider: d.search_provider || undefined,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
@@ -1679,7 +1680,13 @@ export function App() {
|
||||
) : !unattended && pendingDirReq?.kind === "dirreq" ? (
|
||||
<DirectoryRequestCard item={pendingDirReq} onRespond={respondDirectory} />
|
||||
) : !unattended && pendingApproval?.kind === "approval" ? (
|
||||
<ApprovalCard item={pendingApproval} onApprove={approve} runTask={runContext} compact />
|
||||
<ApprovalCard
|
||||
item={pendingApproval}
|
||||
onApprove={approve}
|
||||
runTask={runContext}
|
||||
autoApprove={mode === "auto-approve"}
|
||||
compact
|
||||
/>
|
||||
) : !unattended && pendingQuestion?.kind === "question" ? (
|
||||
// Live ask_user in an attended session — answer inline (reuses the Inbox card UI).
|
||||
<InboxItemCard
|
||||
|
||||
@@ -264,6 +264,78 @@ describe("ApprovalCard — save_skill (SKILLS-SPEC §5.2)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApprovalCard — §1.9 egress cards", () => {
|
||||
const fetchApproval = (extra: Partial<ApprovalItem> = {}): ApprovalItem => ({
|
||||
kind: "approval",
|
||||
name: "web_fetch",
|
||||
args: { url: "https://www.bbc.com/news/article-1" },
|
||||
reason: "requires approval",
|
||||
category: undefined,
|
||||
...extra,
|
||||
});
|
||||
|
||||
it("web_fetch offers the DOMAIN grant (www-stripped), never a tool-wide always", () => {
|
||||
const onApprove = vi.fn();
|
||||
render(<ApprovalCard item={fetchApproval()} onApprove={onApprove} />);
|
||||
// The grant button names exactly what it covers — the spelling the server mints.
|
||||
fireEvent.click(screen.getByText("Always allow bbc.com this session"));
|
||||
expect(onApprove).toHaveBeenCalledWith("always_domain");
|
||||
expect(screen.queryByText("Always allow")).toBeNull(); // no tool-wide button
|
||||
expect(screen.getByText(/leaves this computer → bbc\.com/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("web_fetch with an unparseable url falls back to once/deny only", () => {
|
||||
render(<ApprovalCard item={fetchApproval({ args: { url: "not a url" } })} onApprove={vi.fn()} />);
|
||||
expect(screen.queryByText(/Always allow/)).toBeNull();
|
||||
expect(screen.getByText("Allow once")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("web_search offers the searches grant and names the LIVE provider", () => {
|
||||
const onApprove = vi.fn();
|
||||
render(
|
||||
<ApprovalCard
|
||||
item={fetchApproval({
|
||||
name: "web_search",
|
||||
args: { query: "H-1B visa rule change" },
|
||||
searchProvider: "duckduckgo",
|
||||
})}
|
||||
onApprove={onApprove}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Queries go to your configured search provider \(currently: duckduckgo\)\./),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Always allow searches this session"));
|
||||
expect(onApprove).toHaveBeenCalledWith("always_tool"); // tool-wide IS provider-wide here
|
||||
expect(screen.getByText(/leaves this computer → your search provider/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Auto-Approve fall-through cards hide every session 'always' (§1.5: grants don't skip the reviewer)", () => {
|
||||
render(<ApprovalCard item={fetchApproval()} onApprove={vi.fn()} autoApprove />);
|
||||
expect(screen.queryByText(/Always allow/)).toBeNull();
|
||||
cleanup();
|
||||
render(
|
||||
<ApprovalCard
|
||||
item={fetchApproval({ name: "web_search", args: { query: "x" } })}
|
||||
onApprove={vi.fn()}
|
||||
autoApprove
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/Always allow/)).toBeNull();
|
||||
cleanup();
|
||||
render(
|
||||
<ApprovalCard
|
||||
item={fetchApproval({ name: "run_shell", args: { command: "ls" } })}
|
||||
onApprove={vi.fn()}
|
||||
autoApprove
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Always allow this command")).toBeNull();
|
||||
expect(screen.getByText("Allow once")).toBeTruthy();
|
||||
expect(screen.getByText("Deny")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InboxItemCard — parked save_skill proposals (SKILLS-SPEC §5.2)", () => {
|
||||
const parked = (): InboxItem => ({
|
||||
id: "i9",
|
||||
|
||||
@@ -95,6 +95,18 @@ export function TitleText({ line }: { line: HumanLine }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -106,6 +118,11 @@ export function scopeNote(
|
||||
// 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" };
|
||||
@@ -166,15 +183,33 @@ function Buttons({
|
||||
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")}>
|
||||
@@ -196,7 +231,7 @@ function Buttons({
|
||||
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). */}
|
||||
{!connector && !offerStanding && item.name !== "run_shell" && item.name !== "save_skill" && (
|
||||
{!noSessionGrant && (
|
||||
<button
|
||||
className="btn"
|
||||
title={`Always allow ${TOOL_VERBS[item.name]?.toLowerCase() || item.name} for this session`}
|
||||
@@ -205,7 +240,25 @@ function Buttons({
|
||||
Always allow
|
||||
</button>
|
||||
)}
|
||||
{item.name === "run_shell" && (
|
||||
{!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>
|
||||
@@ -223,6 +276,7 @@ export function ApprovalCard({
|
||||
onApprove,
|
||||
runTask,
|
||||
compact = false,
|
||||
autoApprove = false,
|
||||
}: {
|
||||
item: ApprovalItem;
|
||||
onApprove: (decision: ApprovalDecision) => void;
|
||||
@@ -230,6 +284,9 @@ export function ApprovalCard({
|
||||
// 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);
|
||||
@@ -254,7 +311,13 @@ export function ApprovalCard({
|
||||
</button>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<Buttons item={item} onApprove={onApprove} runTask={runTask} primaryLabel="Allow" />
|
||||
<Buttons
|
||||
item={item}
|
||||
onApprove={onApprove}
|
||||
runTask={runTask}
|
||||
primaryLabel="Allow"
|
||||
autoApprove={autoApprove}
|
||||
/>
|
||||
</div>
|
||||
{peek && content && <PreviewBlock text={content} />}
|
||||
{reason && <div className="approval-reason">{reason}</div>}
|
||||
@@ -298,6 +361,14 @@ export function ApprovalCard({
|
||||
)}
|
||||
{/* 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">
|
||||
@@ -332,6 +403,7 @@ export function ApprovalCard({
|
||||
runTask={runTask}
|
||||
primaryLabel={approvalActionLabels(item.name).allow}
|
||||
denyLabel={approvalActionLabels(item.name).deny}
|
||||
autoApprove={autoApprove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ import type { MessageSource } from "./api";
|
||||
|
||||
// "always_task" persists to the owning automation's task record (standing scoped
|
||||
// approval, UX-DECISIONS §25) — offered only on automation-run approval cards, in-app.
|
||||
export type ApprovalDecision = "once" | "deny" | "always_tool" | "always_command" | "always_task";
|
||||
export type ApprovalDecision = "once" | "deny" | "always_tool" | "always_command" | "always_domain" | "always_task";
|
||||
|
||||
export interface TodoItem {
|
||||
content: string;
|
||||
@@ -119,6 +119,9 @@ export type Item =
|
||||
// The exact target a standing rule could pin (server-computed) — with a run
|
||||
// context, the card offers "Allow every time" (§25).
|
||||
standingTarget?: string;
|
||||
// web_search only (§1.9): the LIVE configured provider name, resolved server-side
|
||||
// when the card was raised — the grant description names the actual destination.
|
||||
searchProvider?: string;
|
||||
resolved?: ApprovalDecision;
|
||||
}
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user