mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 15:26:58 +10:00
235 lines
38 KiB
TypeScript
235 lines
38 KiB
TypeScript
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
|
|
import { Box, Text, useTheme } from '../../../ink.js';
|
||
|
|
import { useKeybinding } from '../../../keybindings/useKeybinding.js';
|
||
|
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js';
|
||
|
|
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../../services/analytics/index.js';
|
||
|
|
import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js';
|
||
|
|
import { getDestructiveCommandWarning } from '../../../tools/PowerShellTool/destructiveCommandWarning.js';
|
||
|
|
import { PowerShellTool } from '../../../tools/PowerShellTool/PowerShellTool.js';
|
||
|
|
import { isAllowlistedCommand } from '../../../tools/PowerShellTool/readOnlyValidation.js';
|
||
|
|
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js';
|
||
|
|
import { getCompoundCommandPrefixesStatic } from '../../../utils/powershell/staticPrefix.js';
|
||
|
|
import { Select } from '../../CustomSelect/select.js';
|
||
|
|
import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js';
|
||
|
|
import { PermissionDecisionDebugInfo } from '../PermissionDecisionDebugInfo.js';
|
||
|
|
import { PermissionDialog } from '../PermissionDialog.js';
|
||
|
|
import { PermissionExplainerContent, usePermissionExplainerUI } from '../PermissionExplanation.js';
|
||
|
|
import type { PermissionRequestProps } from '../PermissionRequest.js';
|
||
|
|
import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js';
|
||
|
|
import { useShellPermissionFeedback } from '../useShellPermissionFeedback.js';
|
||
|
|
import { logUnaryPermissionEvent } from '../utils.js';
|
||
|
|
import { powershellToolUseOptions } from './powershellToolUseOptions.js';
|
||
|
|
export function PowerShellPermissionRequest(props: PermissionRequestProps): React.ReactNode {
|
||
|
|
const {
|
||
|
|
toolUseConfirm,
|
||
|
|
toolUseContext,
|
||
|
|
onDone,
|
||
|
|
onReject,
|
||
|
|
workerBadge
|
||
|
|
} = props;
|
||
|
|
const {
|
||
|
|
command,
|
||
|
|
description
|
||
|
|
} = PowerShellTool.inputSchema.parse(toolUseConfirm.input);
|
||
|
|
const [theme] = useTheme();
|
||
|
|
const explainerState = usePermissionExplainerUI({
|
||
|
|
toolName: toolUseConfirm.tool.name,
|
||
|
|
toolInput: toolUseConfirm.input,
|
||
|
|
toolDescription: toolUseConfirm.description,
|
||
|
|
messages: toolUseContext.messages
|
||
|
|
});
|
||
|
|
const {
|
||
|
|
yesInputMode,
|
||
|
|
noInputMode,
|
||
|
|
yesFeedbackModeEntered,
|
||
|
|
noFeedbackModeEntered,
|
||
|
|
acceptFeedback,
|
||
|
|
rejectFeedback,
|
||
|
|
setAcceptFeedback,
|
||
|
|
setRejectFeedback,
|
||
|
|
focusedOption,
|
||
|
|
handleInputModeToggle,
|
||
|
|
handleReject,
|
||
|
|
handleFocus
|
||
|
|
} = useShellPermissionFeedback({
|
||
|
|
toolUseConfirm,
|
||
|
|
onDone,
|
||
|
|
onReject,
|
||
|
|
explainerVisible: explainerState.visible
|
||
|
|
});
|
||
|
|
const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE('tengu_destructive_command_warning', false) ? getDestructiveCommandWarning(command) : null;
|
||
|
|
const [showPermissionDebug, setShowPermissionDebug] = useState(false);
|
||
|
|
|
||
|
|
// Editable prefix — compute static prefix locally (no LLM call).
|
||
|
|
// Initialize synchronously to the raw command for single-line commands so
|
||
|
|
// the editable input renders immediately, then refine to the extracted prefix
|
||
|
|
// once the AST parser resolves. Multiline commands (`# comment\n...`,
|
||
|
|
// foreach loops) get undefined → powershellToolUseOptions:64 hides the
|
||
|
|
// "don't ask again" option — those literals are one-time-use (settings
|
||
|
|
// corpus shows 14 multiline rules, zero match twice). For compound commands,
|
||
|
|
// computes a prefix per subcommand, excluding subcommands that are already
|
||
|
|
// auto-allowed (read-only).
|
||
|
|
const [editablePrefix, setEditablePrefix] = useState<string | undefined>(command.includes('\n') ? undefined : command);
|
||
|
|
const hasUserEditedPrefix = useRef(false);
|
||
|
|
useEffect(() => {
|
||
|
|
let cancelled = false;
|
||
|
|
// Filter receives ParsedCommandElement — isAllowlistedCommand works from
|
||
|
|
// element.name/nameType/args directly. isReadOnlyCommand(text) would need
|
||
|
|
// to reparse (pwsh.exe spawn per subcommand) and returns false without the
|
||
|
|
// full parsed AST, making the filter a no-op.
|
||
|
|
getCompoundCommandPrefixesStatic(command, element => isAllowlistedCommand(element, element.text)).then(prefixes => {
|
||
|
|
if (cancelled || hasUserEditedPrefix.current) return;
|
||
|
|
if (prefixes.length > 0) {
|
||
|
|
setEditablePrefix(`${prefixes[0]}:*`);
|
||
|
|
}
|
||
|
|
}).catch(() => {});
|
||
|
|
return () => {
|
||
|
|
cancelled = true;
|
||
|
|
};
|
||
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
|
|
}, [command]);
|
||
|
|
const onEditablePrefixChange = useCallback((value: string) => {
|
||
|
|
hasUserEditedPrefix.current = true;
|
||
|
|
setEditablePrefix(value);
|
||
|
|
}, []);
|
||
|
|
const unaryEvent = useMemo<UnaryEvent>(() => ({
|
||
|
|
completion_type: 'tool_use_single',
|
||
|
|
language_name: 'none'
|
||
|
|
}), []);
|
||
|
|
usePermissionRequestLogging(toolUseConfirm, unaryEvent);
|
||
|
|
const options = useMemo(() => powershellToolUseOptions({
|
||
|
|
suggestions: toolUseConfirm.permissionResult.behavior === 'ask' ? toolUseConfirm.permissionResult.suggestions : undefined,
|
||
|
|
onRejectFeedbackChange: setRejectFeedback,
|
||
|
|
onAcceptFeedbackChange: setAcceptFeedback,
|
||
|
|
yesInputMode,
|
||
|
|
noInputMode,
|
||
|
|
editablePrefix,
|
||
|
|
onEditablePrefixChange
|
||
|
|
}), [toolUseConfirm, yesInputMode, noInputMode, editablePrefix, onEditablePrefixChange]);
|
||
|
|
|
||
|
|
// Toggle permission debug info with keybinding
|
||
|
|
const handleToggleDebug = useCallback(() => {
|
||
|
|
setShowPermissionDebug(prev => !prev);
|
||
|
|
}, []);
|
||
|
|
useKeybinding('permission:toggleDebug', handleToggleDebug, {
|
||
|
|
context: 'Confirmation'
|
||
|
|
});
|
||
|
|
function onSelect(value: string) {
|
||
|
|
// Map options to numeric values for analytics (strings not allowed in logEvent)
|
||
|
|
const optionIndex: Record<string, number> = {
|
||
|
|
yes: 1,
|
||
|
|
'yes-apply-suggestions': 2,
|
||
|
|
'yes-prefix-edited': 2,
|
||
|
|
no: 3
|
||
|
|
};
|
||
|
|
logEvent('tengu_permission_request_option_selected', {
|
||
|
|
option_index: optionIndex[value],
|
||
|
|
explainer_visible: explainerState.visible
|
||
|
|
});
|
||
|
|
const toolNameForAnalytics = sanitizeToolNameForAnalytics(toolUseConfirm.tool.name) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS;
|
||
|
|
if (value === 'yes-prefix-edited') {
|
||
|
|
const trimmedPrefix = (editablePrefix ?? '').trim();
|
||
|
|
logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept');
|
||
|
|
if (!trimmedPrefix) {
|
||
|
|
toolUseConfirm.onAllow(toolUseConfirm.input, []);
|
||
|
|
} else {
|
||
|
|
const prefixUpdates: PermissionUpdate[] = [{
|
||
|
|
type: 'addRules',
|
||
|
|
rules: [{
|
||
|
|
toolName: PowerShellTool.name,
|
||
|
|
ruleContent: trimmedPrefix
|
||
|
|
}],
|
||
|
|
behavior: 'allow',
|
||
|
|
destination: 'localSettings'
|
||
|
|
}];
|
||
|
|
toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates);
|
||
|
|
}
|
||
|
|
onDone();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
switch (value) {
|
||
|
|
case 'yes':
|
||
|
|
{
|
||
|
|
const trimmedFeedback = acceptFeedback.trim();
|
||
|
|
logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept');
|
||
|
|
// Log accept submission with feedback context
|
||
|
|
logEvent('tengu_accept_submitted', {
|
||
|
|
toolName: toolNameForAnalytics,
|
||
|
|
isMcp: toolUseConfirm.tool.isMcp ?? false,
|
||
|
|
has_instructions: !!trimmedFeedback,
|
||
|
|
instructions_length: trimmedFeedback.length,
|
||
|
|
entered_feedback_mode: yesFeedbackModeEntered
|
||
|
|
});
|
||
|
|
toolUseConfirm.onAllow(toolUseConfirm.input, [], trimmedFeedback || undefined);
|
||
|
|
onDone();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'yes-apply-suggestions':
|
||
|
|
{
|
||
|
|
logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept');
|
||
|
|
// Extract suggestions if present (works for both 'ask' and 'passthrough' behaviors)
|
||
|
|
const permissionUpdates = 'suggestions' in toolUseConfirm.permissionResult ? toolUseConfirm.permissionResult.suggestions || [] : [];
|
||
|
|
toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates);
|
||
|
|
onDone();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'no':
|
||
|
|
{
|
||
|
|
const trimmedFeedback = rejectFeedback.trim();
|
||
|
|
|
||
|
|
// Log reject submission with feedback context
|
||
|
|
logEvent('tengu_reject_submitted', {
|
||
|
|
toolName: toolNameForAnalytics,
|
||
|
|
isMcp: toolUseConfirm.tool.isMcp ?? false,
|
||
|
|
has_instructions: !!trimmedFeedback,
|
||
|
|
instructions_length: trimmedFeedback.length,
|
||
|
|
entered_feedback_mode: noFeedbackModeEntered
|
||
|
|
});
|
||
|
|
|
||
|
|
// Process rejection (with or without feedback)
|
||
|
|
handleReject(trimmedFeedback || undefined);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return <PermissionDialog workerBadge={workerBadge} title="PowerShell command">
|
||
|
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
||
|
|
<Text dimColor={explainerState.visible}>
|
||
|
|
{PowerShellTool.renderToolUseMessage({
|
||
|
|
command,
|
||
|
|
description
|
||
|
|
}, {
|
||
|
|
theme,
|
||
|
|
verbose: true
|
||
|
|
} // always show the full command
|
||
|
|
)}
|
||
|
|
</Text>
|
||
|
|
{!explainerState.visible && <Text dimColor>{toolUseConfirm.description}</Text>}
|
||
|
|
<PermissionExplainerContent visible={explainerState.visible} promise={explainerState.promise} />
|
||
|
|
</Box>
|
||
|
|
{showPermissionDebug ? <>
|
||
|
|
<PermissionDecisionDebugInfo permissionResult={toolUseConfirm.permissionResult} toolName="PowerShell" />
|
||
|
|
{toolUseContext.options.debug && <Box justifyContent="flex-end" marginTop={1}>
|
||
|
|
<Text dimColor>Ctrl-D to hide debug info</Text>
|
||
|
|
</Box>}
|
||
|
|
</> : <>
|
||
|
|
<Box flexDirection="column">
|
||
|
|
<PermissionRuleExplanation permissionResult={toolUseConfirm.permissionResult} toolType="command" />
|
||
|
|
{destructiveWarning && <Box marginBottom={1}>
|
||
|
|
<Text color="warning">{destructiveWarning}</Text>
|
||
|
|
</Box>}
|
||
|
|
<Text>Do you want to proceed?</Text>
|
||
|
|
<Select options={options} inlineDescriptions onChange={onSelect} onCancel={() => handleReject()} onFocus={handleFocus} onInputModeToggle={handleInputModeToggle} />
|
||
|
|
</Box>
|
||
|
|
<Box justifyContent="space-between" marginTop={1}>
|
||
|
|
<Text dimColor>
|
||
|
|
Esc to cancel
|
||
|
|
{(focusedOption === 'yes' && !yesInputMode || focusedOption === 'no' && !noInputMode) && ' · Tab to amend'}
|
||
|
|
{explainerState.enabled && ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`}
|
||
|
|
</Text>
|
||
|
|
{toolUseContext.options.debug && <Text dimColor>Ctrl+d to show debug info</Text>}
|
||
|
|
</Box>
|
||
|
|
</>}
|
||
|
|
</PermissionDialog>;
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUNhbGxiYWNrIiwidXNlRWZmZWN0IiwidXNlTWVtbyIsInVzZVJlZiIsInVzZVN0YXRlIiwiQm94IiwiVGV4dCIsInVzZVRoZW1lIiwidXNlS2V5YmluZGluZyIsImdldEZlYXR1cmVWYWx1ZV9DQUNIRURfTUFZX0JFX1NUQUxFIiwiQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyIsImxvZ0V2ZW50Iiwic2FuaXRpemVUb29sTmFtZUZvckFuYWx5dGljcyIsImdldERlc3RydWN0aXZlQ29tbWFuZFdhcm5pbmciLCJQb3dlclNoZWxsVG9vbCIsImlzQWxsb3dsaXN0ZWRDb21tYW5kIiwiUGVybWlzc2lvblVwZGF0ZSIsImdldENvbXBvdW5kQ29tbWFuZFByZWZpeGVzU3RhdGljIiwiU2VsZWN0IiwiVW5hcnlFdmVudCIsInVzZVBlcm1pc3Npb25SZXF1ZXN0TG9nZ2luZyIsIlBlcm1pc3Npb25EZWNpc2lvbkRlYnVnSW5mbyIsIlBlcm1pc3Npb25EaWFsb2ciLCJQZXJtaXNzaW9uRXhwbGFpbmVyQ29udGVudCIsInVzZVBlcm1pc3Npb25FeHBsYWluZXJVSSIsIlBlcm1pc3Npb25SZXF1ZXN0UHJvcHMiLCJQZXJtaXNzaW9uUnVsZUV4cGxhbmF0aW9uIiwidXNlU2hlbGxQZXJtaXNzaW9uRmVlZGJhY2siLCJsb2dVbmFyeVBlcm1pc3Npb25FdmVudCIsInBvd2Vyc2hlbGxUb29sVXNlT3B0aW9ucyIsIlBvd2VyU2hlbGxQZXJtaXNzaW9uUmVxdWVzdCIsInByb3BzIiwiUmVhY3ROb2RlIiwidG9vbFVzZUNvbmZpcm0iLCJ0b29sVXNlQ29udGV4dCIsIm9uRG9uZSIsIm9uUmVqZWN0Iiwid29ya2VyQmFkZ2UiLCJjb21tYW5kIiwiZGVzY3JpcHRpb24iLCJpbnB1dFNjaGVtYSIsInBhcnNlIiwiaW5wdXQiLCJ0aGVtZSIsImV4cGxhaW5lclN0YXRlIiwidG9vbE5hbWUiLCJ0b29sIiwibmFtZSIsInRvb2xJbnB1dCIsInRvb2xEZXNjcmlwdGlvbiIsIm1lc3NhZ2VzIiwieWVzSW5wdXRNb2RlIiwibm9JbnB1dE1vZGUiLCJ5ZXNGZWVkYmFja01vZGVFbnRlcmVkIiwibm9GZWVkYmFja01vZGVFbnRlcmVkIiwiYWNjZXB0RmVlZGJhY2siLCJyZWplY3RGZWVkYmFjayIsInNldEFjY2VwdEZlZWRiYWNrIiwic2V0UmVqZWN0RmVlZGJhY2siLCJmb2N1c2VkT3B0aW9uIiwiaGFuZGxlSW5wdXRNb2RlVG9nZ2xlIiwiaGFuZGxlUmVqZWN0IiwiaGFuZGxlRm9jdXMiLCJleHBsYWluZXJWaXNpYmxlIiwidmlzaWJsZSIsImRlc3RydWN0aXZlV2FybmluZyIsInNob3dQZXJtaXNzaW9uRGVidWciLCJzZXRTaG93UGVybWlzc2lvbkRlYnVnIiwiZWRpdGFibGVQcmVmaXgiLCJzZXRFZGl0YWJsZVByZWZpeCIsImluY2x1ZGVzIiwidW5kZWZpbmVkIiwiaGFzVXNlckVkaXRlZFByZWZpeCIsImNhbmNlbGxlZCIsImVsZW1lbnQiLCJ0ZXh0IiwidGhlbiIsInByZWZpeGVzIiwiY3VycmVudCIsImxlbmd0aCIsImNhdGNoIiwib25FZGl0YWJsZVByZWZpeENoYW5nZSIsInZhbHVlIiwidW5hcnlFdmVudCIsImNvbXBsZXRpb25fdHlwZSIsImxhbmd1YWdlX25hbWUiLCJvcHRpb25zIiwic3VnZ2VzdGlvbnMiLCJwZXJtaXNzaW9uUmVzdWx0IiwiYmVoYXZpb3IiLCJvblJlamVjdEZlZWRiYWNrQ2hhbmdlIiwib25BY2NlcHRGZWVkYmFja0NoYW5nZSIsImhhbmRsZVRvZ2dsZURlYnVnIiwicHJldiIsImNvbnRleHQiLCJvblNlbGVjdCIsIm9wdGlvbkluZGV4IiwiUmVjb3JkIiwieWVzIiwibm8iLCJvcHRpb25faW5kZXgiLCJleHBsYWluZXJfdmlzaWJsZSIsInRvb2xOYW1lRm9yQW5hbHl0aWNzIiwidHJpbW1lZFByZWZpeCIsInRyaW0iLCJvbkFsbG93IiwicHJlZml4VXBkYXRlcyIsInR5cGUiLCJydWxlcyIsInJ1bGVDb250ZW50IiwiZGVzdGluYXRpb24iLCJ0cmltbWVkRmVlZGJhY2siLCJpc01jcCIsImhhc19pbnN0cnVjdGlvbnMiLCJpbnN0cnVjdGlvbnNfbGVuZ3RoIiwiZW50ZXJlZF9mZWVkYmFja19tb2RlIiwicGVybWlzc2lvblVwZGF0ZXMiLCJyZW5kZXJUb29sVXNlTWVzc2FnZSIsInZlcmJvc2UiLCJwcm9taXNlIiwiZGVidWciLCJlbmFibGVkIl0sInNvdXJjZXMiOlsiUG93ZXJTaGVsbFBlcm1pc3Npb25SZXF1ZXN0LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QsIHsgdXNlQ2FsbGJhY2ssIHVzZUVmZmVjdCwgdXNlTWVtbywgdXNlUmVmLCB1c2VTdGF0ZSB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgQm94LCBUZXh0LCB1c2VUaGVtZSB9IGZyb20gJy4uLy4uLy4uL2luay5qcydcbmltcG9ydCB7IHVzZUtleWJpbmRpbmcgfSBmcm9tICcuLi8uLi8uLi9rZXliaW5kaW5ncy91c2VLZXliaW5kaW5nLmpzJ1xuaW1wb3J0IHsgZ2V0RmVhdHVyZVZhbHVlX0NBQ0hFRF9NQVlfQkVfU1RBTEUgfSBmcm9tICcuLi8uLi8uLi9zZXJ2aWNlcy9hbmFseXRpY3MvZ3Jvd3RoYm9vay5qcydcbmltcG9ydCB7XG4gIHR5cGUgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgbG9nRXZlbnQsXG59IGZyb20gJy4uLy4uLy4uL3NlcnZpY2VzL2FuYWx5dGljcy9pbmRleC5qcydcbmltcG9ydCB7IHNhbml0aXplVG9vbE5hbWVGb3JBbmFseXRpY3MgfSBmcm9tICcuLi8uLi8uLi9zZXJ2aWNlcy9hbmFseXRpY3MvbWV0YWRhdGEuanMnXG5pbXBvcnQgeyBnZXREZXN0cnVjdGl2ZUNvbW1hbmRXYXJuaW5nIH0gZnJvbSAnLi4vLi4vLi4vdG9vbHMvUG93ZXJTaGVsbFRvb2wvZGVzdHJ1Y3RpdmVDb21tYW5kV2FybmluZy5qcydcbmltcG9ydCB7IFBvd2VyU2hlbGxUb29sIH0gZnJvbSAnLi4vLi4vLi4vdG9vbHMvUG93ZXJTaGVsbFRvb2wvUG93ZXJTaGVsbFRvb2wuanMnXG5pbXBvcnQgeyBpc0FsbG93bGlzdGVkQ29tbWFuZCB9IGZyb20gJy4uLy4uLy4uL3Rvb2xzL1Bvd2VyU2hlbGxUb29sL3JlYWRPbmx5VmFsaWRhdGlvbi5qcydcbmltcG9ydCB0eXBlIHsgUGVybWlzc2lvblVwZGF0ZSB9IGZyb20gJy4uLy4uLy4uL3V0aWxzL3Blcm1pc3Npb25zL1Blcm1pc3Npb25VcGRhdGVTY2hlbWEuanMnXG5pbXBvcnQgeyBnZXRDb21wb3VuZENvbW1hbmRQcmVmaXhlc1N0YXRpYyB
|