权限管理: - PermissionTypes: 权限行为/模式/规则/决策/选择类型系统 - PermissionSettings: 用户级/项目级/会话级权限持久化(settings.json) - PermissionRuleEngine: 多层规则引擎(deny→allow→readOnly→mode→dangerous→ASK) - DangerousPatterns: 危险shell命令检测(rm -rf, eval, exec等) - ReplSession: Y/A/N/D四选项权限确认UI - ConfigCommand: permission-mode/list/reset子命令 - AgentLoop: 集成规则引擎,PermissionChoice回调 上下文压缩: - TokenTracker: 上下文窗口监控(93%自动压缩/82%警告/98%阻塞) - MicroCompact: 微压缩,裁剪旧tool_result(无API调用) - SessionMemoryCompact: Session Memory压缩,AI摘要旧消息保留近期段 - FullCompact: 全量压缩+PTL重试(按API Round逐步丢弃) - AutoCompactManager: 压缩编排器(micro→session→full),含熔断机制 - CompactCommand: 改造为委托FullCompact - StatusLine: token使用百分比+颜色告警(绿→黄→红→闪烁) - AppConfig: 注册PermissionSettings/RuleEngine/AutoCompactManager Bean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>pull/1/head
parent
c989d3b451
commit
730551cb3f
@ -0,0 +1,178 @@ |
||||
package com.claudecode.core.compact; |
||||
|
||||
import com.claudecode.core.TokenTracker; |
||||
import com.claudecode.core.compact.CompactionResult.CompactLayer; |
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
import org.springframework.ai.chat.messages.Message; |
||||
import org.springframework.ai.chat.model.ChatModel; |
||||
|
||||
import java.util.List; |
||||
import java.util.function.Consumer; |
||||
import java.util.function.Supplier; |
||||
|
||||
/** |
||||
* 自动压缩编排器 —— 根据 token 使用量自动选择并执行压缩策略。 |
||||
* <p> |
||||
* 对应 claude-code 的自动压缩编排逻辑。在 AgentLoop 中每次 API 响应后调用。 |
||||
* 流程:检查阈值 → 微压缩 → Session Memory 压缩 → 全量压缩(兜底) |
||||
* 熔断器:连续失败 {@value MAX_CONSECUTIVE_FAILURES} 次后暂停自动压缩。 |
||||
*/ |
||||
public class AutoCompactManager { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AutoCompactManager.class); |
||||
|
||||
/** 连续失败阈值,超过后暂停自动压缩 */ |
||||
private static final int MAX_CONSECUTIVE_FAILURES = 3; |
||||
|
||||
private final MicroCompact microCompact; |
||||
private final SessionMemoryCompact sessionMemoryCompact; |
||||
private final FullCompact fullCompact; |
||||
private final TokenTracker tokenTracker; |
||||
|
||||
/** 连续压缩失败次数 */ |
||||
private int consecutiveFailures = 0; |
||||
|
||||
/** 是否已触发过熔断 */ |
||||
private boolean circuitBroken = false; |
||||
|
||||
/** 压缩事件回调(用于通知 UI) */ |
||||
private Consumer<CompactionResult> onCompactionEvent; |
||||
|
||||
public AutoCompactManager(ChatModel chatModel, TokenTracker tokenTracker) { |
||||
this.tokenTracker = tokenTracker; |
||||
this.microCompact = new MicroCompact(); |
||||
this.sessionMemoryCompact = new SessionMemoryCompact(chatModel); |
||||
this.fullCompact = new FullCompact(chatModel); |
||||
} |
||||
|
||||
public void setOnCompactionEvent(Consumer<CompactionResult> onCompactionEvent) { |
||||
this.onCompactionEvent = onCompactionEvent; |
||||
} |
||||
|
||||
/** |
||||
* 在每次 API 响应后调用,根据 token 使用状态自动执行压缩。 |
||||
* |
||||
* @param historySupplier 获取当前消息历史的函数 |
||||
* @param historyReplacer 替换消息历史的函数 |
||||
* @return 如果执行了压缩返回结果,否则返回 null |
||||
*/ |
||||
public CompactionResult autoCompactIfNeeded( |
||||
Supplier<List<Message>> historySupplier, |
||||
Consumer<List<Message>> historyReplacer) { |
||||
|
||||
// 熔断器检查
|
||||
if (circuitBroken) { |
||||
return null; |
||||
} |
||||
|
||||
// 检查是否需要压缩
|
||||
if (!tokenTracker.shouldAutoCompact()) { |
||||
// 即使不需要自动压缩,也执行微压缩(成本极低)
|
||||
List<Message> history = historySupplier.get(); |
||||
if (history instanceof java.util.ArrayList<Message> mutableHistory) { |
||||
microCompact.compact(mutableHistory); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
log.info("Auto-compact triggered at {}% token usage", |
||||
String.format("%.1f", tokenTracker.getUsagePercentage() * 100)); |
||||
|
||||
List<Message> history = historySupplier.get(); |
||||
|
||||
// 阶段 1:微压缩
|
||||
if (history instanceof java.util.ArrayList<Message> mutableHistory) { |
||||
CompactionResult microResult = microCompact.compact(mutableHistory); |
||||
if (microResult.success()) { |
||||
notifyEvent(microResult); |
||||
// 微压缩后重新检查是否仍需深度压缩
|
||||
if (!tokenTracker.shouldAutoCompact()) { |
||||
consecutiveFailures = 0; |
||||
return microResult; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// 阶段 2:Session Memory 压缩
|
||||
try { |
||||
List<Message> compacted = sessionMemoryCompact.getCompactedHistory(history); |
||||
if (compacted != null) { |
||||
historyReplacer.accept(compacted); |
||||
CompactionResult result = CompactionResult.success( |
||||
CompactLayer.SESSION_MEMORY, |
||||
history.size(), compacted.size(), |
||||
"Auto session memory compact"); |
||||
consecutiveFailures = 0; |
||||
notifyEvent(result); |
||||
log.info("Session memory compact: {} → {} messages", history.size(), compacted.size()); |
||||
return result; |
||||
} |
||||
} catch (Exception e) { |
||||
log.warn("Session memory compact failed: {}", e.getMessage()); |
||||
} |
||||
|
||||
// 阶段 3:全量压缩(兜底)
|
||||
try { |
||||
List<Message> compacted = fullCompact.compact(history); |
||||
if (compacted != null) { |
||||
historyReplacer.accept(compacted); |
||||
CompactionResult result = CompactionResult.success( |
||||
CompactLayer.FULL, |
||||
history.size(), compacted.size(), |
||||
"Auto full compact (fallback)"); |
||||
consecutiveFailures = 0; |
||||
notifyEvent(result); |
||||
log.info("Full compact fallback: {} → {} messages", history.size(), compacted.size()); |
||||
return result; |
||||
} |
||||
} catch (Exception e) { |
||||
log.warn("Full compact failed: {}", e.getMessage()); |
||||
} |
||||
|
||||
// 所有压缩方式均失败
|
||||
consecutiveFailures++; |
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { |
||||
circuitBroken = true; |
||||
log.error("Auto-compact circuit breaker triggered after {} consecutive failures", |
||||
consecutiveFailures); |
||||
CompactionResult result = CompactionResult.failure(CompactLayer.FULL, |
||||
"Circuit breaker: auto-compact disabled after " + consecutiveFailures + " failures"); |
||||
notifyEvent(result); |
||||
return result; |
||||
} |
||||
|
||||
return CompactionResult.failure(CompactLayer.SESSION_MEMORY, |
||||
"All compression strategies failed"); |
||||
} |
||||
|
||||
/** 手动重置熔断器 */ |
||||
public void resetCircuitBreaker() { |
||||
circuitBroken = false; |
||||
consecutiveFailures = 0; |
||||
log.info("Auto-compact circuit breaker reset"); |
||||
} |
||||
|
||||
public boolean isCircuitBroken() { |
||||
return circuitBroken; |
||||
} |
||||
|
||||
public int getConsecutiveFailures() { |
||||
return consecutiveFailures; |
||||
} |
||||
|
||||
/** 获取 FullCompact 实例(供 CompactCommand 委托使用) */ |
||||
public FullCompact getFullCompact() { |
||||
return fullCompact; |
||||
} |
||||
|
||||
private void notifyEvent(CompactionResult result) { |
||||
if (onCompactionEvent != null) { |
||||
try { |
||||
onCompactionEvent.accept(result); |
||||
} catch (Exception e) { |
||||
log.debug("Compaction event notification failed", e); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,46 @@ |
||||
package com.claudecode.core.compact; |
||||
|
||||
/** |
||||
* 压缩操作的结果数据。 |
||||
* |
||||
* @param success 是否成功 |
||||
* @param layer 执行的压缩层级 |
||||
* @param messagesBefore 压缩前消息数 |
||||
* @param messagesAfter 压缩后消息数 |
||||
* @param summary AI 生成的摘要(可能为 null) |
||||
* @param reason 结果原因/描述 |
||||
*/ |
||||
public record CompactionResult( |
||||
boolean success, |
||||
CompactLayer layer, |
||||
int messagesBefore, |
||||
int messagesAfter, |
||||
String summary, |
||||
String reason |
||||
) { |
||||
|
||||
/** 压缩层级 */ |
||||
public enum CompactLayer { |
||||
/** 微压缩:裁剪旧 tool_result 内容 */ |
||||
MICRO, |
||||
/** Session Memory:AI 摘要旧消息,保留近期段 */ |
||||
SESSION_MEMORY, |
||||
/** 全量压缩:AI 摘要全部,PTL 重试 */ |
||||
FULL, |
||||
/** 用户手动触发的全量压缩 */ |
||||
MANUAL |
||||
} |
||||
|
||||
public static CompactionResult success(CompactLayer layer, int before, int after, String summary) { |
||||
return new CompactionResult(true, layer, before, after, summary, |
||||
"Compacted from " + before + " to " + after + " messages"); |
||||
} |
||||
|
||||
public static CompactionResult noAction(CompactLayer layer, String reason) { |
||||
return new CompactionResult(false, layer, 0, 0, null, reason); |
||||
} |
||||
|
||||
public static CompactionResult failure(CompactLayer layer, String reason) { |
||||
return new CompactionResult(false, layer, 0, 0, null, reason); |
||||
} |
||||
} |
||||
@ -0,0 +1,175 @@ |
||||
package com.claudecode.core.compact; |
||||
|
||||
import com.claudecode.core.compact.CompactionResult.CompactLayer; |
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
import org.springframework.ai.chat.messages.*; |
||||
import org.springframework.ai.chat.model.ChatModel; |
||||
import org.springframework.ai.chat.model.ChatResponse; |
||||
import org.springframework.ai.chat.prompt.Prompt; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 全量压缩 —— AI 摘要全部对话历史,带 PTL(Prompt Too Long)重试。 |
||||
* <p> |
||||
* 对应 claude-code 的 fullCompact。当 SessionMemoryCompact 无法有效压缩时作为兜底。 |
||||
* PTL 重试策略:按 API Round(user→assistant→tool_result 为一组)逐步丢弃最旧的组。 |
||||
*/ |
||||
public class FullCompact { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FullCompact.class); |
||||
|
||||
/** PTL 重试最大次数 */ |
||||
private static final int MAX_PTL_RETRIES = 5; |
||||
|
||||
/** 保留最近 N 条消息(不压缩) */ |
||||
private static final int KEEP_RECENT_MESSAGES = 2; |
||||
|
||||
private static final String FULL_COMPACT_PROMPT = """ |
||||
Please compress the following conversation history into a thorough summary. Requirements: |
||||
1. Preserve ALL key decisions, code changes, and technical details |
||||
2. Keep file paths, function names, class names, and specific identifiers |
||||
3. Preserve user preferences, requirements, and constraints |
||||
4. Record the current state of work: what was completed, what remains, what's blocked |
||||
5. Note any errors encountered and their resolutions |
||||
6. Keep important context about the project structure and architecture |
||||
7. Output within 1000 words, using structured bullet points |
||||
|
||||
Conversation history: |
||||
"""; |
||||
|
||||
private final ChatModel chatModel; |
||||
|
||||
public FullCompact(ChatModel chatModel) { |
||||
this.chatModel = chatModel; |
||||
} |
||||
|
||||
/** |
||||
* 执行全量压缩。 |
||||
* |
||||
* @param history 当前消息历史 |
||||
* @return 压缩后的新历史;如果失败返回 null |
||||
*/ |
||||
public List<Message> compact(List<Message> history) { |
||||
if (history.size() <= KEEP_RECENT_MESSAGES + 2) { |
||||
return null; |
||||
} |
||||
|
||||
int before = history.size(); |
||||
Message systemMsg = history.getFirst(); |
||||
|
||||
// 按 API Round 分组
|
||||
List<ApiRound> rounds = groupByRounds(history); |
||||
|
||||
// PTL 重试循环:逐步丢弃最旧的 round
|
||||
int dropCount = 0; |
||||
while (dropCount < rounds.size() - 1 && dropCount < MAX_PTL_RETRIES) { |
||||
List<ApiRound> remaining = rounds.subList(dropCount, rounds.size()); |
||||
|
||||
try { |
||||
String summary = generateFullSummary(remaining); |
||||
if (summary != null && !summary.isBlank()) { |
||||
// 构建新历史
|
||||
List<Message> newHistory = new ArrayList<>(); |
||||
newHistory.add(systemMsg); |
||||
newHistory.add(new SystemMessage("[Conversation Summary]\n" + summary)); |
||||
|
||||
// 保留最后几条消息
|
||||
for (int i = Math.max(1, before - KEEP_RECENT_MESSAGES); i < before; i++) { |
||||
newHistory.add(history.get(i)); |
||||
} |
||||
|
||||
log.info("Full compact succeeded: {} → {} messages (dropped {} rounds)", |
||||
before, newHistory.size(), dropCount); |
||||
return newHistory; |
||||
} |
||||
} catch (Exception e) { |
||||
log.warn("Full compact attempt failed (drop={}): {}", dropCount, e.getMessage()); |
||||
// PTL error — drop oldest round and retry
|
||||
} |
||||
|
||||
dropCount++; |
||||
} |
||||
|
||||
log.error("Full compact failed after {} PTL retries", dropCount); |
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* 执行全量压缩并返回 CompactionResult。 |
||||
*/ |
||||
public CompactionResult compactWithResult(List<Message> history) { |
||||
int before = history.size(); |
||||
List<Message> result = compact(history); |
||||
if (result == null) { |
||||
return CompactionResult.failure(CompactLayer.FULL, "Full compact failed"); |
||||
} |
||||
return CompactionResult.success(CompactLayer.FULL, before, result.size(), null); |
||||
} |
||||
|
||||
// ── 内部方法 ──
|
||||
|
||||
/** 按 API Round 分组:一个 round = [UserMessage] + [AssistantMessage + ToolResponseMessages...] */ |
||||
private List<ApiRound> groupByRounds(List<Message> history) { |
||||
List<ApiRound> rounds = new ArrayList<>(); |
||||
List<Message> currentRound = new ArrayList<>(); |
||||
|
||||
for (int i = 1; i < history.size(); i++) { // 跳过系统消息
|
||||
Message msg = history.get(i); |
||||
if (msg instanceof UserMessage && !currentRound.isEmpty()) { |
||||
rounds.add(new ApiRound(List.copyOf(currentRound))); |
||||
currentRound.clear(); |
||||
} |
||||
currentRound.add(msg); |
||||
} |
||||
|
||||
if (!currentRound.isEmpty()) { |
||||
rounds.add(new ApiRound(List.copyOf(currentRound))); |
||||
} |
||||
|
||||
return rounds; |
||||
} |
||||
|
||||
/** 生成全量摘要 */ |
||||
private String generateFullSummary(List<ApiRound> rounds) { |
||||
StringBuilder dialogText = new StringBuilder(); |
||||
|
||||
for (ApiRound round : rounds) { |
||||
for (Message msg : round.messages()) { |
||||
switch (msg) { |
||||
case UserMessage um -> dialogText.append("[User] ").append(um.getText()).append("\n"); |
||||
case AssistantMessage am -> { |
||||
if (am.getText() != null && !am.getText().isBlank()) { |
||||
String text = am.getText(); |
||||
if (text.length() > 600) text = text.substring(0, 600) + "..."; |
||||
dialogText.append("[Assistant] ").append(text).append("\n"); |
||||
} |
||||
if (am.hasToolCalls()) { |
||||
for (var tc : am.getToolCalls()) { |
||||
dialogText.append("[Tool Call] ").append(tc.name()).append("\n"); |
||||
} |
||||
} |
||||
} |
||||
case ToolResponseMessage trm -> { |
||||
for (var resp : trm.getResponses()) { |
||||
dialogText.append("[Tool Result: ").append(resp.name()).append("]\n"); |
||||
} |
||||
} |
||||
default -> {} |
||||
} |
||||
} |
||||
dialogText.append("---\n"); |
||||
} |
||||
|
||||
if (dialogText.isEmpty()) return null; |
||||
|
||||
Prompt prompt = new Prompt(List.of(new UserMessage(FULL_COMPACT_PROMPT + dialogText))); |
||||
ChatResponse response = chatModel.call(prompt); |
||||
return response.getResult().getOutput().getText(); |
||||
} |
||||
|
||||
/** API Round:一个用户请求 + AI 响应 + 工具调用的完整回合 */ |
||||
private record ApiRound(List<Message> messages) {} |
||||
} |
||||
@ -0,0 +1,91 @@ |
||||
package com.claudecode.core.compact; |
||||
|
||||
import com.claudecode.core.compact.CompactionResult.CompactLayer; |
||||
import org.springframework.ai.chat.messages.*; |
||||
|
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 微压缩 —— 在每次 API 调用后执行,裁剪旧的 tool_result 内容。 |
||||
* <p> |
||||
* 对应 claude-code 的 microCompact。不需要额外 API 调用,纯本地操作。 |
||||
* 策略:保留最近 N 轮的 tool 结果,更早的只保留摘要行 "[Tool result truncated]"。 |
||||
*/ |
||||
public class MicroCompact { |
||||
|
||||
/** 保留最近 N 条 ToolResponseMessage 的完整内容 */ |
||||
private static final int KEEP_RECENT_TOOL_RESULTS = 6; |
||||
|
||||
/** 截断阈值:超过此长度的旧 tool result 才会被截断 */ |
||||
private static final int TRUNCATE_THRESHOLD = 200; |
||||
|
||||
/** 截断后的占位文本 */ |
||||
private static final String TRUNCATED_MARKER = "[Tool result truncated — %d chars omitted]"; |
||||
|
||||
/** |
||||
* 对消息历史执行微压缩。 |
||||
* 直接在原始列表上原地修改以提升性能。 |
||||
* |
||||
* @param history 消息列表(直接修改) |
||||
* @return 压缩结果 |
||||
*/ |
||||
public CompactionResult compact(List<Message> history) { |
||||
int totalToolResponses = 0; |
||||
int truncated = 0; |
||||
|
||||
// 倒序扫描,找到所有 ToolResponseMessage 的位置
|
||||
int recentCount = 0; |
||||
for (int i = history.size() - 1; i >= 0; i--) { |
||||
if (history.get(i) instanceof ToolResponseMessage) { |
||||
totalToolResponses++; |
||||
recentCount++; |
||||
if (recentCount > KEEP_RECENT_TOOL_RESULTS) { |
||||
// 需要截断
|
||||
ToolResponseMessage trm = (ToolResponseMessage) history.get(i); |
||||
if (shouldTruncate(trm)) { |
||||
history.set(i, truncateToolResponse(trm)); |
||||
truncated++; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (truncated == 0) { |
||||
return CompactionResult.noAction(CompactLayer.MICRO, "No tool results to truncate"); |
||||
} |
||||
|
||||
return CompactionResult.success(CompactLayer.MICRO, totalToolResponses, |
||||
totalToolResponses - truncated, null); |
||||
} |
||||
|
||||
/** 判断 ToolResponseMessage 是否需要截断 */ |
||||
private boolean shouldTruncate(ToolResponseMessage trm) { |
||||
var responses = trm.getResponses(); |
||||
if (responses == null || responses.isEmpty()) return false; |
||||
for (var resp : responses) { |
||||
if (resp.responseData() != null && resp.responseData().toString().length() > TRUNCATE_THRESHOLD) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
/** 创建截断后的 ToolResponseMessage */ |
||||
private ToolResponseMessage truncateToolResponse(ToolResponseMessage original) { |
||||
var responses = original.getResponses(); |
||||
if (responses == null || responses.isEmpty()) return original; |
||||
|
||||
var truncatedResponses = responses.stream().map(resp -> { |
||||
String data = resp.responseData() != null ? resp.responseData().toString() : ""; |
||||
if (data.length() > TRUNCATE_THRESHOLD) { |
||||
String marker = String.format(TRUNCATED_MARKER, data.length()); |
||||
return new ToolResponseMessage.ToolResponse(resp.id(), resp.name(), marker); |
||||
} |
||||
return resp; |
||||
}).toList(); |
||||
|
||||
return ToolResponseMessage.builder() |
||||
.responses(truncatedResponses) |
||||
.build(); |
||||
} |
||||
} |
||||
@ -0,0 +1,297 @@ |
||||
package com.claudecode.core.compact; |
||||
|
||||
import com.claudecode.core.compact.CompactionResult.CompactLayer; |
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
import org.springframework.ai.chat.messages.*; |
||||
import org.springframework.ai.chat.model.ChatModel; |
||||
import org.springframework.ai.chat.model.ChatResponse; |
||||
import org.springframework.ai.chat.prompt.Prompt; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
|
||||
/** |
||||
* Session Memory 压缩 —— 保留近期消息段,用 AI 摘要旧消息。 |
||||
* <p> |
||||
* 对应 claude-code 的 sessionMemoryCompact。这是主要的自动压缩方式。 |
||||
* 算法: |
||||
* <ol> |
||||
* <li>找到上次压缩的边界(通过检测 [Conversation Summary] 标记)</li> |
||||
* <li>计算需要保留的近期消息段(至少保留 MIN_KEEP_TOKENS token 估算量 + MIN_KEEP_TEXT_MSGS 条文本消息)</li> |
||||
* <li>将边界之后、保留段之前的消息通过 AI 生成摘要</li> |
||||
* <li>用 [系统提示] + [历史摘要] + [新摘要] + [保留段] 替换历史</li> |
||||
* </ol> |
||||
*/ |
||||
public class SessionMemoryCompact { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SessionMemoryCompact.class); |
||||
|
||||
/** 最少保留的文本消息数(用户 + 助手) */ |
||||
private static final int MIN_KEEP_TEXT_MSGS = 5; |
||||
|
||||
/** 估算最少保留的 token 数 */ |
||||
private static final int MIN_KEEP_TOKENS = 10_000; |
||||
|
||||
/** 估算最多保留的 token 数 */ |
||||
private static final int MAX_KEEP_TOKENS = 40_000; |
||||
|
||||
/** 每字符估算的 token 数(粗略近似) */ |
||||
private static final double CHARS_PER_TOKEN = 4.0; |
||||
|
||||
private static final String SUMMARY_PROMPT = """ |
||||
Summarize the following conversation segment concisely but thoroughly. |
||||
Preserve: |
||||
- All key technical decisions and their rationale |
||||
- File paths, function names, class names, and specific code identifiers |
||||
- User requirements and preferences |
||||
- Current state of work (what was done, what remains) |
||||
- Any errors encountered and their resolutions |
||||
|
||||
Keep the summary under 800 words. Use bullet points for clarity. |
||||
|
||||
Conversation segment to summarize: |
||||
"""; |
||||
|
||||
private final ChatModel chatModel; |
||||
|
||||
public SessionMemoryCompact(ChatModel chatModel) { |
||||
this.chatModel = chatModel; |
||||
} |
||||
|
||||
/** |
||||
* 执行 Session Memory 压缩。 |
||||
* |
||||
* @param history 当前消息历史(不直接修改,返回新列表) |
||||
* @return 压缩结果;如果无法压缩返回 noAction |
||||
*/ |
||||
public CompactionResult compact(List<Message> history) { |
||||
if (history.size() <= MIN_KEEP_TEXT_MSGS + 2) { |
||||
return CompactionResult.noAction(CompactLayer.SESSION_MEMORY, |
||||
"Too few messages to compact"); |
||||
} |
||||
|
||||
int before = history.size(); |
||||
|
||||
// 找到系统提示词(第一条)
|
||||
Message systemMsg = history.getFirst(); |
||||
|
||||
// 找到上一次摘要的位置(如果有的话)
|
||||
int lastSummaryIndex = findLastSummaryIndex(history); |
||||
|
||||
// 从摘要之后开始计算可压缩区域
|
||||
int compressibleStart = lastSummaryIndex + 1; |
||||
|
||||
// 从末尾向前找保留段的起始位置
|
||||
int keepStart = findKeepStart(history, compressibleStart); |
||||
|
||||
// 如果可压缩区域太小,不值得压缩
|
||||
if (keepStart - compressibleStart < 4) { |
||||
return CompactionResult.noAction(CompactLayer.SESSION_MEMORY, |
||||
"Not enough messages to compress (only " + (keepStart - compressibleStart) + " in range)"); |
||||
} |
||||
|
||||
// 提取需要压缩的消息段
|
||||
List<Message> toCompress = history.subList(compressibleStart, keepStart); |
||||
|
||||
// 生成摘要
|
||||
String summary; |
||||
try { |
||||
summary = generateSummary(toCompress); |
||||
} catch (Exception e) { |
||||
log.warn("Session memory compression failed: {}", e.getMessage()); |
||||
return CompactionResult.failure(CompactLayer.SESSION_MEMORY, |
||||
"Summary generation failed: " + e.getMessage()); |
||||
} |
||||
|
||||
if (summary == null || summary.isBlank()) { |
||||
return CompactionResult.failure(CompactLayer.SESSION_MEMORY, |
||||
"Empty summary generated"); |
||||
} |
||||
|
||||
// 构建新历史
|
||||
List<Message> newHistory = new ArrayList<>(); |
||||
newHistory.add(systemMsg); |
||||
|
||||
// 保留旧的摘要(如果有的话,合并到新摘要中)
|
||||
String previousSummary = extractPreviousSummary(history, lastSummaryIndex); |
||||
if (previousSummary != null) { |
||||
summary = "=== Earlier Context ===\n" + previousSummary + "\n\n=== Recent Activity ===\n" + summary; |
||||
} |
||||
|
||||
// 添加新的摘要消息
|
||||
newHistory.add(new SystemMessage("[Conversation Summary]\n" + summary)); |
||||
|
||||
// 添加保留段
|
||||
for (int i = keepStart; i < history.size(); i++) { |
||||
newHistory.add(history.get(i)); |
||||
} |
||||
|
||||
int after = newHistory.size(); |
||||
return new CompactionResult(true, CompactLayer.SESSION_MEMORY, before, after, summary, |
||||
"Session memory compacted: " + before + " → " + after + " messages"); |
||||
} |
||||
|
||||
/** |
||||
* 获取压缩后的新历史。调用方需要先调用 compact() 确认成功,然后调用此方法获取结果。 |
||||
* 为避免重复逻辑,此方法重新执行压缩并返回新历史。 |
||||
*/ |
||||
public List<Message> getCompactedHistory(List<Message> history) { |
||||
if (history.size() <= MIN_KEEP_TEXT_MSGS + 2) return null; |
||||
|
||||
Message systemMsg = history.getFirst(); |
||||
int lastSummaryIndex = findLastSummaryIndex(history); |
||||
int compressibleStart = lastSummaryIndex + 1; |
||||
int keepStart = findKeepStart(history, compressibleStart); |
||||
|
||||
if (keepStart - compressibleStart < 4) return null; |
||||
|
||||
List<Message> toCompress = history.subList(compressibleStart, keepStart); |
||||
String summary; |
||||
try { |
||||
summary = generateSummary(toCompress); |
||||
} catch (Exception e) { |
||||
return null; |
||||
} |
||||
if (summary == null || summary.isBlank()) return null; |
||||
|
||||
List<Message> newHistory = new ArrayList<>(); |
||||
newHistory.add(systemMsg); |
||||
|
||||
String previousSummary = extractPreviousSummary(history, lastSummaryIndex); |
||||
if (previousSummary != null) { |
||||
summary = "=== Earlier Context ===\n" + previousSummary + "\n\n=== Recent Activity ===\n" + summary; |
||||
} |
||||
|
||||
newHistory.add(new SystemMessage("[Conversation Summary]\n" + summary)); |
||||
for (int i = keepStart; i < history.size(); i++) { |
||||
newHistory.add(history.get(i)); |
||||
} |
||||
|
||||
return newHistory; |
||||
} |
||||
|
||||
// ── 内部方法 ──
|
||||
|
||||
/** 找到历史中最后一个 [Conversation Summary] 系统消息的索引 */ |
||||
private int findLastSummaryIndex(List<Message> history) { |
||||
for (int i = history.size() - 1; i >= 1; i--) { |
||||
if (history.get(i) instanceof SystemMessage sm |
||||
&& sm.getText() != null |
||||
&& sm.getText().startsWith("[Conversation Summary]")) { |
||||
return i; |
||||
} |
||||
} |
||||
return 0; // 没有摘要,从系统提示之后开始
|
||||
} |
||||
|
||||
/** 从末尾向前找保留段的起始位置 */ |
||||
private int findKeepStart(List<Message> history, int minStart) { |
||||
int textMsgCount = 0; |
||||
long estimatedTokens = 0; |
||||
|
||||
for (int i = history.size() - 1; i >= minStart; i--) { |
||||
Message msg = history.get(i); |
||||
|
||||
// 估算 token 量
|
||||
long msgTokens = estimateTokens(msg); |
||||
estimatedTokens += msgTokens; |
||||
|
||||
if (msg instanceof UserMessage || msg instanceof AssistantMessage) { |
||||
textMsgCount++; |
||||
} |
||||
|
||||
// 确保不会拆分 tool_use / tool_result 对
|
||||
// 如果当前是 ToolResponseMessage,它的 AssistantMessage(含 tool_calls)应在前面
|
||||
if (msg instanceof ToolResponseMessage && i > minStart) { |
||||
continue; // 继续往前包含对应的 AssistantMessage
|
||||
} |
||||
|
||||
// 满足最小保留条件,且已达到上限则停止
|
||||
if (textMsgCount >= MIN_KEEP_TEXT_MSGS && estimatedTokens >= MIN_KEEP_TOKENS) { |
||||
// 检查是否达到 token 上限
|
||||
if (estimatedTokens >= MAX_KEEP_TOKENS) { |
||||
return i; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// 如果从 minStart 开始全部都在保留范围内,返回 minStart
|
||||
// 说明消息不够多,不需要压缩
|
||||
return minStart; |
||||
} |
||||
|
||||
/** 估算消息的 token 数 */ |
||||
private long estimateTokens(Message msg) { |
||||
String text = switch (msg) { |
||||
case UserMessage um -> um.getText(); |
||||
case AssistantMessage am -> am.getText(); |
||||
case SystemMessage sm -> sm.getText(); |
||||
case ToolResponseMessage trm -> { |
||||
StringBuilder sb = new StringBuilder(); |
||||
for (var resp : trm.getResponses()) { |
||||
if (resp.responseData() != null) { |
||||
sb.append(resp.responseData().toString()); |
||||
} |
||||
} |
||||
yield sb.toString(); |
||||
} |
||||
default -> ""; |
||||
}; |
||||
if (text == null || text.isEmpty()) return 10; // 最小估算
|
||||
return (long) (text.length() / CHARS_PER_TOKEN); |
||||
} |
||||
|
||||
/** 提取上一次的摘要文本 */ |
||||
private String extractPreviousSummary(List<Message> history, int summaryIndex) { |
||||
if (summaryIndex <= 0) return null; |
||||
Message msg = history.get(summaryIndex); |
||||
if (msg instanceof SystemMessage sm && sm.getText() != null) { |
||||
String text = sm.getText(); |
||||
if (text.startsWith("[Conversation Summary]\n")) { |
||||
return text.substring("[Conversation Summary]\n".length()); |
||||
} |
||||
if (text.startsWith("[Conversation Summary] ")) { |
||||
return text.substring("[Conversation Summary] ".length()); |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
/** 调用 AI 生成对话段摘要 */ |
||||
private String generateSummary(List<Message> segment) { |
||||
StringBuilder dialogText = new StringBuilder(); |
||||
for (Message msg : segment) { |
||||
switch (msg) { |
||||
case UserMessage um -> dialogText.append("[User] ").append(um.getText()).append("\n"); |
||||
case AssistantMessage am -> { |
||||
if (am.getText() != null && !am.getText().isBlank()) { |
||||
String text = am.getText(); |
||||
if (text.length() > 800) text = text.substring(0, 800) + "..."; |
||||
dialogText.append("[Assistant] ").append(text).append("\n"); |
||||
} |
||||
if (am.hasToolCalls()) { |
||||
for (var tc : am.getToolCalls()) { |
||||
dialogText.append("[Tool Call] ").append(tc.name()).append("\n"); |
||||
} |
||||
} |
||||
} |
||||
case ToolResponseMessage trm -> { |
||||
for (var resp : trm.getResponses()) { |
||||
String data = resp.responseData() != null ? resp.responseData().toString() : ""; |
||||
if (data.length() > 200) data = data.substring(0, 200) + "..."; |
||||
dialogText.append("[Tool Result: ").append(resp.name()).append("] ") |
||||
.append(data).append("\n"); |
||||
} |
||||
} |
||||
default -> {} |
||||
} |
||||
} |
||||
|
||||
if (dialogText.isEmpty()) return null; |
||||
|
||||
Prompt prompt = new Prompt(List.of(new UserMessage(SUMMARY_PROMPT + dialogText))); |
||||
ChatResponse response = chatModel.call(prompt); |
||||
return response.getResult().getOutput().getText(); |
||||
} |
||||
} |
||||
@ -0,0 +1,105 @@ |
||||
package com.claudecode.permission; |
||||
|
||||
import java.util.List; |
||||
import java.util.Set; |
||||
|
||||
/** |
||||
* 危险命令模式检测 —— 识别可能有害的 shell 命令。 |
||||
* <p> |
||||
* 即使在 BYPASS 模式下也会对这些命令发出警告。 |
||||
*/ |
||||
public final class DangerousPatterns { |
||||
|
||||
private DangerousPatterns() {} |
||||
|
||||
/** 危险 shell 命令前缀(不区分大小写匹配) */ |
||||
private static final List<String> DANGEROUS_BASH_PREFIXES = List.of( |
||||
"rm -rf /", |
||||
"rm -rf ~", |
||||
"rm -rf .", |
||||
"rm -r /", |
||||
"rmdir /s", |
||||
"del /f /s /q", |
||||
"format ", |
||||
"mkfs.", |
||||
"dd if=", |
||||
"> /dev/sda", |
||||
"chmod -R 777 /", |
||||
"chown -R", |
||||
":(){:|:&};:" // fork bomb
|
||||
); |
||||
|
||||
/** 危险代码执行模式 */ |
||||
private static final List<String> CODE_EXECUTION_PATTERNS = List.of( |
||||
"eval ", |
||||
"exec ", |
||||
"python -c", |
||||
"python3 -c", |
||||
"node -e", |
||||
"ruby -e", |
||||
"perl -e", |
||||
"| sh", |
||||
"| bash", |
||||
"| zsh", |
||||
"| powershell", |
||||
"| pwsh", |
||||
"curl | sh", |
||||
"wget | sh", |
||||
"Invoke-Expression", |
||||
"iex ", |
||||
"Start-Process", |
||||
"Add-Type" |
||||
); |
||||
|
||||
/** 在规则匹配中应自动拒绝的工具级通配符 */ |
||||
private static final Set<String> DANGEROUS_TOOL_WILDCARDS = Set.of( |
||||
"Bash", // 不应允许所有 bash 命令
|
||||
"Bash(*)", |
||||
"PowerShell", |
||||
"PowerShell(*)" |
||||
); |
||||
|
||||
/** |
||||
* 检测命令是否包含危险模式 |
||||
* |
||||
* @param command shell 命令文本 |
||||
* @return 如果危险返回原因描述,否则返回 null |
||||
*/ |
||||
public static String detectDangerous(String command) { |
||||
if (command == null || command.isBlank()) return null; |
||||
String lower = command.toLowerCase().trim(); |
||||
|
||||
for (String prefix : DANGEROUS_BASH_PREFIXES) { |
||||
if (lower.startsWith(prefix.toLowerCase()) || lower.contains(prefix.toLowerCase())) { |
||||
return "Dangerous command detected: " + prefix.trim(); |
||||
} |
||||
} |
||||
|
||||
for (String pattern : CODE_EXECUTION_PATTERNS) { |
||||
if (lower.contains(pattern.toLowerCase())) { |
||||
return "Code execution pattern detected: " + pattern.trim(); |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* 检测是否为危险的工具级通配符规则 |
||||
* <p> |
||||
* 用于防止用户添加过于宽泛的 "always allow" 规则。 |
||||
*/ |
||||
public static boolean isDangerousWildcard(String ruleStr) { |
||||
return DANGEROUS_TOOL_WILDCARDS.contains(ruleStr); |
||||
} |
||||
|
||||
/** |
||||
* 获取危险原因的简短描述 |
||||
*/ |
||||
public static String getDangerLevel(String command) { |
||||
String reason = detectDangerous(command); |
||||
if (reason == null) return "LOW"; |
||||
if (reason.contains("Dangerous command")) return "HIGH"; |
||||
return "MEDIUM"; |
||||
} |
||||
} |
||||
@ -0,0 +1,173 @@ |
||||
package com.claudecode.permission; |
||||
|
||||
import com.claudecode.permission.PermissionTypes.*; |
||||
|
||||
import java.util.List; |
||||
import java.util.Map; |
||||
import java.util.Set; |
||||
|
||||
/** |
||||
* 权限规则引擎 —— 根据规则、模式和工具属性做出权限决策。 |
||||
* <p> |
||||
* 决策流程: |
||||
* <ol> |
||||
* <li>检查全局模式(BYPASS → 全部允许,DONT_ASK → 拒绝需确认的)</li> |
||||
* <li>检查 alwaysDeny 规则 → 匹配则 DENY</li> |
||||
* <li>检查 alwaysAllow 规则 → 匹配则 ALLOW</li> |
||||
* <li>只读工具 → ALLOW</li> |
||||
* <li>ACCEPT_EDITS 模式下文件操作 → ALLOW</li> |
||||
* <li>检查危险命令 → 强制 ASK</li> |
||||
* <li>默认 → ASK</li> |
||||
* </ol> |
||||
*/ |
||||
public class PermissionRuleEngine { |
||||
|
||||
private static final Set<String> FILE_EDIT_TOOLS = Set.of("Write", "Edit", "NotebookEdit"); |
||||
private static final Set<String> READ_ONLY_TOOLS = Set.of( |
||||
"Read", "Glob", "Grep", "ListFiles", "WebFetch", "WebSearch", |
||||
"TodoRead", "TaskGet", "TaskList", "AskUserQuestion" |
||||
); |
||||
|
||||
private final PermissionSettings settings; |
||||
|
||||
public PermissionRuleEngine(PermissionSettings settings) { |
||||
this.settings = settings; |
||||
} |
||||
|
||||
/** |
||||
* 评估工具调用的权限 |
||||
* |
||||
* @param toolName 工具名称 |
||||
* @param input 工具参数 |
||||
* @param isReadOnly 工具是否为只读 |
||||
* @return 权限决策 |
||||
*/ |
||||
public PermissionDecision evaluate(String toolName, Map<String, Object> input, boolean isReadOnly) { |
||||
PermissionMode mode = settings.getCurrentMode(); |
||||
|
||||
// BYPASS 模式:全部允许
|
||||
if (mode == PermissionMode.BYPASS) { |
||||
return PermissionDecision.allow("Bypass mode enabled"); |
||||
} |
||||
|
||||
// 获取命令内容(用于 Bash/PowerShell 的命令匹配)
|
||||
String command = extractCommand(toolName, input); |
||||
|
||||
// 检查所有持久化规则
|
||||
List<PermissionRule> rules = settings.getAllRules(); |
||||
|
||||
// 1. 检查 alwaysDeny 规则
|
||||
for (var rule : rules) { |
||||
if (rule.behavior() == PermissionBehavior.DENY && matchesRule(rule, toolName, command)) { |
||||
return PermissionDecision.deny("Denied by rule: " + PermissionSettings.formatRule(rule)); |
||||
} |
||||
} |
||||
|
||||
// 2. 检查 alwaysAllow 规则
|
||||
for (var rule : rules) { |
||||
if (rule.behavior() == PermissionBehavior.ALLOW && matchesRule(rule, toolName, command)) { |
||||
return PermissionDecision.allow("Allowed by rule: " + PermissionSettings.formatRule(rule)); |
||||
} |
||||
} |
||||
|
||||
// 3. 只读工具直接放行
|
||||
if (isReadOnly || READ_ONLY_TOOLS.contains(toolName)) { |
||||
return PermissionDecision.allow("Read-only tool"); |
||||
} |
||||
|
||||
// 4. ACCEPT_EDITS 模式:文件操作工具自动允许
|
||||
if (mode == PermissionMode.ACCEPT_EDITS && FILE_EDIT_TOOLS.contains(toolName)) { |
||||
return PermissionDecision.allow("File edits auto-allowed in accept-edits mode"); |
||||
} |
||||
|
||||
// 5. DONT_ASK 模式:自动拒绝
|
||||
if (mode == PermissionMode.DONT_ASK) { |
||||
return PermissionDecision.deny("Auto-denied in dont-ask mode"); |
||||
} |
||||
|
||||
// 6. 检查危险命令(强制 ASK,附带警告)
|
||||
if (command != null) { |
||||
String danger = DangerousPatterns.detectDangerous(command); |
||||
if (danger != null) { |
||||
String prefix = extractCommandPrefix(command); |
||||
return new PermissionDecision( |
||||
PermissionBehavior.ASK, |
||||
"⚠ DANGEROUS: " + danger, |
||||
toolName, prefix, List.of() |
||||
); |
||||
} |
||||
} |
||||
|
||||
// 7. 默认:需要用户确认
|
||||
String prefix = extractCommandPrefix(command); |
||||
return PermissionDecision.ask(toolName, prefix); |
||||
} |
||||
|
||||
/** |
||||
* 根据用户选择应用权限变更 |
||||
*/ |
||||
public void applyChoice(PermissionChoice choice, String toolName, String command) { |
||||
String prefix = extractCommandPrefix(command); |
||||
switch (choice) { |
||||
case ALWAYS_ALLOW -> { |
||||
var rule = prefix != null |
||||
? PermissionRule.forCommand(toolName, prefix, PermissionBehavior.ALLOW) |
||||
: PermissionRule.forTool(toolName, PermissionBehavior.ALLOW); |
||||
// 检查是否为危险通配符
|
||||
String ruleStr = PermissionSettings.formatRule(rule); |
||||
if (!DangerousPatterns.isDangerousWildcard(ruleStr)) { |
||||
settings.addUserRule(rule); |
||||
} |
||||
} |
||||
case ALWAYS_DENY -> { |
||||
var rule = prefix != null |
||||
? PermissionRule.forCommand(toolName, prefix, PermissionBehavior.DENY) |
||||
: PermissionRule.forTool(toolName, PermissionBehavior.DENY); |
||||
settings.addUserRule(rule); |
||||
} |
||||
case ALLOW_ONCE, DENY_ONCE -> { |
||||
// 单次操作,不持久化
|
||||
} |
||||
} |
||||
} |
||||
|
||||
// ── 内部匹配方法 ──
|
||||
|
||||
/** 检查规则是否匹配当前工具和命令 */ |
||||
boolean matchesRule(PermissionRule rule, String toolName, String command) { |
||||
// 工具名不匹配直接跳过
|
||||
if (!rule.toolName().equalsIgnoreCase(toolName)) return false; |
||||
|
||||
String content = rule.ruleContent(); |
||||
// 通配符 * 匹配所有命令
|
||||
if ("*".equals(content)) return true; |
||||
|
||||
// 前缀匹配模式:npm:* 匹配以 "npm" 开头的命令
|
||||
if (content.endsWith(":*") && command != null) { |
||||
String prefix = content.substring(0, content.length() - 2); |
||||
return command.toLowerCase().startsWith(prefix.toLowerCase()); |
||||
} |
||||
|
||||
// 精确匹配
|
||||
return content.equalsIgnoreCase(command); |
||||
} |
||||
|
||||
/** 从工具参数中提取命令文本 */ |
||||
private String extractCommand(String toolName, Map<String, Object> input) { |
||||
if (input == null) return null; |
||||
return switch (toolName) { |
||||
case "Bash" -> (String) input.get("command"); |
||||
case "Write" -> (String) input.get("file_path"); |
||||
case "Edit" -> (String) input.get("file_path"); |
||||
default -> null; |
||||
}; |
||||
} |
||||
|
||||
/** 提取命令前缀(第一个空格前的部分) */ |
||||
private String extractCommandPrefix(String command) { |
||||
if (command == null || command.isBlank()) return null; |
||||
String trimmed = command.trim(); |
||||
int space = trimmed.indexOf(' '); |
||||
return space > 0 ? trimmed.substring(0, space) : trimmed; |
||||
} |
||||
} |
||||
@ -0,0 +1,200 @@ |
||||
package com.claudecode.permission; |
||||
|
||||
import com.claudecode.permission.PermissionTypes.PermissionBehavior; |
||||
import com.claudecode.permission.PermissionTypes.PermissionRule; |
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; |
||||
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
import com.fasterxml.jackson.databind.SerializationFeature; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 权限设置持久化 —— 管理用户级和项目级权限规则文件。 |
||||
* <p> |
||||
* 存储结构: |
||||
* <ul> |
||||
* <li>用户级: ~/.claude-code-java/settings.json</li> |
||||
* <li>项目级: .claude-code-java/settings.json</li> |
||||
* </ul> |
||||
* 加载优先级: 项目级 > 用户级 > 会话级 |
||||
*/ |
||||
public class PermissionSettings { |
||||
|
||||
private static final String SETTINGS_DIR = ".claude-code-java"; |
||||
private static final String SETTINGS_FILE = "settings.json"; |
||||
private static final ObjectMapper MAPPER = new ObjectMapper() |
||||
.enable(SerializationFeature.INDENT_OUTPUT); |
||||
|
||||
/** 内存中的合并规则(从所有来源加载后合并) */ |
||||
private final List<PermissionRule> sessionRules = new ArrayList<>(); |
||||
private PermissionTypes.PermissionMode currentMode = PermissionTypes.PermissionMode.DEFAULT; |
||||
|
||||
private final Path userSettingsPath; |
||||
private final Path projectSettingsPath; |
||||
|
||||
private SettingsData userData = new SettingsData(); |
||||
private SettingsData projectData = new SettingsData(); |
||||
|
||||
public PermissionSettings() { |
||||
this(Path.of(System.getProperty("user.home")), |
||||
Path.of(System.getProperty("user.dir"))); |
||||
} |
||||
|
||||
public PermissionSettings(Path userHome, Path projectDir) { |
||||
this.userSettingsPath = userHome.resolve(SETTINGS_DIR).resolve(SETTINGS_FILE); |
||||
this.projectSettingsPath = projectDir.resolve(SETTINGS_DIR).resolve(SETTINGS_FILE); |
||||
} |
||||
|
||||
/** 从磁盘加载所有设置 */ |
||||
public void load() { |
||||
userData = loadFromFile(userSettingsPath); |
||||
projectData = loadFromFile(projectSettingsPath); |
||||
// 项目级模式优先
|
||||
if (projectData.permissions.mode != null) { |
||||
currentMode = projectData.permissions.mode; |
||||
} else if (userData.permissions.mode != null) { |
||||
currentMode = userData.permissions.mode; |
||||
} |
||||
} |
||||
|
||||
/** 获取所有合并后的规则(项目级 > 用户级 > 会话级) */ |
||||
public List<PermissionRule> getAllRules() { |
||||
var rules = new ArrayList<PermissionRule>(); |
||||
// 项目级优先
|
||||
rules.addAll(toRules(projectData.permissions.alwaysAllow, PermissionBehavior.ALLOW)); |
||||
rules.addAll(toRules(projectData.permissions.alwaysDeny, PermissionBehavior.DENY)); |
||||
// 用户级
|
||||
rules.addAll(toRules(userData.permissions.alwaysAllow, PermissionBehavior.ALLOW)); |
||||
rules.addAll(toRules(userData.permissions.alwaysDeny, PermissionBehavior.DENY)); |
||||
// 会话级
|
||||
rules.addAll(sessionRules); |
||||
return rules; |
||||
} |
||||
|
||||
/** 添加规则并保存到用户级设置 */ |
||||
public void addUserRule(PermissionRule rule) { |
||||
if (rule.behavior() == PermissionBehavior.ALLOW) { |
||||
userData.permissions.alwaysAllow.add(formatRule(rule)); |
||||
} else if (rule.behavior() == PermissionBehavior.DENY) { |
||||
userData.permissions.alwaysDeny.add(formatRule(rule)); |
||||
} |
||||
saveToFile(userSettingsPath, userData); |
||||
} |
||||
|
||||
/** 添加规则到会话级(不持久化) */ |
||||
public void addSessionRule(PermissionRule rule) { |
||||
sessionRules.add(rule); |
||||
} |
||||
|
||||
/** 移除用户级规则 */ |
||||
public void removeUserRule(String ruleStr) { |
||||
userData.permissions.alwaysAllow.remove(ruleStr); |
||||
userData.permissions.alwaysDeny.remove(ruleStr); |
||||
saveToFile(userSettingsPath, userData); |
||||
} |
||||
|
||||
/** 清除所有规则 */ |
||||
public void clearAll() { |
||||
userData.permissions.alwaysAllow.clear(); |
||||
userData.permissions.alwaysDeny.clear(); |
||||
projectData.permissions.alwaysAllow.clear(); |
||||
projectData.permissions.alwaysDeny.clear(); |
||||
sessionRules.clear(); |
||||
saveToFile(userSettingsPath, userData); |
||||
} |
||||
|
||||
public PermissionTypes.PermissionMode getCurrentMode() { |
||||
return currentMode; |
||||
} |
||||
|
||||
public void setCurrentMode(PermissionTypes.PermissionMode mode) { |
||||
this.currentMode = mode; |
||||
userData.permissions.mode = mode; |
||||
saveToFile(userSettingsPath, userData); |
||||
} |
||||
|
||||
/** 获取所有已保存规则的可读列表 */ |
||||
public List<String> listRules() { |
||||
var result = new ArrayList<String>(); |
||||
for (var r : userData.permissions.alwaysAllow) { |
||||
result.add("[user] ALLOW " + r); |
||||
} |
||||
for (var r : userData.permissions.alwaysDeny) { |
||||
result.add("[user] DENY " + r); |
||||
} |
||||
for (var r : projectData.permissions.alwaysAllow) { |
||||
result.add("[proj] ALLOW " + r); |
||||
} |
||||
for (var r : projectData.permissions.alwaysDeny) { |
||||
result.add("[proj] DENY " + r); |
||||
} |
||||
for (var r : sessionRules) { |
||||
result.add("[sess] " + r.behavior() + " " + formatRule(r)); |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
// ── 内部方法 ──
|
||||
|
||||
private SettingsData loadFromFile(Path path) { |
||||
if (!Files.exists(path)) return new SettingsData(); |
||||
try { |
||||
return MAPPER.readValue(path.toFile(), SettingsData.class); |
||||
} catch (IOException e) { |
||||
return new SettingsData(); |
||||
} |
||||
} |
||||
|
||||
private void saveToFile(Path path, SettingsData data) { |
||||
try { |
||||
Files.createDirectories(path.getParent()); |
||||
MAPPER.writeValue(path.toFile(), data); |
||||
} catch (IOException e) { |
||||
// 静默失败,不影响主流程
|
||||
} |
||||
} |
||||
|
||||
private List<PermissionRule> toRules(List<String> ruleStrings, PermissionBehavior behavior) { |
||||
return ruleStrings.stream() |
||||
.map(s -> parseRule(s, behavior)) |
||||
.toList(); |
||||
} |
||||
|
||||
/** 解析规则字符串,格式: "ToolName(pattern)" 或 "ToolName" */ |
||||
static PermissionRule parseRule(String ruleStr, PermissionBehavior behavior) { |
||||
int parenStart = ruleStr.indexOf('('); |
||||
if (parenStart > 0 && ruleStr.endsWith(")")) { |
||||
String toolName = ruleStr.substring(0, parenStart); |
||||
String content = ruleStr.substring(parenStart + 1, ruleStr.length() - 1); |
||||
return new PermissionRule(toolName, content, behavior); |
||||
} |
||||
return PermissionRule.forTool(ruleStr, behavior); |
||||
} |
||||
|
||||
/** 格式化规则为字符串 */ |
||||
static String formatRule(PermissionRule rule) { |
||||
if ("*".equals(rule.ruleContent())) { |
||||
return rule.toolName(); |
||||
} |
||||
return rule.toolName() + "(" + rule.ruleContent() + ")"; |
||||
} |
||||
|
||||
// ── JSON 数据结构 ──
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true) |
||||
static class SettingsData { |
||||
public PermissionsBlock permissions = new PermissionsBlock(); |
||||
} |
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true) |
||||
static class PermissionsBlock { |
||||
public PermissionTypes.PermissionMode mode; |
||||
public List<String> alwaysAllow = new ArrayList<>(); |
||||
public List<String> alwaysDeny = new ArrayList<>(); |
||||
public List<String> additionalDirectories = new ArrayList<>(); |
||||
} |
||||
} |
||||
@ -0,0 +1,110 @@ |
||||
package com.claudecode.permission; |
||||
|
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 权限管理类型定义 —— 对应 claude-code 中的 permissions.ts。 |
||||
*/ |
||||
public final class PermissionTypes { |
||||
|
||||
private PermissionTypes() {} |
||||
|
||||
/** 权限行为 */ |
||||
public enum PermissionBehavior { |
||||
ALLOW, // 允许执行
|
||||
DENY, // 拒绝执行
|
||||
ASK // 需要用户确认
|
||||
} |
||||
|
||||
/** 权限模式 */ |
||||
public enum PermissionMode { |
||||
/** 默认模式:非只读工具需要用户确认 */ |
||||
DEFAULT, |
||||
/** 自动允许文件编辑,shell 命令仍需确认 */ |
||||
ACCEPT_EDITS, |
||||
/** 跳过所有权限检查(不安全) */ |
||||
BYPASS, |
||||
/** 自动拒绝而非询问用户(无头模式) */ |
||||
DONT_ASK |
||||
} |
||||
|
||||
/** |
||||
* 权限规则 —— 定义工具和命令模式的权限行为。 |
||||
* <p> |
||||
* 示例: |
||||
* <ul> |
||||
* <li>{@code PermissionRule("Bash", "npm:*", ALLOW)} — 允许所有 npm 命令</li> |
||||
* <li>{@code PermissionRule("Bash", "rm -rf:*", DENY)} — 拒绝 rm -rf</li> |
||||
* <li>{@code PermissionRule("Write", "*", ALLOW)} — 允许所有文件写入</li> |
||||
* </ul> |
||||
* |
||||
* @param toolName 工具名称(如 Bash, Write, Edit) |
||||
* @param ruleContent 规则内容,支持通配符 *(如 "npm:*", "git:*", "*") |
||||
* @param behavior 权限行为 |
||||
*/ |
||||
public record PermissionRule( |
||||
String toolName, |
||||
String ruleContent, |
||||
PermissionBehavior behavior |
||||
) { |
||||
/** 匹配整个工具(无命令模式限制) */ |
||||
public static PermissionRule forTool(String toolName, PermissionBehavior behavior) { |
||||
return new PermissionRule(toolName, "*", behavior); |
||||
} |
||||
|
||||
/** 匹配工具的特定命令前缀 */ |
||||
public static PermissionRule forCommand(String toolName, String prefix, PermissionBehavior behavior) { |
||||
return new PermissionRule(toolName, prefix + ":*", behavior); |
||||
} |
||||
} |
||||
|
||||
/** 权限决策结果 */ |
||||
public record PermissionDecision( |
||||
PermissionBehavior behavior, |
||||
String reason, |
||||
String toolName, |
||||
String commandPrefix, |
||||
List<PermissionRule> suggestedRules |
||||
) { |
||||
public static PermissionDecision allow(String reason) { |
||||
return new PermissionDecision(PermissionBehavior.ALLOW, reason, null, null, List.of()); |
||||
} |
||||
|
||||
public static PermissionDecision deny(String reason) { |
||||
return new PermissionDecision(PermissionBehavior.DENY, reason, null, null, List.of()); |
||||
} |
||||
|
||||
public static PermissionDecision ask(String toolName, String commandPrefix) { |
||||
// 生成建议规则供用户选择 "always allow"
|
||||
var suggested = List.of( |
||||
PermissionRule.forCommand(toolName, commandPrefix, PermissionBehavior.ALLOW) |
||||
); |
||||
return new PermissionDecision(PermissionBehavior.ASK, "Requires user confirmation", |
||||
toolName, commandPrefix, suggested); |
||||
} |
||||
|
||||
public boolean isAllowed() { |
||||
return behavior == PermissionBehavior.ALLOW; |
||||
} |
||||
|
||||
public boolean isDenied() { |
||||
return behavior == PermissionBehavior.DENY; |
||||
} |
||||
|
||||
public boolean needsAsk() { |
||||
return behavior == PermissionBehavior.ASK; |
||||
} |
||||
} |
||||
|
||||
/** 权限确认选项(用户在 UI 中的选择) */ |
||||
public enum PermissionChoice { |
||||
/** 允许本次执行 */ |
||||
ALLOW_ONCE, |
||||
/** 始终允许此模式 */ |
||||
ALWAYS_ALLOW, |
||||
/** 拒绝本次执行 */ |
||||
DENY_ONCE, |
||||
/** 始终拒绝此模式 */ |
||||
ALWAYS_DENY |
||||
} |
||||
} |
||||
Loading…
Reference in new issue