@@ -27,15 +27,17 @@ import v8 from 'node:v8'
2727import { errorMessage } from '@socketsecurity/lib-stable/errors/message'
2828import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
2929
30+ import { bypassFooter , bypassPhrasesFor } from './bypass.mts'
31+ import { isFleetManagedDir , isFleetManagedPath } from './fleet-repo.mts'
3032import {
3133 readCommand ,
3234 readFilePath ,
3335 readPayload ,
3436 readWriteContent ,
3537} from './payload.mts'
3638import type { ToolCallPayload } from './payload.mts'
37- import { isFleetManagedDir , isFleetManagedPath } from './fleet-repo.mts'
3839import { commandWorkingDir } from './shell-command.mts'
40+ import { bypassPhrasePresent } from './transcript.mts'
3941
4042// Lazily resolved, NOT eagerly at module-eval. The shared logger graph
4143// (`@socketsecurity/lib`'s logger → primordials/globals) captures `SharedArray
@@ -125,6 +127,17 @@ export type HookMatcher = string
125127 * generator; `check` is the runtime verdict function.
126128 */
127129export interface HookSpec {
130+ // Bypass phrase slug(s) — the `<slug>` in the canonical `Allow <slug> bypass`
131+ // (see _shared/bypass.mts). When set with the default `bypassMode: 'auto'`,
132+ // defineHook wraps `check` so a block verdict is (a) waved through when the
133+ // user typed any accepted phrase, else (b) annotated with the EXACT phrase(s)
134+ // to type — both derived from this one array, so the phrase shown is provably
135+ // the phrase detected. `bypassMode: 'manual'` declares the phrase for the doc
136+ // enumeration + message reuse but leaves detection to the guard's own `check`
137+ // (per-trigger / targeted `Allow <slug> bypass: <target>` bypasses that a
138+ // simple presence test would wrongly re-authorize).
139+ readonly bypass ?: readonly string [ ] | undefined
140+ readonly bypassMode ?: 'auto' | 'manual' | undefined
128141 readonly check : GuardCheck
129142 readonly event : HookEvent
130143 readonly matcher ?: HookMatcher | readonly HookMatcher [ ] | undefined
@@ -327,25 +340,68 @@ function payloadTargetIsFleetManaged(payload: ToolCallPayload): boolean {
327340 return isFleetManagedDir ( payload ?. cwd || process . cwd ( ) )
328341}
329342
343+ /**
344+ * Wrap a check so a BLOCK or a NUDGE (notify) verdict honors the hook's
345+ * declared bypass phrase. When the user typed any accepted phrase (case /
346+ * separator / colon-space / newline-tolerant — see transcript.mts) the verdict
347+ * is dropped (undefined): the block is allowed through, the nudge is silenced —
348+ * matching the pre-metadata per-guard behavior. Otherwise the message gains the
349+ * uniform footer naming the EXACT phrase(s) to type. The detector input and the
350+ * footer both come from `bypassPhrasesFor(slugs)`, so a guard can never surface
351+ * a phrase the detector wouldn't accept — and never forget to surface one.
352+ * Auto-mode only; a per-trigger / targeted guard keeps its own detection
353+ * (`bypassMode: 'manual'`).
354+ */
355+ function withBypass ( base : GuardCheck , slugs : readonly string [ ] ) : GuardCheck {
356+ const phrases = bypassPhrasesFor ( slugs )
357+ return async ( payload : ToolCallPayload ) => {
358+ const result = await base ( payload )
359+ if ( ! result ) {
360+ return result
361+ }
362+ // A typed phrase authorizes the action (block) or silences the nudge
363+ // (notify) — drop the verdict so the tool call proceeds with no output.
364+ if ( bypassPhrasePresent ( payload . transcript_path , phrases ) ) {
365+ return undefined
366+ }
367+ // No phrase — surface the EXACT phrase(s) to type on whichever verdict.
368+ const footer = bypassFooter ( phrases )
369+ return result . kind === 'block'
370+ ? block ( `${ result . message } \n\n${ footer } ` )
371+ : notify ( `${ result . message } \n\n${ footer } ` )
372+ }
373+ }
374+
330375/**
331376 * Build a typed hook instance from its spec. Pure — safe to import, so the
332377 * build-time generator can read `.event` / `.type` / `.matcher` / `.triggers`
333378 * off it without running the hook.
334379 *
335380 * A `scope: 'convention'` spec gets its check wrapped so the hook stands down
336- * when the acted-on repo is not fleet-managed (no `.config/fleet/` at its
337- * root) — fleet conventions never bind a foreign repo unless it opts in by
338- * carrying that directory. The module's own exported raw `check` is untouched,
339- * so in-process tests still exercise the logic directly.
381+ * when the acted-on repo is not fleet-managed (no `.config/fleet/` at its root)
382+ * — fleet conventions never bind a foreign repo unless it opts in by carrying
383+ * that directory. A `bypass` spec (auto mode) wraps the check so a block/nudge
384+ * honors the declared phrase (withBypass). The module's own exported raw
385+ * `check` is untouched, so in-process tests still exercise the logic.
340386 */
387+
341388export function defineHook ( spec : HookSpec ) : Hook {
342- const check : GuardCheck =
389+ const scoped : GuardCheck =
343390 spec . scope === 'convention'
344391 ? payload =>
345392 payloadTargetIsFleetManaged ( payload ) ? spec . check ( payload ) : undefined
346393 : spec . check
394+ // Auto-bypass wrapping — only when phrases are declared AND detection is not
395+ // hand-owned. Applied OUTSIDE the convention scope so a foreign-repo stand-
396+ // down (scoped → undefined, never a block) never reaches the bypass path.
397+ const check : GuardCheck =
398+ spec . bypass && spec . bypass . length > 0 && spec . bypassMode !== 'manual'
399+ ? withBypass ( scoped , spec . bypass )
400+ : scoped
347401 return {
348402 __proto__ : null ,
403+ bypass : spec . bypass ,
404+ bypassMode : spec . bypassMode ,
349405 check,
350406 event : spec . event ,
351407 invoke ( payload : ToolCallPayload ) {
0 commit comments