diff --git a/apps/claude-sdk-cli/src/cli-config/schema.ts b/apps/claude-sdk-cli/src/cli-config/schema.ts index 8d4991da..7f778f09 100644 --- a/apps/claude-sdk-cli/src/cli-config/schema.ts +++ b/apps/claude-sdk-cli/src/cli-config/schema.ts @@ -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({ @@ -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 diff --git a/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts b/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts index aedb50c6..2c41361b 100644 --- a/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts +++ b/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts @@ -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 | 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 { + 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, 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); } diff --git a/apps/claude-sdk-cli/src/createAppTools.ts b/apps/claude-sdk-cli/src/createAppTools.ts index c7437677..0c3866d9 100644 --- a/apps/claude-sdk-cli/src/createAppTools.ts +++ b/apps/claude-sdk-cli/src/createAppTools.ts @@ -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'; @@ -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'; @@ -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 @@ -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 }; } diff --git a/apps/claude-sdk-cli/src/permissions.ts b/apps/claude-sdk-cli/src/permissions.ts index c226ad78..d2812b64 100644 --- a/apps/claude-sdk-cli/src/permissions.ts +++ b/apps/claude-sdk-cli/src/permissions.ts @@ -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 = { @@ -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], }, }; } diff --git a/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts index d83e3554..7fe557c8 100644 --- a/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts +++ b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts @@ -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'; @@ -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. @@ -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' } }); @@ -666,7 +666,7 @@ describe('AgentMessageHandler — tool_approval_request', () => { const neverResolves = new Promise(() => {}); toolApprovalState.requestApproval = () => neverResolves; const { handler, conversationState } = makeHandler({ - config: { tools: [makeTool('DeleteFile', 'delete')] }, + config: { tools: [makeTool('DeleteFile', ToolOperation.Delete)] }, toolApprovalState, }); streamTool(handler, 'toolu_01', 'DeleteFile'); @@ -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: '.' } }, @@ -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); @@ -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 }); @@ -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; @@ -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'); @@ -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'); @@ -778,7 +778,7 @@ describe('AgentMessageHandler — tool_approval_request', () => { const neverResolves = new Promise(() => {}); toolApprovalState.requestApproval = () => neverResolves; const { handler, conversationState } = makeHandler({ - config: { tools: [makeTool('DeleteFile', 'delete')] }, + config: { tools: [makeTool('DeleteFile', ToolOperation.Delete)] }, toolApprovalState, }); streamTool(handler, 'toolu_01', 'DeleteFile'); @@ -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 @@ -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), }); diff --git a/apps/claude-sdk-cli/test/cli-config.spec.ts b/apps/claude-sdk-cli/test/cli-config.spec.ts index 96b089ff..36199427 100644 --- a/apps/claude-sdk-cli/test/cli-config.spec.ts +++ b/apps/claude-sdk-cli/test/cli-config.spec.ts @@ -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' }, @@ -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); @@ -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); diff --git a/apps/claude-sdk-cli/test/createAppTools.spec.ts b/apps/claude-sdk-cli/test/createAppTools.spec.ts index b6327132..aa091d25 100644 --- a/apps/claude-sdk-cli/test/createAppTools.spec.ts +++ b/apps/claude-sdk-cli/test/createAppTools.spec.ts @@ -41,8 +41,8 @@ const tsServer = { const PIPE_STAGES = ['Read', 'Match', 'Head', 'Tail', 'Range']; const CWD = '/project'; const permMatrix: PermissionConfig = { - default: { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Ask }, - outside: { read: PermissionAction.Approve, write: PermissionAction.Ask, delete: PermissionAction.Deny }, + default: { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Ask, reflog: PermissionAction.Ask }, + outside: { read: PermissionAction.Approve, write: PermissionAction.Ask, delete: PermissionAction.Deny, reflog: PermissionAction.Deny }, }; describe('createAppTools — permission resolution for pipe stages', () => { diff --git a/apps/claude-sdk-cli/test/permissions.spec.ts b/apps/claude-sdk-cli/test/permissions.spec.ts index 0e1ec6af..ed2d971e 100644 --- a/apps/claude-sdk-cli/test/permissions.spec.ts +++ b/apps/claude-sdk-cli/test/permissions.spec.ts @@ -1,4 +1,4 @@ -import { pathSchema } from '@shellicar/claude-sdk'; +import { pathSchema, ToolOperation } from '@shellicar/claude-sdk'; import { describe, expect, it } from 'vitest'; import { z } from 'zod'; import type { PermissionConfig, PermissionTool } from '../src/permissions.js'; @@ -13,18 +13,20 @@ const matrix: PermissionConfig = { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Ask, + reflog: PermissionAction.Ask, }, outside: { read: PermissionAction.Approve, write: PermissionAction.Ask, delete: PermissionAction.Deny, + reflog: PermissionAction.Deny, }, }; // 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: ToolOperation, input_schema: PermissionTool['input_schema']): PermissionTool { return { name, operation, input_schema }; } @@ -32,7 +34,9 @@ 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 gitRebaseSchema = z.object({ base: z.string() }); + +const allTools: PermissionTool[] = [toolDef('ReadFile', ToolOperation.Read, readFileSchema), toolDef('EditFile', ToolOperation.Write, editFileSchema), toolDef('DeleteFile', ToolOperation.Delete, deleteFileSchema), toolDef('Git_Rebase', ToolOperation.Reflog, gitRebaseSchema)]; // --------------------------------------------------------------------------- // inside cwd @@ -56,6 +60,12 @@ describe('getPermission — inside cwd', () => { const actual = getPermission({ name: 'DeleteFile', input: { files: [`${CWD}/src/file.ts`] } }, allTools, CWD, matrix); expect(actual).toBe(expected); }); + + it('reflog → Ask', () => { + const expected = PermissionAction.Ask; + const actual = getPermission({ name: 'Git_Rebase', input: { base: 'origin/main' } }, allTools, CWD, matrix); + expect(actual).toBe(expected); + }); }); // --------------------------------------------------------------------------- @@ -80,6 +90,17 @@ describe('getPermission — outside cwd', () => { const actual = getPermission({ name: 'DeleteFile', input: { files: ['/tmp/file.ts'] } }, allTools, CWD, matrix); expect(actual).toBe(expected); }); + + it('reflog → Deny', () => { + // Git_Rebase's own schema carries no marked path (its `base` is a plain string, not pathSchema), + // so it always resolves against the default zone regardless of cwd — there is no "outside" for it + // to reach. A schema with a marked path proves the zone mechanism itself applies to reflog the + // same as any other operation. + const reflogWithPath: PermissionTool[] = [toolDef('SomeReflogTool', ToolOperation.Reflog, z.object({ cwd: pathSchema }))]; + const expected = PermissionAction.Deny; + const actual = getPermission({ name: 'SomeReflogTool', input: { cwd: '/tmp/some-repo' } }, reflogWithPath, CWD, matrix); + expect(actual).toBe(expected); + }); }); // --------------------------------------------------------------------------- @@ -115,7 +136,7 @@ describe('getPermission — Pipe', () => { describe('getPermission — Pipe with a stage step', () => { // The stages (Read, Match, …) carry no path and are read tools; once present in the lookup list // a pipe containing them must resolve to read, not the not-found Deny. - const withStages: PermissionTool[] = [...allTools, toolDef('Find', 'read', z.object({ path: pathSchema })), toolDef('Read', 'read', z.object({})), toolDef('Match', 'read', z.object({ pattern: z.string().optional() }))]; + const withStages: PermissionTool[] = [...allTools, toolDef('Find', ToolOperation.Read, z.object({ path: pathSchema })), toolDef('Read', ToolOperation.Read, z.object({})), toolDef('Match', ToolOperation.Read, z.object({ pattern: z.string().optional() }))]; it('a pipe whose steps include a stage is not auto-denied', () => { const expected = PermissionAction.Approve; @@ -213,15 +234,51 @@ describe('getPermission — reads the replaced (already-expanded) path', () => { // escalate — never reachable as Approve, regardless of config // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// reflog — configurable via the zone matrix, unlike escalate +// --------------------------------------------------------------------------- + +describe('getPermission — reflog operation', () => { + // Unlike escalate, reflog IS governed by the matrix: an all-Approve config really does approve it. + // This is the behavioural proof that reflog earned its own enum member instead of reusing escalate + // (which can never be auto-approved) or delete (which defaults deny-outside instead of ask-outside). + const autoApproveEverything: PermissionConfig = { + default: { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Approve, reflog: PermissionAction.Approve }, + outside: { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Approve, reflog: PermissionAction.Approve }, + }; + + it('resolves to Approve when the matrix auto-approves it, unlike escalate which never can', () => { + const expected = PermissionAction.Approve; + const actual = getPermission({ name: 'Git_Rebase', input: { base: 'origin/main' } }, allTools, CWD, autoApproveEverything); + expect(actual).toBe(expected); + }); + + it('resolves independently of delete\u2019s own zone value', () => { + // default.delete = Ask, default.reflog = Ask here too, but they are read from distinct matrix + // columns — change reflog alone and delete must be untouched, proving they don't alias one field. + const distinctColumns: PermissionConfig = { + default: { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Ask, reflog: PermissionAction.Deny }, + outside: { read: PermissionAction.Approve, write: PermissionAction.Ask, delete: PermissionAction.Deny, reflog: PermissionAction.Deny }, + }; + const expectedReflog = PermissionAction.Deny; + const actualReflog = getPermission({ name: 'Git_Rebase', input: { base: 'origin/main' } }, allTools, CWD, distinctColumns); + expect(actualReflog).toBe(expectedReflog); + + const expectedDelete = PermissionAction.Ask; + const actualDelete = getPermission({ name: 'DeleteFile', input: { files: [`${CWD}/src/file.ts`] } }, allTools, CWD, distinctColumns); + expect(actualDelete).toBe(expectedDelete); + }); +}); + describe('getPermission — escalate operation', () => { // A matrix where every zone/operation is set to auto-approve, the way an autoApproveEdits-style // config would configure ordinary writes. An escalate tool must still resolve to Ask: the whole // point is that no config value, zone included, can turn it into Approve. const autoApproveEverything: PermissionConfig = { - default: { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Approve }, - outside: { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Approve }, + default: { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Approve, reflog: PermissionAction.Approve }, + outside: { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Approve, reflog: PermissionAction.Approve }, }; - const escalateTools: PermissionTool[] = [toolDef('GitHub_PullRequest_Create', 'escalate', z.object({ title: z.string(), body: z.string(), base: z.string() }))]; + const escalateTools: PermissionTool[] = [toolDef('GitHub_PullRequest_Create', ToolOperation.Escalate, z.object({ title: z.string(), body: z.string(), base: z.string() }))]; it('resolves to Ask even when the matrix auto-approves every other operation', () => { const expected = PermissionAction.Ask; diff --git a/packages/claude-sdk-tools/package.json b/packages/claude-sdk-tools/package.json index 412ab95f..30f919e1 100644 --- a/packages/claude-sdk-tools/package.json +++ b/packages/claude-sdk-tools/package.json @@ -345,6 +345,16 @@ "types": "./dist/cjs/Az.d.cts", "default": "./dist/cjs/Az.cjs" } + }, + "./Git": { + "import": { + "types": "./dist/esm/Git.d.ts", + "default": "./dist/esm/Git.js" + }, + "require": { + "types": "./dist/cjs/Git.d.cts", + "default": "./dist/cjs/Git.cjs" + } } }, "scripts": { diff --git a/packages/claude-sdk-tools/src/AppendFile/AppendFile.ts b/packages/claude-sdk-tools/src/AppendFile/AppendFile.ts index d2991888..a84488f6 100644 --- a/packages/claude-sdk-tools/src/AppendFile/AppendFile.ts +++ b/packages/claude-sdk-tools/src/AppendFile/AppendFile.ts @@ -1,12 +1,12 @@ import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; -import { defineTool } from '@shellicar/claude-sdk'; +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; import { AppendFileInputSchema, AppendFileOutputSchema } from './schema'; export function createAppendFile(fs: IFileSystem) { return defineTool({ name: 'AppendFile', description: 'Appends text to the end of a file, creating the file (and any missing parent directories) if it does not exist. Content is written verbatim.', - operation: 'write', + operation: ToolOperation.Write, input_schema: AppendFileInputSchema, output_schema: AppendFileOutputSchema, input_examples: [{ path: './log.txt', content: 'a line\n' }], diff --git a/packages/claude-sdk-tools/src/Az/tools.ts b/packages/claude-sdk-tools/src/Az/tools.ts index 4695d9f1..cf1b71bd 100644 --- a/packages/claude-sdk-tools/src/Az/tools.ts +++ b/packages/claude-sdk-tools/src/Az/tools.ts @@ -1,5 +1,6 @@ import type { Clock } from '@js-joda/core'; import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { ToolOperation } from '@shellicar/claude-sdk'; import { AzSessionCache } from './AzSessionCache'; import { createAzTool } from './createAzTool'; import type { AzDeps } from './runAz'; @@ -47,7 +48,7 @@ export function createAzTools(deps: AzDeps, accounts: AzAccountsConfig, clock: C createAzTool( { name: 'AzCli', - operation: 'write', + operation: ToolOperation.Write, description: 'Run an Azure CLI (`az`) command under the unprivileged reader identity of a configured account.', input_schema: createAzInputSchema(readerAccounts), identity: 'reader', @@ -64,7 +65,7 @@ export function createAzTools(deps: AzDeps, accounts: AzAccountsConfig, clock: C createAzTool( { name: 'EscalatedAzCli', - operation: 'escalate', + operation: ToolOperation.Escalate, description: 'Run an Azure CLI (`az`) command under the privileged holder identity of a configured account. Always asks for approval first.', input_schema: createAzInputSchema(holderAccounts), identity: 'holder', diff --git a/packages/claude-sdk-tools/src/AzureDevOps/createAdoAutoMergeTool.ts b/packages/claude-sdk-tools/src/AzureDevOps/createAdoAutoMergeTool.ts index e2190c59..f7f30bc3 100644 --- a/packages/claude-sdk-tools/src/AzureDevOps/createAdoAutoMergeTool.ts +++ b/packages/claude-sdk-tools/src/AzureDevOps/createAdoAutoMergeTool.ts @@ -1,4 +1,4 @@ -import { defineTool } from '@shellicar/claude-sdk'; +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; import { getGitRemoteUrl } from './gitRemote'; import { parseAdoRemote } from './parseAdoRemote'; import type { AdoEscalatedDeps } from './runAdoEscalated'; @@ -21,7 +21,7 @@ export function buildMergeCommitMessage(id: number, title: string, description: export function createAdoAutoMergeTool(deps: AdoEscalatedDeps) { return defineTool({ name: 'AzureDevOps_PullRequest_AutoMerge', - operation: 'escalate', + operation: ToolOperation.Escalate, description: "Enable or disable auto-complete on a pull request. Never performs an immediate merge — only queues one via --auto-complete true, or clears it via --auto-complete false. The merge commit message is generated from the pull request's own title and description, matching what the Azure DevOps web UI would produce; it cannot be set by the caller.", input_schema: AdoPrAutoMergeInputSchema, diff --git a/packages/claude-sdk-tools/src/AzureDevOps/createAdoPrTool.ts b/packages/claude-sdk-tools/src/AzureDevOps/createAdoPrTool.ts index 740e5bf4..bb8e2be7 100644 --- a/packages/claude-sdk-tools/src/AzureDevOps/createAdoPrTool.ts +++ b/packages/claude-sdk-tools/src/AzureDevOps/createAdoPrTool.ts @@ -1,4 +1,4 @@ -import { defineTool } from '@shellicar/claude-sdk'; +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; import type { z } from 'zod'; import { getGitRemoteUrl } from './gitRemote'; import type { AdoRemoteContext } from './parseAdoRemote'; @@ -30,7 +30,7 @@ export function createAdoPrTool(spec: AdoPrToolSpec number = () => performance.now()) { return defineTool({ name: 'ExecV3', - operation: 'write', + operation: ToolOperation.Write, description: ExecV3ToolDescription, input_schema: ExecV3InputSchema, output_schema: ExecV3OutputSchema, diff --git a/packages/claude-sdk-tools/src/Git/README.md b/packages/claude-sdk-tools/src/Git/README.md new file mode 100644 index 00000000..45bddaab --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/README.md @@ -0,0 +1,129 @@ +# Git tool coverage + +Every command `git help -a` lists on this machine, grouped by whether a `Git_*` tool covers it. +Generated by inspection, not automated — re-check against `git help -a` if this drifts. + +Docs/guide topics (`attributes`, `cli`, `hooks`, `ignore`, `mailmap`, `modules`, +`repository-layout`, `revisions`, every `format-*`/`protocol-*` entry) aren't commands and are +left out. Same for the **External commands** and **Command aliases** sections — those are +this machine's own scripts/aliases (`discard-safe`, `wt-create`, `glog`, ...), not git itself. + +## Main Porcelain Commands + +| Command | Status | +|---|---| +| add | ✅ `Git_Add` | +| am | ❌ | +| archive | ❌ | +| backfill | ❌ | +| bisect | ❌ | +| branch | ✅ `Git_BranchList`, `Git_CreateBranch`, `Git_DeleteBranchForce` | +| bundle | ❌ | +| checkout | ❌ (deliberately replaced — see the `git` skill: `switch`/`restore`/`Git_DiscardFileChanges` split its overloaded meanings apart) | +| cherry-pick | ✅ `Git_CherryPick` (may stop on conflicts — `Git_Continue`/`Git_Abort` detect and resume/unwind it) | +| citool | ❌ (GUI) | +| clean | ❌ (deliberately excluded — no recovery path at all, see `git` skill) | +| clone | ✅ `Git_Clone` | +| commit | ✅ `Git_Commit`, `Git_AmendCommit` | +| describe | ✅ `Git_Describe` | +| diff | ✅ `Git_Diff` | +| fetch | ✅ `Git_Fetch` | +| format-patch | ❌ | +| gc | ❌ | +| gitk | ❌ (GUI) | +| grep | ✅ `Git_Grep` (pattern always passed via `-e`, so a leading `-` can never be misread as a flag; can search a past revision without checking it out) | +| gui | ❌ (GUI) | +| history | ❌ (experimental) | +| init | ✅ `Git_Init` | +| log | ✅ `Git_Log` | +| maintenance | ❌ | +| merge | ✅ `Git_Merge` (may stop on conflicts — `Git_Continue`/`Git_Abort` detect and resume/unwind it) | +| mv | ✅ `Git_Move` | +| notes | ❌ | +| pull | ✅ `Git_Pull` (fast-forward only) | +| push | ✅ `Git_Push`, `Git_ForcePushWithLease` (no plain `--force` — see `git` skill) | +| range-diff | ❌ | +| rebase | ✅ `Git_Rebase`, `Git_RebaseOnto` | +| reset | ⚠️ partial — only `--hard` (`Git_DiscardAllChanges`, unrecoverable tier); plain/mixed reset has no tool | +| restore | ✅ `Git_UnstageFile` (`--staged`), `Git_DiscardFileChanges` (unrecoverable tier) | +| revert | ✅ `Git_Revert` (purely additive — creates a new commit, never rewrites history; may stop on conflicts like cherry-pick) | +| rm | ✅ `Git_RemoveFile`, `Git_RemoveCachedFile`, `Git_ForceRemoveFile` (unrecoverable tier) | +| scalar | ❌ | +| shortlog | ❌ | +| show | ✅ `Git_Show` | +| sparse-checkout | ❌ | +| stash | ✅ `Git_StashSave`, `Git_StashList`, `Git_StashApply`, `Git_StashDrop` | +| status | ✅ `Git_Status` | +| submodule | ⚠️ partial — `Git_SubmoduleAdd`, `Git_SubmoduleStatus`, `Git_SubmoduleUpdate`, `Git_SubmoduleDeinit` (no `--force` anywhere); no sync/foreach | +| switch | ✅ `Git_SwitchBranch` | +| tag | ⚠️ partial — `Git_TagList` only; no create/delete tag tool | +| worktree | ✅ `Git_WorktreeList`, `Git_WorktreeAdd`, `Git_WorktreePrune`, `Git_WorktreeRemove` (no `--force` on add/remove) | + +## Ancillary Commands / Manipulators + +| Command | Status | +|---|---| +| config | ⚠️ partial — `Git_Config` reads (`--list`/`--get`, with credential redaction); nothing writes | +| fast-export | ❌ | +| fast-import | ❌ | +| filter-branch | ❌ | +| mergetool | ❌ | +| pack-refs | ❌ | +| prune | ❌ (plain object prune; worktree-specific prune is covered by `Git_WorktreePrune`) | +| reflog | ⚠️ partial — `Git_Reflog` reads (`show`) only; nothing expires/deletes entries | +| refs | ❌ | +| remote | ⚠️ partial — `Git_RemoteList` only (with credential redaction); no add/remove/set-url | +| repack | ❌ | +| replace | ❌ | + +## Ancillary Commands / Interrogators + +| Command | Status | +|---|---| +| annotate | ❌ | +| blame | ✅ `Git_Blame` | +| bugreport | ❌ | +| count-objects | ❌ | +| diagnose | ❌ | +| difftool | ❌ | +| fsck | ❌ | +| gitweb | ❌ | +| help | ❌ | +| instaweb | ❌ | +| merge-tree | ❌ | +| rerere | ❌ | +| show-branch | ❌ | +| verify-commit | ❌ | +| verify-tag | ❌ | +| version | ❌ | +| whatchanged | ❌ | + +## Interacting with Others + +All ❌ — `archimport`, `cvsexportcommit`, `cvsimport`, `cvsserver`, `imap-send`, `p4`, +`quiltimport`, `request-pull`, `send-email`, `svn`. + +## Low-level Commands (plumbing) + +Plumbing is out of scope by design — this tool is a porcelain surface. Two exceptions already +built because they filled a real gap (`grep` is covered too, but git itself files it under Main +Porcelain above, not here): + +| Command | Status | +|---|---| +| merge-base | ✅ `Git_MergeBase` | +| ls-files | ✅ `Git_LsFiles` | + +Everything else in Manipulators / Interrogators / Syncing Repositories / Internal Helpers is +❌ and not expected to change: `apply`, `checkout-index`, `commit-graph`, `commit-tree`, +`hash-object`, `index-pack`, `merge-file`, `merge-index`, `mktag`, `mktree`, +`multi-pack-index`, `pack-objects`, `prune-packed`, `read-tree`, `replay`, `symbolic-ref`, +`unpack-objects`, `update-index`, `update-ref`, `write-tree`, `cat-file`, `cherry`, +`diff-files`, `diff-index`, `diff-pairs`, `diff-tree`, `for-each-ref`, `for-each-repo`, +`format-rev`, `get-tar-commit-id`, `last-modified`, `ls-remote`, `ls-tree`, `name-rev`, +`pack-redundant`, `repo`, `rev-list`, `rev-parse`, `show-index`, `show-ref`, `unpack-file`, +`var`, `verify-pack`, `daemon`, `fetch-pack`, `http-backend`, `send-pack`, +`update-server-info`, `check-attr`, `check-ignore`, `check-mailmap`, `check-ref-format`, +`column`, `credential`, `credential-cache`, `credential-store`, `fmt-merge-msg`, `hook`, +`interpret-trailers`, `mailinfo`, `mailsplit`, `merge-one-file`, `patch-id`, `sh-i18n`, +`sh-setup`, `stripspace`, `url-parse`. diff --git a/packages/claude-sdk-tools/src/Git/branchList.ts b/packages/claude-sdk-tools/src/Git/branchList.ts new file mode 100644 index 00000000..c7e7e9cc --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/branchList.ts @@ -0,0 +1,40 @@ +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; +import type { GitDeps } from './runGit'; +import { runGit } from './runGit'; +import { GitBranchListInputSchema, GitBranchListOutputSchema } from './schema'; + +/** `git branch`'s own table uses a leading `*`/`+`/blank-space marker to say "current" vs "checked + * out in another worktree" vs neither — a convention unreadable without already knowing it, and + * the exact ambiguity this tool exists to remove. `--format` asks git for the same facts as real, + * named fields instead of a table a reader has to decode. */ +const FORMAT = '%(HEAD)%09%(refname:short)%09%(worktreepath)'; + +export function createGitBranchListTool(deps: GitDeps) { + return defineTool({ + name: 'Git_BranchList', + operation: ToolOperation.Read, + description: 'List branches.', + input_schema: GitBranchListInputSchema, + output_schema: GitBranchListOutputSchema, + input_examples: [{}], + handler: async (input) => { + const cwd = input.cwd ?? process.cwd(); + const args = ['branch', `--format=${FORMAT}`]; + if (input.all) { + args.push('--all'); + } + const result = await runGit(deps, args, cwd); + if (result.exitCode !== 0) { + throw new Error(result.stderr.trim() || `git ${args.join(' ')} failed with exit code ${result.exitCode}`); + } + const branches = result.stdout + .split('\n') + .filter((line) => line.length > 0) + .map((line) => { + const [head, name, worktreePath] = line.split('\t'); + return { name: name ?? '', current: head === '*', worktreePath: worktreePath && worktreePath.length > 0 ? worktreePath : null }; + }); + return { textContent: branches }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Git/continueAbort.ts b/packages/claude-sdk-tools/src/Git/continueAbort.ts new file mode 100644 index 00000000..0ab46fe1 --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/continueAbort.ts @@ -0,0 +1,50 @@ +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; +import { detectInProgress } from './detectInProgress'; +import type { GitDeps } from './runGit'; +import { runGitText } from './runGit'; +import { GitAbortInputSchema, GitContinueInputSchema, GitOutputSchema } from './schema'; + +/** `continue`/`abort` don't fit `createGitTool`'s fixed buildArgs shape: which git subcommand they + * run depends on runtime state (a merge or a rebase in progress), not on the input schema. Both + * are `write` tier regardless of which they resume — see Git/tools.ts's comment for why: continuing + * or aborting doesn't re-decide anything, it carries out or unwinds a step of an operation that was + * already approved when it started. */ +export function createGitContinueAbortTools(deps: GitDeps) { + const Continue = defineTool({ + name: 'Git_Continue', + operation: ToolOperation.Write, + description: 'Continue an in-progress merge, rebase, cherry-pick, or revert, after conflicts have been resolved. Detects which is in progress.', + input_schema: GitContinueInputSchema, + output_schema: GitOutputSchema, + input_examples: [{}], + handler: async (input) => { + const cwd = input.cwd ?? process.cwd(); + const inProgress = await detectInProgress(deps.fs, cwd); + if (inProgress == null) { + throw new Error('No merge, rebase, cherry-pick, or revert is in progress in this repo.'); + } + const text = await runGitText(deps, [inProgress, '--continue'], cwd); + return { textContent: text }; + }, + }); + + const Abort = defineTool({ + name: 'Git_Abort', + operation: ToolOperation.Write, + description: 'Abort an in-progress merge, rebase, cherry-pick, or revert, restoring the state from before it started. Detects which is in progress.', + input_schema: GitAbortInputSchema, + output_schema: GitOutputSchema, + input_examples: [{}], + handler: async (input) => { + const cwd = input.cwd ?? process.cwd(); + const inProgress = await detectInProgress(deps.fs, cwd); + if (inProgress == null) { + throw new Error('No merge, rebase, cherry-pick, or revert is in progress in this repo.'); + } + const text = await runGitText(deps, [inProgress, '--abort'], cwd); + return { textContent: text }; + }, + }); + + return [Continue, Abort]; +} diff --git a/packages/claude-sdk-tools/src/Git/createGitTool.ts b/packages/claude-sdk-tools/src/Git/createGitTool.ts new file mode 100644 index 00000000..48966a22 --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/createGitTool.ts @@ -0,0 +1,47 @@ +import { defineTool, type ToolOperation } from '@shellicar/claude-sdk'; +import type { z } from 'zod'; +import type { GitDeps } from './runGit'; +import { runGitText } from './runGit'; +import { GitOutputSchema } from './schema'; + +/** One named Git.* tool: a fixed mapping from typed input to git args. `buildArgs` is the + * structural guarantee — whatever the agent puts in the fields, only the args this function ever + * emits can reach git, nothing else. `operation` is fixed at registration: it is the tier this + * action was designed into (read / write / escalate), not something the handler decides per call. */ +export type GitToolSpec> = { + name: string; + operation: ToolOperation; + description: string; + input_schema: TSchema; + input_examples?: z.input[]; + buildArgs: (input: z.output) => string[]; + /** Runs before buildArgs/runGit; throwing refuses the call. For a check that needs to inspect repo + * state beyond the input itself (e.g. resolving whether the target is the default branch), not + * something the input schema alone can express. */ + guard?: (input: z.output, deps: GitDeps, cwd: string) => Promise; + /** Runs on git's own output before it is returned. For redacting a credential that legitimately + * belongs in the diagnostic (e.g. an embedded token in a remote URL, or an auth header's value) + * — not a refusal, the call still succeeds and the fact something is configured there stays + * visible, only the secret bytes are masked. Takes the input too, since which redaction applies + * can depend on which key was asked for. */ + postProcess?: (text: string, input: z.output) => string; +}; + +export function createGitTool>(spec: GitToolSpec, deps: GitDeps) { + return defineTool({ + name: spec.name, + operation: spec.operation, + description: spec.description, + input_schema: spec.input_schema, + output_schema: GitOutputSchema, + input_examples: spec.input_examples ?? [], + handler: async (input) => { + const cwd = input.cwd ?? process.cwd(); + if (spec.guard) { + await spec.guard(input, deps, cwd); + } + const text = await runGitText(deps, spec.buildArgs(input), cwd); + return { textContent: spec.postProcess ? spec.postProcess(text, input) : text }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Git/detectInProgress.ts b/packages/claude-sdk-tools/src/Git/detectInProgress.ts new file mode 100644 index 00000000..004d120d --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/detectInProgress.ts @@ -0,0 +1,48 @@ +import { isAbsolute, join } from 'node:path'; +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; + +export type InProgressOperation = 'merge' | 'rebase' | 'cherry-pick' | 'revert'; + +/** Resolves `/` to the directory git itself would use — following the `gitdir: ` + * pointer when `.git` is a file rather than a directory, as it is inside a linked worktree. Falls + * back to the plain join when there is no pointer to follow (an ordinary repo, or nothing there). */ +async function resolveGitDir(fs: IFileSystem, cwd: string, gitDir: string): Promise { + const gitPath = join(cwd, gitDir); + if (!(await fs.exists(gitPath))) { + return gitPath; + } + const stat = await fs.stat(gitPath); + if (!stat.isFile()) { + return gitPath; + } + const content = await fs.readFile(gitPath); + const match = content.match(/^gitdir:\s*(.+)$/m); + if (!match) { + return gitPath; + } + const pointer = match[1].trim(); + return isAbsolute(pointer) ? pointer : join(cwd, pointer); +} + +/** Which operation, if any, is currently in progress in this repo's .git dir — the same state git + * itself checks before honouring --continue/--abort. MERGE_HEAD is written for an in-progress + * merge; rebase-merge/rebase-apply for an in-progress rebase (interactive and non-interactive + * respectively); CHERRY_PICK_HEAD/REVERT_HEAD for a cherry-pick or revert stopped on a conflict. + * Resolves `.git` as a worktree pointer file first, so this matches git's own view when run from + * inside a linked worktree. */ +export async function detectInProgress(fs: IFileSystem, cwd: string, gitDir = '.git'): Promise { + const base = await resolveGitDir(fs, cwd, gitDir); + if (await fs.exists(join(base, 'MERGE_HEAD'))) { + return 'merge'; + } + if ((await fs.exists(join(base, 'rebase-merge'))) || (await fs.exists(join(base, 'rebase-apply')))) { + return 'rebase'; + } + if (await fs.exists(join(base, 'CHERRY_PICK_HEAD'))) { + return 'cherry-pick'; + } + if (await fs.exists(join(base, 'REVERT_HEAD'))) { + return 'revert'; + } + return null; +} diff --git a/packages/claude-sdk-tools/src/Git/protectedBranch.ts b/packages/claude-sdk-tools/src/Git/protectedBranch.ts new file mode 100644 index 00000000..cb52d9de --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/protectedBranch.ts @@ -0,0 +1,56 @@ +import type { GitDeps } from './runGit'; +import { runGit } from './runGit'; + +/** The repo's actual default branch, resolved from the remote's own HEAD pointer — not the + * currently checked-out branch, which can legitimately BE the default branch for a moment (e.g. + * right after a pull, before creating a feature branch) without anything dangerous being + * attempted. "Protected" is a property of the branch name a target resolves to, not of where HEAD + * happens to be sitting right now. Returns null when it can't be determined (no origin remote, a + * repo with no such pointer set) — the guard fails open in that case rather than blocking normal + * use of a repo with nothing configured to protect. */ +export async function resolveDefaultBranch(deps: GitDeps, cwd: string): Promise { + const result = await runGit(deps, ['symbolic-ref', 'refs/remotes/origin/HEAD'], cwd); + if (result.exitCode !== 0) { + return null; + } + const ref = result.stdout.trim(); + const prefix = 'refs/remotes/origin/'; + return ref.startsWith(prefix) ? ref.slice(prefix.length) : null; +} + +async function resolveCurrentBranch(deps: GitDeps, cwd: string): Promise { + const result = await runGit(deps, ['rev-parse', '--abbrev-ref', 'HEAD'], cwd); + if (result.exitCode !== 0) { + return null; + } + const branch = result.stdout.trim(); + return branch === 'HEAD' ? null : branch; // detached HEAD — nothing named to protect +} + +/** A `branch` field on a push-shaped tool isn't necessarily a bare name — git push accepts a full + * refspec, `:` (optionally force-prefixed with `+`), and `assertNotDefaultBranch` must + * compare against the actual destination, not the raw field: `'HEAD:main'` and `'refs/heads/main'` + * both name the default branch just as much as a bare `'main'` does. */ +function normaliseTarget(target: string): string { + const destination = target.includes(':') ? target.slice(target.lastIndexOf(':') + 1) : target; + const unforced = destination.startsWith('+') ? destination.slice(1) : destination; + const prefix = 'refs/heads/'; + return unforced.startsWith(prefix) ? unforced.slice(prefix.length) : unforced; +} + +/** Refuses when `targetBranch` (or, if null, the currently checked-out branch) resolves to the + * repo's default branch. The reflog-recoverability that makes a reflog-tier operation acceptable + * only holds for local, personal history — the moment the target is a branch other clones depend + * on, a collaborator who already pulled the old tip has no reflog entry pointing back to it once + * their own branch moves past it. That is a different, much larger blast radius than the same + * operation on a feature branch, and it is what this guard exists to catch. */ +export async function assertNotDefaultBranch(deps: GitDeps, cwd: string, targetBranch: string | null, toolName: string): Promise { + const defaultBranch = await resolveDefaultBranch(deps, cwd); + if (defaultBranch == null) { + return; + } + const branch = targetBranch != null ? normaliseTarget(targetBranch) : await resolveCurrentBranch(deps, cwd); + if (branch === defaultBranch) { + throw new Error(`${toolName} refused: '${branch}' is this repo's default branch (origin/HEAD). Rewriting it can strand other clones with no local recovery of their own. Disable protectDefaultBranch to override.`); + } +} diff --git a/packages/claude-sdk-tools/src/Git/redact.ts b/packages/claude-sdk-tools/src/Git/redact.ts new file mode 100644 index 00000000..f3885f3b --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/redact.ts @@ -0,0 +1,46 @@ +/** Keys git itself uses to carry a live, working credential rather than a preference — CI runners + * (GitHub Actions especially) commonly write `http..extraheader` with a bearer/basic auth + * header directly into the repo's local config to authenticate git operations. Reading these back + * verbatim would hand that credential to whoever reads the tool's output. The key and the fact a + * value exists stay visible (that's the actual diagnostic fact — "header auth is configured"); the + * value itself does not. */ +const CREDENTIAL_KEY_PATTERNS = [/^http\..*\.extraheader$/i, /^http\.extraheader$/i, /credential\./i, /^http\.proxy$/i]; + +function isCredentialKey(key: string): boolean { + return CREDENTIAL_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} + +/** `scheme://user:token@host/...` is a normal way git remotes and credential managers carry a + * working credential inline in the URL. Masks just the userinfo portion — the host and path stay + * visible, since that's the part actually useful for diagnosing "which remote/host is this going + * to", not the secret riding along with it. */ +export function redactUserinfo(text: string): string { + return text.replace(/(:\/\/)([^\s/@]+)@/g, '$1***@'); +} + +/** For a single `git config --get ` lookup, the key is already known from the input — redact + * the whole value outright for a credential-bearing key, otherwise just mask embedded userinfo. */ +export function redactConfigValue(key: string, value: string): string { + if (isCredentialKey(key)) { + return '***REDACTED***'; + } + return redactUserinfo(value); +} + +/** `git config --list` output is `key=value` per line (no spaces), one pair per line. Applies the + * same per-key redaction as `redactConfigValue` across every line, not just a value the caller + * named up front. */ +export function redactConfigListOutput(text: string): string { + return text + .split('\n') + .map((line) => { + const eq = line.indexOf('='); + if (eq === -1) { + return line; + } + const key = line.slice(0, eq); + const value = line.slice(eq + 1); + return `${key}=${redactConfigValue(key, value)}`; + }) + .join('\n'); +} diff --git a/packages/claude-sdk-tools/src/Git/runGit.ts b/packages/claude-sdk-tools/src/Git/runGit.ts new file mode 100644 index 00000000..c086c654 --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/runGit.ts @@ -0,0 +1,43 @@ +import { PassThrough } from 'node:stream'; +import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import type { IExecutor } from '@shellicar/exec-core'; + +export type GitDeps = { + executor: IExecutor; + fs: IFileSystem; +}; + +export type GitRunResult = { stdout: string; stderr: string; exitCode: number | null }; + +/** Runs one `git ` in `cwd`, no shell — every tool's `buildArgs` is the only thing that ever + * reaches this, so the args a call can produce are fixed at registration time, not assembled from + * free-form model input. */ +export async function runGit(deps: GitDeps, args: string[], cwd: string): Promise { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); + stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); + + const result = await deps.executor.run({ program: 'git', args, cwd, env: process.env }, { stdout, stderr }); + return { stdout: Buffer.concat(stdoutChunks).toString('utf8'), stderr: Buffer.concat(stderrChunks).toString('utf8'), exitCode: result.exitCode }; +} + +/** Every Git_* tool runs exactly one git invocation — unlike ExecV3, there is no chain whose next + * step reads a prior exit code, so a `{ stdout, stderr, exitCode }` object buys nothing and costs + * real readability: git's own text (a diff, a log, a branch list) gets \n- and "-escaped into a + * JSON string value, which is strictly worse to read than the same text unwrapped. git often + * writes real, non-error content to stderr even on success (`switch`'s "Switched to branch", + * fetch/push progress), so both streams are merged into one block rather than one silently + * dropped. A non-zero exit throws with that same merged text, consistent with how this tool's own + * guard refusals already surface — git's own failure is not a distinct case to special-case. */ +export async function runGitText(deps: GitDeps, args: string[], cwd: string): Promise { + const result = await runGit(deps, args, cwd); + const parts = [result.stdout.trim(), result.stderr.trim()].filter((part) => part.length > 0); + const merged = parts.join('\n'); + if (result.exitCode !== 0) { + throw new Error(merged.length > 0 ? merged : `git ${args.join(' ')} failed with exit code ${result.exitCode}`); + } + return merged; +} diff --git a/packages/claude-sdk-tools/src/Git/schema.ts b/packages/claude-sdk-tools/src/Git/schema.ts new file mode 100644 index 00000000..6b3cc936 --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/schema.ts @@ -0,0 +1,415 @@ +import { z } from 'zod'; + +const cwdSchema = z.string().optional().describe("Directory to run `git` in. Supports ~ and $VAR expansion. Determines which repo the command targets. Defaults to the CLI's own working directory when omitted."); + +/** For any field that reaches git as a bare (non-flag-value) argument — a revision, branch, remote, + * or stash ref. A leading '-' would let git parse it as an option instead of a value (e.g. a + * `base` of `--exec=...rm -rf ...` on Git_Rebase, or a `remote` of `--upload-pack=...` on + * Git_Fetch) — the classic git argument-injection RCE class. Rejected here as the reliable, + * version-independent guard; `--end-of-options` is also inserted in buildArgs as a second layer. */ +function refArg(description: string) { + return z + .string() + .min(1) + .refine((value) => !value.startsWith('-'), { message: "must not start with '-' — git would parse it as an option, not a value" }) + .describe(description); +} + +/** For a call whose target doesn't explain itself — the same ref/branch/path could serve a dozen + * different purposes, and whoever's watching can't tell which without being told. Required, not + * optional: the point is that it's always stated, the same way ExecV3's own `intent` field always + * is, so a wrong assumption gets caught before the call runs rather than inferred after the fact. */ +const intentField = z.string().min(1).describe('Your intent for this call — the goal, not a restatement of the arguments.'); + +export const GitOutputSchema = z.string(); + +// ---- read-only ---- + +export const GitStatusInputSchema = z.object({ cwd: cwdSchema }).strict(); + +export const GitDiffInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + staged: z.boolean().optional().describe('Show staged (index vs HEAD) changes instead of working-tree changes'), + ref: refArg('Compare against this ref instead of HEAD').optional(), + path: z.string().optional().describe('Limit the diff to this path'), + }) + .strict(); + +export const GitLogInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + ref: refArg('Ref to start the log from (defaults to HEAD)').optional(), + maxCount: z.number().int().positive().optional().describe('Limit the number of commits shown'), + path: z.string().optional().describe('Limit the log to commits touching this path'), + }) + .strict(); + +export const GitShowInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + ref: refArg('The commit, tag, or object to show'), + }) + .strict(); + +export const GitBlameInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + path: z.string().describe('File to blame'), + ref: refArg('Blame as of this ref instead of the working tree').optional(), + }) + .strict(); + +export const GitBranchListInputSchema = z + .object({ + cwd: cwdSchema, + all: z.boolean().optional().describe('Include remote-tracking branches'), + }) + .strict(); + +export const GitBranchListOutputSchema = z.array( + z.object({ + name: z.string(), + current: z.boolean().describe('True for the branch HEAD currently points at'), + worktreePath: z.string().nullable().describe('The linked worktree this branch is checked out in, or null when not checked out in another worktree'), + }), +); + +export const GitTagListInputSchema = z.object({ cwd: cwdSchema }).strict(); + +export const GitRemoteListInputSchema = z.object({ cwd: cwdSchema }).strict(); + +export const GitStashListInputSchema = z.object({ cwd: cwdSchema }).strict(); + +export const GitReflogInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + ref: refArg('Show the reflog for this ref instead of HEAD').optional(), + maxCount: z.number().int().positive().optional().describe('Limit the number of reflog entries shown'), + }) + .strict(); + +export const GitMergeBaseInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + refA: refArg('The first ref'), + refB: refArg('The second ref'), + }) + .strict(); + +export const GitDescribeInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + ref: refArg('Describe this ref instead of HEAD').optional(), + tags: z.boolean().optional().describe('Consider lightweight tags too, not just annotated ones'), + }) + .strict(); + +export const GitConfigInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + key: refArg('A specific config key to read (e.g. user.email). Omit to list the whole effective config.').optional(), + }) + .strict(); + +export const GitLsFilesInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + path: z.string().optional().describe('Limit the listing to this path'), + }) + .strict(); + +export const GitWorktreeListInputSchema = z.object({ cwd: cwdSchema }).strict(); + +export const GitWorktreeListOutputSchema = z.array( + z.object({ + path: z.string(), + head: z.string().nullable().describe('The commit SHA this worktree has checked out, or null for one with no commits yet'), + branch: z.string().nullable().describe('The branch checked out here, or null when detached'), + locked: z.string().nullable().describe('Non-null when the worktree is locked; the string is the lock reason, or empty if none was given'), + prunable: z.string().nullable().describe('Non-null when git considers this worktree safe to prune; the string is the reason, or empty if none was given'), + }), +); + +// ---- safe ---- + +export const GitAddInputSchema = z + .object({ + cwd: cwdSchema, + paths: z.array(z.string()).min(1).describe('Paths to stage, relative to the repo root'), + }) + .strict(); + +export const GitUnstageFileInputSchema = z + .object({ + cwd: cwdSchema, + paths: z.array(z.string()).min(1).describe('Paths to unstage (git restore --staged), relative to the repo root'), + }) + .strict(); + +export const GitRemoveCachedFileInputSchema = z + .object({ + cwd: cwdSchema, + paths: z.array(z.string()).min(1).describe('Paths to untrack (git rm --cached) without touching the working copy'), + }) + .strict(); + +export const GitRemoveFileInputSchema = z + .object({ + cwd: cwdSchema, + paths: z.array(z.string()).min(1).describe('Paths to remove (git rm, no force) — refused by git unless the path is clean/up to date'), + }) + .strict(); + +export const GitCommitInputSchema = z + .object({ + cwd: cwdSchema, + message: z.string().min(1).describe('Commit message'), + }) + .strict(); + +export const GitCreateBranchInputSchema = z + .object({ + cwd: cwdSchema, + name: refArg('Name of the new branch'), + from: refArg('Ref to branch from (defaults to HEAD)').optional(), + }) + .strict(); + +export const GitSwitchBranchInputSchema = z + .object({ + cwd: cwdSchema, + name: refArg('Branch to switch to'), + }) + .strict(); + +export const GitAbortInputSchema = z.object({ cwd: cwdSchema }).strict(); + +export const GitContinueInputSchema = z.object({ cwd: cwdSchema }).strict(); + +export const GitStashSaveInputSchema = z + .object({ + cwd: cwdSchema, + message: z.string().optional().describe('Description for the stash entry'), + }) + .strict(); + +export const GitStashApplyInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + stashRef: refArg('Stash entry to apply (e.g. stash@{0}). Defaults to the most recent.').optional(), + }) + .strict(); + +export const GitFetchInputSchema = z + .object({ + cwd: cwdSchema, + remote: refArg('Remote to fetch from (defaults to origin)').optional(), + }) + .strict(); + +export const GitPullInputSchema = z + .object({ + cwd: cwdSchema, + remote: refArg('Remote to pull from (defaults to origin)').optional(), + branch: refArg("Branch to pull (defaults to the current branch's upstream)").optional(), + }) + .strict(); + +export const GitPushInputSchema = z + .object({ + cwd: cwdSchema, + remote: refArg('Remote to push to (defaults to origin)').optional(), + branch: refArg('Branch to push (defaults to the current branch)').optional(), + }) + .strict(); + +export const GitWorktreeAddInputSchema = z + .object({ + cwd: cwdSchema, + path: refArg('Directory to create the new worktree at'), + branch: refArg('Existing branch or commit-ish to check out into the new worktree').optional(), + newBranch: refArg('Create this new branch for the worktree instead of checking out an existing one').optional(), + }) + .strict(); + +export const GitWorktreePruneInputSchema = z + .object({ + cwd: cwdSchema, + dryRun: z.boolean().optional().describe('Show what would be pruned without actually removing anything'), + }) + .strict(); + +export const GitWorktreeRemoveInputSchema = z + .object({ + cwd: cwdSchema, + path: refArg('Worktree to remove — refused by git unless it is clean (no uncommitted or untracked changes)'), + }) + .strict(); + +export const GitMergeInputSchema = z + .object({ + cwd: cwdSchema, + branch: refArg('Branch to merge into the current branch'), + }) + .strict(); + +export const GitCherryPickInputSchema = z + .object({ + cwd: cwdSchema, + commit: refArg('Commit to apply onto the current branch'), + }) + .strict(); + +export const GitRevertInputSchema = z + .object({ + cwd: cwdSchema, + commit: refArg('Commit to revert — creates a new commit undoing it, never rewrites history'), + }) + .strict(); + +export const GitCloneInputSchema = z + .object({ + cwd: cwdSchema, + url: refArg('Repository URL or path to clone from'), + path: refArg('Directory to clone into (defaults to a name derived from the URL)').optional(), + }) + .strict(); + +export const GitGrepInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + pattern: z.string().min(1).describe('Pattern to search for'), + ref: refArg('Search this revision instead of the working tree').optional(), + }) + .strict(); + +export const GitInitInputSchema = z + .object({ + cwd: cwdSchema, + path: refArg('Directory to initialise as a new repository (defaults to cwd)').optional(), + }) + .strict(); + +export const GitMoveInputSchema = z + .object({ + cwd: cwdSchema, + source: z.string().min(1).describe('Path to move or rename'), + dest: z.string().min(1).describe('New path'), + }) + .strict(); + +export const GitSubmoduleAddInputSchema = z + .object({ + cwd: cwdSchema, + url: refArg('Repository URL to add as a submodule'), + path: refArg('Path to add the submodule at (defaults to a name derived from the URL)').optional(), + }) + .strict(); + +export const GitSubmoduleStatusInputSchema = z + .object({ + cwd: cwdSchema, + path: z.string().optional().describe('Limit status to this submodule path'), + }) + .strict(); + +export const GitSubmoduleUpdateInputSchema = z + .object({ + cwd: cwdSchema, + init: z.boolean().optional().describe('Initialise submodules that have never been checked out yet'), + recursive: z.boolean().optional().describe('Update nested submodules too'), + path: z.string().optional().describe('Limit the update to this submodule path'), + }) + .strict(); + +export const GitSubmoduleDeinitInputSchema = z + .object({ + cwd: cwdSchema, + path: refArg('Submodule to deinitialise — refused by git unless its working tree is clean'), + }) + .strict(); + +// ---- reflog (always escalate) ---- + +export const GitAmendCommitInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + message: z.string().optional().describe('Replace the commit message. Omit to keep the existing message.'), + }) + .strict(); + +export const GitRebaseInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + base: refArg('The ref to rebase the current branch onto'), + }) + .strict(); + +export const GitRebaseOntoInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + oldBase: refArg("The branch's actual current parent — where its own commits start"), + newBase: refArg('The ref to land those commits on'), + branch: refArg('The branch being rebased'), + }) + .strict(); + +export const GitStashDropInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + stashRef: refArg('Stash entry to drop (e.g. stash@{0}). Defaults to the most recent.').optional(), + }) + .strict(); + +export const GitDeleteBranchForceInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + name: refArg('Branch to force-delete, including unmerged commits'), + }) + .strict(); + +export const GitForcePushWithLeaseInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + remote: refArg('Remote to push to (defaults to origin)').optional(), + branch: refArg('Branch to push (defaults to the current branch)').optional(), + }) + .strict(); + +// ---- unrecoverable (not registered unless explicitly enabled) ---- + +export const GitDiscardFileChangesInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + paths: z.array(z.string()).min(1).describe('Paths to discard working-tree changes for — uncommitted edits are lost with no recovery'), + }) + .strict(); + +export const GitDiscardAllChangesInputSchema = z.object({ cwd: cwdSchema, intent: intentField }).strict(); + +export const GitForceRemoveFileInputSchema = z + .object({ + cwd: cwdSchema, + intent: intentField, + paths: z.array(z.string()).min(1).describe('Paths to force-remove (git rm -f) — bypasses the clean/up-to-date check, uncommitted changes are lost'), + }) + .strict(); diff --git a/packages/claude-sdk-tools/src/Git/stashApply.ts b/packages/claude-sdk-tools/src/Git/stashApply.ts new file mode 100644 index 00000000..ef69f11f --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/stashApply.ts @@ -0,0 +1,31 @@ +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; +import type { GitDeps } from './runGit'; +import { runGit, runGitText } from './runGit'; +import { GitOutputSchema, GitStashApplyInputSchema } from './schema'; + +/** `git stash apply` has no `--abort` — unlike merge/rebase, once it runs there is no command that + * restores the exact prior state. Its own conflict detection only catches an outright textual + * conflict; onto an already-dirty tree it will often just silently three-way-merge the stash's + * changes in among the existing uncommitted ones, with nothing marking which came from where. That + * entangling, not a conflict, is the real danger, and there is no undo for it after the fact — so + * the only safety available is refusing to start unless the working tree is clean. */ +export function createGitStashApplyTool(deps: GitDeps) { + return defineTool({ + name: 'Git_StashApply', + operation: ToolOperation.Write, + description: 'Apply a stash entry onto the working tree, keeping the stash entry. Refused unless the working tree is clean — applying onto uncommitted changes has no undo.', + input_schema: GitStashApplyInputSchema, + output_schema: GitOutputSchema, + input_examples: [{ intent: 'restore the stash saved before switching branches' }], + handler: async (input) => { + const cwd = input.cwd ?? process.cwd(); + const status = await runGit(deps, ['status', '--porcelain'], cwd); + if (status.stdout.trim().length > 0) { + throw new Error('Working tree is not clean. Git_StashApply is refused on a dirty tree: applying has no --abort, so an entangled result could not be undone.'); + } + const args = input.stashRef != null ? ['stash', 'apply', '--end-of-options', input.stashRef] : ['stash', 'apply']; + const text = await runGitText(deps, args, cwd); + return { textContent: text }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Git/tools.ts b/packages/claude-sdk-tools/src/Git/tools.ts new file mode 100644 index 00000000..4856f609 --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/tools.ts @@ -0,0 +1,657 @@ +import { ToolOperation } from '@shellicar/claude-sdk'; +import { createGitBranchListTool } from './branchList'; +import { createGitContinueAbortTools } from './continueAbort'; +import { createGitTool } from './createGitTool'; +import { assertNotDefaultBranch } from './protectedBranch'; +import { redactConfigListOutput, redactConfigValue, redactUserinfo } from './redact'; +import type { GitDeps } from './runGit'; +import { createGitWorktreeListTool } from './worktreeList'; +import { + GitAddInputSchema, + GitAmendCommitInputSchema, + GitBlameInputSchema, + GitCherryPickInputSchema, + GitCloneInputSchema, + GitCommitInputSchema, + GitConfigInputSchema, + GitCreateBranchInputSchema, + GitDeleteBranchForceInputSchema, + GitDescribeInputSchema, + GitDiffInputSchema, + GitDiscardAllChangesInputSchema, + GitDiscardFileChangesInputSchema, + GitFetchInputSchema, + GitForcePushWithLeaseInputSchema, + GitForceRemoveFileInputSchema, + GitGrepInputSchema, + GitInitInputSchema, + GitLogInputSchema, + GitLsFilesInputSchema, + GitMergeBaseInputSchema, + GitMergeInputSchema, + GitMoveInputSchema, + GitPullInputSchema, + GitPushInputSchema, + GitRebaseInputSchema, + GitRebaseOntoInputSchema, + GitReflogInputSchema, + GitRemoteListInputSchema, + GitRemoveCachedFileInputSchema, + GitRemoveFileInputSchema, + GitRevertInputSchema, + GitShowInputSchema, + GitStashDropInputSchema, + GitStashListInputSchema, + GitStashSaveInputSchema, + GitStatusInputSchema, + GitSubmoduleAddInputSchema, + GitSubmoduleDeinitInputSchema, + GitSubmoduleStatusInputSchema, + GitSubmoduleUpdateInputSchema, + GitSwitchBranchInputSchema, + GitTagListInputSchema, + GitUnstageFileInputSchema, + GitWorktreeAddInputSchema, + GitWorktreePruneInputSchema, + GitWorktreeRemoveInputSchema, +} from './schema'; +import { createGitStashApplyTool } from './stashApply'; + +/** Every action the SC decided is worth building, gated by the tier it was designed into. + * `enableUnrecoverable` mirrors Az's presence-based gating: the unrecoverable-tier tools are not + * registered at all unless explicitly turned on, same as an account with no configured identity + * simply doesn't produce a tool. `continue`/`abort` come from `createGitContinueAbortTools` instead + * of `createGitTool`: which git subcommand they run depends on runtime state, not the input schema. + * `protectDefaultBranch` (on by default) refuses the reflog-tier tools that can rewrite a *branch* + * (not just local history) when that branch is the repo's default — see protectedBranch.ts for why + * reflog-recoverability stops holding once other clones may depend on the target. */ +export function createGitTools(deps: GitDeps, options: { enableUnrecoverable: boolean; protectDefaultBranch?: boolean }) { + const protectDefaultBranch = options.protectDefaultBranch ?? true; + const defaultBranchGuard = (targetBranch: string | null, toolName: string) => (protectDefaultBranch ? (_input: unknown, guardDeps: GitDeps, cwd: string) => assertNotDefaultBranch(guardDeps, cwd, targetBranch, toolName) : undefined); + + const tools = [ + ...createGitContinueAbortTools(deps), + createGitStashApplyTool(deps), + + // read-only + createGitTool({ name: 'Git_Status', operation: ToolOperation.Read, description: 'Show the working tree status.', input_schema: GitStatusInputSchema, input_examples: [{}], buildArgs: () => ['status'] }, deps), + createGitTool( + { + name: 'Git_Diff', + operation: ToolOperation.Read, + description: 'Show changes between commits, the working tree, and the index.', + input_schema: GitDiffInputSchema, + input_examples: [{ intent: 'see what changed before committing' }], + buildArgs: (input) => { + const args = ['diff']; + if (input.staged) { + args.push('--staged'); + } + if (input.ref != null) { + args.push('--end-of-options', input.ref); + } + if (input.path != null) { + args.push('--', input.path); + } + return args; + }, + }, + deps, + ), + createGitTool( + { + name: 'Git_Log', + operation: ToolOperation.Read, + description: 'Show commit history.', + input_schema: GitLogInputSchema, + input_examples: [{ intent: 'find when a regression was introduced' }], + buildArgs: (input) => { + const args = ['log']; + if (input.maxCount != null) { + args.push('-n', String(input.maxCount)); + } + if (input.ref != null) { + args.push('--end-of-options', input.ref); + } + if (input.path != null) { + args.push('--', input.path); + } + return args; + }, + }, + deps, + ), + createGitTool({ name: 'Git_Show', operation: ToolOperation.Read, description: 'Show a commit, tag, or other git object.', input_schema: GitShowInputSchema, input_examples: [{ intent: 'confirm the last commit landed as expected', ref: 'HEAD' }], buildArgs: (input) => ['show', '--end-of-options', input.ref] }, deps), + createGitTool( + { + name: 'Git_Blame', + operation: ToolOperation.Read, + description: 'Show what revision and author last modified each line of a file.', + input_schema: GitBlameInputSchema, + input_examples: [{ intent: 'find who last touched this line and why', path: 'src/index.ts' }], + buildArgs: (input) => { + const args = ['blame']; + if (input.ref != null) { + args.push('--end-of-options', input.ref); + } + args.push('--', input.path); + return args; + }, + }, + deps, + ), + createGitBranchListTool(deps), + createGitTool({ name: 'Git_TagList', operation: ToolOperation.Read, description: 'List tags.', input_schema: GitTagListInputSchema, input_examples: [{}], buildArgs: () => ['tag'] }, deps), + createGitTool({ name: 'Git_RemoteList', operation: ToolOperation.Read, description: 'List configured remotes.', input_schema: GitRemoteListInputSchema, input_examples: [{}], buildArgs: () => ['remote', '-v'], postProcess: redactUserinfo }, deps), + createGitTool({ name: 'Git_StashList', operation: ToolOperation.Read, description: 'List stash entries.', input_schema: GitStashListInputSchema, input_examples: [{}], buildArgs: () => ['stash', 'list'] }, deps), + createGitTool( + { + name: 'Git_Reflog', + operation: ToolOperation.Read, + description: "Show the reflog — every position HEAD (or another ref) has pointed at, including commits no longer reachable from any branch. This is the actual recovery path after a reflog-tier operation (rebase, amend, branch -D, stash drop): a commit that looks lost is usually still here.", + input_schema: GitReflogInputSchema, + input_examples: [{ intent: 'find the commit that got orphaned by the rebase' }], + buildArgs: (input) => { + const args = ['reflog', 'show']; + if (input.maxCount != null) { + args.push('-n', String(input.maxCount)); + } + if (input.ref != null) { + args.push('--end-of-options', input.ref); + } + return args; + }, + }, + deps, + ), + createGitTool( + { + name: 'Git_MergeBase', + operation: ToolOperation.Read, + description: 'Find the common ancestor of two refs — the actual check for whether a branch has diverged from another, not just what changed.', + input_schema: GitMergeBaseInputSchema, + input_examples: [{ intent: 'check how far this branch has diverged from main', refA: 'HEAD', refB: 'origin/main' }], + buildArgs: (input) => ['merge-base', '--end-of-options', input.refA, input.refB], + }, + deps, + ), + createGitTool( + { + name: 'Git_Describe', + operation: ToolOperation.Read, + description: 'Describe a ref in human-readable form relative to the nearest tag (e.g. v1.2.0-3-gabc1234).', + input_schema: GitDescribeInputSchema, + input_examples: [{ intent: 'find which release this commit shipped in' }], + buildArgs: (input) => { + const args = ['describe']; + if (input.tags) { + args.push('--tags'); + } + if (input.ref != null) { + args.push('--end-of-options', input.ref); + } + return args; + }, + }, + deps, + ), + createGitTool( + { + name: 'Git_Config', + operation: ToolOperation.Read, + description: "Read the repo's effective git config, or one specific key.", + input_schema: GitConfigInputSchema, + input_examples: [{ intent: 'confirm which remote a push will actually go to' }], + buildArgs: (input) => (input.key != null ? ['config', '--get', '--end-of-options', input.key] : ['config', '--list']), + // A credential-bearing key's value is redacted outright; every other value only has embedded + // URL userinfo masked. --list's output is `key=value` per line and needs the per-line form; + // --get's output is a bare value for the one key already known from input.key. + postProcess: (text, input) => (input.key != null ? redactConfigValue(input.key, text) : redactConfigListOutput(text)), + }, + deps, + ), + createGitTool( + { + name: 'Git_LsFiles', + operation: ToolOperation.Read, + description: 'List tracked files — respects .gitignore and the index, unlike a plain filesystem walk.', + input_schema: GitLsFilesInputSchema, + input_examples: [{ intent: 'confirm a generated file is actually gitignored, not just untracked by accident' }], + buildArgs: (input) => (input.path != null ? ['ls-files', '--', input.path] : ['ls-files']), + }, + deps, + ), + createGitWorktreeListTool(deps), + createGitTool( + { + name: 'Git_Grep', + operation: ToolOperation.Read, + description: 'Search file contents for a pattern — optionally at a past revision, without checking it out.', + input_schema: GitGrepInputSchema, + input_examples: [{ intent: 'find every place this function is still called before removing it', pattern: 'oldFunctionName' }], + buildArgs: (input) => { + const args = ['grep', '-e', input.pattern]; + if (input.ref != null) { + args.push('--end-of-options', input.ref); + } + return args; + }, + }, + deps, + ), + createGitTool( + { + name: 'Git_SubmoduleStatus', + operation: ToolOperation.Read, + description: 'Show the status of submodules.', + input_schema: GitSubmoduleStatusInputSchema, + input_examples: [{}], + buildArgs: (input) => (input.path != null ? ['submodule', 'status', '--', input.path] : ['submodule', 'status']), + }, + deps, + ), + + // safe + createGitTool({ name: 'Git_Add', operation: ToolOperation.Write, description: 'Stage paths for the next commit.', input_schema: GitAddInputSchema, input_examples: [{ paths: ['src/index.ts'] }], buildArgs: (input) => ['add', '--', ...input.paths] }, deps), + createGitTool({ name: 'Git_UnstageFile', operation: ToolOperation.Write, description: 'Unstage paths, leaving the working tree untouched.', input_schema: GitUnstageFileInputSchema, input_examples: [{ paths: ['src/index.ts'] }], buildArgs: (input) => ['restore', '--staged', '--', ...input.paths] }, deps), + createGitTool( + { + name: 'Git_RemoveCachedFile', + operation: ToolOperation.Write, + description: 'Untrack paths without touching the working copy (git rm --cached).', + input_schema: GitRemoveCachedFileInputSchema, + input_examples: [{ paths: ['secrets.env'] }], + buildArgs: (input) => ['rm', '--cached', '-r', '--', ...input.paths], + }, + deps, + ), + createGitTool( + { + name: 'Git_RemoveFile', + operation: ToolOperation.Write, + description: 'Remove paths from the working tree and index (git rm, no force — refused unless the path is clean).', + input_schema: GitRemoveFileInputSchema, + input_examples: [{ paths: ['old-file.ts'] }], + buildArgs: (input) => ['rm', '-r', '--', ...input.paths], + }, + deps, + ), + createGitTool({ name: 'Git_Commit', operation: ToolOperation.Write, description: 'Record staged changes as a new commit.', input_schema: GitCommitInputSchema, input_examples: [{ message: 'Fix the flaky retry test' }], buildArgs: (input) => ['commit', '-m', input.message] }, deps), + createGitTool( + { + name: 'Git_CreateBranch', + operation: ToolOperation.Write, + description: 'Create a new branch.', + input_schema: GitCreateBranchInputSchema, + input_examples: [{ name: 'feature/my-change' }], + buildArgs: (input) => (input.from != null ? ['branch', '--end-of-options', input.name, input.from] : ['branch', '--end-of-options', input.name]), + }, + deps, + ), + createGitTool( + { + name: 'Git_SwitchBranch', + operation: ToolOperation.Write, + description: 'Switch to an existing branch. Refused by git if it would discard conflicting uncommitted changes.', + input_schema: GitSwitchBranchInputSchema, + input_examples: [{ name: 'main' }], + buildArgs: (input) => ['switch', '--end-of-options', input.name], + }, + deps, + ), + createGitTool( + { name: 'Git_StashSave', operation: ToolOperation.Write, description: 'Save working-tree and staged changes to a new stash entry.', input_schema: GitStashSaveInputSchema, input_examples: [{}], buildArgs: (input) => (input.message != null ? ['stash', 'push', '-m', input.message] : ['stash', 'push']) }, + deps, + ), + createGitTool( + { + name: 'Git_Fetch', + operation: ToolOperation.Write, + description: 'Fetch refs from a remote into the local remote-tracking branches. Does not touch the working tree.', + input_schema: GitFetchInputSchema, + input_examples: [{}], + buildArgs: (input) => (input.remote != null ? ['fetch', '--end-of-options', input.remote] : ['fetch']), + }, + deps, + ), + createGitTool( + { + name: 'Git_Pull', + operation: ToolOperation.Write, + description: 'Fetch and fast-forward merge from a remote. Refused by git if the merge would not be a fast-forward.', + input_schema: GitPullInputSchema, + input_examples: [{}], + buildArgs: (input) => { + const args = ['pull', '--ff-only']; + if (input.remote != null || input.branch != null) { + args.push('--end-of-options'); + } + if (input.remote != null) { + args.push(input.remote); + } + if (input.branch != null) { + args.push(input.branch); + } + return args; + }, + }, + deps, + ), + createGitTool( + { + name: 'Git_Push', + operation: ToolOperation.Write, + description: 'Push the current branch to a remote. Rejected by git if it is not a fast-forward.', + input_schema: GitPushInputSchema, + input_examples: [{}], + buildArgs: (input) => { + const args = ['push']; + if (input.remote != null || input.branch != null) { + args.push('--end-of-options'); + } + if (input.remote != null) { + args.push(input.remote); + } + if (input.branch != null) { + args.push(input.branch); + } + return args; + }, + }, + deps, + ), + createGitTool( + { + name: 'Git_WorktreeAdd', + operation: ToolOperation.Write, + description: 'Add a new worktree. Refused by git if the branch is already checked out elsewhere or the path exists.', + input_schema: GitWorktreeAddInputSchema, + input_examples: [{ path: '../repo-feature-x', newBranch: 'feature/x' }], + buildArgs: (input) => { + const args = ['worktree', 'add']; + if (input.newBranch != null) { + args.push('-b', input.newBranch); + } + args.push('--end-of-options', input.path); + if (input.branch != null) { + args.push(input.branch); + } + return args; + }, + }, + deps, + ), + createGitTool( + { + name: 'Git_WorktreePrune', + operation: ToolOperation.Write, + description: 'Remove administrative files for worktrees whose directories are already gone. Never touches a worktree that still exists on disk.', + input_schema: GitWorktreePruneInputSchema, + input_examples: [{}], + buildArgs: (input) => (input.dryRun ? ['worktree', 'prune', '--dry-run'] : ['worktree', 'prune']), + }, + deps, + ), + createGitTool( + { + name: 'Git_WorktreeRemove', + operation: ToolOperation.Write, + description: 'Remove a worktree. Refused by git unless it is clean — no uncommitted or untracked changes.', + input_schema: GitWorktreeRemoveInputSchema, + input_examples: [{ path: '../repo-feature-x' }], + buildArgs: (input) => ['worktree', 'remove', '--end-of-options', input.path], + }, + deps, + ), + createGitTool( + { + name: 'Git_Merge', + operation: ToolOperation.Write, + description: 'Merge a branch into the current branch. May stop on conflicts — resolve them, then use Git_Continue, or Git_Abort to unwind.', + input_schema: GitMergeInputSchema, + input_examples: [{ branch: 'origin/main' }], + buildArgs: (input) => ['merge', '--end-of-options', input.branch], + }, + deps, + ), + createGitTool( + { + name: 'Git_CherryPick', + operation: ToolOperation.Write, + description: 'Apply the changes from an existing commit onto the current branch as a new commit. May stop on conflicts — resolve them, then use Git_Continue, or Git_Abort to unwind.', + input_schema: GitCherryPickInputSchema, + input_examples: [{ commit: 'abc1234' }], + buildArgs: (input) => ['cherry-pick', '--end-of-options', input.commit], + }, + deps, + ), + createGitTool( + { + name: 'Git_Revert', + operation: ToolOperation.Write, + description: 'Create a new commit that undoes an existing one. Purely additive — never rewrites history, unlike Git_Rebase/Git_AmendCommit.', + input_schema: GitRevertInputSchema, + input_examples: [{ commit: 'abc1234' }], + buildArgs: (input) => ['revert', '--no-edit', '--end-of-options', input.commit], + }, + deps, + ), + createGitTool( + { + name: 'Git_Clone', + operation: ToolOperation.Write, + description: 'Clone a repository into a new directory.', + input_schema: GitCloneInputSchema, + input_examples: [{ url: 'https://github.com/shellicar/claude-cli.git' }], + buildArgs: (input) => { + const args = ['clone', '--end-of-options', input.url]; + if (input.path != null) { + args.push(input.path); + } + return args; + }, + }, + deps, + ), + createGitTool( + { + name: 'Git_Init', + operation: ToolOperation.Write, + description: 'Create an empty git repository, or reinitialise an existing one.', + input_schema: GitInitInputSchema, + input_examples: [{}], + buildArgs: (input) => (input.path != null ? ['init', '--end-of-options', input.path] : ['init']), + }, + deps, + ), + createGitTool( + { + name: 'Git_Move', + operation: ToolOperation.Write, + description: 'Move or rename a tracked file, directory, or symlink.', + input_schema: GitMoveInputSchema, + input_examples: [{ source: 'old-name.ts', dest: 'new-name.ts' }], + buildArgs: (input) => ['mv', '--', input.source, input.dest], + }, + deps, + ), + createGitTool( + { + name: 'Git_SubmoduleAdd', + operation: ToolOperation.Write, + description: 'Add a new submodule.', + input_schema: GitSubmoduleAddInputSchema, + input_examples: [{ url: 'https://github.com/shellicar/some-lib.git' }], + buildArgs: (input) => { + const args = ['submodule', 'add', '--end-of-options', input.url]; + if (input.path != null) { + args.push(input.path); + } + return args; + }, + }, + deps, + ), + createGitTool( + { + name: 'Git_SubmoduleUpdate', + operation: ToolOperation.Write, + description: 'Update submodules to the commit recorded in the superproject. No force — refused by git if a submodule has local changes it would discard.', + input_schema: GitSubmoduleUpdateInputSchema, + input_examples: [{}], + buildArgs: (input) => { + const args = ['submodule', 'update']; + if (input.init) { + args.push('--init'); + } + if (input.recursive) { + args.push('--recursive'); + } + if (input.path != null) { + args.push('--', input.path); + } + return args; + }, + }, + deps, + ), + createGitTool( + { + name: 'Git_SubmoduleDeinit', + operation: ToolOperation.Write, + description: 'Deinitialise a submodule, removing its working tree. No force — refused by git unless the submodule is clean.', + input_schema: GitSubmoduleDeinitInputSchema, + input_examples: [{ path: 'vendor/some-lib' }], + buildArgs: (input) => ['submodule', 'deinit', '--end-of-options', input.path], + }, + deps, + ), + + // reflog — crosses no privilege boundary (unlike escalate) and destroys nothing irrecoverable + // (unlike delete); recoverable only via the underlying system's own undo (git's reflog), not this + // tool. Configurable via the zone matrix like read/write/delete, defaulting to Ask either way. + createGitTool( + { + name: 'Git_AmendCommit', + operation: ToolOperation.Reflog, + description: 'Replace the tip commit. Rewrites local history — reflog-recoverable, not safe to auto-approve.', + input_schema: GitAmendCommitInputSchema, + input_examples: [{ intent: 'fix a typo in the commit message before pushing' }], + buildArgs: (input) => (input.message != null ? ['commit', '--amend', '-m', input.message] : ['commit', '--amend', '--no-edit']), + guard: defaultBranchGuard(null, 'Git_AmendCommit'), + }, + deps, + ), + createGitTool( + { + name: 'Git_Rebase', + operation: ToolOperation.Reflog, + description: 'Rebase the current branch onto another ref. Rewrites local history.', + input_schema: GitRebaseInputSchema, + input_examples: [{ intent: 'bring the branch up to date before opening a PR', base: 'origin/main' }], + buildArgs: (input) => ['rebase', '--end-of-options', input.base], + guard: defaultBranchGuard(null, 'Git_Rebase'), + }, + deps, + ), + createGitTool( + { + name: 'Git_RebaseOnto', + operation: ToolOperation.Reflog, + description: "Rebase only the branch's own commits (oldBase..branch) onto newBase — use when the branch was not cut from oldBase directly.", + input_schema: GitRebaseOntoInputSchema, + input_examples: [{ intent: 'move the branch off develop onto main now that develop merged', oldBase: 'develop', newBase: 'origin/main', branch: 'feature/my-change' }], + buildArgs: (input) => ['rebase', '--onto', input.newBase, '--end-of-options', input.oldBase, input.branch], + guard: protectDefaultBranch ? (input, guardDeps, cwd) => assertNotDefaultBranch(guardDeps, cwd, input.branch, 'Git_RebaseOnto') : undefined, + }, + deps, + ), + createGitTool( + { + name: 'Git_StashDrop', + operation: ToolOperation.Reflog, + description: 'Permanently delete a stash entry.', + input_schema: GitStashDropInputSchema, + input_examples: [{ intent: 'clean up a stash that was already applied' }], + buildArgs: (input) => (input.stashRef != null ? ['stash', 'drop', '--end-of-options', input.stashRef] : ['stash', 'drop']), + }, + deps, + ), + createGitTool( + { + name: 'Git_DeleteBranchForce', + operation: ToolOperation.Reflog, + description: 'Force-delete a branch, including unmerged commits.', + input_schema: GitDeleteBranchForceInputSchema, + input_examples: [{ intent: 'remove a merged feature branch that is no longer needed', name: 'old-branch' }], + buildArgs: (input) => ['branch', '-D', '--end-of-options', input.name], + guard: protectDefaultBranch ? (input, guardDeps, cwd) => assertNotDefaultBranch(guardDeps, cwd, input.name, 'Git_DeleteBranchForce') : undefined, + }, + deps, + ), + createGitTool( + { + name: 'Git_ForcePushWithLease', + operation: ToolOperation.Reflog, + description: 'Force-push, refused by git if the remote tip has moved since it was last fetched — the safer alternative to plain force, which this tool does not provide.', + input_schema: GitForcePushWithLeaseInputSchema, + input_examples: [{ intent: 'publish the rebase just performed on this feature branch' }], + buildArgs: (input) => { + const args = ['push', '--force-with-lease']; + if (input.remote != null || input.branch != null) { + args.push('--end-of-options'); + } + if (input.remote != null) { + args.push(input.remote); + } + if (input.branch != null) { + args.push(input.branch); + } + return args; + }, + guard: protectDefaultBranch ? (input, guardDeps, cwd) => assertNotDefaultBranch(guardDeps, cwd, input.branch ?? null, 'Git_ForcePushWithLease') : undefined, + }, + deps, + ), + ]; + + if (options.enableUnrecoverable) { + tools.push( + createGitTool( + { + name: 'Git_DiscardFileChanges', + operation: ToolOperation.Delete, + description: 'Discard uncommitted working-tree changes to paths. No recovery — this content was never committed.', + input_schema: GitDiscardFileChangesInputSchema, + input_examples: [{ intent: 'throw away a failed experiment before trying a different approach', paths: ['src/index.ts'] }], + buildArgs: (input) => ['restore', '--', ...input.paths], + }, + deps, + ), + createGitTool( + { + name: 'Git_DiscardAllChanges', + operation: ToolOperation.Delete, + description: 'Discard all uncommitted working-tree and staged changes (git reset --hard). No recovery for the discarded content.', + input_schema: GitDiscardAllChangesInputSchema, + input_examples: [{ intent: 'reset the working tree after an approach that did not pan out' }], + buildArgs: () => ['reset', '--hard'], + }, + deps, + ), + createGitTool( + { + name: 'Git_ForceRemoveFile', + operation: ToolOperation.Delete, + description: 'Force-remove paths (git rm -f), bypassing the clean/up-to-date check. Uncommitted changes are lost with no recovery.', + input_schema: GitForceRemoveFileInputSchema, + input_examples: [{ intent: 'remove a generated file that keeps reappearing as modified', paths: ['src/index.ts'] }], + buildArgs: (input) => ['rm', '-f', '-r', '--', ...input.paths], + }, + deps, + ), + ); + } + + return tools; +} diff --git a/packages/claude-sdk-tools/src/Git/worktreeList.ts b/packages/claude-sdk-tools/src/Git/worktreeList.ts new file mode 100644 index 00000000..9aa54eac --- /dev/null +++ b/packages/claude-sdk-tools/src/Git/worktreeList.ts @@ -0,0 +1,69 @@ +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; +import type { GitDeps } from './runGit'; +import { runGit } from './runGit'; +import { GitWorktreeListInputSchema, GitWorktreeListOutputSchema } from './schema'; + +/** `git worktree list`'s default table has no header and packs everything (path, abbreviated SHA, + * branch in brackets, [locked]/[prunable] markers) into one line the reader has to already know + * how to parse \u2014 the same shape of ambiguity Git_BranchList's `*`/`+` markers had. `--porcelain` + * gives the same facts as real, blank-line-separated key/value blocks instead. */ +type WorktreeEntry = { path: string; head: string | null; branch: string | null; locked: string | null; prunable: string | null }; + +function parsePorcelain(stdout: string): WorktreeEntry[] { + const entries: WorktreeEntry[] = []; + let current: WorktreeEntry | null = null; + + for (const line of stdout.split('\n')) { + if (line.length === 0) { + continue; + } + if (line.startsWith('worktree ')) { + if (current) { + entries.push(current); + } + current = { path: line.slice('worktree '.length), head: null, branch: null, locked: null, prunable: null }; + continue; + } + if (!current) { + continue; + } + if (line.startsWith('HEAD ')) { + current.head = line.slice('HEAD '.length); + } else if (line.startsWith('branch ')) { + const ref = line.slice('branch '.length); + const prefix = 'refs/heads/'; + current.branch = ref.startsWith(prefix) ? ref.slice(prefix.length) : ref; + } else if (line === 'locked') { + current.locked = ''; + } else if (line.startsWith('locked ')) { + current.locked = line.slice('locked '.length); + } else if (line === 'prunable') { + current.prunable = ''; + } else if (line.startsWith('prunable ')) { + current.prunable = line.slice('prunable '.length); + } + } + if (current) { + entries.push(current); + } + return entries; +} + +export function createGitWorktreeListTool(deps: GitDeps) { + return defineTool({ + name: 'Git_WorktreeList', + operation: ToolOperation.Read, + description: 'List worktrees.', + input_schema: GitWorktreeListInputSchema, + output_schema: GitWorktreeListOutputSchema, + input_examples: [{}], + handler: async (input) => { + const cwd = input.cwd ?? process.cwd(); + const result = await runGit(deps, ['worktree', 'list', '--porcelain'], cwd); + if (result.exitCode !== 0) { + throw new Error(result.stderr.trim() || `git worktree list --porcelain failed with exit code ${result.exitCode}`); + } + return { textContent: parsePorcelain(result.stdout) }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/GitHub/createGhPrTool.ts b/packages/claude-sdk-tools/src/GitHub/createGhPrTool.ts index ce96994b..3b374741 100644 --- a/packages/claude-sdk-tools/src/GitHub/createGhPrTool.ts +++ b/packages/claude-sdk-tools/src/GitHub/createGhPrTool.ts @@ -1,4 +1,4 @@ -import { defineTool } from '@shellicar/claude-sdk'; +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; import type { z } from 'zod'; import type { GhEscalatedDeps } from './runGhEscalated'; import { runGhEscalated } from './runGhEscalated'; @@ -24,7 +24,7 @@ export function createGhPrTool(spec: GhPrToolSpec string, clock: Clock) { const SearchHistory = defineTool({ name: 'SearchHistory', - operation: 'read', + operation: ToolOperation.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: ToolOperation.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/Memory/Memory.ts b/packages/claude-sdk-tools/src/Memory/Memory.ts index cee3d65d..88adf2ea 100644 --- a/packages/claude-sdk-tools/src/Memory/Memory.ts +++ b/packages/claude-sdk-tools/src/Memory/Memory.ts @@ -1,4 +1,5 @@ import type { IMemoryStore } from '@shellicar/claude-core/memory/interfaces'; +import { ToolOperation } from '@shellicar/claude-sdk'; import { defineTool } from '@shellicar/claude-sdk/defineTool'; import { DeleteMemoryInputSchema, DeleteMemoryOutputSchema, MemoryTypesInputSchema, MemoryTypesOutputSchema, ReadMemoryInputSchema, ReadMemoryOutputSchema, SearchMemoryInputSchema, SearchMemoryOutputSchema, WriteMemoryInputSchema, WriteMemoryOutputSchema } from './schema'; import type { DeleteMemoryOutput, MemoryTypesOutput, ReadMemoryOutput, SearchMemoryOutput, WriteMemoryOutput } from './types'; @@ -6,7 +7,7 @@ import type { DeleteMemoryOutput, MemoryTypesOutput, ReadMemoryOutput, SearchMem export function createMemoryTools(store: IMemoryStore) { const WriteMemory = defineTool({ name: 'WriteMemory', - operation: 'write', + operation: ToolOperation.Write, description: 'Write a memory for any later Claude to find. Records what you learned — a trap, a decision and its reasoning, a correction — so it survives this session. Title is the handle that ranks; body is the memory; type classifies it.', input_schema: WriteMemoryInputSchema, output_schema: WriteMemoryOutputSchema, @@ -40,7 +41,7 @@ export function createMemoryTools(store: IMemoryStore) { const ReadMemory = defineTool({ name: 'ReadMemory', - operation: 'read', + operation: ToolOperation.Read, description: 'Fetch one memory by its id. Returns not-found if the id is unknown or has been retired.', input_schema: ReadMemoryInputSchema, output_schema: ReadMemoryOutputSchema, @@ -54,7 +55,7 @@ export function createMemoryTools(store: IMemoryStore) { const SearchMemory = defineTool({ name: 'SearchMemory', - operation: 'read', + operation: ToolOperation.Read, description: 'Search every memory by relevance. Describe what you need in plain words; the most relevant memories come back ranked, best first. Optionally narrow to one type. Results are NOT scoped to the current repository — search spans every memory in the store. Each hit carries the environment (host/org/repo) it was written in; that is there to help you judge whether a memory is relevant to what you are doing now, not to filter results. The only isolation is the tenantId in CLI config, which selects a separate store.', input_schema: SearchMemoryInputSchema, @@ -71,7 +72,7 @@ export function createMemoryTools(store: IMemoryStore) { const DeleteMemory = defineTool({ name: 'DeleteMemory', - operation: 'delete', + operation: ToolOperation.Delete, description: 'Retire a memory by id so it stops surfacing in search — use when rewriting a memory that is wrong. Idempotent: deleting an unknown or already-retired id still succeeds.', input_schema: DeleteMemoryInputSchema, output_schema: DeleteMemoryOutputSchema, @@ -84,7 +85,7 @@ export function createMemoryTools(store: IMemoryStore) { const MemoryTypes = defineTool({ name: 'MemoryTypes', - operation: 'read', + operation: ToolOperation.Read, description: 'List the distinct memory types in use with their counts, so you reuse an established word rather than coin a near-duplicate.', input_schema: MemoryTypesInputSchema, output_schema: MemoryTypesOutputSchema, diff --git a/packages/claude-sdk-tools/src/Pipe/Pipe.ts b/packages/claude-sdk-tools/src/Pipe/Pipe.ts index d73b80ce..fa884057 100644 --- a/packages/claude-sdk-tools/src/Pipe/Pipe.ts +++ b/packages/claude-sdk-tools/src/Pipe/Pipe.ts @@ -1,4 +1,4 @@ -import { defineTool } from '@shellicar/claude-sdk'; +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; import { z } from 'zod'; import { type ComposableTool, type EdgeIn, PipeStepError, reconcile } from '../composable'; import { flattenContent, flattenFiles, type Stream, type StreamKind } from '../stream'; @@ -31,7 +31,7 @@ export function createPipe(tools: ComposableTool[]) { return defineTool({ name: 'Pipe', description: 'Run a sequence of composable read tools as a pipeline. Start with a source (Find, Paths); follow with stages (Read, Match, Head, Tail, Range). Each step writes only its own fields — the stream flows between steps automatically.', - operation: 'read', + operation: ToolOperation.Read, input_schema: PipeToolInputSchema, output_schema: z.union([z.string(), FatalSchema]), input_examples: [ diff --git a/packages/claude-sdk-tools/src/ReadFile/ReadFile.ts b/packages/claude-sdk-tools/src/ReadFile/ReadFile.ts index 9cb2d727..db9a86ff 100644 --- a/packages/claude-sdk-tools/src/ReadFile/ReadFile.ts +++ b/packages/claude-sdk-tools/src/ReadFile/ReadFile.ts @@ -3,7 +3,7 @@ import { conditionImage } from '@shellicar/claude-core/image/conditionImage'; import type { SipsBridge } from '@shellicar/claude-core/image/SipsBridge'; import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; import type { ToolAttachmentBlock } from '@shellicar/claude-sdk'; -import { defineTool } from '@shellicar/claude-sdk'; +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; import { fileTypeFromBuffer } from 'file-type'; import { isNodeError } from '../isNodeError'; import { ReadFileInputSchema, ReadFileOutputSchema } from './schema'; @@ -51,7 +51,7 @@ export function createReadFile(fs: IFileSystem, sips: SipsBridge, logger: ILogge return defineTool({ name: 'ReadFile', description: 'Read a single file outside a pipe. Text returns as line-numbered content; PDFs and images (png, jpeg, gif, webp) return as native document/image blocks via the mimeType parameter. To read files inside a pipe, use Paths | Read.', - operation: 'read', + operation: ToolOperation.Read, input_schema: ReadFileInputSchema, output_schema: ReadFileOutputSchema, input_examples: [{ path: '/path/to/file.ts' }, { path: '~/file.ts' }, { path: '$HOME/file.ts' }, { path: '/path/to/doc.pdf', mimeType: 'application/pdf' }, { path: '/path/to/image.png', mimeType: 'image/*' }], diff --git a/packages/claude-sdk-tools/src/Skill/Skill.ts b/packages/claude-sdk-tools/src/Skill/Skill.ts index cbc9db04..69649f20 100644 --- a/packages/claude-sdk-tools/src/Skill/Skill.ts +++ b/packages/claude-sdk-tools/src/Skill/Skill.ts @@ -1,6 +1,6 @@ import type { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; import type { ILogger } from '@shellicar/claude-core/logging/ILogger'; -import { defineTool } from '@shellicar/claude-sdk'; +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; import { splitFrontmatter } from './frontmatter'; import { resolveSkills } from './resolve'; import { SkillInputSchema, SkillOutputSchema } from './schema'; @@ -16,7 +16,7 @@ import type { SkillOutput } from './types'; export function createSkillTool(fs: IFileSystem, skillDirs: readonly string[], logger?: ILogger) { return defineTool({ name: 'Skill', - operation: 'read', + operation: ToolOperation.Read, description: "Load a skill's instructions into the conversation. Available skills are listed in the injected skills catalogue; invoke only names from that list, never guessed ones. When a skill matches the task, invoke it before responding.", input_schema: SkillInputSchema, output_schema: SkillOutputSchema, diff --git a/packages/claude-sdk-tools/src/TsDefinition/TsDefinition.ts b/packages/claude-sdk-tools/src/TsDefinition/TsDefinition.ts index af4029eb..051d96dd 100644 --- a/packages/claude-sdk-tools/src/TsDefinition/TsDefinition.ts +++ b/packages/claude-sdk-tools/src/TsDefinition/TsDefinition.ts @@ -1,4 +1,4 @@ -import { defineTool } from '@shellicar/claude-sdk'; +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; import type { z } from 'zod'; import { groupByFile } from '../typescript/groupByFile'; import type { ITypeScriptService } from '../typescript/ITypeScriptService'; @@ -8,7 +8,7 @@ export type TsDefinitionOutput = z.output; export function createTsDefinition(ts: ITypeScriptService) { return defineTool({ - operation: 'read', + operation: ToolOperation.Read, name: 'TsDefinition', description: 'Go to the definition of a symbol at a specific position in a TypeScript file. Returns the definition positions grouped by file path. May return multiple locations for overloaded functions or declaration merging.', input_schema: TsDefinitionInputSchema, diff --git a/packages/claude-sdk-tools/src/TsDiagnostics/TsDiagnostics.ts b/packages/claude-sdk-tools/src/TsDiagnostics/TsDiagnostics.ts index d7a27012..09786cbf 100644 --- a/packages/claude-sdk-tools/src/TsDiagnostics/TsDiagnostics.ts +++ b/packages/claude-sdk-tools/src/TsDiagnostics/TsDiagnostics.ts @@ -1,4 +1,4 @@ -import { defineTool } from '@shellicar/claude-sdk'; +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; import type { z } from 'zod'; import { groupByFile } from '../typescript/groupByFile'; import type { Diagnostic, ITypeScriptService } from '../typescript/ITypeScriptService'; @@ -8,7 +8,7 @@ export type TsDiagnosticsOutput = z.output; export function createTsDiagnostics(ts: ITypeScriptService) { return defineTool({ - operation: 'read', + operation: ToolOperation.Read, name: 'TsDiagnostics', description: 'Get TypeScript diagnostics (type errors, syntax errors) for one or more files. Returns diagnostics grouped by file path, each entry including line, character, message, and error code.', input_schema: TsDiagnosticsInputSchema, diff --git a/packages/claude-sdk-tools/src/TsHover/TsHover.ts b/packages/claude-sdk-tools/src/TsHover/TsHover.ts index 2b85ac35..a311bf94 100644 --- a/packages/claude-sdk-tools/src/TsHover/TsHover.ts +++ b/packages/claude-sdk-tools/src/TsHover/TsHover.ts @@ -1,10 +1,10 @@ -import { defineTool } from '@shellicar/claude-sdk'; +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; import type { ITypeScriptService } from '../typescript/ITypeScriptService'; import { TsHoverInputSchema, TsHoverOutputSchema } from './schema'; export function createTsHover(ts: ITypeScriptService) { return defineTool({ - operation: 'read', + operation: ToolOperation.Read, name: 'TsHover', description: 'Get type information and documentation for a symbol at a specific position in a TypeScript file. Returns the type signature, symbol kind, and any JSDoc documentation.', input_schema: TsHoverInputSchema, diff --git a/packages/claude-sdk-tools/src/TsReferences/TsReferences.ts b/packages/claude-sdk-tools/src/TsReferences/TsReferences.ts index 2eaef6f0..8dcade44 100644 --- a/packages/claude-sdk-tools/src/TsReferences/TsReferences.ts +++ b/packages/claude-sdk-tools/src/TsReferences/TsReferences.ts @@ -1,4 +1,4 @@ -import { defineTool } from '@shellicar/claude-sdk'; +import { defineTool, ToolOperation } from '@shellicar/claude-sdk'; import type { z } from 'zod'; import { groupByFile } from '../typescript/groupByFile'; import type { ITypeScriptService } from '../typescript/ITypeScriptService'; @@ -8,7 +8,7 @@ export type TsReferencesOutput = z.output; export function createTsReferences(ts: ITypeScriptService) { return defineTool({ - operation: 'read', + operation: ToolOperation.Read, name: 'TsReferences', description: 'Find all references to a symbol at a specific position in a TypeScript file. Returns every location where the symbol is used across the project, grouped by file path, including the definition site.', input_schema: TsReferencesInputSchema, diff --git a/packages/claude-sdk-tools/src/composable.ts b/packages/claude-sdk-tools/src/composable.ts index 1e55d215..d44301b3 100644 --- a/packages/claude-sdk-tools/src/composable.ts +++ b/packages/claude-sdk-tools/src/composable.ts @@ -1,4 +1,4 @@ -import { type AnyToolDefinition, defineTool } from '@shellicar/claude-sdk'; +import { type AnyToolDefinition, defineTool, ToolOperation } from '@shellicar/claude-sdk'; import { z } from 'zod'; import { type ContentStream, type FilesStream, flattenContent, flattenFiles, type Stream } from './stream'; @@ -58,7 +58,7 @@ export function toStandalone(t: ComposableTool): AnyToolDefinition { const tool = defineTool({ name: t.name, description: t.description, - operation: 'read', + operation: ToolOperation.Read, input_schema: t.model, output_schema: z.union([z.string(), z.object({ tool: z.string(), error: z.string() })]), input_examples: t.input_examples as Record[], diff --git a/packages/claude-sdk-tools/src/entry/Git.ts b/packages/claude-sdk-tools/src/entry/Git.ts new file mode 100644 index 00000000..0e760baa --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/Git.ts @@ -0,0 +1,9 @@ +import { executor } from '../exec-shared'; +import { nodeFs } from '../fs/nodeFs.js'; +import type { GitDeps } from '../Git/runGit'; +import { createGitTools } from '../Git/tools'; + +export type { GitDeps }; +// Shares the process-wide Executor with ExecV3/GitHub/AzureDevOps/Az (see their entry files), so +// git calls are tracked and reaped by the same exit-sweep handler as every other exec child. +export { createGitTools, executor as gitExecutor, nodeFs as gitFs }; diff --git a/packages/claude-sdk-tools/test/ExecV3/rules.spec.ts b/packages/claude-sdk-tools/test/ExecV3/rules.spec.ts index 77e6d68f..4ef5efba 100644 --- a/packages/claude-sdk-tools/test/ExecV3/rules.spec.ts +++ b/packages/claude-sdk-tools/test/ExecV3/rules.spec.ts @@ -1,6 +1,7 @@ import { ToolRefusedError } from '@shellicar/claude-sdk'; import { describe, expect, it } from 'vitest'; import { StaticRulesConfigProvider } from '../../src/Exec/IRulesConfigProvider'; +import { supersededGitRules } from '../../src/Exec/ruleConfig'; import { createExecV3 } from '../../src/ExecV3/ExecV3'; import { FakeExecutor, shellLikeResponder } from '../FakeExecutor'; import { call } from '../helpers'; @@ -17,6 +18,19 @@ async function blocked(commands: { program: string; args?: string[] }[]) { await expect(actual).rejects.toBeInstanceOf(ToolRefusedError); } +// supersededGitRules is no longer part of defaultRules — no-raw-git blocks git outright, so +// these are inert unless a config opts back into them explicitly. Build a dedicated instance per +// describe block that does exactly that (with no-raw-git nulled out), so each test proves the +// specific rule's own matcher/message, not just that git is blocked at all for some other reason. +function execV3WithSupersededRule(name: keyof typeof supersededGitRules) { + return createExecV3(new MemoryFileSystem(), new FakeExecutor(shellLikeResponder()), { buildEnv: (cmdEnv) => ({ ...process.env, ...cmdEnv }) }, new StaticRulesConfigProvider({ 'no-raw-git': null, [name]: supersededGitRules[name] })); +} + +async function blockedBySuperseded(name: keyof typeof supersededGitRules, commands: { program: string; args?: string[] }[]) { + const actual = call(execV3WithSupersededRule(name), { intent: 'test', commands }); + await expect(actual).rejects.toBeInstanceOf(ToolRefusedError); +} + describe('no-destructive-commands — rm -rf /tmp/whatever', () => { it('refuses rm', async () => { await blocked([{ program: 'rm', args: ['-rf', '/tmp/whatever'] }]); @@ -46,64 +60,87 @@ describe('no-sed-in-place', () => { }); }); -describe('no-git-rm — git rm file', () => { +describe('no-git-rm (superseded by no-raw-git; opt back in via config) — git rm file', () => { it('refuses git rm', async () => { - await blocked([{ program: 'git', args: ['rm', 'file'] }]); + await blockedBySuperseded('no-git-rm', [{ program: 'git', args: ['rm', 'file'] }]); }); }); -describe('no-git-checkout — git checkout .', () => { +describe('no-git-checkout (superseded by no-raw-git; opt back in via config) — git checkout .', () => { it('refuses git checkout', async () => { - await blocked([{ program: 'git', args: ['checkout', '.'] }]); + await blockedBySuperseded('no-git-checkout', [{ program: 'git', args: ['checkout', '.'] }]); }); }); -describe('no-git-reset — git reset --hard', () => { +describe('no-git-reset (superseded by no-raw-git; opt back in via config) — git reset --hard', () => { it('refuses git reset', async () => { - await blocked([{ program: 'git', args: ['reset', '--hard'] }]); + await blockedBySuperseded('no-git-reset', [{ program: 'git', args: ['reset', '--hard'] }]); }); }); -describe('no-git-clean — git clean -fd', () => { +describe('no-git-clean (superseded by no-raw-git; opt back in via config) — git clean -fd', () => { it('refuses git clean', async () => { - await blocked([{ program: 'git', args: ['clean', '-fd'] }]); + await blockedBySuperseded('no-git-clean', [{ program: 'git', args: ['clean', '-fd'] }]); }); }); -describe('no-force-push', () => { +describe('no-force-push (superseded by no-raw-git; opt back in via config)', () => { it('refuses "git push -f"', async () => { - await blocked([{ program: 'git', args: ['push', '-f'] }]); + await blockedBySuperseded('no-force-push', [{ program: 'git', args: ['push', '-f'] }]); }); it('refuses "git push --force"', async () => { - await blocked([{ program: 'git', args: ['push', '--force'] }]); + await blockedBySuperseded('no-force-push', [{ program: 'git', args: ['push', '--force'] }]); }); it('refuses "git push --force-with-lease=main:abc" (attached value)', async () => { - await blocked([{ program: 'git', args: ['push', '--force-with-lease=main:abc'] }]); + await blockedBySuperseded('no-force-push', [{ program: 'git', args: ['push', '--force-with-lease=main:abc'] }]); }); it('names the rule in the refusal reason', async () => { - const actual = call(ExecV3, { intent: 'test', commands: [{ program: 'git', args: ['push', '-f'] }] }); + const actual = call(execV3WithSupersededRule('no-force-push'), { intent: 'test', commands: [{ program: 'git', args: ['push', '-f'] }] }); await expect(actual).rejects.toThrow('no-force-push'); }); }); -describe('no-git-C', () => { +describe('no-git-C (superseded by no-raw-git; opt back in via config)', () => { it('refuses "git -C /tmp status"', async () => { - await blocked([{ program: 'git', args: ['-C', '/tmp', 'status'] }]); + await blockedBySuperseded('no-git-C', [{ program: 'git', args: ['-C', '/tmp', 'status'] }]); }); it('refuses "git --work-tree /tmp status"', async () => { - await blocked([{ program: 'git', args: ['--work-tree', '/tmp', 'status'] }]); + await blockedBySuperseded('no-git-C', [{ program: 'git', args: ['--work-tree', '/tmp', 'status'] }]); }); it('refuses "git --git-dir /tmp/.git status"', async () => { - await blocked([{ program: 'git', args: ['--git-dir', '/tmp/.git', 'status'] }]); + await blockedBySuperseded('no-git-C', [{ program: 'git', args: ['--git-dir', '/tmp/.git', 'status'] }]); }); it('refuses "git -c core.pager=id log" (config injection)', async () => { - await blocked([{ program: 'git', args: ['-c', 'core.pager=id', 'log'] }]); + await blockedBySuperseded('no-git-C', [{ program: 'git', args: ['-c', 'core.pager=id', 'log'] }]); + }); +}); + +describe('no-raw-git — the Git_* tools are the only door to git, not just the recommended one', () => { + it('refuses a benign git call the other, more specific git rules never covered (git status)', async () => { + await blocked([{ program: 'git', args: ['status'] }]); + }); + + it('refuses git config --list, which no other git rule matches at all', async () => { + await blocked([{ program: 'git', args: ['config', '--list'] }]); + }); + + it('names the rule in the refusal reason', async () => { + const actual = call(ExecV3, { intent: 'test', commands: [{ program: 'git', args: ['status'] }] }); + await expect(actual).rejects.toThrow('no-raw-git'); + }); + + it('is itself removable via config, same as any other built-in rule', async () => { + const configured = createExecV3(new MemoryFileSystem(), new FakeExecutor(shellLikeResponder()), { buildEnv: (cmdEnv) => ({ ...process.env, ...cmdEnv }) }, new StaticRulesConfigProvider({ 'no-raw-git': null })); + const result = await call(configured, { intent: 'test', commands: [{ program: 'git', args: ['status'] }] }); + const expected = true; + const actual = result.results[0]?.exitCode !== undefined; + expect(actual).toBe(expected); }); }); @@ -185,7 +222,9 @@ describe('rule config — a key set to null removes a built-in rule', () => { describe('rule config — a key naming a built-in replaces it wholesale', () => { it('narrows no-force-push to only match --force (not -f) when replaced', async () => { - const configured = createExecV3(new MemoryFileSystem(), new FakeExecutor(shellLikeResponder()), { buildEnv: (cmdEnv) => ({ ...process.env, ...cmdEnv }) }, new StaticRulesConfigProvider({ 'no-force-push': { argsAnyOf: ['--force'] } })); + // no-raw-git blocks every git invocation outright, independent of no-force-push — null it out here + // so this test isolates what it's actually about: does replacing a rule narrow its own matcher. + const configured = createExecV3(new MemoryFileSystem(), new FakeExecutor(shellLikeResponder()), { buildEnv: (cmdEnv) => ({ ...process.env, ...cmdEnv }) }, new StaticRulesConfigProvider({ 'no-force-push': { argsAnyOf: ['--force'] }, 'no-raw-git': null })); const result = await call(configured, { intent: 'test', commands: [{ program: 'git', args: ['push', '-f'] }] }); const expected = true; const actual = result.results[0]?.exitCode !== undefined; diff --git a/packages/claude-sdk-tools/test/Git/branchList.spec.ts b/packages/claude-sdk-tools/test/Git/branchList.spec.ts new file mode 100644 index 00000000..5b3ce9c9 --- /dev/null +++ b/packages/claude-sdk-tools/test/Git/branchList.spec.ts @@ -0,0 +1,44 @@ +import type { CommandSpec, IExecutor, SpawnOpts } from '@shellicar/exec-core'; +import { describe, expect, it } from 'vitest'; +import { createGitBranchListTool } from '../../src/Git/branchList'; +import { call } from '../helpers'; +import { MemoryFileSystem } from '../MemoryFileSystem'; + +function scriptedExecutor(stdout: string): { executor: IExecutor; calls: CommandSpec[] } { + const calls: CommandSpec[] = []; + const executor: IExecutor = { + run: async (cmd: CommandSpec, opts?: SpawnOpts) => { + calls.push(cmd); + opts?.stdout?.write(stdout); + return { exitCode: 0, signal: null }; + }, + }; + return { executor, calls }; +} + +describe('Git_BranchList', () => { + it('parses the current branch, an ordinary branch, and one checked out in another worktree into real fields', async () => { + const stdout = ['*\tmain\t', ' \tfeature/x\t', ' \tfeature/y\t/repo-worktrees/y'].join('\n'); + const { executor } = scriptedExecutor(`${stdout}\n`); + const tool = createGitBranchListTool({ executor, fs: new MemoryFileSystem() }); + + const expected = [ + { name: 'main', current: true, worktreePath: null }, + { name: 'feature/x', current: false, worktreePath: null }, + { name: 'feature/y', current: false, worktreePath: '/repo-worktrees/y' }, + ]; + const actual = await call(tool, {}); + expect(actual).toEqual(expected); + }); + + it('passes --all through when requested', async () => { + const { executor, calls } = scriptedExecutor(''); + const tool = createGitBranchListTool({ executor, fs: new MemoryFileSystem() }); + + await call(tool, { all: true }); + + const expected = true; + const actual = calls[0]?.args?.includes('--all'); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Git/continueAbort.spec.ts b/packages/claude-sdk-tools/test/Git/continueAbort.spec.ts new file mode 100644 index 00000000..8e5da7fa --- /dev/null +++ b/packages/claude-sdk-tools/test/Git/continueAbort.spec.ts @@ -0,0 +1,150 @@ +import type { CommandSpec, IExecutor, SpawnOpts } from '@shellicar/exec-core'; +import { describe, expect, it } from 'vitest'; +import { createGitContinueAbortTools } from '../../src/Git/continueAbort'; +import { call } from '../helpers'; +import { MemoryFileSystem } from '../MemoryFileSystem'; + +function recordingExecutor(): { executor: IExecutor; calls: CommandSpec[] } { + const calls: CommandSpec[] = []; + const executor: IExecutor = { + run: async (cmd: CommandSpec, _opts?: SpawnOpts) => { + calls.push(cmd); + return { exitCode: 0, signal: null }; + }, + }; + return { executor, calls }; +} + +describe('Git_Continue', () => { + it('runs merge --continue when a merge is in progress', async () => { + const { executor, calls } = recordingExecutor(); + const fs = new MemoryFileSystem({ '/repo/.git/MERGE_HEAD': 'abc123\n' }); + const [Continue] = createGitContinueAbortTools({ executor, fs }); + + await call(Continue, { cwd: '/repo' }); + + const expected = ['merge', '--continue']; + const actual = calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('runs rebase --continue when a rebase is in progress', async () => { + const { executor, calls } = recordingExecutor(); + const fs = new MemoryFileSystem({ '/repo/.git/rebase-merge': '' }); + const [Continue] = createGitContinueAbortTools({ executor, fs }); + + await call(Continue, { cwd: '/repo' }); + + const expected = ['rebase', '--continue']; + const actual = calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('runs cherry-pick --continue when a cherry-pick is in progress', async () => { + const { executor, calls } = recordingExecutor(); + const fs = new MemoryFileSystem({ '/repo/.git/CHERRY_PICK_HEAD': 'abc123\n' }); + const [Continue] = createGitContinueAbortTools({ executor, fs }); + + await call(Continue, { cwd: '/repo' }); + + const expected = ['cherry-pick', '--continue']; + const actual = calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('runs revert --continue when a revert is in progress', async () => { + const { executor, calls } = recordingExecutor(); + const fs = new MemoryFileSystem({ '/repo/.git/REVERT_HEAD': 'abc123\n' }); + const [Continue] = createGitContinueAbortTools({ executor, fs }); + + await call(Continue, { cwd: '/repo' }); + + const expected = ['revert', '--continue']; + const actual = calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('refuses when nothing is in progress', async () => { + const { executor } = recordingExecutor(); + const fs = new MemoryFileSystem({ '/repo/.git/config': '[core]\n' }); + const [Continue] = createGitContinueAbortTools({ executor, fs }); + + const actual = call(Continue, { cwd: '/repo' }); + await expect(actual).rejects.toThrow(/No merge, rebase, cherry-pick, or revert/); + }); +}); + +describe('Git_Abort', () => { + it('runs merge --abort when a merge is in progress', async () => { + const { executor, calls } = recordingExecutor(); + const fs = new MemoryFileSystem({ '/repo/.git/MERGE_HEAD': 'abc123\n' }); + const [, Abort] = createGitContinueAbortTools({ executor, fs }); + + await call(Abort, { cwd: '/repo' }); + + const expected = ['merge', '--abort']; + const actual = calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('runs rebase --abort when a rebase is in progress', async () => { + const { executor, calls } = recordingExecutor(); + const fs = new MemoryFileSystem({ '/repo/.git/rebase-apply': '' }); + const [, Abort] = createGitContinueAbortTools({ executor, fs }); + + await call(Abort, { cwd: '/repo' }); + + const expected = ['rebase', '--abort']; + const actual = calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('runs cherry-pick --abort when a cherry-pick is in progress', async () => { + const { executor, calls } = recordingExecutor(); + const fs = new MemoryFileSystem({ '/repo/.git/CHERRY_PICK_HEAD': 'abc123\n' }); + const [, Abort] = createGitContinueAbortTools({ executor, fs }); + + await call(Abort, { cwd: '/repo' }); + + const expected = ['cherry-pick', '--abort']; + const actual = calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('runs revert --abort when a revert is in progress', async () => { + const { executor, calls } = recordingExecutor(); + const fs = new MemoryFileSystem({ '/repo/.git/REVERT_HEAD': 'abc123\n' }); + const [, Abort] = createGitContinueAbortTools({ executor, fs }); + + await call(Abort, { cwd: '/repo' }); + + const expected = ['revert', '--abort']; + const actual = calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('refuses when nothing is in progress', async () => { + const { executor } = recordingExecutor(); + const fs = new MemoryFileSystem({ '/repo/.git/config': '[core]\n' }); + const [, Abort] = createGitContinueAbortTools({ executor, fs }); + + const actual = call(Abort, { cwd: '/repo' }); + await expect(actual).rejects.toThrow(/No merge, rebase, cherry-pick, or revert/); + }); + + it('runs abort against a linked worktree, resolving MERGE_HEAD through the gitdir pointer', async () => { + const { executor, calls } = recordingExecutor(); + const worktreeGitDir = '/main-repo/.git/worktrees/wt'; + const fs = new MemoryFileSystem({ + [`${worktreeGitDir}/MERGE_HEAD`]: 'abc123\n', + '/worktree/.git': `gitdir: ${worktreeGitDir}\n`, + }); + const [, Abort] = createGitContinueAbortTools({ executor, fs }); + + await call(Abort, { cwd: '/worktree' }); + + const expected = ['merge', '--abort']; + const actual = calls[0]?.args; + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Git/detectInProgress.spec.ts b/packages/claude-sdk-tools/test/Git/detectInProgress.spec.ts new file mode 100644 index 00000000..f13b7bf9 --- /dev/null +++ b/packages/claude-sdk-tools/test/Git/detectInProgress.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { detectInProgress } from '../../src/Git/detectInProgress'; +import { MemoryFileSystem } from '../MemoryFileSystem'; + +describe('detectInProgress', () => { + it('detects a merge in progress in an ordinary repo', async () => { + const fs = new MemoryFileSystem({ '/repo/.git/MERGE_HEAD': 'abc123\n' }); + + const actual = await detectInProgress(fs, '/repo'); + + expect(actual).toBe('merge'); + }); + + it('detects a merge in progress inside a linked worktree, where .git is a file pointing elsewhere', async () => { + // Real worktree layout: the worktree's `.git` is a file with `gitdir: `, and the actual + // per-worktree state (including MERGE_HEAD) lives at that pointed-to path, not under + // `/.git/`. + const worktreeGitDir = '/main-repo/.git/worktrees/wt'; + const fs = new MemoryFileSystem({ + [`${worktreeGitDir}/MERGE_HEAD`]: 'abc123\n', + '/worktree/.git': `gitdir: ${worktreeGitDir}\n`, + }); + + const actual = await detectInProgress(fs, '/worktree'); + + expect(actual).toBe('merge'); + }); + + it('detects a cherry-pick in progress', async () => { + const fs = new MemoryFileSystem({ '/repo/.git/CHERRY_PICK_HEAD': 'abc123\n' }); + + const actual = await detectInProgress(fs, '/repo'); + + expect(actual).toBe('cherry-pick'); + }); + + it('detects a revert in progress', async () => { + const fs = new MemoryFileSystem({ '/repo/.git/REVERT_HEAD': 'abc123\n' }); + + const actual = await detectInProgress(fs, '/repo'); + + expect(actual).toBe('revert'); + }); + + it('returns null when nothing is in progress', async () => { + const fs = new MemoryFileSystem({ '/repo/.git/config': '[core]\n' }); + + const actual = await detectInProgress(fs, '/repo'); + + expect(actual).toBeNull(); + }); +}); diff --git a/packages/claude-sdk-tools/test/Git/protectedBranch.spec.ts b/packages/claude-sdk-tools/test/Git/protectedBranch.spec.ts new file mode 100644 index 00000000..bdbd8146 --- /dev/null +++ b/packages/claude-sdk-tools/test/Git/protectedBranch.spec.ts @@ -0,0 +1,112 @@ +import type { CommandSpec, IExecutor, SpawnOpts } from '@shellicar/exec-core'; +import { describe, expect, it } from 'vitest'; +import { assertNotDefaultBranch, resolveDefaultBranch } from '../../src/Git/protectedBranch'; + +function scriptedExecutor(responses: Record): IExecutor { + return { + run: async (cmd: CommandSpec, opts?: SpawnOpts) => { + const key = cmd.args?.join(' ') ?? ''; + const output = responses[key]; + if (output != null) { + opts?.stdout?.write(output); + return { exitCode: 0, signal: null }; + } + return { exitCode: 1, signal: null }; + }, + }; +} + +describe('resolveDefaultBranch', () => { + it('reads the default branch name from origin/HEAD', async () => { + const executor = scriptedExecutor({ 'symbolic-ref refs/remotes/origin/HEAD': 'refs/remotes/origin/main\n' }); + const deps = { executor, fs: {} as never }; + + const expected = 'main'; + const actual = await resolveDefaultBranch(deps, '/repo'); + expect(actual).toBe(expected); + }); + + it('returns null when there is no origin/HEAD pointer to read', async () => { + const executor = scriptedExecutor({}); + const deps = { executor, fs: {} as never }; + + const expected = null; + const actual = await resolveDefaultBranch(deps, '/repo'); + expect(actual).toBe(expected); + }); +}); + +describe('assertNotDefaultBranch', () => { + it('throws when the target branch is the default branch', async () => { + const executor = scriptedExecutor({ 'symbolic-ref refs/remotes/origin/HEAD': 'refs/remotes/origin/main\n' }); + const deps = { executor, fs: {} as never }; + + const actual = assertNotDefaultBranch(deps, '/repo', 'main', 'Git_ForcePushWithLease'); + await expect(actual).rejects.toThrow(/default branch/); + }); + + it('does not throw for a non-default branch', async () => { + const executor = scriptedExecutor({ 'symbolic-ref refs/remotes/origin/HEAD': 'refs/remotes/origin/main\n' }); + const deps = { executor, fs: {} as never }; + + const actual = assertNotDefaultBranch(deps, '/repo', 'feature/x', 'Git_ForcePushWithLease'); + await expect(actual).resolves.toBeUndefined(); + }); + + it('falls back to the currently checked-out branch when no target is given', async () => { + const executor = scriptedExecutor({ + 'symbolic-ref refs/remotes/origin/HEAD': 'refs/remotes/origin/main\n', + 'rev-parse --abbrev-ref HEAD': 'main\n', + }); + const deps = { executor, fs: {} as never }; + + const actual = assertNotDefaultBranch(deps, '/repo', null, 'Git_Rebase'); + await expect(actual).rejects.toThrow(/default branch/); + }); + + it('does not throw when the default branch cannot be resolved (fails open)', async () => { + const executor = scriptedExecutor({}); + const deps = { executor, fs: {} as never }; + + const actual = assertNotDefaultBranch(deps, '/repo', 'main', 'Git_ForcePushWithLease'); + await expect(actual).resolves.toBeUndefined(); + }); + + // A push `branch` field isn't necessarily a bare name — git push accepts a full refspec, + // `:`, as one argument. The guard compares the raw target string to the default branch + // name, so a refspec whose *destination* is the default branch sails past it: 'HEAD:main' !== 'main'. + // These spec the fix: the guard must resolve the actual destination out of a refspec (the part + // after ':', with a leading '+' stripped) before comparing, not compare the raw field. + + it('throws when the target is a refspec whose destination is the default branch', async () => { + const executor = scriptedExecutor({ 'symbolic-ref refs/remotes/origin/HEAD': 'refs/remotes/origin/main\n' }); + const deps = { executor, fs: {} as never }; + + const actual = assertNotDefaultBranch(deps, '/repo', 'HEAD:main', 'Git_ForcePushWithLease'); + await expect(actual).rejects.toThrow(/default branch/); + }); + + it('throws when the refspec destination has a leading + (force marker)', async () => { + const executor = scriptedExecutor({ 'symbolic-ref refs/remotes/origin/HEAD': 'refs/remotes/origin/main\n' }); + const deps = { executor, fs: {} as never }; + + const actual = assertNotDefaultBranch(deps, '/repo', '+HEAD:main', 'Git_ForcePushWithLease'); + await expect(actual).rejects.toThrow(/default branch/); + }); + + it('does not throw for a refspec whose destination is a non-default branch', async () => { + const executor = scriptedExecutor({ 'symbolic-ref refs/remotes/origin/HEAD': 'refs/remotes/origin/main\n' }); + const deps = { executor, fs: {} as never }; + + const actual = assertNotDefaultBranch(deps, '/repo', 'HEAD:feature/x', 'Git_ForcePushWithLease'); + await expect(actual).resolves.toBeUndefined(); + }); + + it('throws for a fully-qualified target naming the default branch (refs/heads/main)', async () => { + const executor = scriptedExecutor({ 'symbolic-ref refs/remotes/origin/HEAD': 'refs/remotes/origin/main\n' }); + const deps = { executor, fs: {} as never }; + + const actual = assertNotDefaultBranch(deps, '/repo', 'refs/heads/main', 'Git_ForcePushWithLease'); + await expect(actual).rejects.toThrow(/default branch/); + }); +}); diff --git a/packages/claude-sdk-tools/test/Git/redact.spec.ts b/packages/claude-sdk-tools/test/Git/redact.spec.ts new file mode 100644 index 00000000..c9e2a70e --- /dev/null +++ b/packages/claude-sdk-tools/test/Git/redact.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { redactConfigListOutput, redactConfigValue, redactUserinfo } from '../../src/Git/redact'; + +describe('redactUserinfo', () => { + it('masks embedded credentials in a URL, keeping the host and path visible', () => { + const expected = 'https://***@github.com/shellicar/claude-cli.git'; + const actual = redactUserinfo('https://x-access-token:ghp_abc123@github.com/shellicar/claude-cli.git'); + expect(actual).toBe(expected); + }); + + it('leaves a URL with no embedded credentials unchanged', () => { + const expected = 'https://github.com/shellicar/claude-cli.git'; + const actual = redactUserinfo('https://github.com/shellicar/claude-cli.git'); + expect(actual).toBe(expected); + }); + + it('redacts every occurrence across multiple lines, e.g. git remote -v (fetch) and (push)', () => { + const input = 'origin\thttps://token:secret@github.com/x/y.git (fetch)\norigin\thttps://token:secret@github.com/x/y.git (push)'; + const expected = 'origin\thttps://***@github.com/x/y.git (fetch)\norigin\thttps://***@github.com/x/y.git (push)'; + const actual = redactUserinfo(input); + expect(actual).toBe(expected); + }); +}); + +describe('redactConfigValue', () => { + it('redacts the whole value for a known credential-bearing key', () => { + const expected = '***REDACTED***'; + const actual = redactConfigValue('http.https://github.com/.extraheader', 'AUTHORIZATION: basic abc123'); + expect(actual).toBe(expected); + }); + + it('redacts credential.* keys', () => { + const expected = '***REDACTED***'; + const actual = redactConfigValue('credential.helper', 'store --file=/home/user/.git-credentials'); + expect(actual).toBe(expected); + }); + + it('only masks embedded userinfo for an ordinary key, leaving the rest visible', () => { + const expected = 'https://***@github.com/x/y.git'; + const actual = redactConfigValue('remote.origin.url', 'https://token@github.com/x/y.git'); + expect(actual).toBe(expected); + }); + + it('leaves a non-credential value with nothing to redact untouched', () => { + const expected = 'Stephen Hellicar'; + const actual = redactConfigValue('user.name', 'Stephen Hellicar'); + expect(actual).toBe(expected); + }); +}); + +describe('redactConfigListOutput', () => { + it('redacts a credential-bearing line while leaving ordinary lines untouched', () => { + const input = ['user.name=Stephen Hellicar', 'http.https://github.com/.extraheader=AUTHORIZATION: basic abc123', 'core.editor=vim'].join('\n'); + + const expected = ['user.name=Stephen Hellicar', 'http.https://github.com/.extraheader=***REDACTED***', 'core.editor=vim'].join('\n'); + const actual = redactConfigListOutput(input); + expect(actual).toBe(expected); + }); + + it('masks embedded userinfo in a remote.*.url line', () => { + const input = 'remote.origin.url=https://token@github.com/x/y.git'; + const expected = 'remote.origin.url=https://***@github.com/x/y.git'; + const actual = redactConfigListOutput(input); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Git/runGit.spec.ts b/packages/claude-sdk-tools/test/Git/runGit.spec.ts new file mode 100644 index 00000000..de4c265d --- /dev/null +++ b/packages/claude-sdk-tools/test/Git/runGit.spec.ts @@ -0,0 +1,54 @@ +import type { CommandSpec, IExecutor, SpawnOpts } from '@shellicar/exec-core'; +import { describe, expect, it } from 'vitest'; +import { runGitText } from '../../src/Git/runGit'; +import { MemoryFileSystem } from '../MemoryFileSystem'; + +function scriptedExecutor(exitCode: number, stdout: string, stderr: string): IExecutor { + return { + run: async (_cmd: CommandSpec, opts?: SpawnOpts) => { + opts?.stdout?.write(stdout); + opts?.stderr?.write(stderr); + return { exitCode, signal: null }; + }, + }; +} + +describe('runGitText', () => { + it('returns stdout alone when stderr is empty', async () => { + const deps = { executor: scriptedExecutor(0, 'On branch main\n', ''), fs: new MemoryFileSystem() }; + + const expected = 'On branch main'; + const actual = await runGitText(deps, ['status'], '/repo'); + expect(actual).toBe(expected); + }); + + it('merges stderr in on success, since git often writes real content there (e.g. switch)', async () => { + const deps = { executor: scriptedExecutor(0, '', "Switched to branch 'feature/x'\n"), fs: new MemoryFileSystem() }; + + const expected = "Switched to branch 'feature/x'"; + const actual = await runGitText(deps, ['switch', 'feature/x'], '/repo'); + expect(actual).toBe(expected); + }); + + it('merges both streams when both are present', async () => { + const deps = { executor: scriptedExecutor(0, 'stdout line\n', 'stderr line\n'), fs: new MemoryFileSystem() }; + + const expected = 'stdout line\nstderr line'; + const actual = await runGitText(deps, ['fetch'], '/repo'); + expect(actual).toBe(expected); + }); + + it('throws on a non-zero exit instead of returning it as data', async () => { + const deps = { executor: scriptedExecutor(1, '', 'fatal: not a git repository\n'), fs: new MemoryFileSystem() }; + + const actual = runGitText(deps, ['status'], '/repo'); + await expect(actual).rejects.toThrow('fatal: not a git repository'); + }); + + it('throws a fallback message when a failing command produced no output at all', async () => { + const deps = { executor: scriptedExecutor(1, '', ''), fs: new MemoryFileSystem() }; + + const actual = runGitText(deps, ['status'], '/repo'); + await expect(actual).rejects.toThrow(/exit code 1/); + }); +}); diff --git a/packages/claude-sdk-tools/test/Git/tools.spec.ts b/packages/claude-sdk-tools/test/Git/tools.spec.ts new file mode 100644 index 00000000..d6151b53 --- /dev/null +++ b/packages/claude-sdk-tools/test/Git/tools.spec.ts @@ -0,0 +1,596 @@ +import type { ToolDefinition } from '@shellicar/claude-sdk'; +import type { CommandSpec, IExecutor, SpawnOpts } from '@shellicar/exec-core'; +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import type { + GitAmendCommitInputSchema, + GitCherryPickInputSchema, + GitCloneInputSchema, + GitConfigInputSchema, + GitDeleteBranchForceInputSchema, + GitDescribeInputSchema, + GitFetchInputSchema, + GitForcePushWithLeaseInputSchema, + GitGrepInputSchema, + GitInitInputSchema, + GitLsFilesInputSchema, + GitMergeBaseInputSchema, + GitMergeInputSchema, + GitMoveInputSchema, + GitPushInputSchema, + GitRebaseInputSchema, + GitRebaseOntoInputSchema, + GitReflogInputSchema, + GitRevertInputSchema, + GitStashApplyInputSchema, + GitSubmoduleAddInputSchema, + GitSubmoduleDeinitInputSchema, + GitSubmoduleStatusInputSchema, + GitSubmoduleUpdateInputSchema, + GitWorktreeAddInputSchema, + GitWorktreePruneInputSchema, + GitWorktreeRemoveInputSchema, +} from '../../src/Git/schema'; +import { createGitTools } from '../../src/Git/tools'; +import { call } from '../helpers'; +import { MemoryFileSystem } from '../MemoryFileSystem'; + +/** Records the argv git was actually invoked with, so a test can assert on exactly what reaches + * the child process — the thing git itself parses for flags vs values. */ +function recordingExecutor(): { executor: IExecutor; calls: CommandSpec[] } { + const calls: CommandSpec[] = []; + const executor: IExecutor = { + run: async (cmd: CommandSpec, _opts?: SpawnOpts) => { + calls.push(cmd); + return { exitCode: 0, signal: null }; + }, + }; + return { executor, calls }; +} + +function deps() { + return { ...recordingExecutor(), fs: new MemoryFileSystem() }; +} + +function findTool(tools: ReturnType, name: string): ToolDefinition { + const tool = tools.find((t) => t.name === name); + if (!tool) { + throw new Error(`tool not found: ${name}`); + } + return tool as unknown as ToolDefinition; +} + +describe('createGitTools rejects option-shaped user input (proves git argument injection)', () => { + // Each of these feeds a value that git would parse as a flag, not a ref/remote name, into a + // field the tool passes straight to git argv. The schema layer refuses before buildArgs ever runs. + + it('refuses an option-shaped remote on Git_Fetch instead of handing git --upload-pack=', async () => { + const tools = createGitTools(deps(), { enableUnrecoverable: false }); + const Git_Fetch = findTool(tools, 'Git_Fetch'); + + const actual = call(Git_Fetch, { remote: '--upload-pack=touch /tmp/pwned' }); + await expect(actual).rejects.toThrow(); + }); + + it('refuses an option-shaped remote on Git_Push instead of handing git --receive-pack=', async () => { + const tools = createGitTools(deps(), { enableUnrecoverable: false }); + const Git_Push = findTool(tools, 'Git_Push'); + + const actual = call(Git_Push, { remote: '--receive-pack=touch /tmp/pwned' }); + await expect(actual).rejects.toThrow(); + }); + + it('refuses an option-shaped base on Git_Rebase instead of handing git --exec=', async () => { + const tools = createGitTools(deps(), { enableUnrecoverable: false }); + const Git_Rebase = findTool(tools, 'Git_Rebase'); + + const actual = call(Git_Rebase, { intent: 'test', base: '--exec=touch /tmp/pwned' }); + await expect(actual).rejects.toThrow(); + }); + + it('refuses an option-shaped oldBase on Git_RebaseOnto instead of handing git --exec=', async () => { + const tools = createGitTools(deps(), { enableUnrecoverable: false }); + const Git_RebaseOnto = findTool(tools, 'Git_RebaseOnto'); + + const actual = call(Git_RebaseOnto, { intent: 'test', oldBase: '--exec=touch /tmp/pwned', newBase: 'origin/main', branch: 'feature/x' }); + await expect(actual).rejects.toThrow(); + }); +}); + +describe('Git_StashApply refuses on a dirty working tree (no --abort exists to undo it)', () => { + // git status --porcelain is the first call the handler makes; a scripted executor writes to the + // status call's own stdout stream (as a real git status --porcelain would) so the handler's dirty/ + // clean check can be driven without a real repo. + function scriptedExecutor(statusOutput: string): { executor: IExecutor; calls: CommandSpec[] } { + const calls: CommandSpec[] = []; + const executor: IExecutor = { + run: async (cmd: CommandSpec, opts?: SpawnOpts) => { + calls.push(cmd); + if (cmd.args?.includes('--porcelain')) { + opts?.stdout?.write(statusOutput); + } + return { exitCode: 0, signal: null }; + }, + }; + return { executor, calls }; + } + + it('refuses when the working tree has uncommitted changes', async () => { + const { executor, calls } = scriptedExecutor(' M src/index.ts\n'); + const d = { executor, fs: new MemoryFileSystem() }; + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_StashApply = findTool(tools, 'Git_StashApply'); + + const actual = call(Git_StashApply, { intent: 'test' }); + await expect(actual).rejects.toThrow(/clean/); + + const expected = 1; // only the status check ran — stash apply itself never got invoked + expect(calls).toHaveLength(expected); + }); + + it('proceeds to stash apply when the working tree is clean', async () => { + const { executor, calls } = scriptedExecutor(''); + const d = { executor, fs: new MemoryFileSystem() }; + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_StashApply = findTool(tools, 'Git_StashApply'); + + await call(Git_StashApply, { intent: 'test' }); + + const expected = ['stash', 'apply']; + const actual = calls[1]?.args; + expect(actual).toEqual(expected); + }); +}); + +describe('protectDefaultBranch refuses reflog-tier tools that target the default branch', () => { + // origin/HEAD resolves to 'main' in every case here; rev-parse --abbrev-ref HEAD backs the tools + // that fall back to the checked-out branch (Git_AmendCommit, Git_Rebase) when no target field + // is given. + function defaultBranchExecutor(currentBranch: string): { executor: IExecutor; calls: CommandSpec[] } { + const calls: CommandSpec[] = []; + const executor: IExecutor = { + run: async (cmd: CommandSpec, opts?: SpawnOpts) => { + calls.push(cmd); + if (cmd.args?.join(' ') === 'symbolic-ref refs/remotes/origin/HEAD') { + opts?.stdout?.write('refs/remotes/origin/main\n'); + } else if (cmd.args?.join(' ') === 'rev-parse --abbrev-ref HEAD') { + opts?.stdout?.write(`${currentBranch}\n`); + } + return { exitCode: 0, signal: null }; + }, + }; + return { executor, calls }; + } + + it('refuses Git_ForcePushWithLease targeting main', async () => { + const { executor } = defaultBranchExecutor('main'); + const tools = createGitTools({ executor, fs: new MemoryFileSystem() }, { enableUnrecoverable: false }); + const Git_ForcePushWithLease = findTool(tools, 'Git_ForcePushWithLease'); + + const actual = call(Git_ForcePushWithLease, { intent: 'test', branch: 'main' }); + await expect(actual).rejects.toThrow(/default branch/); + }); + + it('refuses Git_DeleteBranchForce targeting main', async () => { + const { executor } = defaultBranchExecutor('main'); + const tools = createGitTools({ executor, fs: new MemoryFileSystem() }, { enableUnrecoverable: false }); + const Git_DeleteBranchForce = findTool(tools, 'Git_DeleteBranchForce'); + + const actual = call(Git_DeleteBranchForce, { intent: 'test', name: 'main' }); + await expect(actual).rejects.toThrow(/default branch/); + }); + + it('refuses Git_Rebase when main is the checked-out branch', async () => { + const { executor } = defaultBranchExecutor('main'); + const tools = createGitTools({ executor, fs: new MemoryFileSystem() }, { enableUnrecoverable: false }); + const Git_Rebase = findTool(tools, 'Git_Rebase'); + + const actual = call(Git_Rebase, { intent: 'test', base: 'origin/main' }); + await expect(actual).rejects.toThrow(/default branch/); + }); + + it('refuses Git_AmendCommit when main is the checked-out branch', async () => { + const { executor } = defaultBranchExecutor('main'); + const tools = createGitTools({ executor, fs: new MemoryFileSystem() }, { enableUnrecoverable: false }); + const Git_AmendCommit = findTool(tools, 'Git_AmendCommit'); + + const actual = call(Git_AmendCommit, { intent: 'test' }); + await expect(actual).rejects.toThrow(/default branch/); + }); + + it('allows Git_Rebase on a feature branch', async () => { + const { executor } = defaultBranchExecutor('feature/x'); + const tools = createGitTools({ executor, fs: new MemoryFileSystem() }, { enableUnrecoverable: false }); + const Git_Rebase = findTool(tools, 'Git_Rebase'); + + const actual = call(Git_Rebase, { intent: 'test', base: 'origin/main' }); + await expect(actual).resolves.toBeDefined(); + }); + + it('allows a normally-refused call when protectDefaultBranch is disabled', async () => { + const { executor } = defaultBranchExecutor('main'); + const tools = createGitTools({ executor, fs: new MemoryFileSystem() }, { enableUnrecoverable: false, protectDefaultBranch: false }); + const Git_DeleteBranchForce = findTool(tools, 'Git_DeleteBranchForce'); + + const actual = call(Git_DeleteBranchForce, { intent: 'test', name: 'main' }); + await expect(actual).resolves.toBeDefined(); + }); +}); + +describe('the new read-only ancestry/config tools build the argv the SC asked for', () => { + it('Git_Reflog defaults to reflog show, applying -n and the ref when given', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_Reflog = findTool(tools, 'Git_Reflog'); + + await call(Git_Reflog, { intent: 'test', maxCount: 5, ref: 'feature/x' }); + + const expected = ['reflog', 'show', '-n', '5', '--end-of-options', 'feature/x']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_MergeBase passes both refs after a single --end-of-options', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_MergeBase = findTool(tools, 'Git_MergeBase'); + + await call(Git_MergeBase, { intent: 'test', refA: 'HEAD', refB: 'origin/main' }); + + const expected = ['merge-base', '--end-of-options', 'HEAD', 'origin/main']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_Describe applies --tags and the ref when given', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_Describe = findTool(tools, 'Git_Describe'); + + await call(Git_Describe, { intent: 'test', tags: true, ref: 'HEAD' }); + + const expected = ['describe', '--tags', '--end-of-options', 'HEAD']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_Config lists everything when no key is given', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_Config = findTool(tools, 'Git_Config'); + + await call(Git_Config, { intent: 'test' }); + + const expected = ['config', '--list']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_Config reads a single key when given', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_Config = findTool(tools, 'Git_Config'); + + await call(Git_Config, { intent: 'test', key: 'user.email' }); + + const expected = ['config', '--get', '--end-of-options', 'user.email']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_LsFiles scopes to a path behind -- when given', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_LsFiles = findTool(tools, 'Git_LsFiles'); + + await call(Git_LsFiles, { intent: 'test', path: 'src' }); + + const expected = ['ls-files', '--', 'src']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('refuses an option-shaped key on Git_Config instead of handing git an arbitrary flag', async () => { + const tools = createGitTools(deps(), { enableUnrecoverable: false }); + const Git_Config = findTool(tools, 'Git_Config'); + + const actual = call(Git_Config, { intent: 'test', key: '--file=/etc/passwd' }); + await expect(actual).rejects.toThrow(); + }); +}); + +describe('the new worktree tools build the argv the SC asked for (no force on add/remove)', () => { + it('Git_WorktreeAdd checks out an existing branch when no newBranch is given', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_WorktreeAdd = findTool(tools, 'Git_WorktreeAdd'); + + await call(Git_WorktreeAdd, { path: '../repo-feature-x', branch: 'feature/x' }); + + const expected = ['worktree', 'add', '--end-of-options', '../repo-feature-x', 'feature/x']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_WorktreeAdd creates a new branch with -b when newBranch is given', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_WorktreeAdd = findTool(tools, 'Git_WorktreeAdd'); + + await call(Git_WorktreeAdd, { path: '../repo-feature-x', newBranch: 'feature/x' }); + + const expected = ['worktree', 'add', '-b', 'feature/x', '--end-of-options', '../repo-feature-x']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_WorktreePrune applies --dry-run when requested', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_WorktreePrune = findTool(tools, 'Git_WorktreePrune'); + + await call(Git_WorktreePrune, { dryRun: true }); + + const expected = ['worktree', 'prune', '--dry-run']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_WorktreePrune runs plainly when dryRun is omitted', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_WorktreePrune = findTool(tools, 'Git_WorktreePrune'); + + await call(Git_WorktreePrune, {}); + + const expected = ['worktree', 'prune']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_WorktreeRemove has no force flag to expose', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_WorktreeRemove = findTool(tools, 'Git_WorktreeRemove'); + + await call(Git_WorktreeRemove, { path: '../repo-feature-x' }); + + const expected = ['worktree', 'remove', '--end-of-options', '../repo-feature-x']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('refuses an option-shaped path on Git_WorktreeAdd instead of handing git an arbitrary flag', async () => { + const tools = createGitTools(deps(), { enableUnrecoverable: false }); + const Git_WorktreeAdd = findTool(tools, 'Git_WorktreeAdd'); + + const actual = call(Git_WorktreeAdd, { path: '--upload-pack=touch /tmp/pwned' }); + await expect(actual).rejects.toThrow(); + }); + + it('refuses an option-shaped path on Git_WorktreeRemove instead of handing git an arbitrary flag', async () => { + const tools = createGitTools(deps(), { enableUnrecoverable: false }); + const Git_WorktreeRemove = findTool(tools, 'Git_WorktreeRemove'); + + const actual = call(Git_WorktreeRemove, { path: '--force' }); + await expect(actual).rejects.toThrow(); + }); +}); + +describe('the new merge/cherry-pick/revert/clone/grep/init/mv/submodule tools build the argv the SC asked for', () => { + it('Git_Merge merges the given branch', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_Merge = findTool(tools, 'Git_Merge'); + + await call(Git_Merge, { branch: 'origin/main' }); + + const expected = ['merge', '--end-of-options', 'origin/main']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_CherryPick applies the given commit', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_CherryPick = findTool(tools, 'Git_CherryPick'); + + await call(Git_CherryPick, { commit: 'abc1234' }); + + const expected = ['cherry-pick', '--end-of-options', 'abc1234']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_Revert reverts the given commit without opening an editor', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_Revert = findTool(tools, 'Git_Revert'); + + await call(Git_Revert, { commit: 'abc1234' }); + + const expected = ['revert', '--no-edit', '--end-of-options', 'abc1234']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_Clone passes the target path when given', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_Clone = findTool(tools, 'Git_Clone'); + + await call(Git_Clone, { url: 'https://github.com/shellicar/claude-cli.git', path: 'my-clone' }); + + const expected = ['clone', '--end-of-options', 'https://github.com/shellicar/claude-cli.git', 'my-clone']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_Grep always marks the pattern with -e, so a leading dash can never be read as a flag', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_Grep = findTool(tools, 'Git_Grep'); + + await call(Git_Grep, { intent: 'test', pattern: '--recurse-submodules', ref: 'HEAD~5' }); + + const expected = ['grep', '-e', '--recurse-submodules', '--end-of-options', 'HEAD~5']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_Init passes the target path when given', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_Init = findTool(tools, 'Git_Init'); + + await call(Git_Init, { path: 'new-project' }); + + const expected = ['init', '--end-of-options', 'new-project']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_Move passes source and dest behind --', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_Move = findTool(tools, 'Git_Move'); + + await call(Git_Move, { source: 'old.ts', dest: 'new.ts' }); + + const expected = ['mv', '--', 'old.ts', 'new.ts']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_SubmoduleAdd passes the target path when given', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_SubmoduleAdd = findTool(tools, 'Git_SubmoduleAdd'); + + await call(Git_SubmoduleAdd, { url: 'https://github.com/shellicar/some-lib.git', path: 'vendor/some-lib' }); + + const expected = ['submodule', 'add', '--end-of-options', 'https://github.com/shellicar/some-lib.git', 'vendor/some-lib']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_SubmoduleStatus scopes to a path when given', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_SubmoduleStatus = findTool(tools, 'Git_SubmoduleStatus'); + + await call(Git_SubmoduleStatus, { path: 'vendor/some-lib' }); + + const expected = ['submodule', 'status', '--', 'vendor/some-lib']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_SubmoduleUpdate applies --init and --recursive when requested, with no force flag to expose', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_SubmoduleUpdate = findTool(tools, 'Git_SubmoduleUpdate'); + + await call(Git_SubmoduleUpdate, { init: true, recursive: true }); + + const expected = ['submodule', 'update', '--init', '--recursive']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('Git_SubmoduleDeinit has no force flag to expose', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_SubmoduleDeinit = findTool(tools, 'Git_SubmoduleDeinit'); + + await call(Git_SubmoduleDeinit, { path: 'vendor/some-lib' }); + + const expected = ['submodule', 'deinit', '--end-of-options', 'vendor/some-lib']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); + + it('refuses an option-shaped branch on Git_Merge instead of handing git an arbitrary flag', async () => { + const tools = createGitTools(deps(), { enableUnrecoverable: false }); + const Git_Merge = findTool(tools, 'Git_Merge'); + + const actual = call(Git_Merge, { branch: '--upload-pack=touch /tmp/pwned' }); + await expect(actual).rejects.toThrow(); + }); + + it('refuses an option-shaped path on Git_SubmoduleDeinit instead of handing git an arbitrary flag', async () => { + const tools = createGitTools(deps(), { enableUnrecoverable: false }); + const Git_SubmoduleDeinit = findTool(tools, 'Git_SubmoduleDeinit'); + + const actual = call(Git_SubmoduleDeinit, { path: '--force' }); + await expect(actual).rejects.toThrow(); + }); +}); + +describe('Git_Config scrubs credential-bearing values before returning them', () => { + // git config --list routinely surfaces secrets nothing else in this tool ever sees: a remote URL + // with embedded userinfo, an http..extraheader carrying a bearer token, a credential helper + // line. Git_Config is Read tier (auto-approved by default), so these values would reach the model + // and conversation history with no confirmation step at all. These tests spec the fix: whatever + // Git_Config returns must have known-sensitive values redacted, not passed through verbatim. + function configExecutor(configOutput: string): { executor: IExecutor; calls: CommandSpec[] } { + const calls: CommandSpec[] = []; + const executor: IExecutor = { + run: async (cmd: CommandSpec, opts?: SpawnOpts) => { + calls.push(cmd); + opts?.stdout?.write(configOutput); + return { exitCode: 0, signal: null }; + }, + }; + return { executor, calls }; + } + + it('redacts a token embedded in a remote URL', async () => { + const { executor } = configExecutor('remote.origin.url=https://x-access-token:ghp_secrettoken1234567890@github.com/org/repo.git\n'); + const tools = createGitTools({ executor, fs: new MemoryFileSystem() }, { enableUnrecoverable: false }); + const Git_Config = findTool(tools, 'Git_Config'); + + const expected = 'remote.origin.url=https://***@github.com/org/repo.git'; + const actual = await call(Git_Config, { intent: 'test' }); + expect(actual).toBe(expected); + }); + + it('redacts an http.extraheader bearer token', async () => { + const { executor } = configExecutor('http.https://github.com/.extraheader=AUTHORIZATION: basic dGVzdHRva2VuMTIzNDU2\n'); + const tools = createGitTools({ executor, fs: new MemoryFileSystem() }, { enableUnrecoverable: false }); + const Git_Config = findTool(tools, 'Git_Config'); + + const expected = 'http.https://github.com/.extraheader=***REDACTED***'; + const actual = await call(Git_Config, { intent: 'test' }); + expect(actual).toBe(expected); + }); + + it('leaves ordinary, non-sensitive config values untouched', async () => { + const { executor } = configExecutor('user.email=dev@example.com\n'); + const tools = createGitTools({ executor, fs: new MemoryFileSystem() }, { enableUnrecoverable: false }); + const Git_Config = findTool(tools, 'Git_Config'); + + const expected = 'user.email=dev@example.com'; + const actual = await call(Git_Config, { intent: 'test' }); + expect(actual).toBe(expected); + }); +}); + +describe('createGitTools shields git argv with --end-of-options as a second layer', () => { + // Calls tool.handler directly, bypassing input_schema.parse (which the tests above prove already + // refuses this value) — so this proves the second, independent layer: even if the schema guard + // were ever removed or had a gap, the same injected flag can no longer act as an option, because + // it is preceded by --end-of-options in the argv actually handed to git. + + it('inserts --end-of-options immediately before an option-shaped remote on Git_Fetch', async () => { + const d = deps(); + const tools = createGitTools(d, { enableUnrecoverable: false }); + const Git_Fetch = findTool(tools, 'Git_Fetch'); + + await Git_Fetch.handler({ remote: '--upload-pack=touch /tmp/pwned' } as z.output); + + const expected = ['fetch', '--end-of-options', '--upload-pack=touch /tmp/pwned']; + const actual = d.calls[0]?.args; + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk-tools/test/Git/worktreeList.spec.ts b/packages/claude-sdk-tools/test/Git/worktreeList.spec.ts new file mode 100644 index 00000000..db86b1a4 --- /dev/null +++ b/packages/claude-sdk-tools/test/Git/worktreeList.spec.ts @@ -0,0 +1,75 @@ +import type { CommandSpec, IExecutor, SpawnOpts } from '@shellicar/exec-core'; +import { describe, expect, it } from 'vitest'; +import { createGitWorktreeListTool } from '../../src/Git/worktreeList'; +import { call } from '../helpers'; + +function scriptedExecutor(stdout: string): { executor: IExecutor; calls: CommandSpec[] } { + const calls: CommandSpec[] = []; + const executor: IExecutor = { + run: async (cmd: CommandSpec, opts?: SpawnOpts) => { + calls.push(cmd); + opts?.stdout?.write(stdout); + return { exitCode: 0, signal: null }; + }, + }; + return { executor, calls }; +} + +describe('Git_WorktreeList', () => { + it('parses the main worktree, a branch worktree, a detached one, a locked one, and a prunable one', async () => { + const stdout = [ + 'worktree /repo', + 'HEAD abc123', + 'branch refs/heads/main', + '', + 'worktree /repo-feature', + 'HEAD def456', + 'branch refs/heads/feature/x', + '', + 'worktree /repo-detached', + 'HEAD 789abc', + 'detached', + '', + 'worktree /repo-locked', + 'HEAD 111222', + 'branch refs/heads/locked-branch', + 'locked a reason for the lock', + '', + 'worktree /repo-prunable', + 'HEAD 333444', + 'detached', + 'prunable gitdir file points to non-existent location', + '', + ].join('\n'); + const { executor } = scriptedExecutor(stdout); + const tool = createGitWorktreeListTool({ executor, fs: {} as never }); + + const expected = [ + { path: '/repo', head: 'abc123', branch: 'main', locked: null, prunable: null }, + { path: '/repo-feature', head: 'def456', branch: 'feature/x', locked: null, prunable: null }, + { path: '/repo-detached', head: '789abc', branch: null, locked: null, prunable: null }, + { path: '/repo-locked', head: '111222', branch: 'locked-branch', locked: 'a reason for the lock', prunable: null }, + { path: '/repo-prunable', head: '333444', branch: null, locked: null, prunable: 'gitdir file points to non-existent location' }, + ]; + const actual = await call(tool, {}); + expect(actual).toEqual(expected); + }); + + it('represents a locked entry with no stated reason as an empty string, distinct from not locked at all', async () => { + const stdout = ['worktree /repo', 'HEAD abc123', 'branch refs/heads/main', 'locked', ''].join('\n'); + const { executor } = scriptedExecutor(stdout); + const tool = createGitWorktreeListTool({ executor, fs: {} as never }); + + const expected = ''; + const actual = (await call(tool, {}))[0]?.locked; + expect(actual).toBe(expected); + }); + + it('throws when git itself fails', async () => { + const executor: IExecutor = { run: async () => ({ exitCode: 128, signal: null }) }; + const tool = createGitWorktreeListTool({ executor, fs: {} as never }); + + const actual = call(tool, {}); + await expect(actual).rejects.toThrow(); + }); +}); diff --git a/packages/claude-sdk/src/index.ts b/packages/claude-sdk/src/index.ts index bbbff487..2b34eb96 100644 --- a/packages/claude-sdk/src/index.ts +++ b/packages/claude-sdk/src/index.ts @@ -53,13 +53,12 @@ import type { ToolDefinition, ToolHandler, ToolHandlerResult, - ToolOperation, ToolResultBlock, ToolResultBlockContent, TransformToolResult, WakeLockHandle, } from './public/types'; -import { AccountLimitListener, IRequestClockListener, IToolBlockNotifier, IToolsClockListener, StreamInterruptListener } from './public/types'; +import { AccountLimitListener, IRequestClockListener, IToolBlockNotifier, IToolsClockListener, StreamInterruptListener, ToolOperation } from './public/types'; export type { BetaMessage, BetaMessageParam } from '@anthropic-ai/sdk/resources/beta.js'; export type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta.mjs'; @@ -99,7 +98,6 @@ export type { ToolDefinition, ToolHandler, ToolHandlerResult, - ToolOperation, ToolResultBlock, ToolResultBlockContent, TransformToolResult, @@ -146,6 +144,7 @@ export { TOOL_INPUT_KEYED_BY, ToolBlockNotifier, ToolCancelledError, + ToolOperation, ToolRefusedError, ToolRegistry, TurnRunner, diff --git a/packages/claude-sdk/src/public/types.ts b/packages/claude-sdk/src/public/types.ts index b70e6664..759d15d5 100644 --- a/packages/claude-sdk/src/public/types.ts +++ b/packages/claude-sdk/src/public/types.ts @@ -5,11 +5,34 @@ import type { z } from 'zod'; import type { Sender } from '../private/Conversation'; import type { AnthropicBeta, CacheTtl } from './enums'; -// 'escalate' is distinct from 'write': a write's risk is scoped by the cwd-zone matrix (default/ -// 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'; +/** What a tool does to state, for permission-gating purposes — not a description of the tool itself. + * Each member documents its own recoverability/privilege story; read the member you're looking at, + * not a wall of text above the type. Values are the same strings used throughout (config files, + * the permission matrix's zone keys) — this only gives each one a name and a place to hang its doc. */ +export enum ToolOperation { + /** No state changes. Always safe; the cwd-zone matrix still governs auto-approve vs ask for paths + * outside the working directory, but nothing here is ever destructive. */ + Read = 'read', + /** Additive or reversible state changes — nothing existing is destroyed. Scoped by the cwd-zone + * matrix (default/outside), which a config can set to auto-approve (e.g. autoApproveEdits). */ + Write = 'write', + /** Destroys state with no recovery path anywhere — not through this tool, not through the + * underlying system. Part of the configurable zone matrix, same as Read/Write, but defaults + * cautious (Ask) because there is nothing to undo if it was wrong. */ + Delete = 'delete', + /** Replaces reachable state with new state that is still recoverable — just not through this tool + * itself: through the underlying system's own undo log (e.g. git's reflog, after a rebase or + * amend). Crosses no privilege boundary (same identity, same permissions as Write) and destroys + * nothing irrecoverable (unlike Delete), so it is its own column in the configurable zone matrix, + * not folded into either. The name is the recovery mechanism this tier relies on, not the verb + * the tool performs. */ + Reflog = 'reflog', + /** Crosses a privilege boundary — a different credential or identity than the one running this + * process (e.g. a holder token). No zone or config auto-approve should ever cover this: it + * always asks, unconditionally (see permissions.ts getPermission), and is NOT part of the + * configurable zone matrix at all — the matrix has no concept of a privilege boundary to gate. */ + Escalate = 'escalate', +} export type ToolHandlerResult = { textContent: TOutput; diff --git a/schema/sdk-config.schema.json b/schema/sdk-config.schema.json index bcb7a244..c5657ada 100644 --- a/schema/sdk-config.schema.json +++ b/schema/sdk-config.schema.json @@ -567,12 +567,14 @@ "default": { "read": "approve", "write": "approve", - "delete": "ask" + "delete": "ask", + "reflog": "ask" }, "outside": { "read": "approve", "write": "ask", - "delete": "deny" + "delete": "deny", + "reflog": "deny" } }, "description": "Tool approval permission matrix", @@ -582,7 +584,8 @@ "default": { "read": "approve", "write": "approve", - "delete": "ask" + "delete": "ask", + "reflog": "ask" }, "description": "Permissions for paths inside the working directory", "type": "object", @@ -616,6 +619,16 @@ "ask", "deny" ] + }, + "reflog": { + "default": "ask", + "description": "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", + "type": "string", + "enum": [ + "approve", + "ask", + "deny" + ] } } }, @@ -623,7 +636,8 @@ "default": { "read": "approve", "write": "ask", - "delete": "deny" + "delete": "deny", + "reflog": "deny" }, "description": "Permissions for paths outside the working directory", "type": "object", @@ -657,6 +671,16 @@ "ask", "deny" ] + }, + "reflog": { + "default": "deny", + "description": "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", + "type": "string", + "enum": [ + "approve", + "ask", + "deny" + ] } } }