Skip to content

Commit 59078c6

Browse files
committed
feat: route Claude lifecycle events to bounded Rust checks
1 parent 676ddc3 commit 59078c6

12 files changed

Lines changed: 363 additions & 44 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ Supply the original checker's required tool/configuration options to `task verif
160160
| `plan CATEGORY LANGUAGE` | Preview selections and gaps | Not a certified execution plan |
161161
| `hook plan` | Route a versioned host event to a candidate check tier | Reads bounded JSON on stdin; exit 3, no check or host blocking |
162162
| `hook execute` | Run read-only discovery, bounded Stop guidance, task-bound recheck, selected Python Ruff feedback, or a live pre-commit index safety preview | Explicit timeout; repair reuses `task verify` and never closes a task or approves delivery |
163-
| `hook claude post-tool-use` | Convert a successful Claude Code file-tool event into bounded Rust edit feedback | Candidate soft Hook; no plugin runtime binding or delivery gate yet |
163+
| `hook claude <session-start\|post-tool-use\|post-tool-use-failure\|stop>` | Map Claude Code lifecycle events to read-only discovery, bounded edit feedback, no-check failure feedback, or local next-step guidance | Candidate soft Hooks; Stop offers one task continuation at most; no plugin runtime binding or delivery gate yet |
164164
| `lint python / java / typescript / go` | Run selected native checks | Adapter-specific options and scope |
165165
| `comments rust`, `build rust` | Documentation and type checking | Build does not run project tests |
166166
| `cve rust / python / typescript` | Native advisory observations | Database identity/freshness and full coverage remain limited |

‎README.zh-CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ codeguard task verify "$TASK_ID" . --format json
159159
| `tools list / verify`、`doctor` | 查看工具和有限环境探测 | 显式 Ruff doctor 探测;`tools install --apply` 受阻 |
160160
| `plan CATEGORY LANGUAGE` | 预览选择和缺口 | 不是已认证执行计划 |
161161
| `hook execute` | 启动只读发现、Stop 有界下一步、按任务原工具复检、Python 编辑局部反馈及显式 Git 工具的提交面安全预览 | 必须给超时;复检不自动关闭任务,推送/CI 仍未接线,不构成宿主交付门禁 |
162-
| `hook claude post-tool-use` | 将 Claude Code 成功文件工具事件转换为有界 Rust 编辑反馈 | 候选软 Hook;尚无插件二进制绑定或交付门禁 |
162+
| `hook claude <session-start\|post-tool-use\|post-tool-use-failure\|stop>` | 将 Claude Code 生命周期事件映射为只读发现、局部编辑反馈、失败不检查或本地下一步指引 | 候选软 Hook;Stop 最多引导一次继续;尚无插件二进制绑定或交付门禁 |
163163
| `lint python / java / typescript / go` | 执行已接入原生检查 | 参数和范围因适配器而异 |
164164
| `comments rust`、`build rust` | 文档与类型检查 | build 不运行项目测试 |
165165
| `cve rust / python / typescript` | 原生漏洞公告观察 | 漏洞库身份、时效及完整覆盖仍有限 |

‎crates/codeguard-cli/src/claude_hook_command.rs‎

Lines changed: 209 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Claude Code 文件工具事件到 Rust 快检的受限转换;只回传脱敏对话摘要。
1+
//! Claude Code 生命周期事件到 Rust 路由的受限转换;只回传脱敏对话摘要。
22
33
use crate::hook_execute_command::execute_host_input;
44
use codeguard_core::{HookEvent, HookTriggerInput, HookWriteOutcome};
@@ -11,21 +11,28 @@ use std::process::ExitCode;
1111
const MAX_HOST_INPUT_BYTES: u64 = 1024 * 1024;
1212
const MAX_CONTEXT_CHARS: usize = 1200;
1313

14-
/// 从 Claude Code stdin 读取成功的文件工具事件并注入局部检查摘要。
14+
/// 从 Claude Code stdin 读取生命周期事件并注入受限检查摘要。
1515
/// 参数包含项目根和原 `hook execute` 的显式选项;返回宿主退出码而非质量结论。
1616
pub fn run(args: &[String]) -> ExitCode {
1717
let [event, rest @ ..] = args else {
1818
eprintln!("需要 Claude Code 事件与项目路径");
1919
return ExitCode::from(2);
2020
};
21-
if event != "post-tool-use" || rest.first().is_none_or(|value| value.starts_with('-')) {
22-
eprintln!("目前仅支持 post-tool-use PATH --timeout DURATION --format=json");
21+
let Some((host_event, route_event)) = mapped_event(event) else {
22+
eprintln!("支持 session-start、post-tool-use、post-tool-use-failure、stop");
23+
return ExitCode::from(2);
24+
};
25+
if rest.first().is_none_or(|value| value.starts_with('-')) {
26+
eprintln!("需要 PATH --timeout DURATION --format=json");
2327
return ExitCode::from(2);
2428
}
2529
let root = match Path::new(&rest[0]).canonicalize() {
2630
Ok(root) if root.is_dir() => root,
2731
_ => {
28-
print_context("范围未确定:项目根不可读取;本次源码检查未运行,交付未评估。");
32+
print_context(
33+
host_event,
34+
"范围未确定:项目根不可读取;本次源码检查未运行,交付未评估。",
35+
);
2936
return ExitCode::SUCCESS;
3037
}
3138
};
@@ -38,41 +45,135 @@ pub fn run(args: &[String]) -> ExitCode {
3845
|| raw.is_empty()
3946
|| raw.len() as u64 > MAX_HOST_INPUT_BYTES
4047
{
41-
print_context("范围未确定:宿主事件缺失或超出输入预算;本次源码检查未运行,交付未评估。");
48+
print_context(
49+
host_event,
50+
"范围未确定:宿主事件缺失或超出输入预算;本次源码检查未运行,交付未评估。",
51+
);
4252
return ExitCode::SUCCESS;
4353
}
4454
let host = match codeguard_adapters::parse_unique_json(&raw) {
4555
Ok(host) => host,
4656
Err(_) => {
47-
print_context("范围未确定:宿主事件格式无效;本次源码检查未运行,交付未评估。");
48-
return ExitCode::SUCCESS;
49-
}
50-
};
51-
let path = match selected_file(&root, &host) {
52-
Some(path) => path,
53-
None => {
5457
print_context(
55-
"范围未确定:成功编辑事件没有可核对的项目内普通文件;本次源码检查未运行,交付未评估。",
58+
host_event,
59+
"范围未确定:宿主事件格式无效;本次源码检查未运行,交付未评估。",
5660
);
5761
return ExitCode::SUCCESS;
5862
}
5963
};
64+
if host["hook_event_name"] != host_event || !valid_cwd(&root, &host) {
65+
print_context(
66+
host_event,
67+
"范围未确定:宿主事件或工作目录不匹配;本次源码检查未运行,交付未评估。",
68+
);
69+
return ExitCode::SUCCESS;
70+
}
71+
if route_event == HookEvent::SessionStart
72+
&& !matches!(
73+
host["source"].as_str(),
74+
Some("startup" | "resume" | "clear" | "compact" | "fork")
75+
)
76+
{
77+
print_context(
78+
host_event,
79+
"范围未确定:会话来源无效;只读发现未运行,交付未评估。",
80+
);
81+
return ExitCode::SUCCESS;
82+
}
83+
if route_event == HookEvent::FileChanged
84+
&& host_event == "PostToolUseFailure"
85+
&& (!matches!(
86+
host["tool_name"].as_str(),
87+
Some("Write" | "Edit" | "MultiEdit")
88+
) || !host["tool_input"].is_object()
89+
|| !host["error"].is_string())
90+
{
91+
print_context(
92+
host_event,
93+
"范围未确定:失败编辑事件无效;本次源码检查未运行,交付未评估。",
94+
);
95+
return ExitCode::SUCCESS;
96+
}
97+
let path = if host_event == "PostToolUse" {
98+
match selected_file(&root, &host) {
99+
Some(path) => Some(path),
100+
None => {
101+
print_context(
102+
host_event,
103+
"范围未确定:成功编辑事件没有可核对的项目内普通文件;本次源码检查未运行,交付未评估。",
104+
);
105+
return ExitCode::SUCCESS;
106+
}
107+
}
108+
} else {
109+
None
110+
};
111+
if route_event == HookEvent::Stop && !host["stop_hook_active"].is_boolean() {
112+
print_context(
113+
host_event,
114+
"范围未确定:Stop 状态缺失;只读指引未运行,交付未评估。",
115+
);
116+
return ExitCode::SUCCESS;
117+
}
60118
let input = HookTriggerInput {
61-
event: HookEvent::FileChanged,
62-
changed_paths: vec![path.clone()],
119+
event: route_event,
120+
changed_paths: path.iter().cloned().collect(),
63121
task_id: None,
64-
write_outcome: HookWriteOutcome::Confirmed,
122+
write_outcome: if host_event == "PostToolUseFailure" {
123+
HookWriteOutcome::Failed
124+
} else {
125+
HookWriteOutcome::Confirmed
126+
},
65127
host_claims_blocking: false,
66128
};
67129
match execute_host_input(rest, &input) {
68-
Ok((report, _)) => print_context(&summarize(&path, &report)),
69-
Err(_) => {
70-
print_context("CodeGuard:宿主事件参数或路由无效;本次源码检查未运行,交付未评估。")
130+
Ok((report, _)) => {
131+
let context = match route_event {
132+
HookEvent::SessionStart => summarize_discovery(&report),
133+
HookEvent::Stop => summarize_stop(&report, host["stop_hook_active"] == true),
134+
HookEvent::FileChanged if host_event == "PostToolUseFailure" => {
135+
"CodeGuard:写入失败,本次源码检查未运行;请先处理工具错误,交付未评估。"
136+
.to_owned()
137+
}
138+
HookEvent::FileChanged => {
139+
summarize(path.as_deref().unwrap_or("<changed-file>"), &report)
140+
}
141+
_ => "CodeGuard:宿主事件未运行,交付未评估。".to_owned(),
142+
};
143+
if route_event == HookEvent::Stop {
144+
print_stop_context(
145+
&context,
146+
safe_task_id(&report).is_some() && host["stop_hook_active"] == false,
147+
);
148+
} else {
149+
print_context(host_event, &context);
150+
}
71151
}
152+
Err(_) => print_context(
153+
host_event,
154+
"CodeGuard:宿主事件参数或路由无效;本次源码检查未运行,交付未评估。",
155+
),
72156
}
73157
ExitCode::SUCCESS
74158
}
75159

160+
fn mapped_event(event: &str) -> Option<(&'static str, HookEvent)> {
161+
match event {
162+
"session-start" => Some(("SessionStart", HookEvent::SessionStart)),
163+
"post-tool-use" => Some(("PostToolUse", HookEvent::FileChanged)),
164+
"post-tool-use-failure" => Some(("PostToolUseFailure", HookEvent::FileChanged)),
165+
"stop" => Some(("Stop", HookEvent::Stop)),
166+
_ => None,
167+
}
168+
}
169+
170+
fn valid_cwd(root: &Path, host: &Value) -> bool {
171+
host["cwd"].as_str().is_some_and(|cwd| {
172+
let cwd = Path::new(cwd);
173+
cwd.is_absolute() && cwd.canonicalize().is_ok_and(|cwd| cwd.starts_with(root))
174+
})
175+
}
176+
76177
fn selected_file(root: &Path, host: &Value) -> Option<String> {
77178
if host["hook_event_name"] != "PostToolUse"
78179
|| !matches!(
@@ -82,10 +183,6 @@ fn selected_file(root: &Path, host: &Value) -> Option<String> {
82183
{
83184
return None;
84185
}
85-
let cwd = Path::new(host["cwd"].as_str()?);
86-
if !cwd.is_absolute() || !cwd.canonicalize().ok()?.starts_with(root) {
87-
return None;
88-
}
89186
let path = PathBuf::from(host["tool_input"]["file_path"].as_str()?);
90187
if !host["tool_response"].is_object() {
91188
return None;
@@ -106,6 +203,63 @@ fn selected_file(root: &Path, host: &Value) -> Option<String> {
106203
Some(relative.to_owned())
107204
}
108205

206+
fn summarize_discovery(report: &Value) -> String {
207+
if report["execution"] != "read_only_discovery" {
208+
return "CodeGuard:会话启动只读发现未运行;源码检查未运行,交付未评估。".into();
209+
}
210+
let languages = report["local_feedback"]["languages"]
211+
.as_array()
212+
.into_iter()
213+
.flat_map(|items| items.iter())
214+
.filter_map(Value::as_str)
215+
.filter(|language| {
216+
language.len() <= 24
217+
&& language
218+
.bytes()
219+
.all(|byte| byte.is_ascii_lowercase() || byte == b'-')
220+
})
221+
.take(8)
222+
.collect::<Vec<_>>();
223+
let languages = if languages.is_empty() {
224+
"未识别".to_owned()
225+
} else {
226+
languages.join(", ")
227+
};
228+
let count = report["local_feedback"]["checker_count"]
229+
.as_u64()
230+
.unwrap_or(0);
231+
let complete = report["local_feedback"]["observation_complete"] == true;
232+
let status = if complete { "完成" } else { "不完整" };
233+
format!(
234+
"CodeGuard:会话启动只读发现{status};语言 {languages},检查器配置观察 {count} 项。源码检查未运行,交付未评估。"
235+
)
236+
}
237+
238+
fn safe_task_id(report: &Value) -> Option<&str> {
239+
report["local_feedback"]["task_id"].as_str().filter(|task| {
240+
task.len() <= 64
241+
&& task.starts_with("CG-")
242+
&& task[3..]
243+
.bytes()
244+
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
245+
})
246+
}
247+
248+
fn summarize_stop(report: &Value, already_continuing: bool) -> String {
249+
if report["execution"] != "read_only_guidance" {
250+
return "CodeGuard:结束时只读指引未运行;请显式执行 codeguard next . --format=json,交付未评估。".into();
251+
}
252+
match safe_task_id(report) {
253+
Some(task) if !already_continuing => format!(
254+
"CodeGuard:待处理任务 {task};请读取 codeguard next . --format=json 并按原检查器复检。结束指引不运行源码检查,交付未评估。"
255+
),
256+
Some(task) => format!(
257+
"CodeGuard:待处理任务 {task};本轮已在 Stop 后继续,避免重复唤醒。结束指引不运行源码检查,交付未评估。"
258+
),
259+
None => "CodeGuard:没有可展示的待处理任务不代表检查通过;交付前仍需新鲜完整检查。结束指引不运行源码检查,交付未评估。".into(),
260+
}
261+
}
262+
109263
fn summarize(path: &str, report: &Value) -> String {
110264
let label = if path.len() <= 128
111265
&& path
@@ -171,15 +325,35 @@ fn summarize(path: &str, report: &Value) -> String {
171325
summary.chars().take(MAX_CONTEXT_CHARS).collect()
172326
}
173327

174-
fn print_context(context: &str) {
175-
println!(
176-
"{}",
177-
json!({
178-
"hookSpecificOutput": {
179-
"hookEventName":"PostToolUse",
180-
"additionalContext":context
181-
},
182-
"systemMessage":"CodeGuard 局部检查反馈"
183-
})
184-
);
328+
fn print_context(event: &str, context: &str) {
329+
if event == "Stop" {
330+
println!("{}", json!({"systemMessage":context}));
331+
} else {
332+
let label = match event {
333+
"SessionStart" => "CodeGuard 项目发现反馈",
334+
"PostToolUseFailure" => "CodeGuard 写入失败反馈",
335+
_ => "CodeGuard 局部检查反馈",
336+
};
337+
println!(
338+
"{}",
339+
json!({
340+
"hookSpecificOutput": {"hookEventName":event,"additionalContext":context},
341+
"systemMessage":label
342+
})
343+
);
344+
}
345+
}
346+
347+
fn print_stop_context(context: &str, continue_once: bool) {
348+
if continue_once {
349+
println!(
350+
"{}",
351+
json!({
352+
"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":context},
353+
"systemMessage":"CodeGuard 待处理任务指引"
354+
})
355+
);
356+
} else {
357+
println!("{}", json!({"systemMessage":context}));
358+
}
185359
}

‎crates/codeguard-cli/src/main.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ fn main() -> ExitCode {
3333
"局部事件执行:hook execute PATH --timeout DURATION --format=json [--ruff-tool ABS_PATH] [--git-tool ABS_PATH] 从 stdin 读取同一事件协议;启动只发现、Stop 有界只读提示、repair_ready 复用 task verify 原工具复检、确认 Python 编辑调用 Ruff 局部快检、显式 Git 工具的 pre_commit 观察真实 index;完整交付始终 not_evaluated。"
3434
);
3535
println!(
36-
"Claude 候选软 Hook:hook claude post-tool-use PATH --timeout DURATION --format=json [--ruff-tool ABS_PATH] 从 stdin 读取成功文件工具事件,规范化项目内路径后复用 Rust 局部执行器;输出有界 additionalContext,宿主退出 0 不表示检查或交付通过。插件尚未自动接线。"
36+
"Claude 候选软 Hook:hook claude <session-start|post-tool-use|post-tool-use-failure|stop> PATH --timeout DURATION --format=json [--ruff-tool ABS_PATH] 从 stdin 读取宿主事件;启动只读发现,成功保存局部 Ruff,失败保存不检查,Stop 有界读取下一步;输出脱敏反馈,宿主退出 0 不表示检查或交付通过。插件尚未自动接线。"
3737
);
3838
println!(
3939
"check all 的 Python CVE 节点可用 --pip-audit-tool ABS_PATH --pip-audit-version VERSION;逐构建根反馈原生结果或锁文件/工具阻塞,不签发零漏洞结论。"

0 commit comments

Comments
 (0)