diff --git a/coworker/agent.py b/coworker/agent.py index 86eb1e44..5456312d 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -282,6 +282,12 @@ def build_engine( registry.register(request_tool_tool()) if agent.connectors: enabled_connectors, enabled_tools = _enabled_connector_tools(secrets) + # Least-privilege grant (OPE-93): a persona with an allowlist gets ONLY the + # connectors it declared — an undeclared connector's tools never enter the + # session, no matter what the user has connected. True = general personas + # (Cowork) that legitimately drive whatever is connected. + if agent.connectors is not True: + enabled_connectors = enabled_connectors & set(agent.connectors) # Per-session connection hierarchy (UI-REFRESH §4.3): when the caller supplies the session's # effective connector set, intersect it so only effective-enabled connectors expose tools. # Default None preserves CLI / direct callers (no per-session restriction). diff --git a/coworker/agents/base.py b/coworker/agents/base.py index d451b9f3..0fb78cd1 100644 --- a/coworker/agents/base.py +++ b/coworker/agents/base.py @@ -35,10 +35,12 @@ class Agent: # Traits that replace the old per-agent-name branching in build_engine / manager. # family: "code" gets explorer subagents; "knowledge" gets scheduling / request_directory / # roots context (when it has a workspace). messaging: exposes send_message. connectors: - # loads the integration toolset. Defaults keep non-persona callers behaving as before. + # loads the integration toolset — True = every connected connector (general builtins + # only), a tuple = allowlist (session gets declared ∩ connected; OPE-93), False = none. + # Defaults keep non-persona callers behaving as before. family: str = "knowledge" messaging: bool = False - connectors: bool = False + connectors: bool | tuple[str, ...] = False def build_tools(self, context: AgentContext) -> list: return list(self.tool_factory(context)) if self.tool_factory else [] diff --git a/coworker/personas/builtin/cloud-posture/manifest.md b/coworker/personas/builtin/cloud-posture/manifest.md index dc7f2705..be1b201a 100644 --- a/coworker/personas/builtin/cloud-posture/manifest.md +++ b/coworker/personas/builtin/cloud-posture/manifest.md @@ -6,7 +6,7 @@ tagline: Review Terraform & cloud config — read-only, evidence first family: code version: "1" tools: [code_files, git, search, shell, todo] -connectors: true +connectors: [github] skills: [iac-scan, aws-posture] recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] default_permission_mode: interactive diff --git a/coworker/personas/builtin/dep-audit/manifest.md b/coworker/personas/builtin/dep-audit/manifest.md index c5ba4c6d..3dfc510f 100644 --- a/coworker/personas/builtin/dep-audit/manifest.md +++ b/coworker/personas/builtin/dep-audit/manifest.md @@ -6,7 +6,7 @@ tagline: Vulnerable dependencies — audit, minimal upgrades, PRs family: code version: "1" tools: [code_files, git, search, shell, todo] -connectors: true +connectors: [github] skills: [dependency-audit, safe-upgrade-pr] recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] default_permission_mode: interactive diff --git a/coworker/personas/builtin/security/manifest.md b/coworker/personas/builtin/security/manifest.md index 6707a2e2..ad20c133 100644 --- a/coworker/personas/builtin/security/manifest.md +++ b/coworker/personas/builtin/security/manifest.md @@ -6,7 +6,7 @@ tagline: Find and fix security issues — scan, triage, PR family: code version: "1" tools: [code_files, git, search, shell, todo] -connectors: true +connectors: [github] skills: [semgrep-review, secret-scan, security-fix-pr] recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] default_permission_mode: interactive diff --git a/coworker/personas/loading.py b/coworker/personas/loading.py index 25412d4e..27d3303e 100644 --- a/coworker/personas/loading.py +++ b/coworker/personas/loading.py @@ -26,7 +26,9 @@ def consent_summary(m: PersonaManifest) -> dict: "description": m.description, "tools": list(m.tools), "risk": sorted(rc.value for rc in risk_summary(m.tools)), - "connectors": m.connectors, + # "all" | [connector ids] | [] — the consent screen shows the actual names, + # never a bare "uses connectors" bit (OPE-93). + "connectors": "all" if m.connectors is True else list(m.connectors or ()), "mcp": list(m.mcp), "messaging": m.messaging, "recommended_mode": m.default_permission_mode, @@ -49,8 +51,12 @@ def capability_set(m: PersonaManifest) -> set[str]: update keeps the user's enabled state).""" caps = {f"tool:{t}" for t in m.tools} caps |= {f"mcp:{s}" for s in m.mcp} - if m.connectors: - caps.add("connectors") + # Per-connector caps (OPE-93): an update that ADDS a connector must grow the set and + # re-trigger consent — the old single "connectors" bit hid exactly that change. + if m.connectors is True: + caps.add("connectors:all") + else: + caps |= {f"connector:{c}" for c in m.connectors or ()} if m.messaging: caps.add("messaging") return caps diff --git a/coworker/personas/manifest.py b/coworker/personas/manifest.py index 4715fee9..0ddeb517 100644 --- a/coworker/personas/manifest.py +++ b/coworker/personas/manifest.py @@ -58,7 +58,11 @@ class PersonaManifest: # "deliverable". Builtins registered via builders may still carry "none" (Chat). workspace: str = "deliverable" messaging: bool = False - connectors: bool = False + # Connector grant (OPE-93): False = none, a tuple = allowlist of connector ids + # (session exposes declared ∩ connected), True = every connected connector — the + # `all` sentinel, reserved for built-in general personas. Coarser grants leaked + # undeclared tools (browser, email) into security sessions; undeclared = absent. + connectors: bool | tuple[str, ...] = False default_permission_mode: str = "interactive" recommended_models: list[str] = field(default_factory=list) skills: list[str] = field(default_factory=list) @@ -96,6 +100,59 @@ class PersonaManifest: ) +def _connectors( + persona_id: str, + raw: Any, + recommends: list[Recommendation], + builtin: bool, +) -> bool | tuple[str, ...]: + """Parse the connector grant (OPE-93). Fail closed at every ambiguity. + + - list → explicit allowlist (the normal case). + - "all" → every connected connector; reserved for BUILT-IN general personas — a + shared bundle claiming it is exactly the trust violation the allowlist exists + to prevent, so third-party loads reject it. + - legacy `true` (pre-allowlist manifests) → the connector refs the manifest already + recommends (author intent); no recommends → no grant. + - recommends must stay within the grant: a recommendation the coworker can't use is + author drift, surfaced at load rather than at the user's consent screen. + """ + if raw is None or raw is False: + declared: bool | tuple[str, ...] = False + elif raw is True: + refs = {r.ref for r in recommends if r.kind == "connector"} + declared = tuple(sorted(refs)) if refs else False + elif isinstance(raw, str): + if raw.strip().lower() != "all": + raise ManifestError( + f"{persona_id}: `connectors` must be a list of connector ids or 'all'" + ) + if not builtin: + raise ManifestError( + f"{persona_id}: `connectors: all` is reserved for built-in coworkers — " + "declare the specific connectors this coworker uses" + ) + declared = True + elif isinstance(raw, list): + declared = tuple( + dict.fromkeys(s for s in (str(x).strip() for x in raw) if s) + ) + else: + raise ManifestError( + f"{persona_id}: `connectors` must be a list of connector ids or 'all'" + ) + + if declared is not True: + granted = set(declared or ()) + for r in recommends: + if r.kind == "connector" and r.ref not in granted: + raise ManifestError( + f"{persona_id}: recommends connector '{r.ref}' but does not declare " + "it in `connectors` — a recommendation must stay within the grant" + ) + return declared + + def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]: if not text.startswith("---"): raise ManifestError("manifest must start with a YAML frontmatter block (---)") @@ -224,6 +281,8 @@ def parse_manifest( tools = _strlist(meta, "tools") _validate_tools(persona_id, tools) + recommends = _recommends(persona_id, meta) + connectors = _connectors(persona_id, meta.get("connectors"), recommends, builtin) return PersonaManifest( id=persona_id, @@ -236,13 +295,13 @@ def parse_manifest( family=family, workspace=workspace, messaging=bool(meta.get("messaging", False)), - connectors=bool(meta.get("connectors", False)), + connectors=connectors, default_permission_mode=mode, recommended_models=_strlist(meta, "recommended_models"), skills=_strlist(meta, "skills"), mcp=_strlist(meta, "mcp"), version=str(meta.get("version", "") or "").strip(), - recommends=_recommends(persona_id, meta), + recommends=recommends, builtin=builtin, source=source, ) diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 61c38af1..cceee941 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -898,7 +898,8 @@ export interface PersonaConsent { description: string; tools: string[]; risk: string[]; - connectors: boolean; + // "all" (general builtins) or the declared allowlist — [] means no connector access. + connectors: "all" | string[]; mcp: string[]; messaging: boolean; recommended_mode: string; diff --git a/surfaces/gui/src/components/PersonasTab.tsx b/surfaces/gui/src/components/PersonasTab.tsx index 3fab8746..ff0b285b 100644 --- a/surfaces/gui/src/components/PersonasTab.tsx +++ b/surfaces/gui/src/components/PersonasTab.tsx @@ -376,7 +376,11 @@ function ConsentCard({ )}