fix(v0.15.1): RED-1 CVE + typed-handoff + schema minItems

Security hotfix — v0.15.1 Wave 1 fixes from 4-parallel audit.

RED-1 (CVE): KEI_DISABLED_HOOKS tokenized match — was `*all*`
substring-glob (trivially bypassable via "install", "wall-clock", etc.),
now exact-token split on comma/space. Patched in all 9 hooks:
no-hand-edit-agents, assemble-agents, assemble-validate, tomd-preread,
agent-fork-logger, site-wysiwyd-check, error-spike-detector,
milestone-commit-hook, session-end-dump.

RED-2 (observability): minimal profile whitelist now includes
agent-fork-logger and session-end-dump (ledger + trace paths) so
observability is not silently lost on minimal installs.

HIGH: review.json schema minItems:1 on findings — rejects empty
reviews; new Rust test review_schema_rejects_empty_findings.

HIGH: typed-handoff wire-up — produces_artifact declared at top
level on 5 manifests (kei-security-auditor, kei-validator,
kei-architect, kei-code-implementer, kei-critic); duplicate
per-handoff declarations removed.

MED: kei-artifact validate.rs gains warn_unsupported_keywords —
non-fatal stderr warning when schema uses keywords outside the
hand-rolled 2020-12 subset.

LOW: CI Node matrix dropped 18, now ['20','22'].

Doc drift: skills/hooks-control/SKILL.md reflects tokenized-match
semantics and updated minimal-profile hook list.

Tests: 191 Rust workspace + 30 assembler (both pass). RED-1
reproducer 10/10 (4 former-CVE vectors blocked, 5 legit vectors
accepted, empty passes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-22 15:08:51 +08:00
parent b62b219500
commit f77c1b7fdc
19 changed files with 193 additions and 50 deletions

View file

@ -37,7 +37,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
node: ['18', '20', '22']
node: ['20', '22']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -73,4 +73,7 @@ jobs:
- run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: shellcheck (advisory)
run: find hooks _primitives -name '*.sh' -exec shellcheck -S warning {} +
continue-on-error: true # warnings are advisory initially
# v0.15.1: kept advisory because local shellcheck sweep not yet clean
# (quoted-var nits in hooks). Flip to fatal once the sweep is committed;
# planned for v0.16.
continue-on-error: true

View file

@ -73,7 +73,6 @@ produces_artifact = "spec"
[[handoff]]
target = "kei-code-implementer"
trigger = "structural finding implies a concrete refactor / extraction / module split"
produces_artifact = "spec"
[[handoff]]
target = "kei-critic"

View file

@ -79,7 +79,6 @@ trigger = "task involves deploy / CI/CD / secrets / IaC / credentials / public-s
[[handoff]]
target = "kei-critic"
trigger = "anti-pattern sweep / code smell review on large diff (>500 LOC) or long function chains"
produces_artifact = "patch"
[[handoff]]
target = "kei-security-auditor"

View file

@ -62,7 +62,6 @@ produces_artifact = "review"
target = "kei-code-implementer"
trigger = "confirmed findings need code edits (user approves fix plan first)"
expects_artifact = "patch"
produces_artifact = "review"
[[handoff]]
target = "kei-security-auditor"

View file

@ -54,6 +54,10 @@ output_extra_fields = [
"9-point checklist coverage: [x]/[ ] per item",
]
# v0.15.1: typed-artifact handoff — security-auditor emits a `review` artifact
# with severity-sorted findings (schema: kei-artifact://review).
produces_artifact = "review"
# Handoffs MUST come after all top-level keys (TOML array-of-tables scope rule)
[[handoff]]
target = "kei-code-implementer"

View file

@ -55,6 +55,10 @@ output_extra_fields = [
"Overall verdict: ALL VERIFIED | PARTIAL (fix list) | BLOCK (FALSE findings present)",
]
# v0.15.1: typed-artifact handoff — validator emits a `review` artifact
# with severity-sorted findings (schema: kei-artifact://review).
produces_artifact = "review"
# Handoffs MUST come after all top-level keys (TOML array-of-tables scope rule)
[[handoff]]
target = "kei-ml-researcher"

View file

@ -17,6 +17,7 @@
},
"findings": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,

View file

@ -27,6 +27,54 @@ pub fn validate_content(schema: &Value, content: &Value) -> Result<(), String> {
check(schema, content, "$")
}
/// Keywords the minimal validator knows about. Used by `warn_unsupported_keywords`
/// to flag — but not reject — schemas that lean on unsupported features (so an
/// operator writing human-readable docs in a schema still sees them stored,
/// while being warned they do not actually enforce anything).
const KNOWN_KEYWORDS: &[&str] = &[
"$schema",
"$id",
"title",
"description",
"type",
"required",
"properties",
"additionalProperties",
"enum",
"items",
"minLength",
"minItems",
"minimum",
];
/// Emit a stderr warning for each schema keyword this validator does not
/// enforce. Non-fatal: the schema is still accepted and stored verbatim —
/// operators can keep `pattern` / `format` / `oneOf` etc. as human-readable
/// hints without expecting runtime validation of them.
///
/// Walks the schema recursively so a nested `items` / `properties` sub-schema
/// with an unsupported keyword is caught too.
pub fn warn_unsupported_keywords(schema: &Value) {
fn walk(v: &Value, path: &str) {
if let Value::Object(map) = v {
for (k, sub) in map {
if !KNOWN_KEYWORDS.contains(&k.as_str()) {
eprintln!(
"[kei-artifact] schema warning: unsupported keyword '{k}' at {path} — \
stored but not enforced by the minimal validator (see validate.rs KNOWN_KEYWORDS)"
);
}
walk(sub, &format!("{path}.{k}"));
}
} else if let Value::Array(arr) = v {
for (i, el) in arr.iter().enumerate() {
walk(el, &format!("{path}[{i}]"));
}
}
}
walk(schema, "$");
}
fn check(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
if let Some(t) = schema.get("type") {
check_type(t, value, path)?;
@ -195,4 +243,26 @@ mod tests {
let err = validate_content(&schema, &json!(["nope"])).unwrap_err();
assert!(err.contains("enum"));
}
#[test]
fn warn_unsupported_keywords_does_not_panic_or_mutate() {
// Smoke test — the warn function prints to stderr but returns unit and
// never mutates the schema. We cannot portably capture stderr without
// a gag-style helper, so we just assert execution is stable and the
// schema is still usable by `validate_content` afterwards.
let schema = json!({
"type": "object",
"required": ["k"],
"properties": {
"k": {"type": "string", "pattern": "^[a-z]+$", "format": "email"}
},
"oneOf": [{"type": "object"}],
"patternProperties": {"^x_": {"type": "string"}}
});
warn_unsupported_keywords(&schema);
// Validator is still callable and still enforces the supported subset.
assert!(validate_content(&schema, &json!({"k": "hi"})).is_ok());
let err = validate_content(&schema, &json!({})).unwrap_err();
assert!(err.contains("k"));
}
}

View file

@ -137,3 +137,22 @@ fn patch_schema_rejects_invalid_op_enum() {
let msg = format!("{err:#}");
assert!(msg.contains("enum"), "unexpected: {msg}");
}
#[test]
fn review_schema_rejects_empty_findings() {
// v0.15.1 HIGH fix: review artifacts must list ≥ 1 finding so a `reject`
// or `request_changes` verdict cannot ship with nothing to point at.
let s = seed();
let bad = serde_json::to_vec(&json!({
"reviewer": "kei-critic",
"findings": [],
"verdict": "reject"
}))
.unwrap();
let err = emit(&s, "review", "kei-critic", &bad, None, None).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("array") || msg.contains("min"),
"unexpected: {msg}"
);
}

View file

@ -8,16 +8,21 @@
command -v jq >/dev/null 2>&1 || exit 0
# --- RUNTIME CONTROLS (v0.14.2) ---
# --- RUNTIME CONTROLS (v0.15.1) ---
# KEI_DISABLED_HOOKS: tokenized exact-match list (comma- or space-separated).
# Repro of pre-v0.15.1 substring bypass (CVE-class): KEI_DISABLED_HOOKS="foo-all-bar"
# previously disabled every hook via `*all*`. v0.15.1 requires token equality.
_hook_name="$(basename "$0" .sh)"
case "${KEI_DISABLED_HOOKS:-}" in
*"$_hook_name"*|*all*) exit 0 ;;
_disabled=" $(printf '%s' "${KEI_DISABLED_HOOKS:-}" | tr ',' ' ') "
case "$_disabled" in
*" $_hook_name "*|*" all "*) unset _disabled; exit 0 ;;
esac
unset _disabled
case "${KEI_HOOK_PROFILE:-full}" in
off) exit 0 ;;
minimal)
case "$_hook_name" in
no-github-push|genesis-leak-guard|no-hand-edit-agents|secrets-guard|assemble-validate|git-pre-commit-genesis) ;;
no-hand-edit-agents|assemble-validate|agent-fork-logger|session-end-dump) ;;
*) exit 0 ;;
esac
;;

View file

@ -13,16 +13,21 @@
# Claude Code would refuse the tool call system-wide.
command -v jq >/dev/null 2>&1 || exit 0
# --- RUNTIME CONTROLS (v0.14.2) ---
# --- RUNTIME CONTROLS (v0.15.1) ---
# KEI_DISABLED_HOOKS: tokenized exact-match list (comma- or space-separated).
# Repro of pre-v0.15.1 substring bypass (CVE-class): KEI_DISABLED_HOOKS="foo-all-bar"
# previously disabled every hook via `*all*`. v0.15.1 requires token equality.
_hook_name="$(basename "$0" .sh)"
case "${KEI_DISABLED_HOOKS:-}" in
*"$_hook_name"*|*all*) exit 0 ;;
_disabled=" $(printf '%s' "${KEI_DISABLED_HOOKS:-}" | tr ',' ' ') "
case "$_disabled" in
*" $_hook_name "*|*" all "*) unset _disabled; exit 0 ;;
esac
unset _disabled
case "${KEI_HOOK_PROFILE:-full}" in
off) exit 0 ;;
minimal)
case "$_hook_name" in
no-github-push|genesis-leak-guard|no-hand-edit-agents|secrets-guard|assemble-validate|git-pre-commit-genesis) ;;
no-hand-edit-agents|assemble-validate|agent-fork-logger|session-end-dump) ;;
*) exit 0 ;;
esac
;;

View file

@ -10,16 +10,21 @@
# Claude Code would refuse the tool call system-wide.
command -v jq >/dev/null 2>&1 || exit 0
# --- RUNTIME CONTROLS (v0.14.2) ---
# --- RUNTIME CONTROLS (v0.15.1) ---
# KEI_DISABLED_HOOKS: tokenized exact-match list (comma- or space-separated).
# Repro of pre-v0.15.1 substring bypass (CVE-class): KEI_DISABLED_HOOKS="foo-all-bar"
# previously disabled every hook via `*all*`. v0.15.1 requires token equality.
_hook_name="$(basename "$0" .sh)"
case "${KEI_DISABLED_HOOKS:-}" in
*"$_hook_name"*|*all*) exit 0 ;;
_disabled=" $(printf '%s' "${KEI_DISABLED_HOOKS:-}" | tr ',' ' ') "
case "$_disabled" in
*" $_hook_name "*|*" all "*) unset _disabled; exit 0 ;;
esac
unset _disabled
case "${KEI_HOOK_PROFILE:-full}" in
off) exit 0 ;;
minimal)
case "$_hook_name" in
no-github-push|genesis-leak-guard|no-hand-edit-agents|secrets-guard|assemble-validate|git-pre-commit-genesis) ;;
no-hand-edit-agents|assemble-validate|agent-fork-logger|session-end-dump) ;;
*) exit 0 ;;
esac
;;

View file

@ -8,16 +8,21 @@
command -v jq >/dev/null 2>&1 || exit 0
# --- RUNTIME CONTROLS (v0.14.2) ---
# --- RUNTIME CONTROLS (v0.15.1) ---
# KEI_DISABLED_HOOKS: tokenized exact-match list (comma- or space-separated).
# Repro of pre-v0.15.1 substring bypass (CVE-class): KEI_DISABLED_HOOKS="foo-all-bar"
# previously disabled every hook via `*all*`. v0.15.1 requires token equality.
_hook_name="$(basename "$0" .sh)"
case "${KEI_DISABLED_HOOKS:-}" in
*"$_hook_name"*|*all*) exit 0 ;;
_disabled=" $(printf '%s' "${KEI_DISABLED_HOOKS:-}" | tr ',' ' ') "
case "$_disabled" in
*" $_hook_name "*|*" all "*) unset _disabled; exit 0 ;;
esac
unset _disabled
case "${KEI_HOOK_PROFILE:-full}" in
off) exit 0 ;;
minimal)
case "$_hook_name" in
no-github-push|genesis-leak-guard|no-hand-edit-agents|secrets-guard|assemble-validate|git-pre-commit-genesis) ;;
no-hand-edit-agents|assemble-validate|agent-fork-logger|session-end-dump) ;;
*) exit 0 ;;
esac
;;

View file

@ -8,16 +8,21 @@
command -v jq >/dev/null 2>&1 || exit 0
# --- RUNTIME CONTROLS (v0.14.2) ---
# --- RUNTIME CONTROLS (v0.15.1) ---
# KEI_DISABLED_HOOKS: tokenized exact-match list (comma- or space-separated).
# Repro of pre-v0.15.1 substring bypass (CVE-class): KEI_DISABLED_HOOKS="foo-all-bar"
# previously disabled every hook via `*all*`. v0.15.1 requires token equality.
_hook_name="$(basename "$0" .sh)"
case "${KEI_DISABLED_HOOKS:-}" in
*"$_hook_name"*|*all*) exit 0 ;;
_disabled=" $(printf '%s' "${KEI_DISABLED_HOOKS:-}" | tr ',' ' ') "
case "$_disabled" in
*" $_hook_name "*|*" all "*) unset _disabled; exit 0 ;;
esac
unset _disabled
case "${KEI_HOOK_PROFILE:-full}" in
off) exit 0 ;;
minimal)
case "$_hook_name" in
no-github-push|genesis-leak-guard|no-hand-edit-agents|secrets-guard|assemble-validate|git-pre-commit-genesis) ;;
no-hand-edit-agents|assemble-validate|agent-fork-logger|session-end-dump) ;;
*) exit 0 ;;
esac
;;

View file

@ -12,16 +12,21 @@
# Claude Code would refuse Edit/Write system-wide.
command -v jq >/dev/null 2>&1 || exit 0
# --- RUNTIME CONTROLS (v0.14.2) ---
# --- RUNTIME CONTROLS (v0.15.1) ---
# KEI_DISABLED_HOOKS: tokenized exact-match list (comma- or space-separated).
# Repro of pre-v0.15.1 substring bypass (CVE-class): KEI_DISABLED_HOOKS="foo-all-bar"
# previously disabled every hook via `*all*`. v0.15.1 requires token equality.
_hook_name="$(basename "$0" .sh)"
case "${KEI_DISABLED_HOOKS:-}" in
*"$_hook_name"*|*all*) exit 0 ;;
_disabled=" $(printf '%s' "${KEI_DISABLED_HOOKS:-}" | tr ',' ' ') "
case "$_disabled" in
*" $_hook_name "*|*" all "*) unset _disabled; exit 0 ;;
esac
unset _disabled
case "${KEI_HOOK_PROFILE:-full}" in
off) exit 0 ;;
minimal)
case "$_hook_name" in
no-github-push|genesis-leak-guard|no-hand-edit-agents|secrets-guard|assemble-validate|git-pre-commit-genesis) ;;
no-hand-edit-agents|assemble-validate|agent-fork-logger|session-end-dump) ;;
*) exit 0 ;;
esac
;;

View file

@ -8,16 +8,21 @@
command -v jq >/dev/null 2>&1 || exit 0
# --- RUNTIME CONTROLS (v0.14.2) ---
# --- RUNTIME CONTROLS (v0.15.1) ---
# KEI_DISABLED_HOOKS: tokenized exact-match list (comma- or space-separated).
# Repro of pre-v0.15.1 substring bypass (CVE-class): KEI_DISABLED_HOOKS="foo-all-bar"
# previously disabled every hook via `*all*`. v0.15.1 requires token equality.
_hook_name="$(basename "$0" .sh)"
case "${KEI_DISABLED_HOOKS:-}" in
*"$_hook_name"*|*all*) exit 0 ;;
_disabled=" $(printf '%s' "${KEI_DISABLED_HOOKS:-}" | tr ',' ' ') "
case "$_disabled" in
*" $_hook_name "*|*" all "*) unset _disabled; exit 0 ;;
esac
unset _disabled
case "${KEI_HOOK_PROFILE:-full}" in
off) exit 0 ;;
minimal)
case "$_hook_name" in
no-github-push|genesis-leak-guard|no-hand-edit-agents|secrets-guard|assemble-validate|git-pre-commit-genesis) ;;
no-hand-edit-agents|assemble-validate|agent-fork-logger|session-end-dump) ;;
*) exit 0 ;;
esac
;;

View file

@ -13,16 +13,21 @@
command -v jq >/dev/null 2>&1 || exit 0
# --- RUNTIME CONTROLS (v0.14.2) ---
# --- RUNTIME CONTROLS (v0.15.1) ---
# KEI_DISABLED_HOOKS: tokenized exact-match list (comma- or space-separated).
# Repro of pre-v0.15.1 substring bypass (CVE-class): KEI_DISABLED_HOOKS="foo-all-bar"
# previously disabled every hook via `*all*`. v0.15.1 requires token equality.
_hook_name="$(basename "$0" .sh)"
case "${KEI_DISABLED_HOOKS:-}" in
*"$_hook_name"*|*all*) exit 0 ;;
_disabled=" $(printf '%s' "${KEI_DISABLED_HOOKS:-}" | tr ',' ' ') "
case "$_disabled" in
*" $_hook_name "*|*" all "*) unset _disabled; exit 0 ;;
esac
unset _disabled
case "${KEI_HOOK_PROFILE:-full}" in
off) exit 0 ;;
minimal)
case "$_hook_name" in
no-github-push|genesis-leak-guard|no-hand-edit-agents|secrets-guard|assemble-validate|git-pre-commit-genesis) ;;
no-hand-edit-agents|assemble-validate|agent-fork-logger|session-end-dump) ;;
*) exit 0 ;;
esac
;;

View file

@ -11,16 +11,21 @@
# Claude Code would refuse Read system-wide.
command -v jq >/dev/null 2>&1 || exit 0
# --- RUNTIME CONTROLS (v0.14.2) ---
# --- RUNTIME CONTROLS (v0.15.1) ---
# KEI_DISABLED_HOOKS: tokenized exact-match list (comma- or space-separated).
# Repro of pre-v0.15.1 substring bypass (CVE-class): KEI_DISABLED_HOOKS="foo-all-bar"
# previously disabled every hook via `*all*`. v0.15.1 requires token equality.
_hook_name="$(basename "$0" .sh)"
case "${KEI_DISABLED_HOOKS:-}" in
*"$_hook_name"*|*all*) exit 0 ;;
_disabled=" $(printf '%s' "${KEI_DISABLED_HOOKS:-}" | tr ',' ' ') "
case "$_disabled" in
*" $_hook_name "*|*" all "*) unset _disabled; exit 0 ;;
esac
unset _disabled
case "${KEI_HOOK_PROFILE:-full}" in
off) exit 0 ;;
minimal)
case "$_hook_name" in
no-github-push|genesis-leak-guard|no-hand-edit-agents|secrets-guard|assemble-validate|git-pre-commit-genesis) ;;
no-hand-edit-agents|assemble-validate|agent-fork-logger|session-end-dump) ;;
*) exit 0 ;;
esac
;;

View file

@ -1,6 +1,6 @@
---
name: hooks-control
description: Runtime enable/disable of KeiSeiKit hooks via env vars (v0.14.2). Click-only wizard that emits shell `export` / `unset` commands for the user to paste. Supports per-hook disable, profile switch (full / advisory-off / minimal / off), or full re-enable. Does NOT execute anything — user controls their shell.
description: Runtime enable/disable of KeiSeiKit hooks via env vars (v0.15.1). Click-only wizard that emits shell `export` / `unset` commands for the user to paste. Supports per-hook disable, profile switch (full / advisory-off / minimal / off), or full re-enable. Does NOT execute anything — user controls their shell.
argument-hint: (none — fully click-driven)
---
@ -10,18 +10,18 @@ Click-only wizard. Helps you toggle KeiSeiKit hooks **for the current shell
session** via env vars, without editing `~/.claude/settings.json`. The skill
emits shell commands; it NEVER runs them.
Two env vars are honoured by every kit-shipped hook (v0.14.2+):
Two env vars are honoured by every kit-shipped hook (v0.15.1+):
| Var | Meaning |
|---|---|
| `KEI_DISABLED_HOOKS` | Comma- or space-list of hook base names (no `.sh`). `all` disables every hook. |
| `KEI_DISABLED_HOOKS` | Comma- or space-list of hook base names (no `.sh`). Matching is **tokenized exact-match** (v0.15.1 fix — earlier versions used substring-glob, which let `foo-all-bar` disable every hook). The literal `all` token still disables every hook. |
| `KEI_HOOK_PROFILE` | One of `full` (default), `advisory-off`, `minimal`, `off`. |
| Profile | What stays on |
|---|---|
| `full` (default) | Every hook |
| `advisory-off` | Disables pure-stderr advisories: `recurrence-suggest`, `citation-verify`, `error-spike-detector`, `milestone-commit-hook`. |
| `minimal` | Safety-only: `no-github-push`, `genesis-leak-guard`, `no-hand-edit-agents`, `secrets-guard`, `assemble-validate`, `git-pre-commit-genesis`. |
| `minimal` | Only the four kit-shipped hooks needed for structural integrity or observability: `no-hand-edit-agents`, `assemble-validate`, `agent-fork-logger`, `session-end-dump`. User-global safety hooks (`no-github-push`, `secrets-guard`, `genesis-leak-guard`, `git-pre-commit-genesis`) are not shipped by the kit but are respected when present in `~/.claude/hooks/`. |
| `off` | Every hook off (escape hatch — use when debugging hook interactions). |
---
@ -34,7 +34,7 @@ Print current state:
```
Current KEI_DISABLED_HOOKS: <value or "(unset)">
Current KEI_HOOK_PROFILE: <value or "full (default)">
Active kit-shipped hooks: <list of 10 minus disabled set>
Active kit-shipped hooks: <list of 9 minus disabled set>
```
`AskUserQuestion` — **What do you want to do?**
@ -45,10 +45,10 @@ Active kit-shipped hooks: <list of 10 minus disabled set>
### Phase 2a — Hook multi-select (if picked 1)
`AskUserQuestion` multi-select over the 10 kit-shipped hook names:
`AskUserQuestion` multi-select over the 9 kit-shipped hook names:
`assemble-agents`, `assemble-validate`, `no-hand-edit-agents`, `tomd-preread`,
`agent-fork-logger`, `site-wysiwyd-check`, `error-spike-detector`,
`milestone-commit-hook`, `session-end-dump`, `git-pre-commit-genesis`.
`milestone-commit-hook`, `session-end-dump`.
Emit:
```sh
@ -88,7 +88,7 @@ Stop after the state block.
exit — the shell running hooks is a subshell.
- **No rc edits.** If the user wants persistence, we say "paste into your
shell rc". The skill MUST NOT modify `~/.zshrc` / `~/.bashrc`.
- **RULE 0.4 — no invented hook names.** Only the 10 names in Phase 2a
- **RULE 0.4 — no invented hook names.** Only the 9 names in Phase 2a
are valid choices. Never suggest a name not in the kit.
- **RULE -1 — NO DOWNGRADE.** If the user asks "can I silence all safety
hooks?", present tradeoffs; point at `KEI_HOOK_PROFILE=off` with a
@ -111,7 +111,7 @@ Undo: unset KEI_DISABLED_HOOKS KEI_HOOK_PROFILE
## References
- `hooks/*.sh` — each kit hook sources the v0.14.2 runtime-controls block
- `hooks/*.sh` — each kit hook sources the v0.15.1 runtime-controls block
- `README.md` → "Runtime hook controls" section
- `~/.claude/rules/recurrence-escalate.md` — severity ladder notes that
hooks can be silenced at runtime, no rule deletion required