security: refine workspace trust controls

This commit is contained in:
Rohit P
2026-07-24 18:44:39 -07:00
parent 8ee0a0d082
commit 3f5ac872ca
11 changed files with 427 additions and 9 deletions
+11
View File
@@ -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
View File
@@ -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&rsquo;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>
);
}