fix(hook-protocol): discard a hookSpecificOutput block whose hookEventName mismatches the firing event
The reference schemas key the `hookSpecificOutput` block by `hookEventName`, so a block naming a DIFFERENT event than the one firing is malformed — a hook emitting `hookSpecificOutput.hookEventName: "PreToolUse"` on a `Stop` event must not deny the Stop. The codec surfaced `hookEventName` for a bridge to compare but never enforced the discard, so both bridges pushed every parsed output into the merge unconditionally. parseHookOutput now takes an optional `expectedEventName`; when the block's `hookEventName` names a different event, its event-scoped fields (permissionDecision/permissionDecisionReason/additionalContext/updatedInput) are discarded (the discriminator is still surfaced for the log, and the event-agnostic top-level decision/continue/etc. are unaffected). runHook threads it via RunHookOptions.expectedEventName; a caller that omits it opts out. Codex review finding on the bridges PR (PR-F); fixed here on the codec that owns the fold and knows field provenance, then flows down to both bridges.
This commit is contained in:
@@ -17,8 +17,8 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
## Primitives
|
||||
|
||||
- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws).
|
||||
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `defaultTimeoutMs`), and decode the result. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
||||
- **`parseHookOutput(exitCode, stdout, stderr)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason`/`suppressOutput` are parsed too. Pure and total.
|
||||
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `defaultTimeoutMs`), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason`/`suppressOutput` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed), while the event-agnostic top-level fields still apply. Pure and total.
|
||||
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
|
||||
|
||||
## `hook/*` session events
|
||||
|
||||
Reference in New Issue
Block a user