mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 06:30:25 +00:00
security: refine workspace trust controls
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
type Persona,
|
||||
type RecentWorkspace,
|
||||
type SurfaceVisibility,
|
||||
type WorkspaceCommandTrust,
|
||||
} from "./api";
|
||||
import type { ApprovalDecision, Attachment, Item, SessionInfo, TodoItem, WsEvent } from "./types";
|
||||
import { isProjectScoped } from "./personaScope";
|
||||
@@ -54,6 +55,7 @@ import { InboxView } from "./components/InboxView";
|
||||
import { ApprovalCard } from "./components/ApprovalCard";
|
||||
import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
|
||||
import { PlanCard } from "./components/PlanCard";
|
||||
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
|
||||
|
||||
const newId = () =>
|
||||
(crypto as any).randomUUID ? crypto.randomUUID().slice(0, 12) : Math.random().toString(36).slice(2, 14);
|
||||
@@ -145,6 +147,8 @@ export function App() {
|
||||
const [workspace, setWorkspace] = useState<string | null>(null);
|
||||
const [branch, setBranch] = useState<string | null>(null);
|
||||
const [showGate, setShowGate] = useState(false);
|
||||
const [workspaceTrustRequest, setWorkspaceTrustRequest] =
|
||||
useState<WorkspaceCommandTrust | null>(null);
|
||||
const [agent, setAgent] = useState("cowork");
|
||||
const [model, setModel] = useState("gpt-5.6-sol");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
@@ -558,6 +562,7 @@ export function App() {
|
||||
setConnected(true);
|
||||
if (d.model) setModel(d.model);
|
||||
if (d.mode) setMode(d.mode);
|
||||
if (d.command_trust?.required) setWorkspaceTrustRequest(d.command_trust);
|
||||
// Cowork: adopt the server-provisioned scratch dir (only when we don't already have one).
|
||||
if (d.workspace) setWorkspace((cur) => cur || d.workspace);
|
||||
break;
|
||||
@@ -1623,6 +1628,12 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{workspaceTrustRequest && (
|
||||
<WorkspaceTrustPrompt
|
||||
request={workspaceTrustRequest}
|
||||
onClose={() => setWorkspaceTrustRequest(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+32
-2
@@ -24,6 +24,14 @@ export interface RecentWorkspace {
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandTrust {
|
||||
workspace: string;
|
||||
requested_commands: string[];
|
||||
trusted: boolean;
|
||||
required: boolean;
|
||||
exists?: boolean;
|
||||
}
|
||||
|
||||
export async function getHealth(): Promise<Health> {
|
||||
const res = await fetch(`${httpBase()}/v1/health`);
|
||||
return res.json();
|
||||
@@ -49,7 +57,13 @@ export async function pickFolderViaServer(): Promise<string | null> {
|
||||
export async function openWorkspace(
|
||||
path: string,
|
||||
create = false,
|
||||
): Promise<{ path: string; ok: boolean; error?: string; git_branch?: string | null }> {
|
||||
): Promise<{
|
||||
path: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
git_branch?: string | null;
|
||||
command_trust?: WorkspaceCommandTrust;
|
||||
}> {
|
||||
const res = await fetch(`${httpBase()}/v1/workspaces/open`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -58,6 +72,23 @@ export async function openWorkspace(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getTrustedWorkspaces(): Promise<WorkspaceCommandTrust[]> {
|
||||
const res = await fetch(`${httpBase()}/v1/workspaces/trusted`);
|
||||
return (await res.json()).workspaces ?? [];
|
||||
}
|
||||
|
||||
export async function setWorkspaceTrusted(
|
||||
path: string,
|
||||
trusted: boolean,
|
||||
): Promise<{ ok: boolean; error?: string } & WorkspaceCommandTrust> {
|
||||
const res = await fetch(`${httpBase()}/v1/workspaces/trust`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, trusted }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getSessions(workspace?: string): Promise<SessionInfo[]> {
|
||||
const q = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
|
||||
const res = await fetch(`${httpBase()}/v1/sessions${q}`);
|
||||
@@ -1771,4 +1802,3 @@ export class Session {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getSettings,
|
||||
getTrustedWorkspaces,
|
||||
setOnboarded,
|
||||
setPdfSettings,
|
||||
setScratchBase,
|
||||
setSessionsPeek,
|
||||
setWorkspaceTrusted,
|
||||
type ModelSettings,
|
||||
type PdfSettings,
|
||||
type WorkspaceCommandTrust,
|
||||
} from "../api";
|
||||
import {
|
||||
cancelDictationModelDownload,
|
||||
@@ -424,6 +427,8 @@ function AppearanceSection() {
|
||||
|
||||
<FilesCard />
|
||||
|
||||
<TrustedWorkspacesCard />
|
||||
|
||||
{desktop && (
|
||||
<div className={CARD + " p-4"}>
|
||||
<div className={FIELD_LABEL + " mb-2.5"}>Always-on</div>
|
||||
@@ -461,6 +466,61 @@ function AppearanceSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function TrustedWorkspacesCard() {
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceCommandTrust[] | null>(null);
|
||||
|
||||
const refresh = () =>
|
||||
getTrustedWorkspaces()
|
||||
.then(setWorkspaces)
|
||||
.catch(() => setWorkspaces([]));
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const revoke = async (path: string) => {
|
||||
if (!window.confirm(`Revoke command trust for ${path}?`)) return;
|
||||
await setWorkspaceTrusted(path, false);
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={CARD + " p-4 mb-4"} data-testid="trusted-workspaces-card">
|
||||
<div className={FIELD_LABEL}>Trusted workspaces</div>
|
||||
<div className={FIELD_HELP}>
|
||||
Trusted projects may manage their command allowances in .coworker/config.toml.
|
||||
</div>
|
||||
{workspaces === null ? (
|
||||
<div className="text-[12px] text-muted mt-3">Loading…</div>
|
||||
) : workspaces.length === 0 ? (
|
||||
<div className="text-[12px] text-muted mt-3">No workspaces are trusted.</div>
|
||||
) : (
|
||||
<div className="mt-3 divide-y divide-line">
|
||||
{workspaces.map((workspace) => (
|
||||
<div key={workspace.workspace} className="py-2.5 flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12.5px] text-ink break-all">{workspace.workspace}</div>
|
||||
<div className="text-[11.5px] text-muted mt-0.5">
|
||||
{workspace.requested_commands.length
|
||||
? `${workspace.requested_commands.length} project command allowance${workspace.requested_commands.length === 1 ? "" : "s"}`
|
||||
: "No project command allowances currently declared"}
|
||||
{!workspace.exists ? " · Folder unavailable" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="text-[12px] text-red-600 px-2 py-1"
|
||||
onClick={() => void revoke(workspace.workspace)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateInline() {
|
||||
const [state, setState] = useState<"idle" | "checking" | "none" | "found" | "installing" | "error">("idle");
|
||||
const [version, setVersion] = useState("");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { setWorkspaceTrusted, type WorkspaceCommandTrust } from "../api";
|
||||
|
||||
export function WorkspaceTrustPrompt({
|
||||
request,
|
||||
onClose,
|
||||
}: {
|
||||
request: WorkspaceCommandTrust;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const trust = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const result = await setWorkspaceTrusted(request.workspace, true).catch(() => null);
|
||||
setSaving(false);
|
||||
if (!result?.ok) {
|
||||
setError(result?.error || "Could not save workspace trust.");
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gate-overlay" role="dialog" aria-modal="true" aria-labelledby="workspace-trust-title">
|
||||
<div className="gate max-w-[560px]">
|
||||
<div className="gate-mark">✦</div>
|
||||
<h2 id="workspace-trust-title">Trust this workspace’s commands?</h2>
|
||||
<p className="gate-sub">
|
||||
This project asks OpenWorker to run the commands below without individual approval.
|
||||
Trust applies to future configuration changes at this exact folder until you revoke it
|
||||
in Settings.
|
||||
</p>
|
||||
<div className="rounded-lg border border-line bg-paper px-3 py-2.5 max-h-48 overflow-y-auto">
|
||||
{request.requested_commands.map((command) => (
|
||||
<code key={command} className="block text-[12.5px] py-1 text-ink">
|
||||
{command}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[11.5px] text-muted mt-2 break-all">{request.workspace}</div>
|
||||
{error && <div className="gate-error">{error}</div>}
|
||||
<div className="gate-foot justify-end gap-2">
|
||||
<button className="btn" onClick={onClose} disabled={saving}>
|
||||
Keep asking
|
||||
</button>
|
||||
<button className="btn primary" onClick={() => void trust()} disabled={saving}>
|
||||
{saving ? "Saving…" : "Trust workspace"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user