Skip to content

Commit d8a8275

Browse files
committed
完善 Hook 定向复检与运行时终止分类
1 parent af2282a commit d8a8275

22 files changed

Lines changed: 690 additions & 21 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ Supply the original checker's required tool/configuration options to `task verif
159159
| `tools list / verify`, `doctor` | Inspect tools and limited environment probes | Explicit Ruff doctor probe; `tools install --apply` is blocked |
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 |
162-
| `hook execute` | Run read-only discovery, bounded Stop guidance, selected Python Ruff feedback, or a live pre-commit index safety preview | Explicit timeout and Git tool path for index observation; no host delivery gate |
162+
| `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 |
163163
| `lint python / java / typescript / go` | Run selected native checks | Adapter-specific options and scope |
164164
| `comments rust`, `build rust` | Documentation and type checking | Build does not run project tests |
165165
| `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
@@ -158,7 +158,7 @@ codeguard task verify "$TASK_ID" . --format json
158158
| `config validate / explain`、`rules list` | 查看配置和规则来源 | 候选不构成策略批准 |
159159
| `tools list / verify`、`doctor` | 查看工具和有限环境探测 | 显式 Ruff doctor 探测;`tools install --apply` 受阻 |
160160
| `plan CATEGORY LANGUAGE` | 预览选择和缺口 | 不是已认证执行计划 |
161-
| `hook execute` | 启动只读发现、Stop 有界下一步、Python 编辑局部反馈及显式 Git 工具的提交面安全预览 | 必须给超时;修复/推送/CI 仍未接线,不构成宿主交付门禁 |
161+
| `hook execute` | 启动只读发现、Stop 有界下一步、按任务原工具复检、Python 编辑局部反馈及显式 Git 工具的提交面安全预览 | 必须给超时;复检不自动关闭任务,推送/CI 仍未接线,不构成宿主交付门禁 |
162162
| `lint python / java / typescript / go` | 执行已接入原生检查 | 参数和范围因适配器而异 |
163163
| `comments rust`、`build rust` | 文档与类型检查 | build 不运行项目测试 |
164164
| `cve rust / python / typescript` | 原生漏洞公告观察 | 漏洞库身份、时效及完整覆盖仍有限 |

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

Lines changed: 269 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
//! 宿主事件的局部执行入口;接入只读发现、Stop 指引、Python 编辑快检和暂存面观察。
1+
//! 宿主事件的局部执行入口;接入只读发现、Stop 指引、任务复检、编辑快检和暂存面观察。
22
33
use crate::check_budget::parse_check_timeout;
44
use crate::discovery::discover;
55
use crate::git_index_safety::observe_index_safety_with_deadline;
66
use crate::hook_plan_command::parse_request;
7-
use crate::next_command::read_local_brief;
7+
use crate::next_command::{read_local_brief, read_task_brief};
88
use crate::python_lint_command::{
99
annotate_conversation_budget, scan_selected_report_with_deadline,
1010
};
1111
use codeguard_adapters::legacy_registry;
1212
use codeguard_core::{HookTriggerAction, plan_hook_trigger};
13-
use codeguard_runtime::NativeObservation;
13+
use codeguard_runtime::{NativeObservation, ProcessSpec, Termination, run_process};
1414
use serde_json::{Value, json};
15+
use std::collections::BTreeMap;
1516
use std::env;
17+
use std::ffi::OsString;
1618
use std::fs;
1719
use std::io::Read;
1820
use std::path::{Path, PathBuf};
@@ -24,12 +26,17 @@ const MAX_REQUEST_BYTES: u64 = 64 * 1024;
2426
const MAX_FAST_TIMEOUT_MS: u64 = 120_000;
2527
const MAX_GUIDANCE_RECORDS: usize = 64;
2628
const MAX_GUIDANCE_REPORT_BYTES: u64 = 8 * 1024 * 1024;
29+
const MAX_VERIFY_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
30+
const MAX_VERIFY_OPTION_BYTES: usize = 16 * 1024;
31+
const MAX_VERIFY_EVENTS: usize = 128;
32+
const MAX_VERIFY_EVENT_BYTES: u64 = 1024 * 1024;
2733

2834
struct Arguments {
2935
root: PathBuf,
3036
timeout_ms: u64,
3137
ruff_tool: Option<PathBuf>,
3238
git_tool: Option<PathBuf>,
39+
verify_options: BTreeMap<String, String>,
3340
}
3441

3542
/// 从 `args` 与 stdin 读取宿主事件,按纯路由执行已接入的局部动作。
@@ -72,6 +79,10 @@ pub fn run(args: &[String]) -> ExitCode {
7279
return ExitCode::from(2);
7380
}
7481
};
82+
if plan.action != HookTriggerAction::VerifyTask && !arguments.verify_options.is_empty() {
83+
eprintln!("任务复检参数仅用于 repair_ready 事件");
84+
return ExitCode::from(2);
85+
}
7586
let root = arguments
7687
.root
7788
.canonicalize()
@@ -90,6 +101,13 @@ pub fn run(args: &[String]) -> ExitCode {
90101
}
91102
Err(_) => ("not_run", Some("guidance_unavailable"), Value::Null, 3),
92103
},
104+
(HookTriggerAction::VerifyTask, Some(root)) => {
105+
let task_id = plan.task_id.as_deref().expect("核心已校验任务 ID");
106+
match task_verification_summary(root, task_id, &arguments) {
107+
Ok(feedback) => ("task_verification", None, feedback, 3),
108+
Err((reason, exit_code)) => ("not_run", Some(reason), Value::Null, exit_code),
109+
}
110+
}
93111
(HookTriggerAction::FastFileCheck, Some(root))
94112
if plan.target_paths.iter().all(|path| path.ends_with(".py")) =>
95113
{
@@ -192,7 +210,7 @@ pub fn run(args: &[String]) -> ExitCode {
192210
println!(
193211
"{}",
194212
json!({
195-
"schema_version":"0.3.0", "report_type":"hook_execution_feedback",
213+
"schema_version":"0.4.0", "report_type":"hook_execution_feedback",
196214
"plan":plan, "execution":execution, "reason":reason,
197215
"local_feedback":feedback, "delivery_decision":"not_evaluated",
198216
"host_blocking_verified":false, "soft_result_reused":false
@@ -201,6 +219,192 @@ pub fn run(args: &[String]) -> ExitCode {
201219
ExitCode::from(exit_code)
202220
}
203221

222+
fn task_verification_summary(
223+
root: &Path,
224+
task_id: &str,
225+
arguments: &Arguments,
226+
) -> Result<Value, (&'static str, u8)> {
227+
let deadline = Instant::now() + Duration::from_millis(arguments.timeout_ms);
228+
check_verification_history_budget(root, task_id)?;
229+
let brief = read_task_brief(root, task_id).map_err(|_| ("task_unavailable", 3))?;
230+
let checker_id = brief["checker_id"]
231+
.as_str()
232+
.ok_or(("task_unavailable", 3))?;
233+
if (arguments.ruff_tool.is_some()
234+
&& !matches!(checker_id, "python.ruff" | "python.ruff.doctor"))
235+
|| arguments
236+
.verify_options
237+
.keys()
238+
.any(|key| !verify_option_matches_checker(key, checker_id))
239+
{
240+
return Err(("verification_arguments_invalid", 3));
241+
}
242+
let executable = env::current_exe().map_err(|_| ("verification_process_failed", 4))?;
243+
let remaining_ms = deadline
244+
.saturating_duration_since(Instant::now())
245+
.as_millis();
246+
if remaining_ms == 0 {
247+
return Err(("request_deadline_exceeded", 3));
248+
}
249+
let mut args = vec![
250+
OsString::from("task"),
251+
OsString::from("verify"),
252+
OsString::from(task_id),
253+
root.as_os_str().to_owned(),
254+
OsString::from("--timeout"),
255+
OsString::from(format!("{remaining_ms}ms")),
256+
OsString::from("--format=json"),
257+
];
258+
if let Some(ruff_tool) = &arguments.ruff_tool {
259+
args.extend([
260+
OsString::from("--ruff-tool"),
261+
ruff_tool.as_os_str().to_owned(),
262+
]);
263+
}
264+
for (key, value) in &arguments.verify_options {
265+
args.extend([OsString::from(key), OsString::from(value)]);
266+
}
267+
let mut child_env = BTreeMap::new();
268+
for key in [
269+
"PATH",
270+
"HOME",
271+
"TMPDIR",
272+
"CARGO_HOME",
273+
"RUSTUP_HOME",
274+
"JAVA_HOME",
275+
] {
276+
if let Some(value) = env::var_os(key) {
277+
child_env.insert(OsString::from(key), value);
278+
}
279+
}
280+
let outcome = run_process(
281+
&ProcessSpec {
282+
executable,
283+
args,
284+
cwd: root.to_path_buf(),
285+
env: child_env,
286+
stdin: None,
287+
deadline,
288+
output_limit_bytes: MAX_VERIFY_OUTPUT_BYTES,
289+
},
290+
&AtomicBool::new(false),
291+
);
292+
match outcome.termination {
293+
Termination::Exited(3) => {}
294+
Termination::Exited(2) => return Err(("verification_arguments_invalid", 3)),
295+
Termination::Exited(130) | Termination::Cancelled => {
296+
return Err(("request_cancelled", 130));
297+
}
298+
Termination::TimedOut | Termination::DeadlineBeforeStart => {
299+
return Err(("request_deadline_exceeded", 3));
300+
}
301+
Termination::OutputLimit => return Err(("verification_output_exceeded", 3)),
302+
_ => return Err(("verification_process_failed", 4)),
303+
}
304+
let report: Value =
305+
serde_json::from_slice(&outcome.stdout).map_err(|_| ("verification_report_invalid", 4))?;
306+
if report["report_type"] != "task_verification_preview"
307+
|| report["operation"] != "task_verify"
308+
|| report["task_id"] != task_id
309+
|| report["exit_code"] != 3
310+
|| report["authority"] != "local_unverified"
311+
|| report["delivery_decision"] != "not_evaluated"
312+
|| !report["event_persisted"].is_boolean()
313+
{
314+
return Err(("verification_report_invalid", 4));
315+
}
316+
let observation = report["observation"]
317+
.as_str()
318+
.filter(|value| value.len() <= 64)
319+
.ok_or(("verification_report_invalid", 4))?;
320+
let reason = report["reason"].as_str().filter(|value| value.len() <= 128);
321+
if !report["reason"].is_null() && reason.is_none() {
322+
return Err(("verification_report_invalid", 4));
323+
}
324+
Ok(json!({
325+
"schema_version":"0.1.0", "report_type":"hook_task_verification_summary",
326+
"task_id":task_id, "checker_id":checker_id,
327+
"observation":observation, "event_persisted":report["event_persisted"],
328+
"reason":reason, "scan_report_available":report["native_scan"].is_object(),
329+
"authority":"local_unverified", "delivery_decision":"not_evaluated"
330+
}))
331+
}
332+
333+
fn check_verification_history_budget(root: &Path, task_id: &str) -> Result<(), (&'static str, u8)> {
334+
let events = root
335+
.join(".codeguard/findings")
336+
.join(task_id)
337+
.join("events");
338+
if !events.exists() {
339+
return Ok(());
340+
}
341+
if !fs::symlink_metadata(&events).is_ok_and(|metadata| metadata.file_type().is_dir()) {
342+
return Err(("task_unavailable", 3));
343+
}
344+
let mut total_bytes = 0_u64;
345+
for (index, entry) in fs::read_dir(events)
346+
.map_err(|_| ("task_unavailable", 3))?
347+
.enumerate()
348+
{
349+
if index >= MAX_VERIFY_EVENTS {
350+
return Err(("verification_scope_exceeded", 3));
351+
}
352+
let entry = entry.map_err(|_| ("task_unavailable", 3))?;
353+
let metadata = fs::symlink_metadata(entry.path()).map_err(|_| ("task_unavailable", 3))?;
354+
if !metadata.file_type().is_file() {
355+
return Err(("task_unavailable", 3));
356+
}
357+
total_bytes = total_bytes.saturating_add(metadata.len());
358+
if total_bytes > MAX_VERIFY_EVENT_BYTES {
359+
return Err(("verification_scope_exceeded", 3));
360+
}
361+
}
362+
Ok(())
363+
}
364+
365+
fn verify_option_matches_checker(key: &str, checker_id: &str) -> bool {
366+
if matches!(key, "--owner" | "--lease-token") {
367+
return true;
368+
}
369+
match checker_id {
370+
"node.eslint" | "node.eslint.preparation" => matches!(
371+
key,
372+
"--node-tool" | "--eslint-entry" | "--eslint-version" | "--config" | "--cwd"
373+
),
374+
"node.npm.audit" => matches!(
375+
key,
376+
"--node-tool"
377+
| "--npm-entry"
378+
| "--npm-version"
379+
| "--userconfig"
380+
| "--globalconfig"
381+
| "--registry"
382+
),
383+
"python.pip_audit" => matches!(key, "--pip-audit-tool" | "--pip-audit-version"),
384+
"go.vet" => key == "--go-tool",
385+
"rust.cargo_clippy" | "rust.cargo_check" | "rust.cargo_rustdoc" => key == "--cargo-tool",
386+
"rust.cargo_audit" => matches!(key, "--cargo-audit-tool" | "--rustsec-db"),
387+
"java.checkstyle" | "java.checkstyle.preparation" => {
388+
matches!(key, "--java-tool" | "--checkstyle-jar" | "--config")
389+
}
390+
"java.maven.p3c" => matches!(
391+
key,
392+
"--maven-tool" | "--java-home" | "--maven-repo" | "--repo-sha256"
393+
),
394+
"java.maven.dependency_check" => matches!(
395+
key,
396+
"--maven-tool"
397+
| "--java-home"
398+
| "--maven-repo"
399+
| "--repo-sha256"
400+
| "--cve-data-dir"
401+
| "--cve-data-sha256"
402+
),
403+
"python.ruff" | "python.ruff.doctor" => false,
404+
_ => false,
405+
}
406+
}
407+
204408
fn guidance_summary(root: &Path) -> Result<Value, &'static str> {
205409
let workspace = root.join(".codeguard");
206410
if workspace.exists() {
@@ -296,6 +500,8 @@ fn parse_args(args: &[String]) -> Result<Arguments, String> {
296500
let mut timeout_ms = None;
297501
let mut ruff_tool = None;
298502
let mut git_tool = None;
503+
let mut verify_options = BTreeMap::new();
504+
let mut verify_option_bytes = 0_usize;
299505
let mut format_seen = false;
300506
let mut index = 1;
301507
while index < args.len() {
@@ -325,6 +531,64 @@ fn parse_args(args: &[String]) -> Result<Arguments, String> {
325531
}
326532
git_tool = Some(path);
327533
}
534+
"--cargo-tool"
535+
| "--cargo-audit-tool"
536+
| "--rustsec-db"
537+
| "--pip-audit-tool"
538+
| "--pip-audit-version"
539+
| "--go-tool"
540+
| "--maven-tool"
541+
| "--java-home"
542+
| "--java-tool"
543+
| "--checkstyle-jar"
544+
| "--config"
545+
| "--maven-repo"
546+
| "--repo-sha256"
547+
| "--cve-data-dir"
548+
| "--cve-data-sha256"
549+
| "--node-tool"
550+
| "--npm-entry"
551+
| "--npm-version"
552+
| "--userconfig"
553+
| "--globalconfig"
554+
| "--registry"
555+
| "--eslint-entry"
556+
| "--eslint-version"
557+
| "--cwd"
558+
| "--owner"
559+
| "--lease-token"
560+
if !verify_options.contains_key(key) =>
561+
{
562+
if matches!(
563+
key,
564+
"--cargo-tool"
565+
| "--cargo-audit-tool"
566+
| "--rustsec-db"
567+
| "--pip-audit-tool"
568+
| "--go-tool"
569+
| "--maven-tool"
570+
| "--java-home"
571+
| "--java-tool"
572+
| "--checkstyle-jar"
573+
| "--config"
574+
| "--maven-repo"
575+
| "--cve-data-dir"
576+
| "--node-tool"
577+
| "--npm-entry"
578+
| "--userconfig"
579+
| "--globalconfig"
580+
| "--eslint-entry"
581+
| "--cwd"
582+
) && !Path::new(&value).is_absolute()
583+
{
584+
return Err(format!("{key} 必须为绝对路径"));
585+
}
586+
verify_option_bytes = verify_option_bytes.saturating_add(key.len() + value.len());
587+
if value.is_empty() || verify_option_bytes > MAX_VERIFY_OPTION_BYTES {
588+
return Err("任务复检参数为空或超预算".into());
589+
}
590+
verify_options.insert(key.to_owned(), value);
591+
}
328592
_ => return Err(format!("不支持或重复的参数:{key}")),
329593
}
330594
index += 1;
@@ -341,5 +605,6 @@ fn parse_args(args: &[String]) -> Result<Arguments, String> {
341605
timeout_ms,
342606
ruff_tool,
343607
git_tool,
608+
verify_options,
344609
})
345610
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ fn main() -> ExitCode {
3030
"宿主事件只读规划:hook plan [--format=json] 从 stdin 读取 hook_trigger_request 1.0.0,返回 hook_trigger_plan 1.1.0 与退出码 3;批量编辑超出快检预算时要求改用有界批量范围,尚不执行检查或阻断宿主。"
3131
);
3232
println!(
33-
"局部事件执行:hook execute PATH --timeout DURATION --format=json [--ruff-tool ABS_PATH] [--git-tool ABS_PATH] 从 stdin 读取同一事件协议;启动只发现、Stop 有界只读提示、确认 Python 编辑调用 Ruff 局部快检、显式 Git 工具的 pre_commit 观察真实 index;完整交付始终 not_evaluated。"
33+
"局部事件执行: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!(
3636
"check all 的 Python CVE 节点可用 --pip-audit-tool ABS_PATH --pip-audit-version VERSION;逐构建根反馈原生结果或锁文件/工具阻塞,不签发零漏洞结论。"

0 commit comments

Comments
 (0)