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

@@ -24,7 +24,7 @@ The shape Claude Code reads, and therefore the shape to author under `.apm/hooks
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "echo 'tool used'" }
{ "type": "command", "command": "echo 'tool used'", "timeout": 10 }
]
}
]
@@ -71,9 +71,12 @@ tracked in a `.claude/apm-hooks.json` sidecar, so an uninstall removes them with
hand-authored hooks. Both `.claude/hooks/` and the sidecar are gitignored install output.
Note that apm's **executable-trust gate is off** unless the consuming project's `apm.yml` has an
`executables:` block — without one, package hooks deploy with no prompt. The allow key is
version-pinned (`kyberforge#<version>`), so a version bump on one side alone stops the hook
deploying; `check-executables-allow-sync` is the pre-push gate that catches it. See ADR-0019.
`executables:` block — without one, package hooks deploy with no prompt. The allow key carries a
version (`kyberforge#<version>`), but apm 0.28.0 matches grants version-blind, so a version bump on
one side does not stop the hook deploying. `check-executables-allow-sync` is a pre-push gate for this
repo's own convention that the key tracks `plugins/kyberforge/apm.yml`'s `version:`, not for an apm
mechanic. See ADR-0019, correction 2026-09-19, and the comment above `executables:` in the root
`apm.yml`.
## The SessionStart hook
@@ -82,14 +85,16 @@ and if anything is behind, runs `apm update --yes` and returns `reloadSkills: tr
session picks up the redeployed content. Rationale, measurements, and the failure modes are in
ADR-0019.
**Where it looks for the lockfile.** The hook resolves a project directory as `${CLAUDE_PROJECT_DIR}`
when the host exports it (Claude Code does, for SessionStart hooks) and the current directory
otherwise, then exits silently unless that directory holds an `apm.lock.yaml` — which is what makes
it inert in any project that does not consume packages through apm. Both `apm` invocations run
against the same resolved directory. The earlier spelling checked a bare `apm.lock.yaml` against the
session's cwd, so a session opened in a subdirectory of an apm-consuming repo no-opped silently.
Keep the cwd fallback: a host that sets no `CLAUDE_PROJECT_DIR` must still get inert-but-harmless
behaviour, not an unset-variable error.
**Claude Code only, and where it looks for the lockfile.** The hook exits 0 at once, silently and
without calling `apm`, unless `CLAUDE_PROJECT_DIR` is set and non-empty — Claude Code exports it for
SessionStart hooks, and that guard is what keeps the hook inert under Copilot and Codex (see below).
It then takes `${CLAUDE_PROJECT_DIR}` as the project directory and exits silently unless that
directory holds an `apm.lock.yaml` — which is what makes it inert in any project that does not
consume packages through apm. Both `apm` invocations run against the same directory. The earlier
spelling checked a bare `apm.lock.yaml` against the session's cwd, so a session opened in a
subdirectory of an apm-consuming repo no-opped silently. Do not reintroduce a cwd fallback: under a
host that sets no `CLAUDE_PROJECT_DIR` the lockfile guard passes in every apm consumer, and the
fallback ran `apm update --yes` there (ADR-0019, correction 2026-09-28).
**The `timeout` in `hooks.json` must exceed the script's own budget.** The script spends at most
`timeout 60 apm outdated` plus `timeout 300 apm update`; the hook entry declares `timeout: 380`, the
@@ -123,10 +128,12 @@ apm 0.28.0):
- **Codex** gets the entry merged into `.codex/hooks.json`, but only when `.codex/` already exists;
otherwise nothing is written.
This is accepted rather than fixed (ADR-0019, amendment 2026-09-28). The hook's behaviour is
Claude-specific anyway — the `startup` matcher, `CLAUDE_PROJECT_DIR`, and the `reloadSkills`
output — and the script exits silently without an `apm.lock.yaml`, so a harness that does run it is
unharmed. The only apm-native way to keep it Claude-only is a separate package whose `apm.yml`
This is accepted rather than fixed (ADR-0019, amendment and correction 2026-09-28). The hook's
behaviour is Claude-specific anyway — the `startup` matcher, `CLAUDE_PROJECT_DIR`, and the
`reloadSkills` output — and a harness that does run it exits immediately, because the script's
first guard exits 0 when `CLAUDE_PROJECT_DIR` is unset. The `apm.lock.yaml` guard cannot do that
job: `apm install` wrote the lock, so it passes in every project the hook reaches. The only
apm-native way to keep it Claude-only is a separate package whose `apm.yml`
declares `target: claude`; per-file target routing (`claude-hooks.json`) is deprecated, and
kyberforge cannot narrow its own `targets:` without dropping its skills from Copilot and Codex.

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.