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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions apps/claude-sdk-cli/src/cli-config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,20 +199,22 @@ const defaultZonePermissionsSchema = z
read: permissionActionSchema.optional().default('approve').catch('approve').describe('Action for read operations'),
write: permissionActionSchema.optional().default('approve').catch('approve').describe('Action for write operations'),
delete: permissionActionSchema.optional().default('ask').catch('ask').describe('Action for delete operations'),
reflog: permissionActionSchema.optional().default('ask').catch('ask').describe("Action for reflog operations — replaces reachable state with new state recoverable only through the underlying system's own undo mechanism (e.g. git reflog), not through this tool"),
})
.optional()
.default({ read: 'approve', write: 'approve', delete: 'ask' })
.catch({ read: 'approve', write: 'approve', delete: 'ask' });
.default({ read: 'approve', write: 'approve', delete: 'ask', reflog: 'ask' })
.catch({ read: 'approve', write: 'approve', delete: 'ask', reflog: 'ask' });

const outsideZonePermissionsSchema = z
.object({
read: permissionActionSchema.optional().default('approve').catch('approve').describe('Action for read operations'),
write: permissionActionSchema.optional().default('ask').catch('ask').describe('Action for write operations'),
delete: permissionActionSchema.optional().default('deny').catch('deny').describe('Action for delete operations'),
reflog: permissionActionSchema.optional().default('deny').catch('deny').describe("Action for reflog operations — replaces reachable state with new state recoverable only through the underlying system's own undo mechanism (e.g. git reflog), not through this tool"),
})
.optional()
.default({ read: 'approve', write: 'ask', delete: 'deny' })
.catch({ read: 'approve', write: 'ask', delete: 'deny' });
.default({ read: 'approve', write: 'ask', delete: 'deny', reflog: 'deny' })
.catch({ read: 'approve', write: 'ask', delete: 'deny', reflog: 'deny' });

const permissionsSchema = z
.object({
Expand All @@ -221,12 +223,12 @@ const permissionsSchema = z
})
.optional()
.default({
default: { read: 'approve', write: 'approve', delete: 'ask' },
outside: { read: 'approve', write: 'ask', delete: 'deny' },
default: { read: 'approve', write: 'approve', delete: 'ask', reflog: 'ask' },
outside: { read: 'approve', write: 'ask', delete: 'deny', reflog: 'deny' },
})
.catch({
default: { read: 'approve', write: 'approve', delete: 'ask' },
outside: { read: 'approve', write: 'ask', delete: 'deny' },
default: { read: 'approve', write: 'approve', delete: 'ask', reflog: 'ask' },
outside: { read: 'approve', write: 'ask', delete: 'deny', reflog: 'deny' },
});

const persistenceSchema = z
Expand Down
72 changes: 72 additions & 0 deletions apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,82 @@ export function formatMemoryResult(name: string, content: string): string | null
}
}

// The target field alone (a ref, a branch name, a path) doesn't say *why* a git call is happening —
// two calls with identical args can have opposite reasons (Git_Diff against origin/main to confirm
// a merge landed clean, or to hunt a regression). intent carries the why; this carries the what, so
// the rendered line has both: 'Git_Rebase: bring the branch up to date — origin/main'.
function formatGitDetail(name: string, input: Record<string, unknown>): string | null {
const str = (key: string): string | undefined => (typeof input[key] === 'string' ? (input[key] as string) : undefined);
const paths = Array.isArray(input.paths) ? (input.paths as unknown[]).filter((p): p is string => typeof p === 'string') : undefined;

switch (name) {
case 'Git_CreateBranch': {
const nm = str('name');
const from = str('from');
return nm ? (from ? `${nm} from ${from}` : nm) : null;
}
case 'Git_SwitchBranch':
case 'Git_DeleteBranchForce':
return str('name') ?? null;
case 'Git_Commit':
case 'Git_AmendCommit': {
const message = str('message');
return message ? `"${message}"` : null;
}
case 'Git_Rebase':
return str('base') ?? null;
case 'Git_RebaseOnto': {
const oldBase = str('oldBase');
const newBase = str('newBase');
const branch = str('branch');
return oldBase && newBase && branch ? `${branch}: ${oldBase} \u2192 ${newBase}` : null;
}
case 'Git_Show':
case 'Git_Blame':
return str('ref') ?? null;
case 'Git_Diff':
case 'Git_Log':
return str('ref') ?? str('path') ?? null;
case 'Git_Push':
case 'Git_Fetch':
case 'Git_Pull':
case 'Git_ForcePushWithLease': {
const remote = str('remote');
const branch = str('branch');
return remote || branch ? [remote, branch].filter((part): part is string => part != null).join(' ') : null;
}
case 'Git_StashDrop':
case 'Git_StashApply':
return str('stashRef') ?? null;
case 'Git_Add':
case 'Git_UnstageFile':
case 'Git_RemoveFile':
case 'Git_RemoveCachedFile':
case 'Git_DiscardFileChanges':
case 'Git_ForceRemoveFile':
if (paths && paths.length > 0) {
return paths.length === 1 ? paths[0] : `${paths[0]} (+${paths.length - 1} more)`;
}
return null;
default:
return null;
}
}

export function formatGitSummary(name: string, input: Record<string, unknown>): string {
const intent = typeof input.intent === 'string' ? input.intent : '';
const head = intent ? `${name}: ${intent}` : name;
const detail = formatGitDetail(name, input);
return detail ? `${head} \u2014 ${detail}` : head;
}

function formatToolSummary(name: string, input: Record<string, unknown>, cwd: string, store: RefStore, resolveSchema: (toolName: string) => AnyToolDefinition['input_schema'] | undefined): string {
if (MEMORY_TOOLS.has(name)) {
return formatMemorySummary(name, input);
}
if (name.startsWith('Git_')) {
return formatGitSummary(name, input);
}
if (name === 'Ref') {
return formatRefSummary(input, store);
}
Expand Down
6 changes: 4 additions & 2 deletions apps/claude-sdk-cli/src/createAppTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { IHistoryReader } from '@shellicar/claude-core/history/interfaces';
import type { ILogger } from '@shellicar/claude-core/logging/ILogger';
import type { IMemoryStore } from '@shellicar/claude-core/memory/interfaces';
import type { IObjectStore } from '@shellicar/claude-core/persistence/interfaces';
import type { AnyToolDefinition, ToolBlockLifetime } from '@shellicar/claude-sdk';
import { type AnyToolDefinition, type ToolBlockLifetime, ToolOperation } from '@shellicar/claude-sdk';
import { AppendFile } from '@shellicar/claude-sdk-tools/AppendFile';
import { type AzAccountsConfig, azExecutor, createAzTools } from '@shellicar/claude-sdk-tools/Az';
import { adoExecutor, createAdoPrTools } from '@shellicar/claude-sdk-tools/AzureDevOps';
Expand All @@ -17,6 +17,7 @@ import { Exec } from '@shellicar/claude-sdk-tools/Exec';
import { ExecV2 } from '@shellicar/claude-sdk-tools/ExecV2';
import { configureExecV3, type IEnvProvider, type IRulesConfigProvider } from '@shellicar/claude-sdk-tools/ExecV3';
import { Find } from '@shellicar/claude-sdk-tools/Find';
import { createGitTools, gitExecutor } from '@shellicar/claude-sdk-tools/Git';
import { createGhPrTools, ghExecutor } from '@shellicar/claude-sdk-tools/GitHub';
import { Head } from '@shellicar/claude-sdk-tools/Head';
import { createHistoryTools } from '@shellicar/claude-sdk-tools/History';
Expand Down Expand Up @@ -111,6 +112,7 @@ export function createAppTools({ fs, tsServer, toolsConfig, rulesProvider, objec
tools.push(createSkillTool(fs, skillDirs, logger));
tools.push(...createHistoryTools(history, currentSessionId, clock));
tools.push(...createGhPrTools({ executor: ghExecutor, getHolderToken: () => secrets.ghHolderToken() }));
tools.push(...createGitTools({ executor: gitExecutor, fs }, { enableUnrecoverable: false, protectDefaultBranch: true }));

// The AzureDevOps.PullRequest.* tools run as the same holder identity EscalatedAzCli uses — one
// certificate, proven to authenticate to Azure DevOps directly (see AzCli's runAz), no separate
Expand Down Expand Up @@ -155,6 +157,6 @@ export function createAppTools({ fs, tsServer, toolsConfig, rulesProvider, objec
// step up by name and reads its operation and input_schema (to locate marked paths), so it needs them
// too — projected rather than carried whole, so no runnable (and, uninvoked, crash-prone) stage
// handler comes along. A composable stage's path-schema is its `model` (its standalone input face).
const permissionTools: PermissionTool[] = [...tools.map((t) => ({ name: t.name, operation: t.operation, input_schema: t.input_schema })), ...stages.map((t) => ({ name: t.name, operation: t.operation, input_schema: t.model }))];
const permissionTools: PermissionTool[] = [...tools.map((t) => ({ name: t.name, operation: t.operation, input_schema: t.input_schema })), ...stages.map((t) => ({ name: t.name, operation: ToolOperation.Read, input_schema: t.model }))];
return { tools, permissionTools, store, refTransform };
}
6 changes: 4 additions & 2 deletions apps/claude-sdk-cli/src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ function isPipeTool(tool: ToolCall): tool is PipeToolCall {
return tool.name === 'Pipe';
}

export type ZonePermissions = { read: PermissionAction; write: PermissionAction; delete: PermissionAction };
export type ZonePermissions = { read: PermissionAction; write: PermissionAction; delete: PermissionAction; reflog: PermissionAction };
export type PermissionConfig = { default: ZonePermissions; outside: ZonePermissions };

type ZonePermissionsConfig = { read: PermissionActionOutput; write: PermissionActionOutput; delete: PermissionActionOutput };
type ZonePermissionsConfig = { read: PermissionActionOutput; write: PermissionActionOutput; delete: PermissionActionOutput; reflog: PermissionActionOutput };
type PermissionMatrixConfig = { default: ZonePermissionsConfig; outside: ZonePermissionsConfig };

const permissionActionByName = {
Expand All @@ -58,11 +58,13 @@ export function buildPermissionMatrix(config: PermissionMatrixConfig): Permissio
read: permissionActionByName[config.default.read],
write: permissionActionByName[config.default.write],
delete: permissionActionByName[config.default.delete],
reflog: permissionActionByName[config.default.reflog],
},
outside: {
read: permissionActionByName[config.outside.read],
write: permissionActionByName[config.outside.write],
delete: permissionActionByName[config.outside.delete],
reflog: permissionActionByName[config.outside.reflog],
},
};
}
Expand Down
26 changes: 13 additions & 13 deletions apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { GREEN, RESET } from '@shellicar/claude-core/ansi';
import { ConfigLoader } from '@shellicar/claude-core/Config/ConfigLoader';
import { IFileSystem } from '@shellicar/claude-core/fs/interfaces';
import { ILogger } from '@shellicar/claude-core/logging/ILogger';
import { type AnyToolDefinition, CacheTtl, type ConsumerMessage, Conversation, type DurableConfig, IDurableConfigProvider, pathSchema } from '@shellicar/claude-sdk';
import { type AnyToolDefinition, CacheTtl, type ConsumerMessage, Conversation, type DurableConfig, IDurableConfigProvider, pathSchema, ToolOperation } from '@shellicar/claude-sdk';
import { RefStore } from '@shellicar/claude-sdk-tools/RefStore';
import { createServiceCollection } from '@shellicar/core-di-lite';
import { describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -634,7 +634,7 @@ describe('AgentMessageHandler — tool_use_input_delta', () => {

describe('AgentMessageHandler — tool_use_input_stop', () => {
it('resolves the tool to its summary when input stops', () => {
const { handler, conversationState } = makeHandler({ config: { tools: [makeTool('ReadFile', 'read')] } });
const { handler, conversationState } = makeHandler({ config: { tools: [makeTool('ReadFile', ToolOperation.Read)] } });
handler.handle({ type: 'tool_batch_start' });
handler.handle({ type: 'tool_use_start', id: 'toolu_01', name: 'ReadFile' });
// The input arrives parsed on the stop event; the tool flips to its resolved view.
Expand All @@ -645,7 +645,7 @@ describe('AgentMessageHandler — tool_use_input_stop', () => {
});

it('renders the Skill tool with its skill name argument', () => {
const { handler, conversationState } = makeHandler({ config: { tools: [makeTool('Skill', 'read')] } });
const { handler, conversationState } = makeHandler({ config: { tools: [makeTool('Skill', ToolOperation.Read)] } });
handler.handle({ type: 'tool_batch_start' });
handler.handle({ type: 'tool_use_start', id: 'toolu_01', name: 'Skill' });
handler.handle({ type: 'tool_use_input_stop', id: 'toolu_01', input: { skill: 'git' } });
Expand All @@ -666,7 +666,7 @@ describe('AgentMessageHandler — tool_approval_request', () => {
const neverResolves = new Promise<boolean>(() => {});
toolApprovalState.requestApproval = () => neverResolves;
const { handler, conversationState } = makeHandler({
config: { tools: [makeTool('DeleteFile', 'delete')] },
config: { tools: [makeTool('DeleteFile', ToolOperation.Delete)] },
toolApprovalState,
});
streamTool(handler, 'toolu_01', 'DeleteFile');
Expand All @@ -691,7 +691,7 @@ describe('AgentMessageHandler — tool_approval_request', () => {

it('a pipe with an unknown step is reported as tool-not-found, naming the step', async () => {
const sends: ConsumerMessage[] = [];
const { handler } = makeHandler({ config: { tools: [makeTool('Find', 'read')] }, onSend: (m) => sends.push(m) });
const { handler } = makeHandler({ config: { tools: [makeTool('Find', ToolOperation.Read)] }, onSend: (m) => sends.push(m) });
const pipeInput = {
steps: [
{ tool: 'Find', input: { path: '.' } },
Expand All @@ -709,7 +709,7 @@ describe('AgentMessageHandler — tool_approval_request', () => {

it('auto-denies a delete outside cwd without a reason claiming user rejection', async () => {
const sends: ConsumerMessage[] = [];
const { handler } = makeHandler({ config: { tools: [makeTool('DeleteFile', 'delete')] }, onSend: (m) => sends.push(m) });
const { handler } = makeHandler({ config: { tools: [makeTool('DeleteFile', ToolOperation.Delete)] }, onSend: (m) => sends.push(m) });
// default matrix: outside.delete = 'deny' — settles without a prompt (PermissionAction.Deny).
const input = { path: '/outside/file.txt' };
streamTool(handler, 'toolu_01', 'DeleteFile', input);
Expand All @@ -723,7 +723,7 @@ describe('AgentMessageHandler — tool_approval_request', () => {

it('an auto-denied tool carries a reason distinct from a user rejection', async () => {
const sends: ConsumerMessage[] = [];
const { handler } = makeHandler({ config: { tools: [makeTool('DeleteFile', 'delete')] }, onSend: (m) => sends.push(m) });
const { handler } = makeHandler({ config: { tools: [makeTool('DeleteFile', ToolOperation.Delete)] }, onSend: (m) => sends.push(m) });
const input = { path: '/outside/file.txt' };
streamTool(handler, 'toolu_01', 'DeleteFile', input);
handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'DeleteFile', input });
Expand All @@ -735,7 +735,7 @@ describe('AgentMessageHandler — tool_approval_request', () => {
});

it('records auto-approved status for a read tool', () => {
const { handler, conversationState } = makeHandler({ config: { tools: [makeTool('Find', 'read')] } });
const { handler, conversationState } = makeHandler({ config: { tools: [makeTool('Find', ToolOperation.Read)] } });
streamTool(handler, 'toolu_01', 'Find');
handler.handle({ type: 'tool_approval_request', requestId: 'toolu_01', name: 'Find', input: {} });
const expected = true;
Expand All @@ -746,7 +746,7 @@ describe('AgentMessageHandler — tool_approval_request', () => {
it('records manual approval after user input for a delete tool', async () => {
const toolApprovalState = new ToolApprovalState();
const { handler, conversationState } = makeHandler({
config: { tools: [makeTool('DeleteFile', 'delete')] },
config: { tools: [makeTool('DeleteFile', ToolOperation.Delete)] },
toolApprovalState,
});
streamTool(handler, 'toolu_01', 'DeleteFile');
Expand All @@ -761,7 +761,7 @@ describe('AgentMessageHandler — tool_approval_request', () => {
it('records manual denial after user input for a delete tool', async () => {
const toolApprovalState = new ToolApprovalState();
const { handler, conversationState } = makeHandler({
config: { tools: [makeTool('DeleteFile', 'delete')] },
config: { tools: [makeTool('DeleteFile', ToolOperation.Delete)] },
toolApprovalState,
});
streamTool(handler, 'toolu_01', 'DeleteFile');
Expand All @@ -778,7 +778,7 @@ describe('AgentMessageHandler — tool_approval_request', () => {
const neverResolves = new Promise<boolean>(() => {});
toolApprovalState.requestApproval = () => neverResolves;
const { handler, conversationState } = makeHandler({
config: { tools: [makeTool('DeleteFile', 'delete')] },
config: { tools: [makeTool('DeleteFile', ToolOperation.Delete)] },
toolApprovalState,
});
streamTool(handler, 'toolu_01', 'DeleteFile');
Expand All @@ -793,7 +793,7 @@ describe('AgentMessageHandler — tool_approval_request', () => {

it('shows both tools approved after both auto-approvals complete', () => {
const { handler, conversationState } = makeHandler({
config: { tools: [makeTool('Find', 'read'), makeTool('ReadFile', 'read')] },
config: { tools: [makeTool('Find', ToolOperation.Read), makeTool('ReadFile', ToolOperation.Read)] },
});
streamTool(handler, 'toolu_01', 'Find');
streamTool(handler, 'toolu_02', 'ReadFile', {}, false); // same batch
Expand Down Expand Up @@ -831,7 +831,7 @@ describe('AgentMessageHandler + ApprovalHandler — batch approval identity', ()
const sends: ConsumerMessage[] = [];
const toolApprovalState = new ToolApprovalState();
const { handler } = makeHandler({
config: { tools: [makeTool('DeleteFile', 'delete')] },
config: { tools: [makeTool('DeleteFile', ToolOperation.Delete)] },
toolApprovalState,
onSend: (m) => sends.push(m),
});
Expand Down
12 changes: 6 additions & 6 deletions apps/claude-sdk-cli/test/cli-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ describe('sdkConfigSchema', () => {
disabledTools: [],
statusBar: { showConversationId: true },
permissions: {
default: { read: 'approve', write: 'approve', delete: 'ask' },
outside: { read: 'approve', write: 'ask', delete: 'deny' },
default: { read: 'approve', write: 'approve', delete: 'ask', reflog: 'ask' },
outside: { read: 'approve', write: 'ask', delete: 'deny', reflog: 'deny' },
},
preventSleep: { enabled: true, platforms: { macos: 'caffeinate', windows: null, linux: null } },
persistence: { database: 'persistence.db' },
Expand Down Expand Up @@ -289,8 +289,8 @@ describe('sdkConfigSchema', () => {
it('defaults to the current permission matrix', () => {
const config = parse({});
const expected = {
default: { read: 'approve', write: 'approve', delete: 'ask' },
outside: { read: 'approve', write: 'ask', delete: 'deny' },
default: { read: 'approve', write: 'approve', delete: 'ask', reflog: 'ask' },
outside: { read: 'approve', write: 'ask', delete: 'deny', reflog: 'deny' },
};
const actual = config.permissions;
expect(actual).toEqual(expected);
Expand All @@ -299,8 +299,8 @@ describe('sdkConfigSchema', () => {
it('falls back to defaults on invalid value', () => {
const config = parse({ permissions: 'bad' });
const expected = {
default: { read: 'approve', write: 'approve', delete: 'ask' },
outside: { read: 'approve', write: 'ask', delete: 'deny' },
default: { read: 'approve', write: 'approve', delete: 'ask', reflog: 'ask' },
outside: { read: 'approve', write: 'ask', delete: 'deny', reflog: 'deny' },
};
const actual = config.permissions;
expect(actual).toEqual(expected);
Expand Down
Loading
Loading