diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 8da6ff55937..a5ccf1157ec 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -9,6 +9,7 @@ import { ScrapCommand } from "./scrap" import { SkillCommand } from "./skill" import { SnapshotCommand } from "./snapshot" import { AgentCommand } from "./agent" +import { PermissionCommand } from "./permission" export const DebugCommand = cmd({ command: "debug", @@ -23,6 +24,7 @@ export const DebugCommand = cmd({ .command(SkillCommand) .command(SnapshotCommand) .command(AgentCommand) + .command(PermissionCommand) .command(PathsCommand) .command({ command: "wait", diff --git a/packages/opencode/src/cli/cmd/debug/permission.ts b/packages/opencode/src/cli/cmd/debug/permission.ts new file mode 100644 index 00000000000..2217488fce9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/debug/permission.ts @@ -0,0 +1,68 @@ +import { bootstrap } from "../../bootstrap" +import { cmd } from "../cmd" +import { PermissionNext } from "@/permission/next" +import { Agent } from "@/agent/agent" + +export const PermissionCommand = cmd({ + command: "permission [agent]", + describe: "show all permissions with sources", + builder: (yargs) => + yargs.positional("agent", { + describe: "agent name (default: build)", + type: "string", + default: "build", + }), + async handler(args) { + await bootstrap(process.cwd(), async () => { + const agent = args.agent as string + + console.log(`\n=== Permission Report for Agent: ${agent} ===\n`) + + const allPerms = await PermissionNext.all(agent) + + // Group by source + const bySource = { + default: [] as PermissionNext.RuleWithSource[], + global: [] as PermissionNext.RuleWithSource[], + project: [] as PermissionNext.RuleWithSource[], + session: [] as PermissionNext.RuleWithSource[], + } + + for (const rule of allPerms) { + bySource[rule.source].push(rule) + } + + // Print by source + for (const [source, rules] of Object.entries(bySource)) { + if (rules.length === 0) continue + + console.log(`\n[${source.toUpperCase()}] - ${rules.length} rules:`) + console.log("─".repeat(60)) + + // Group by permission type + const byPermission = new Map() + for (const rule of rules) { + if (!byPermission.has(rule.permission)) { + byPermission.set(rule.permission, []) + } + byPermission.get(rule.permission)!.push(rule) + } + + for (const [permission, perms] of byPermission) { + console.log(`\n ${permission}:`) + for (const perm of perms) { + const icon = perm.action === "allow" ? "✓" : perm.action === "deny" ? "✗" : "?" + const readonly = perm.readonly ? "[readonly]" : "[editable]" + console.log(` ${icon} ${perm.action.padEnd(5)} ${perm.pattern.padEnd(30)} ${readonly}`) + } + } + } + + console.log(`\n\nTotal rules: ${allPerms.length}`) + console.log(" Default: ", bySource.default.length) + console.log(" Global: ", bySource.global.length) + console.log(" Project: ", bySource.project.length) + console.log(" Session: ", bySource.session.length) + }) + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 1fea3f4b305..83412a61866 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -14,6 +14,7 @@ import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" +import { DialogPermission } from "@tui/component/dialog-permission" import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" @@ -426,6 +427,14 @@ function App() { }, category: "System", }, + { + title: "View permissions", + value: "permission.view", + onSelect: () => { + dialog.replace(() => ) + }, + category: "System", + }, { title: "Switch theme", value: "theme.switch", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-permission.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-permission.tsx new file mode 100644 index 00000000000..f2526694778 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-permission.tsx @@ -0,0 +1,866 @@ +import { createMemo, For, Show, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { useTheme } from "@tui/context/theme" +import { useDialog } from "@tui/ui/dialog" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { TextAttributes, TextareaRenderable } from "@opentui/core" +import { useSDK } from "@tui/context/sdk" +import { useLocal } from "@tui/context/local" +import type { PermissionNext } from "@/permission/next" + +type TabType = "file" | "execute" | "network" | "external" + +const TABS: { id: TabType; label: string; permissions: string[] }[] = [ + { id: "file", label: "File", permissions: ["read", "edit", "glob", "grep", "list"] }, + { id: "execute", label: "Execute", permissions: ["bash", "task"] }, + { id: "network", label: "Network", permissions: ["webfetch", "websearch", "codesearch"] }, + { id: "external", label: "External", permissions: ["external_directory"] }, +] + +const ACTION_ICONS: Record = { + allow: "✓", + deny: "✗", + ask: "?", +} + +export function DialogPermission() { + const { theme } = useTheme() + const dialog = useDialog() + const sdk = useSDK() + const local = useLocal() + const [store, setStore] = createStore({ + tab: 0 as number, + loading: true, + rules: [] as PermissionNext.RuleWithSource[], + selected: 0 as number, + editing: null as null | { + rule: PermissionNext.RuleWithSource | null + pattern: string + action: PermissionNext.Action + permission?: string + source: "session" | "project" | "global" + }, + confirmingDelete: false, + confirmingSave: false, + }) + + let createInput: TextareaRenderable | undefined + let editInput: TextareaRenderable | undefined + + onMount(() => { + dialog.setSize("large") + }) + + // Fetch permissions on mount + onMount(async () => { + try { + const agent = local.agent.current().name + const result = await sdk.client.permission.all({ agent }) + // Filter out internal permissions that aren't interesting to users + const hiddenPermissions = new Set([ + "question", + "doom_loop", + "plan_enter", + "plan_exit", + "todowrite", + "todoread", + "lsp", + ]) + const filtered = Array.isArray(result.data) + ? result.data.filter((rule) => !hiddenPermissions.has(rule.permission)) + : [] + setStore("rules", filtered) + } catch (error) { + setStore("rules", []) + } finally { + setStore("loading", false) + } + }) + + const currentTab = createMemo(() => TABS[store.tab]) + + // Group rules by permission type for current tab + const groupedRules = createMemo(() => { + const permissionTypes = currentTab().permissions + const rulesByPermission = new Map() + + for (const permission of permissionTypes) { + rulesByPermission.set(permission, []) + } + + for (const rule of store.rules) { + if (permissionTypes.includes(rule.permission)) { + rulesByPermission.get(rule.permission)!.push(rule) + } + } + + return rulesByPermission + }) + + // Flatten rules for navigation + const flatRules = createMemo(() => { + const permissionTypes = currentTab().permissions + return store.rules.filter((rule) => permissionTypes.includes(rule.permission)) + }) + + function move(delta: number) { + const max = flatRules().length - 1 + if (max < 0) return + let next = store.selected + delta + if (next < 0) next = 0 + if (next > max) next = max + setStore("selected", next) + setStore("confirmingDelete", false) + } + + async function deleteSelected() { + const rule = flatRules()[store.selected] + // Only allow deleting session, project, and global permissions + if (!rule || (rule.source !== "session" && rule.source !== "project" && rule.source !== "global")) return + + // For project and global permissions, require double confirmation + if ((rule.source === "project" || rule.source === "global") && !store.confirmingDelete) { + setStore("confirmingDelete", true) + return + } + + setStore("confirmingDelete", false) + + try { + if (rule.source === "project") { + // Delete from project config file + await sdk.client.permission.deleteProject({ permission: rule.permission, pattern: rule.pattern }) + } else if (rule.source === "global") { + // Delete from global config file + await sdk.client.permission.deleteGlobal({ permission: rule.permission, pattern: rule.pattern }) + } else { + // Delete from session (in-memory) + await sdk.client.permission.delete({ permissionRule: rule }) + } + + // Remove from local state using deep comparison + setStore( + "rules", + store.rules.filter( + (r) => !(r.permission === rule.permission && r.pattern === rule.pattern && r.action === rule.action), + ), + ) + // Adjust selection if needed + const newLength = flatRules().length + if (store.selected >= newLength && newLength > 0) { + setStore("selected", newLength - 1) + } else if (newLength === 0) { + setStore("selected", 0) + } + } catch (error) { + // Silently handle error + } + } + + function startEditing() { + const rule = flatRules()[store.selected] + // Only allow editing session, project, and global permissions (not default) + if (!rule || (rule.source !== "session" && rule.source !== "project" && rule.source !== "global")) return + // For binary permissions at project/global level, force pattern to "*" + const pattern = + (rule.source === "project" || rule.source === "global") && isBinaryPermission(rule.permission) + ? "*" + : rule.pattern + setStore("editing", { rule, pattern, action: rule.action, source: rule.source }) + } + + function startCreating() { + // Get the first permission type from the current tab + const permissionType = currentTab().permissions[0] + if (!permissionType) return + // For binary permissions at project/global level, start with "*" pattern + const pattern = "" + setStore("editing", { rule: null, pattern, action: "allow", permission: permissionType, source: "session" }) + } + + function toggleSource() { + if (!store.editing || store.editing.rule) return // Only when creating new + const sources: ("session" | "project" | "global")[] = ["session", "project", "global"] + const currentIndex = sources.indexOf(store.editing.source) + const nextIndex = (currentIndex + 1) % sources.length + setStore("editing", "source", sources[nextIndex]) + } + + function cycleAction(direction: "forward" | "backward" = "forward") { + if (!store.editing) return + const actions: PermissionNext.Action[] = ["allow", "deny", "ask"] + const currentIndex = actions.indexOf(store.editing.action) + const delta = direction === "forward" ? 1 : -1 + const nextIndex = (currentIndex + delta + actions.length) % actions.length + setStore("editing", "action", actions[nextIndex]) + } + + function cyclePermission() { + if (!store.editing || store.editing.rule) return // Only when creating new + const permissions = currentTab().permissions + const currentIndex = permissions.indexOf(store.editing.permission || permissions[0]) + const nextIndex = (currentIndex + 1) % permissions.length + setStore("editing", "permission", permissions[nextIndex]) + } + + async function saveEdit() { + if (!store.editing) return + const oldRule = store.editing.rule + + // Creating new rule + if (!oldRule) { + const permissionType = store.editing.permission || currentTab().permissions[0] + if (!permissionType) return + // For binary permissions at project/global level, force pattern to "*" + const pattern = + (store.editing.source === "project" || store.editing.source === "global") && isBinaryPermission(permissionType) + ? "*" + : store.editing.pattern + const newRule: PermissionNext.Rule = { + permission: permissionType, + pattern, + action: store.editing.action, + } + + // Confirm project/global permission creation + if ((store.editing.source === "project" || store.editing.source === "global") && !store.confirmingSave) { + setStore("confirmingSave", true) + return + } + + setStore("confirmingSave", false) + + try { + if (store.editing.source === "project") { + await sdk.client.permission.updateProject({ permissionRule: newRule }) + const newRuleWithSource: PermissionNext.RuleWithSource = { + ...newRule, + source: "project", + readonly: true, + } + setStore("rules", [...store.rules, newRuleWithSource]) + } else if (store.editing.source === "global") { + await sdk.client.permission.updateGlobal({ permissionRule: newRule }) + const newRuleWithSource: PermissionNext.RuleWithSource = { + ...newRule, + source: "global", + readonly: true, + } + setStore("rules", [...store.rules, newRuleWithSource]) + } else { + await sdk.client.permission.add({ permissionRule: newRule }) + const newRuleWithSource: PermissionNext.RuleWithSource = { + ...newRule, + source: "session", + readonly: false, + } + setStore("rules", [...store.rules, newRuleWithSource]) + } + setStore("editing", null) + } catch (error) { + // Silently handle error + } + return + } + + // Confirm project/global permission update + if ((oldRule.source === "project" || oldRule.source === "global") && !store.confirmingSave) { + setStore("confirmingSave", true) + return + } + + setStore("confirmingSave", false) + + // Updating existing rule + // For binary permissions at project/global level, force pattern to "*" + const pattern = + (oldRule.source === "project" || oldRule.source === "global") && isBinaryPermission(oldRule.permission) + ? "*" + : store.editing.pattern + const newRule: PermissionNext.Rule = { + permission: oldRule.permission, + pattern, + action: store.editing.action, + } + try { + if (oldRule.source === "project") { + // Update project config file + // First delete the old pattern, then add the new one + if (oldRule.pattern !== store.editing.pattern) { + await sdk.client.permission.deleteProject({ permission: oldRule.permission, pattern: oldRule.pattern }) + } + await sdk.client.permission.updateProject({ permissionRule: newRule }) + // Update local state + setStore( + "rules", + store.rules.map((r) => + r.permission === oldRule.permission && r.pattern === oldRule.pattern && r.action === oldRule.action + ? { ...newRule, source: "project", readonly: true } + : r, + ), + ) + } else if (oldRule.source === "global") { + // Update global config file + // First delete the old pattern, then add the new one + if (oldRule.pattern !== store.editing.pattern) { + await sdk.client.permission.deleteGlobal({ permission: oldRule.permission, pattern: oldRule.pattern }) + } + await sdk.client.permission.updateGlobal({ permissionRule: newRule }) + // Update local state + setStore( + "rules", + store.rules.map((r) => + r.permission === oldRule.permission && r.pattern === oldRule.pattern && r.action === oldRule.action + ? { ...newRule, source: "global", readonly: true } + : r, + ), + ) + } else { + // Update session (in-memory) + await sdk.client.permission.update({ oldRule, newRule }) + // Update local state + setStore( + "rules", + store.rules.map((r) => + r.permission === oldRule.permission && r.pattern === oldRule.pattern && r.action === oldRule.action + ? { ...newRule, source: "session", readonly: false } + : r, + ), + ) + } + setStore("editing", null) + } catch (error) { + // Silently handle error + } + } + + function cancelEdit() { + if (store.confirmingSave) { + setStore("confirmingSave", false) + return + } + setStore("editing", null) + } + + function isBinaryPermission(permission: string): boolean { + // Binary permissions at project/global level can only have "*" pattern + return ["glob", "grep", "webfetch", "websearch", "codesearch", "task"].includes(permission) + } + + function placeholderForPermission(permission: string): string { + switch (permission) { + case "read": + case "edit": + case "list": + return "Pattern (e.g. *, *.env, src/**/*)" + case "glob": + case "grep": + return "Pattern (e.g. *, **/*.ts, src/**)" + case "bash": + return "Pattern (e.g. *, npm, git)" + case "task": + return "Pattern (e.g. *)" + case "webfetch": + case "websearch": + case "codesearch": + return "Pattern (e.g. *, https://*)" + case "external_directory": + return "Pattern (e.g. *, /home/*, /tmp/*)" + default: + return "Pattern (e.g. *)" + } + } + + function sourceLabel(source: PermissionNext.Source): string { + switch (source) { + case "default": + return "default" + case "global": + return "global" + case "project": + return "project" + case "session": + return "" + } + } + + function selectTab(index: number) { + setStore("tab", index) + setStore("confirmingDelete", false) + } + + const dimensions = useTerminalDimensions() + const height = createMemo(() => Math.floor(dimensions().height / 2) - 6) + + useKeyboard((evt) => { + // Editing mode + if (store.editing) { + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + saveEdit() + return + } + if (evt.name === "escape") { + evt.preventDefault() + evt.stopPropagation() + cancelEdit() + return + } + if (evt.name === "up") { + evt.preventDefault() + evt.stopPropagation() + cycleAction("backward") + return + } + if (evt.name === "down") { + evt.preventDefault() + evt.stopPropagation() + cycleAction("forward") + return + } + if (evt.name === "tab" && !store.editing.rule) { + evt.preventDefault() + evt.stopPropagation() + cyclePermission() + return + } + if (evt.ctrl && evt.name === "p" && !store.editing.rule) { + evt.preventDefault() + evt.stopPropagation() + toggleSource() + return + } + return + } + + // Navigation mode + if (evt.name === "left" || evt.name === "h") { + evt.preventDefault() + selectTab((store.tab - 1 + TABS.length) % TABS.length) + setStore("selected", 0) + } + if (evt.name === "right" || evt.name === "l") { + evt.preventDefault() + selectTab((store.tab + 1) % TABS.length) + setStore("selected", 0) + } + if (evt.name === "tab") { + evt.preventDefault() + if (evt.shift) { + selectTab((store.tab - 1 + TABS.length) % TABS.length) + } else { + selectTab((store.tab + 1) % TABS.length) + } + setStore("selected", 0) + } + if (evt.name === "up" || evt.name === "k") { + evt.preventDefault() + move(-1) + } + if (evt.name === "down" || evt.name === "j") { + evt.preventDefault() + move(1) + } + if (evt.name === "e") { + evt.preventDefault() + startEditing() + } + if (evt.name === "n" || evt.name === "a") { + evt.preventDefault() + startCreating() + } + if (evt.name === "d" || evt.name === "x" || evt.name === "delete") { + evt.preventDefault() + deleteSelected() + } + if (evt.name === "escape") { + evt.preventDefault() + dialog.clear() + } + }) + + return ( + + {/* Title */} + + + Permissions + + + + {/* Tabs */} + + + {(tab, index) => { + const isActive = () => index() === store.tab + return ( + selectTab(index())} + > + {tab.label} + + ) + }} + + + + {/* Content */} + Loading...}> + + + {/* Create mode - show at top */} + + + + ● New {store.editing!.source} permission for{" "} + {store.editing!.permission || currentTab().permissions[0]} + + + + {(editing) => { + const editActionColor = () => { + switch (editing().action) { + case "allow": + return theme.success + case "deny": + return theme.error + case "ask": + return theme.warning + } + } + const isBinary = () => + (editing().source === "project" || editing().source === "global") && + isBinaryPermission(editing().permission || currentTab().permissions[0]) + return ( + <> + {ACTION_ICONS[editing().action]} + {editing().action} + + * + + + ) + }} + + +