From 12189e5ddd8ca06869cbecb046e9b18a76288543 Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 1 Jul 2026 17:14:07 +0900 Subject: [PATCH] Add permissions slash command --- .../application/composer/slash-commands.ts | 7 +++ .../application/conversation/composition.ts | 2 + .../conversation/slash-command-execution.ts | 4 ++ .../chat/host/bundles/runtime-bundle.ts | 1 + src/features/chat/host/bundles/turn-bundle.ts | 1 + .../chat/panel/runtime-status-projection.ts | 14 ++++++ .../slash-command-execution.test.ts | 24 ++++++++++ .../slash-command-executor.test.ts | 1 + tests/features/chat/host/turn-bundle.test.ts | 1 + .../panel/runtime-status-projection.test.ts | 47 +++++++++++++++++++ 10 files changed, 102 insertions(+) diff --git a/src/features/chat/application/composer/slash-commands.ts b/src/features/chat/application/composer/slash-commands.ts index 4e17811a..10b3e77d 100644 --- a/src/features/chat/application/composer/slash-commands.ts +++ b/src/features/chat/application/composer/slash-commands.ts @@ -129,6 +129,13 @@ export const SLASH_COMMANDS = [ surface: "diagnostic", detail: "Show current thread, context, and usage limits.", }, + { + command: "/permissions", + usage: "/permissions", + argsKind: "none", + surface: "diagnostic", + detail: "Show current permissions and approval settings.", + }, { command: "/doctor", usage: "/doctor", diff --git a/src/features/chat/application/conversation/composition.ts b/src/features/chat/application/conversation/composition.ts index 0290c727..054047ec 100644 --- a/src/features/chat/application/conversation/composition.ts +++ b/src/features/chat/application/conversation/composition.ts @@ -26,6 +26,7 @@ export interface ConversationTurnActionsContext { }; runtime: { connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; + permissionDetails: () => MessageStreamNoticeSection[]; modelStatusLines: () => string[]; effortStatusLines: () => string[]; statusSummaryLines: () => string[]; @@ -105,6 +106,7 @@ export function createConversationTurnActions( addStructuredSystemMessage: status.addStructuredSystemMessage, setStatus: status.set, statusSummaryLines: runtime.statusSummaryLines, + permissionDetails: runtime.permissionDetails, connectionDiagnosticDetails: runtime.connectionDiagnosticDetails, toolInventoryDetails: runtime.toolInventoryDetails, modelStatusLines: runtime.modelStatusLines, diff --git a/src/features/chat/application/conversation/slash-command-execution.ts b/src/features/chat/application/conversation/slash-command-execution.ts index b4e1e8db..7c370416 100644 --- a/src/features/chat/application/conversation/slash-command-execution.ts +++ b/src/features/chat/application/conversation/slash-command-execution.ts @@ -52,6 +52,7 @@ export interface SlashCommandExecutionPorts { clear: GoalActions["clear"]; }; statusSummaryLines: () => string[]; + permissionDetails: () => MessageStreamNoticeSection[]; connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; toolInventoryDetails: () => MessageStreamNoticeSection[] | Promise; modelStatusLines: () => string[]; @@ -183,6 +184,9 @@ export async function executeSlashCommand( case "status": context.addStructuredSystemMessage("Thread status", detailsFromLines(context.statusSummaryLines())); return; + case "permissions": + context.addStructuredSystemMessage("Permissions & Approvals", context.permissionDetails()); + return; case "doctor": context.addStructuredSystemMessage("Connection diagnostics", context.connectionDiagnosticDetails()); return; diff --git a/src/features/chat/host/bundles/runtime-bundle.ts b/src/features/chat/host/bundles/runtime-bundle.ts index cf25f0e0..202f4eab 100644 --- a/src/features/chat/host/bundles/runtime-bundle.ts +++ b/src/features/chat/host/bundles/runtime-bundle.ts @@ -45,6 +45,7 @@ export function createRuntimeBundle( state: () => host.stateStore.getState(), connected: () => input.connection.isConnected(), configuredCommand: () => host.environment.plugin.settingsRef.settings.codexPath(), + vaultPath: () => host.environment.plugin.settingsRef.vaultPath, nowMs: () => Date.now(), }), }; diff --git a/src/features/chat/host/bundles/turn-bundle.ts b/src/features/chat/host/bundles/turn-bundle.ts index 2db367f9..e82d8d90 100644 --- a/src/features/chat/host/bundles/turn-bundle.ts +++ b/src/features/chat/host/bundles/turn-bundle.ts @@ -105,6 +105,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn modelStatusLines: runtimeProjection.modelStatusLines, effortStatusLines: runtimeProjection.effortStatusLines, statusSummaryLines: runtimeProjection.statusSummaryLines, + permissionDetails: runtimeProjection.permissionDetails, toolInventoryDetails: async () => { if (host.stateStore.getState().connection.serverDiagnostics.toolInventory) { return runtimeProjection.toolInventoryDetails(); diff --git a/src/features/chat/panel/runtime-status-projection.ts b/src/features/chat/panel/runtime-status-projection.ts index 54f1efc2..af8bd35d 100644 --- a/src/features/chat/panel/runtime-status-projection.ts +++ b/src/features/chat/panel/runtime-status-projection.ts @@ -4,6 +4,7 @@ import type { MessageStreamNoticeSection } from "../domain/message-stream/items" import { collaborationModeLabel as formatCollaborationModeLabel } from "../domain/runtime/labels"; import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; import { appServerDiagnosticSections } from "../presentation/runtime/diagnostic-sections"; +import { runtimePermissionDetails } from "../presentation/runtime/permission-sections"; import { effortStatusLines as buildEffortStatusLines, modelStatusLines as buildModelStatusLines, @@ -13,6 +14,7 @@ import { toolInventoryDiagnosticSections } from "../presentation/runtime/tool-in export interface ChatPanelRuntimeProjection { connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; + permissionDetails: () => MessageStreamNoticeSection[]; modelStatusLines: () => string[]; effortStatusLines: () => string[]; statusSummaryLines: () => string[]; @@ -23,12 +25,14 @@ interface ChatPanelRuntimeProjectionInput { state: () => ChatState; connected: () => boolean; configuredCommand: () => string; + vaultPath: () => string; nowMs: () => number; } export function createChatPanelRuntimeProjection(input: ChatPanelRuntimeProjectionInput): ChatPanelRuntimeProjection { return { connectionDiagnosticDetails: () => connectionDiagnosticDetails(input), + permissionDetails: () => permissionDetails(input), modelStatusLines: () => modelStatusLines(input), effortStatusLines: () => effortStatusLines(input), statusSummaryLines: () => statusSummaryLines(input), @@ -79,6 +83,16 @@ function toolInventoryDetails(input: ChatPanelRuntimeProjectionInput): MessageSt return noticeSectionsFromDiagnostics(toolInventoryDiagnosticSections(input.state().connection.serverDiagnostics)); } +function permissionDetails(input: ChatPanelRuntimeProjectionInput): MessageStreamNoticeSection[] { + const state = input.state(); + return noticeSectionsFromDiagnostics( + runtimePermissionDetails({ + snapshot: runtimeSnapshot(state), + vaultPath: input.vaultPath(), + }).sections, + ); +} + function noticeSectionsFromDiagnostics( sections: readonly { title: string; rows: readonly { label: string; value: string }[] }[], ): MessageStreamNoticeSection[] { diff --git a/tests/features/chat/application/conversation/slash-command-execution.test.ts b/tests/features/chat/application/conversation/slash-command-execution.test.ts index 8898cf9d..406bfc96 100644 --- a/tests/features/chat/application/conversation/slash-command-execution.test.ts +++ b/tests/features/chat/application/conversation/slash-command-execution.test.ts @@ -47,6 +47,7 @@ function context(overrides: Partial = {}): SlashCo clear: vi.fn().mockResolvedValue(true), }, statusSummaryLines: () => ["status"], + permissionDetails: () => [{ title: "Permissions", auditFacts: [{ key: "Profile", value: "read-only" }] }], connectionDiagnosticDetails: () => [{ title: "Process", rows: [{ key: "connection", value: "connected" }] }], toolInventoryDetails: vi.fn(() => [{ title: "Tool providers", auditFacts: [{ key: "codex_apps", value: "github" }] }]), modelStatusLines: () => ["model"], @@ -559,6 +560,28 @@ describe("slash commands", () => { expect(ctx.addSystemMessage).toHaveBeenCalledWith("/status does not take arguments. Usage: /status"); }); + it("shows permissions and approvals as shared structured sections", async () => { + const details = [ + { title: "Permissions", auditFacts: [{ key: "Profile", value: "workspace-write" }] }, + { title: "Approvals", auditFacts: [{ key: "Reviewer", value: "auto_review" }] }, + ]; + const ctx = context({ permissionDetails: () => details }); + + await executeSlashCommand("permissions", "", ctx); + + expect(ctx.addSystemMessage).not.toHaveBeenCalled(); + expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Permissions & Approvals", details); + }); + + it("rejects /permissions arguments", async () => { + const ctx = context(); + + await executeSlashCommand("permissions", "anything", ctx); + + expect(ctx.addStructuredSystemMessage).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("/permissions does not take arguments. Usage: /permissions"); + }); + it("shows doctor diagnostics as shared structured sections", async () => { const details = [{ title: "Process", rows: [{ key: "connection", value: "connected" }] }]; const ctx = context({ connectionDiagnosticDetails: () => details }); @@ -596,6 +619,7 @@ describe("slash commands", () => { it("documents status and doctor as separate commands", () => { expect(slashCommandHelpValue("/status")).toBe("Show current thread, context, and usage limits."); + expect(slashCommandHelpValue("/permissions")).toBe("Show current permissions and approval settings."); expect(slashCommandHelpValue("/doctor")).toBe("Show Codex CLI and Codex App Server diagnostics."); }); diff --git a/tests/features/chat/application/conversation/slash-command-executor.test.ts b/tests/features/chat/application/conversation/slash-command-executor.test.ts index a1c414c5..d3dc5e18 100644 --- a/tests/features/chat/application/conversation/slash-command-executor.test.ts +++ b/tests/features/chat/application/conversation/slash-command-executor.test.ts @@ -62,6 +62,7 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) { addStructuredSystemMessage: vi.fn(), setStatus: vi.fn(), statusSummaryLines: () => [], + permissionDetails: () => [], connectionDiagnosticDetails: () => [], toolInventoryDetails: vi.fn(() => []), modelStatusLines: () => [], diff --git a/tests/features/chat/host/turn-bundle.test.ts b/tests/features/chat/host/turn-bundle.test.ts index 41c34a61..6b2aee37 100644 --- a/tests/features/chat/host/turn-bundle.test.ts +++ b/tests/features/chat/host/turn-bundle.test.ts @@ -45,6 +45,7 @@ function turnBundleFixture(options: { stateStore?: ReturnType []), effortStatusLines: vi.fn(() => []), statusSummaryLines: vi.fn(() => []), + permissionDetails: vi.fn(() => []), toolInventoryDetails: vi.fn(() => [{ title: "Tool providers", auditFacts: [{ key: "codex_apps", value: "github, gmail" }] }]), }; const refreshDiagnostics = vi.fn().mockResolvedValue(undefined); diff --git a/tests/features/chat/panel/runtime-status-projection.test.ts b/tests/features/chat/panel/runtime-status-projection.test.ts index 94b4ffa3..876d6da9 100644 --- a/tests/features/chat/panel/runtime-status-projection.test.ts +++ b/tests/features/chat/panel/runtime-status-projection.test.ts @@ -25,6 +25,7 @@ describe("createChatPanelRuntimeProjection", () => { state: () => state, connected: () => true, configuredCommand: () => "codex", + vaultPath: () => "/vault", nowMs: () => 0, }); @@ -33,6 +34,52 @@ describe("createChatPanelRuntimeProjection", () => { expect(projection.modelStatusLines()).toContain("Mode: Default"); expect(projection.effortStatusLines()).toContain("Supported: high"); }); + + it("builds slash-command permission details from chat state", () => { + const state = chatStateWith(chatStateFixture(), { + activeThread: { id: "thread-1" }, + runtime: { + active: { + activePermissionProfile: { id: "workspace-write", extends: null }, + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: ["/vault/Notes"], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }, + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + }, + }, + }); + const projection = createChatPanelRuntimeProjection({ + state: () => state, + connected: () => true, + configuredCommand: () => "codex", + vaultPath: () => "/vault", + nowMs: () => 0, + }); + + expect(projection.permissionDetails()).toEqual([ + { + title: "Permissions", + auditFacts: [ + { key: "Profile", value: "workspace-write" }, + { key: "Sandbox", value: "workspace-write" }, + { key: "Network", value: "blocked" }, + { key: "Extra writable roots", value: "Vault/Notes" }, + ], + }, + { + title: "Approvals", + auditFacts: [ + { key: "Approval policy", value: "on-request" }, + { key: "Reviewer", value: "auto_review" }, + ], + }, + ]); + }); }); function runtimeConfigFixture(config: Record): RuntimeConfigSnapshot {