Skip to content

feat(hooks): Hookshot integration — cross-platform hook dispatcher, DIY tripwire, and L3 policy gate - #68

Open
basicScandal wants to merge 5 commits into
mainfrom
feat/hookshot-hook-dispatcher
Open

feat(hooks): Hookshot integration — cross-platform hook dispatcher, DIY tripwire, and L3 policy gate#68
basicScandal wants to merge 5 commits into
mainfrom
feat/hookshot-hook-dispatcher

Conversation

@basicScandal

Copy link
Copy Markdown
Member

What & why

Integrates the Hookshot model into Starlog: a portable agent-lifecycle hook contract that turns Starlog from pull-based/advisory into a push + enforcement plane, wired into starlog init so it fires in Claude Code today. Full design + decision log in .planning/hookshot-integration.md.

Four handlers ride one contract (starlog hook <event>, stdin {event,cwd,payload} → stdout {decision: allow|warn|deny, message?, source}, fail-open):

Phase Handler What it does
0 contract portable dispatcher + starlog hook <event> CLI; before_execution/prompt_submit/stop forward-compatible
1 after_file_edit recurrence-gated DIY-capability tripwire (cheap detectFile gate → warn once at threshold → points at starlog_advise)
1.5 inline hint tripwire names example vetted libs per category (eval-motivated)
2 before_execution L3 policy gate — blocks/asks on a policy-denied dependency install

Did the tripwire actually change behavior? (we measured)

Built a 3-arm decision-flip eval (npm run eval:tripwire) with the decision rule precommitted before running (anti-confirmation-bias). Result (haiku, k=15, 90/arm):

Arm adopt-library rate
A control 6.7%
B +tripwire 66.7%
C +advice inlined 83.3%

A→B = +60pp → KEEP. Honest boundary (documented): the control was a bare model, so this proves the nudge text works (upper bound), not marginal value over Starlog's existing MCP tool + CLAUDE.md — that needs a multi-turn eval. The eval also surfaced the inline-hint win (B→C +16.7pp), which Phase 1.5 acts on.

Key decisions

  • Go stays out of the npm package. The deliverable is a stable starlog hook <event> CLI; a thin Go/Hookshot shim (future, separate repo) fronts Cursor/Windsurf/Factory/Codex. L1/L2/L3 stays TypeScript.
  • before_execution = dependency-introduction gating only, not general command safety. denypermissionDecision:"deny" (hard block), warn"ask" (surface + user decides), allow→nothing. Default warn; opt-in hard block via STARLOG_HOOK_ENFORCE=deny. Local-only policy resolution (no network in the blocking hot path).
  • Field names / output formats verified against primary source (code.claude.com/docs + this repo's real tool schemas) — a docs summary had hallucinated path/new_text; adopting it would have silently broken the feature.
  • Privacy: hook event bodies are never sent to telemetry.

Testing — 663/663, typecheck clean

  • Property tests (fast-check) found + fixed a real fail-open bug (dispatchRaw('null') threw outside the try/catch).
  • Coverage: dispatcher contract + hostile-payload hardening; detectFile gate; editPayload translation; install-command parser; policy gate (warn/deny/allow, fail-open); golden real-envelope e2e (all edit tools + Bash); PreToolUse e2e (ask/deny/allow); init 3-registration upgrade path.
  • Verified end-to-end through the real starlog hook CLI and a real init/uninstall round-trip.

Notes for review

  • .planning/* is gitignored (design docs not in the diff).
  • fast-check added as a dev-dependency (dev-only; not shipped).
  • Existing Claude Code PostToolUse install-facts path is preserved; hook-runner now self-routes by hook_event_name + tool_name.

🤖 Generated with Claude Code

StarlogHQ and others added 5 commits August 6, 2026 16:55
…pwire

Add the keystone of the Hookshot integration (.planning/hookshot-integration.md):
a portable agent-lifecycle hook contract, and wire its first handler into
`starlog init` so it fires in Claude Code.

- src/hook/dispatch.ts: the transport-agnostic `starlog hook <event>` contract —
  { event, cwd, payload } -> { decision: allow|warn|deny, message?, source }.
  Fail-open (malformed input or any handler error -> allow) and warn-only until
  policy opt-in. before_execution/prompt_submit/stop are forward-compatible
  allow-stubs for later phases.
- after_file_edit MVP: a cheap, recurrence-gated tripwire. detectFile gates on a
  single edited file (no fs walk, 512KB head-slice cap); the recurrence store is
  the counter; one warn fires at the threshold crossing pointing at
  starlog_advise. No search/LLM in the hot path (deviates from the plan's first
  sketch, which paid a per-edit search even to watch).
- src/patterns/detect.ts: new detectFile(relPath, content) — the single-file gate.
- src/hook-runner.ts: the installed shim self-routes by tool_name —
  Edit|Write|MultiEdit drive the tripwire (translating Claude's tool_input and
  re-emitting warns as hookSpecificOutput); everything else keeps the existing
  install-facts path.
- src/init.ts: register the shim under two matchers (Bash + Edit|Write|MultiEdit)
  with matcher-aware dedup; hookAction reports `update` when either is missing so
  pre-tripwire installs re-init; uninstall already strips both by filename.
- src/cli.ts: `starlog hook <event>` subcommand (reads stdin, always exits 0).

Tests (627/627): dispatch contract + fail-open + hostile-payload hardening +
independent per-category counters + cross-project accumulation; detectFile
false-positive floor / empty / oversized-edit; edit-tripwire e2e through the real
bundled shim; init upgrade-path e2e (Bash-only -> gains edit matcher, no dup,
foreign hooks preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verify the integration against the REAL Claude Code payload schema (primary
source), harden it with property/fuzz tests, and fix a fail-open bug the property
suite surfaced.

- fix(dispatch): dispatchRaw threw on any JSON that parses to a non-object
  (e.g. `null`, `123`) — `parsed.event` ran outside the try/catch, breaking the
  fail-open contract. Guard that the parsed value is a non-null object.
- Verified field names against Claude Code's real Edit/Write/MultiEdit tool
  schemas: file_path / content / new_string / edits[].new_string are correct
  (a docs-summary claim of path / new_text was a hallucination — not adopted).
- Export editPayload for direct unit testing of the tool_input translation.
- New tests (645/645):
  * src/hook-runner.test.ts — editPayload translation (Write/Edit/MultiEdit,
    missing/garbage, malformed edits[]).
  * src/hook/dispatch.property.test.ts — fast-check invariants: never throws,
    always a valid decision, no deny (warn-only), JSON round-trips, metamorphic
    "exactly one warn per fresh store"; plus the non-object fail-open regression.
  * src/hook/dispatch.test.ts — concurrent-edit robustness (no throw / no store
    corruption), independent per-category counters, cross-project accumulation.
  * src/init.hook.test.ts — golden real-envelope e2e (full PostToolUse payload,
    all three edit tools + Bash no-regression), MultiEdit coverage.
- fast-check added as a dev dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a $-gated behavioral eval that measures whether the after_file_edit tripwire's
injected warn actually changes an agent's decision — the "LLM decision-flip eval"
the bench-facts scope note names as deferred.

Design (3 arms isolate the marginal contribution, per .planning/tripwire-eval.md):
  A control · B +tripwire-nudge · C +advice-inlined
A->B lift in the adopt-library rate is the go/no-go number; blind LLM grader;
decision rule (kill if A->B < 15pp) precommitted BEFORE running.

- src/engine/tripwire-eval/scenarios.ts — 6 genuinely-ambiguous DIY scenarios.
- src/engine/tripwire-eval/run.ts — arms, blind grader, scoring (LLM injected).
- src/engine/tripwire-eval/run.test.ts — deterministic coverage (prompts differ
  only by injection, grader parser, scoring math via a stubbed LLM — no spend).
- scripts/eval-tripwire.ts — harness (npm run eval:tripwire), reuses createLlmFn.

Result (haiku, k=15, 90/arm): adopt-rate A 6.7% / B 66.7% / C 83.3%; A->B +60pp
=> KEEP. Honest boundary: control is a bare model, so this proves the nudge text
works (upper bound), not marginal value over the existing MCP tool + CLAUDE.md —
that needs a multi-turn eval. Surfaced: auth is immovable even with advice (0/7/0);
inlining a brief hint (B->C +16.7pp) beats the "call starlog_advise" indirection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…icy gate

Two changes, both flowing from the decision-flip eval.

1. Inline hint (eval-motivated). The tripwire message now names example vetted
   libraries per category ("like Clerk, Auth0, or better-auth") instead of a bare
   "call starlog_advise" indirection — the eval showed a concrete named hint moves
   behavior far more (B->C +16.7pp). The authoritative, safety/policy-checked
   recommendation still comes from starlog_advise. (CATEGORY_HINTS in dispatch.ts.)

2. Phase 2 — before_execution L3 policy gate (the handler with teeth).
   - src/patterns/install-parse.ts: shared install-command parser extracted from
     hook-runner (reused by both the install-facts hook and the gate); hook-runner
     refactored onto it (its regression tests are the safety net).
   - dispatch.handleBeforeExecution: parse npm/pnpm/yarn/pip install, resolve L3
     policy LOCAL-only (lookupFactView, no network in a blocking hot path; policy
     path resolved from cwd via overlayPath). Any policy-DENY package -> warn
     (default) or deny (STARLOG_HOOK_ENFORCE=deny). Dependency-introduction only,
     not general command safety.
   - hook-runner: PreToolUse/Bash -> gate; maps deny->permissionDecision "deny"
     (hard block), warn->"ask" (surface + user decides), allow->emit nothing.
     PreToolUse blocking format verified against code.claude.com/docs.
   - init: registers 3 (event,matcher) pairs now, incl. PreToolUse/Bash; hookAction
     flags 'update' when any is missing; uninstall strips both event arrays.

Tests (663/663): install-parse unit; before_execution policy gate (warn/deny/allow,
fail-open); PreToolUse e2e through the real shim (ask/deny/allow); init 3-registration
upgrade e2e. Verified end-to-end via `starlog hook before_execution`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…question

The 3-arm eval measured the tripwire nudge vs a BARE model (+60pp) — but the real
production baseline already carries the CLAUDE.md instruction Starlog ships. That
A/B result proved the nudge text works, not that the hook adds value over the
existing pull-surface (the honest boundary the eval doc flagged).

Add two arms with an AMBIENT system prompt (simulating the CLAUDE.md guidance):
  D_ambient (instruction, no hook) · E_ambient_nudge (instruction + hook nudge)
D→E lift is the honest go/no-go: a hook that adds nothing over the ambient
instruction is redundant. Harness prints it and gates the verdict on BOTH A→B and
D→E. Money-free parts covered by the stubbed-LLM test (6/6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@basicScandal

Copy link
Copy Markdown
Member Author

⚠️ Correction: the tripwire's marginal value is UNPROVEN (follow-up eval)

The PR description reports the tripwire eval as +60pp → KEEP. A follow-up with two added arms (ambient CLAUDE.md instruction present) flips that conclusion, and I want the record straight before review.

Arm adopt-library rate
A control (bare model) 8.9%
B +tripwire (bare) 65.6%
C +advice (bare) 83.3%
D — CLAUDE.md instruction, no hook 100%
E — instruction + tripwire 98.9%

D→E = −1.1pp. The +60pp (A→B) was nudge-vs-bare-model. But Starlog already ships the CLAUDE.md instruction, and in this single-turn probe that instruction alone gets 100% adoption — the after_file_edit tripwire adds nothing over it.

Caveat (both ways): D=100% means the probe ceilings on the instruction, so it structurally can't measure the hook's hypothesized value in a long multi-turn trajectory where instruction salience decays. So this is "could-not-prove-value + evidence-of-redundancy," not "useless." A multi-turn eval is the only way to settle it.

What this does and doesn't affect:

  • after_file_edit tripwire (Phases 1/1.5): marginal value UNPROVEN. Kept warn-only (harmless), but should not be oversold. Multi-turn eval needed to justify.
  • before_execution policy gate (Phase 2): unaffected — it's deterministic enforcement, not a nudge competing with an instruction. An instruction can't block an install; the hook can.
  • Phase 0 contract + the eval methodology: stand on their own.

Recommend reviewers weight Phase 0 + Phase 2 as the durable value, and treat the tripwire as experimental pending a multi-turn eval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant