fix: validate hook config entries partially

Hook config now supports the Claude Code structured hook format with
partial validation. Invalid hook entries are recorded in invalid_hooks
while valid siblings are retained, following the same pattern as MCP
partial validation (#440).

Key changes:
- RuntimeInvalidHookConfig now includes typed kind field (invalid_hooks_config
  or unknown_hook_event) for machine-readable error classification
- Hook parsing collects all invalid entries instead of halting at first error
- Unknown hook event names recorded as invalid without rejecting valid hooks
- Legacy bare-string hooks still load with deprecation warnings
- Claude Code documented format loads without error (matcher + nested hooks)
- config/status/doctor JSON surfaces hook_validation metadata
- classify_error_kind maps hook errors to invalid_hooks_config

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
This commit is contained in:
bellman
2026-06-04 23:42:58 +09:00
parent 4619375c14
commit 453d8945bb
8 changed files with 596 additions and 131 deletions

View File

@@ -6398,7 +6398,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
440. **DONE — invalid `mcpServers` siblings no longer drop valid MCP servers** — fixed 2026-06-04 in `fix: load partial MCP configs`. MCP config loading now records every invalid server entry as `invalid_servers:[{name, scope, path, error_field, reason, valid:false}]` while retaining valid siblings in `servers[]`; valid entries carry `valid:true`, `configured_servers` and `valid_count` report loaded valid servers, `invalid_count` reports rejected entries, and `total_configured` reports all discovered entries. `status --output-format json` mirrors the `mcp_validation` summary, and `doctor --output-format json` includes an `mcp validation` check for one-pass repair. Empty stdio commands and unknown per-transport fields are per-server validation errors instead of global config failures. Regression coverage: `loads_valid_mcp_servers_and_collects_all_invalid_siblings_440`, `records_invalid_mcp_server_shapes_without_rejecting_config_440`, `mcp_loads_valid_servers_and_reports_invalid_siblings_440`, and `mcp_degraded_config_and_failed_usage_are_distinct_json_contracts`. 440. **DONE — invalid `mcpServers` siblings no longer drop valid MCP servers** — fixed 2026-06-04 in `fix: load partial MCP configs`. MCP config loading now records every invalid server entry as `invalid_servers:[{name, scope, path, error_field, reason, valid:false}]` while retaining valid siblings in `servers[]`; valid entries carry `valid:true`, `configured_servers` and `valid_count` report loaded valid servers, `invalid_count` reports rejected entries, and `total_configured` reports all discovered entries. `status --output-format json` mirrors the `mcp_validation` summary, and `doctor --output-format json` includes an `mcp validation` check for one-pass repair. Empty stdio commands and unknown per-transport fields are per-server validation errors instead of global config failures. Regression coverage: `loads_valid_mcp_servers_and_collects_all_invalid_siblings_440`, `records_invalid_mcp_server_shapes_without_rejecting_config_440`, `mcp_loads_valid_servers_and_reports_invalid_siblings_440`, and `mcp_degraded_config_and_failed_usage_are_distinct_json_contracts`.
441. **`hooks` config schema diverges from Claude Code documented format — claw-code expects `{"hooks":{"PreToolUse":["command-string"]}}` (array of command strings) while Claude Code documentation specifies `{"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"..."}]}]}}` (structured matcher objects); users copy-pasting from Claude Code docs see `field "hooks.PreToolUse" must be an array of strings`** — dogfooded 2026-05-11 by Jobdori on `86ff83c2` in response to Clawhip pinpoint nudge at `1503350990680887418`. Reproduction: write `.claw.json` with the Claude-Code-documented hook format `{"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"/bin/echo pretool"}]}]}}`. Run `claw status --output-format json``config_load_error: "/private/tmp/claw-hook-probe/.claw.json: field \"hooks.PreToolUse\" must be an array of strings, got an array (line 3)"`, `status: "degraded"`. The error wording ("must be an array of strings, got an array") is confusingly tautological — the user did provide an array; the parser objects that the array contains objects instead of strings. Replacing with the claw-code-actual format `{"hooks":{"PreToolUse":["/bin/echo pretool"]}}` succeeds: `config_load_error: null, status: "ok"`. The two formats are fundamentally incompatible: claw-code drops the `matcher` field (no tool-specific filtering at the config layer), drops the `type:"command"` discriminator (no future expansion to other hook types), and treats each entry as a bare command string instead of a structured hook spec. **Sibling: PR #3000 (justcode049) was attempting to tolerate object-style hook entries** — that PR's title `fix: tolerate object-style hook entries in config parser` confirms this is a known user complaint, but the PR is still conflicting and unmerged. **Three sibling findings in same probe:** (a) **unknown event names reject entire hooks config**: `.claw.json` with `hooks.InvalidEvent` (not a real event name like `PreToolUse`/`PostToolUse`/`Stop`/`Notification`) triggers `config_load_error: "unknown key \"hooks.InvalidEvent\""` and rejects ALL hooks in the same file, even valid ones — same "one bad apple kills all" pattern as #440 (MCP servers). (b) **`kind:"unknown"` for the validation error** — should be `kind:"invalid_hooks_config"` or `kind:"unknown_hook_event"` (catch-all cluster #422/#423/#424/#428/#430/#431/#432/#433/#435 — 13th occurrence). (c) **first-error-only halting**: a `.claw.json` with `hooks.Stop:"not-an-array"` (type mismatch) AND `hooks.InvalidEvent` (unknown name) AND `hooks.Notification:[{}]` (empty entry) surfaces only the FIRST error in iteration order — user must fix one at a time across 3 iterations. **Required fix shape:** (a) **adopt Claude Code's structured hook format as the canonical**: support `{matcher, hooks:[{type, command}]}` natively, with `matcher` for tool-filtering, `type` for hook-type discriminator (future-proof for `inline`/`webhook`/etc beyond just `command`); (b) **keep backward compat for bare command strings**: legacy `["command-string"]` arrays still load, but emit a deprecation warning suggesting migration to the structured form; (c) **partial-success loading**: invalid hook entries surface in `invalid_hooks:[{event, index, reason}]` while valid ones load — same fix as #440 for MCP; (d) **typed `kind:"invalid_hooks_config"` envelope** instead of `kind:"unknown"`; (e) **rebase and merge PR #3000** which addresses this directly; (f) regression test: Claude-Code-documented hook config loads without error on claw-code. **Why this matters:** users migrating from Claude Code to Claw Code hit this on their first `.claw.json` write. The error message ("array of strings, got an array") is unhelpful; the documentation doesn't surface the schema divergence; and Claude Code's structured format is strictly more expressive (matchers, types) than claw-code's bare-string format. Cross-references #407 (config files no load_error), #410 (list-envelope schema drift), #428 (default permission mode), #440 (one invalid MCP entry blocks all), PR #3000 (justcode049's pending fix). Source: Jobdori live dogfood, `86ff83c2`, 2026-05-11. 441. **DONE — hook config now supports Claude Code structured format with partial validation**fixed 2026-06-04 in `fix: validate hook config entries partially`. Hook config loading now records every invalid hook entry as `invalid_hooks:[{event, index, hook_index, kind, error_field, reason, valid:false}]` while retaining valid siblings. Legacy bare-string hook entries (`["command-string"]`) still load for backward compatibility but emit deprecation warnings suggesting migration to object-style entries. Unknown hook event names (e.g. `Stop`, `Notification`) are recorded as invalid with `kind:"unknown_hook_event"` without rejecting valid hooks. Multiple invalid entries in the same config are all collected instead of halting at the first error. The Claude Code documented format `{"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"..."}]}]}}` loads without error, including `matcher` for tool filtering and `type:"command"` discriminator. `config --output-format json` includes `hook_validation` metadata and reports `status:"degraded"` when invalid hooks exist. `status --output-format json` mirrors `hook_validation` at both top-level and workspace scope. `doctor --output-format json` includes a `hook validation` check after `mcp validation` for one-pass repair. `classify_error_kind` maps hook-related config parse errors to `invalid_hooks_config` instead of generic `config_parse_error`. Regression coverage: `documented_claude_code_hook_format_loads_without_error_441`, `collects_all_invalid_hook_siblings_instead_of_halting_at_first_441`, `unknown_hook_events_recorded_with_correct_kind_441`, `loads_valid_hook_entries_and_records_invalid_siblings_441`, `records_object_style_hook_entries_without_command_441`, `hook_event_wrong_type_is_recorded_without_config_failure_441`, `allows_wrong_hook_entry_types_for_partial_runtime_validation_441`, `validates_object_style_hook_entries`. Cross-references #407 (config files no load_error), #422 (typed error kind), #440 (MCP partial validation pattern), PR #3000 (tolerate object-style hook entries).
442. **`agents` discovery requires TOML format (`.toml` files) while Claude Code documents agents as Markdown with YAML frontmatter (`.md`) — claw-code silently ignores `.md` files in `.claw/agents/` without any warning; the help text lists `.claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents` as sources but does not mention the `.toml` file format requirement** — dogfooded 2026-05-11 by Jobdori on `8499599b` in response to Clawhip pinpoint nudge at `1503358540230692876`. Reproduction: write `.claw/agents/valid-agent.md` with Claude-Code-format YAML frontmatter `---\nname: valid-agent\ndescription: A simple test agent\ntools: [bash, read_file]\n---\nYou are a helpful agent.` Run `claw agents list --output-format json``{"agents":[], "count":0, "summary":{"active":0,"shadowed":0,"total":0}}`. The valid `.md` agent is silently dropped. Replace with `.claw/agents/toml-agent.toml` containing TOML format `name = "toml-agent"\ndescription = "..."` → loads correctly with `count:1`. Source code confirms (`rust/crates/commands/src/lib.rs:3378`): `if entry.path().extension().is_none_or(|ext| ext != "toml") { continue; }` — only `.toml` extension is recognized, all others (including `.md`) skipped without warning. The help text `claw agents --help` documents the source paths but **omits the file-format requirement**. **Five sibling problems compounded:** (a) **schema divergence from Claude Code**: Claude Code's `agents` are documented as `.md` files with YAML frontmatter (matching the `CLAUDE.md`/`.claude/agents/` convention upstream). claw-code chose TOML for no documented reason. Users migrating from Claude Code or copy-pasting community agent definitions hit silent failure. (b) **silent file drop**: invalid agent files (wrong extension, broken frontmatter, missing required fields, file-name vs frontmatter-name mismatch) are all silently ignored with `count:0`. No `invalid_agents:[]` array, no warning, no `kind:"agent_load_failed"` envelope. Same all-or-nothing pattern as #440 (MCP servers) and #441 (hooks). (c) **no documentation of the schema**: `claw agents --help --output-format json` (per #427, this hits the auth gate; without auth it doesn't return the schema either). The required TOML fields (`name`, `description`, `model`, `model_reasoning_effort` per source code) aren't documented in any user-facing surface. (d) **missing `.claude/agents/` discovery**: many existing projects have `.claude/agents/` from Claude Code installs. claw-code only looks at `.claw/agents/` — users have to copy/move their existing agents. (e) **no agent-scaffolding command**: cross-reference #431 — there's no `claw agents create <name>` to generate a valid `.toml` skeleton; users must hand-craft. **Required fix shape:** (a) accept BOTH `.md` (with YAML frontmatter) AND `.toml` formats in `.claw/agents/`; prefer YAML frontmatter for Claude Code parity, keep TOML for back-compat; (b) include `.claude/agents/` in the discovery sources alongside `.claw/agents/` with documented precedence; (c) expose `invalid_agents:[{path, reason}]` array in `agents list --output-format json` so users can see what was skipped and why; (d) document the agent schema (required + optional fields) in `claw agents --help` and in USAGE.md; (e) add `claw agents create <name>` scaffolding command per #431; (f) regression test: `.claw/agents/foo.md` with YAML frontmatter loads correctly. **Why this matters:** agents are the primary extension surface for custom workflows. A silent-drop on the wrong file format breaks the discoverability promise of CLI agents. Claude Code's `.md`-with-YAML convention is the lingua franca across AI coding tools; deviating to TOML breaks copy-paste compatibility. Cross-references #430 (dump-manifests needs upstream), #431 (skills/agents lifecycle), #440 (MCP all-or-nothing), #441 (hooks all-or-nothing), #438 (memory file discovery only CLAUDE.md). Source: Jobdori live dogfood, `8499599b`, 2026-05-11. 442. **`agents` discovery requires TOML format (`.toml` files) while Claude Code documents agents as Markdown with YAML frontmatter (`.md`) — claw-code silently ignores `.md` files in `.claw/agents/` without any warning; the help text lists `.claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents` as sources but does not mention the `.toml` file format requirement** — dogfooded 2026-05-11 by Jobdori on `8499599b` in response to Clawhip pinpoint nudge at `1503358540230692876`. Reproduction: write `.claw/agents/valid-agent.md` with Claude-Code-format YAML frontmatter `---\nname: valid-agent\ndescription: A simple test agent\ntools: [bash, read_file]\n---\nYou are a helpful agent.` Run `claw agents list --output-format json``{"agents":[], "count":0, "summary":{"active":0,"shadowed":0,"total":0}}`. The valid `.md` agent is silently dropped. Replace with `.claw/agents/toml-agent.toml` containing TOML format `name = "toml-agent"\ndescription = "..."` → loads correctly with `count:1`. Source code confirms (`rust/crates/commands/src/lib.rs:3378`): `if entry.path().extension().is_none_or(|ext| ext != "toml") { continue; }` — only `.toml` extension is recognized, all others (including `.md`) skipped without warning. The help text `claw agents --help` documents the source paths but **omits the file-format requirement**. **Five sibling problems compounded:** (a) **schema divergence from Claude Code**: Claude Code's `agents` are documented as `.md` files with YAML frontmatter (matching the `CLAUDE.md`/`.claude/agents/` convention upstream). claw-code chose TOML for no documented reason. Users migrating from Claude Code or copy-pasting community agent definitions hit silent failure. (b) **silent file drop**: invalid agent files (wrong extension, broken frontmatter, missing required fields, file-name vs frontmatter-name mismatch) are all silently ignored with `count:0`. No `invalid_agents:[]` array, no warning, no `kind:"agent_load_failed"` envelope. Same all-or-nothing pattern as #440 (MCP servers) and #441 (hooks). (c) **no documentation of the schema**: `claw agents --help --output-format json` (per #427, this hits the auth gate; without auth it doesn't return the schema either). The required TOML fields (`name`, `description`, `model`, `model_reasoning_effort` per source code) aren't documented in any user-facing surface. (d) **missing `.claude/agents/` discovery**: many existing projects have `.claude/agents/` from Claude Code installs. claw-code only looks at `.claw/agents/` — users have to copy/move their existing agents. (e) **no agent-scaffolding command**: cross-reference #431 — there's no `claw agents create <name>` to generate a valid `.toml` skeleton; users must hand-craft. **Required fix shape:** (a) accept BOTH `.md` (with YAML frontmatter) AND `.toml` formats in `.claw/agents/`; prefer YAML frontmatter for Claude Code parity, keep TOML for back-compat; (b) include `.claude/agents/` in the discovery sources alongside `.claw/agents/` with documented precedence; (c) expose `invalid_agents:[{path, reason}]` array in `agents list --output-format json` so users can see what was skipped and why; (d) document the agent schema (required + optional fields) in `claw agents --help` and in USAGE.md; (e) add `claw agents create <name>` scaffolding command per #431; (f) regression test: `.claw/agents/foo.md` with YAML frontmatter loads correctly. **Why this matters:** agents are the primary extension surface for custom workflows. A silent-drop on the wrong file format breaks the discoverability promise of CLI agents. Claude Code's `.md`-with-YAML convention is the lingua franca across AI coding tools; deviating to TOML breaks copy-paste compatibility. Cross-references #430 (dump-manifests needs upstream), #431 (skills/agents lifecycle), #440 (MCP all-or-nothing), #441 (hooks all-or-nothing), #438 (memory file discovery only CLAUDE.md). Source: Jobdori live dogfood, `8499599b`, 2026-05-11.

View File

@@ -615,6 +615,7 @@ The list is also the precedence chain: project-local settings override project s
``` ```
Object-style matchers are optional. When present, they match tool names case-insensitively and support `*` wildcards plus comma or pipe separated alternatives. Nested hook `type` may be omitted or set to `"command"`; each nested command runs in configuration order. Object-style matchers are optional. When present, they match tool names case-insensitively and support `*` wildcards plus comma or pipe separated alternatives. Nested hook `type` may be omitted or set to `"command"`; each nested command runs in configuration order.
Legacy bare-string hook entries still load for backward compatibility but emit deprecation warnings suggesting migration to object-style entries. Unknown hook event names (e.g. `Stop`, `Notification`) are recorded as invalid without rejecting valid hooks. `status --output-format json` mirrors partial hook validation under `hook_validation` with `valid_count`, `invalid_count`, and `invalid_hooks:[{event, index, hook_index, kind, error_field, reason, valid:false}]`. `doctor --output-format json` includes a `hook validation` check so automation can repair every rejected hook entry without losing usable hooks.
## Project instruction rules ## Project instruction rules

View File

@@ -151,6 +151,7 @@ Top-level commands:
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field. `claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt. `status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt.
`claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check. `claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check.
`status --output-format json` also reports partial hook config success under `hook_validation`: valid hook entries are retained while malformed or unknown-event siblings appear in `invalid_hooks[]`, with `valid_count`, `invalid_count`, and typed `kind` fields (`invalid_hooks_config` or `unknown_hook_event`) for automation. `doctor --output-format json` includes a `hook validation` check, and `config --output-format json` includes `hook_validation` metadata with degraded status when invalid entries exist.
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options. Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory. `claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.

View File

@@ -182,6 +182,7 @@ pub struct RuntimeHookConfig {
pre_tool_use: Vec<RuntimeHookCommand>, pre_tool_use: Vec<RuntimeHookCommand>,
post_tool_use: Vec<RuntimeHookCommand>, post_tool_use: Vec<RuntimeHookCommand>,
post_tool_use_failure: Vec<RuntimeHookCommand>, post_tool_use_failure: Vec<RuntimeHookCommand>,
invalid_hooks: Vec<RuntimeInvalidHookConfig>,
} }
/// A hook command plus optional tool matcher from object-style hook config. /// A hook command plus optional tool matcher from object-style hook config.
@@ -191,6 +192,16 @@ pub struct RuntimeHookCommand {
matcher: Option<String>, matcher: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeInvalidHookConfig {
pub event: String,
pub index: Option<usize>,
pub hook_index: Option<usize>,
pub kind: String,
pub error_field: String,
pub reason: String,
}
/// Raw permission rule lists grouped by allow, deny, and ask behavior. /// Raw permission rule lists grouped by allow, deny, and ask behavior.
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePermissionRuleConfig { pub struct RuntimePermissionRuleConfig {
@@ -1198,6 +1209,7 @@ impl RuntimeHookConfig {
pre_tool_use, pre_tool_use,
post_tool_use, post_tool_use,
post_tool_use_failure, post_tool_use_failure,
invalid_hooks: Vec::new(),
} }
} }
@@ -1235,6 +1247,8 @@ impl RuntimeHookConfig {
&mut self.post_tool_use_failure, &mut self.post_tool_use_failure,
other.post_tool_use_failure_entries(), other.post_tool_use_failure_entries(),
); );
self.invalid_hooks
.extend(other.invalid_hooks.iter().cloned());
} }
#[must_use] #[must_use]
@@ -1246,6 +1260,25 @@ impl RuntimeHookConfig {
pub fn post_tool_use_failure_entries(&self) -> &[RuntimeHookCommand] { pub fn post_tool_use_failure_entries(&self) -> &[RuntimeHookCommand] {
&self.post_tool_use_failure &self.post_tool_use_failure
} }
#[must_use]
pub fn invalid_hooks(&self) -> &[RuntimeInvalidHookConfig] {
&self.invalid_hooks
}
#[must_use]
pub fn invalid_count(&self) -> usize {
self.invalid_hooks.len()
}
#[must_use]
pub fn has_invalid_hooks(&self) -> bool {
!self.invalid_hooks.is_empty()
}
pub fn push_invalid_hook(&mut self, invalid: RuntimeInvalidHookConfig) {
self.invalid_hooks.push(invalid);
}
} }
fn hook_commands(commands: &[RuntimeHookCommand]) -> Vec<String> { fn hook_commands(commands: &[RuntimeHookCommand]) -> Vec<String> {
@@ -1634,14 +1667,217 @@ fn parse_optional_hooks_config_object(
return Ok(RuntimeHookConfig::default()); return Ok(RuntimeHookConfig::default());
}; };
let hooks = expect_object(hooks_value, context)?; let hooks = expect_object(hooks_value, context)?;
Ok(RuntimeHookConfig { Ok(parse_hooks_object_partial(hooks, context))
pre_tool_use: optional_hook_command_array(hooks, "PreToolUse", context)? }
.unwrap_or_default(),
post_tool_use: optional_hook_command_array(hooks, "PostToolUse", context)? fn parse_hooks_object_partial(
.unwrap_or_default(), hooks: &BTreeMap<String, JsonValue>,
post_tool_use_failure: optional_hook_command_array(hooks, "PostToolUseFailure", context)? context: &str,
.unwrap_or_default(), ) -> RuntimeHookConfig {
}) let mut config = RuntimeHookConfig::default();
parse_hook_event_partial(
&mut config,
hooks,
"PreToolUse",
context,
|config, command| {
config.pre_tool_use.push(command);
},
);
parse_hook_event_partial(
&mut config,
hooks,
"PostToolUse",
context,
|config, command| {
config.post_tool_use.push(command);
},
);
parse_hook_event_partial(
&mut config,
hooks,
"PostToolUseFailure",
context,
|config, command| {
config.post_tool_use_failure.push(command);
},
);
for event in hooks.keys().filter(|event| !is_supported_hook_event(event)) {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.clone(),
index: None,
hook_index: None,
kind: "unknown_hook_event".to_string(),
error_field: event.clone(),
reason: format!("{context}: unknown hook event {event}"),
});
}
config
}
fn is_supported_hook_event(event: &str) -> bool {
matches!(event, "PreToolUse" | "PostToolUse" | "PostToolUseFailure")
}
fn parse_hook_event_partial(
config: &mut RuntimeHookConfig,
hooks: &BTreeMap<String, JsonValue>,
event: &str,
context: &str,
mut push_command: impl FnMut(&mut RuntimeHookConfig, RuntimeHookCommand),
) {
let Some(value) = hooks.get(event) else {
return;
};
let Some(array) = value.as_array() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: None,
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: event.to_string(),
reason: format!("{context}: field {event} must be an array"),
});
return;
};
for (index, item) in array.iter().enumerate() {
if let Some(command) = item.as_str() {
if command.trim().is_empty() {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: "command".to_string(),
reason: format!("{context}: field {event}[{index}] must be a non-empty string"),
});
} else {
push_command(config, RuntimeHookCommand::new(command.to_string()));
}
continue;
}
let Some(entry) = item.as_object() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: event.to_string(),
reason: format!(
"{context}: field {event}[{index}] must be a string or hook object"
),
});
continue;
};
let matcher = match optional_hook_matcher(entry, context, event, index) {
Ok(matcher) => matcher,
Err(error) => {
config.push_invalid_hook(runtime_invalid_hook(
event,
Some(index),
None,
"matcher",
error,
));
continue;
}
};
let Some(hook_array) = entry.get("hooks").and_then(JsonValue::as_array) else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: "hooks".to_string(),
reason: format!("{context}: field {event}[{index}].hooks must be an array"),
});
continue;
};
for (hook_index, hook) in hook_array.iter().enumerate() {
let Some(hook_object) = hook.as_object() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "hooks".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}] must be an object"
),
});
continue;
};
if let Some(hook_type) = hook_object.get("type") {
let Some(hook_type) = hook_type.as_str() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "type".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}].type must be a string"
),
});
continue;
};
if hook_type != "command" {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "type".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}].type must be \"command\""
),
});
continue;
}
}
let Some(command) = hook_object
.get("command")
.and_then(JsonValue::as_str)
.filter(|command| !command.trim().is_empty())
else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "command".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}].command must be a non-empty string"
),
});
continue;
};
push_command(
config,
RuntimeHookCommand::with_matcher(command.to_string(), matcher.clone()),
);
}
}
}
fn runtime_invalid_hook(
event: &str,
index: Option<usize>,
hook_index: Option<usize>,
error_field: &str,
error: ConfigError,
) -> RuntimeInvalidHookConfig {
RuntimeInvalidHookConfig {
event: event.to_string(),
index,
hook_index,
kind: "invalid_hooks_config".to_string(),
error_field: error_field.to_string(),
reason: config_error_detail(&error),
}
} }
fn validate_optional_hooks_config( fn validate_optional_hooks_config(
@@ -2108,77 +2344,6 @@ fn optional_string_array(
} }
} }
fn optional_hook_command_array(
object: &BTreeMap<String, JsonValue>,
key: &str,
context: &str,
) -> Result<Option<Vec<RuntimeHookCommand>>, ConfigError> {
let Some(value) = object.get(key) else {
return Ok(None);
};
let Some(array) = value.as_array() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key} must be an array"
)));
};
let mut commands = Vec::new();
for (index, item) in array.iter().enumerate() {
if let Some(command) = item.as_str() {
commands.push(RuntimeHookCommand::new(command.to_string()));
continue;
}
let Some(entry) = item.as_object() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}] must be a string or hook object"
)));
};
let matcher = optional_hook_matcher(entry, context, key, index)?;
let hooks = entry
.get("hooks")
.and_then(JsonValue::as_array)
.ok_or_else(|| {
ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks must be an array"
))
})?;
for (hook_index, hook) in hooks.iter().enumerate() {
let Some(hook_object) = hook.as_object() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}] must be an object"
)));
};
if let Some(hook_type) = hook_object.get("type") {
let Some(hook_type) = hook_type.as_str() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}].type must be a string"
)));
};
if hook_type != "command" {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}].type must be \"command\""
)));
}
}
let command = hook_object
.get("command")
.and_then(JsonValue::as_str)
.filter(|command| !command.trim().is_empty())
.ok_or_else(|| {
ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}].command must be a non-empty string"
))
})?;
commands.push(RuntimeHookCommand::with_matcher(
command.to_string(),
matcher.clone(),
));
}
}
Ok(Some(commands))
}
fn optional_hook_matcher( fn optional_hook_matcher(
entry: &BTreeMap<String, JsonValue>, entry: &BTreeMap<String, JsonValue>,
context: &str, context: &str,
@@ -2428,7 +2593,7 @@ mod tests {
} }
#[test] #[test]
fn rejects_object_style_hook_entries_without_command() { fn records_object_style_hook_entries_without_command_441() {
let root = temp_dir(); let root = temp_dir();
let cwd = root.join("project"); let cwd = root.join("project");
let home = root.join("home").join(".claw"); let home = root.join("home").join(".claw");
@@ -2440,12 +2605,20 @@ mod tests {
) )
.expect("write settings"); .expect("write settings");
let error = ConfigLoader::new(&cwd, &home) let loaded = ConfigLoader::new(&cwd, &home)
.load() .load()
.expect_err("config should reject malformed hook entry"); .expect("config should load valid siblings and record malformed hook entry");
assert!(error assert!(loaded.hooks().pre_tool_use().is_empty());
.to_string() assert_eq!(loaded.hooks().invalid_count(), 1);
assert_eq!(
loaded.hooks().invalid_hooks()[0].kind,
"invalid_hooks_config"
);
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
assert_eq!(loaded.hooks().invalid_hooks()[0].error_field, "command");
assert!(loaded.hooks().invalid_hooks()[0]
.reason
.contains("command must be a non-empty string")); .contains("command must be a non-empty string"));
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }
@@ -3188,7 +3361,7 @@ mod tests {
} }
#[test] #[test]
fn rejects_invalid_hook_entries_before_merge() { fn loads_valid_hook_entries_and_records_invalid_siblings_441() {
// given // given
let root = temp_dir(); let root = temp_dir();
let cwd = root.join("project"); let cwd = root.join("project");
@@ -3208,19 +3381,21 @@ mod tests {
) )
.expect("write invalid project settings"); .expect("write invalid project settings");
// when let loaded = ConfigLoader::new(&cwd, &home)
let error = ConfigLoader::new(&cwd, &home)
.load() .load()
.expect_err("config should fail"); .expect("config should load valid hook entries and record invalid siblings");
// then — config validation now catches the mixed array before the hooks parser assert_eq!(loaded.hooks().pre_tool_use(), &["project".to_string()]);
let rendered = error.to_string(); assert_eq!(loaded.hooks().invalid_count(), 1);
assert!( assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
rendered.contains("hooks.PreToolUse") assert_eq!(
&& rendered.contains("must be an array of strings"), loaded.hooks().invalid_hooks()[0].kind,
"expected validation error for hooks.PreToolUse, got: {rendered}" "invalid_hooks_config"
); );
assert!(!rendered.contains("merged settings.hooks")); assert_eq!(loaded.hooks().invalid_hooks()[0].index, Some(1));
assert!(loaded.hooks().invalid_hooks()[0]
.reason
.contains("must be a string or hook object"));
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }
@@ -3363,7 +3538,7 @@ mod tests {
} }
#[test] #[test]
fn validates_wrong_type_for_known_field_with_field_path() { fn hook_event_wrong_type_is_recorded_without_config_failure_441() {
// given // given
let root = temp_dir(); let root = temp_dir();
let cwd = root.join("project"); let cwd = root.join("project");
@@ -3377,29 +3552,145 @@ mod tests {
) )
.expect("write user settings"); .expect("write user settings");
// when let loaded = ConfigLoader::new(&cwd, &home)
let error = ConfigLoader::new(&cwd, &home)
.load() .load()
.expect_err("config should fail"); .expect("config should record malformed hook event without failing");
// then assert!(loaded.hooks().pre_tool_use().is_empty());
let rendered = error.to_string(); assert_eq!(loaded.hooks().invalid_count(), 1);
assert!( assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
rendered.contains(&user_settings.display().to_string()), assert_eq!(
"error should include file path, got: {rendered}" loaded.hooks().invalid_hooks()[0].kind,
"invalid_hooks_config"
); );
assert_eq!(loaded.hooks().invalid_hooks()[0].index, None);
assert!(loaded.hooks().invalid_hooks()[0]
.reason
.contains("field PreToolUse must be an array"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn collects_all_invalid_hook_siblings_instead_of_halting_at_first_441() {
// ROADMAP #441 finding (c): first-error-only halting means users must fix
// one hook at a time. After #441 partial fix, all invalid entries in the
// same config are collected.
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{"hooks":{"PreToolUse":[42],"PostToolUse":"not-an-array","InvalidEvent":["cmd"]}}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should collect all invalid hooks without halting at first");
assert!(loaded.hooks().pre_tool_use().is_empty());
assert!(loaded.hooks().post_tool_use().is_empty());
// Three distinct invalid entries: 42, wrong type, unknown event
assert_eq!(loaded.hooks().invalid_count(), 3);
let invalid = loaded.hooks().invalid_hooks();
// PreToolUse[0]=42
assert_eq!(invalid[0].event, "PreToolUse");
assert_eq!(invalid[0].index, Some(0));
assert_eq!(invalid[0].kind, "invalid_hooks_config");
// PostToolUse wrong type
assert_eq!(invalid[1].event, "PostToolUse");
assert_eq!(invalid[1].index, None);
assert_eq!(invalid[1].kind, "invalid_hooks_config");
// Unknown event
assert_eq!(invalid[2].event, "InvalidEvent");
assert_eq!(invalid[2].index, None);
assert_eq!(invalid[2].kind, "unknown_hook_event");
assert!(invalid[2]
.reason
.contains("unknown hook event InvalidEvent"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn unknown_hook_events_recorded_with_correct_kind_441() {
// ROADMAP #441 finding (a): unknown event names like Stop/Notification
// should not reject entire hooks config; they are recorded as invalid.
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{"hooks":{"PreToolUse":["valid-cmd"],"Stop":"not-an-array","Notification":[{}]}}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load valid hooks and record unknown event siblings");
// Valid PreToolUse hook should load
assert_eq!(loaded.hooks().pre_tool_use(), &["valid-cmd".to_string()]);
// Stop and Notification are unknown events; each gets one invalid entry
// Notification:[{}] also has an empty-object entry issue but since we
// don't parse unknown events, only the unknown-event invalid is recorded
let invalid = loaded.hooks().invalid_hooks();
assert!( assert!(
rendered.contains("hooks"), invalid.len() >= 2,
"error should include field path component 'hooks', got: {rendered}" "expected at least 2 invalid hooks, got {}",
invalid.len()
); );
assert!(
rendered.contains("PreToolUse"), let stop = invalid
"error should describe the type mismatch, got: {rendered}" .iter()
); .find(|h| h.event == "Stop")
assert!( .expect("Stop invalid hook");
rendered.contains("array"), assert_eq!(stop.kind, "unknown_hook_event");
"error should describe the expected type, got: {rendered}" assert_eq!(stop.index, None);
assert!(stop.reason.contains("unknown hook event Stop"));
let notif = invalid
.iter()
.find(|h| h.event == "Notification")
.expect("Notification invalid hook");
assert_eq!(notif.kind, "unknown_hook_event");
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn documented_claude_code_hook_format_loads_without_error_441() {
// ROADMAP #441: the Claude Code documented hook format
// {"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"..."}]}]}}
// must load without config_load_error.
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"/bin/echo pretool"}]}]}}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("Claude Code documented hook format must load without error");
assert_eq!(
loaded.hooks().pre_tool_use(),
&["/bin/echo pretool".to_string()]
); );
assert_eq!(loaded.hooks().invalid_count(), 0);
let entries = loaded.hooks().pre_tool_use_entries();
assert_eq!(entries[0].matcher(), Some("Read"));
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }

View File

@@ -118,10 +118,7 @@ impl FieldType {
Self::StringArray => value Self::StringArray => value
.as_array() .as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())), .is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
Self::HookArray => value.as_array().is_some_and(|arr| { Self::HookArray => true,
arr.iter()
.all(|entry| entry.as_str().is_some() || entry.as_object().is_some())
}),
Self::RulesImport => { Self::RulesImport => {
value.as_str().is_some() value.as_str().is_some()
|| value || value
@@ -439,6 +436,43 @@ fn validate_object_keys(
result result
} }
/// Emit deprecation warnings for bare string hook entries in the hooks object.
/// Legacy `["command-string"]` arrays still load but suggest migration to the
/// structured `{matcher, hooks:[{type, command}]}` form.
fn validate_hook_entry_format(
hooks: &BTreeMap<String, JsonValue>,
source: &str,
path_display: &str,
) -> ValidationResult {
let mut result = ValidationResult {
errors: Vec::new(),
warnings: Vec::new(),
};
for spec in HOOKS_FIELDS {
let Some(value) = hooks.get(spec.name) else {
continue;
};
let Some(array) = value.as_array() else {
continue;
};
for item in array {
if item.as_str().is_some() {
result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(),
field: format!("hooks.{}", spec.name),
line: find_key_line(source, spec.name),
kind: DiagnosticKind::Deprecated {
replacement: "object-style hook entries with hooks:[{type:\"command\",command:\"...\"}]",
},
});
// One deprecation warning per event is enough
break;
}
}
}
result
}
fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> { fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
let input_lower = input.to_ascii_lowercase(); let input_lower = input.to_ascii_lowercase();
candidates candidates
@@ -510,6 +544,7 @@ pub fn validate_config_file(
source, source,
&path_display, &path_display,
)); ));
result.merge(validate_hook_entry_format(hooks, source, &path_display));
} }
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) { if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
result.merge(validate_object_keys( result.merge(validate_object_keys(
@@ -714,7 +749,7 @@ mod tests {
#[test] #[test]
fn validates_nested_hooks_keys() { fn validates_nested_hooks_keys() {
// given // given
let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#; let source = r#"{"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"cmd"}]}], "BadHook": ["x"]}}"#;
let parsed = JsonValue::parse(source).expect("valid json"); let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object"); let object = parsed.as_object().expect("object");
@@ -723,7 +758,12 @@ mod tests {
// then // then
assert!(result.errors.is_empty()); assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1); assert_eq!(
result.warnings.len(),
1,
"expected only the unknown key warning, got {:?}",
result.warnings
);
assert_eq!(result.warnings[0].field, "hooks.BadHook"); assert_eq!(result.warnings[0].field, "hooks.BadHook");
} }
@@ -739,15 +779,14 @@ mod tests {
} }
#[test] #[test]
fn rejects_wrong_hook_entry_types() { fn allows_wrong_hook_entry_types_for_partial_runtime_validation_441() {
let source = r#"{"hooks":{"PreToolUse":[42]}}"#; let source = r#"{"hooks":{"PreToolUse":[42]}}"#;
let parsed = JsonValue::parse(source).expect("valid json"); let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object"); let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty(), "{:?}", result.errors);
assert_eq!(result.errors[0].field, "hooks.PreToolUse");
} }
#[test] #[test]
@@ -847,7 +886,7 @@ mod tests {
// given // given
let source = r#"{ let source = r#"{
"model": "opus", "model": "opus",
"hooks": {"PreToolUse": ["guard"]}, "hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]},
"permissions": {"defaultMode": "plan", "allow": ["Read"]}, "permissions": {"defaultMode": "plan", "allow": ["Read"]},
"mcpServers": {}, "mcpServers": {},
"sandbox": {"enabled": false} "sandbox": {"enabled": false}

View File

@@ -71,8 +71,8 @@ pub use config::{
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig, RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
CLAW_SETTINGS_SCHEMA_NAME, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
}; };
pub use config_validate::{ pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic, check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
#![allow( #![allow(
dead_code, dead_code,
unused_imports, unused_imports,
@@ -59,7 +60,7 @@ use runtime::{
ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig, ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig,
McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode,
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
@@ -3503,6 +3504,11 @@ fn render_doctor_report(
.ok() .ok()
.map(|runtime_config| McpValidationSummary::from_collection(runtime_config.mcp())) .map(|runtime_config| McpValidationSummary::from_collection(runtime_config.mcp()))
.unwrap_or_default(); .unwrap_or_default();
let hook_validation = config
.as_ref()
.ok()
.map(HookValidationSummary::from_config)
.unwrap_or_default();
let context = StatusContext { let context = StatusContext {
cwd: cwd.clone(), cwd: cwd.clone(),
session_path: None, session_path: None,
@@ -3532,12 +3538,14 @@ fn render_doctor_report(
config_load_error: config.as_ref().err().map(ToString::to_string), config_load_error: config.as_ref().err().map(ToString::to_string),
config_load_error_kind: None, config_load_error_kind: None,
mcp_validation: mcp_validation.clone(), mcp_validation: mcp_validation.clone(),
hook_validation: hook_validation.clone(),
}; };
Ok(DoctorReport { Ok(DoctorReport {
checks: vec![ checks: vec![
check_auth_health(), check_auth_health(),
check_config_health(&config_loader, config.as_ref()), check_config_health(&config_loader, config.as_ref()),
check_mcp_validation_health(&mcp_validation), check_mcp_validation_health(&mcp_validation),
check_hook_validation_health(&hook_validation),
check_install_source_health(), check_install_source_health(),
check_workspace_health(&context), check_workspace_health(&context),
check_memory_health(&context), check_memory_health(&context),
@@ -3838,6 +3846,10 @@ fn check_config_health(
"mcp_invalid_servers".to_string(), "mcp_invalid_servers".to_string(),
json!(runtime_config.mcp().invalid_count()), json!(runtime_config.mcp().invalid_count()),
), ),
(
"hook_invalid_entries".to_string(),
json!(runtime_config.hooks().invalid_count()),
),
])) ]))
} }
Err(error) => DiagnosticCheck::new( Err(error) => DiagnosticCheck::new(
@@ -3918,6 +3930,51 @@ fn check_mcp_validation_health(summary: &McpValidationSummary) -> DiagnosticChec
])) ]))
} }
fn check_hook_validation_health(summary: &HookValidationSummary) -> DiagnosticCheck {
let mut details = vec![
format!("Valid entries {}", summary.valid_count),
format!("Invalid entries {}", summary.invalid_count()),
];
details.extend(
summary
.invalid_hooks
.iter()
.map(|hook| format!("Invalid hook {} ({})", hook.event, hook.reason)),
);
DiagnosticCheck::new(
"Hook validation",
if summary.has_invalid_hooks() {
DiagnosticLevel::Warn
} else {
DiagnosticLevel::Ok
},
if summary.has_invalid_hooks() {
format!(
"{} hook entries are invalid; {} valid entries remain loaded",
summary.invalid_count(),
summary.valid_count
)
} else {
format!("{} hook entries validated", summary.valid_count)
},
)
.with_hint(if summary.has_invalid_hooks() {
"Inspect `claw status --output-format json` hook_validation.invalid_hooks and fix each rejected hooks entry."
} else {
""
})
.with_details(details)
.with_data(Map::from_iter([
("valid_count".to_string(), json!(summary.valid_count)),
("invalid_count".to_string(), json!(summary.invalid_count())),
(
"invalid_hooks".to_string(),
Value::Array(invalid_hooks_json(&summary.invalid_hooks)),
),
]))
}
fn check_permission_health(permission_mode: PermissionModeProvenance) -> DiagnosticCheck { fn check_permission_health(permission_mode: PermissionModeProvenance) -> DiagnosticCheck {
let mode = permission_mode.mode.as_str(); let mode = permission_mode.mode.as_str();
let source = permission_mode.source.as_str(); let source = permission_mode.source.as_str();
@@ -4897,6 +4954,57 @@ impl McpValidationSummary {
} }
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct HookValidationSummary {
valid_count: usize,
invalid_hooks: Vec<RuntimeInvalidHookConfig>,
}
impl HookValidationSummary {
fn from_config(config: &runtime::RuntimeConfig) -> Self {
let hooks = config.hooks();
Self {
valid_count: hooks.pre_tool_use_entries().len()
+ hooks.post_tool_use_entries().len()
+ hooks.post_tool_use_failure_entries().len(),
invalid_hooks: hooks.invalid_hooks().to_vec(),
}
}
fn invalid_count(&self) -> usize {
self.invalid_hooks.len()
}
fn has_invalid_hooks(&self) -> bool {
!self.invalid_hooks.is_empty()
}
fn json_value(&self) -> serde_json::Value {
json!({
"valid_count": self.valid_count,
"invalid_count": self.invalid_count(),
"invalid_hooks": invalid_hooks_json(&self.invalid_hooks),
})
}
}
fn invalid_hooks_json(invalid_hooks: &[RuntimeInvalidHookConfig]) -> Vec<serde_json::Value> {
invalid_hooks
.iter()
.map(|hook| {
json!({
"event": &hook.event,
"index": hook.index,
"hook_index": hook.hook_index,
"kind": &hook.kind,
"error_field": &hook.error_field,
"reason": &hook.reason,
"valid": false,
})
})
.collect()
}
fn invalid_mcp_servers_json(invalid_servers: &[McpInvalidServerConfig]) -> Vec<serde_json::Value> { fn invalid_mcp_servers_json(invalid_servers: &[McpInvalidServerConfig]) -> Vec<serde_json::Value> {
invalid_servers invalid_servers
.iter() .iter()
@@ -5060,6 +5168,7 @@ struct StatusContext {
/// instead of regex-scraping the prose. /// instead of regex-scraping the prose.
config_load_error_kind: Option<&'static str>, config_load_error_kind: Option<&'static str>,
mcp_validation: McpValidationSummary, mcp_validation: McpValidationSummary,
hook_validation: HookValidationSummary,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -8972,10 +9081,11 @@ fn status_json_value(
json!({ json!({
"kind": "status", "kind": "status",
"action": "show", "action": "show",
"status": if degraded || context.mcp_validation.has_invalid_servers() { "degraded" } else { "ok" }, "status": if degraded || context.mcp_validation.has_invalid_servers() || context.hook_validation.has_invalid_hooks() { "degraded" } else { "ok" },
"config_load_error": context.config_load_error, "config_load_error": context.config_load_error,
"config_load_error_kind": context.config_load_error_kind, "config_load_error_kind": context.config_load_error_kind,
"mcp_validation": context.mcp_validation.json_value(), "mcp_validation": context.mcp_validation.json_value(),
"hook_validation": context.hook_validation.json_value(),
"model": model, "model": model,
"model_source": model_source, "model_source": model_source,
"model_raw": model_raw, "model_raw": model_raw,
@@ -9043,6 +9153,7 @@ fn status_json_value(
"memory_files": memory_files_json(&context.memory_files), "memory_files": memory_files_json(&context.memory_files),
"unloaded_memory_files": context.unloaded_memory_files, "unloaded_memory_files": context.unloaded_memory_files,
"mcp_validation": context.mcp_validation.json_value(), "mcp_validation": context.mcp_validation.json_value(),
"hook_validation": context.hook_validation.json_value(),
}, },
"sandbox": { "sandbox": {
"enabled": context.sandbox_status.enabled, "enabled": context.sandbox_status.enabled,
@@ -9121,6 +9232,11 @@ fn status_context(
.ok() .ok()
.map(|runtime_config| McpValidationSummary::from_collection(runtime_config.mcp())) .map(|runtime_config| McpValidationSummary::from_collection(runtime_config.mcp()))
.unwrap_or_default(); .unwrap_or_default();
let hook_validation = runtime_config
.as_ref()
.ok()
.map(HookValidationSummary::from_config)
.unwrap_or_default();
Ok(StatusContext { Ok(StatusContext {
cwd: cwd.clone(), cwd: cwd.clone(),
session_path: session_path.map(Path::to_path_buf), session_path: session_path.map(Path::to_path_buf),
@@ -9145,6 +9261,7 @@ fn status_context(
config_load_error, config_load_error,
config_load_error_kind, config_load_error_kind,
mcp_validation, mcp_validation,
hook_validation,
}) })
} }
@@ -9727,7 +9844,7 @@ fn render_doctor_help_json() -> serde_json::Value {
"requires_session_resume": false, "requires_session_resume": false,
"mutates_workspace": false, "mutates_workspace": false,
"output_fields": ["kind", "action", "status", "message", "report", "has_failures", "summary", "checks", "allowed_tools"], "output_fields": ["kind", "action", "status", "message", "report", "has_failures", "summary", "checks", "allowed_tools"],
"check_names": ["auth", "config", "mcp validation", "install source", "workspace", "memory", "boot preflight", "sandbox", "permissions", "system"], "check_names": ["auth", "config", "mcp validation", "hook validation", "install source", "workspace", "memory", "boot preflight", "sandbox", "permissions", "system"],
"status_values": ["ok", "warn", "fail"], "status_values": ["ok", "warn", "fail"],
"options": [ "options": [
{ {
@@ -9981,10 +10098,19 @@ fn render_config_json(
.map(|w| serde_json::Value::String(w.clone())) .map(|w| serde_json::Value::String(w.clone()))
.collect(); .collect();
let hook_validation = HookValidationSummary::from_config(&runtime_config);
let has_hook_issues = hook_validation.has_invalid_hooks();
let status_value = if inspection.load_error.is_some() {
"error"
} else if has_hook_issues {
"degraded"
} else {
"ok"
};
let base = serde_json::json!({ let base = serde_json::json!({
"kind": "config", "kind": "config",
"action": if section.is_some() { "show" } else { "list" }, "action": if section.is_some() { "show" } else { "list" },
"status": if inspection.load_error.is_some() { "error" } else { "ok" }, "status": status_value,
"cwd": cwd.display().to_string(), "cwd": cwd.display().to_string(),
"loaded_files": loaded_files, "loaded_files": loaded_files,
"merged_keys": merged_keys, "merged_keys": merged_keys,
@@ -9993,6 +10119,7 @@ fn render_config_json(
"files": files, "files": files,
"warnings": warnings_json, "warnings": warnings_json,
"load_error": inspection.load_error.clone(), "load_error": inspection.load_error.clone(),
"hook_validation": hook_validation.json_value(),
}); });
if let Some(section) = section { if let Some(section) = section {
@@ -16736,6 +16863,7 @@ mod tests {
config_load_error: None, config_load_error: None,
config_load_error_kind: None, config_load_error_kind: None,
mcp_validation: super::McpValidationSummary::default(), mcp_validation: super::McpValidationSummary::default(),
hook_validation: super::HookValidationSummary::default(),
}, },
None, // #148 None, // #148
None, None,
@@ -16887,6 +17015,7 @@ mod tests {
config_load_error: None, config_load_error: None,
config_load_error_kind: None, config_load_error_kind: None,
mcp_validation: super::McpValidationSummary::default(), mcp_validation: super::McpValidationSummary::default(),
hook_validation: super::HookValidationSummary::default(),
}; };
let check = super::check_workspace_health(&context); let check = super::check_workspace_health(&context);
@@ -16937,6 +17066,7 @@ mod tests {
config_load_error: None, config_load_error: None,
config_load_error_kind: None, config_load_error_kind: None,
mcp_validation: super::McpValidationSummary::default(), mcp_validation: super::McpValidationSummary::default(),
hook_validation: super::HookValidationSummary::default(),
}; };
let check = super::check_memory_health(&context); let check = super::check_memory_health(&context);
@@ -16979,6 +17109,7 @@ mod tests {
config_load_error: None, config_load_error: None,
config_load_error_kind: None, config_load_error_kind: None,
mcp_validation: super::McpValidationSummary::default(), mcp_validation: super::McpValidationSummary::default(),
hook_validation: super::HookValidationSummary::default(),
}; };
let value = status_json_value( let value = status_json_value(

View File

@@ -112,6 +112,7 @@ fn assert_doctor_help_json_contract(parsed: &Value) {
assert!(checks.iter().any(|check| check == "boot preflight")); assert!(checks.iter().any(|check| check == "boot preflight"));
assert!(checks.iter().any(|check| check == "memory")); assert!(checks.iter().any(|check| check == "memory"));
assert!(checks.iter().any(|check| check == "mcp validation")); assert!(checks.iter().any(|check| check == "mcp validation"));
assert!(checks.iter().any(|check| check == "hook validation"));
} }
#[test] #[test]
@@ -1459,7 +1460,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
.is_some_and(|available| available.iter().any(|name| name == "web_fetch"))); .is_some_and(|available| available.iter().any(|name| name == "web_fetch")));
let checks = doctor["checks"].as_array().expect("doctor checks"); let checks = doctor["checks"].as_array().expect("doctor checks");
assert_eq!(checks.len(), 10); assert_eq!(checks.len(), 11);
let check_names = checks let check_names = checks
.iter() .iter()
.map(|check| { .map(|check| {
@@ -1481,6 +1482,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
"auth", "auth",
"config", "config",
"mcp validation", "mcp validation",
"hook validation",
"install source", "install source",
"workspace", "workspace",
"memory", "memory",