Files
293aa2a5e1 (feat) MCP Server (#1365)
* feat(hooks/observe): add structured output validation hook (ObserveHook)

- hooks/types.go: Add ObserveHook interface + BuiltInHookType enum
- hooks/dispatcher.go: HookDispatcher emits ObserveHook with PhaseResult payload
- pipeline/observe_stage.go: ObserveStage emits hook after ObserveResult built
- pipeline/substates.go: ObserveStageResult carries hook results + validation errors
- hooks/config.go: BuiltInHookTypeObserve added to BuiltInHookType enum

PhaseResult carries structured output, token usage, tool calls + validation errors
emitted post-ObserveStage. ObserveHook implementations can validate structured
output against schemas, detect tool-call loops, enforce token budgets, etc.

Hook fires after ObserveStage produces ObserveResult, before results propagate
to next stage. ValidationError returned by hook halts pipeline and propagates
error to caller without further stage execution.

Co-Authored-By: Claude <noreply@anthropic.com>

* ui(hooks): add post_model_response event to web UI

- Add event to Zod schema, filter dropdown, and form dialog
- Implement conditional test panel UI for model response payload
- Add translations (en/zh/vi) for new test panel fields
- Updated beta description to reference the new event

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(mcp): add MCP CRUD server exposing goclaw resources at /api/mcp/ with Bearer token auth
and X-GoClaw-Tenant-Id header, default to master tenant

* feat(mcp): add goclaw_skills_write_file tool to edit skill files on disk

The CRUD MCP server's goclaw_skills_update only touched skill DB metadata,
with no way to edit a skill's SKILL.md/file content on the filesystem. Extract
the versioned write logic from the web UI's skill file editor
(SkillsHandler.handleWriteFile) into skills.WriteVersionedFile so both
surfaces share identical validation and versioning, and expose it as a new
MCP tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 09:56:43 +07:00

209 lines
7.6 KiB
Go

package hooks
import (
"fmt"
"github.com/dop251/goja"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/edition"
)
// Defaults and limits for HookConfig fields. Exported so the UI layer can
// surface the same values that Validate() enforces.
const (
// DefaultTimeoutMS applies when TimeoutMS is zero. Chosen at 5s to match
// the blocking-sync budget in the plan.
DefaultTimeoutMS = 5000
// MaxTimeoutMS caps the per-hook timeout. The 10s ceiling matches the
// chain wall-time budget; setting above this would let one hook starve
// the whole chain.
MaxTimeoutMS = 10_000
// DefaultSource is the fallback when Source is empty (M1: distinguishes
// UI-created hooks from agent-seeded ones).
DefaultSource = "ui"
)
// knownEvents enumerates every HookEvent value that Validate accepts.
// Extending the hook system requires adding the new event both here and in
// types.go's const block.
var knownEvents = map[HookEvent]struct{}{
EventSessionStart: {},
EventUserPromptSubmit: {},
EventPreToolUse: {},
EventPostToolUse: {},
EventStop: {},
EventSubagentStart: {},
EventSubagentStop: {},
EventPostModelResponse: {},
}
// Validate checks a HookConfig for semantic correctness and fills in defaults.
// It MUST be called before Create/Update so that malformed configs never reach
// the DB or the dispatcher. Side effects (on success): TimeoutMS, OnTimeout,
// Version, and Source may be populated with their defaults.
//
// isMasterScope must be true when the caller is master/owner scope (e.g.
// store.IsMasterScope(ctx)). Master callers may legitimately create
// ScopeTenant and ScopeAgent hooks whose TenantID equals MasterTenantID
// (the system tenant). Regular callers cannot use MasterTenantID / the
// sentinel UUID for tenant-or-agent-scoped hooks.
//
// Validation order matters — cheap checks run first so we fail fast:
// 1. Event enum — zero-cost map lookup.
// 2. Scope / tenant / agent invariants — no I/O.
// 3. Edition gate — pure-function policy.
// 4. Matcher + CEL compile — most expensive; done last.
func (h *HookConfig) Validate(ed edition.Edition, isMasterScope bool) error {
if _, ok := knownEvents[h.Event]; !ok {
return fmt.Errorf("hook: unknown event %q", h.Event)
}
if err := h.validateScope(isMasterScope); err != nil {
return err
}
if err := h.validateHandler(ed); err != nil {
return err
}
if err := h.validateTimeout(); err != nil {
return err
}
h.applyDefaults()
// Matcher + CEL go last: parsing is the most expensive step and either
// value is optional (nil pattern / empty expr match all).
if _, err := CompileMatcher(h.Matcher); err != nil {
return fmt.Errorf("hook: matcher invalid: %w", err)
}
if _, err := CompileCELExpr(h.IfExpr); err != nil {
return fmt.Errorf("hook: if_expr invalid: %w", err)
}
return nil
}
// validateScope enforces (scope, tenant_id, agent_id) invariants:
// - global → tenant_id MUST be the sentinel UUID.
// - tenant → tenant_id MUST be non-sentinel (or MasterTenantID when isMasterScope).
// - agent → same tenant_id rule AND at least one agent_id non-nil.
//
// isMasterScope relaxes the sentinel check for tenant/agent scope: master/owner
// callers creating hooks for the system tenant legitimately supply MasterTenantID
// (which equals SentinelTenantID), so we must not reject them.
func (h *HookConfig) validateScope(isMasterScope bool) error {
switch h.Scope {
case ScopeGlobal:
if h.TenantID != SentinelTenantID {
return fmt.Errorf("hook: global scope requires sentinel tenant_id, got %s", h.TenantID)
}
case ScopeTenant:
// Master-scope callers may use MasterTenantID (== SentinelTenantID) because
// they are scoping the hook to the real system tenant row.
if h.TenantID == uuid.Nil || (!isMasterScope && h.TenantID == SentinelTenantID) {
return fmt.Errorf("hook: tenant scope requires a real tenant_id")
}
case ScopeAgent:
if h.TenantID == uuid.Nil || (!isMasterScope && h.TenantID == SentinelTenantID) {
return fmt.Errorf("hook: agent scope requires a real tenant_id")
}
if len(h.AgentIDs) == 0 {
if h.AgentID != nil && *h.AgentID != uuid.Nil {
h.AgentIDs = []uuid.UUID{*h.AgentID}
} else {
return fmt.Errorf("hook: agent scope requires at least one agent_id")
}
}
default:
return fmt.Errorf("hook: unknown scope %q", h.Scope)
}
return nil
}
// validateHandler enforces per-handler rules + edition policy.
// Command on Standard (any non-global scope) is blocked. Prompt requires at
// least one filter (matcher OR if_expr) so we never fire an LLM per event.
func (h *HookConfig) validateHandler(ed edition.Edition) error {
// Edition gate — applies the same rule as the dispatcher fire-time gate.
if ok, reason := (HookEditionPolicy{}).Allow(h.HandlerType, h.Scope, ed); !ok {
return fmt.Errorf("hook: %s handler not allowed: %s", h.HandlerType, reason)
}
switch h.HandlerType {
case HandlerPrompt:
if h.Matcher == "" && h.IfExpr == "" {
return fmt.Errorf("hook: prompt handler requires a matcher or if_expr (runaway-cost guard)")
}
if tmpl, _ := h.Config["prompt_template"].(string); tmpl == "" {
return fmt.Errorf("hook: prompt handler requires non-empty prompt_template")
}
case HandlerScript:
source, _ := h.Config["source"].(string)
if source == "" {
return fmt.Errorf("hook: script handler requires non-empty config.source")
}
if len(source) > MaxScriptSourceBytes {
return fmt.Errorf("hook: script source exceeds %d bytes (got %d)", MaxScriptSourceBytes, len(source))
}
// Strict=true parallels the handler's compile; surfaces `Line N:M`
// errors that the UI editor can display inline.
if _, err := goja.Compile(h.ID.String(), source, true); err != nil {
return fmt.Errorf("hook: script compile error: %w", err)
}
case HandlerCommand, HandlerHTTP:
// No extra filter required — matcher/if_expr are optional.
default:
return fmt.Errorf("hook: unknown handler_type %q", h.HandlerType)
}
return nil
}
// MaxScriptSourceBytes mirrors handlers.MaxScriptSourceBytes (32 KiB). Declared
// here to avoid importing the handlers package (which imports goja) just for
// this constant — the size cap is part of the config contract and should be
// enforceable without the runtime dep elsewhere.
const MaxScriptSourceBytes = 32 * 1024
// validateTimeout bounds the per-hook timeout within [0, MaxTimeoutMS] and
// rejects on_timeout values reserved for future phases.
// Zero is normalized to DefaultTimeoutMS in applyDefaults.
func (h *HookConfig) validateTimeout() error {
if h.TimeoutMS < 0 {
return fmt.Errorf("hook: timeout_ms must be >= 0, got %d", h.TimeoutMS)
}
if h.TimeoutMS > MaxTimeoutMS {
return fmt.Errorf("hook: timeout_ms %d exceeds max %d", h.TimeoutMS, MaxTimeoutMS)
}
// ask/defer are reserved for Wave 3 human-approval + external-arbitration
// flows. Until the dispatcher wires a `request_user_input` path, accepting
// these as timeout-fallback would let a misconfigured hook hang blocking
// events indefinitely.
if h.OnTimeout == DecisionAsk || h.OnTimeout == DecisionDefer {
return fmt.Errorf("hook: on_timeout=%q not supported in Wave 1 (reserved for future)", h.OnTimeout)
}
return nil
}
// applyDefaults fills optional fields with their plan-specified defaults.
// Called after validation so we never paper over a real error.
func (h *HookConfig) applyDefaults() {
if h.TimeoutMS == 0 {
h.TimeoutMS = DefaultTimeoutMS
}
if h.OnTimeout == "" {
if h.Event.IsBlocking() {
h.OnTimeout = DecisionBlock
} else {
h.OnTimeout = DecisionAllow
}
}
if h.Version == 0 {
h.Version = 1
}
if h.Source == "" {
h.Source = DefaultSource
}
}