mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-12 23:29:55 +00:00
security: refine workspace trust controls
This commit is contained in:
+3
-1
@@ -37,6 +37,7 @@ from .tools.directories import request_directory_tool
|
|||||||
from .tools.plan import propose_plan_tool
|
from .tools.plan import propose_plan_tool
|
||||||
from .tools.subagent import explorer_tools
|
from .tools.subagent import explorer_tools
|
||||||
from .web import make_web_fetch_tool, make_web_search_tool
|
from .web import make_web_fetch_tool, make_web_search_tool
|
||||||
|
from .workspace_trust import WorkspaceTrustStore
|
||||||
from .tools.shell import LocalExecutor
|
from .tools.shell import LocalExecutor
|
||||||
from .tools.todo import TodoList
|
from .tools.todo import TodoList
|
||||||
|
|
||||||
@@ -147,7 +148,8 @@ def build_engine(
|
|||||||
else:
|
else:
|
||||||
root_list = []
|
root_list = []
|
||||||
|
|
||||||
config = load_config(ws)
|
workspace_trusted = bool(ws and WorkspaceTrustStore().is_trusted(ws))
|
||||||
|
config = load_config(ws, workspace_trusted=workspace_trusted)
|
||||||
executor = (
|
executor = (
|
||||||
LocalExecutor(cwd=ws) if (agent.needs_workspace and ws is not None) else None
|
LocalExecutor(cwd=ws) if (agent.needs_workspace and ws is not None) else None
|
||||||
)
|
)
|
||||||
|
|||||||
+24
-5
@@ -3,8 +3,8 @@
|
|||||||
Global: <state-dir>/config.toml (see `secrets.state_dir`; platform-native)
|
Global: <state-dir>/config.toml (see `secrets.state_dir`; platform-native)
|
||||||
Workspace: <workspace>/.coworker/config.toml (overrides global)
|
Workspace: <workspace>/.coworker/config.toml (overrides global)
|
||||||
|
|
||||||
Permission grants are deliberately global-only. A checked-out repository is untrusted
|
Workspace command allowances apply only after the user trusts that exact canonical
|
||||||
input and must not be able to grant its own shell commands or consequential tools.
|
workspace path. Other permission grants remain global-only.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -74,8 +74,9 @@ _FIELDS = {
|
|||||||
"cloud_relay_ws_url",
|
"cloud_relay_ws_url",
|
||||||
}
|
}
|
||||||
|
|
||||||
# These fields change what consequential actions can run without a prompt. Only the
|
# These fields change what consequential actions can run without a prompt, so the normal
|
||||||
# user-owned global config may set them; a repository's `.coworker/config.toml` may not.
|
# workspace override pass never applies them. `allowed_commands` is added separately only
|
||||||
|
# for a canonically trusted workspace; `auto_allow` remains user-global only.
|
||||||
_GLOBAL_ONLY_FIELDS = {"allowed_commands", "auto_allow"}
|
_GLOBAL_ONLY_FIELDS = {"allowed_commands", "auto_allow"}
|
||||||
_WORKSPACE_FIELDS = _FIELDS - _GLOBAL_ONLY_FIELDS
|
_WORKSPACE_FIELDS = _FIELDS - _GLOBAL_ONLY_FIELDS
|
||||||
|
|
||||||
@@ -92,8 +93,20 @@ def _read(path: Path) -> dict[str, Any]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_allowed_commands(workspace: str | Path) -> list[str]:
|
||||||
|
"""Command prefixes requested by repository config; advisory until workspace trust."""
|
||||||
|
path = Path(workspace).expanduser() / ".coworker" / "config.toml"
|
||||||
|
value = _read(path).get("allowed_commands", [])
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return list(dict.fromkeys(v.strip() for v in value if isinstance(v, str) and v.strip()))
|
||||||
|
|
||||||
|
|
||||||
def load_config(
|
def load_config(
|
||||||
workspace: Optional[str | Path] = None, *, global_path: Optional[Path] = None
|
workspace: Optional[str | Path] = None,
|
||||||
|
*,
|
||||||
|
global_path: Optional[Path] = None,
|
||||||
|
workspace_trusted: bool = False,
|
||||||
) -> Config:
|
) -> Config:
|
||||||
cfg = Config()
|
cfg = Config()
|
||||||
|
|
||||||
@@ -108,4 +121,10 @@ def load_config(
|
|||||||
for key, value in _read(w).items():
|
for key, value in _read(w).items():
|
||||||
if key in _WORKSPACE_FIELDS:
|
if key in _WORKSPACE_FIELDS:
|
||||||
setattr(cfg, key, value)
|
setattr(cfg, key, value)
|
||||||
|
if workspace_trusted:
|
||||||
|
cfg.allowed_commands = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
[*cfg.allowed_commands, *workspace_allowed_commands(workspace)]
|
||||||
|
)
|
||||||
|
)
|
||||||
return cfg
|
return cfg
|
||||||
|
|||||||
@@ -523,6 +523,17 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
body.get("path", ""), create=bool(body.get("create"))
|
body.get("path", ""), create=bool(body.get("create"))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.get("/v1/workspaces/trusted")
|
||||||
|
def trusted_workspaces() -> dict[str, Any]:
|
||||||
|
return {"workspaces": manager.trusted_workspaces()}
|
||||||
|
|
||||||
|
@app.post("/v1/workspaces/trust")
|
||||||
|
def set_workspace_trust(body: dict) -> dict[str, Any]:
|
||||||
|
return manager.set_workspace_trust(
|
||||||
|
str((body or {}).get("path", "")),
|
||||||
|
trusted=bool((body or {}).get("trusted", False)),
|
||||||
|
)
|
||||||
|
|
||||||
@app.post("/v1/workspaces/pick")
|
@app.post("/v1/workspaces/pick")
|
||||||
async def pick_workspace() -> dict[str, Any]:
|
async def pick_workspace() -> dict[str, Any]:
|
||||||
# Native folder picker opened by the LOCAL sidecar (browser GUIs can't get absolute
|
# Native folder picker opened by the LOCAL sidecar (browser GUIs can't get absolute
|
||||||
@@ -1607,6 +1618,9 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
if getattr(engine, "executor", None)
|
if getattr(engine, "executor", None)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
"command_trust": manager.workspace_command_trust(
|
||||||
|
str(getattr(engine, "audit_context", {}).get("workspace", ""))
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ from ..subscriptions import ChannelBuffer, SubscriptionStore
|
|||||||
from ..unrouted import UnroutedStore
|
from ..unrouted import UnroutedStore
|
||||||
from ..unattended import UnattendedRegistry
|
from ..unattended import UnattendedRegistry
|
||||||
from ..audit import AuditStore
|
from ..audit import AuditStore
|
||||||
|
from ..config import load_config, workspace_allowed_commands
|
||||||
from ..conversations import ConversationStore, title_from
|
from ..conversations import ConversationStore, title_from
|
||||||
from ..engine import ApprovalOutcome, Approver, TurnEngine
|
from ..engine import ApprovalOutcome, Approver, TurnEngine
|
||||||
from ..roots import RootDir
|
from ..roots import RootDir
|
||||||
|
from ..workspace_trust import WorkspaceTrustStore
|
||||||
from ..automation import Schedule, ScheduledTask, Scheduler, TaskRun, TaskStore
|
from ..automation import Schedule, ScheduledTask, Scheduler, TaskRun, TaskStore
|
||||||
from ..connectors import (
|
from ..connectors import (
|
||||||
Gateway,
|
Gateway,
|
||||||
@@ -140,6 +142,7 @@ class SessionManager:
|
|||||||
self._autotitle_inflight: set[str] = set()
|
self._autotitle_inflight: set[str] = set()
|
||||||
self._autotitle_tasks: set[asyncio.Task] = set()
|
self._autotitle_tasks: set[asyncio.Task] = set()
|
||||||
self._autotitle_attempts: dict[str, int] = {}
|
self._autotitle_attempts: dict[str, int] = {}
|
||||||
|
self.workspace_trust = WorkspaceTrustStore()
|
||||||
self.secrets = SecretStore()
|
self.secrets = SecretStore()
|
||||||
# No explicit provider injected → route by the model's `provider:` prefix (OpenAI default,
|
# No explicit provider injected → route by the model's `provider:` prefix (OpenAI default,
|
||||||
# Ollama, …). Tests inject a provider directly and bypass the router. The same router is
|
# Ollama, …). Tests inject a provider directly and bypass the router. The same router is
|
||||||
@@ -242,7 +245,69 @@ class SessionManager:
|
|||||||
return {"path": str(resolved), "ok": False, "error": str(exc)}
|
return {"path": str(resolved), "ok": False, "error": str(exc)}
|
||||||
resolved = resolved.resolve()
|
resolved = resolved.resolve()
|
||||||
self.session_store.touch_workspace(str(resolved))
|
self.session_store.touch_workspace(str(resolved))
|
||||||
return {"path": str(resolved), "ok": True, "git_branch": _git_branch(resolved)}
|
return {
|
||||||
|
"path": str(resolved),
|
||||||
|
"ok": True,
|
||||||
|
"git_branch": _git_branch(resolved),
|
||||||
|
"command_trust": self.workspace_command_trust(resolved),
|
||||||
|
}
|
||||||
|
|
||||||
|
def workspace_command_trust(self, path: str | Path) -> dict[str, Any]:
|
||||||
|
if not str(path).strip():
|
||||||
|
return {
|
||||||
|
"workspace": "",
|
||||||
|
"requested_commands": [],
|
||||||
|
"trusted": False,
|
||||||
|
"required": False,
|
||||||
|
}
|
||||||
|
canonical = WorkspaceTrustStore.canonical(path)
|
||||||
|
commands = (
|
||||||
|
workspace_allowed_commands(canonical)
|
||||||
|
if Path(canonical).is_dir()
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
trusted = self.workspace_trust.is_trusted(canonical)
|
||||||
|
return {
|
||||||
|
"workspace": canonical,
|
||||||
|
"requested_commands": commands,
|
||||||
|
"trusted": trusted,
|
||||||
|
"required": bool(commands and not trusted),
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_workspace_trust(
|
||||||
|
self, path: str | Path, *, trusted: bool
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not str(path).strip():
|
||||||
|
return {"ok": False, "error": "workspace path is required"}
|
||||||
|
candidate = Path(path).expanduser()
|
||||||
|
if trusted and not candidate.is_dir():
|
||||||
|
return {"ok": False, "error": "workspace is not a directory"}
|
||||||
|
canonical = self.workspace_trust.set_trusted(candidate, trusted)
|
||||||
|
effective = load_config(
|
||||||
|
canonical, workspace_trusted=trusted
|
||||||
|
).allowed_commands
|
||||||
|
# Apply trust/revocation immediately to live sessions rooted at this exact path.
|
||||||
|
for engine in self._engines.values():
|
||||||
|
engine_workspace = str(
|
||||||
|
(getattr(engine, "audit_context", {}) or {}).get("workspace", "")
|
||||||
|
)
|
||||||
|
if engine_workspace and WorkspaceTrustStore.canonical(
|
||||||
|
engine_workspace
|
||||||
|
) == canonical:
|
||||||
|
engine.permissions.allowed_commands = list(effective)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
**self.workspace_command_trust(canonical),
|
||||||
|
}
|
||||||
|
|
||||||
|
def trusted_workspaces(self) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
**self.workspace_command_trust(path),
|
||||||
|
"exists": Path(path).is_dir(),
|
||||||
|
}
|
||||||
|
for path in self.workspace_trust.list()
|
||||||
|
]
|
||||||
|
|
||||||
def recent_workspaces(self) -> list[dict[str, Any]]:
|
def recent_workspaces(self) -> list[dict[str, Any]]:
|
||||||
"""Recent real projects for the folder gate. Per-conversation scratch dirs are
|
"""Recent real projects for the folder gate. Per-conversation scratch dirs are
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""User-owned trust decisions for repository-provided command allowances.
|
||||||
|
|
||||||
|
A repository may declare command prefixes in `.coworker/config.toml`, but those grants
|
||||||
|
take effect only after the user trusts that exact canonical workspace root. Trust follows
|
||||||
|
the path rather than a snapshot of the config: future changes at a trusted path are
|
||||||
|
accepted until the user revokes trust.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .secrets import state_dir
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceTrustStore:
|
||||||
|
def __init__(self, path: Optional[str | Path] = None) -> None:
|
||||||
|
self.path = (
|
||||||
|
Path(path) if path is not None else state_dir() / "workspace_trust.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def canonical(path: str | Path) -> str:
|
||||||
|
return str(Path(path).expanduser().resolve())
|
||||||
|
|
||||||
|
def _load(self) -> set[str]:
|
||||||
|
try:
|
||||||
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return set()
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return set()
|
||||||
|
values = data.get("trusted_workspaces", [])
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return set()
|
||||||
|
return {str(v) for v in values if isinstance(v, str) and v}
|
||||||
|
|
||||||
|
def is_trusted(self, workspace: str | Path) -> bool:
|
||||||
|
return self.canonical(workspace) in self._load()
|
||||||
|
|
||||||
|
def list(self) -> list[str]:
|
||||||
|
return sorted(self._load())
|
||||||
|
|
||||||
|
def set_trusted(self, workspace: str | Path, trusted: bool) -> str:
|
||||||
|
canonical = self.canonical(workspace)
|
||||||
|
values = self._load()
|
||||||
|
if trusted:
|
||||||
|
values.add(canonical)
|
||||||
|
else:
|
||||||
|
values.discard(canonical)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self.path.with_name(f".{self.path.name}.{os.getpid()}.tmp")
|
||||||
|
tmp.write_text(
|
||||||
|
json.dumps({"trusted_workspaces": sorted(values)}, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
tmp.replace(self.path)
|
||||||
|
return canonical
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type Persona,
|
type Persona,
|
||||||
type RecentWorkspace,
|
type RecentWorkspace,
|
||||||
type SurfaceVisibility,
|
type SurfaceVisibility,
|
||||||
|
type WorkspaceCommandTrust,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import type { ApprovalDecision, Attachment, Item, SessionInfo, TodoItem, WsEvent } from "./types";
|
import type { ApprovalDecision, Attachment, Item, SessionInfo, TodoItem, WsEvent } from "./types";
|
||||||
import { isProjectScoped } from "./personaScope";
|
import { isProjectScoped } from "./personaScope";
|
||||||
@@ -54,6 +55,7 @@ import { InboxView } from "./components/InboxView";
|
|||||||
import { ApprovalCard } from "./components/ApprovalCard";
|
import { ApprovalCard } from "./components/ApprovalCard";
|
||||||
import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
|
import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
|
||||||
import { PlanCard } from "./components/PlanCard";
|
import { PlanCard } from "./components/PlanCard";
|
||||||
|
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
|
||||||
|
|
||||||
const newId = () =>
|
const newId = () =>
|
||||||
(crypto as any).randomUUID ? crypto.randomUUID().slice(0, 12) : Math.random().toString(36).slice(2, 14);
|
(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 [workspace, setWorkspace] = useState<string | null>(null);
|
||||||
const [branch, setBranch] = useState<string | null>(null);
|
const [branch, setBranch] = useState<string | null>(null);
|
||||||
const [showGate, setShowGate] = useState(false);
|
const [showGate, setShowGate] = useState(false);
|
||||||
|
const [workspaceTrustRequest, setWorkspaceTrustRequest] =
|
||||||
|
useState<WorkspaceCommandTrust | null>(null);
|
||||||
const [agent, setAgent] = useState("cowork");
|
const [agent, setAgent] = useState("cowork");
|
||||||
const [model, setModel] = useState("gpt-5.6-sol");
|
const [model, setModel] = useState("gpt-5.6-sol");
|
||||||
const [models, setModels] = useState<string[]>([]);
|
const [models, setModels] = useState<string[]>([]);
|
||||||
@@ -558,6 +562,7 @@ export function App() {
|
|||||||
setConnected(true);
|
setConnected(true);
|
||||||
if (d.model) setModel(d.model);
|
if (d.model) setModel(d.model);
|
||||||
if (d.mode) setMode(d.mode);
|
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).
|
// Cowork: adopt the server-provisioned scratch dir (only when we don't already have one).
|
||||||
if (d.workspace) setWorkspace((cur) => cur || d.workspace);
|
if (d.workspace) setWorkspace((cur) => cur || d.workspace);
|
||||||
break;
|
break;
|
||||||
@@ -1623,6 +1628,12 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{workspaceTrustRequest && (
|
||||||
|
<WorkspaceTrustPrompt
|
||||||
|
request={workspaceTrustRequest}
|
||||||
|
onClose={() => setWorkspaceTrustRequest(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-2
@@ -24,6 +24,14 @@ export interface RecentWorkspace {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceCommandTrust {
|
||||||
|
workspace: string;
|
||||||
|
requested_commands: string[];
|
||||||
|
trusted: boolean;
|
||||||
|
required: boolean;
|
||||||
|
exists?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getHealth(): Promise<Health> {
|
export async function getHealth(): Promise<Health> {
|
||||||
const res = await fetch(`${httpBase()}/v1/health`);
|
const res = await fetch(`${httpBase()}/v1/health`);
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -49,7 +57,13 @@ export async function pickFolderViaServer(): Promise<string | null> {
|
|||||||
export async function openWorkspace(
|
export async function openWorkspace(
|
||||||
path: string,
|
path: string,
|
||||||
create = false,
|
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`, {
|
const res = await fetch(`${httpBase()}/v1/workspaces/open`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -58,6 +72,23 @@ export async function openWorkspace(
|
|||||||
return res.json();
|
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[]> {
|
export async function getSessions(workspace?: string): Promise<SessionInfo[]> {
|
||||||
const q = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
|
const q = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
|
||||||
const res = await fetch(`${httpBase()}/v1/sessions${q}`);
|
const res = await fetch(`${httpBase()}/v1/sessions${q}`);
|
||||||
@@ -1771,4 +1802,3 @@ export class Session {
|
|||||||
this.ws.close();
|
this.ws.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
getSettings,
|
getSettings,
|
||||||
|
getTrustedWorkspaces,
|
||||||
setOnboarded,
|
setOnboarded,
|
||||||
setPdfSettings,
|
setPdfSettings,
|
||||||
setScratchBase,
|
setScratchBase,
|
||||||
setSessionsPeek,
|
setSessionsPeek,
|
||||||
|
setWorkspaceTrusted,
|
||||||
type ModelSettings,
|
type ModelSettings,
|
||||||
type PdfSettings,
|
type PdfSettings,
|
||||||
|
type WorkspaceCommandTrust,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import {
|
import {
|
||||||
cancelDictationModelDownload,
|
cancelDictationModelDownload,
|
||||||
@@ -424,6 +427,8 @@ function AppearanceSection() {
|
|||||||
|
|
||||||
<FilesCard />
|
<FilesCard />
|
||||||
|
|
||||||
|
<TrustedWorkspacesCard />
|
||||||
|
|
||||||
{desktop && (
|
{desktop && (
|
||||||
<div className={CARD + " p-4"}>
|
<div className={CARD + " p-4"}>
|
||||||
<div className={FIELD_LABEL + " mb-2.5"}>Always-on</div>
|
<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() {
|
function UpdateInline() {
|
||||||
const [state, setState] = useState<"idle" | "checking" | "none" | "found" | "installing" | "error">("idle");
|
const [state, setState] = useState<"idle" | "checking" | "none" | "found" | "installing" | "error">("idle");
|
||||||
const [version, setVersion] = useState("");
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,45 @@ def test_workspace_cannot_grant_its_own_permissions(tmp_path):
|
|||||||
assert cfg.auto_allow == ["write_file"]
|
assert cfg.auto_allow == ["write_file"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_workspace_adds_its_command_allowances_only(tmp_path):
|
||||||
|
g = tmp_path / "global.toml"
|
||||||
|
g.write_text(
|
||||||
|
'allowed_commands = ["git status"]\nauto_allow = ["write_file"]\n'
|
||||||
|
)
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
(ws / ".coworker").mkdir(parents=True)
|
||||||
|
(ws / ".coworker" / "config.toml").write_text(
|
||||||
|
'allowed_commands = ["pytest", "git status"]\n'
|
||||||
|
'auto_allow = ["run_shell"]\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = load_config(ws, global_path=g, workspace_trusted=True)
|
||||||
|
assert cfg.allowed_commands == ["git status", "pytest"]
|
||||||
|
assert cfg.auto_allow == ["write_file"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_trust_is_canonical_and_user_owned(tmp_path):
|
||||||
|
from coworker.workspace_trust import WorkspaceTrustStore
|
||||||
|
|
||||||
|
real = tmp_path / "real"
|
||||||
|
real.mkdir()
|
||||||
|
alias = tmp_path / "alias"
|
||||||
|
alias.symlink_to(real, target_is_directory=True)
|
||||||
|
store = WorkspaceTrustStore(tmp_path / "state" / "workspace_trust.json")
|
||||||
|
|
||||||
|
canonical = store.set_trusted(alias, True)
|
||||||
|
assert canonical == str(real.resolve())
|
||||||
|
assert store.is_trusted(real)
|
||||||
|
assert store.list() == [str(real.resolve())]
|
||||||
|
assert (store.path.stat().st_mode & 0o777) == 0o600
|
||||||
|
|
||||||
|
store.set_trusted(real, False)
|
||||||
|
assert not store.is_trusted(alias)
|
||||||
|
|
||||||
|
store.path.write_text("[]")
|
||||||
|
assert store.list() == []
|
||||||
|
|
||||||
|
|
||||||
def test_build_engine_honors_explicit_empty_command_allowlist(tmp_path):
|
def test_build_engine_honors_explicit_empty_command_allowlist(tmp_path):
|
||||||
from coworker.agent import build_code_engine
|
from coworker.agent import build_code_engine
|
||||||
from coworker.config import global_config_path
|
from coworker.config import global_config_path
|
||||||
|
|||||||
@@ -606,6 +606,66 @@ def test_open_and_recent_workspaces(tmp_path):
|
|||||||
assert any(w["path"] == str(proj.resolve()) for w in recents)
|
assert any(w["path"] == str(proj.resolve()) for w in recents)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_command_trust_controls_live_engine(tmp_path):
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
proj = tmp_path / "trusted-project"
|
||||||
|
(proj / ".coworker").mkdir(parents=True)
|
||||||
|
(proj / ".coworker" / "config.toml").write_text(
|
||||||
|
'allowed_commands = ["pytest"]\nauto_allow = ["write_file"]\n'
|
||||||
|
)
|
||||||
|
manager = SessionManager(
|
||||||
|
workspace=None, data_dir=tmp_path / "data", provider=ScriptedProvider([])
|
||||||
|
)
|
||||||
|
client = TestClient(create_app(manager))
|
||||||
|
|
||||||
|
with client.websocket_connect(
|
||||||
|
f"/ws/session/trust?workspace={quote(str(proj))}"
|
||||||
|
) as ws:
|
||||||
|
ready = ws.receive_json()
|
||||||
|
policy = ready["data"]["command_trust"]
|
||||||
|
assert policy["required"] is True
|
||||||
|
assert policy["requested_commands"] == ["pytest"]
|
||||||
|
|
||||||
|
engine = manager._engines["trust"]
|
||||||
|
before = engine.permissions.evaluate(
|
||||||
|
"run_shell", {"command": "pytest -q"}, None
|
||||||
|
)
|
||||||
|
assert not before.allowed and before.needs_user
|
||||||
|
# Workspace auto_allow remains ignored even after command trust.
|
||||||
|
assert "write_file" not in engine.permissions.auto_allow_tools
|
||||||
|
|
||||||
|
trusted = client.post(
|
||||||
|
"/v1/workspaces/trust",
|
||||||
|
json={"path": str(proj), "trusted": True},
|
||||||
|
).json()
|
||||||
|
assert trusted["ok"] and trusted["trusted"]
|
||||||
|
assert engine.permissions.evaluate(
|
||||||
|
"run_shell", {"command": "pytest -q"}, None
|
||||||
|
).allowed
|
||||||
|
|
||||||
|
listed = client.get("/v1/workspaces/trusted").json()["workspaces"]
|
||||||
|
assert [item["workspace"] for item in listed] == [str(proj.resolve())]
|
||||||
|
|
||||||
|
revoked = client.post(
|
||||||
|
"/v1/workspaces/trust",
|
||||||
|
json={"path": str(proj), "trusted": False},
|
||||||
|
).json()
|
||||||
|
assert revoked["ok"] and not revoked["trusted"]
|
||||||
|
after = engine.permissions.evaluate(
|
||||||
|
"run_shell", {"command": "pytest -q"}, None
|
||||||
|
)
|
||||||
|
assert not after.allowed and after.needs_user
|
||||||
|
|
||||||
|
manager.workspace_trust.set_trusted(proj, True)
|
||||||
|
proj.rename(tmp_path / "moved-project")
|
||||||
|
assert client.post(
|
||||||
|
"/v1/workspaces/trust",
|
||||||
|
json={"path": str(proj), "trusted": False},
|
||||||
|
).json()["ok"]
|
||||||
|
assert manager.trusted_workspaces() == []
|
||||||
|
|
||||||
|
|
||||||
def test_recent_workspaces_exclude_scratch_dirs(tmp_path):
|
def test_recent_workspaces_exclude_scratch_dirs(tmp_path):
|
||||||
# Scratch dirs get touched like any workspace, but must never show up as
|
# Scratch dirs get touched like any workspace, but must never show up as
|
||||||
# "recent projects" in the folder gate (owner call, 2026-07-03).
|
# "recent projects" in the folder gate (owner call, 2026-07-03).
|
||||||
|
|||||||
Reference in New Issue
Block a user