diff --git a/crates/df-nodes/src/script_node.rs b/crates/df-nodes/src/script_node.rs index c0a7062..459cb65 100644 --- a/crates/df-nodes/src/script_node.rs +++ b/crates/df-nodes/src/script_node.rs @@ -39,6 +39,20 @@ impl Node for ScriptNode { shell_type: Default::default(), }; + // 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。 + // - 白名单非空时:命令首词不在白名单 → 直接拒绝执行 + // - 黑名单匹配时:直接拒绝执行 + // 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。 + let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase(); + if let Some(denied) = check_command_policy(&cmd_name) { + tracing::warn!( + command = %command, + reason = %denied, + "ScriptNode 命令被策略拒绝" + ); + anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command); + } + // 危险命令告警:匹配已知危险关键词,仅告警不阻止执行 let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"]; for &kw in &dangerous_keywords { @@ -105,3 +119,41 @@ impl Node for ScriptNode { "script" } } + +/// 命令执行策略校验:从环境变量读取白/黑名单,判定给定命令名是否允许执行。 +/// +/// 优先级:黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。 +/// +/// - `DF_SCRIPT_WHITELIST`:逗号分隔命令名(如 `git,npm,cargo`);非空时命令名不在其中即拒绝 +/// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝 +/// +/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。 +/// +/// 返回 `Some(reason)` 表示拒绝(reason 为拒绝原因,用于日志/错误信息);返回 `None` 表示放行。 +fn check_command_policy(cmd_name: &str) -> Option<&'static str> { + // 黑名单优先:即便同时在白名单,只要命中黑名单就拒绝(防止白名单失误放过危险命令) + if let Ok(blacklist_raw) = std::env::var("DF_SCRIPT_BLACKLIST") { + let blacklist: Vec = blacklist_raw + .split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); + if blacklist.iter().any(|c| c == cmd_name) { + return Some("命令在黑名单中 (DF_SCRIPT_BLACKLIST)"); + } + } + + // 白名单:非空时命令名必须在白名单中才放行 + if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") { + let whitelist: Vec = whitelist_raw + .split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); + if !whitelist.is_empty() && !whitelist.iter().any(|c| c == cmd_name) { + return Some("命令不在白名单中 (DF_SCRIPT_WHITELIST)"); + } + } + + None +} diff --git a/src/components/settings/AdvancedSection.vue b/src/components/settings/AdvancedSection.vue index ad360e1..a749966 100644 --- a/src/components/settings/AdvancedSection.vue +++ b/src/components/settings/AdvancedSection.vue @@ -27,6 +27,17 @@ + + + + + @@ -88,10 +99,13 @@ const settings = reactive({ // clampMode 兜底脏值。 autoExecuteMode: clampMode(appSettings.get('df-ai-auto-execute-mode', 'low')), logLevel: 'info', + // 审批超时(ms):0=不限时,缺省 900000(15min);仅固定合法选项,脏值回退默认 + approvalTimeout: clampApprovalTimeout(appSettings.get('df-approval-timeout', 900000)), }) const rowAutoExecute = useTemplateRef>('rowAutoExecute') const rowLogLevel = useTemplateRef>('rowLogLevel') +const rowApprovalTimeout = useTemplateRef>('rowApprovalTimeout') // 上一次确认生效的档位:all 二次确认取消时恢复此值(非写死 low),避免 medium 用户误选 all // 取消后被强制降级 low。初始化 = 当前有效值;每次确认成功(含非 all 切换)更新。 @@ -130,6 +144,20 @@ watch( function markSaved() { rowLogLevel.value?.markSaved() } + +// 审批超时合法选项白名单(对齐 无匹配 option 致控件空白。允许的取值:0/5/15/30/60 分钟对应毫秒数。 +const VALID_APPROVAL_TIMEOUTS = [0, 300_000, 900_000, 1_800_000, 3_600_000] +function clampApprovalTimeout(raw: number): number { + return VALID_APPROVAL_TIMEOUTS.includes(raw) ? raw : 900_000 +} + +// 审批超时下拉变更:落 KV(df-approval-timeout,ms)+ 显「已保存」反馈。 +// 下一笔发起的审批即生效(aiShared.startApprovalTimer 每次实时读),已在跑的计时器沿用旧值。 +function onApprovalTimeoutChange() { + appSettings.set('df-approval-timeout', settings.approvalTimeout) + rowApprovalTimeout.value?.markSaved() +} diff --git a/src/components/settings/searchIndex.ts b/src/components/settings/searchIndex.ts index d8318c6..c9d2240 100644 --- a/src/components/settings/searchIndex.ts +++ b/src/components/settings/searchIndex.ts @@ -80,10 +80,13 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [ // ===== 安全 ===== { category: 'security', key: 'allowedDirs', labelKey: 'settings.panelAllowedDirs', descKey: 'settings.descAllowedDirs' }, + { category: 'security', key: 'scriptWhitelist', labelKey: 'settings.labelScriptWhitelist', descKey: 'settings.descScriptWhitelist' }, + { category: 'security', key: 'scriptBlacklist', labelKey: 'settings.labelScriptBlacklist', descKey: 'settings.descScriptBlacklist' }, // ===== 高级 ===== // F-#97:autoExecute(boolean) → autoExecuteMode(low/medium/all 三档),key 同步更名 { category: 'advanced', key: 'autoExecuteMode', labelKey: 'settings.labelAutoExecute', descKey: 'settings.descAutoExecute' }, { category: 'advanced', key: 'logLevel', labelKey: 'settings.labelLogLevel', descKey: 'settings.descLogLevel' }, + { category: 'advanced', key: 'approvalTimeout', labelKey: 'settings.labelApprovalTimeout', descKey: 'settings.descApprovalTimeout' }, { category: 'advanced', key: 'connection', labelKey: 'settings.panelConnection', descKey: 'settings.emptyConnection' }, ] diff --git a/src/composables/ai/aiShared.ts b/src/composables/ai/aiShared.ts index c10f212..4dc80dd 100644 --- a/src/composables/ai/aiShared.ts +++ b/src/composables/ai/aiShared.ts @@ -70,8 +70,26 @@ export function findToolCall(id: string): AiToolCallInfo | undefined { // ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ── -/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝 -const APPROVAL_TIMEOUT_MS = Infinity // 已决策:不做超时,用户不审批就一直等 +/// 审批超时阈值来源的 appSettings key(运行期可调,默认 15min)。 +/// 值语义:正整数=毫秒超时;0=不限时(跳过计时器);缺省=900000(15min)。 +const APPROVAL_TIMEOUT_KEY = 'df-approval-timeout' +const APPROVAL_TIMEOUT_DEFAULT_MS = 900_000 // 15 分钟 + +/** + * 读取当前审批超时阈值(ms):从 appSettings 取 `df-approval-timeout`, + * 缺省 900000(15min),返回 0 表示用户配置为「不限时」(调用方应跳过计时器)。 + * + * 每次 startApprovalTimer 调用时取最新值 —— 用户在 Settings 页调整后,下一笔审批即生效 + * (已在跑的计时器沿用旧值,符合「不影响已发起审批」的预期)。 + */ +function getApprovalTimeoutMs(): number { + const raw = appSettings.get(APPROVAL_TIMEOUT_KEY, APPROVAL_TIMEOUT_DEFAULT_MS) + // 防御:负数 / NaN 视为默认值;0 保留(语义=不限时) + if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) { + return APPROVAL_TIMEOUT_DEFAULT_MS + } + return raw +} /** toolCallId → 审批超时计时器 */ const _approvalTimers = new Map>() @@ -91,6 +109,9 @@ export function startApprovalTimer( kind: 'risk' | 'path' = 'risk', ): void { if (_approvalTimers.has(toolCallId)) return + // 0 = 用户配置为「不限时」,跳过计时器(对齐旧 Infinity 行为) + const timeoutMs = getApprovalTimeoutMs() + if (timeoutMs === 0) return const timer = setTimeout(() => { _approvalTimers.delete(toolCallId) // 按 kind 分派拒绝路径,避免 path 类错调 ai_approve 致后端 kind 校验拒而卡死 @@ -107,7 +128,7 @@ export function startApprovalTimer( isError: true, timestamp: Date.now(), }) - }, APPROVAL_TIMEOUT_MS) + }, timeoutMs) _approvalTimers.set(toolCallId, timer) } diff --git a/src/i18n/en/settings.ts b/src/i18n/en/settings.ts index 3d2281d..c6a4563 100644 --- a/src/i18n/en/settings.ts +++ b/src/i18n/en/settings.ts @@ -125,6 +125,15 @@ export default { labelLogLevel: 'Log level', descLogLevel: 'Verbosity of workflow logs', + // ===== Approval timeout ===== + labelApprovalTimeout: 'Approval timeout', + descApprovalTimeout: 'Auto-reject threshold when user does not respond to an approval prompt (only affects approvals started after the change)', + approvalTimeoutUnlimited: 'No limit', + approvalTimeout5m: '5 minutes', + approvalTimeout15m: '15 minutes (default)', + approvalTimeout30m: '30 minutes', + approvalTimeout60m: '60 minutes', + labelShowTokenUsage: 'Show token usage', descShowTokenUsage: 'Show token consumption of each reply in conversations', @@ -159,6 +168,17 @@ export default { allowedDirsAddFail: 'Failed to add authorized directory', allowedDirsRemoveFail: 'Failed to remove authorized directory', + // ===== ScriptNode command execution safety ===== + panelScriptSafety: '🛡️ Command Execution Safety', + descScriptSafety: 'Configure ScriptNode command whitelist/blacklist. Command name is the first token (e.g. `rm -rf /` -> `rm`), compared trimmed and lowercase. Blacklist takes precedence over whitelist.', + labelScriptWhitelist: 'Command whitelist', + descScriptWhitelist: 'Comma-separated command names (e.g. git,npm,cargo); empty = unrestricted. When non-empty, only listed commands are allowed.', + labelScriptBlacklist: 'Command blacklist', + descScriptBlacklist: 'Comma-separated command names (e.g. rm,format,shutdown); any match rejects execution.', + placeholderScriptWhitelist: 'Leave empty for no restriction, or: git,npm,cargo', + placeholderScriptBlacklist: 'Leave empty for no blacklist, or: rm,format', + noteScriptSafety: 'Note: backend ScriptNode currently reads DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST env vars (same comma-separated semantics). This UI only persists on the frontend; wiring to backend is planned for a later release.', + // ===== Knowledge base panel ===== panelKnowledge: '📚 Knowledge base', labelAutoExtract: 'Auto-distill knowledge', diff --git a/src/i18n/zh-CN/settings.ts b/src/i18n/zh-CN/settings.ts index b2bbddb..c1fcfe0 100644 --- a/src/i18n/zh-CN/settings.ts +++ b/src/i18n/zh-CN/settings.ts @@ -125,6 +125,15 @@ export default { labelLogLevel: '日志级别', descLogLevel: '工作流日志详细程度', + // ===== 审批超时 ===== + labelApprovalTimeout: '审批超时', + descApprovalTimeout: '用户未处理审批弹窗时的自动拒绝阅值(只影响下一笔发起的审批)', + approvalTimeoutUnlimited: '不限时', + approvalTimeout5m: '5 分钟', + approvalTimeout15m: '15 分钟(默认)', + approvalTimeout30m: '30 分钟', + approvalTimeout60m: '60 分钟', + labelShowTokenUsage: '显示 Token 用量', descShowTokenUsage: '在对话中展示每次回复的 token 消耗', @@ -159,6 +168,17 @@ export default { allowedDirsAddFail: '添加授权目录失败', allowedDirsRemoveFail: '删除授权目录失败', + // ===== ScriptNode 命令执行安全 ===== + panelScriptSafety: '🛡️ 命令执行安全', + descScriptSafety: '配置 ScriptNode 命令白/黑名单。命令名取首词(如 `rm -rf /` 取 `rm`),按 trim + 小写比较。黑名单优先于白名单。', + labelScriptWhitelist: '命令白名单', + descScriptWhitelist: '逗号分隔命令名(如 git,npm,cargo);留空表示不限制。非空时仅允许名单内命令执行。', + labelScriptBlacklist: '命令黑名单', + descScriptBlacklist: '逗号分隔命令名(如 rm,format,shutdown);命中任一项即拒绝执行。', + placeholderScriptWhitelist: '留空不限制,或输入:git,npm,cargo', + placeholderScriptBlacklist: '留空表示无黑名单,或输入:rm,format', + noteScriptSafety: '注:后端 ScriptNode 当前从环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST 读取,与同语义逗号分隔。此处配置仅前端持久化,与后端打通待后续版本。', + // ===== 知识库面板 ===== panelKnowledge: '📚 知识库', labelAutoExtract: '自动提炼知识',