diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e271a3..5657e4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 for one named server of one named client, because you opened its drawer — and a copy still reports which values travelled between files by name. +- **gcloud permissions are read, not described.** `permissions` for gcloud used + to answer "IAM roles are per-project and per-resource; patchbay does not + resolve them yet" and hand back a `gcloud projects get-iam-policy` line to + paste — a bare command where an action would have worked, which is the one + thing CONTRIBUTING says the panel does not do. It now runs the read itself + and reports the account's roles on the project. + + That needed a shape the report did not have, because IAM grants live on the + resource: a Google account has no roles of its own, only roles *on a + project*. So permissions became optionally scoped, following `verify_profile` + exactly. `Probe` gains `permission_scopes()` and `permissions_in(scope)`, + both defaulted, so the other 24 probes are untouched and gh and wrangler + behave as before; `PermissionsReport` gains `scope`, omitted from JSON when + there is none. The panel shows a searchable project picker (type to filter, + arrows and enter to choose, the configured project preselected) that appears + only once the backend says the tool has scopes — listing them execs gcloud, + so nothing runs until you press the button. `pb perms` gains `--scope` and + `--list-scopes`; the MCP `get_permissions` gains an optional `scope`, beside + a new `list_permission_scopes` tool. + + Two things that stayed deliberate. The unscoped read resolves the active + configuration's `core/project` and reads *that*, the same move `verify` makes + with the active profile, rather than answering a question it could work out + for itself. And the copyable line survives in exactly one place — when there + is no gcloud on `PATH` to run, so patchbay genuinely cannot answer. + +- The frontend's hardcoded `PERMISSIONS_TOOLS` set is gone. Which tools can + report permissions is the backend's fact, answered by `supported`, not a list + in the UI that goes stale the moment a probe learns a new trick. + ### Fixed - **The main pane no longer scrolls sideways.** 0.3.3 stopped the *window* @@ -53,6 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the same as the vault's, and above the ~613px the headers themselves need — a typical matrix comes to 666px and fits the smallest window with room over. + ## [0.3.3] - 2026-08-14 ### Fixed diff --git a/README.md b/README.md index 05ba05f..38b381f 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ table →](docs/migration.md)** { "mcpServers": { "patchbay": { "command": "/usr/local/bin/patchbay-mcp" } } } ``` -Your agent gets `list_connections`, `switch_profile`, `verify`, `get_permissions`, `store_key`, `plan_setup`, and friends — "switch to the work gcloud account and deploy" becomes one sentence, and a key your AI creates mid-task gets registered instead of rotting in a chat log. Reading secret values back is **off by default** (`PATCHBAY_ALLOW_SECRET_READ=1` to opt in). +Your agent gets `list_connections`, `switch_profile`, `verify`, `get_permissions`, `store_key`, `plan_setup`, and friends — "switch to the work gcloud account and deploy" becomes one sentence, and a key your AI creates mid-task gets registered instead of rotting in a chat log. Where permissions are granted per resource rather than per credential, `get_permissions` takes a `scope` and `list_permission_scopes` says what the choices are — a Google account has no roles of its own, only roles on a project, so patchbay reads the IAM policy of the one you name. Reading secret values back is **off by default** (`PATCHBAY_ALLOW_SECRET_READ=1` to opt in). ## Showcase diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 383cc38..9175030 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -10,8 +10,9 @@ use patchbay_core::keys::NewKey; // module is public and they belong to the write layer this file wraps. use patchbay_core::mcp_clients::{CopyReport, WriteReport}; use patchbay_core::{ - KeyEntry, KeyExpiryState, KeyRegistry, McpClient, McpClientRegistry, PermissionsReport, - Registry, ServerSpec, SwitchOutcome, ToolStatus, TransportSpec, VerifyOutcome, + KeyEntry, KeyExpiryState, KeyRegistry, McpClient, McpClientRegistry, PermissionScope, + PermissionsReport, Registry, ServerSpec, SwitchOutcome, ToolStatus, TransportSpec, + VerifyOutcome, }; /// Probe errors are surfaced to the panel as strings; the panel renders them, @@ -76,6 +77,28 @@ async fn permissions(tool: String) -> CmdResult { blocking(move |registry| registry.permissions(&tool)).await } +/// The scopes this tool's permissions can be read against — GCP projects, for +/// gcloud. Empty means the credential carries one answer everywhere and the +/// panel shows no picker. +/// +/// Tier 2, like everything it sits beside: enumerating scopes executes the +/// tool's CLI, so the panel only calls this from a click, never on open. +#[tauri::command] +async fn permission_scopes(tool: String) -> CmdResult> { + blocking(move |registry| registry.permission_scopes(&tool)).await +} + +/// Permissions within one scope rather than the tool's default. +/// +/// The same reason `verify_profile` exists: a single answer filed under a tool +/// is worse than ambiguous when the grants live on the resource. "viewer" is a +/// fact about one project, and rendering it as a fact about gcloud would be +/// wrong on every other project the account can reach. +#[tauri::command] +async fn permissions_in(tool: String, scope: String) -> CmdResult { + blocking(move |registry| registry.permissions_in(&tool, Some(&scope))).await +} + /// One vault entry as the panel needs it: the registry's own metadata plus the /// expiry verdict, derived here with core's rule so the vault table and the key /// markers on the board can never disagree about what "expiring soon" means. @@ -378,6 +401,8 @@ pub fn run() { verify, verify_profile, permissions, + permission_scopes, + permissions_in, keys_list, key_add, key_remove, diff --git a/app/src/App.tsx b/app/src/App.tsx index bd6f3d1..12ccf49 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,10 +1,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getVersion } from "@tauri-apps/api/app"; -import { permissions as fetchPerms, statusAll, switchProfile, verifyProfile } from "./api"; +import { + permissions as fetchPerms, + permissionScopes, + permissionsIn, + statusAll, + switchProfile, + verifyProfile, +} from "./api"; import { clockTime, summarize, summaryLine } from "./expiry"; import { apply, isFiltered, NO_FILTERS, type Filters } from "./filters"; import { rowKey, switchMessage, type Panel, type SwitchNote, type View } from "./panel"; -import { categoryLabel, STATE_LABEL, type PermissionsReport, type ToolStatus, type VerifyOutcome } from "./types"; +import { + categoryLabel, + STATE_LABEL, + type PermissionScope, + type PermissionsReport, + type ToolStatus, + type VerifyOutcome, +} from "./types"; import { KeysView } from "./components/KeysView"; import { McpView } from "./components/McpView"; import { Sidebar } from "./components/Sidebar"; @@ -29,10 +43,13 @@ export default function App() { const [detail, setDetail] = useState<{ tool: string; permissions: boolean } | null>(null); const [verdicts, setVerdicts] = useState>({}); const [perms, setPerms] = useState>({}); + const [permScopes, setPermScopes] = useState>({}); const [switching, setSwitching] = useState(null); const [switchNotes, setSwitchNotes] = useState>({}); const searchRef = useRef(null); + /** Tools whose scope list has been asked for. See `loadPerms`. */ + const scopesAsked = useRef>(new Set()); const refresh = useCallback(async () => { setRefreshing(true); @@ -100,9 +117,10 @@ export default function App() { setVerdicts((v) => ({ ...v, [key]: { result: "invalid", tool, detail: String(e) } })), ); }, - loadPerms(tool: string) { + loadPerms(tool: string, scope?: string) { setPerms((p) => ({ ...p, [tool]: null })); - fetchPerms(tool) + const read = scope === undefined ? fetchPerms(tool) : permissionsIn(tool, scope); + read .then((report) => setPerms((p) => ({ ...p, [tool]: report }))) .catch((e) => setPerms((p) => ({ @@ -114,9 +132,24 @@ export default function App() { scopes: [], notes: [String(e)], hint: null, + scope, }, })), ); + + // The scope list rides along with the first read, not with the render: + // it execs the tool's CLI too, and the click that asked for + // permissions is the consent for both. Once per tool — the guard is a + // ref rather than the state it fills, because a re-read arrives before + // the first answer does. A failure parks `[]`, which reads as "no + // picker" rather than retrying forever. + if (!scopesAsked.current.has(tool)) { + scopesAsked.current.add(tool); + setPermScopes((s) => ({ ...s, [tool]: null })); + permissionScopes(tool) + .then((list) => setPermScopes((s) => ({ ...s, [tool]: list }))) + .catch(() => setPermScopes((s) => ({ ...s, [tool]: [] }))); + } }, switchTo(tool: string, profileId: string) { setSwitching(rowKey(tool, profileId)); @@ -140,7 +173,7 @@ export default function App() { [], ); - const panel: Panel = { now, verdicts, perms, switching, switchNotes, ...actions }; + const panel: Panel = { now, verdicts, perms, permScopes, switching, switchNotes, ...actions }; const all = statuses ?? []; const shown = useMemo(() => apply(all, filters), [all, filters]); diff --git a/app/src/api.ts b/app/src/api.ts index 1748848..97d6187 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -6,6 +6,7 @@ import type { McpSpec, McpWriteReport, NewKeyInput, + PermissionScope, PermissionsReport, RemovedKey, SwitchOutcome, @@ -30,6 +31,24 @@ export const verifyProfile = (tool: string, profile: string) => export const permissions = (tool: string) => invoke("permissions", { tool }); +/** + * The scopes this tool's permissions can be read against. Empty means the + * credential carries one answer everywhere — no picker, just read. + * + * Tier 2 like its neighbours: this executes the tool's CLI, so it belongs + * behind a click and never on a render. + */ +export const permissionScopes = (tool: string) => + invoke("permission_scopes", { tool }); + +/** + * Permissions within one scope. Same reason `verifyProfile` exists: where the + * grants live on the resource, a single answer filed under the tool is wrong + * everywhere but one place. + */ +export const permissionsIn = (tool: string, scope: string) => + invoke("permissions_in", { tool, scope }); + /** Vault metadata. There is no command that returns a value — see `keyAdd`. */ export const keysList = () => invoke("keys_list"); diff --git a/app/src/components/ToolDetail.tsx b/app/src/components/ToolDetail.tsx index 1ef07f1..bdef896 100644 --- a/app/src/components/ToolDetail.tsx +++ b/app/src/components/ToolDetail.tsx @@ -1,11 +1,11 @@ -import { useEffect } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import { countdown, levelOf } from "../expiry"; import { profileMatches } from "../filters"; import { metaEntries, rowKey, verdictText, type Panel, type SwitchNote } from "../panel"; import { KEY_EXPIRY_LABEL, KEY_EXPIRY_LEVEL, - PERMISSIONS_TOOLS, + type PermissionScope, type PermissionsReport, type Profile, type ToolStatus, @@ -202,8 +202,13 @@ function SwitchNoteBlock({ note }: Readonly<{ note: SwitchNote }>) { /** * States what it knows, then offers the action that gets more. The button is - * always here: even where patchbay has no scope reader, asking and reporting - * the answer beats a sentence that just says no and gives you nothing to press. + * always here, for every tool: whether patchbay can answer is the backend's + * fact to report, not a list kept in the frontend that goes stale the moment a + * probe learns a new trick. A tool with no reader comes back `supported: + * false` and its notes say why — which is more than a hidden button ever did. + * + * Where a tool grants per resource rather than per credential, the read is + * only half the surface: the other half is choosing *what* to read against. */ function PermissionsSection({ tool, @@ -211,11 +216,16 @@ function PermissionsSection({ panel, }: Readonly<{ tool: string; report: PermissionsReport | null | undefined; panel: Panel }>) { const loading = report === null; - const hasScopeReader = PERMISSIONS_TOOLS.has(tool); + const scopes = panel.permScopes[tool]; + const [picked, setPicked] = useState(null); - // "re-read" only where something was read. A tool patchbay has no scope - // reader for answers "not supported", and offering to re-read that implies a - // second press could say something different. + // The scope in the box: what you chose, else the one this tool is already + // configured for, else the first. Never nothing while there is a list. + const chosen = + picked ?? scopes?.find((s) => s.active)?.id ?? (scopes?.length ? scopes[0].id : null); + + // "re-read" only where something was read. A tool patchbay cannot answer for + // says so, and offering to re-read that implies a second press could differ. let readLabel: string; if (loading) readLabel = "reading…"; else if (report?.supported) readLabel = "re-read scopes"; @@ -235,13 +245,32 @@ function PermissionsSection({ {readLabel} {report === undefined && ( - - {hasScopeReader - ? `asks ${tool} what this credential carries` - : `no scope reader for ${tool} yet — most permissions live server-side, per resource`} - + asks {tool} what this credential carries )} + + {/* Only once the backend has said this tool has scopes — which it only + knows after the first read, because listing them execs the CLI too. */} + {scopes && scopes.length > 0 && chosen && ( +
+ + granted per resource, not per credential — read another one + +
+ + +
+
+ )} + {report && (
{report.subject && ( @@ -250,6 +279,14 @@ function PermissionsSection({ {report.subject}
)} + {/* Which resource this is about is part of the answer: "viewer" is a + different fact about one project than about the next. */} + {report.scope && ( +
+ scope + {report.scope} +
+ )} {report.notes.length > 0 && } {report.hint && } @@ -259,6 +296,155 @@ function PermissionsSection({ ); } +/** + * Type to filter, arrow to move, enter to take it — the app has no select and + * no combobox, and a native `= 0 ? `${listId}-${at}` : undefined} + aria-label={`scope to read ${tool} permissions in`} + value={open ? query : value} + placeholder={value} + onChange={(e) => { + setQuery(e.target.value); + setCursor(0); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + /> + {open && ( +
    + {matches.length === 0 &&
  • no match
  • } + {matches.map((s, i) => ( +
  • + {/* An option you can click is a button; the role puts it back + into the listbox for anyone reading it as one. */} + +
  • + ))} +
+ )} + + ); +} + function Scopes({ report }: Readonly<{ report: PermissionsReport }>) { if (!report.supported) return

not supported for this tool

; if (report.scopes.length === 0) return

the tool reported no scopes

; diff --git a/app/src/panel.ts b/app/src/panel.ts index 2560b07..633e379 100644 --- a/app/src/panel.ts +++ b/app/src/panel.ts @@ -1,4 +1,10 @@ -import type { Meta, PermissionsReport, SwitchOutcome, VerifyOutcome } from "./types"; +import type { + Meta, + PermissionScope, + PermissionsReport, + SwitchOutcome, + VerifyOutcome, +} from "./types"; /** * What the main pane is showing. The board is the app; the other two are @@ -34,11 +40,26 @@ export interface Panel { */ verdicts: Record; perms: Record; + /** + * Keyed by tool: the scopes its permissions can be read against, once + * somebody has asked. `null` = in flight, `undefined` = never asked, `[]` = + * asked and this tool has none (the ordinary case — most credentials carry + * the same permissions everywhere). + * + * Never populated on render: listing scopes execs the tool's CLI, so it + * waits for the same click that reads the permissions. + */ + permScopes: Record; /** `rowKey` of the switch in flight. */ switching: string | null; switchNotes: Record; verifyRow(tool: string, profileId: string): void; - loadPerms(tool: string): void; + /** + * Read permissions. With `scope`, reads that one and files the answer under + * the tool; without, reads the tool's default and asks — once — whether this + * tool has scopes at all, so the picker can appear beside the result. + */ + loadPerms(tool: string, scope?: string): void; switchTo(tool: string, profileId: string): void; open(tool: string, opts?: { permissions?: boolean }): void; } diff --git a/app/src/styles.css b/app/src/styles.css index 24220f9..12d14cb 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -1200,6 +1200,93 @@ dialog.detail { gap: 10px; } +/* Choosing what to read permissions against. Only appears for tools that + grant per resource, and only after the first read has told us they do. */ + +.perm-pick { + display: flex; + flex-direction: column; + gap: 6px; +} + +.perm-pick-row { + display: flex; + align-items: center; + gap: 8px; +} + +/* The app has no select and no combobox. This is one: a text input that + filters a listbox floating under it. Positioned rather than in the flow, so + opening it does not shove the report below out from under the cursor. */ +.combo { + position: relative; + flex: 1; + min-width: 0; +} + +.combo-input { + padding-block: 3px; +} + +.combo-list { + position: absolute; + z-index: 2; + top: calc(100% + 3px); + left: 0; + right: 0; + max-height: 190px; + overflow-y: auto; + margin: 0; + padding: 3px; + list-style: none; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--panel-2); + box-shadow: 0 6px 18px rgb(0 0 0 / 28%); +} + +.combo-opt { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + padding: 4px 6px; + border: 0; + border-radius: 3px; + background: transparent; + color: var(--ink-2); + font-family: var(--mono); + font-size: 11.5px; + text-align: left; + cursor: pointer; +} + +/* One highlight, driven by the keyboard cursor — the pointer moves it too, so + hover and arrow keys can never disagree about which row Enter would take. */ +.combo-opt.is-on { + background: var(--neutral-wash); + color: var(--ink); +} + +.combo-opt-id { + flex: none; + overflow-wrap: anywhere; +} + +.combo-opt-label { + flex: 1; + min-width: 0; + color: var(--muted); + font-size: 10.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.combo-empty { + padding: 4px 6px; +} + .perm-body .kv { display: flex; gap: 8px; diff --git a/app/src/types.ts b/app/src/types.ts index ab1af0d..7972097 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -298,6 +298,19 @@ export type VerifyOutcome = | { result: "invalid"; tool: string; detail: string } | { result: "unsupported"; tool: string; reason: string; hint: string | null }; +/** + * One thing a tool's permissions can be read *against*. Some tools grant per + * resource rather than per credential — a Google account's IAM roles live on a + * project — so the question needs a scope before it has an answer. + */ +export interface PermissionScope { + /** What `permissionsIn` takes, e.g. a GCP project id. */ + id: string; + label: string; + /** The scope the tool is already configured for; the picker opens on it. */ + active: boolean; +} + export interface PermissionsReport { tool: string; supported: boolean; @@ -305,11 +318,6 @@ export interface PermissionsReport { scopes: string[]; notes: string[]; hint: string | null; + /** Which scope this report is about. Absent for tools that have none. */ + scope?: string; } - -/** - * Tools whose `permissions()` can actually answer. Everything else returns - * `supported: false`, so the panel omits the action rather than offering a - * button that only ever says "not implemented". - */ -export const PERMISSIONS_TOOLS = new Set(["gh", "wrangler"]); diff --git a/crates/patchbay-cli/src/main.rs b/crates/patchbay-cli/src/main.rs index 7561daa..1731873 100644 --- a/crates/patchbay-cli/src/main.rs +++ b/crates/patchbay-cli/src/main.rs @@ -14,8 +14,8 @@ use anyhow::Result; use chrono::Utc; use clap::{Parser, Subcommand}; use patchbay_core::{ - Advisory, CheckOptions, KeyRegistry, McpClientRegistry, PermissionsReport, Registry, - SwitchOutcome, VerifyOutcome, + Advisory, CheckOptions, KeyRegistry, McpClientRegistry, PermissionScope, PermissionsReport, + Registry, SwitchOutcome, VerifyOutcome, }; use render::Styles; @@ -67,8 +67,22 @@ enum Command { json: bool, }, /// Show what the active credential of a tool is allowed to do. + /// + /// Some tools grant per resource rather than per credential — a Google + /// account's IAM roles exist on a project, not on the account — so their + /// permissions are read against a scope. `--list-scopes` shows the ones + /// this login can see; `--scope` reads one of them. Tools that answer once + /// for the whole credential list no scopes and ignore the flag. Perms { tool: String, + /// Read permissions within this scope (e.g. a GCP project id) instead + /// of whatever the tool treats as the default. + #[arg(long, value_name = "ID", conflicts_with = "list_scopes")] + scope: Option, + /// List the scopes this tool's permissions can be read against, and + /// stop. + #[arg(long)] + list_scopes: bool, #[arg(long)] json: bool, }, @@ -206,8 +220,22 @@ fn run() -> Result { _ => 0, }) } - Command::Perms { tool, json } => { - let report = registry.permissions(&tool)?; + Command::Perms { + tool, + scope, + list_scopes, + json, + } => { + if list_scopes { + let scopes = registry.permission_scopes(&tool)?; + if json { + println!("{}", serde_json::to_string_pretty(&scopes)?); + } else { + print_scopes(&tool, &scopes); + } + return Ok(0); + } + let report = registry.permissions_in(&tool, scope.as_deref())?; if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { @@ -384,6 +412,25 @@ fn print_verify(outcome: &VerifyOutcome) { } } +/// The scopes a tool's permissions can be read against, with the one its +/// current configuration points at marked — that is the id `pb perms` uses +/// when none is given. +fn print_scopes(tool: &str, scopes: &[PermissionScope]) { + if scopes.is_empty() { + println!("{tool}: permissions are not read per scope for this tool"); + return; + } + println!("{tool}: {} scope(s)", scopes.len()); + for scope in scopes { + let marker = if scope.active { "*" } else { " " }; + if scope.label == scope.id { + println!(" {marker} {}", scope.id); + } else { + println!(" {marker} {} {}", scope.id, scope.label); + } + } +} + fn print_perms(report: &PermissionsReport) { let PermissionsReport { tool, @@ -392,12 +439,20 @@ fn print_perms(report: &PermissionsReport) { scopes, notes, hint, + scope, } = report; + // The scope is part of the answer, not decoration: "viewer" is a different + // fact about `proj-a` than about `proj-b`. + let where_ = match scope { + Some(scope) => format!(" in {scope}"), + None => String::new(), + }; + if *supported { match subject { - Some(subject) => println!("{tool}: {subject}"), - None => println!("{tool}:"), + Some(subject) => println!("{tool}: {subject}{where_}"), + None => println!("{tool}:{where_}"), } if scopes.is_empty() { println!(" (no scopes reported)"); @@ -406,7 +461,7 @@ fn print_perms(report: &PermissionsReport) { println!("{}", render::render_scopes(scopes)); } } else { - println!("{tool}: permissions not available"); + println!("{tool}: permissions not available{where_}"); } if !notes.is_empty() { diff --git a/crates/patchbay-core/src/lib.rs b/crates/patchbay-core/src/lib.rs index 0d2a63c..560b12b 100644 --- a/crates/patchbay-core/src/lib.rs +++ b/crates/patchbay-core/src/lib.rs @@ -70,7 +70,7 @@ pub use paths::Paths; pub use probe::Probe; pub use registry::Registry; pub use types::{ - ConnectionState, KeyRef, PermissionsReport, Profile, SwitchOutcome, ToolCategory, ToolStatus, - VerifyOutcome, + ConnectionState, KeyRef, PermissionScope, PermissionsReport, Profile, SwitchOutcome, + ToolCategory, ToolStatus, VerifyOutcome, }; pub use versions::{CheckOptions, CheckReport, Source, VersionCache, VersionInfo}; diff --git a/crates/patchbay-core/src/probe.rs b/crates/patchbay-core/src/probe.rs index fe318e9..0c186b9 100644 --- a/crates/patchbay-core/src/probe.rs +++ b/crates/patchbay-core/src/probe.rs @@ -1,6 +1,6 @@ //! The per-tool adapter contract. -use crate::types::{PermissionsReport, SwitchOutcome, ToolStatus, VerifyOutcome}; +use crate::types::{PermissionScope, PermissionsReport, SwitchOutcome, ToolStatus, VerifyOutcome}; /// One developer CLI's auth state, as patchbay sees it. /// @@ -9,9 +9,10 @@ use crate::types::{PermissionsReport, SwitchOutcome, ToolStatus, VerifyOutcome}; /// * **tier 1** — [`Probe::status`]. Reads local state files only. No process /// spawning, no network. Must stay in the low-milliseconds so the whole board /// can be rendered on every prompt. -/// * **tier 2** — [`Probe::verify`] and [`Probe::permissions`]. May execute the -/// tool's own CLI, which may in turn hit the network. Seconds, not -/// milliseconds. Only run when explicitly asked. +/// * **tier 2** — [`Probe::verify`], [`Probe::permissions`] and the scoped +/// variants beside them. May execute the tool's own CLI, which may in turn +/// hit the network. Seconds, not milliseconds. Only run when explicitly +/// asked. /// /// [`Probe::switch`] mutates state and may exec the tool's CLI. /// @@ -52,6 +53,34 @@ pub trait Probe: Send + Sync { /// Tier 2: report what the active credential is allowed to do. fn permissions(&self) -> anyhow::Result; + + /// Tier 2: the scopes this tool's permissions can be read against. + /// + /// Empty — the default — means "one credential, one answer": ask + /// [`Probe::permissions`] and be done. A non-empty list means the question + /// is only well-formed once a scope is named, because the grants live on + /// the resource rather than on the credential (GCP IAM is the type case: + /// roles are per project, and the same account can be owner of one and a + /// stranger to the next). + /// + /// This is tier 2 like its neighbours — enumerating scopes may execute the + /// tool's CLI — so callers must treat it as a click, not a page load. + fn permission_scopes(&self) -> anyhow::Result> { + Ok(Vec::new()) + } + + /// Tier 2: report the credential's permissions within one scope. + /// + /// The default ignores `scope_id` and delegates to [`Probe::permissions`], + /// which is right for every tool that reports no scopes: there is nothing + /// to narrow. + /// + /// Implementors: resolve it yourself. Same rule as + /// [`Probe::verify_profile`] — if a CLI has to be invoked, patchbay + /// invokes it. + fn permissions_in(&self, _scope_id: &str) -> anyhow::Result { + self.permissions() + } } /// Helper for probes with no switch path. diff --git a/crates/patchbay-core/src/probes/claude.rs b/crates/patchbay-core/src/probes/claude.rs index bd7c97d..bc80d81 100644 --- a/crates/patchbay-core/src/probes/claude.rs +++ b/crates/patchbay-core/src/probes/claude.rs @@ -180,6 +180,7 @@ impl Probe for ClaudeProbe { .to_string(), ], hint: Some("claude /status".to_string()), + scope: None, }) } } diff --git a/crates/patchbay-core/src/probes/firebase.rs b/crates/patchbay-core/src/probes/firebase.rs index 667b275..0c85cd0 100644 --- a/crates/patchbay-core/src/probes/firebase.rs +++ b/crates/patchbay-core/src/probes/firebase.rs @@ -244,6 +244,7 @@ impl Probe for FirebaseProbe { .to_string(), ], hint: Some("firebase login --reauth".to_string()), + scope: None, }) } } diff --git a/crates/patchbay-core/src/probes/gcloud.rs b/crates/patchbay-core/src/probes/gcloud.rs index 8d79b4c..1ca12bf 100644 --- a/crates/patchbay-core/src/probes/gcloud.rs +++ b/crates/patchbay-core/src/probes/gcloud.rs @@ -28,7 +28,9 @@ use rusqlite::{Connection, OpenFlags}; use crate::paths::Paths; use crate::probe::{unknown_profile, unsupported_switch, unsupported_verify, Probe}; -use crate::types::{PermissionsReport, Profile, SwitchOutcome, ToolStatus, VerifyOutcome}; +use crate::types::{ + PermissionScope, PermissionsReport, Profile, SwitchOutcome, ToolStatus, VerifyOutcome, +}; use crate::util::{read_text, CmdOutput, Ini}; pub struct GcloudProbe { @@ -105,6 +107,26 @@ impl GcloudProbe { }) } + /// `core/account` and `core/project` of the active configuration. + /// + /// Both permission paths start here: an IAM question is only well-formed + /// once it names *who* and *where*, and gcloud keeps both in the same INI + /// file `status` already parses. + fn active_meta(&self) -> anyhow::Result<(Option, Option)> { + let status = self.status()?; + let active = status + .active + .as_ref() + .and_then(|a| status.profiles.iter().find(|p| &p.id == a)); + let meta = |key: &str| { + active + .and_then(|p| p.meta.get(key)) + .and_then(|v| v.as_str()) + .map(str::to_string) + }; + Ok((meta("account"), meta("project"))) + } + fn read_adc(&self, notes: &mut Vec) -> Option { let path = self .paths @@ -457,41 +479,261 @@ impl Probe for GcloudProbe { }) } - fn permissions(&self) -> anyhow::Result { - // patchbay has no IAM reader yet, but it does know the two values the - // command needs — leaving `` and `` for the human to - // fill in from the row directly above is a hint that has not finished - // the job. The quoting is not decoration either: unquoted, - // `bindings[].members` is a glob, and zsh answers `no matches found` - // before gcloud ever runs. - let status = self.status()?; - let active = status - .active - .as_ref() - .and_then(|a| status.profiles.iter().find(|p| &p.id == a)); - let meta = |key: &str| { - active - .and_then(|p| p.meta.get(key)) - .and_then(|v| v.as_str()) - .map(str::to_string) + /// The projects an IAM question can be asked about. + /// + /// A Google account has no permissions of its own — the grants live on the + /// resource, so "what may I do" only becomes a question once a project is + /// named. This enumerates the ones this login can see, which is what lets + /// the panel offer a picker instead of a blank where the answer should be. + /// + /// Tier 2: it runs `gcloud projects list`, so nothing calls it on the way + /// in. An empty list is the honest answer to every way this can fail to + /// enumerate — no gcloud on PATH, a login that cannot list projects — and + /// [`Probe::permissions`] is what then says why out loud. + fn permission_scopes(&self) -> anyhow::Result> { + if !self.paths.may_exec() || !self.paths.has_binary("gcloud") { + return Ok(Vec::new()); + } + let (_, active_project) = self.active_meta()?; + + let out = self + .paths + .run("gcloud", &["projects", "list", "--format=json", "--quiet"])?; + let mut scopes: Vec = Vec::new(); + if out.ok { + if let Ok(serde_json::Value::Array(items)) = + serde_json::from_str::(&out.stdout) + { + for item in items { + let Some(id) = item.get("projectId").and_then(|v| v.as_str()) else { + continue; + }; + // Projects pending deletion are still listed and cannot be + // usefully inspected; offering one is offering a dead end. + let live = item + .get("lifecycleState") + .and_then(|v| v.as_str()) + .is_none_or(|s| s == "ACTIVE"); + if !live { + continue; + } + let label = item + .get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or(id); + scopes.push(PermissionScope { + id: id.to_string(), + label: label.to_string(), + active: active_project.as_deref() == Some(id), + }); + } + } + } + + // `resourcemanager.projects.list` is a separate grant from being able + // to read a project you already work in every day, so the configured + // project can be missing from its own list. It is the one the user is + // most likely to ask about, so it goes in regardless. + if let Some(project) = &active_project { + if !scopes.iter().any(|s| &s.id == project) { + scopes.insert( + 0, + PermissionScope { + id: project.clone(), + label: project.clone(), + active: true, + }, + ); + } + } + Ok(scopes) + } + + /// The active account's IAM roles on one project. + /// + /// Note what this is *not*: the roles are read for the account the active + /// configuration names, on the project named here. Those two are usually + /// the same project, and deliberately do not have to be — "what would I be + /// able to do over there" is the question worth being able to ask. + fn permissions_in(&self, scope_id: &str) -> anyhow::Result { + let (account, _) = self.active_meta()?; + let Some(account) = account else { + return Ok(PermissionsReport::unsupported( + Self::TOOL, + "no active configuration with an account, so there is no identity to resolve roles for", + None, + )); }; - let account = meta("account"); - let hint = format!( - "gcloud projects get-iam-policy {} --flatten='bindings[].members' --filter='bindings.members:{}'", - meta("project").as_deref().unwrap_or(""), - account.as_deref().unwrap_or(""), - ); + + if !self.paths.may_exec() || !self.paths.has_binary("gcloud") { + // The one honest last resort: with no gcloud to run, patchbay + // cannot answer, so it hands over the line it would have run. The + // quoting matters here and only here — this string is for a shell, + // and unquoted `bindings[].members` is a glob that zsh refuses + // before gcloud ever starts. The exec path below passes the same + // values as argv, where no shell ever sees them. + let mut report = PermissionsReport::unsupported( + Self::TOOL, + "the gcloud CLI is not available on PATH, so patchbay cannot read the IAM policy itself", + Some(&format!( + "gcloud projects get-iam-policy {scope_id} --flatten='bindings[].members' --filter='bindings.members:{account}'" + )), + ); + report.subject = Some(account); + report.scope = Some(scope_id.to_string()); + return Ok(report); + } + + let out = self.paths.run( + "gcloud", + &[ + "projects", + "get-iam-policy", + scope_id, + "--flatten=bindings[].members", + &format!("--filter=bindings.members:{account}"), + "--format=json", + "--quiet", + ], + )?; + if !out.ok { + // A refusal is an answer about this project — "you may not read + // its policy" is itself a permissions fact — so it comes back as a + // report naming the project, not as an error that blanks the pane. + // + // Through `auth_failure` for the same reason `verify` is: gcloud + // answers a reauth with four lines of shell instructions, and + // pasting those into a note is handing back someone else's error + // instead of an answer. + let detail = auth_failure(&account, &out); + return Ok(PermissionsReport { + tool: Self::TOOL.to_string(), + supported: false, + subject: Some(account), + scopes: Vec::new(), + notes: vec![format!( + "could not read the IAM policy of {scope_id} — {detail}" + )], + hint: None, + scope: Some(scope_id.to_string()), + }); + } + + let mut roles = match serde_json::from_str::(&out.stdout) { + Ok(json) => { + let mut roles = Vec::new(); + collect_roles(&json, &account, &mut roles); + roles + } + Err(e) => { + return Ok(PermissionsReport { + tool: Self::TOOL.to_string(), + supported: false, + subject: Some(account), + scopes: Vec::new(), + notes: vec![format!( + "gcloud returned something that is not the IAM policy JSON patchbay expects ({e})" + )], + hint: None, + scope: Some(scope_id.to_string()), + }); + } + }; + roles.sort(); + roles.dedup(); + + let mut notes = + vec!["project-level bindings only; org/folder inheritance not shown".to_string()]; + if roles.is_empty() { + notes.push(format!( + "{account} holds no role granted directly on {scope_id} — it may still reach it through a role inherited from the organisation or folder, or through a group" + )); + } + Ok(PermissionsReport { + tool: Self::TOOL.to_string(), + supported: true, + subject: Some(account), + scopes: roles, + notes, + hint: None, + scope: Some(scope_id.to_string()), + }) + } + + /// "What may this login do", with no project named. + /// + /// The active configuration already names one, so this resolves it and + /// hands over to [`Probe::permissions_in`] — the same move + /// [`Probe::verify`] makes with the active profile. Only a configuration + /// with no `core/project` has nothing to resolve, and that is the one case + /// this answers with a "pick one" rather than a reading. + fn permissions(&self) -> anyhow::Result { + let (account, project) = self.active_meta()?; + if let Some(project) = project { + return self.permissions_in(&project); + } let mut report = PermissionsReport::unsupported( Self::TOOL, - "IAM roles are per-project and per-resource; patchbay does not resolve them yet", - Some(&hint), + "IAM roles are granted per project, and the active configuration sets no `core/project` — pick a project to read its policy", + (!self.paths.may_exec() || !self.paths.has_binary("gcloud")).then_some( + "gcloud projects get-iam-policy --flatten='bindings[].members' --filter='bindings.members:'", + ), ); report.subject = account; Ok(report) } } +/// Every role the JSON grants `account`, at any nesting. +/// +/// Two shapes arrive here. `--flatten=bindings[].members` yields one record per +/// (role, member) pair with `bindings` an object; a policy read without it has +/// `bindings` as an array of `{role, members[]}`. Rather than commit to either, +/// this walks for objects carrying a `role` — and where a record still lists +/// its members, checks them, so a role the account does not hold can never be +/// reported as one it does even if the server-side `--filter` were dropped. +fn collect_roles(value: &serde_json::Value, account: &str, out: &mut Vec) { + match value { + serde_json::Value::Array(items) => { + for item in items { + collect_roles(item, account, out); + } + } + serde_json::Value::Object(map) => { + if let Some(role) = map.get("role").and_then(|v| v.as_str()) { + if members_include(map.get("members"), account) { + out.push(role.to_string()); + } + } + for nested in map.values() { + collect_roles(nested, account, out); + } + } + _ => {} + } +} + +/// Whether an IAM `members` value names this account. +/// +/// Members are prefixed by principal type — `user:a@example.com`, +/// `serviceAccount:…` — so a bare comparison would match nothing. No members +/// field at all means the record did not record them, not that it excludes the +/// account: gcloud's own filter already made that call. +fn members_include(members: Option<&serde_json::Value>, account: &str) -> bool { + fn one(member: &serde_json::Value, account: &str) -> bool { + member + .as_str() + .is_some_and(|m| m == account || m.ends_with(&format!(":{account}"))) + } + match members { + None | Some(serde_json::Value::Null) => true, + Some(serde_json::Value::Array(items)) => items.iter().any(|m| one(m, account)), + Some(other) => one(other, account), + } +} + /// One sentence for a failed `print-access-token`, plus the command that ends /// it. /// @@ -935,8 +1177,9 @@ mod tests { } } - #[test] - fn test_permissions_hint_carries_the_real_project_and_is_shell_safe() { + /// A configuration with a project, an account, and credentials — the shape + /// every IAM question is asked from. + fn with_project() -> Fixture { let fx = Fixture::new(); fx.config( "work", @@ -944,10 +1187,27 @@ mod tests { ) .active("work") .credentials_db(&["b@example.com"]); + fx + } + + /// The policy `gcloud projects get-iam-policy --flatten=bindings[].members + /// --format=json` actually prints: one record per (role, member) pair. + const FLATTENED_POLICY: &str = r#"[ + {"bindings": {"members": "user:b@example.com", "role": "roles/viewer"}, + "etag": "BwXyz", "version": 1}, + {"bindings": {"members": "user:b@example.com", "role": "roles/run.admin"}, + "etag": "BwXyz", "version": 1} + ]"#; + + #[test] + fn test_permissions_without_gcloud_falls_back_to_a_shell_safe_line() { + // The one case a copyable command is the honest answer: nothing to run. + let fx = with_project(); let report = fx.probe().permissions().unwrap(); let hint = report.hint.unwrap(); assert_eq!(report.subject.as_deref(), Some("b@example.com")); + assert_eq!(report.scope.as_deref(), Some("proj-b")); assert!( hint.contains("proj-b") && hint.contains("b@example.com"), "{hint}" @@ -957,6 +1217,238 @@ mod tests { assert!(hint.contains("--flatten='bindings[].members'"), "{hint}"); } + #[test] + fn test_permission_scopes_lists_projects_and_flags_the_configured_one() { + let fx = with_project(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "projects list", + true, + r#"[ + {"projectId": "proj-a", "name": "Alpha", "lifecycleState": "ACTIVE"}, + {"projectId": "proj-b", "name": "Beta", "lifecycleState": "ACTIVE"}, + {"projectId": "proj-dead", "name": "Gone", "lifecycleState": "DELETE_REQUESTED"} + ]"#, + "", + )); + + let scopes = probe_with(&fx, exec).permission_scopes().unwrap(); + let ids: Vec<&str> = scopes.iter().map(|s| s.id.as_str()).collect(); + // A project pending deletion is a dead end, not a choice. + assert_eq!(ids, vec!["proj-a", "proj-b"], "{scopes:?}"); + assert_eq!(scopes[1].label, "Beta"); + assert!( + scopes[1].active, + "the configured project is the default one" + ); + assert!(!scopes[0].active); + } + + #[test] + fn test_the_configured_project_is_offered_even_when_it_is_not_listable() { + // `resourcemanager.projects.list` is its own grant: an account can work + // in a project every day and never see it in `projects list`. + let fx = with_project(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "projects list", + true, + r#"[{"projectId": "proj-a", "name": "Alpha", "lifecycleState": "ACTIVE"}]"#, + "", + )); + + let scopes = probe_with(&fx, exec).permission_scopes().unwrap(); + let configured = scopes.iter().find(|s| s.id == "proj-b").expect("proj-b"); + assert!(configured.active); + } + + #[test] + fn test_a_probe_with_no_scope_reader_reports_none_and_ignores_the_scope() { + // The 24 probes that never opted in: `permission_scopes` is empty, and + // `permissions_in` is `permissions` with the argument thrown away, so + // a scoped caller cannot get a different (or worse, a wrong) answer. + let dir = tempfile::tempdir().unwrap(); + let probe = crate::probes::gh::GhProbe::new(Paths::for_test(dir.path())); + assert!(probe.permission_scopes().unwrap().is_empty()); + assert_eq!( + probe.permissions_in("anything-at-all").unwrap(), + probe.permissions().unwrap() + ); + } + + #[test] + fn test_permissions_reads_the_active_projects_roles_rather_than_handing_over_a_command() { + let fx = with_project(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "get-iam-policy", + true, + FLATTENED_POLICY, + "", + )); + + let report = probe_with(&fx, exec.clone()).permissions().unwrap(); + assert!(report.supported); + assert_eq!(report.subject.as_deref(), Some("b@example.com")); + assert_eq!(report.scope.as_deref(), Some("proj-b")); + assert_eq!(report.scopes, vec!["roles/run.admin", "roles/viewer"]); + // The panel operates the tool; it does not hand out a line to paste. + assert_eq!(report.hint, None); + assert!( + report.notes.iter().any(|n| n.contains("org/folder")), + "{:?}", + report.notes + ); + + // No shell is involved, so the filter goes as one argv item, unquoted. + let call = exec.last().unwrap(); + assert!( + call.args + .contains(&"--flatten=bindings[].members".to_string()), + "{:?}", + call.args + ); + assert!( + call.args + .contains(&"--filter=bindings.members:b@example.com".to_string()), + "{:?}", + call.args + ); + assert!(call.args.contains(&"proj-b".to_string()), "{:?}", call.args); + } + + #[test] + fn test_permissions_in_reads_a_project_other_than_the_configured_one() { + let fx = with_project(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "get-iam-policy", + true, + r#"[{"bindings": {"members": "user:b@example.com", "role": "roles/owner"}}]"#, + "", + )); + + let report = probe_with(&fx, exec.clone()) + .permissions_in("other-project") + .unwrap(); + assert_eq!(report.scope.as_deref(), Some("other-project")); + assert_eq!(report.scopes, vec!["roles/owner"]); + assert!(exec + .last() + .unwrap() + .args + .contains(&"other-project".to_string())); + } + + #[test] + fn test_a_role_held_by_somebody_else_is_never_reported_as_yours() { + // Belt and braces over gcloud's own `--filter`: the member list decides. + let fx = with_project(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "get-iam-policy", + true, + r#"[ + {"bindings": {"members": ["user:b@example.com"], "role": "roles/viewer"}}, + {"bindings": {"members": ["user:someone-else@example.com"], "role": "roles/owner"}} + ]"#, + "", + )); + + let report = probe_with(&fx, exec).permissions_in("proj-b").unwrap(); + assert_eq!(report.scopes, vec!["roles/viewer"]); + } + + #[test] + fn test_no_direct_binding_is_answered_as_a_fact_not_as_an_empty_pane() { + let fx = with_project(); + let exec = + std::sync::Arc::new(crate::util::FakeExec::new().on("get-iam-policy", true, "[]", "")); + + let report = probe_with(&fx, exec).permissions_in("proj-b").unwrap(); + assert!(report.supported); + assert!(report.scopes.is_empty()); + assert!( + report.notes.iter().any(|n| n.contains("no role granted")), + "{:?}", + report.notes + ); + } + + #[test] + fn test_a_refused_iam_read_is_a_report_about_that_project_not_an_error() { + let fx = with_project(); + let stderr = "ERROR: (gcloud.projects.get-iam-policy) User [b@example.com] does not have permission to access projects instance [proj-b] (or it may not exist): Policy retrieval failed.\n"; + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "get-iam-policy", + false, + "", + stderr, + )); + + let report = probe_with(&fx, exec).permissions_in("proj-b").unwrap(); + assert!(!report.supported); + assert_eq!(report.scope.as_deref(), Some("proj-b")); + assert!(report.scopes.is_empty()); + let notes = report.notes.join("\n"); + assert!(notes.contains("proj-b"), "{notes}"); + assert!(notes.contains("does not have permission"), "{notes}"); + // gcloud's own `ERROR: (command)` prefix only repeats what we ran. + assert!(!notes.contains("ERROR:"), "{notes}"); + } + + #[test] + fn test_a_reauth_during_an_iam_read_is_one_sentence_not_four_lines_of_shell() { + // The same four-line answer `verify` already flattens. Pasting it into + // a note would be handing back someone else's error, not an answer. + let fx = with_project(); + let stderr = "ERROR: (gcloud.projects.get-iam-policy) There was a problem refreshing your current auth tokens: Reauthentication failed. cannot prompt during non-interactive execution.\nPlease run:\n\n $ gcloud auth login\n\nto obtain new credentials.\n"; + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "get-iam-policy", + false, + "", + stderr, + )); + + let report = probe_with(&fx, exec).permissions_in("proj-b").unwrap(); + let notes = report.notes.join("\n"); + assert_eq!(notes.lines().count(), 1, "{notes}"); + assert!(notes.contains("proj-b"), "{notes}"); + assert!(notes.contains("gcloud auth login b@example.com"), "{notes}"); + assert!(!notes.contains("Please run"), "{notes}"); + } + + #[test] + fn test_unparseable_iam_output_degrades_to_a_note() { + let fx = with_project(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "get-iam-policy", + true, + "Updated IAM policy, probably", + "", + )); + + let report = probe_with(&fx, exec).permissions_in("proj-b").unwrap(); + assert!(!report.supported); + assert!(report.scopes.is_empty()); + assert!(!report.notes.is_empty()); + } + + #[test] + fn test_a_configuration_with_no_project_asks_for_one_instead_of_guessing() { + let fx = Fixture::new(); + fx.config("work", "[core]\naccount = b@example.com\n") + .active("work") + .credentials_db(&["b@example.com"]); + let exec = std::sync::Arc::new(crate::util::FakeExec::new()); + + let report = probe_with(&fx, exec.clone()).permissions().unwrap(); + assert!(!report.supported); + assert!( + report.notes.iter().any(|n| n.contains("pick a project")), + "{:?}", + report.notes + ); + // gcloud is available, so there is nothing to paste — the picker is it. + assert_eq!(report.hint, None); + assert!(exec.calls().is_empty(), "nothing to read a policy for"); + } + #[test] fn test_switch_to_unknown_profile_lists_the_real_ones() { let fx = Fixture::new(); diff --git a/crates/patchbay-core/src/probes/gh.rs b/crates/patchbay-core/src/probes/gh.rs index 763bad4..829d264 100644 --- a/crates/patchbay-core/src/probes/gh.rs +++ b/crates/patchbay-core/src/probes/gh.rs @@ -261,6 +261,7 @@ impl Probe for GhProbe { out.message() )], hint: Some("gh auth login".to_string()), + scope: None, }); } @@ -282,6 +283,7 @@ impl Probe for GhProbe { hint: Some( "add a missing scope with `gh auth refresh -s ` (e.g. `gh auth refresh -s read:project`)".to_string(), ), + scope: None, }) } } diff --git a/crates/patchbay-core/src/probes/neon.rs b/crates/patchbay-core/src/probes/neon.rs index c1922e5..4645bd6 100644 --- a/crates/patchbay-core/src/probes/neon.rs +++ b/crates/patchbay-core/src/probes/neon.rs @@ -194,6 +194,7 @@ impl Probe for NeonProbe { .to_string(), ], hint: Some("neon auth".to_string()), + scope: None, }) } } diff --git a/crates/patchbay-core/src/probes/wrangler.rs b/crates/patchbay-core/src/probes/wrangler.rs index 672bea4..fb771d1 100644 --- a/crates/patchbay-core/src/probes/wrangler.rs +++ b/crates/patchbay-core/src/probes/wrangler.rs @@ -191,6 +191,7 @@ impl Probe for WranglerProbe { "scopes are read from the local OAuth grant; account-level API token permissions are not visible here".to_string(), ], hint: Some("re-run `wrangler login` to request a different scope set".to_string()), + scope: None, }) } } diff --git a/crates/patchbay-core/src/registry.rs b/crates/patchbay-core/src/registry.rs index 137f79d..d0070cc 100644 --- a/crates/patchbay-core/src/registry.rs +++ b/crates/patchbay-core/src/registry.rs @@ -11,7 +11,9 @@ use crate::keystore::SecurityCliKeystore; use crate::paths::Paths; use crate::probe::Probe; use crate::probes; -use crate::types::{KeyRef, PermissionsReport, SwitchOutcome, ToolStatus, VerifyOutcome}; +use crate::types::{ + KeyRef, PermissionScope, PermissionsReport, SwitchOutcome, ToolStatus, VerifyOutcome, +}; use crate::versions::{self, CheckOptions, CheckReport, VersionCache}; pub struct Registry { @@ -272,6 +274,26 @@ impl Registry { pub fn permissions(&self, tool: &str) -> anyhow::Result { self.require(tool)?.permissions() } + + /// The scopes this tool's permissions can be read against. Empty for tools + /// whose credential carries one answer everywhere. + pub fn permission_scopes(&self, tool: &str) -> anyhow::Result> { + self.require(tool)?.permission_scopes() + } + + /// Permissions within one scope. `None` reads whatever the tool treats as + /// the default — the same shape as [`Registry::verify_profile`]. + pub fn permissions_in( + &self, + tool: &str, + scope_id: Option<&str>, + ) -> anyhow::Result { + let probe = self.require(tool)?; + match scope_id { + Some(id) => probe.permissions_in(id), + None => probe.permissions(), + } + } } /// The vault, grouped by tool, ready to be stamped onto statuses. diff --git a/crates/patchbay-core/src/types.rs b/crates/patchbay-core/src/types.rs index 62570c2..34fd64b 100644 --- a/crates/patchbay-core/src/types.rs +++ b/crates/patchbay-core/src/types.rs @@ -358,6 +358,24 @@ pub enum VerifyOutcome { }, } +/// One thing a tool's permissions can be read *against*. +/// +/// Some tools have a single answer to "what may this credential do" — a gh +/// token carries its scopes wherever it goes. Others do not: a Google account's +/// IAM roles exist per project, so "what may I do" is only a question once a +/// project is named. This is that name, resolved by the probe rather than typed +/// by the human. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PermissionScope { + /// What [`crate::Probe::permissions_in`] takes, e.g. a GCP project id. + pub id: String, + /// How to show it — a display name where the tool has one, else the id. + pub label: String, + /// The scope the tool's current configuration already points at, so the + /// picker can open on the answer the user most likely wants. + pub active: bool, +} + /// What the active credential of a tool is allowed to do. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PermissionsReport { @@ -372,6 +390,12 @@ pub struct PermissionsReport { pub notes: Vec, /// How to change what is granted. pub hint: Option, + /// Which [`PermissionScope`] this report is about, when the tool has any. + /// `None` means the tool answers once for the whole credential — the + /// field is omitted from JSON entirely in that case, so consumers written + /// against the unscoped shape keep parsing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, } impl PermissionsReport { @@ -383,6 +407,7 @@ impl PermissionsReport { scopes: Vec::new(), notes: vec![reason.to_string()], hint: hint.map(|h| h.to_string()), + scope: None, } } } diff --git a/crates/patchbay-mcp/smoke.sh b/crates/patchbay-mcp/smoke.sh index 803699d..ab6909e 100755 --- a/crates/patchbay-mcp/smoke.sh +++ b/crates/patchbay-mcp/smoke.sh @@ -113,6 +113,7 @@ tl = msgs.get(2) need(tl and "result" in tl, "no tools/list result") names = {t["name"] for t in tl["result"]["tools"]} expected = {"list_connections", "get_status", "switch_profile", "verify", "get_permissions", + "list_permission_scopes", "store_key", "list_keys", "get_key", "remove_key", "verify_key", "list_mcp_clients", "add_mcp_server", "copy_mcp_server", "remove_mcp_server", "check_updates", "plan_setup", "mark_setup_done", diff --git a/crates/patchbay-mcp/src/server.rs b/crates/patchbay-mcp/src/server.rs index a19feaf..6c8778a 100644 --- a/crates/patchbay-mcp/src/server.rs +++ b/crates/patchbay-mcp/src/server.rs @@ -148,6 +148,22 @@ pub struct VerifyParams { pub profile_id: Option, } +/// `{ "tool": "gcloud", "scope": "my-project" }` — one tool, optionally one +/// scope of it. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PermissionsParams { + /// Tool key, e.g. "gcloud". An unknown key returns an error listing every + /// valid key. + pub tool: String, + /// Optional: the scope to read permissions within, as listed by + /// `list_permission_scopes`. Required in practice for tools that grant per + /// resource rather than per credential — GCP IAM roles exist on a project, + /// so "what may this account do" has no answer until one is named. + /// Omitting it reads whatever the tool treats as the default (for gcloud, + /// the active configuration's project). + pub scope: Option, +} + /// `{ "tool": "gcloud" }` — identifies one supported CLI. #[derive(Debug, Deserialize, JsonSchema)] pub struct ToolParams { @@ -431,22 +447,56 @@ Call this when the user asks about permissions, or when an operation failed with looks like a scope or role problem and knowing the granted scopes changes your advice. It is not \ part of a routine status check: use list_connections for that. -Returns a PermissionsReport: { tool, supported, subject, scopes, notes, hint }. +SCOPED MODE. Some tools grant per resource, not per credential: a Google account has no roles of \ +its own, only roles on a project. For those, pass `scope` — a project id from \ +list_permission_scopes — and the answer is about THAT resource. Omitting it reads the tool's \ +default (for gcloud, the active configuration's `core/project`), so a report about one project \ +says nothing about another: re-read with the right scope rather than generalising. Tools whose \ +credential carries one answer everywhere list no scopes and ignore the field. + +Returns a PermissionsReport: { tool, supported, subject, scopes, notes, hint, scope }. `scope` is \ +present only when the report is about one — always name it when relaying the result. -`supported: false` is a normal answer, not a failure: patchbay cannot enumerate permissions for \ -this tool yet, `scopes` will be empty, and `hint` tells the human where to look or what to run. \ -Relay the hint; do not retry.")] +`supported: false` is a normal answer, not a failure: it means patchbay cannot enumerate \ +permissions for this tool yet, or the login was refused the policy of that one resource (which is \ +itself a permissions fact worth relaying). `scopes` will be empty, and `hint` — where there is \ +one — tells the human where to look or what to run. Relay the notes and the hint; do not retry.")] async fn get_permissions( &self, - Parameters(ToolParams { tool }): Parameters, + Parameters(PermissionsParams { tool, scope }): Parameters, ) -> Result { let registry = self.registry.clone(); - match offload(move || registry.permissions(&tool)).await? { + match offload(move || registry.permissions_in(&tool, scope.as_deref())).await? { Ok(report) => Ok(json_ok(encode(&report)?)), Err(err) => Ok(tool_error(err)), } } + #[tool(description = "\ +TIER 2, EXPENSIVE. List the scopes a tool's permissions can be read against, so get_permissions \ +can be given one. Executes the tool's own CLI (for gcloud, `gcloud projects list`) and may hit \ +the network — call it when you are about to ask about permissions, not as a warm-up. + +Returns an array of { id, label, active }. `id` is what get_permissions takes; `label` is the \ +human name; `active` marks the scope the tool's current configuration already points at, which \ +is also what get_permissions reads when no scope is given. + +An EMPTY array is a normal answer with a specific meaning: this tool's credential carries the \ +same permissions everywhere, so there is nothing to choose — call get_permissions with no scope. \ +It can also mean the login cannot enumerate scopes at all; get_permissions will say so. Never \ +invent an id: if the resource the user means is not listed, pass its id only if the user gave \ +it, otherwise say it was not found.")] + async fn list_permission_scopes( + &self, + Parameters(ToolParams { tool }): Parameters, + ) -> Result { + let registry = self.registry.clone(); + match offload(move || registry.permission_scopes(&tool)).await? { + Ok(scopes) => Ok(json_ok(encode(&scopes)?)), + Err(err) => Ok(tool_error(err)), + } + } + #[tool(description = "\ TIER 2, EXPENSIVE. Find out which CLIs on this machine are out of date, and how to update each \ one. This EXECUTES every tool's version command and makes network calls (Homebrew, the npm \