From 07c29add11921c1e7d3213a63bb0775fbf8c896c Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 1 Jul 2026 12:01:36 +0900 Subject: [PATCH] Show runtime permissions in status panel --- src/app-server/protocol/runtime-config.ts | 61 +++++ src/app-server/services/threads.ts | 10 +- src/domain/runtime/config.ts | 8 +- src/domain/runtime/permissions.ts | 74 ++++++ src/domain/runtime/thread-settings.ts | 3 + src/domain/threads/activation.ts | 3 +- .../chat/application/state/actions.ts | 26 +- .../chat/application/state/root-reducer.ts | 6 + .../chat/domain/runtime/resolution.ts | 92 ++++++- src/features/chat/domain/runtime/state.ts | 44 +++- .../domain/runtime/thread-settings-patch.ts | 18 +- .../chat/panel/surface/toolbar-projection.tsx | 7 + .../runtime/permission-sections.ts | 113 +++++++++ src/features/chat/ui/toolbar.tsx | 23 +- tests/app-server/thread-activation.test.ts | 3 + tests/app-server/thread-settings.test.ts | 1 + .../chat/app-server/actions/actions.test.ts | 2 +- .../chat/app-server/inbound/handler.test.ts | 9 + .../connection/reconnect-actions.test.ts | 3 + .../composer-submit-actions.test.ts | 3 + .../conversation/plan-implementation.test.ts | 3 + .../slash-command-executor.test.ts | 6 + .../turn-submission-actions.test.ts | 3 + .../runtime/settings-actions.test.ts | 5 +- .../chat/application/state/actions.test.ts | 36 +++ .../application/state/root-reducer.test.ts | 28 ++- .../active-thread-identity-sync.test.ts | 9 + .../application/threads/goal-actions.test.ts | 3 + .../threads/resume-actions.test.ts | 9 + .../threads/thread-management-actions.test.ts | 24 ++ .../threads/thread-navigation-actions.test.ts | 3 + .../chat/host/reconnect-action.test.ts | 3 + .../features/chat/host/session-graph.test.ts | 3 + .../surface/message-stream-presenter.test.ts | 3 + .../chat/panel/surface/projections.test.ts | 43 ++++ tests/features/chat/ui/toolbar.test.ts | 46 +++- tests/runtime/runtime-settings.test.ts | 229 +++++++++++++++++- tests/runtime/service-tier-state.test.ts | 9 +- 38 files changed, 920 insertions(+), 54 deletions(-) create mode 100644 src/domain/runtime/permissions.ts create mode 100644 src/features/chat/presentation/runtime/permission-sections.ts diff --git a/src/app-server/protocol/runtime-config.ts b/src/app-server/protocol/runtime-config.ts index 893e90b9..2c422fe0 100644 --- a/src/app-server/protocol/runtime-config.ts +++ b/src/app-server/protocol/runtime-config.ts @@ -1,5 +1,6 @@ import { normalizeReasoningEffort } from "../../domain/catalog/metadata"; import type { ReasoningSummary, RuntimeConfigSnapshot, Verbosity } from "../../domain/runtime/config"; +import type { RuntimeApprovalPolicy, RuntimePermissionState, RuntimeSandboxPolicy } from "../../domain/runtime/permissions"; import { approvalsReviewerOrNull, parseServiceTier } from "../../domain/runtime/policy"; interface ConfigLayerRecord { @@ -28,11 +29,59 @@ export function runtimeConfigSnapshotFromAppServerConfig(response: ConfigReadRes verbosity: verbosityOrNull(config["model_verbosity"]), serviceTier: parseServiceTier(config["service_tier"]), approvalsReviewer: approvalsReviewerOrNull(config["approvals_reviewer"]), + startupPermissions: startupPermissionsFromConfig(config), modelContextWindow: numberOrNull(config["model_context_window"]), autoCompactTokenLimit: numberOrNull(config["model_auto_compact_token_limit"]), }; } +function startupPermissionsFromConfig(config: Record): RuntimePermissionState { + return { + approvalPolicy: approvalPolicyOrNull(config["approval_policy"]), + sandboxPolicy: sandboxPolicyFromConfig(config), + activePermissionProfile: permissionProfileFromConfig(config), + }; +} + +function permissionProfileFromConfig(config: Record): RuntimePermissionState["activePermissionProfile"] { + if (typeof config["sandbox_mode"] === "string") return null; + const profile = nonEmptyStringOrNull(config["default_permissions"]); + return profile ? { id: profile, extends: null } : null; +} + +function sandboxPolicyFromConfig(config: Record): RuntimeSandboxPolicy | null { + const mode = config["sandbox_mode"]; + if (mode === "danger-full-access") return { type: "dangerFullAccess" }; + if (mode === "read-only") return { type: "readOnly", networkAccess: false }; + if (mode !== "workspace-write") return null; + + const workspaceWrite = recordOrNull(config["sandbox_workspace_write"]); + return { + type: "workspaceWrite", + writableRoots: stringArrayOrEmpty(workspaceWrite?.["writable_roots"]), + networkAccess: booleanOrFalse(workspaceWrite?.["network_access"]), + excludeTmpdirEnvVar: booleanOrFalse(workspaceWrite?.["exclude_tmpdir_env_var"]), + excludeSlashTmp: booleanOrFalse(workspaceWrite?.["exclude_slash_tmp"]), + }; +} + +function approvalPolicyOrNull(value: unknown): RuntimeApprovalPolicy | null { + if (value === "untrusted" || value === "on-failure" || value === "on-request" || value === "never") return value; + if (!value || typeof value !== "object") return null; + const granular = (value as Record)["granular"]; + if (!granular || typeof granular !== "object") return null; + const granularRecord = granular as Record; + return { + granular: { + sandbox_approval: booleanOrFalse(granularRecord["sandbox_approval"]), + rules: booleanOrFalse(granularRecord["rules"]), + skill_approval: booleanOrFalse(granularRecord["skill_approval"]), + request_permissions: booleanOrFalse(granularRecord["request_permissions"]), + mcp_elicitations: booleanOrFalse(granularRecord["mcp_elicitations"]), + }, + }; +} + function selectedConfigProfile(layers: ConfigReadResult["layers"]): string | null { let selected: string | null = null; if (!layers) return null; @@ -55,6 +104,18 @@ function numberOrNull(value: unknown): number | null { return null; } +function recordOrNull(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function stringArrayOrEmpty(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length > 0) : []; +} + +function booleanOrFalse(value: unknown): boolean { + return value === true; +} + function reasoningSummaryOrNull(value: unknown): ReasoningSummary | null { return value === "auto" || value === "concise" || value === "detailed" || value === "none" ? value : null; } diff --git a/src/app-server/services/threads.ts b/src/app-server/services/threads.ts index 4e32950a..33be5c85 100644 --- a/src/app-server/services/threads.ts +++ b/src/app-server/services/threads.ts @@ -1,4 +1,5 @@ import { normalizeReasoningEffort } from "../../domain/catalog/metadata"; +import { type RuntimePermissionState, runtimePermissionStateOrDefault } from "../../domain/runtime/permissions"; import type { ApprovalsReviewer, ServiceTier } from "../../domain/runtime/policy"; import { parseServiceTier } from "../../domain/runtime/policy"; import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../domain/runtime/thread-settings"; @@ -34,13 +35,14 @@ interface ThreadConversationSummaryPage { nextCursor: string | null; } -interface ThreadActivationResponse { +interface ThreadActivationResponse extends Partial { thread: ThreadRecord; cwd: string; model: string | null; serviceTier: ServiceTier | null; approvalsReviewer: ApprovalsReviewer | null; reasoningEffort: string | null; + sandbox?: RuntimePermissionState["sandboxPolicy"]; } export interface AppServerStartThreadOptions { @@ -215,6 +217,11 @@ export async function restoreArchivedThread(client: AppServerRequestClient, thre } export function threadActivationSnapshotFromAppServerResponse(response: ThreadActivationResponse): ThreadActivationSnapshot { + const permissions = runtimePermissionStateOrDefault({ + approvalPolicy: response.approvalPolicy ?? null, + sandboxPolicy: response.sandbox ?? response.sandboxPolicy ?? null, + activePermissionProfile: response.activePermissionProfile ?? null, + }); return { thread: threadFromThreadRecord(response.thread), cwd: response.cwd, @@ -222,6 +229,7 @@ export function threadActivationSnapshotFromAppServerResponse(response: ThreadAc reasoningEffort: normalizeReasoningEffort(response.reasoningEffort), serviceTier: parseServiceTier(response.serviceTier), approvalsReviewer: response.approvalsReviewer, + ...permissions, }; } diff --git a/src/domain/runtime/config.ts b/src/domain/runtime/config.ts index 4675154b..84dc72e6 100644 --- a/src/domain/runtime/config.ts +++ b/src/domain/runtime/config.ts @@ -1,4 +1,5 @@ import type { ReasoningEffort } from "../catalog/metadata"; +import { cloneRuntimePermissionState, initialRuntimePermissionState, type RuntimePermissionState } from "./permissions"; import type { ApprovalsReviewer, ServiceTier } from "./policy"; export type ReasoningSummary = "auto" | "concise" | "detailed" | "none"; @@ -13,6 +14,7 @@ export interface RuntimeConfigSnapshot { readonly verbosity: Verbosity | null; readonly serviceTier: ServiceTier | null; readonly approvalsReviewer: ApprovalsReviewer | null; + readonly startupPermissions: RuntimePermissionState; readonly modelContextWindow: number | null; readonly autoCompactTokenLimit: number | null; } @@ -27,13 +29,17 @@ export function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot { verbosity: null, serviceTier: null, approvalsReviewer: null, + startupPermissions: initialRuntimePermissionState(), modelContextWindow: null, autoCompactTokenLimit: null, }; } export function cloneRuntimeConfigSnapshot(config: RuntimeConfigSnapshot): RuntimeConfigSnapshot { - return { ...config }; + return { + ...config, + startupPermissions: cloneRuntimePermissionState(config.startupPermissions), + }; } export function runtimeConfigOrDefault(runtimeConfig: RuntimeConfigSnapshot | null): RuntimeConfigSnapshot { diff --git a/src/domain/runtime/permissions.ts b/src/domain/runtime/permissions.ts new file mode 100644 index 00000000..7a56e01e --- /dev/null +++ b/src/domain/runtime/permissions.ts @@ -0,0 +1,74 @@ +export type RuntimeApprovalPolicy = + | "untrusted" + | "on-failure" + | "on-request" + | "never" + | { + granular: { + sandbox_approval: boolean; + rules: boolean; + skill_approval: boolean; + request_permissions: boolean; + mcp_elicitations: boolean; + }; + }; + +interface RuntimeActivePermissionProfile { + readonly id: string; + readonly extends: string | null; +} + +type RuntimeNetworkAccess = "restricted" | "enabled"; + +export type RuntimeSandboxPolicy = + | { readonly type: "dangerFullAccess" } + | { readonly type: "readOnly"; readonly networkAccess: boolean } + | { readonly type: "externalSandbox"; readonly networkAccess: RuntimeNetworkAccess } + | { + readonly type: "workspaceWrite"; + readonly writableRoots: readonly string[]; + readonly networkAccess: boolean; + readonly excludeTmpdirEnvVar: boolean; + readonly excludeSlashTmp: boolean; + }; + +export interface RuntimePermissionState { + readonly approvalPolicy: RuntimeApprovalPolicy | null; + readonly sandboxPolicy: RuntimeSandboxPolicy | null; + readonly activePermissionProfile: RuntimeActivePermissionProfile | null; +} + +export function initialRuntimePermissionState(): RuntimePermissionState { + return { + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, + }; +} + +export function runtimePermissionStateOrDefault(value: Partial | null | undefined): RuntimePermissionState { + return { + approvalPolicy: value?.approvalPolicy ? cloneRuntimeApprovalPolicy(value.approvalPolicy) : null, + sandboxPolicy: value?.sandboxPolicy ? cloneRuntimeSandboxPolicy(value.sandboxPolicy) : null, + activePermissionProfile: value?.activePermissionProfile ? { ...value.activePermissionProfile } : null, + }; +} + +export function cloneRuntimePermissionState(value: RuntimePermissionState): RuntimePermissionState { + return runtimePermissionStateOrDefault(value); +} + +function cloneRuntimeApprovalPolicy(value: RuntimeApprovalPolicy): RuntimeApprovalPolicy { + if (typeof value === "string") return value; + return { + granular: { ...value.granular }, + }; +} + +function cloneRuntimeSandboxPolicy(value: RuntimeSandboxPolicy): RuntimeSandboxPolicy { + if (value.type !== "workspaceWrite") return { ...value }; + return { + ...value, + writableRoots: [...value.writableRoots], + }; +} diff --git a/src/domain/runtime/thread-settings.ts b/src/domain/runtime/thread-settings.ts index 2b7e80eb..63d99ce6 100644 --- a/src/domain/runtime/thread-settings.ts +++ b/src/domain/runtime/thread-settings.ts @@ -1,4 +1,5 @@ import type { ReasoningEffort } from "../catalog/metadata"; +import type { RuntimeApprovalPolicy } from "./permissions"; import type { ApprovalsReviewer } from "./policy"; export type RuntimeServiceTierRequest = string | null | undefined; @@ -15,7 +16,9 @@ export interface CollaborationMode { export interface RuntimeSettingsPatch { cwd?: string | null; + approvalPolicy?: RuntimeApprovalPolicy | null; approvalsReviewer?: ApprovalsReviewer | null; + permissions?: string | null; model?: string | null; serviceTier?: string | null; effort?: ReasoningEffort | null; diff --git a/src/domain/threads/activation.ts b/src/domain/threads/activation.ts index 2da0a165..b84e2546 100644 --- a/src/domain/threads/activation.ts +++ b/src/domain/threads/activation.ts @@ -1,8 +1,9 @@ import type { ReasoningEffort } from "../catalog/metadata"; +import type { RuntimePermissionState } from "../runtime/permissions"; import type { ApprovalsReviewer, ServiceTier } from "../runtime/policy"; import type { Thread } from "./model"; -export interface ThreadActivationSnapshot { +export interface ThreadActivationSnapshot extends RuntimePermissionState { thread: Thread; cwd: string; model: string | null; diff --git a/src/features/chat/application/state/actions.ts b/src/features/chat/application/state/actions.ts index 5cc17c79..e08706e3 100644 --- a/src/features/chat/application/state/actions.ts +++ b/src/features/chat/application/state/actions.ts @@ -1,4 +1,5 @@ import { normalizeReasoningEffort, type ReasoningEffort } from "../../../../domain/catalog/metadata"; +import { type RuntimePermissionState, runtimePermissionStateOrDefault } from "../../../../domain/runtime/permissions"; import { parseServiceTier, type ServiceTier } from "../../../../domain/runtime/policy"; import type { ServerInitialization } from "../../../../domain/server/initialization"; import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation"; @@ -19,12 +20,22 @@ interface ResumedThreadActionParams { interface ResumedThreadFromActiveRuntimeParams { thread: Thread; cwd: string; - runtime: Pick; + runtime: Pick< + ActiveThreadRuntimeState, + | "model" + | "reasoningEffort" + | "serviceTier" + | "serviceTierKnown" + | "approvalsReviewer" + | "approvalPolicy" + | "sandboxPolicy" + | "activePermissionProfile" + >; listedThreads?: readonly Thread[]; items?: readonly MessageStreamItem[]; } -export interface ActiveThreadResumedAction { +export interface ActiveThreadResumedAction extends RuntimePermissionState { type: "active-thread/resumed"; thread: Thread; cwd: string; @@ -39,7 +50,7 @@ export interface ActiveThreadResumedAction { preserveRequestedRuntimeSettings?: boolean; } -export interface ActiveThreadSettingsAppliedAction { +export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionState { type: "active-thread/settings-applied"; cwd: string; model: string | null; @@ -49,7 +60,7 @@ export interface ActiveThreadSettingsAppliedAction { approvalsReviewer: ActiveThreadRuntimeState["approvalsReviewer"]; } -export interface ActiveThreadSettingsAppliedActionSettings { +export interface ActiveThreadSettingsAppliedActionSettings extends RuntimePermissionState { cwd: string; model: string | null; effort: string | null; @@ -114,6 +125,9 @@ export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromAc reasoningEffort: params.runtime.reasoningEffort, serviceTier: params.runtime.serviceTier, approvalsReviewer: params.runtime.approvalsReviewer, + approvalPolicy: params.runtime.approvalPolicy, + sandboxPolicy: params.runtime.sandboxPolicy, + activePermissionProfile: params.runtime.activePermissionProfile, }, serviceTierKnown: params.runtime.serviceTierKnown, ...(params.listedThreads ? { listedThreads: params.listedThreads } : {}), @@ -123,6 +137,7 @@ export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromAc export function resumedThreadAction(params: ResumedThreadActionParams): ActiveThreadResumedAction { const { response } = params; + const permissions = runtimePermissionStateOrDefault(response); return { type: "active-thread/resumed", thread: response.thread, @@ -132,6 +147,7 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh serviceTier: response.serviceTier, serviceTierKnown: params.serviceTierKnown ?? true, approvalsReviewer: response.approvalsReviewer, + ...permissions, ...(params.items ? { items: params.items } : {}), ...(params.listedThreads ? { listedThreads: upsertThread(params.listedThreads, response.thread) } : {}), ...(params.preserveRequestedRuntimeSettings ? { preserveRequestedRuntimeSettings: true } : {}), @@ -139,6 +155,7 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh } export function activeThreadSettingsAppliedAction(settings: ActiveThreadSettingsAppliedActionSettings): ActiveThreadSettingsAppliedAction { + const permissions = runtimePermissionStateOrDefault(settings); return { type: "active-thread/settings-applied", cwd: settings.cwd, @@ -147,5 +164,6 @@ export function activeThreadSettingsAppliedAction(settings: ActiveThreadSettings collaborationMode: settings.collaborationMode.mode, serviceTier: parseServiceTier(settings.serviceTier), approvalsReviewer: settings.approvalsReviewer, + ...permissions, }; } diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 3bad366c..c420827b 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -328,6 +328,9 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr collaborationMode: initialActiveChatRuntimeState().collaborationMode, serviceTier: action.serviceTier, approvalsReviewer: action.approvalsReviewer, + approvalPolicy: action.approvalPolicy, + sandboxPolicy: action.sandboxPolicy, + activePermissionProfile: action.activePermissionProfile, }, }, turn: initialTurnState(), @@ -353,6 +356,9 @@ function reduceActiveThreadSettingsAppliedTransition(state: ChatState, action: A collaborationMode: action.collaborationMode, serviceTier: action.serviceTier, approvalsReviewer: action.approvalsReviewer, + approvalPolicy: action.approvalPolicy, + sandboxPolicy: action.sandboxPolicy, + activePermissionProfile: action.activePermissionProfile, }, pending: { ...state.runtime.pending, diff --git a/src/features/chat/domain/runtime/resolution.ts b/src/features/chat/domain/runtime/resolution.ts index d7b90562..c888fb4d 100644 --- a/src/features/chat/domain/runtime/resolution.ts +++ b/src/features/chat/domain/runtime/resolution.ts @@ -1,6 +1,7 @@ import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import { findModelMetadataByIdOrName, type ModelMetadata, supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata"; import type { RuntimeConfigSnapshot } from "../../../../domain/runtime/config"; +import { cloneRuntimePermissionState, type RuntimePermissionState } from "../../../../domain/runtime/permissions"; import type { ApprovalsReviewer, ServiceTier } from "../../../../domain/runtime/policy"; import { effectiveCollaborationMode, type PendingRuntimeIntent, type RequestedFastMode } from "./intent"; import type { RuntimeSnapshot } from "./snapshot"; @@ -25,7 +26,6 @@ interface FastModeResolution { interface AutoReviewResolution { readonly active: boolean; - readonly reviewer: ApprovalsReviewer | null; readonly source: RuntimeValueSource; } @@ -37,14 +37,23 @@ interface CollaborationModeResolution { readonly blockedReason: "missing-model" | null; } +interface RuntimePermissionsResolution { + readonly scope: "new-thread" | "current-thread"; + readonly configured: RuntimePermissionState; + readonly active: RuntimePermissionState | null; + readonly effective: RuntimePermissionState; + readonly reviewer: RuntimeLayeredValue; + readonly source: RuntimeValueSource; +} + export interface RuntimeControlsResolution { readonly model: RuntimeLayeredValue; readonly reasoningEffort: RuntimeLayeredValue; - readonly approvalsReviewer: RuntimeLayeredValue; readonly autoReview: AutoReviewResolution; readonly serviceTier: RuntimeLayeredValue; readonly fastMode: FastModeResolution; readonly collaborationMode: CollaborationModeResolution; + readonly permissions: RuntimePermissionsResolution; readonly supportedReasoningEfforts: readonly ReasoningEffort[]; } @@ -59,37 +68,100 @@ export function resolveRuntimeControls(snapshot: RuntimeSnapshot, config: Runtim active: snapshot.active.reasoningEffort, pending: snapshot.pending.reasoningEffort, }); - const approvalsReviewer = resolveRuntimeValue({ + const reviewer = resolveRuntimeValue({ configured: config.approvalsReviewer, active: snapshot.active.approvalsReviewer, - pending: snapshot.pending.approvalsReviewer, + pending: snapshot.pending.permissions.reviewer, }); - const autoReview = resolveAutoReview(approvalsReviewer); const serviceTiers = findModelMetadataByIdOrName(snapshot.availableModels, model.effective)?.serviceTiers ?? []; const serviceTier = resolveServiceTier(snapshot, config); const fastMode = resolveFastMode(snapshot.pending.fastMode, serviceTier, serviceTiers); const collaborationMode = resolveCollaborationMode(snapshot, model.effective); + const permissions = resolveRuntimePermissions(snapshot, config, reviewer); + const autoReview = resolveAutoReview(permissions.reviewer); return { model, reasoningEffort, - approvalsReviewer, autoReview, serviceTier, fastMode, collaborationMode, + permissions, supportedReasoningEfforts: supportedEffortsForModelMetadata(findModelMetadataByIdOrName(snapshot.availableModels, model.effective)), }; } -function resolveAutoReview(approvalsReviewer: RuntimeLayeredValue): AutoReviewResolution { +function resolveAutoReview(reviewer: RuntimeLayeredValue): AutoReviewResolution { return { - active: approvalsReviewer.effective === "auto_review" || approvalsReviewer.effective === "guardian_subagent", - reviewer: approvalsReviewer.effective, - source: approvalsReviewer.source, + active: reviewer.effective === "auto_review" || reviewer.effective === "guardian_subagent", + source: reviewer.source, }; } +function resolveRuntimePermissions( + snapshot: RuntimeSnapshot, + config: RuntimeConfigSnapshot, + reviewer: RuntimeLayeredValue, +): RuntimePermissionsResolution { + const configured = cloneRuntimePermissionState(config.startupPermissions); + const scope = snapshot.activeThreadId ? "current-thread" : "new-thread"; + const baseSource = snapshot.activeThreadId ? "active-thread" : "config"; + if (snapshot.activeThreadId) { + const active = cloneRuntimePermissionState(snapshot.active); + const { effective, source } = resolveRuntimePermissionState(active, configured, snapshot.pending.permissions, baseSource); + return { + scope, + configured, + active, + effective, + reviewer, + source, + }; + } + const { effective, source } = resolveRuntimePermissionState(configured, configured, snapshot.pending.permissions, baseSource); + return { + scope, + configured, + active: null, + effective, + reviewer, + source, + }; +} + +function resolveRuntimePermissionState( + base: RuntimePermissionState, + configured: RuntimePermissionState, + pending: RuntimeSnapshot["pending"]["permissions"], + baseSource: RuntimeValueSource, +): Pick { + let effective = cloneRuntimePermissionState(base); + let source = baseSource; + const approvalPolicy = runtimePermissionIntentValue(pending.approvalPolicy, configured.approvalPolicy); + if (approvalPolicy !== undefined) { + effective = { ...effective, approvalPolicy }; + source = "pending"; + } + const configuredPermissionProfile = configured.activePermissionProfile?.id ?? null; + const permissionProfile = runtimePermissionIntentValue(pending.permissionProfile, configuredPermissionProfile); + if (permissionProfile !== undefined) { + effective = { + ...effective, + sandboxPolicy: permissionProfile === configuredPermissionProfile ? configured.sandboxPolicy : null, + activePermissionProfile: permissionProfile ? { id: permissionProfile, extends: null } : null, + }; + source = "pending"; + } + return { effective: cloneRuntimePermissionState(effective), source }; +} + +function runtimePermissionIntentValue(intent: PendingRuntimeIntent, configured: T | null): T | null | undefined { + if (intent.kind === "set") return intent.value; + if (intent.kind === "resetToConfig") return configured; + return undefined; +} + function resolveRuntimeValue(input: { configured: T | null | undefined; active: T | null | undefined; diff --git a/src/features/chat/domain/runtime/state.ts b/src/features/chat/domain/runtime/state.ts index 29b9d860..05bcc826 100644 --- a/src/features/chat/domain/runtime/state.ts +++ b/src/features/chat/domain/runtime/state.ts @@ -1,4 +1,9 @@ import { normalizeReasoningEffort, type ReasoningEffort } from "../../../../domain/catalog/metadata"; +import { + initialRuntimePermissionState, + type RuntimeApprovalPolicy, + type RuntimePermissionState, +} from "../../../../domain/runtime/permissions"; import { type ApprovalsReviewer, parseServiceTier, type ServiceTier } from "../../../../domain/runtime/policy"; import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings"; import { @@ -16,7 +21,7 @@ export interface ChatRuntimeState { readonly pending: PendingRuntimeIntentState; } -export interface ActiveThreadRuntimeState { +export interface ActiveThreadRuntimeState extends RuntimePermissionState { readonly serviceTierKnown: boolean; readonly model: string | null; readonly reasoningEffort: ReasoningEffort | null; @@ -28,13 +33,20 @@ export interface ActiveThreadRuntimeState { export interface PendingRuntimeIntentState { readonly model: PendingRuntimeIntent; readonly reasoningEffort: PendingRuntimeIntent; - readonly approvalsReviewer: PendingRuntimeIntent; + readonly permissions: PendingRuntimePermissionIntentState; readonly collaborationMode: CollaborationModeSelection; readonly fastMode: PendingRuntimeIntent; } +interface PendingRuntimePermissionIntentState { + readonly approvalPolicy: PendingRuntimeIntent; + readonly permissionProfile: PendingRuntimeIntent; + readonly reviewer: PendingRuntimeIntent; +} + export function initialActiveChatRuntimeState(): ActiveThreadRuntimeState { return { + ...initialRuntimePermissionState(), serviceTierKnown: false, model: null, reasoningEffort: null, @@ -56,12 +68,20 @@ function initialPendingRuntimeIntentState(): PendingRuntimeIntentState { return { model: unchangedRuntimeIntent(), reasoningEffort: unchangedRuntimeIntent(), - approvalsReviewer: unchangedRuntimeIntent(), + permissions: initialPendingRuntimePermissionIntentState(), collaborationMode: "default", fastMode: unchangedRuntimeIntent(), }; } +function initialPendingRuntimePermissionIntentState(): PendingRuntimePermissionIntentState { + return { + approvalPolicy: unchangedRuntimeIntent(), + permissionProfile: unchangedRuntimeIntent(), + reviewer: unchangedRuntimeIntent(), + }; +} + export function initialChatRuntimeState(): ChatRuntimeState { return { active: initialActiveChatRuntimeState(), @@ -114,14 +134,20 @@ export function clearRequestedFastModeRuntimeState(state: ChatRuntimeState): Cha export function requestApprovalsReviewerRuntimeState(state: ChatRuntimeState, approvalsReviewer: ApprovalsReviewer): ChatRuntimeState { return { ...state, - pending: { ...state.pending, approvalsReviewer: setRuntimeIntentValue(approvalsReviewer) }, + pending: { + ...state.pending, + permissions: { ...state.pending.permissions, reviewer: setRuntimeIntentValue(approvalsReviewer) }, + }, }; } export function clearRequestedApprovalsReviewerRuntimeState(state: ChatRuntimeState): ChatRuntimeState { return { ...state, - pending: { ...state.pending, approvalsReviewer: unchangedRuntimeIntent() }, + pending: { + ...state.pending, + permissions: { ...state.pending.permissions, reviewer: unchangedRuntimeIntent() }, + }, }; } @@ -144,6 +170,7 @@ export function commitAppliedRuntimeSettingsPatchState(state: ChatRuntimeState, ...("effort" in update ? { reasoningEffort: normalizeReasoningEffort(update.effort) } : {}), ...("serviceTier" in update ? { serviceTier: parseServiceTier(update.serviceTier), serviceTierKnown: true } : {}), ...("approvalsReviewer" in update ? { approvalsReviewer: update.approvalsReviewer ?? null } : {}), + ...("approvalPolicy" in update ? { approvalPolicy: update.approvalPolicy ?? null } : {}), ...(update.collaborationMode ? { collaborationMode: update.collaborationMode.mode } : {}), }, pending: { @@ -151,7 +178,12 @@ export function commitAppliedRuntimeSettingsPatchState(state: ChatRuntimeState, ...("model" in update ? { model: unchangedRuntimeIntent() } : {}), ...("effort" in update ? { reasoningEffort: unchangedRuntimeIntent() } : {}), ...("serviceTier" in update ? { fastMode: unchangedRuntimeIntent() } : {}), - ...("approvalsReviewer" in update ? { approvalsReviewer: unchangedRuntimeIntent() } : {}), + permissions: { + ...state.pending.permissions, + ...("approvalPolicy" in update ? { approvalPolicy: unchangedRuntimeIntent() } : {}), + ...("permissions" in update ? { permissionProfile: unchangedRuntimeIntent() } : {}), + ...("approvalsReviewer" in update ? { reviewer: unchangedRuntimeIntent() } : {}), + }, }, }; } diff --git a/src/features/chat/domain/runtime/thread-settings-patch.ts b/src/features/chat/domain/runtime/thread-settings-patch.ts index 70717cbd..4553fcd0 100644 --- a/src/features/chat/domain/runtime/thread-settings-patch.ts +++ b/src/features/chat/domain/runtime/thread-settings-patch.ts @@ -60,11 +60,25 @@ export function pendingRuntimeSettingsPatch(snapshot: RuntimeSnapshot, config: R "serviceTier", runtimeSettingsPatchValue(serviceTierPatchIntent(snapshot, resolution, "thread-update")), ); - if (snapshot.pending.approvalsReviewer.kind !== "unchanged") { + if (snapshot.pending.permissions.approvalPolicy.kind !== "unchanged") { + applyRuntimeSettingsPatchValue( + update, + "approvalPolicy", + runtimeSettingsPatchValue(runtimeSettingsPatchIntentFromPending(snapshot.pending.permissions.approvalPolicy)), + ); + } + if (snapshot.pending.permissions.permissionProfile.kind !== "unchanged") { + applyRuntimeSettingsPatchValue( + update, + "permissions", + runtimeSettingsPatchValue(runtimeSettingsPatchIntentFromPending(snapshot.pending.permissions.permissionProfile)), + ); + } + if (snapshot.pending.permissions.reviewer.kind !== "unchanged") { applyRuntimeSettingsPatchValue( update, "approvalsReviewer", - runtimeSettingsPatchValue(runtimeSettingsPatchIntentFromPending(snapshot.pending.approvalsReviewer)), + runtimeSettingsPatchValue(runtimeSettingsPatchIntentFromPending(snapshot.pending.permissions.reviewer)), ); } if (resolution.collaborationMode.dirty) { diff --git a/src/features/chat/panel/surface/toolbar-projection.tsx b/src/features/chat/panel/surface/toolbar-projection.tsx index c8c1fddf..8c4a37e7 100644 --- a/src/features/chat/panel/surface/toolbar-projection.tsx +++ b/src/features/chat/panel/surface/toolbar-projection.tsx @@ -6,6 +6,7 @@ import type { Thread } from "../../../../domain/threads/model"; import { threadRowCoreProjection } from "../../../threads/list/row-projection"; import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { appServerDiagnosticSections } from "../../presentation/runtime/diagnostic-sections"; +import { runtimePermissionDetails } from "../../presentation/runtime/permission-sections"; import { rateLimitSummary } from "../../presentation/runtime/status"; import { toolInventoryDiagnosticSections } from "../../presentation/runtime/tool-inventory-diagnostic-sections"; import { Toolbar, type ToolbarActions, type ToolbarThreadRow, type ToolbarViewModel } from "../../ui/toolbar"; @@ -75,6 +76,10 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo const projection = toolbarStateProjection(input); const limit = rateLimitSummary(snapshot, input.nowMs); const diagnostics = model.diagnostics.value; + const permissions = runtimePermissionDetails({ + snapshot, + vaultPath: input.vaultPath, + }); return { newChatDisabled: projection.newChatDisabled, chatActionsOpen: projection.chatActionsOpen, @@ -85,6 +90,8 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo openPanel: projection.openPanel, threads: projection.threads, connectLabel: input.connected ? "Reconnect" : "Connect", + runtimePermissionsTitle: permissions.title, + runtimePermissions: permissions.sections, diagnostics: appServerDiagnosticSections({ connected: input.connected, configuredCommand: input.configuredCommand, diff --git a/src/features/chat/presentation/runtime/permission-sections.ts b/src/features/chat/presentation/runtime/permission-sections.ts new file mode 100644 index 00000000..22f91670 --- /dev/null +++ b/src/features/chat/presentation/runtime/permission-sections.ts @@ -0,0 +1,113 @@ +import { runtimeConfigOrDefault } from "../../../../domain/runtime/config"; +import type { RuntimePermissionState, RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions"; +import { resolveRuntimeControls } from "../../domain/runtime/resolution"; +import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; + +interface RuntimePermissionRow { + label: string; + value: string; +} + +interface RuntimePermissionSection { + title: string; + rows: RuntimePermissionRow[]; +} + +interface RuntimePermissionDetails { + title: string; + sections: RuntimePermissionSection[]; +} + +interface RuntimePermissionSectionsInput { + snapshot: RuntimeSnapshot; + vaultPath: string; +} + +export function runtimePermissionDetails(input: RuntimePermissionSectionsInput): RuntimePermissionDetails { + const config = runtimeConfigOrDefault(input.snapshot.runtimeConfig); + const resolution = resolveRuntimeControls(input.snapshot, config); + return { + title: permissionPanelTitle(resolution.permissions.scope), + sections: [ + { + title: "", + rows: permissionRows(resolution.permissions.effective, resolution.permissions.reviewer.effective, input.vaultPath), + }, + ], + }; +} + +function permissionPanelTitle(scope: "new-thread" | "current-thread"): string { + return scope === "current-thread" ? "Permissions: Current Thread" : "Permissions: New Thread"; +} + +function permissionRows(permissions: RuntimePermissionState, reviewer: string | null, vaultPath: string): RuntimePermissionRow[] { + return [ + { label: "Profile", value: profileLabel(permissions) }, + ...extendsRows(permissions), + { label: "Sandbox", value: sandboxLabel(permissions.sandboxPolicy) }, + { label: "Network", value: networkLabel(permissions.sandboxPolicy) }, + { label: "Extra writable roots", value: writableRootsLabel(permissions.sandboxPolicy, vaultPath) }, + { label: "Approval policy", value: approvalPolicyLabel(permissions.approvalPolicy) }, + { + label: "Reviewer", + value: reviewer ?? "(Codex default)", + }, + ]; +} + +function profileLabel(permissions: RuntimePermissionState): string { + if (permissions.activePermissionProfile) return permissions.activePermissionProfile.id; + if (permissions.sandboxPolicy) return "(legacy sandbox)"; + return "(not reported)"; +} + +function extendsRows(permissions: RuntimePermissionState): RuntimePermissionRow[] { + const extendsProfile = permissions.activePermissionProfile?.extends; + return extendsProfile ? [{ label: "Extends", value: extendsProfile }] : []; +} + +function sandboxLabel(sandbox: RuntimeSandboxPolicy | null): string { + if (!sandbox) return "(not reported)"; + switch (sandbox.type) { + case "dangerFullAccess": + return "danger-full-access"; + case "readOnly": + return "read-only"; + case "workspaceWrite": + return "workspace-write"; + case "externalSandbox": + return "external sandbox"; + } +} + +function networkLabel(sandbox: RuntimeSandboxPolicy | null): string { + if (!sandbox) return "(not reported)"; + if (sandbox.type === "dangerFullAccess") return "allowed"; + if (sandbox.type === "externalSandbox") return sandbox.networkAccess; + return sandbox.networkAccess ? "allowed" : "blocked"; +} + +function writableRootsLabel(sandbox: RuntimeSandboxPolicy | null, vaultPath: string): string { + if (!sandbox) return "(not reported)"; + if (sandbox.type === "dangerFullAccess") return "all files"; + if (sandbox.type !== "workspaceWrite") return "-"; + if (sandbox.writableRoots.length === 0) return "(none)"; + return sandbox.writableRoots.map((root) => displayRoot(root, vaultPath)).join(", "); +} + +function displayRoot(root: string, vaultPath: string): string { + if (root === vaultPath) return "Vault"; + if (vaultPath && root.startsWith(`${vaultPath}/`)) return `Vault/${root.slice(vaultPath.length + 1)}`; + return root; +} + +function approvalPolicyLabel(policy: RuntimePermissionState["approvalPolicy"]): string { + if (!policy) return "(not reported)"; + if (typeof policy === "string") return policy; + + const enabled = Object.entries(policy.granular) + .filter(([, value]) => value) + .map(([key]) => key.replace(/_/g, " ")); + return enabled.length ? `granular: ${enabled.join(", ")}` : "granular"; +} diff --git a/src/features/chat/ui/toolbar.tsx b/src/features/chat/ui/toolbar.tsx index 6ac6c11a..4bd65dd7 100644 --- a/src/features/chat/ui/toolbar.tsx +++ b/src/features/chat/ui/toolbar.tsx @@ -21,15 +21,15 @@ export interface ToolbarThreadRow { } | null; } -interface ToolbarDiagnosticRow { +interface ToolbarStatusRow { label: string; value: string; level?: "normal" | "warning" | "error"; } -interface ToolbarDiagnosticSection { +interface ToolbarStatusSection { title: string; - rows: ToolbarDiagnosticRow[]; + rows: ToolbarStatusRow[]; } export interface ToolbarViewModel { @@ -42,8 +42,10 @@ export interface ToolbarViewModel { openPanel: "history" | "chat-actions" | "status" | null; threads: ToolbarThreadRow[]; connectLabel: string; - diagnostics: ToolbarDiagnosticSection[]; - toolInventory: ToolbarDiagnosticSection[]; + runtimePermissionsTitle: string; + runtimePermissions: ToolbarStatusSection[]; + diagnostics: ToolbarStatusSection[]; + toolInventory: ToolbarStatusSection[]; } interface ToolbarPrimaryActions { @@ -194,6 +196,7 @@ function StatusPanel({ model, actions }: { model: ToolbarViewModel; actions: Too + ); @@ -247,7 +250,7 @@ function RateLimitPanel({ rateLimit }: { rateLimit: RateLimitSummary | null }): ); } -function DiagnosticSectionsPanel({ title, sections }: { title: string; sections: ToolbarDiagnosticSection[] }): UiNode { +function DiagnosticSectionsPanel({ title, sections }: { title: string; sections: ToolbarStatusSection[] }): UiNode { return (
{title}
@@ -258,7 +261,7 @@ function DiagnosticSectionsPanel({ title, sections }: { title: string; sections: ); } -function DiagnosticRows({ rows }: { rows: ToolbarDiagnosticRow[] }): UiNode { +function DiagnosticRows({ rows }: { rows: ToolbarStatusRow[] }): UiNode { return (
{rows.map((row) => ( @@ -268,16 +271,16 @@ function DiagnosticRows({ rows }: { rows: ToolbarDiagnosticRow[] }): UiNode { ); } -function DiagnosticSection({ section }: { section: ToolbarDiagnosticSection }): UiNode { +function DiagnosticSection({ section }: { section: ToolbarStatusSection }): UiNode { return ( <> -
{section.title}
+ {section.title ?
{section.title}
: null} ); } -function DiagnosticRow({ row }: { row: ToolbarDiagnosticRow }): UiNode { +function DiagnosticRow({ row }: { row: ToolbarStatusRow }): UiNode { const level = row.level ?? "normal"; return (
{ serviceTier: "fast", approvalsReviewer: "user", reasoningEffort: "high", + approvalPolicy: "on-request", + sandboxPolicy: { type: "readOnly", networkAccess: false }, + activePermissionProfile: null, }); }); }); diff --git a/tests/app-server/thread-settings.test.ts b/tests/app-server/thread-settings.test.ts index 76e29924..16c25eb7 100644 --- a/tests/app-server/thread-settings.test.ts +++ b/tests/app-server/thread-settings.test.ts @@ -54,6 +54,7 @@ describe("app-server thread settings", () => { }, }, }); + expect(appServerRuntimeSettingsPatch({ permissions: ":workspace" })).toEqual({ permissions: ":workspace" }); }); it.each([ diff --git a/tests/features/chat/app-server/actions/actions.test.ts b/tests/features/chat/app-server/actions/actions.test.ts index c5c6f260..66b9eac9 100644 --- a/tests/features/chat/app-server/actions/actions.test.ts +++ b/tests/features/chat/app-server/actions/actions.test.ts @@ -105,7 +105,7 @@ describe("chat app-server actions", () => { expect(stateStore.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" }); expect(stateStore.getState().runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" }); expect(stateStore.getState().runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" }); - expect(stateStore.getState().runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" }); + expect(stateStore.getState().runtime.pending.permissions.reviewer).toEqual({ kind: "set", value: "auto_review" }); expect(stateStore.getState().runtime.pending.collaborationMode).toBe("plan"); }); diff --git a/tests/features/chat/app-server/inbound/handler.test.ts b/tests/features/chat/app-server/inbound/handler.test.ts index 80814dbb..b36a6ebd 100644 --- a/tests/features/chat/app-server/inbound/handler.test.ts +++ b/tests/features/chat/app-server/inbound/handler.test.ts @@ -1703,6 +1703,9 @@ describe("ChatInboundHandler", () => { expect(handler.currentState().runtime.active.model).toBe("gpt-5.5"); expect(handler.currentState().runtime.active.serviceTier).toBe("fast"); expect(handler.currentState().runtime.active.approvalsReviewer).toBe("auto_review"); + expect(handler.currentState().runtime.active.approvalPolicy).toBe("on-request"); + expect(handler.currentState().runtime.active.sandboxPolicy).toEqual({ type: "readOnly", networkAccess: false }); + expect(handler.currentState().runtime.active.activePermissionProfile).toBeNull(); expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]); }); @@ -1713,6 +1716,9 @@ describe("ChatInboundHandler", () => { state = chatStateWith(state, { runtime: { active: { model: "gpt-active" } } }); state = chatStateWith(state, { runtime: { active: { serviceTier: "flex" } } }); state = chatStateWith(state, { runtime: { active: { approvalsReviewer: "user" } } }); + state = chatStateWith(state, { runtime: { active: { approvalPolicy: "on-request" } } }); + state = chatStateWith(state, { runtime: { active: { sandboxPolicy: { type: "readOnly", networkAccess: false } } } }); + state = chatStateWith(state, { runtime: { active: { activePermissionProfile: null } } }); const handler = handlerForState(state); handler.handleNotification({ @@ -1744,6 +1750,9 @@ describe("ChatInboundHandler", () => { expect(handler.currentState().runtime.active.model).toBe("gpt-active"); expect(handler.currentState().runtime.active.serviceTier).toBe("flex"); expect(handler.currentState().runtime.active.approvalsReviewer).toBe("user"); + expect(handler.currentState().runtime.active.approvalPolicy).toBe("on-request"); + expect(handler.currentState().runtime.active.sandboxPolicy).toEqual({ type: "readOnly", networkAccess: false }); + expect(handler.currentState().runtime.active.activePermissionProfile).toBeNull(); }); it("syncs null service tier from settings notifications", () => { diff --git a/tests/features/chat/application/connection/reconnect-actions.test.ts b/tests/features/chat/application/connection/reconnect-actions.test.ts index 51db5876..dcc166d7 100644 --- a/tests/features/chat/application/connection/reconnect-actions.test.ts +++ b/tests/features/chat/application/connection/reconnect-actions.test.ts @@ -8,6 +8,9 @@ function createHost(overrides: Partial = {}) { stateStore.dispatch({ type: "ui/panel-set", panel: "history" }); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: { id: "thread" } as never, cwd: "/vault", model: null, diff --git a/tests/features/chat/application/conversation/composer-submit-actions.test.ts b/tests/features/chat/application/conversation/composer-submit-actions.test.ts index 63568a07..552193e3 100644 --- a/tests/features/chat/application/conversation/composer-submit-actions.test.ts +++ b/tests/features/chat/application/conversation/composer-submit-actions.test.ts @@ -151,6 +151,9 @@ describe("submitComposer", () => { const { host, interruptTurn, showLatest, stateStore } = createHost(""); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("thread"), cwd: "/vault", model: null, diff --git a/tests/features/chat/application/conversation/plan-implementation.test.ts b/tests/features/chat/application/conversation/plan-implementation.test.ts index cc8cecc1..d254619a 100644 --- a/tests/features/chat/application/conversation/plan-implementation.test.ts +++ b/tests/features/chat/application/conversation/plan-implementation.test.ts @@ -30,6 +30,9 @@ const streamingPlanItem = (id: string): MessageStreamItem => ({ function resumeThread(stateStore: ChatStateStore, items: readonly MessageStreamItem[]): void { stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: { id: "thread", cliVersion: "test" } as never, cwd: "/vault", model: null, 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 89b2bd65..a1c414c5 100644 --- a/tests/features/chat/application/conversation/slash-command-executor.test.ts +++ b/tests/features/chat/application/conversation/slash-command-executor.test.ts @@ -89,6 +89,9 @@ describe("executeSlashCommandWithState", () => { }); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("thread", "Thread"), cwd: "/vault", model: null, @@ -106,6 +109,9 @@ describe("executeSlashCommandWithState", () => { const { compactThread, host, stateStore } = createHost({ connectionAvailable: () => false }); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("thread", "Thread"), cwd: "/vault", model: null, diff --git a/tests/features/chat/application/conversation/turn-submission-actions.test.ts b/tests/features/chat/application/conversation/turn-submission-actions.test.ts index a7d506b7..cbe341cc 100644 --- a/tests/features/chat/application/conversation/turn-submission-actions.test.ts +++ b/tests/features/chat/application/conversation/turn-submission-actions.test.ts @@ -60,6 +60,9 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) { function resumeThread(stateStore: ReturnType) { stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("thread"), cwd: "/vault", model: null, diff --git a/tests/features/chat/application/runtime/settings-actions.test.ts b/tests/features/chat/application/runtime/settings-actions.test.ts index 2fd6b6c2..5f4b6762 100644 --- a/tests/features/chat/application/runtime/settings-actions.test.ts +++ b/tests/features/chat/application/runtime/settings-actions.test.ts @@ -52,7 +52,7 @@ describe("createChatRuntimeSettingsActions", () => { expect(store.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" }); expect(store.getState().runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" }); expect(store.getState().runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" }); - expect(store.getState().runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" }); + expect(store.getState().runtime.pending.permissions.reviewer).toEqual({ kind: "set", value: "auto_review" }); expect(store.getState().runtime.pending.collaborationMode).toBe("default"); expect(messages).toEqual([ "Fast mode on for subsequent turns.", @@ -425,6 +425,9 @@ function threadSettings( collaborationMode: "default", serviceTier, approvalsReviewer, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, }; } diff --git a/tests/features/chat/application/state/actions.test.ts b/tests/features/chat/application/state/actions.test.ts index 585580e4..6a749795 100644 --- a/tests/features/chat/application/state/actions.test.ts +++ b/tests/features/chat/application/state/actions.test.ts @@ -18,6 +18,15 @@ describe("chat thread resume helpers", () => { serviceTier: "fast", serviceTierKnown: true, approvalsReviewer: "user", + approvalPolicy: "on-request", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: ["/vault"], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }, + activePermissionProfile: { id: ":workspace", extends: null }, }, listedThreads: [existing], items: [loading], @@ -32,6 +41,15 @@ describe("chat thread resume helpers", () => { serviceTier: "fast", serviceTierKnown: true, approvalsReviewer: "user", + approvalPolicy: "on-request", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: ["/vault"], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }, + activePermissionProfile: { id: ":workspace", extends: null }, items: [loading], }); expect(action.listedThreads?.map((thread) => thread.id)).toEqual(["thread", "existing"]); @@ -47,6 +65,15 @@ describe("chat thread resume helpers", () => { expect(action).toMatchObject({ type: "active-thread/resumed", + approvalPolicy: "on-request", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: ["/vault"], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }, + activePermissionProfile: { id: ":workspace", extends: null }, thread: resumed, preserveRequestedRuntimeSettings: true, }); @@ -62,6 +89,15 @@ function responseFixture(thread: Thread): ThreadActivationSnapshot { cwd: "/vault", approvalsReviewer: "user", reasoningEffort: "high", + approvalPolicy: "on-request", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: ["/vault"], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }, + activePermissionProfile: { id: ":workspace", extends: null }, }; } diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index 00d4c980..431fa16c 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -55,7 +55,7 @@ describe("chatReducer", () => { expect(next.runtime.pending.model).toEqual({ kind: "unchanged" }); expect(next.runtime.pending.reasoningEffort).toEqual({ kind: "unchanged" }); expect(next.runtime.pending.fastMode).toEqual({ kind: "unchanged" }); - expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" }); + expect(next.runtime.pending.permissions.reviewer).toEqual({ kind: "unchanged" }); expect(next.runtime.pending.collaborationMode).toBe("default"); expect(activeTurnId(next)).toBeNull(); expect(chatStateMessageStreamItems(next)).toEqual([]); @@ -102,6 +102,9 @@ describe("chatReducer", () => { const next = chatReducer(state, { type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("resumed-thread"), cwd: "/vault", model: "gpt-5.1", @@ -142,6 +145,9 @@ describe("chatReducer", () => { const next = chatReducer(state, { type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("started-thread"), cwd: "/vault", model: "gpt-5", @@ -159,7 +165,7 @@ describe("chatReducer", () => { expect(next.runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" }); expect(next.runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" }); expect(next.runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" }); - expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" }); + expect(next.runtime.pending.permissions.reviewer).toEqual({ kind: "set", value: "auto_review" }); expect(next.runtime.pending.collaborationMode).toBe("plan"); expect(next.runtime.active.collaborationMode).toBeNull(); }); @@ -170,6 +176,9 @@ describe("chatReducer", () => { const next = chatReducer(state, { type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("resumed-thread"), cwd: "/vault", model: null, @@ -620,7 +629,7 @@ describe("chatReducer", () => { expect(next.runtime.active.serviceTierKnown).toBe(true); expect(next.runtime.pending.fastMode).toEqual({ kind: "unchanged" }); expect(next.runtime.active.approvalsReviewer).toBe("auto_review"); - expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" }); + expect(next.runtime.pending.permissions.reviewer).toEqual({ kind: "unchanged" }); expect(next.runtime.active.collaborationMode).toBe("plan"); }); @@ -641,6 +650,9 @@ describe("chatReducer", () => { it("preserves unknown service tier state when resuming from active runtime", () => { const state = chatReducer(chatStateFixture(), { type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("thread"), cwd: "/vault", model: "gpt-5.1", @@ -664,7 +676,7 @@ describe("chatReducer", () => { expect(state.runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" }); expect(state.runtime.active.serviceTier).toBe("flex"); - expect(state.runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" }); + expect(state.runtime.pending.permissions.reviewer).toEqual({ kind: "set", value: "auto_review" }); expect(state.runtime.active.approvalsReviewer).toBe("user"); }); @@ -683,7 +695,7 @@ describe("chatReducer", () => { expect(state.runtime.pending.model).toEqual({ kind: "resetToConfig" }); expect(state.runtime.pending.reasoningEffort).toEqual({ kind: "resetToConfig" }); expect(state.runtime.pending.fastMode).toEqual({ kind: "unchanged" }); - expect(state.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" }); + expect(state.runtime.pending.permissions.reviewer).toEqual({ kind: "unchanged" }); }); it("stores updates through ChatStateStore without mutating the initial snapshot", () => { @@ -703,6 +715,9 @@ describe("chatReducer", () => { panelA.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("thread-a"), cwd: "/vault", model: null, @@ -716,6 +731,9 @@ describe("chatReducer", () => { panelB.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("thread-b"), cwd: "/vault", model: null, diff --git a/tests/features/chat/application/threads/active-thread-identity-sync.test.ts b/tests/features/chat/application/threads/active-thread-identity-sync.test.ts index 9d0f5412..53cee8da 100644 --- a/tests/features/chat/application/threads/active-thread-identity-sync.test.ts +++ b/tests/features/chat/application/threads/active-thread-identity-sync.test.ts @@ -44,6 +44,9 @@ describe("createActiveThreadIdentitySync", () => { const { controller, host, restoredClear, stateStore } = createController(); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("thread"), cwd: "/vault", model: null, @@ -66,6 +69,9 @@ describe("createActiveThreadIdentitySync", () => { const { controller, host, restoredClear, stateStore } = createController(); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("active"), cwd: "/vault", model: null, @@ -101,6 +107,9 @@ describe("createActiveThreadIdentitySync", () => { const { controller, host, restoredRename, stateStore } = createController(); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: thread("thread", "Old"), cwd: "/vault", model: null, diff --git a/tests/features/chat/application/threads/goal-actions.test.ts b/tests/features/chat/application/threads/goal-actions.test.ts index 45834e52..17805333 100644 --- a/tests/features/chat/application/threads/goal-actions.test.ts +++ b/tests/features/chat/application/threads/goal-actions.test.ts @@ -187,6 +187,9 @@ describe("createGoalActions", () => { const startThread = vi.fn().mockImplementation(async () => { stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: { id: "thread-new", name: null, preview: "Plan release", archived: false, createdAt: 1, updatedAt: 1 }, cwd: "/vault", model: null, diff --git a/tests/features/chat/application/threads/resume-actions.test.ts b/tests/features/chat/application/threads/resume-actions.test.ts index 26db5c8b..61146e55 100644 --- a/tests/features/chat/application/threads/resume-actions.test.ts +++ b/tests/features/chat/application/threads/resume-actions.test.ts @@ -24,6 +24,9 @@ function activation(threadId: string, overrides: Partial = serviceTier: null, approvalsReviewer: "user", reasoningEffort: null, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, }, rolloutPath: null, initialHistoryPage: null, @@ -117,6 +120,9 @@ describe("ResumeActions", () => { const { actions, host, resumeThread, stateStore } = createActions(); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: panelThread("active"), cwd: "/vault", model: null, @@ -159,6 +165,9 @@ describe("ResumeActions", () => { await actions.resumeThread("thread"); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: second.activation.thread, cwd: "/vault", model: null, diff --git a/tests/features/chat/application/threads/thread-management-actions.test.ts b/tests/features/chat/application/threads/thread-management-actions.test.ts index d26d76c1..2d4a1ddf 100644 --- a/tests/features/chat/application/threads/thread-management-actions.test.ts +++ b/tests/features/chat/application/threads/thread-management-actions.test.ts @@ -82,6 +82,9 @@ describe("thread management actions", () => { host.threadTransport.compactThread.mockReturnValue(compact.promise); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: panelThread("source"), cwd: "/vault", model: null, @@ -97,6 +100,9 @@ describe("thread management actions", () => { }); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: panelThread("other"), cwd: "/vault", model: null, @@ -235,6 +241,9 @@ describe("thread management actions", () => { host.threadTransport.forkThread.mockReturnValue(fork.promise); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: panelThread("source"), cwd: "/vault", model: null, @@ -255,6 +264,9 @@ describe("thread management actions", () => { }); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: panelThread("other"), cwd: "/vault", model: null, @@ -313,6 +325,9 @@ describe("thread management actions", () => { const host = hostMock({ items: turnItems() }); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: panelThread("source"), cwd: "/vault", model: null, @@ -345,6 +360,9 @@ describe("thread management actions", () => { host.threadTransport.rollbackThread.mockReturnValue(rollback.promise); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: panelThread("source"), cwd: "/vault", model: null, @@ -366,6 +384,9 @@ describe("thread management actions", () => { }); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: panelThread("other"), cwd: "/vault", model: null, @@ -391,6 +412,9 @@ describe("thread management actions", () => { }); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: panelThread("source"), cwd: "/vault", model: null, diff --git a/tests/features/chat/application/threads/thread-navigation-actions.test.ts b/tests/features/chat/application/threads/thread-navigation-actions.test.ts index 4eff15b3..73228468 100644 --- a/tests/features/chat/application/threads/thread-navigation-actions.test.ts +++ b/tests/features/chat/application/threads/thread-navigation-actions.test.ts @@ -11,6 +11,9 @@ import { function resumeThreadState(stateStore: ChatStateStore, threadId: string): void { stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: { id: threadId, cliVersion: "test" } as never, cwd: "/vault", model: null, diff --git a/tests/features/chat/host/reconnect-action.test.ts b/tests/features/chat/host/reconnect-action.test.ts index 79826d9b..a36c2415 100644 --- a/tests/features/chat/host/reconnect-action.test.ts +++ b/tests/features/chat/host/reconnect-action.test.ts @@ -11,6 +11,9 @@ describe("createReconnectAction", () => { const stateStore = createChatStateStore(); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: { id: "thread-1" } as never, cwd: "/vault", model: null, diff --git a/tests/features/chat/host/session-graph.test.ts b/tests/features/chat/host/session-graph.test.ts index 937624dc..c92f20bf 100644 --- a/tests/features/chat/host/session-graph.test.ts +++ b/tests/features/chat/host/session-graph.test.ts @@ -158,6 +158,9 @@ describe("createChatPanelSessionGraph actions", () => { }); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: threadFixture({ id: "thread-1", preview: "Active" }), cwd: "/vault", model: null, diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/message-stream-presenter.test.ts index ec524455..266a8335 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/message-stream-presenter.test.ts @@ -42,6 +42,9 @@ describe("MessageStreamPresenter scroll pinning", () => { const store = createChatStateStore(chatStateFixture()); store.dispatch({ type: "active-thread/resumed", + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, thread: { id: "thread-1", preview: "", archived: false, createdAt: 1, updatedAt: 1, name: "Thread" }, cwd: "/repo", model: null, diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index f68dd0f0..cf471434 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -111,6 +111,49 @@ describe("chat panel surface projections", () => { unmountUiRoot(parent); }); + it("renders new thread permission baseline in the status panel before a thread is active", () => { + let state = chatStateFixture(); + state = chatStateWith(state, { ui: { toolbarPanel: "status-panel" } }); + state = chatStateWith(state, { + connection: { + runtimeConfig: runtimeConfigFixture({ + default_permissions: ":workspace", + approval_policy: "on-request", + approvals_reviewer: "user", + }), + }, + }); + state = chatStateWith(state, { + runtime: { + pending: { + permissions: { + approvalPolicy: { kind: "unchanged" }, + permissionProfile: { kind: "unchanged" }, + reviewer: { kind: "set", value: "auto_review" }, + }, + }, + }, + }); + + const parent = renderWithShellReadModel(state, (readModel) => + h(ChatPanelToolbar, { + model: readModel.toolbar, + surface: toolbarSurfaceFixture(), + actions: toolbarActionsFixture(), + }), + ); + + expect(parent.textContent).toContain("Permissions: New Thread"); + expect( + [...parent.querySelectorAll(".codex-panel__connection-diagnostics-section")].map((section) => section.textContent), + ).not.toContain("New thread"); + expect(parent.textContent).toContain(":workspace"); + expect(parent.textContent).toContain("on-request"); + expect(parent.textContent).toContain("auto_review"); + expect(parent.textContent).not.toContain("user -> auto_review"); + unmountUiRoot(parent); + }); + it("builds composer meta from context and runtime state", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread-1" } }); diff --git a/tests/features/chat/ui/toolbar.test.ts b/tests/features/chat/ui/toolbar.test.ts index 6a923cb6..a0c998bb 100644 --- a/tests/features/chat/ui/toolbar.test.ts +++ b/tests/features/chat/ui/toolbar.test.ts @@ -167,7 +167,11 @@ describe("Toolbar decisions", () => { toolbarActions({ refreshStatus }), ); - expect(parent.querySelector(".codex-panel__connection-diagnostics-title")?.textContent).toBe("Connection"); + expect([...parent.querySelectorAll(".codex-panel__connection-diagnostics-title")].map((title) => title.textContent)).toEqual([ + "Connection", + "Permissions: Current Thread", + "Codex capabilities", + ]); expect(parent.textContent).toContain("Codex capabilities"); expect(parent.textContent).toContain("Tool providers"); expect(parent.textContent).toContain("Process"); @@ -186,6 +190,44 @@ describe("Toolbar decisions", () => { expect(refreshStatus).toHaveBeenCalled(); }); + it("renders runtime permissions in the status menu without severity styling", () => { + const parent = document.createElement("div"); + + mountToolbar( + parent, + toolbarModel({ + statusPanelOpen: true, + openPanel: "status", + runtimePermissionsTitle: "Permissions: Current Thread", + runtimePermissions: [ + { + title: "", + rows: [ + { label: "Profile", value: ":workspace" }, + { label: "Sandbox", value: "workspace-write" }, + { label: "Network", value: "blocked" }, + { label: "Extra writable roots", value: "Vault" }, + { label: "Approval policy", value: "on-request" }, + { label: "Reviewer", value: "auto_review" }, + ], + }, + ], + toolInventory: [{ title: "Tool providers", rows: [{ label: "Tool providers", value: "loaded" }] }], + }), + toolbarActions(), + ); + + expect(parent.textContent).toContain("Permissions: Current Thread"); + expect( + [...parent.querySelectorAll(".codex-panel__connection-diagnostics-section")].map((section) => section.textContent), + ).not.toContain("Current thread"); + expect(parent.textContent).toContain(":workspace"); + expect(parent.textContent).toContain("workspace-write"); + expect(parent.textContent).toContain("auto_review"); + expect(parent.querySelector(".codex-panel__connection-diagnostics-row--warning")).toBeNull(); + expect(parent.querySelector(".codex-panel__connection-diagnostics-row--error")).toBeNull(); + }); + it("copies raw debug details from the status menu", () => { const parent = document.createElement("div"); const copyDebugDetails = vi.fn(); @@ -362,6 +404,8 @@ function toolbarModel(overrides: Partial = {}): ToolbarViewMod openPanel: null, threads: [{ title: "Thread", threadId: "thread", selected: true, disabled: false, canArchive: true, rename: null }], connectLabel: "Reconnect", + runtimePermissionsTitle: "Permissions: Current Thread", + runtimePermissions: [{ title: "", rows: [{ label: "Thread", value: "(none)" }] }], diagnostics: [{ title: "Process", rows: [{ label: "Codex App Server", value: "codex-cli/test" }] }], toolInventory: [{ title: "Tool providers", rows: [{ label: "Tool providers", value: "not loaded", level: "warning" }] }], ...overrides, diff --git a/tests/runtime/runtime-settings.test.ts b/tests/runtime/runtime-settings.test.ts index 6b82d56b..c4370ecf 100644 --- a/tests/runtime/runtime-settings.test.ts +++ b/tests/runtime/runtime-settings.test.ts @@ -31,6 +31,202 @@ describe("runtime settings", () => { expect(compactReasoningEffortLabel(null)).toBe("default"); }); + it("keeps startup permission defaults in the runtime config snapshot", () => { + expect( + runtimeConfigFixture({ + default_permissions: ":workspace", + approval_policy: "on-request", + approvals_reviewer: "auto_review", + }), + ).toMatchObject({ + approvalsReviewer: "auto_review", + startupPermissions: { + activePermissionProfile: { id: ":workspace", extends: null }, + approvalPolicy: "on-request", + sandboxPolicy: null, + }, + }); + }); + + it("deep-clones granular startup approval policy in runtime config snapshots", () => { + const config = runtimeConfigFixture({ + approval_policy: { + granular: { + sandbox_approval: true, + rules: false, + skill_approval: true, + request_permissions: false, + mcp_elicitations: true, + }, + }, + }); + + const cloned = runtimeConfigOrDefault(config); + const originalPolicy = config.startupPermissions.approvalPolicy; + const clonedPolicy = cloned.startupPermissions.approvalPolicy; + + expect(clonedPolicy).toEqual(originalPolicy); + if (!originalPolicy || typeof originalPolicy === "string" || !clonedPolicy || typeof clonedPolicy === "string") { + throw new Error("expected granular approval policy"); + } + expect(clonedPolicy).not.toBe(originalPolicy); + expect(clonedPolicy.granular).not.toBe(originalPolicy.granular); + }); + + it("uses legacy sandbox config instead of default permissions when both are reported", () => { + expect( + runtimeConfigFixture({ + default_permissions: ":workspace", + approval_policy: "on-request", + sandbox_mode: "workspace-write", + sandbox_workspace_write: { + writable_roots: ["/vault"], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + }), + ).toMatchObject({ + startupPermissions: { + activePermissionProfile: null, + approvalPolicy: "on-request", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: ["/vault"], + networkAccess: false, + }, + }, + }); + }); + + it("resolves permissions through runtime controls without falling active threads back to config", () => { + const configured = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture({ + default_permissions: ":workspace", + approval_policy: "on-request", + }), + }); + expect(resolveRuntimeControls(configured, snapshotConfig(configured)).permissions).toMatchObject({ + scope: "new-thread", + source: "config", + active: null, + reviewer: { effective: null, source: "none" }, + effective: { + activePermissionProfile: { id: ":workspace", extends: null }, + approvalPolicy: "on-request", + sandboxPolicy: null, + }, + }); + + const activeUnreported = runtimeSnapshot({ + activeThreadId: "thread", + runtimeConfig: runtimeConfigFixture({ + default_permissions: ":workspace", + approval_policy: "on-request", + }), + }); + expect(resolveRuntimeControls(activeUnreported, snapshotConfig(activeUnreported)).permissions).toMatchObject({ + scope: "current-thread", + source: "active-thread", + reviewer: { effective: null, source: "none" }, + effective: { + activePermissionProfile: null, + approvalPolicy: null, + sandboxPolicy: null, + }, + }); + + const activeReported = runtimeSnapshot({ + activeThreadId: "thread", + active: { + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly", networkAccess: false }, + activePermissionProfile: { id: ":read-only", extends: null }, + approvalsReviewer: "user", + }, + pending: { permissions: { reviewer: setRuntimeIntentValue("auto_review") } }, + }); + expect(resolveRuntimeControls(activeReported, snapshotConfig(activeReported)).permissions).toMatchObject({ + scope: "current-thread", + source: "active-thread", + reviewer: { effective: "auto_review", source: "pending" }, + effective: { + activePermissionProfile: { id: ":read-only", extends: null }, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly", networkAccess: false }, + }, + }); + }); + + it("keeps permission display scope separate from pending permission source", () => { + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + active: { + approvalPolicy: "on-request", + sandboxPolicy: { type: "readOnly", networkAccess: false }, + activePermissionProfile: null, + }, + pending: { + permissions: { + approvalPolicy: setRuntimeIntentValue("never"), + permissionProfile: setRuntimeIntentValue(":workspace"), + }, + }, + }); + + expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot)).permissions).toMatchObject({ + scope: "current-thread", + source: "pending", + effective: { + activePermissionProfile: { id: ":workspace", extends: null }, + approvalPolicy: "never", + sandboxPolicy: null, + }, + }); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ + update: { + approvalPolicy: "never", + permissions: ":workspace", + }, + }); + }); + + it("resolves permission profile reset intent back to legacy sandbox config", () => { + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + runtimeConfig: runtimeConfigFixture({ + sandbox_mode: "workspace-write", + sandbox_workspace_write: { + writable_roots: ["/vault"], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + }), + active: { + activePermissionProfile: { id: ":workspace", extends: null }, + sandboxPolicy: null, + }, + pending: { permissions: { permissionProfile: resetRuntimeIntentToConfig() } }, + }); + + expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot)).permissions).toMatchObject({ + scope: "current-thread", + source: "pending", + effective: { + activePermissionProfile: null, + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: ["/vault"], + networkAccess: false, + }, + }, + }); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ + update: { permissions: null }, + }); + }); + it("keeps runtime defaults, resets, and collaboration mode semantics distinct", () => { const snapshot = runtimeSnapshot({ pending: { @@ -152,7 +348,7 @@ describe("runtime settings", () => { const reviewerSnapshot = runtimeSnapshot({ pending: { collaborationMode: "plan", - approvalsReviewer: setRuntimeIntentValue("auto_review"), + permissions: { reviewer: setRuntimeIntentValue("auto_review") }, }, }); const activeRuntimeSnapshot = runtimeSnapshot({ @@ -187,7 +383,7 @@ describe("runtime settings", () => { it("resolves auto-review mode from requested, active, then effective config", () => { const requested = runtimeSnapshot({ - pending: { approvalsReviewer: setRuntimeIntentValue("user") }, + pending: { permissions: { reviewer: setRuntimeIntentValue("user") } }, active: { approvalsReviewer: "auto_review" }, runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "guardian_subagent" }), }); @@ -223,7 +419,7 @@ describe("runtime settings", () => { it("uses requested reviewer above active and configured reviewers", () => { const snapshot = runtimeSnapshot({ - pending: { approvalsReviewer: setRuntimeIntentValue("user") }, + pending: { permissions: { reviewer: setRuntimeIntentValue("user") } }, active: { approvalsReviewer: "user" }, runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "auto_review" }), }); @@ -367,7 +563,7 @@ describe("runtime settings", () => { ...active.pending, model: setRuntimeIntentValue("gpt-pending"), reasoningEffort: setRuntimeIntentValue("low"), - approvalsReviewer: setRuntimeIntentValue("guardian_subagent"), + permissions: { reviewer: setRuntimeIntentValue("guardian_subagent") }, fastMode: setRuntimeIntentValue("enabled"), }, }); @@ -375,19 +571,19 @@ describe("runtime settings", () => { expect(resolveRuntimeControls(configured, snapshotConfig(configured))).toMatchObject({ model: { effective: "gpt-config", source: "config" }, reasoningEffort: { effective: "medium", source: "config" }, - approvalsReviewer: { effective: "auto_review", source: "config" }, + permissions: { reviewer: { effective: "auto_review", source: "config" } }, serviceTier: { effective: "fast", source: "config" }, }); expect(resolveRuntimeControls(active, snapshotConfig(active))).toMatchObject({ model: { effective: "gpt-active", source: "active-thread" }, reasoningEffort: { effective: "high", source: "active-thread" }, - approvalsReviewer: { effective: "user", source: "active-thread" }, + permissions: { reviewer: { effective: "user", source: "active-thread" } }, serviceTier: { effective: "flex", source: "active-thread" }, }); expect(resolveRuntimeControls(pending, snapshotConfig(pending))).toMatchObject({ model: { effective: "gpt-pending", source: "pending" }, reasoningEffort: { effective: "low", source: "pending" }, - approvalsReviewer: { effective: "guardian_subagent", source: "pending" }, + permissions: { reviewer: { effective: "guardian_subagent", source: "pending" } }, serviceTier: { effective: "fast", source: "pending" }, fastMode: { active: true, source: "pending", serviceTierRequestValue: "fast" }, }); @@ -420,7 +616,7 @@ describe("runtime settings", () => { }); it("resolves requested approval reviewer without adding it to turn runtime settings", () => { - const snapshot = runtimeSnapshot({ pending: { approvalsReviewer: setRuntimeIntentValue("auto_review") } }); + const snapshot = runtimeSnapshot({ pending: { permissions: { reviewer: setRuntimeIntentValue("auto_review") } } }); expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(true); expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ @@ -663,7 +859,9 @@ describe("runtime settings", () => { interface RuntimeSnapshotPatch extends Partial> { active?: Partial; - pending?: Partial; + pending?: Partial> & { + permissions?: Partial; + }; } function runtimeSnapshot(overrides: RuntimeSnapshotPatch = {}): RuntimeSnapshot { @@ -683,11 +881,18 @@ function runtimeSnapshot(overrides: RuntimeSnapshotPatch = {}): RuntimeSnapshot collaborationMode: null, serviceTier: null, approvalsReviewer: null, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, }, pending: { model: { kind: "unchanged" }, reasoningEffort: { kind: "unchanged" }, - approvalsReviewer: { kind: "unchanged" }, + permissions: { + approvalPolicy: { kind: "unchanged" }, + permissionProfile: { kind: "unchanged" }, + reviewer: { kind: "unchanged" }, + }, collaborationMode: "default", fastMode: { kind: "unchanged" }, }, @@ -707,6 +912,10 @@ function runtimeSnapshot(overrides: RuntimeSnapshotPatch = {}): RuntimeSnapshot pending: { ...snapshot.pending, ...pending, + permissions: { + ...snapshot.pending.permissions, + ...(pending?.permissions ?? {}), + }, }, }; } diff --git a/tests/runtime/service-tier-state.test.ts b/tests/runtime/service-tier-state.test.ts index b3045130..ed48471b 100644 --- a/tests/runtime/service-tier-state.test.ts +++ b/tests/runtime/service-tier-state.test.ts @@ -29,11 +29,18 @@ function fastMode(serviceTier: string | null, serviceTiers: ModelMetadata["servi collaborationMode: "default", serviceTier, approvalsReviewer: null, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, }, pending: { model: { kind: "unchanged" }, reasoningEffort: { kind: "unchanged" }, - approvalsReviewer: { kind: "unchanged" }, + permissions: { + approvalPolicy: { kind: "unchanged" }, + permissionProfile: { kind: "unchanged" }, + reviewer: { kind: "unchanged" }, + }, collaborationMode: "default", fastMode: { kind: "unchanged" }, },