Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 27 additions & 2 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -76,6 +77,28 @@ async fn permissions(tool: String) -> CmdResult<PermissionsReport> {
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<Vec<PermissionScope>> {
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<PermissionsReport> {
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.
Expand Down Expand Up @@ -378,6 +401,8 @@ pub fn run() {
verify,
verify_profile,
permissions,
permission_scopes,
permissions_in,
keys_list,
key_add,
key_remove,
Expand Down
43 changes: 38 additions & 5 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -29,10 +43,13 @@ export default function App() {
const [detail, setDetail] = useState<{ tool: string; permissions: boolean } | null>(null);
const [verdicts, setVerdicts] = useState<Record<string, VerifyOutcome | null>>({});
const [perms, setPerms] = useState<Record<string, PermissionsReport | null>>({});
const [permScopes, setPermScopes] = useState<Record<string, PermissionScope[] | null>>({});
const [switching, setSwitching] = useState<string | null>(null);
const [switchNotes, setSwitchNotes] = useState<Record<string, SwitchNote>>({});

const searchRef = useRef<HTMLInputElement>(null);
/** Tools whose scope list has been asked for. See `loadPerms`. */
const scopesAsked = useRef<Set<string>>(new Set());

const refresh = useCallback(async () => {
setRefreshing(true);
Expand Down Expand Up @@ -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) => ({
Expand All @@ -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));
Expand All @@ -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]);
Expand Down
19 changes: 19 additions & 0 deletions app/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
McpSpec,
McpWriteReport,
NewKeyInput,
PermissionScope,
PermissionsReport,
RemovedKey,
SwitchOutcome,
Expand All @@ -30,6 +31,24 @@ export const verifyProfile = (tool: string, profile: string) =>

export const permissions = (tool: string) => invoke<PermissionsReport>("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<PermissionScope[]>("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<PermissionsReport>("permissions_in", { tool, scope });

/** Vault metadata. There is no command that returns a value — see `keyAdd`. */
export const keysList = () => invoke<KeyRow[]>("keys_list");

Expand Down
Loading
Loading