diff --git a/coworker/conversations.py b/coworker/conversations.py index 300ca9cc9..9e677d79a 100644 --- a/coworker/conversations.py +++ b/coworker/conversations.py @@ -485,6 +485,7 @@ def list(self, *, workspace: Optional[str] = None) -> list[SessionRecord]: archived=bool(r["archived"]), origin=r["origin"], origin_label=r["origin_label"], + grants=_load_grants(r["grants"] if "grants" in r.keys() else None), team=_load_grants(r["team"] if "team" in r.keys() else None), ) for r in rows diff --git a/coworker/overrides.py b/coworker/overrides.py index c7cca1dae..96e9f8c96 100644 --- a/coworker/overrides.py +++ b/coworker/overrides.py @@ -150,11 +150,13 @@ def set_trust(self, pattern: str) -> None: self._trust.append(pattern) self.save() - def revoke_trust(self, pattern: str) -> None: + def revoke_trust(self, pattern: str) -> bool: before = len(self._trust) self._trust = [p for p in self._trust if p != pattern] if len(self._trust) != before: self.save() + return True + return False def trust_patterns(self) -> list[str]: return list(self._trust) diff --git a/coworker/permissions.py b/coworker/permissions.py index 54ea04c9d..bd67e3a09 100644 --- a/coworker/permissions.py +++ b/coworker/permissions.py @@ -559,6 +559,33 @@ def allow_domain_for_session(self, url_or_domain: str) -> None: if host: self.session_allow_domains.add(host) + def revoke_tool_for_session(self, tool_name: str) -> bool: + if tool_name in self.session_allow_tools: + self.session_allow_tools.remove(tool_name) + return True + return False + + def revoke_command_for_session(self, command: str) -> bool: + if command in self.session_allow_commands: + self.session_allow_commands.remove(command) + return True + return False + + def revoke_readonly_for_session(self) -> bool: + if self.session_readonly: + self.session_readonly = False + return True + return False + + def revoke_domain_for_session(self, url_or_domain: str) -> bool: + host = _host_of(url_or_domain) + if host.startswith("www."): + host = host[4:] + if host in self.session_allow_domains: + self.session_allow_domains.remove(host) + return True + return False + # -- helpers ---------------------------------------------------------------- def _candidate(self, path: str) -> Path: # Relative paths resolve against the primary (workspace_root); absolute/`~` taken as-is. diff --git a/coworker/server/app.py b/coworker/server/app.py index c2e60e159..31e48a104 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -694,6 +694,19 @@ def set_workspace_trust(body: dict) -> dict[str, Any]: trusted=bool((body or {}).get("trusted", False)), ) + @app.get("/v1/grants") + def active_grants() -> dict[str, Any]: + return {"grants": manager.list_active_grants()} + + @app.post("/v1/grants/revoke") + def revoke_grant(body: dict) -> dict[str, Any]: + return manager.revoke_grant( + grant_id=(body or {}).get("grant_id"), + kind=(body or {}).get("kind"), + target=(body or {}).get("target"), + source_id=(body or {}).get("source_id"), + ) + @app.post("/v1/workspaces/temp") def provision_temp_workspace(body: dict) -> dict[str, Any]: # UX-029: a code-family session starting "in a temporary folder" — created only diff --git a/coworker/server/manager.py b/coworker/server/manager.py index cf9b740fa..5442deedf 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -114,10 +114,13 @@ def _grants_of(engine) -> dict[str, Any]: """The engine's session-scoped "Always allow" approvals, in persistable shape.""" tools = sorted(getattr(engine.permissions, "session_allow_tools", None) or ()) commands = sorted(getattr(engine.permissions, "session_allow_commands", None) or ()) + domains = sorted(getattr(engine.permissions, "session_allow_domains", None) or ()) readonly = bool(getattr(engine.permissions, "session_readonly", False)) out: dict[str, Any] = {} - if tools or commands or readonly: + if tools or commands or domains or readonly: out = {"tools": tools, "commands": commands} + if domains: + out["domains"] = domains if readonly: out["readonly"] = True return out @@ -440,11 +443,39 @@ def set_workspace_trust( engine_workspace ) == canonical: engine.permissions.allowed_commands = list(effective) + if trusted: + try: + self.audit_store.append( + { + "workspace": canonical, + "stage": "workspace_trust_granted", + "status": "granted", + "reason": f"workspace trust granted: {canonical}", + } + ) + except Exception: + pass + else: + try: + self.audit_store.append( + { + "workspace": canonical, + "stage": "grant_revoked", + "status": "revoked", + "reason": f"workspace trust revoked: {canonical}", + } + ) + except Exception: + pass return { "ok": True, **self.workspace_command_trust(canonical), } + def revoke_workspace_trust(self, path: str | Path) -> dict[str, Any]: + """Revoke trust for a workspace root, mirroring overrides.py:revoke_trust (#620).""" + return self.set_workspace_trust(path, trusted=False) + def trusted_workspaces(self) -> list[dict[str, Any]]: return [ { @@ -1635,8 +1666,20 @@ def mcp_trust(self, name: str) -> dict[str, Any]: } def revoke_mcp_trust(self, name: str, tool: str) -> dict[str, Any]: - self._override_store().revoke_trust(f"mcp__{name}__{tool}") - return {"ok": True} + pattern = f"mcp__{name}__{tool}" + revoked = self._override_store().revoke_trust(pattern) + try: + self.audit_store.append( + { + "tool": pattern, + "stage": "grant_revoked", + "status": "revoked", + "reason": f"MCP tool trust revoked: {pattern}", + } + ) + except Exception: + pass + return {"ok": True, "revoked": revoked} async def convert_mcp_trust(self, name: str) -> dict[str, Any]: """Migrate the legacy server-wide flag to named per-tool trust rules: one rule @@ -5096,6 +5139,20 @@ def update_automation( # Revocation from the task detail page ("Allowed without asking … · Revoke"). # Human-only, like minting; the agent-facing update tool has no such field. task.revoke_rule(str(changes["revoke"])) + try: + from ..automation.models import rule_parts + + r_tool, _ = rule_parts(str(changes["revoke"])) + self.audit_store.append( + { + "tool": r_tool, + "stage": "grant_revoked", + "status": "revoked", + "reason": f"standing automation rule revoked: {changes['revoke']} (task {task.id})", + } + ) + except Exception: + pass self.task_store.save(task) if changes.get("revoke"): # A live run engine may still hold the revoked rule — reseed from the record. @@ -5188,6 +5245,8 @@ def _apply_grants(engine: TurnEngine, grants: dict[str, Any]) -> None: engine.permissions.allow_tool_for_session(str(tool)) for command in grants.get("commands") or []: engine.permissions.allow_command_for_session(str(command)) + for domain in grants.get("domains") or []: + engine.permissions.allow_domain_for_session(str(domain)) if grants.get("readonly"): engine.permissions.allow_readonly_for_session() @@ -6153,6 +6212,409 @@ def set_memory_settings( ) -> dict[str, Any]: return self.memory_settings.set(enabled=enabled, user_rules=user_rules) + def list_active_grants(self) -> list[dict[str, Any]]: + """List all live standing grants across the ladder of earned autonomy (#620): + 1. Workspace trust (WorkspaceTrustStore) + 2. MCP per-tool trust (RiskOverrideStore) + 3. Standing automation rules (TaskStore always_allowed_tools) + 4. Session grants (active engines and persisted SessionRecord.grants) + 5. Egress / domain allows (config.allowed_domains) + """ + grants: list[dict[str, Any]] = [] + + # 1. Workspace trust + for path in self.workspace_trust.list(): + info = self.workspace_command_trust(path) + grants.append( + { + "id": f"workspace:{path}", + "kind": "workspace_trust", + "name": path, + "source": "workspace", + "source_id": path, + "source_label": Path(path).name or path, + "workspace": path, + "details": { + "allowed_commands": info.get("allowed_commands", []), + "exists": Path(path).is_dir(), + }, + } + ) + + # 2. MCP per-tool trust + override_store = self._override_store() + for pattern in override_store.trust_patterns(): + server_name = "" + tool_name = pattern + if pattern.startswith("mcp__"): + parts = pattern[len("mcp__"):].split("__", 1) + if len(parts) == 2: + server_name, tool_name = parts[0], parts[1] + else: + server_name = parts[0] + grants.append( + { + "id": f"mcp:{pattern}", + "kind": "mcp_tool", + "name": tool_name, + "source": "mcp", + "source_id": server_name or None, + "source_label": f"MCP: {server_name}" if server_name else "MCP Tool Trust", + "workspace": None, + "details": { + "pattern": pattern, + "server": server_name, + "tool": tool_name, + }, + } + ) + + # 3. Standing automation rules + for task in self.task_store.list(): + for entry in getattr(task, "always_allowed_tools", []) or []: + from ..automation.models import rule_parts + + tool, target = rule_parts(entry) + grants.append( + { + "id": f"task:{task.id}:{entry}", + "kind": "standing_automation", + "name": entry, + "source": "task", + "source_id": task.id, + "source_label": f"Task: {task.title}", + "workspace": task.workspace, + "details": { + "task_id": task.id, + "task_title": task.title, + "tool": tool, + "target": target, + }, + } + ) + + # 4. Session grants + seen_sessions: set[str] = set() + # Active in-memory engines first + for sid, engine in self._engines.items(): + seen_sessions.add(sid) + rec = self.session_store.load(sid) + title = rec.title if rec and rec.title else sid + executor = getattr(engine, "executor", None) + workspace = os.path.realpath(str(executor.cwd)) if executor else "" + session_label = f"Session: {title}" + + perms = engine.permissions + for tool in sorted(getattr(perms, "session_allow_tools", None) or ()): + grants.append( + { + "id": f"session:{sid}:tool:{tool}", + "kind": "session_tool", + "name": tool, + "source": "session", + "source_id": sid, + "source_label": session_label, + "workspace": workspace, + "details": {"session_id": sid, "tool": tool}, + } + ) + for cmd in sorted(getattr(perms, "session_allow_commands", None) or ()): + grants.append( + { + "id": f"session:{sid}:command:{cmd}", + "kind": "session_command", + "name": cmd, + "source": "session", + "source_id": sid, + "source_label": session_label, + "workspace": workspace, + "details": {"session_id": sid, "command": cmd}, + } + ) + for dom in sorted(getattr(perms, "session_allow_domains", None) or ()): + grants.append( + { + "id": f"session:{sid}:domain:{dom}", + "kind": "session_domain", + "name": dom, + "source": "session", + "source_id": sid, + "source_label": session_label, + "workspace": workspace, + "details": {"session_id": sid, "domain": dom}, + } + ) + if getattr(perms, "session_readonly", False): + grants.append( + { + "id": f"session:{sid}:readonly", + "kind": "session_readonly", + "name": "Read-only mode", + "source": "session", + "source_id": sid, + "source_label": session_label, + "workspace": workspace, + "details": {"session_id": sid}, + } + ) + + # Persisted sessions from the store (not active in-memory) + for rec in self.session_store.list(): + if rec.session_id in seen_sessions: + continue + stored_grants = rec.grants or {} + if not stored_grants: + continue + session_label = f"Session: {rec.title or rec.session_id}" + for tool in stored_grants.get("tools") or []: + grants.append( + { + "id": f"session:{rec.session_id}:tool:{tool}", + "kind": "session_tool", + "name": tool, + "source": "session", + "source_id": rec.session_id, + "source_label": session_label, + "workspace": rec.workspace, + "details": {"session_id": rec.session_id, "tool": tool}, + } + ) + for cmd in stored_grants.get("commands") or []: + grants.append( + { + "id": f"session:{rec.session_id}:command:{cmd}", + "kind": "session_command", + "name": cmd, + "source": "session", + "source_id": rec.session_id, + "source_label": session_label, + "workspace": rec.workspace, + "details": {"session_id": rec.session_id, "command": cmd}, + } + ) + for dom in stored_grants.get("domains") or []: + grants.append( + { + "id": f"session:{rec.session_id}:domain:{dom}", + "kind": "session_domain", + "name": dom, + "source": "session", + "source_id": rec.session_id, + "source_label": session_label, + "workspace": rec.workspace, + "details": {"session_id": rec.session_id, "domain": dom}, + } + ) + if stored_grants.get("readonly"): + grants.append( + { + "id": f"session:{rec.session_id}:readonly", + "kind": "session_readonly", + "name": "Read-only mode", + "source": "session", + "source_id": rec.session_id, + "source_label": session_label, + "workspace": rec.workspace, + "details": {"session_id": rec.session_id}, + } + ) + + # 5. Egress / allowed domains from user config + for domain in load_config().allowed_domains: + grants.append( + { + "id": f"domain:global:{domain}", + "kind": "allowed_domain", + "name": domain, + "source": "config", + "source_id": None, + "source_label": "Global Config", + "workspace": None, + "details": {"domain": domain}, + } + ) + + return grants + + def revoke_grant( + self, + grant_id: Optional[str] = None, + *, + kind: Optional[str] = None, + target: Optional[str] = None, + source_id: Optional[str] = None, + ) -> dict[str, Any]: + """Revoke a standing grant and record the revocation in the audit trail (#620).""" + if grant_id and (not kind or not target): + parts = grant_id.split(":", 2) + prefix = parts[0] + if prefix == "workspace": + kind = "workspace_trust" + target = grant_id[len("workspace:"):] + elif prefix == "mcp": + kind = "mcp_tool" + target = grant_id[len("mcp:"):] + elif prefix == "task" and len(parts) >= 3: + kind = "standing_automation" + source_id = parts[1] + target = parts[2] + elif prefix == "session": + session_parts = grant_id.split(":", 3) + if len(session_parts) >= 3: + source_id = session_parts[1] + subkind = session_parts[2] + kind = f"session_{subkind}" + target = session_parts[3] if len(session_parts) > 3 else subkind + elif prefix == "domain" and len(parts) >= 3: + kind = "allowed_domain" + target = parts[2] + + if not kind or not target: + return {"ok": False, "error": "kind and target are required to revoke grant"} + + revoked = False + if kind == "workspace_trust": + canonical = WorkspaceTrustStore.canonical(target) + res = self.revoke_workspace_trust(canonical) + return {"ok": res.get("ok", True), "revoked": True, "kind": kind, "target": target} + + elif kind == "mcp_tool": + pattern = target + revoked = self._override_store().revoke_trust(pattern) + try: + self.audit_store.append( + { + "tool": pattern, + "stage": "grant_revoked", + "status": "revoked", + "reason": f"MCP tool trust revoked: {pattern}", + } + ) + except Exception: + pass + return {"ok": True, "revoked": revoked, "kind": kind, "target": pattern} + + elif kind == "standing_automation": + task_id = source_id or "" + task = self.task_store.get(task_id) + if not task: + return {"ok": False, "error": "task not found"} + revoked = task.revoke_rule(target) + if revoked: + self.task_store.save(task) + for sid, engine in self._engines.items(): + owner = self.task_store.task_for_run_session(sid) + if owner is not None and owner.id == task.id: + engine.permissions.task_rules = task.standing_rules() + from ..automation.models import rule_parts + + tool, _ = rule_parts(target) + try: + self.audit_store.append( + { + "tool": tool, + "stage": "grant_revoked", + "status": "revoked", + "reason": f"standing automation rule revoked: {target} (task {task.id})", + } + ) + except Exception: + pass + return {"ok": True, "revoked": revoked, "kind": kind, "target": target} + + elif kind in ("session_tool", "session_command", "session_domain", "session_readonly"): + sid = source_id or "" + engine = self._engines.get(sid) + if engine is not None: + if kind == "session_tool": + revoked = engine.permissions.revoke_tool_for_session(target) + elif kind == "session_command": + revoked = engine.permissions.revoke_command_for_session(target) + elif kind == "session_domain": + revoked = engine.permissions.revoke_domain_for_session(target) + elif kind == "session_readonly": + revoked = engine.permissions.revoke_readonly_for_session() + + rec = self.session_store.load(sid) + if rec and rec.grants: + grants = dict(rec.grants) + if kind == "session_tool" and "tools" in grants: + if target in grants["tools"]: + grants["tools"] = [t for t in grants["tools"] if t != target] + revoked = True + elif kind == "session_command" and "commands" in grants: + if target in grants["commands"]: + grants["commands"] = [c for c in grants["commands"] if c != target] + revoked = True + elif kind == "session_domain" and "domains" in grants: + if target in grants["domains"]: + grants["domains"] = [d for d in grants["domains"] if d != target] + revoked = True + elif kind == "session_readonly" and "readonly" in grants: + del grants["readonly"] + revoked = True + rec.grants = grants + self.session_store.save(rec, touch=False) + + try: + self.audit_store.append( + { + "session_id": sid, + "workspace": rec.workspace if rec else "", + "tool": target if kind != "session_readonly" else "readonly", + "stage": "grant_revoked", + "status": "revoked", + "reason": f"session grant revoked: {kind}:{target}", + } + ) + except Exception: + pass + return {"ok": True, "revoked": revoked, "kind": kind, "target": target} + + elif kind == "allowed_domain": + domain = target + for engine in self._engines.values(): + if hasattr(engine.permissions, "allowed_domains"): + engine.permissions.allowed_domains = [ + d for d in engine.permissions.allowed_domains if d != domain + ] + cfg_path = global_config_path() + if cfg_path.is_file(): + try: + content = cfg_path.read_text(encoding="utf-8") + pattern = re.compile(r"allowed_domains\s*=\s*\[[^\]]*\]") + match = pattern.search(content) + if match: + import tomllib + + parsed = tomllib.loads(content) + domains = parsed.get("allowed_domains", []) + if isinstance(domains, list) and domain in domains: + new_domains = [d for d in domains if d != domain] + new_content = pattern.sub( + f"allowed_domains = {json.dumps(new_domains)}", + content, + count=1, + ) + cfg_path.write_text(new_content, encoding="utf-8") + revoked = True + except Exception: + pass + try: + self.audit_store.append( + { + "tool": domain, + "stage": "grant_revoked", + "status": "revoked", + "reason": f"allowed domain revoked: {domain}", + } + ) + except Exception: + pass + return {"ok": True, "revoked": True, "kind": kind, "target": target} + + return {"ok": False, "error": f"unknown grant kind: {kind}"} + def _parse_inbox_json(s: str) -> dict[str, Any]: """Parse a structured Inbox resolution (directory/plan carry their reply as a JSON string).""" diff --git a/coworker/workspace_trust.py b/coworker/workspace_trust.py index c749d6546..1fa0cea38 100644 --- a/coworker/workspace_trust.py +++ b/coworker/workspace_trust.py @@ -44,13 +44,17 @@ def is_trusted(self, workspace: str | Path) -> bool: def list(self) -> list[str]: return sorted(self._load()) - def set_trusted(self, workspace: str | Path, trusted: bool) -> str: + def revoke_trust(self, workspace: str | Path) -> bool: + """Revoke trust for a workspace root. Returns True if the path was trusted and removed.""" canonical = self.canonical(workspace) values = self._load() - if trusted: - values.add(canonical) - else: - values.discard(canonical) + if canonical not in values: + return False + values.discard(canonical) + self._write(values) + return True + + def _write(self, values: set[str]) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) tmp = self.path.with_name(f".{self.path.name}.{os.getpid()}.tmp") tmp.write_text( @@ -59,4 +63,13 @@ def set_trusted(self, workspace: str | Path, trusted: bool) -> str: ) os.chmod(tmp, 0o600) tmp.replace(self.path) + + 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._write(values) return canonical diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index a69e0ceca..9ed2e7eaf 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -145,6 +145,46 @@ export async function setWorkspaceTrusted( return res.json(); } +export interface ActiveGrant { + id: string; + kind: + | "workspace_trust" + | "mcp_tool" + | "standing_automation" + | "session_tool" + | "session_command" + | "session_domain" + | "session_readonly" + | "allowed_domain" + | string; + name: string; + source: string; + source_id?: string | null; + source_label: string; + workspace?: string | null; + created_at?: string | null; + details?: Record; +} + +export async function getActiveGrants(): Promise { + const res = await fetch(`${httpBase()}/v1/grants`); + return (await res.json()).grants ?? []; +} + +export async function revokeGrant(params: { + grant_id?: string; + kind?: string; + target?: string; + source_id?: string | null; +}): Promise<{ ok: boolean; error?: string; revoked?: boolean }> { + const res = await fetch(`${httpBase()}/v1/grants/revoke`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + return res.json(); +} + export async function getSessions(workspace?: string): Promise { const q = workspace ? `?workspace=${encodeURIComponent(workspace)}` : ""; const res = await fetch(`${httpBase()}/v1/sessions${q}`); diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index 4c65970a5..217c70470 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -13,6 +13,9 @@ import { setScratchBase, setSessionsPeek, setWorkspaceTrusted, + getActiveGrants, + revokeGrant, + type ActiveGrant, type CompactionSettings, type ModelSettings, type PdfSettings, @@ -55,7 +58,7 @@ import { showPersonas } from "../flags"; // Models + Personas host the existing tab components inside the page shell (field re-skin to follow). // "appearance" is the General tab's stable key — callers deep-link with it, so the // rename (UX-021) changed only the label. "files" folded into General as a card. -type SetTab = "appearance" | "models" | "context" | "skills" | "voice" | "memory" | "personas"; +type SetTab = "appearance" | "models" | "context" | "skills" | "voice" | "memory" | "grants" | "personas"; const CARD = "rounded-xl2 border border-line bg-panel"; const FIELD_LABEL = "text-[13px] font-medium text-ink"; @@ -69,7 +72,7 @@ const BTN_BORDERED = const SET_TABS: { key: SetTab; labelKey: string; - icon: "sliders" | "code" | "mic" | "archive" | "sparkle" | "book" | "refresh"; + icon: "sliders" | "code" | "mic" | "archive" | "sparkle" | "book" | "refresh" | "shield"; }[] = [ { key: "appearance", labelKey: "settings.tab.general", icon: "sliders" }, { key: "models", labelKey: "settings.tab.models", icon: "code" }, @@ -77,6 +80,7 @@ const SET_TABS: { { key: "skills", labelKey: "settings.tab.skills", icon: "book" }, { key: "voice", labelKey: "settings.tab.voice", icon: "mic" }, { key: "memory", labelKey: "settings.tab.memory", icon: "archive" }, + { key: "grants", labelKey: "settings.tab.grants", icon: "shield" }, { key: "personas", labelKey: "settings.tab.personas", icon: "sparkle" }, ]; @@ -150,6 +154,8 @@ export function SettingsView({ ) : tab === "memory" ? ( + ) : tab === "grants" ? ( + ) : ( )} @@ -159,6 +165,143 @@ export function SettingsView({ ); } +function GrantsSection() { + const { t } = useTranslation(); + const [grants, setGrants] = useState(null); + const [filter, setFilter] = useState("all"); + const [revoking, setRevoking] = useState(null); + const [error, setError] = useState(null); + + const load = async () => { + try { + const list = await getActiveGrants(); + setGrants(list); + } catch { + setError("Failed to load active grants"); + } + }; + + useEffect(() => { + void load(); + }, []); + + const handleRevoke = async (grant: ActiveGrant) => { + setRevoking(grant.id); + try { + await revokeGrant({ + grant_id: grant.id, + kind: grant.kind, + target: grant.name, + source_id: grant.source_id, + }); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to revoke grant"); + } finally { + setRevoking(null); + } + }; + + const filtered = (grants || []).filter((g) => { + if (filter === "all") return true; + if (filter === "workspace") return g.kind === "workspace_trust"; + if (filter === "mcp") return g.kind === "mcp_tool"; + if (filter === "task") return g.kind === "standing_automation"; + if (filter === "session") return g.kind.startsWith("session_"); + if (filter === "domain") return g.kind === "allowed_domain" || g.kind === "session_domain"; + return true; + }); + + const kindBadge = (kind: string) => { + if (kind === "workspace_trust") return "Workspace"; + if (kind === "mcp_tool") return "MCP Tool"; + if (kind === "standing_automation") return "Automation"; + if (kind === "session_tool") return "Session Tool"; + if (kind === "session_command") return "Session Command"; + if (kind === "session_domain") return "Session Domain"; + if (kind === "session_readonly") return "Read-Only"; + if (kind === "allowed_domain") return "Domain Allow"; + return kind; + }; + + return ( +
+ + +
+ {[ + { key: "all", label: "All" }, + { key: "workspace", label: "Workspaces" }, + { key: "mcp", label: "MCP Tools" }, + { key: "task", label: "Automations" }, + { key: "session", label: "Sessions" }, + { key: "domain", label: "Domains" }, + ].map((f) => ( + + ))} +
+ +
+ {error && ( +
{error}
+ )} + {grants === null ? ( +
{t("settings.grants_loading", "Loading active grants…")}
+ ) : filtered.length === 0 ? ( +
{t("settings.grants_empty", "No active standing grants.")}
+ ) : ( +
+ {filtered.map((grant) => ( +
+
+
+ + {kindBadge(grant.kind)} + + + {grant.name} + +
+
+ {grant.source_label} + {grant.workspace && ( + · {grant.workspace} + )} +
+
+ +
+ ))} +
+ )} +
+
+ ); +} + // -- Voice input: deliberate model provisioning + compatibility + microphone test (§37) -------- const voiceError = (error: unknown) => error instanceof Error diff --git a/surfaces/gui/src/locales/en.json b/surfaces/gui/src/locales/en.json index 869ea7397..75529dfb9 100644 --- a/surfaces/gui/src/locales/en.json +++ b/surfaces/gui/src/locales/en.json @@ -287,8 +287,14 @@ "personas": "Coworkers", "context": "Context optimization", "skills": "Skills", - "memory": "Memory" + "memory": "Memory", + "grants": "Active grants" }, + "grants_title": "Active grants", + "grants_sub": "Review and revoke standing approvals, workspace trust, MCP tool permissions, and automation rules.", + "grants_revoke": "Revoke", + "grants_empty": "No active standing grants found.", + "grants_loading": "Loading active grants…", "general_title": "General", "general_sub": "How OpenWorker looks and behaves on this machine.", "theme": "Theme", diff --git a/surfaces/gui/src/locales/zh.json b/surfaces/gui/src/locales/zh.json index 6ac0f33c3..c09f1c665 100644 --- a/surfaces/gui/src/locales/zh.json +++ b/surfaces/gui/src/locales/zh.json @@ -280,8 +280,14 @@ "personas": "同事", "context": "上下文优化", "skills": "技能", - "memory": "记忆" + "memory": "记忆", + "grants": "活动授权" }, + "grants_title": "活动授权", + "grants_sub": "查看并撤销长期授权、受信任工作区、MCP 工具权限和自动化规则。", + "grants_revoke": "撤销", + "grants_empty": "未找到活动授权。", + "grants_loading": "正在加载活动授权…", "general_title": "通用", "general_sub": "OpenWorker 在本机的外观与行为。", "theme": "主题", diff --git a/tests/test_grants.py b/tests/test_grants.py new file mode 100644 index 000000000..5ba5d8088 --- /dev/null +++ b/tests/test_grants.py @@ -0,0 +1,275 @@ +"""Tests for standing-grant revocation dashboard (#620). + +Covers: +- Explicit revoke_trust on WorkspaceTrustStore and RiskOverrideStore +- PermissionEngine session revocation methods +- SessionManager.list_active_grants across all 5 autonomy layers +- SessionManager.revoke_grant and audit trail recording (stage="grant_revoked", status="revoked") +- Workspace trust changes auditing +- REST endpoints /v1/grants and /v1/grants/revoke +""" + +from __future__ import annotations + +from pathlib import Path +from fastapi.testclient import TestClient + +from coworker.automation import Schedule, ScheduledTask +from coworker.overrides import RiskOverrideStore +from coworker.permissions import PermissionEngine +from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient +from coworker.server import SessionManager, create_app +from coworker.sessions import SessionRecord +from coworker.workspace_trust import WorkspaceTrustStore + + +class _DummyProvider(ProviderClient): + def complete(self, *, model, messages, tools=None, **settings): + return AssistantTurn(text="ok", finish_reason="stop") + + def capabilities(self, model): + return ModelCapabilities() + + +def test_workspace_trust_store_revoke(tmp_path: Path): + store = WorkspaceTrustStore(tmp_path / "trust.json") + proj = tmp_path / "project" + proj.mkdir() + + store.set_trusted(proj, True) + assert store.is_trusted(proj) is True + + # Revoke trust explicitly + assert store.revoke_trust(proj) is True + assert store.is_trusted(proj) is False + + # Second revocation returns False (already revoked) + assert store.revoke_trust(proj) is False + + +def test_risk_override_store_revoke(tmp_path: Path): + store = RiskOverrideStore(tmp_path / "overrides.json") + pattern = "mcp__github__create_issue" + + store.set_trust(pattern) + assert pattern in store.trust_patterns() + + assert store.revoke_trust(pattern) is True + assert pattern not in store.trust_patterns() + + assert store.revoke_trust(pattern) is False + + +def test_permission_engine_session_revocations(tmp_path: Path): + eng = PermissionEngine(workspace_root=tmp_path) + + # Tool grant & revoke + eng.allow_tool_for_session("run_shell") + assert "run_shell" in eng.session_allow_tools + assert eng.revoke_tool_for_session("run_shell") is True + assert "run_shell" not in eng.session_allow_tools + assert eng.revoke_tool_for_session("run_shell") is False + + # Command grant & revoke + eng.allow_command_for_session("pytest") + assert "pytest" in eng.session_allow_commands + assert eng.revoke_command_for_session("pytest") is True + assert "pytest" not in eng.session_allow_commands + assert eng.revoke_command_for_session("pytest") is False + + # Domain grant & revoke + eng.allow_domain_for_session("https://api.github.com/v1") + assert "api.github.com" in eng.session_allow_domains + assert eng.revoke_domain_for_session("api.github.com") is True + assert "api.github.com" not in eng.session_allow_domains + assert eng.revoke_domain_for_session("api.github.com") is False + + # Readonly grant & revoke + eng.allow_readonly_for_session() + assert eng.session_readonly is True + assert eng.revoke_readonly_for_session() is True + assert eng.session_readonly is False + assert eng.revoke_readonly_for_session() is False + + +def test_manager_workspace_trust_audit(tmp_path: Path): + manager = SessionManager(workspace=tmp_path, provider=_DummyProvider()) + proj = tmp_path / "trusted_proj" + proj.mkdir() + + # Grant trust + res = manager.set_workspace_trust(proj, trusted=True) + assert res["ok"] is True + + events = manager.audit_store.list() + grant_events = [e for e in events if e.get("stage") == "workspace_trust_granted"] + assert len(grant_events) >= 1 + assert grant_events[-1]["status"] == "granted" + + # Revoke trust + res = manager.revoke_workspace_trust(proj) + assert res["ok"] is True + + events = manager.audit_store.list() + revoke_events = [ + e for e in events if e.get("stage") == "grant_revoked" and e.get("status") == "revoked" + ] + assert len(revoke_events) >= 1 + assert str(proj.resolve()) in revoke_events[-1]["reason"] + + +def test_manager_mcp_trust_audit(tmp_path: Path): + manager = SessionManager(workspace=tmp_path, provider=_DummyProvider()) + override_store = manager._override_store() + pattern = "mcp__slack__post_message" + override_store.set_trust(pattern) + + res = manager.revoke_mcp_trust("slack", "post_message") + assert res["ok"] is True + + events = manager.audit_store.list() + revoke_events = [ + e for e in events if e.get("stage") == "grant_revoked" and e.get("tool") == pattern + ] + assert len(revoke_events) == 1 + assert revoke_events[0]["status"] == "revoked" + + +def test_manager_standing_automation_audit(tmp_path: Path): + manager = SessionManager(workspace=tmp_path, provider=_DummyProvider()) + task = ScheduledTask( + title="Slack Bot", + instructions="do something", + schedule=Schedule(kind="cron", cron="0 9 * * 1"), + workspace=str(tmp_path), + always_allowed_tools=["send_message slack:C123"], + ) + manager.task_store.save(task) + + # Revoke via update_automation + res = manager.update_automation(task.id, {"revoke": "send_message slack:C123"}) + assert res["ok"] is True + + events = manager.audit_store.list() + revoke_events = [ + e for e in events if e.get("stage") == "grant_revoked" and e.get("tool") == "send_message" + ] + assert len(revoke_events) == 1 + assert revoke_events[0]["status"] == "revoked" + + +def test_list_and_revoke_active_grants(tmp_path: Path): + manager = SessionManager(workspace=tmp_path, provider=_DummyProvider()) + + # 1. Setup workspace trust + proj = tmp_path / "project_alpha" + proj.mkdir() + manager.set_workspace_trust(proj, trusted=True) + + # 2. Setup MCP trust + manager._override_store().set_trust("mcp__github__create_issue") + + # 3. Setup standing automation rule + task = ScheduledTask( + title="Sync Task", + instructions="sync files", + schedule=Schedule(kind="cron", cron="0 0 * * *"), + workspace=str(tmp_path), + always_allowed_tools=["web_search", "send_message slack:general"], + ) + manager.task_store.save(task) + + # 4. Setup session grants (persisted and live) + # Stored session + rec = SessionRecord( + session_id="stored_sess_1", + workspace=str(tmp_path), + model="test-model", + mode="interactive", + title="Old Session", + grants={ + "tools": ["run_shell"], + "commands": ["npm test"], + "domains": ["api.example.com"], + "readonly": True, + }, + ) + manager.session_store.save(rec) + + # Live engine session + live_eng = manager.get_engine("live_sess_2") + live_eng.permissions.allow_tool_for_session("write_file") + live_eng.permissions.allow_command_for_session("git status") + live_eng.permissions.allow_domain_for_session("live.example.com") + + # List active grants + grants = manager.list_active_grants() + grant_ids = {g["id"] for g in grants} + + assert f"workspace:{proj.resolve()}" in grant_ids + assert "mcp:mcp__github__create_issue" in grant_ids + assert f"task:{task.id}:web_search" in grant_ids + assert f"task:{task.id}:send_message slack:general" in grant_ids + assert "session:stored_sess_1:tool:run_shell" in grant_ids + assert "session:stored_sess_1:command:npm test" in grant_ids + assert "session:stored_sess_1:domain:api.example.com" in grant_ids + assert "session:stored_sess_1:readonly" in grant_ids + assert "session:live_sess_2:tool:write_file" in grant_ids + assert "session:live_sess_2:command:git status" in grant_ids + assert "session:live_sess_2:domain:live.example.com" in grant_ids + + # Revoke standing automation grant via revoke_grant + res = manager.revoke_grant(f"task:{task.id}:web_search") + assert res["ok"] is True + updated_task = manager.task_store.get(task.id) + assert "web_search" not in updated_task.always_allowed_tools + + # Revoke live session tool grant via revoke_grant + res = manager.revoke_grant("session:live_sess_2:tool:write_file") + assert res["ok"] is True + assert "write_file" not in live_eng.permissions.session_allow_tools + + # Revoke stored session command grant via revoke_grant + res = manager.revoke_grant("session:stored_sess_1:command:npm test") + assert res["ok"] is True + reloaded_rec = manager.session_store.load("stored_sess_1") + assert "npm test" not in reloaded_rec.grants.get("commands", []) + + # Revoke MCP tool grant + res = manager.revoke_grant("mcp:mcp__github__create_issue") + assert res["ok"] is True + assert "mcp__github__create_issue" not in manager._override_store().trust_patterns() + + # Revoke workspace trust + res = manager.revoke_grant(f"workspace:{proj.resolve()}") + assert res["ok"] is True + assert manager.workspace_trust.is_trusted(proj) is False + + +def test_grants_rest_api(tmp_path: Path): + manager = SessionManager(workspace=tmp_path, provider=_DummyProvider()) + client = TestClient(create_app(manager)) + + proj = tmp_path / "rest_proj" + proj.mkdir() + manager.set_workspace_trust(proj, trusted=True) + + # GET /v1/grants + resp = client.get("/v1/grants") + assert resp.status_code == 200 + data = resp.json() + assert "grants" in data + grant_names = [g["name"] for g in data["grants"]] + assert str(proj.resolve()) in grant_names + + # POST /v1/grants/revoke + resp = client.post( + "/v1/grants/revoke", + json={"grant_id": f"workspace:{proj.resolve()}"}, + ) + assert resp.status_code == 200 + res_data = resp.json() + assert res_data.get("ok") is True + + # Check that it's no longer trusted + assert manager.workspace_trust.is_trusted(proj) is False