From ef30a6e762ee58709ba13b5464a2ab366cbb7f1b Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Tue, 28 Jul 2026 23:11:46 +1000 Subject: [PATCH 1/2] Add a read-only/no-tools mode, cycled via command mode --- .../src/controller/CommandIntentExecutor.ts | 7 +- .../src/controller/CommandKeyHandler.ts | 1 + apps/claude-sdk-cli/src/model/StatusState.ts | 10 ++ .../claude-sdk-cli/src/model/ToolModeState.ts | 34 ++++++ apps/claude-sdk-cli/src/permissions.ts | 8 +- apps/claude-sdk-cli/src/runAgent.ts | 5 +- .../src/setup/ConfigDisabledToolsProvider.ts | 24 +++- .../src/setup/ToolAvailabilityTracker.ts | 107 ++++++++++++++++++ .../src/setup/TurnCoordinator.ts | 10 +- apps/claude-sdk-cli/src/setup/container.ts | 5 + apps/claude-sdk-cli/src/view/renderStatus.ts | 5 +- .../test/CommandIntentExecutor.spec.ts | 2 + .../test/CommandKeyHandler.spec.ts | 2 + .../test/ConfigDisabledToolsProvider.spec.ts | 76 ++++++++++--- .../test/DisabledToolsRequestWiring.spec.ts | 2 + .../test/ToolAvailabilityTracker.spec.ts | 90 +++++++++++++++ apps/claude-sdk-cli/test/ViewHost.spec.ts | 2 + .../claude-sdk-tools/src/History/History.ts | 4 +- packages/claude-sdk-tools/src/Ref/Ref.ts | 1 + packages/claude-sdk/src/index.ts | 3 +- packages/claude-sdk/src/public/types.ts | 16 ++- 21 files changed, 389 insertions(+), 25 deletions(-) create mode 100644 apps/claude-sdk-cli/src/model/ToolModeState.ts create mode 100644 apps/claude-sdk-cli/src/setup/ToolAvailabilityTracker.ts create mode 100644 apps/claude-sdk-cli/test/ToolAvailabilityTracker.spec.ts diff --git a/apps/claude-sdk-cli/src/controller/CommandIntentExecutor.ts b/apps/claude-sdk-cli/src/controller/CommandIntentExecutor.ts index 16136122..6da4d458 100644 --- a/apps/claude-sdk-cli/src/controller/CommandIntentExecutor.ts +++ b/apps/claude-sdk-cli/src/controller/CommandIntentExecutor.ts @@ -16,9 +16,10 @@ import { IConversationState } from '../model/ConversationState.js'; import { ISystemIdentity } from '../model/ISystemIdentity.js'; import { ModelSettings } from '../model/ModelSettings.js'; import { StatusState } from '../model/StatusState.js'; +import { ToolModeState } from '../model/ToolModeState.js'; import { IWorkingDirectory } from '../model/WorkingDirectory.js'; -export type CommandIntent = 'pasteText' | 'pasteFile' | 'pasteImage' | 'removeAttachment' | 'togglePreview' | 'newSession' | 'selectPrev' | 'selectNext' | 'enterModelSubMode' | 'cycleThinking' | 'cycleEffort' | 'openModelEditor' | 'submitModel' | 'enterCdSubMode' | 'openCdEditor' | 'submitCd'; +export type CommandIntent = 'pasteText' | 'pasteFile' | 'pasteImage' | 'removeAttachment' | 'togglePreview' | 'newSession' | 'selectPrev' | 'selectNext' | 'enterModelSubMode' | 'cycleThinking' | 'cycleEffort' | 'openModelEditor' | 'submitModel' | 'enterCdSubMode' | 'openCdEditor' | 'submitCd' | 'cycleToolMode'; /** Deliberate-path test for the missing-file chip (was AppLayout.isLikelyPath). */ function isLikelyPath(s: string): boolean { @@ -54,6 +55,7 @@ export class CommandIntentExecutor { @dependsOn(IFileSystem) private readonly fs!: IFileSystem; @dependsOn(IWorkingDirectory) private readonly workingDirectory!: IWorkingDirectory; @dependsOn(IModelCatalog) private readonly modelCatalog!: IModelCatalog; + @dependsOn(ToolModeState) private readonly toolModeState!: ToolModeState; public async execute(intent: CommandIntent): Promise { try { @@ -121,6 +123,9 @@ export class CommandIntentExecutor { case 'submitCd': this.#submitCd(); return; + case 'cycleToolMode': + this.toolModeState.cycle(); + return; } } catch { // Fire-and-forget: a failed clipboard read leaves state untouched. diff --git a/apps/claude-sdk-cli/src/controller/CommandKeyHandler.ts b/apps/claude-sdk-cli/src/controller/CommandKeyHandler.ts index 356eb4d3..6d7e90f8 100644 --- a/apps/claude-sdk-cli/src/controller/CommandKeyHandler.ts +++ b/apps/claude-sdk-cli/src/controller/CommandKeyHandler.ts @@ -14,6 +14,7 @@ export const PRIMARY_COMMAND_BINDINGS: ReadonlyMap = new ['c', 'enterCdSubMode'], ['n', 'newSession'], ['m', 'enterModelSubMode'], + ['o', 'cycleToolMode'], ]); /** cd sub-menu command set: d opens the path editor. One entry by design — the diff --git a/apps/claude-sdk-cli/src/model/StatusState.ts b/apps/claude-sdk-cli/src/model/StatusState.ts index 9bab6414..08a62ec7 100644 --- a/apps/claude-sdk-cli/src/model/StatusState.ts +++ b/apps/claude-sdk-cli/src/model/StatusState.ts @@ -1,5 +1,6 @@ import EventEmitter from 'node:events'; import type { SdkMessageUsage, ThinkingEffort } from '@shellicar/claude-sdk'; +import type { ToolMode } from './ToolModeState.js'; type StatusStateEvents = { change: []; @@ -36,6 +37,7 @@ export class StatusState { #showConversationId = false; #thinkingOverride: 'on' | 'off' | null = null; #effortOverride: ThinkingEffort | null = null; + #toolMode: ToolMode = 'normal'; #cwdBasename: string; readonly #emitter = new EventEmitter(); @@ -81,6 +83,9 @@ export class StatusState { public get effortOverride(): ThinkingEffort | null { return this.#effortOverride; } + public get toolMode(): ToolMode { + return this.#toolMode; + } public get cwdBasename(): string { return this.#cwdBasename; } @@ -135,6 +140,11 @@ export class StatusState { this.#emitter.emit('change'); } + public setToolMode(mode: ToolMode): void { + this.#toolMode = mode; + this.#emitter.emit('change'); + } + /** * Replace the running totals wholesale from a derived snapshot. Called when * the figures are re-derived from the audit for the current conversation id diff --git a/apps/claude-sdk-cli/src/model/ToolModeState.ts b/apps/claude-sdk-cli/src/model/ToolModeState.ts new file mode 100644 index 00000000..6967c33f --- /dev/null +++ b/apps/claude-sdk-cli/src/model/ToolModeState.ts @@ -0,0 +1,34 @@ +import { dependsOn } from '@shellicar/core-di'; +import { StatusState } from './StatusState.js'; + +/** + * The tool-availability mode command mode cycles through `o`: `normal` offers every tool the + * config/az-account state would otherwise allow; `readOnly` narrows the wire tool list to only + * `read`/`ephemeral.read` operations (see `isReadOperation`) — Claude can look, not act, useful + * for "let's agree what to do before you go do it"; `noTools` narrows it to nothing, for "stop + * calling tools and talk to me." Cycling is a session-only concern: it does not persist across a + * restart the way the tool-availability reminder does. + */ +export type ToolMode = 'normal' | 'readOnly' | 'noTools'; + +const TOOL_MODE_CYCLE: readonly ToolMode[] = ['normal', 'readOnly', 'noTools']; + +export abstract class ToolModeState { + public abstract get mode(): ToolMode; + public abstract cycle(): void; +} + +export class ToolModeSettings extends ToolModeState { + @dependsOn(StatusState) private readonly statusState!: StatusState; + #mode: ToolMode = 'normal'; + + public get mode(): ToolMode { + return this.#mode; + } + + public cycle(): void { + const idx = TOOL_MODE_CYCLE.indexOf(this.#mode); + this.#mode = TOOL_MODE_CYCLE[(idx + 1) % TOOL_MODE_CYCLE.length] ?? 'normal'; + this.statusState.setToolMode(this.#mode); + } +} diff --git a/apps/claude-sdk-cli/src/permissions.ts b/apps/claude-sdk-cli/src/permissions.ts index c226ad78..6d4f6bd5 100644 --- a/apps/claude-sdk-cli/src/permissions.ts +++ b/apps/claude-sdk-cli/src/permissions.ts @@ -97,12 +97,18 @@ export function getPermission(tool: ToolCall, allTools: readonly PermissionTool[ if (operation === 'escalate') { return PermissionAction.Ask; } + // 'ephemeral.read'/'ephemeral.write' have no zone concept of their own either — no marked paths + // (Ref, SearchHistory, ReadHistory carry none), so they always land in the 'default' zone below. + // The matrix itself only knows read/write/delete, so an ephemeral operation maps onto its plain + // counterpart for approve/ask/deny purposes; the 'ephemeral.' half of the name only matters to + // read-only-mode gating (see isReadOperation), never to this matrix. + const matrixOperation = operation === 'ephemeral.read' ? 'read' : operation === 'ephemeral.write' ? 'write' : operation; // The marked paths in tool.input were already replaced in place by the SDK; locate them via the // schema marker and read the (normalised) values. Any path outside cwd escalates to the outside // zone, matching the pipe's Math.max escalation across steps. const paths = definition.input_schema ? collectPaths(definition.input_schema, tool.input) : []; const zone: 'default' | 'outside' = paths.some((p) => !isInsideCwd(p, cwd)) ? 'outside' : 'default'; - return matrix[zone][operation]; + return matrix[zone][matrixOperation]; } /** Names every tool with no definition — the top-level tool, or, for a pipe, each unfound step. diff --git a/apps/claude-sdk-cli/src/runAgent.ts b/apps/claude-sdk-cli/src/runAgent.ts index e5948498..88540e3b 100644 --- a/apps/claude-sdk-cli/src/runAgent.ts +++ b/apps/claude-sdk-cli/src/runAgent.ts @@ -77,7 +77,7 @@ export type RunAgentStores = { primaryViewState: IPrimaryViewState; }; -export async function runAgent(queryRunner: QueryRunner, input: RunAgentInput, stores: RunAgentStores, transformToolResult: TransformToolResult, abortController: AbortController, gitDelta?: string, skillDelta?: string | null, cwdDelta?: string | null): Promise { +export async function runAgent(queryRunner: QueryRunner, input: RunAgentInput, stores: RunAgentStores, transformToolResult: TransformToolResult, abortController: AbortController, gitDelta?: string, skillDelta?: string | null, cwdDelta?: string | null, toolsDelta?: string | null): Promise { const { conversationState, toolApprovalState, editorState, primaryViewState } = stores; // On resume there is no new user message: don't open a prompt block. @@ -97,6 +97,9 @@ export async function runAgent(queryRunner: QueryRunner, input: RunAgentInput, s if (cwdDelta) { reminders.push({ text: cwdDelta, persisted: true, position: 'leading' }); } + if (toolsDelta) { + reminders.push({ text: toolsDelta, persisted: true, position: 'leading' }); + } if (gitDelta) { reminders.push({ text: gitDelta, persisted: false, position: 'trailing' }); } diff --git a/apps/claude-sdk-cli/src/setup/ConfigDisabledToolsProvider.ts b/apps/claude-sdk-cli/src/setup/ConfigDisabledToolsProvider.ts index c2c2685b..af95462d 100644 --- a/apps/claude-sdk-cli/src/setup/ConfigDisabledToolsProvider.ts +++ b/apps/claude-sdk-cli/src/setup/ConfigDisabledToolsProvider.ts @@ -1,12 +1,18 @@ import { ConfigLoader } from '@shellicar/claude-core/Config/ConfigLoader'; -import { IDisabledToolsProvider } from '@shellicar/claude-sdk'; +import { IDisabledToolsProvider, isReadOperation } from '@shellicar/claude-sdk'; import { AZ_CLI_TOOL_NAME, ESCALATED_AZ_CLI_TOOL_NAME } from '@shellicar/claude-sdk-tools/Az'; import { ADO_PR_TOOL_NAMES } from '@shellicar/claude-sdk-tools/AzureDevOps'; import { dependsOn } from '@shellicar/core-di'; +import { ToolModeState } from '../model/ToolModeState.js'; +import { AppToolsService } from './AppToolsService.js'; export class ConfigDisabledToolsProvider extends IDisabledToolsProvider { @dependsOn(ConfigLoader) public configLoader!: ConfigLoader; + @dependsOn(ToolModeState) + public toolModeState!: ToolModeState; + @dependsOn(AppToolsService) + public appTools!: AppToolsService; /** Read fresh on every access (see `IDisabledToolsProvider`): whether any account currently has a * reader/holder identity configured is live config, so `AzCli`/`EscalatedAzCli`/the @@ -28,6 +34,22 @@ export class ConfigDisabledToolsProvider extends IDisabledToolsProvider { disabled.add(name); } } + + // The tool-availability mode (see ToolModeState) narrows the wire list further, on top of + // whatever config/az-account state already disabled above — never in place of it. 'readOnly' + // keeps only read/ephemeral.read tools; 'noTools' keeps none. + const mode = this.toolModeState.mode; + if (mode === 'noTools') { + for (const tool of this.appTools.tools) { + disabled.add(tool.name); + } + } else if (mode === 'readOnly') { + for (const tool of this.appTools.tools) { + if (!isReadOperation(tool.operation)) { + disabled.add(tool.name); + } + } + } return disabled; } } diff --git a/apps/claude-sdk-cli/src/setup/ToolAvailabilityTracker.ts b/apps/claude-sdk-cli/src/setup/ToolAvailabilityTracker.ts new file mode 100644 index 00000000..fa06a975 --- /dev/null +++ b/apps/claude-sdk-cli/src/setup/ToolAvailabilityTracker.ts @@ -0,0 +1,107 @@ +import type { Anthropic } from '@anthropic-ai/sdk'; + +const ENABLED_HEADER = 'Enabled tools:'; +const DISABLED_HEADER = 'Disabled tools:'; + +function isSystemReminderText(text: string): boolean { + const trimmed = text.trim(); + return trimmed.startsWith('') && trimmed.endsWith(''); +} + +/** Parses one previously-emitted reminder's delta back into name lists, or null when `text` isn't + * one of ours. Tool names never contain '.' or ',', so splitting on the header/period/comma + * boundaries is unambiguous. */ +function parseDelta(text: string): { enabled: string[]; disabled: string[] } | null { + if (!isSystemReminderText(text)) { + return null; + } + const inner = text.trim().slice(''.length, -''.length).trim(); + if (!inner.startsWith(ENABLED_HEADER) && !inner.startsWith(DISABLED_HEADER)) { + return null; + } + const enabledMatch = inner.match(/Enabled tools: ([^.]+)\./); + const disabledMatch = inner.match(/Disabled tools: ([^.]+)\./); + const splitNames = (s: string): string[] => + s + .split(',') + .map((n) => n.trim()) + .filter((n) => n.length > 0); + return { + enabled: enabledMatch ? splitNames(enabledMatch[1] ?? '') : [], + disabled: disabledMatch ? splitNames(disabledMatch[1] ?? '') : [], + }; +} + +function formatDelta(enabled: readonly string[], disabled: readonly string[]): string { + const parts: string[] = []; + if (enabled.length > 0) { + parts.push(`${ENABLED_HEADER} ${[...enabled].sort().join(', ')}.`); + } + if (disabled.length > 0) { + parts.push(`${DISABLED_HEADER} ${[...disabled].sort().join(', ')}.`); + } + return parts.join(' '); +} + +/** + * Tells the model which tools it currently has, as a delta rather than a repeated full list — a + * single tool flipping never re-announces the other 99. + * + * On the first call this process makes, the baseline is reconstructed by replaying every reminder + * this tracker ever emitted, in order, out of the persisted conversation history — so a restart + * loses nothing without the tracker needing its own persisted state. Finding nothing (fresh + * conversation, or history compacted past every prior reminder) reconstructs an empty baseline, + * which is not a special case: diffing the live set against empty naturally produces a full + * "Enabled tools:" opener with nothing in "Disabled tools:". + * + * Every call after the first behaves like `CwdTracker`/`SkillCatalogueTracker`: an in-memory diff + * against the previous call's result, updated (and only emitted) when something actually changed; + * `messages` is ignored once seeded. Call this once per turn, with the live set as computed at the + * point a message is actually about to be built and sent — never speculatively, and never advanced + * by an attempt that didn't land — so a cancel-and-resend recomputes the same diff against the same + * unmoved baseline rather than skipping or doubling it. + */ +export class ToolAvailabilityTracker { + #known: Set | null = null; + + #seedFromHistory(messages: readonly Anthropic.Beta.Messages.BetaMessageParam[]): Set { + const known = new Set(); + for (const msg of messages) { + if (msg.role !== 'user' || !Array.isArray(msg.content)) { + continue; + } + for (const block of msg.content) { + if (block.type !== 'text') { + continue; + } + const delta = parseDelta(block.text); + if (delta == null) { + continue; + } + for (const name of delta.enabled) { + known.add(name); + } + for (const name of delta.disabled) { + known.delete(name); + } + } + } + return known; + } + + /** Returns the delta reminder text for this query, or null when nothing changed. On the first + * call this process makes, the baseline is reconstructed by replaying `messages` (see class + * doc); every call after that diffs against the in-memory result of the previous call, and + * `messages` is ignored. Never advances state for a message that hasn't actually been sent — + * call this once, at the point a query is actually being built, not speculatively. */ + public scanForDelta(messages: readonly Anthropic.Beta.Messages.BetaMessageParam[], liveEnabled: ReadonlySet): string | null { + const previous = this.#known ?? this.#seedFromHistory(messages); + const enabled = [...liveEnabled].filter((name) => !previous.has(name)); + const disabled = [...previous].filter((name) => !liveEnabled.has(name)); + this.#known = new Set(liveEnabled); + if (enabled.length === 0 && disabled.length === 0) { + return null; + } + return formatDelta(enabled, disabled); + } +} diff --git a/apps/claude-sdk-cli/src/setup/TurnCoordinator.ts b/apps/claude-sdk-cli/src/setup/TurnCoordinator.ts index f0add86e..4eb92c54 100644 --- a/apps/claude-sdk-cli/src/setup/TurnCoordinator.ts +++ b/apps/claude-sdk-cli/src/setup/TurnCoordinator.ts @@ -1,5 +1,5 @@ import { ConfigLoader } from '@shellicar/claude-core/Config/ConfigLoader'; -import { IDurableConfigProvider, QueryRunner } from '@shellicar/claude-sdk'; +import { IConversation, IDisabledToolsProvider, IDurableConfigProvider, QueryRunner } from '@shellicar/claude-sdk'; import { dependsOn } from '@shellicar/core-di'; import { ClaudeMdLoader } from '../ClaudeMdLoader.js'; import { IConvChangePublisher } from '../conv/ConvChangePublisher.js'; @@ -22,6 +22,7 @@ import { CwdTracker } from './CwdTracker.js'; import { ModelOverrides } from './ModelOverrides.js'; import { ISdkEventBridge } from './SdkEventBridge.js'; import { SkillCatalogueTracker } from './SkillCatalogueTracker.js'; +import { ToolAvailabilityTracker } from './ToolAvailabilityTracker.js'; /** The coordinator's contract; register abstract→concrete and depend on the abstract (DI rule). */ export abstract class ITurnCoordinator { @@ -62,6 +63,9 @@ export class TurnCoordinator extends ITurnCoordinator { @dependsOn(GitStateMonitor) private readonly gitMonitor!: GitStateMonitor; @dependsOn(SkillCatalogueTracker) private readonly skillTracker!: SkillCatalogueTracker; @dependsOn(CwdTracker) private readonly cwdTracker!: CwdTracker; + @dependsOn(ToolAvailabilityTracker) private readonly toolAvailabilityTracker!: ToolAvailabilityTracker; + @dependsOn(IDisabledToolsProvider) private readonly disabledToolsProvider!: IDisabledToolsProvider; + @dependsOn(IConversation) private readonly conversation!: IConversation; @dependsOn(QueryRunner) private readonly queryRunner!: QueryRunner; @dependsOn(IConversationState) private readonly conversationState!: IConversationState; @dependsOn(IToolApprovalState) private readonly toolApprovalState!: IToolApprovalState; @@ -130,6 +134,9 @@ export class TurnCoordinator extends ITurnCoordinator { // reminder on the user message. First scan of the process records the baseline and returns null. const skillDelta = await this.skillTracker.scanForDelta(); const cwdDelta = this.cwdTracker.scanForDelta(); + const disabledNames = this.disabledToolsProvider.disabledTools; + const liveEnabledNames = new Set(this.appTools.tools.filter((t) => !disabledNames.has(t.name)).map((t) => t.name)); + const toolsDelta = this.toolAvailabilityTracker.scanForDelta(this.conversation.messages, liveEnabledNames); const agentInput = buildRunAgentInput(userInput); await runAgent( this.queryRunner, @@ -145,6 +152,7 @@ export class TurnCoordinator extends ITurnCoordinator { gitDelta, skillDelta, cwdDelta, + toolsDelta, ); await this.gitMonitor.takeSnapshot(); diff --git a/apps/claude-sdk-cli/src/setup/container.ts b/apps/claude-sdk-cli/src/setup/container.ts index ca165c0b..a8b6d0bb 100644 --- a/apps/claude-sdk-cli/src/setup/container.ts +++ b/apps/claude-sdk-cli/src/setup/container.ts @@ -117,6 +117,7 @@ import { StreamInterruptNotice } from '../model/StreamInterruptNotice.js'; import { SystemIdentity } from '../model/SystemIdentity.js'; import { ITerminalState, TerminalState } from '../model/TerminalState.js'; import { IToolApprovalState, ToolApprovalState } from '../model/ToolApprovalState.js'; +import { ToolModeSettings, ToolModeState } from '../model/ToolModeState.js'; import { TurnClock } from '../model/TurnClock.js'; import { IWorkingDirectory, WorkingDirectory } from '../model/WorkingDirectory.js'; import { DatabaseFactory } from '../persistence/DatabaseFactory.js'; @@ -157,6 +158,7 @@ import { IShutdownCoordinator, ShutdownCoordinator } from './ShutdownCoordinator import { IShutdownSequence, ShutdownSequence } from './ShutdownSequence.js'; import { SkillCatalogueTracker } from './SkillCatalogueTracker.js'; import { SkillGateProvider } from './SkillGateProvider.js'; +import { ToolAvailabilityTracker } from './ToolAvailabilityTracker.js'; import { ITurnCoordinator, TurnCoordinator } from './TurnCoordinator.js'; import { IWorkingDirectoryMoveHandler, WorkingDirectoryMoveHandler } from './WorkingDirectoryMoveHandler.js'; @@ -393,6 +395,7 @@ export function buildContainer(options: ContainerOptions): IServiceCollection { services.register(DurableConfigFactory).as(IDurableConfigProvider); services.register(SkillCatalogueTracker).asSelf(); services.register(CwdTracker).asSelf(); + services.register(ToolAvailabilityTracker).asSelf(); // SdkChannel and ISdkMessagePublisher share identity from this one register() call. services.register(SdkChannel).asSelf().as(ISdkMessagePublisher); services.register(ConsumerChannel).asSelf(); @@ -422,6 +425,8 @@ export function buildContainer(options: ContainerOptions): IServiceCollection { services.register(NodeSipsBridge).asSelf().as(SipsBridge); // ModelOverrides and ModelSettings share identity from this one register() call. services.register(ModelOverrides).asSelf().as(ModelSettings); + // ToolModeSettings and ToolModeState share identity from this one register() call. + services.register(ToolModeSettings).asSelf().as(ToolModeState); // --- state stores --- services diff --git a/apps/claude-sdk-cli/src/view/renderStatus.ts b/apps/claude-sdk-cli/src/view/renderStatus.ts index 4d75a6c6..54576e3b 100644 --- a/apps/claude-sdk-cli/src/view/renderStatus.ts +++ b/apps/claude-sdk-cli/src/view/renderStatus.ts @@ -27,16 +27,17 @@ export function renderModel(state: StatusState, _cols: number, conversationId: s const model = state.model; const thinking = state.thinkingOverride === 'on' ? ` ${BOLD_WHITE}*thinking${RESET}` : state.thinkingOverride === 'off' ? ` ${BOLD_WHITE}*no thinking${RESET}` : ''; const effort = state.effortOverride != null ? ` ${BOLD_WHITE}*effort:${state.effortOverride}${RESET}` : ''; + const toolMode = state.toolMode === 'readOnly' ? ` ${BOLD_WHITE}*read-only${RESET}` : state.toolMode === 'noTools' ? ` ${BOLD_WHITE}*no-tools${RESET}` : ''; const idSuffix = state.showConversationId && conversationId ? ` ${conversationId}` : ''; const identity = state.identityName != null ? ` ${CYAN}${state.identityName}${RESET}` : ''; const buildVersion = ` ${DIM}v${versionInfo.version}${RESET}`; if (!model) { - return ` ${label}${identity}${thinking}${effort}${idSuffix}${buildVersion}`; + return ` ${label}${identity}${thinking}${effort}${toolMode}${idSuffix}${buildVersion}`; } const { name, version } = parseModelName(model); const versionPart = version != null ? ` ${version}` : ''; const overridePart = state.isModelOverridden ? '*' : ''; - return ` ${YELLOW}⚡ ${name}${versionPart}${overridePart}${RESET} ${label}${identity}${thinking}${effort}${idSuffix}${buildVersion}`; + return ` ${YELLOW}⚡ ${name}${versionPart}${overridePart}${RESET} ${label}${identity}${thinking}${effort}${toolMode}${idSuffix}${buildVersion}`; } function formatTokens(n: number): string { diff --git a/apps/claude-sdk-cli/test/CommandIntentExecutor.spec.ts b/apps/claude-sdk-cli/test/CommandIntentExecutor.spec.ts index ef4d91aa..2afa2308 100644 --- a/apps/claude-sdk-cli/test/CommandIntentExecutor.spec.ts +++ b/apps/claude-sdk-cli/test/CommandIntentExecutor.spec.ts @@ -20,6 +20,7 @@ import { ISystemIdentity } from '../src/model/ISystemIdentity.js'; import { ModelSettings } from '../src/model/ModelSettings.js'; import { StatusState } from '../src/model/StatusState.js'; import { SystemIdentity } from '../src/model/SystemIdentity.js'; +import { ToolModeSettings, ToolModeState } from '../src/model/ToolModeState.js'; import { IWorkingDirectory, WorkingDirectory } from '../src/model/WorkingDirectory.js'; import { ISqliteSessionStore, SqliteSessionStore } from '../src/persistence/SqliteSessionStore.js'; import { FakeAttachmentSource } from './FakeAttachmentSource.js'; @@ -119,6 +120,7 @@ function makeExecutor(source: AttachmentSource) { .using(() => ({ instanceId: 'inst-test', world: 'test', boot: () => {}, attach: () => {}, detach: () => {}, stop: () => {} })) .asSelf(); services.register(WorkingDirectory).asSelf().as(IWorkingDirectory); + services.register(ToolModeSettings).asSelf().as(ToolModeState); services.register(CommandIntentExecutor).asSelf(); const provider = services.buildProvider(); const executor = provider.resolve(CommandIntentExecutor); diff --git a/apps/claude-sdk-cli/test/CommandKeyHandler.spec.ts b/apps/claude-sdk-cli/test/CommandKeyHandler.spec.ts index 6fa19e63..01fa0a10 100644 --- a/apps/claude-sdk-cli/test/CommandKeyHandler.spec.ts +++ b/apps/claude-sdk-cli/test/CommandKeyHandler.spec.ts @@ -21,6 +21,7 @@ import { ISystemIdentity } from '../src/model/ISystemIdentity.js'; import { ModelSettings } from '../src/model/ModelSettings.js'; import { StatusState } from '../src/model/StatusState.js'; import { SystemIdentity } from '../src/model/SystemIdentity.js'; +import { ToolModeSettings, ToolModeState } from '../src/model/ToolModeState.js'; import { IWorkingDirectory, WorkingDirectory } from '../src/model/WorkingDirectory.js'; import { ISqliteSessionStore, SqliteSessionStore } from '../src/persistence/SqliteSessionStore.js'; import { FakeAttachmentSource } from './FakeAttachmentSource.js'; @@ -119,6 +120,7 @@ function makeHandler(sourceText: string | null = null) { .using(() => ({ instanceId: 'inst-test', world: 'test', boot: () => {}, attach: () => {}, detach: () => {}, stop: () => {} })) .asSelf(); services.register(WorkingDirectory).asSelf().as(IWorkingDirectory); + services.register(ToolModeSettings).asSelf().as(ToolModeState); services.register(CommandIntentExecutor).asSelf(); services.register(CommandKeyHandler).asSelf(); const handler = services.buildProvider().resolve(CommandKeyHandler); diff --git a/apps/claude-sdk-cli/test/ConfigDisabledToolsProvider.spec.ts b/apps/claude-sdk-cli/test/ConfigDisabledToolsProvider.spec.ts index ffe11df5..cc406b46 100644 --- a/apps/claude-sdk-cli/test/ConfigDisabledToolsProvider.spec.ts +++ b/apps/claude-sdk-cli/test/ConfigDisabledToolsProvider.spec.ts @@ -1,5 +1,8 @@ import { ConfigLoader } from '@shellicar/claude-core/Config/ConfigLoader'; +import type { AnyToolDefinition } from '@shellicar/claude-sdk'; import { describe, expect, it } from 'vitest'; +import { StatusState } from '../src/model/StatusState.js'; +import { ToolModeSettings } from '../src/model/ToolModeState.js'; import { ConfigDisabledToolsProvider } from '../src/setup/ConfigDisabledToolsProvider.js'; type AzAccounts = Record; @@ -8,18 +11,29 @@ function makeLoader(disabledTools: string[], azAccounts: AzAccounts = {}): Confi return new ConfigLoader({ config: { disabledTools, az: { accounts: azAccounts } }, sources: [], warnings: [] }); } +/** The provider's toolModeState/appTools deps are set directly here (as configLoader already is + * above) rather than through DI — a fresh ToolModeSettings stays in its default 'normal' mode + * unless a test calls .cycle(), so it never disables anything on its own. */ +function makeProvider(loader: ConfigLoader, tools: AnyToolDefinition[] = []): ConfigDisabledToolsProvider { + const provider = new ConfigDisabledToolsProvider(); + provider.configLoader = loader; + const toolModeState = new ToolModeSettings(); + (toolModeState as unknown as { statusState: StatusState }).statusState = new StatusState('cwd'); + provider.toolModeState = toolModeState; + provider.appTools = { tools } as unknown as ConfigDisabledToolsProvider['appTools']; + return provider; +} + describe('ConfigDisabledToolsProvider', () => { it('reflects the config loader disabledTools as a set', () => { - const provider = new ConfigDisabledToolsProvider(); - provider.configLoader = makeLoader(['ExecV3']); + const provider = makeProvider(makeLoader(['ExecV3'])); const actual = provider.disabledTools; expect(actual.has('ExecV3')).toBe(true); }); it('reads the config loader live, reflecting an applied config change', () => { const loader = makeLoader([]); - const provider = new ConfigDisabledToolsProvider(); - provider.configLoader = loader; + const provider = makeProvider(loader); loader.apply({ config: { disabledTools: ['DeleteFile'], az: { accounts: {} } }, sources: [], warnings: [] }); const actual = provider.disabledTools; expect(actual.has('DeleteFile')).toBe(true); @@ -27,22 +41,19 @@ describe('ConfigDisabledToolsProvider', () => { describe('az/AzureDevOps tool availability', () => { it('disables AzCli when no account has a reader identity configured', () => { - const provider = new ConfigDisabledToolsProvider(); - provider.configLoader = makeLoader([]); + const provider = makeProvider(makeLoader([])); const actual = provider.disabledTools.has('AzCli'); expect(actual).toBe(true); }); it('does not disable AzCli when an account has a reader identity configured', () => { - const provider = new ConfigDisabledToolsProvider(); - provider.configLoader = makeLoader([], { shellicar: { tenantId: 't', reader: { type: 'cert', clientId: 'r', subscriptionIds: [] }, holder: null } }); + const provider = makeProvider(makeLoader([], { shellicar: { tenantId: 't', reader: { type: 'cert', clientId: 'r', subscriptionIds: [] }, holder: null } })); const actual = provider.disabledTools.has('AzCli'); expect(actual).toBe(false); }); it('disables EscalatedAzCli and every AzureDevOps.PullRequest.* tool when no account has a holder identity configured', () => { - const provider = new ConfigDisabledToolsProvider(); - provider.configLoader = makeLoader([]); + const provider = makeProvider(makeLoader([])); const disabled = provider.disabledTools; const expected = true; const actual = disabled.has('EscalatedAzCli') && disabled.has('AzureDevOps_PullRequest_Create'); @@ -50,8 +61,7 @@ describe('ConfigDisabledToolsProvider', () => { }); it('does not disable EscalatedAzCli or AzureDevOps.PullRequest.* tools when an account has a holder identity configured', () => { - const provider = new ConfigDisabledToolsProvider(); - provider.configLoader = makeLoader([], { shellicar: { tenantId: 't', reader: null, holder: { type: 'cert', clientId: 'h', subscriptionIds: [] } } }); + const provider = makeProvider(makeLoader([], { shellicar: { tenantId: 't', reader: null, holder: { type: 'cert', clientId: 'h', subscriptionIds: [] } } })); const disabled = provider.disabledTools; const expected = false; const actual = disabled.has('EscalatedAzCli') || disabled.has('AzureDevOps_PullRequest_Create'); @@ -60,11 +70,49 @@ describe('ConfigDisabledToolsProvider', () => { it('reflects a config reload adding a holder account, with no rebuild', () => { const loader = makeLoader([]); - const provider = new ConfigDisabledToolsProvider(); - provider.configLoader = loader; + const provider = makeProvider(loader); loader.apply({ config: { disabledTools: [], az: { accounts: { shellicar: { tenantId: 't', reader: null, holder: { type: 'cert', clientId: 'h', subscriptionIds: [] } } } } }, sources: [], warnings: [] }); const actual = provider.disabledTools.has('EscalatedAzCli'); expect(actual).toBe(false); }); }); + + describe('tool-mode gating', () => { + const readTool = { name: 'ReadFile', operation: 'read' } as AnyToolDefinition; + const ephemeralReadTool = { name: 'Ref', operation: 'ephemeral.read' } as AnyToolDefinition; + const writeTool = { name: 'EditFile', operation: 'write' } as AnyToolDefinition; + const escalateTool = { name: 'EscalatedAzCli', operation: 'escalate' } as AnyToolDefinition; + + it('disables nothing extra in normal mode', () => { + const provider = makeProvider(makeLoader([]), [readTool, writeTool]); + const actual = provider.disabledTools.has('EditFile'); + expect(actual).toBe(false); + }); + + it('keeps read tools enabled in read-only mode', () => { + const provider = makeProvider(makeLoader([]), [readTool, ephemeralReadTool, writeTool]); + provider.toolModeState.cycle(); + const actual = provider.disabledTools.has('ReadFile') || provider.disabledTools.has('Ref'); + expect(actual).toBe(false); + }); + + it('disables write and escalate tools in read-only mode', () => { + const provider = makeProvider(makeLoader([]), [readTool, writeTool, escalateTool]); + provider.toolModeState.cycle(); + const disabled = provider.disabledTools; + const expected = true; + const actual = disabled.has('EditFile') && disabled.has('EscalatedAzCli'); + expect(actual).toBe(expected); + }); + + it('disables every tool, including read ones, in no-tools mode', () => { + const provider = makeProvider(makeLoader([]), [readTool, ephemeralReadTool, writeTool]); + provider.toolModeState.cycle(); + provider.toolModeState.cycle(); + const disabled = provider.disabledTools; + const expected = true; + const actual = disabled.has('ReadFile') && disabled.has('Ref') && disabled.has('EditFile'); + expect(actual).toBe(expected); + }); + }); }); diff --git a/apps/claude-sdk-cli/test/DisabledToolsRequestWiring.spec.ts b/apps/claude-sdk-cli/test/DisabledToolsRequestWiring.spec.ts index b3f1a71d..26c35504 100644 --- a/apps/claude-sdk-cli/test/DisabledToolsRequestWiring.spec.ts +++ b/apps/claude-sdk-cli/test/DisabledToolsRequestWiring.spec.ts @@ -31,6 +31,7 @@ import { createServiceCollection, Lifetime } from '@shellicar/core-di'; import { describe, expect, it } from 'vitest'; import { sdkConfigSchema } from '../src/cli-config/schema.js'; import { StatusState } from '../src/model/StatusState.js'; +import { ToolModeSettings, ToolModeState } from '../src/model/ToolModeState.js'; import { SystemPromptLoader } from '../src/SystemPromptLoader.js'; import { AppToolsService } from '../src/setup/AppToolsService.js'; import { ConfigDisabledToolsProvider } from '../src/setup/ConfigDisabledToolsProvider.js'; @@ -145,6 +146,7 @@ function buildHarness(tools: AnyToolDefinition[], disabledTools: string[]) { services.register(NoopLogger).as(ILogger); services.register(DurableConfigFactory).as(IDurableConfigProvider); services.register(ConfigDisabledToolsProvider).as(IDisabledToolsProvider); + services.register(ToolModeSettings).asSelf().as(ToolModeState); // Mirrors container.ts: ToolRegistry built from the tool list plus the live disabledToolsProvider. services .register(IToolRegistry) diff --git a/apps/claude-sdk-cli/test/ToolAvailabilityTracker.spec.ts b/apps/claude-sdk-cli/test/ToolAvailabilityTracker.spec.ts new file mode 100644 index 00000000..76b39d48 --- /dev/null +++ b/apps/claude-sdk-cli/test/ToolAvailabilityTracker.spec.ts @@ -0,0 +1,90 @@ +import type { Anthropic } from '@anthropic-ai/sdk'; +import { describe, expect, it } from 'vitest'; +import { ToolAvailabilityTracker } from '../src/setup/ToolAvailabilityTracker.js'; + +type Message = Anthropic.Beta.Messages.BetaMessageParam; + +function reminderMessage(text: string): Message { + return { role: 'user', content: [{ type: 'text', text: `\n${text}\n\n` }] }; +} + +describe('ToolAvailabilityTracker — fresh conversation', () => { + it('announces every enabled tool as newly enabled when history holds no prior reminder', () => { + const tracker = new ToolAvailabilityTracker(); + const actual = tracker.scanForDelta([], new Set(['A', 'B'])); + expect(actual).toBe('Enabled tools: A, B.'); + }); + + it('announces nothing on the next call when nothing changed', () => { + const tracker = new ToolAvailabilityTracker(); + tracker.scanForDelta([], new Set(['A', 'B'])); + const actual = tracker.scanForDelta([], new Set(['A', 'B'])); + expect(actual).toBe(null); + }); +}); + +describe('ToolAvailabilityTracker — live changes', () => { + it('announces only the newly enabled tool when one is added', () => { + const tracker = new ToolAvailabilityTracker(); + tracker.scanForDelta([], new Set(['A', 'B'])); + const actual = tracker.scanForDelta([], new Set(['A', 'B', 'C'])); + expect(actual).toBe('Enabled tools: C.'); + }); + + it('announces only the newly disabled tool when one is removed', () => { + const tracker = new ToolAvailabilityTracker(); + tracker.scanForDelta([], new Set(['A', 'B'])); + const actual = tracker.scanForDelta([], new Set(['A'])); + expect(actual).toBe('Disabled tools: B.'); + }); + + it('announces both halves together when tools are simultaneously added and removed', () => { + const tracker = new ToolAvailabilityTracker(); + tracker.scanForDelta([], new Set(['A', 'B'])); + const actual = tracker.scanForDelta([], new Set(['A', 'C'])); + expect(actual).toBe('Enabled tools: C. Disabled tools: B.'); + }); +}); + +describe('ToolAvailabilityTracker — seeding from history', () => { + it('reconstructs the baseline by replaying every reminder in order', () => { + const history: Message[] = [reminderMessage('Enabled tools: A, B.'), { role: 'assistant', content: 'ok' }, reminderMessage('Enabled tools: C. Disabled tools: B.')]; + const tracker = new ToolAvailabilityTracker(); + // Reconstructed baseline is {A, C}; live set adds nothing and removes nothing. + const actual = tracker.scanForDelta(history, new Set(['A', 'C'])); + expect(actual).toBe(null); + }); + + it('diffs the live set against the reconstructed baseline, not the full history', () => { + const history: Message[] = [reminderMessage('Enabled tools: A, B.')]; + const tracker = new ToolAvailabilityTracker(); + const actual = tracker.scanForDelta(history, new Set(['A', 'C'])); + expect(actual).toBe('Enabled tools: C. Disabled tools: B.'); + }); + + it('ignores unrelated text blocks that are not one of its own reminders', () => { + const history: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'Enabled tools: X. (typed by a person, not wrapped in a system-reminder)' }] }]; + const tracker = new ToolAvailabilityTracker(); + const actual = tracker.scanForDelta(history, new Set(['A'])); + expect(actual).toBe('Enabled tools: A.'); + }); +}); + +describe('ToolAvailabilityTracker — cancel and resend', () => { + it('does not re-announce the same delta on a resend after a cancel, when nothing changed in between', () => { + const tracker = new ToolAvailabilityTracker(); + const first = tracker.scanForDelta([], new Set(['A', 'B'])); + // The cancelled attempt's message still landed (see class doc): the second call is the resend, + // computed against the same unmoved baseline. + const second = tracker.scanForDelta([], new Set(['A', 'B'])); + expect(first).toBe('Enabled tools: A, B.'); + expect(second).toBe(null); + }); + + it('reflects the mode at the time of the eventual send, not at an earlier cancelled attempt', () => { + const tracker = new ToolAvailabilityTracker(); + tracker.scanForDelta([], new Set(['A', 'B'])); // cancelled attempt, still landed + const actual = tracker.scanForDelta([], new Set(['A'])); // resend, mode changed in between + expect(actual).toBe('Disabled tools: B.'); + }); +}); diff --git a/apps/claude-sdk-cli/test/ViewHost.spec.ts b/apps/claude-sdk-cli/test/ViewHost.spec.ts index 9e3df5a8..aae95576 100644 --- a/apps/claude-sdk-cli/test/ViewHost.spec.ts +++ b/apps/claude-sdk-cli/test/ViewHost.spec.ts @@ -35,6 +35,7 @@ import { StatusState } from '../src/model/StatusState.js'; import { SystemIdentity } from '../src/model/SystemIdentity.js'; import { ITerminalState, TerminalState } from '../src/model/TerminalState.js'; import { IToolApprovalState, ToolApprovalState } from '../src/model/ToolApprovalState.js'; +import { ToolModeSettings, ToolModeState } from '../src/model/ToolModeState.js'; import { TurnClock } from '../src/model/TurnClock.js'; import { IWorkingDirectory, WorkingDirectory } from '../src/model/WorkingDirectory.js'; import { ISqliteSessionStore } from '../src/persistence/SqliteSessionStore.js'; @@ -307,6 +308,7 @@ describe('ViewHost — escape routing through the primary chains', () => { .using(() => ({ instanceId: 'inst-test', world: 'test', boot: () => {}, attach: () => {}, detach: () => {}, stop: () => {} })) .asSelf(); services.register(WorkingDirectory).as(IWorkingDirectory); + services.register(ToolModeSettings).asSelf().as(ToolModeState); services.register(CommandIntentExecutor).asSelf(); services.register(ApprovalHandler).asSelf(); services.register(CommandKeyHandler).asSelf(); diff --git a/packages/claude-sdk-tools/src/History/History.ts b/packages/claude-sdk-tools/src/History/History.ts index 97d87f77..5e27d191 100644 --- a/packages/claude-sdk-tools/src/History/History.ts +++ b/packages/claude-sdk-tools/src/History/History.ts @@ -35,7 +35,7 @@ function resolveBound(value: string | undefined, edge: TimeBoundEdge, clock: Clo export function createHistoryTools(reader: IHistoryReader, currentSessionId: () => string, clock: Clock) { const SearchHistory = defineTool({ name: 'SearchHistory', - operation: 'read', + operation: 'ephemeral.read', description: 'Search your past conversations by relevance and get back ranked, cited snippets. A citation is a session id plus a turn id; pass one (or several) to ReadHistory to open the full exchange around it. Thinking is indexed and ranks on par with prose — the reasoning in a thinking block is often the most descriptive account of what a piece of work was.', input_schema: SearchHistoryInputSchema, @@ -53,7 +53,7 @@ export function createHistoryTools(reader: IHistoryReader, currentSessionId: () const ReadHistory = defineTool({ name: 'ReadHistory', - operation: 'read', + operation: 'ephemeral.read', description: 'Open the full exchange around one or more search citations. Each citation is a { session, turnId } from a SearchHistory hit; the shared `window` sets how many turns either side of each centre to include. Each event text is capped so one giant tool_result cannot flood context.', input_schema: ReadHistoryInputSchema, output_schema: ReadHistoryOutputSchema, diff --git a/packages/claude-sdk-tools/src/Ref/Ref.ts b/packages/claude-sdk-tools/src/Ref/Ref.ts index b7a44d87..7ba3190d 100644 --- a/packages/claude-sdk-tools/src/Ref/Ref.ts +++ b/packages/claude-sdk-tools/src/Ref/Ref.ts @@ -13,6 +13,7 @@ export type CreateRefResult = { export function createRef(store: RefStore, threshold: number): CreateRefResult { const tool = defineTool({ name: 'Ref', + operation: 'ephemeral.read', description: `Fetch the content of a stored ref. When a tool result contains { ref, size, hint } instead of the full value, use this tool to retrieve it. Returns at most \`limit\` characters starting at \`start\`. Both default (start=0, limit=10000) so a bare { id } call gives the first 10000 chars — safe for arbitrarily large refs. The response includes \`hint\` (what produced the ref), \`totalSize\`, and the slice bounds so you know whether to page further.`, input_schema: RefInputSchema, output_schema: RefOutputSchema, diff --git a/packages/claude-sdk/src/index.ts b/packages/claude-sdk/src/index.ts index b5ca3a7e..b3b4c4aa 100644 --- a/packages/claude-sdk/src/index.ts +++ b/packages/claude-sdk/src/index.ts @@ -60,7 +60,7 @@ import type { TransformToolResult, WakeLockHandle, } from './public/types'; -import { AccountLimitListener, IRequestClockListener, IToolBlockNotifier, IToolsClockListener, StreamInterruptListener } from './public/types'; +import { AccountLimitListener, IRequestClockListener, IToolBlockNotifier, IToolsClockListener, isReadOperation, StreamInterruptListener } from './public/types'; export type { BetaMessage, BetaMessageParam } from '@anthropic-ai/sdk/resources/beta.js'; export type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta.mjs'; @@ -141,6 +141,7 @@ export { IToolsClockListener, ITurnRunner, IWakeLock, + isReadOperation, ModelCatalog, normalisePaths, pathSchema, diff --git a/packages/claude-sdk/src/public/types.ts b/packages/claude-sdk/src/public/types.ts index b70e6664..4b9eed7b 100644 --- a/packages/claude-sdk/src/public/types.ts +++ b/packages/claude-sdk/src/public/types.ts @@ -9,7 +9,21 @@ import type { AnthropicBeta, CacheTtl } from './enums'; // outside), which a config can set to auto-approve (e.g. autoApproveEdits). Escalate is for a tool // that crosses a privilege boundary no zone or config auto-approve should ever cover — it always // asks, unconditionally (see permissions.ts getPermission). Not part of the configurable matrix. -export type ToolOperation = 'read' | 'write' | 'delete' | 'escalate'; +// +// The 'ephemeral.*' pair is for a tool with no filesystem/zone footprint at all — no marked paths, +// so the cwd-zone matrix does not apply (e.g. Ref, SearchHistory, ReadHistory: a local index query +// or in-process cache, never `fs.read`). It still has to answer read-or-write for read-only-mode +// gating purposes (see IDisabledToolsProvider): 'ephemeral.read' behaves like 'read' there, +// 'ephemeral.write' like 'write'. Use isReadOperation() for that test rather than comparing to +// 'read' directly, so a caller never has to remember both spellings. +export type ToolOperation = 'read' | 'write' | 'delete' | 'escalate' | 'ephemeral.read' | 'ephemeral.write'; + +/** True for 'read' and 'ephemeral.read' — the operations a read-only mode should leave enabled. + * An undefined operation is never read-only-safe by this test; callers that treat "no operation" + * as its own case (e.g. getPermission's `?? 'read'` default) do that explicitly, not via this. */ +export function isReadOperation(operation: ToolOperation | undefined): boolean { + return operation === 'read' || operation === 'ephemeral.read'; +} export type ToolHandlerResult = { textContent: TOutput; From 52d7446e5b9d8eab79f1b2b8b36eb61590c3e7cd Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Tue, 28 Jul 2026 23:35:00 +1000 Subject: [PATCH 2/2] Cover ephemeral.read/write mapping onto the permission matrix --- apps/claude-sdk-cli/test/permissions.spec.ts | 24 ++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/claude-sdk-cli/test/permissions.spec.ts b/apps/claude-sdk-cli/test/permissions.spec.ts index 0e1ec6af..e1da8f89 100644 --- a/apps/claude-sdk-cli/test/permissions.spec.ts +++ b/apps/claude-sdk-cli/test/permissions.spec.ts @@ -24,7 +24,7 @@ const matrix: PermissionConfig = { // getPermission locates a tool's paths via its schema's isPath marker. Paths arrive already expanded // (the SDK replaced them in place upstream), so getPermission does no expansion — the stub only needs // a real marked schema so the marked field can be found and zoned by cwd. -function toolDef(name: string, operation: 'read' | 'write' | 'delete' | 'escalate', input_schema: PermissionTool['input_schema']): PermissionTool { +function toolDef(name: string, operation: 'read' | 'write' | 'delete' | 'escalate' | 'ephemeral.read' | 'ephemeral.write', input_schema: PermissionTool['input_schema']): PermissionTool { return { name, operation, input_schema }; } @@ -32,7 +32,15 @@ const readFileSchema = z.object({ path: pathSchema }); const editFileSchema = z.object({ file: pathSchema }); const deleteFileSchema = z.object({ files: z.array(pathSchema) }); -const allTools: PermissionTool[] = [toolDef('ReadFile', 'read', readFileSchema), toolDef('EditFile', 'write', editFileSchema), toolDef('DeleteFile', 'delete', deleteFileSchema)]; +const noPathSchema = z.object({}); + +const allTools: PermissionTool[] = [ + toolDef('ReadFile', 'read', readFileSchema), + toolDef('EditFile', 'write', editFileSchema), + toolDef('DeleteFile', 'delete', deleteFileSchema), + toolDef('Ref', 'ephemeral.read', noPathSchema), + toolDef('HypotheticalEphemeralWrite', 'ephemeral.write', noPathSchema), +]; // --------------------------------------------------------------------------- // inside cwd @@ -56,6 +64,18 @@ describe('getPermission — inside cwd', () => { const actual = getPermission({ name: 'DeleteFile', input: { files: [`${CWD}/src/file.ts`] } }, allTools, CWD, matrix); expect(actual).toBe(expected); }); + + it('ephemeral.read → the same as read (Approve)', () => { + const expected = PermissionAction.Approve; + const actual = getPermission({ name: 'Ref', input: {} }, allTools, CWD, matrix); + expect(actual).toBe(expected); + }); + + it('ephemeral.write → the same as write (Approve)', () => { + const expected = PermissionAction.Approve; + const actual = getPermission({ name: 'HypotheticalEphemeralWrite', input: {} }, allTools, CWD, matrix); + expect(actual).toBe(expected); + }); }); // ---------------------------------------------------------------------------