fix(kyberforge): resolve PR #144 review and audit round 1

- factory-audit: no-op hooks, ./ after interpreters, split-quote and
  spaced ${PLUGIN_ROOT} paths, camelCase events in Claude-targeted flat
  files, case-insensitive routing stems, and non-string YAML keys are
  now caught; input: forms and prompt boundary clauses align with
  primitive-author; bats 347 -> 367
- primitive-author: routing forms, quoting guidance, install exit on
  hidden Unicode, argument-hint exception
- forge: drop duplicated gotcha, fit description and body budgets (#143)
- skill-author: primitive-author boundary, Claude-only env vars
- hook: exit unless CLAUDE_PROJECT_DIR is set, so Copilot/Codex never
  run apm update; ADR-0019 correction, ADR-0025 amendment, docs fixes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkT7RSDwDbmrM9T34b6sTi
This commit is contained in:
2026-09-28 20:13:43 +00:00
parent b2d77b2945
commit df28351d3e
23 changed files with 591 additions and 147 deletions

View File

@@ -13,7 +13,7 @@ Ground truth for this file is the installed apm-cli **0.28.0** source (`apm_cli/
- `HookIntegrator.find_hook_files()` globs `<pkg>/.apm/hooks/*.json` first, then `<pkg>/hooks/*.json` (Claude-native layout). Non-recursive; symlinks skipped; stems deduplicated case-insensitively, so `.apm/hooks/x.json` shadows `hooks/x.json`. `security/executables.scan_package_executables` uses the same two directories.
- Genuinely JSON, not Markdown-with-frontmatter. There is no `Hook` dataclass in `primitives/models.py`; hooks never enter `discover_primitives()`, so `apm compile` (and `apm compile --validate`) never see them. Hooks are deployed by `apm install` only.
- **Filename routing (deprecated, still active).** `hook_file_routing._hook_file_allowed_targets` routes a file whose stem is `hooks-<token>` or ends `-<token>-hooks` (tokens: `copilot`, `vscode`, `cursor`, `claude`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`) to that target only, with a deprecation warning. If any file for a target is target-specific, universal files are ignored for that target (`specific if specific else universal`). A stem like `claude-hooks.json` is therefore Claude-only. The replacement is `target:`/`targets:` in the package's own `apm.yml`, or object-form per-dependency `targets:` on the consumer side.
- **Filename routing (deprecated, still active).** `hook_file_routing._hook_file_allowed_targets` lowercases the stem first (`hook_file.stem.lower()`), so matching is case-insensitive: `Claude-Hooks.json` routes like `claude-hooks.json`. It routes a file whose stem is `hooks-<token>`, is a bare `<token>-hooks`, or ends `-<token>-hooks` (tokens: `copilot`, `vscode`, `cursor`, `claude`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`) to that target only, with a deprecation warning. A combined stem whose trailing segments are all tokens, such as `claude-codex-hooks`, routes to the union of those targets (`_target_suffix_segments`, `_union_target_sets`). `copilot` and `vscode` are one set: either token selects both. If any file for a target is target-specific, universal files are ignored for that target (`specific if specific else universal`). A stem like `claude-hooks.json` is therefore Claude-only. The replacement is `target:`/`targets:` in the package's own `apm.yml`, or object-form per-dependency `targets:` on the consumer side.
## Accepted source shapes
@@ -77,7 +77,7 @@ Nested and flat entries can be mixed in one event array. Parse failure modes (al
**Flat Copilot entry → Claude** (live): `bash` becomes `command`, `timeoutSec` becomes `timeout`, and the **unused `powershell` key and any other extras are left in the Claude handler** (for example `"powershell": "pwsh $env:CLAUDE_PROJECT_DIR/…"`). Whether Claude Code tolerates unknown handler keys has not been verified here.
**Confirmed for this repo:** `plugins/kyberforge/.apm/hooks/hooks.json` (`SessionStart`, `"matcher": "startup"`, `command: ${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh`, `timeout: 380`) compiles in `/root/ai-development/.claude/settings.json` to `{"matcher": "startup", "hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/kyberforge/.apm/hooks/check-apm-current.sh\"", "timeout": 380}]}`. Matcher and timeout are kept, and the path is re-anchored and quoted.
**Confirmed for this repo:** `plugins/kyberforge/.apm/hooks/hooks.json` (`SessionStart`, `"matcher": "startup"`, `command: ${PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh`, `timeout: 380`) compiles in `/root/ai-development/.claude/settings.json` to `{"matcher": "startup", "hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/kyberforge/.apm/hooks/check-apm-current.sh\"", "timeout": 380}]}`. Matcher and timeout are kept, and the path is re-anchored and quoted.
### Copilot: one file per source file, *not* reshaped
@@ -110,7 +110,7 @@ Merge targets: `cursor` (`.cursor/hooks.json`, `version: 1` default), `codex` (`
## Security / trust gate
Hooks are an executable primitive (`security/executables.EXEC_TYPE_HOOKS`). If the consuming project's `apm.yml` has an `allowExecutables` block, dependency hooks are deny-by-default until approved (`apm approve`). Non-interactive runs hard-error. Local project content (`_local`) is always trusted (`install/exec_gate.check_executable_approval`). With no `allowExecutables` block, everything deploys. The pre-deploy hidden-Unicode scan (`install/helpers/security_scan`, `BLOCK_POLICY`) also covers hook files.
Hooks are an executable primitive (`security/executables.EXEC_TYPE_HOOKS`). If the consuming project's `apm.yml` has an `executables:` block (`executables: {allow: …, deny: …}`, the form this repo's root `apm.yml` uses), dependency hooks are deny-by-default until approved (`apm approve`). `allowExecutables` is the deprecated spelling, still read as an alias for one minor cycle and migrated into `executables.allow` on write (`security/executables.parse_project_executables`, `write_project_executables`). Non-interactive runs hard-error. Local project content (`_local`) is always trusted (`install/exec_gate.check_executable_approval`). With neither block, everything deploys. The pre-deploy hidden-Unicode scan (`install/helpers/security_scan`, `BLOCK_POLICY`) also covers hook files.
## Validation constraints and gotchas
@@ -127,13 +127,13 @@ Must (an author skill enforces these; an audit skill checks them):
3. Every event value is a list of objects, and every nested `hooks` is a list of objects. Otherwise the Copilot install fails. *Source: `_validate_copilot_payload`.*
4. Event names use PascalCase for Claude (`PreToolUse`, `UserPromptSubmit`, `SessionStart`, `Stop`, …). Never use all-lowercase names, and never use camelCase for events outside the Claude map. *Source: `_HOOK_EVENT_MAP["claude"]`, `_detect_event_casing`.*
5. Script references use `${CLAUDE_PLUGIN_ROOT}/…` / `${PLUGIN_ROOT}/…` (package-root relative) or `./…` (hook-dir relative), and the referenced file exists inside the package. No absolute paths, and no `$` or backtick in the script path. *Source: `_rewrite_command_for_target`, `_project_scoped_command_path`.*
6. Avoid a stem matching `hooks-<target>` or `*-<target>-hooks` unless you intend deprecated routing. Use `target:` in the package `apm.yml` instead. *Source: `hook_file_routing`.*
6. Avoid a stem matching `hooks-<target>`, `<target>-hooks`, `*-<target>-hooks` or a combined `<a>-<b>-hooks`, in any letter case, unless you intend deprecated routing. Use `target:` in the package `apm.yml` instead. *Source: `hook_file_routing`.*
Should:
7. Every handler sets `"type": "command"` and an explicit `timeout` in seconds. APM passes `type` through but never supplies it. *Source: `_handler_to_ir`, `_handler_from_ir`.*
8. Set `matcher` explicitly on tool events (`PreToolUse`/`PostToolUse`) and on `SessionStart` (`startup` / `resume` / …). If you omit it, Claude receives `"*"`. *Source: `_to_claude_hook_entries` `default_matcher="*"`.*
9. For a Claude-targeted package, do not author `bash`/`powershell`/`timeoutSec`. They render but leave stray keys. For Copilot-correct output, author the flat Copilot shape in a Copilot-targeted file. *Source: live render; `_handler_to_ir`.*
10. Quote script paths that may contain spaces. *Source: published hooks guide; quote detection in `_rewrite_command_for_target`.*
10. Script paths must not contain spaces. apm's token pattern is `\$\{…PLUGIN_ROOT\}([\\/][^\s"']+)`, so the path must follow `}` directly and ends at the first whitespace or quote. Quoting the whole token, `"${PLUGIN_ROOT}/x.sh"`, is rewritten (and keeps its quotes); the split form `"${PLUGIN_ROOT}"/x.sh` is never matched and deploys unrewritten and unbundled; `"${PLUGIN_ROOT}/my hook.sh"` resolves only `my` and warns "Hook script not found". Quoting does not make a space safe. *Source: `plugin_root_pattern` and `rel_pattern` in `_rewrite_command_for_target` (`hook_integrator.py` ~L652, L694); the adjacent-quote check there only decides whether apm adds its own quotes.*
11. Hook scripts must be executable and self-contained within the hook directory bundle. For Copilot, do not ship `.json` helper files in the bundle, because Copilot's loader rejects JSON without a `hooks` key. *Source: published hooks guide; `copy_deployed_hook_bundle(exclude_json_files=True)`.*
Audit-only (apm does not check these): unknown or misspelled event names; missing `type`; non-numeric timeout; matcher on non-tool events; extra handler keys that the target ignores.