6 Commits
Author SHA1 Message Date
ntduc 03a196e613 feat(hooks): add script context injection 2026-07-22 14:13:23 +07:00
Bruno ClermontandBruno Clermont cf80d90afb feat(hooks): add debug logging, capture console.log output, fail closed on matched-then-errored hooks (#1337)
prefilter() had zero logging -- matcher regex failures, tool-name
mismatches, CEL compile errors, and CEL evaluating to false were all
completely silent, making a misconfigured hook impossible to debug.
A user's PreToolUse script hook silently failed to block an
unauthorized file write with no way to determine why.

- Added slog.Debug/Warn logging throughout prefilter() and the
  dispatch chain: hooks.prefilter.considered, matcher_checked,
  condition_evaluated, error, and hooks.dispatch.decision -- covering
  matcher match/miss, CEL compile/eval result, and final decision
  with reasoning.
- console.log/console.error output from script hooks (captured via
  the existing goja sandbox stdout buffer, previously discarded after
  execution) now surfaces via slog.Debug and persists into
  HookExecution.ConsoleOutput / Metadata["console_output"] in the
  audit record, using the existing metadata JSON column (no
  migration needed).
- Fail-closed, narrowly scoped: when a hook's matcher successfully
  matches a tool call but CEL condition or script execution then
  errors, the dispatcher now blocks that specific tool call
  (previously: silently treated as non-match, call proceeded
  allowed). Hooks that legitimately don't match remain unaffected
  pass-through -- this does not turn one broken hook into a global
  outage, it only blocks the specific gated action that couldn't be
  confidently evaluated.

Added tests: matched-then-CEL-errors blocks the call and never
invokes the handler; matcher-doesn't-match proceeds normally
unaffected (confirms narrow scope); console.log output is captured
in the audit record.

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
2026-07-03 21:25:13 +07:00
viettranx ee328d4266 refactor: consolidate hooks table migration and drop deprecated agent_id column
- Consolidated PG migration 054: agent_hooks rename + junction table + deprecated column drop
- Updated SQLite schema v19 with final consolidated migration
- Removed obsolete schema rebuild files (v21, v23)
- Updated Go store layer (pg/hooks.go, sqlitestore/hooks.go)
- Updated integration tests to use new table names (hooks, hook_agents)
- Updated TypeScript protocol, UI components, and i18n strings
- Updated gateway methods to reflect schema changes

Tables: agent_hooks → hooks, agent_hook_agents → hook_agents
2026-04-16 14:17:48 +07:00
viettranx a6592fd65f feat(hooks/builtin): ship pii-redactor exemplar — email + phone masking
First real builtin under Phase 04 infrastructure. Registers two DB rows
(user_prompt_submit + pre_tool_use) on startup, runs the embedded JS with
source-tier gate + dispatcher dotted-path allowlist.

- internal/hooks/builtin/pii-redactor.js (ES5.1, goja-compatible):
  handle(event) returns updatedInput only when email or E.164-ish phone
  actually matched. Redacts rawInput + toolInput.{command,query,content}.
- builtins.yaml: pii-redactor entry (version 1, priority 900, timeout 2s,
  on_timeout=allow — missing redaction < blocking conversation).
- Drop _placeholder.js; pii-redactor.js is now the first real //go:embed match.
- Export builtin.Source(name) so handler tests load embedded JS without
  going through Load()/cache state.
- Tests:
  * loader_test: registry contains pii-redactor after Load().
  * script_pii_redactor_test (handler-level, real goja runtime): email
    masked in rawInput; +E164 phone masked; toolInput.command redacted,
    toolInput.path left alone (not in mutable_fields); no PII → no
    mutation; redact(redact(s)) == redact(s).
  * dispatcher_test: 3 dotted-path walker cases (toolInput.command only
    lets that key through; rawInput-only doesn't let forged toolInput
    override original event keys; toolInput wildcard merges all keys).

Bench: ~242µs/op on 1 KiB Apple M4 Max, well under <1ms target.
2026-04-16 14:17:47 +07:00
viettranx b7592bcacf feat(hooks/script): integrate script handler — FireResult + source-tier gate + migration
Wave 1 Phase 03. Wires the Phase 02 Goja handler into the dispatcher, widens
the DB schema for script + builtin sources, and refactors Dispatcher.Fire to
return a FireResult so builtin-source hooks can mutate event input.

Dispatcher:
- FireResult{Decision, UpdatedToolInput, UpdatedRawInput} replaces the plain
  Decision return. stdDispatcher.runSync keeps a local evMut copy so a
  builtin-source hook's updatedInput flows to downstream hooks in the chain
  and to the caller; non-builtin (source=ui) script mutations are dropped +
  WARN-logged (defense-in-depth; source tier enforced at the dispatcher, not
  only at the handler).
- applyBuiltinMutation + placeholder builtinAllowlistFor (rawInput, toolInput)
  — Phase 04 overrides the allowlist from builtins.yaml.
- noopDispatcher returns FireResult{DecisionAllow}.
- Script mutation tests: TestDispatcher_ScriptMutation_BuiltinSourceApplies,
  TestDispatcher_ScriptMutation_UISourceDenied.

Pipeline + callers (14 Fire sites refactored):
- FireHook wrapper returns FireResult; context_stage.go:52 applies
  UpdatedRawInput → state.Input.Message; tool_stage.go:52 applies
  UpdatedToolInput → tc.Arguments before ExecuteToolCall.
- delegate_bridge + delegate_tool keep the Decision branch; Updated* ignored.
- dispatcher_test / delegate_bridge_test / delegate_tool_hooks_test /
  integration test helpers updated for the new shape.

Validation:
- edition_gate allows HandlerScript on every edition (sandboxed, no shell
  escape surface).
- config.validateHandler rejects empty source, source > 32 KiB, goja compile
  error; validateTimeout rejects on_timeout=ask|defer (reserved).
- Six new config tests cover the script path.

Migrations:
- PG migration 000053 relaxes handler_type + source CHECKs and drops
  uq_hooks_{global,tenant,agent} — scripts routinely want many small hooks
  per event. RequiredSchemaVersion → 53.
- SQLite SchemaVersion → 21; patch 20 is a SELECT 1 placeholder because
  SQLite can't ALTER a CHECK — rebuildAgentHooksV21 runs outside the
  migration tx (parallel to backfillV16) to rename/recreate the table with
  widened CHECKs. schema.sql fresh-DB path matches.
- H9 fix: PGHookStore.Create + SqliteHookStore.Create honor caller-provided
  cfg.ID (uuid.Nil falls back to UUIDv7), unblocking Phase 04 idempotent
  UUIDv5 seed. TestCreateHonorsFixedID on both stores.

Config:
- config.HooksConfig{ScriptConcurrency, ScriptPerTenantConcurrency,
  ScriptCacheSize, BuiltinDisable}; buildHookHandlers wires the script
  handler with the caps from appCfg.Hooks.

Gates green: go build ./... + sqliteonly, go test -race -count=1 across
hooks/pipeline/sqlitestore.
2026-04-16 14:17:47 +07:00
viettranx 3fe5ae0b50 feat(hooks): introduce agent lifecycle hooks foundation with fail-closed blocking semantics
Phase 1 of agent-hooks-system establishes:
- Hook types, matchers, and CEL evaluation engine for event-driven extensibility
- Edition-gated command handlers (Lite disabled) with dedup_key-indexed audit log
- Dual-DB store layer (PostgreSQL + SQLite) with transaction boundary enforcement
- Blocking-event semantics: hook execution failures propagate to agent loop, graceful retry
- Tenant-isolated audit trail with per-hook execution context tracing
- Integration tests verifying store correctness and hook dispatch atomicity

Lays groundwork for post-v3.0 phase-02 (per-tenant onboarding hooks).
2026-04-15 14:25:59 +07:00