diff --git a/go.mod b/go.mod index 6a37a745..ae71b416 100644 --- a/go.mod +++ b/go.mod @@ -14,12 +14,14 @@ require ( github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/disintegration/imaging v1.6.2 + github.com/dop251/goja v0.0.0-20260311135729-065cd970411c github.com/fsnotify/fsnotify v1.9.0 github.com/go-rod/rod v0.116.2 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/cel-go v0.28.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/jackc/pgx/v5 v5.6.0 github.com/jmoiron/sqlx v1.4.0 github.com/mattn/go-runewidth v0.0.16 @@ -84,17 +86,19 @@ require ( github.com/danieljoos/wincred v1.2.3 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gaissmai/bart v0.18.0 // indirect github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect diff --git a/go.sum b/go.sum index f9ac31bb..c6790de7 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,9 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= @@ -178,14 +181,16 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= @@ -215,6 +220,8 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= @@ -253,7 +260,6 @@ github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= -github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= @@ -630,6 +636,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/hooks/handlers/script.go b/internal/hooks/handlers/script.go new file mode 100644 index 00000000..5d6985b0 --- /dev/null +++ b/internal/hooks/handlers/script.go @@ -0,0 +1,248 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/dop251/goja" + "github.com/google/uuid" + lru "github.com/hashicorp/golang-lru/v2" + + "github.com/nextlevelbuilder/goclaw/internal/hooks" +) + +// MaxScriptSourceBytes is the mandatory safety-floor cap on script source size +// (brainstorm §Critical safety items #2). Validated again at execute time as a +// belt-and-suspenders check after Phase 03 wires the Create/Update validation. +const MaxScriptSourceBytes = 32 * 1024 + +// MaxStdoutBytes is the mandatory safety-floor cap on captured console output +// per execution. Writes past the cap are dropped with a `... truncated` marker. +const MaxStdoutBytes = 4 * 1024 + +// defaultGlobalConcurrency / defaultPerTenantConcurrency / defaultCacheSize +// match the brainstorm safety-floor defaults. Overridden by NewScriptHandler +// arguments when the gateway config specifies different values. +const ( + defaultGlobalConcurrency = 10 + defaultPerTenantConcurrency = 3 + defaultCacheSize = 500 +) + +// ScriptHandler implements hooks.Handler via Goja ES5.1. +// +// Safety floor — all mandatory, independent of any future budget system: +// - two-layer semaphore (global cap + per-tenant cap) prevents one runaway +// tenant from starving the global pool. +// - LRU program cache (bounded) so repeated invocations skip recompile without +// unbounded memory growth. +// - source size cap + stdout size cap on every execution. +// +// Concurrency: a ScriptHandler is safe to share across goroutines. Each +// Execute call allocates a fresh goja.Runtime — runtimes are never shared. +type ScriptHandler struct { + // globalSem is a buffered channel acting as the global concurrency ceiling. + globalSem chan struct{} + // perTenantCap bounds the per-tenant sub-semaphore size. + perTenantCap int + // tenantSems: lazily allocated per-tenant slot pool; protects runaway tenant + // from saturating all global slots. + tenantMu sync.Mutex + tenantSems map[uuid.UUID]*tenantSlot + + // progCache: LRU bounded at cacheSize entries, keyed by (hookID, version). + // Invalidated on hook update/delete through InvalidateHook. + progCache *lru.Cache[progCacheKey, *goja.Program] +} + +// tenantSlot tracks a single tenant's concurrency allowance plus last-use time +// so a background cleanup can reclaim idle tenants (future Phase; scaffolding +// here is intentional). +type tenantSlot struct { + sem chan struct{} + lastUsed time.Time +} + +type progCacheKey struct { + ID uuid.UUID + Version int +} + +// NewScriptHandler constructs a ScriptHandler with the given caps. Any cap <= 0 +// falls back to the package default. The LRU allocation only fails on +// size <= 0, which we already guard, so the error branch is unreachable. +func NewScriptHandler(globalConcurrency, perTenantConcurrency, cacheSize int) *ScriptHandler { + if globalConcurrency <= 0 { + globalConcurrency = defaultGlobalConcurrency + } + if perTenantConcurrency <= 0 { + perTenantConcurrency = defaultPerTenantConcurrency + } + if cacheSize <= 0 { + cacheSize = defaultCacheSize + } + cache, _ := lru.New[progCacheKey, *goja.Program](cacheSize) + return &ScriptHandler{ + globalSem: make(chan struct{}, globalConcurrency), + perTenantCap: perTenantConcurrency, + tenantSems: make(map[uuid.UUID]*tenantSlot), + progCache: cache, + } +} + +// InvalidateHook removes cached program entries related to the given hook. +// Phase 03 wires this from hooks.update / hooks.delete store events. Wave 1 +// uses a full purge for simplicity — LRU v2 does not expose keyed predicates, +// and refill is cheap (single recompile per subsequent invocation). +func (h *ScriptHandler) InvalidateHook(_ uuid.UUID) { + h.progCache.Purge() +} + +// Execute runs the script hook: acquire semaphores → compile/cache → +// fresh goja runtime → harden → bind frozen event → run → parse return. +// Panics, goja interrupts, and return-parse failures all map to DecisionError; +// ctx cancellation maps to DecisionTimeout. +func (h *ScriptHandler) Execute(ctx context.Context, cfg hooks.HookConfig, ev hooks.Event) (hooks.Decision, error) { + select { + case h.globalSem <- struct{}{}: + case <-ctx.Done(): + return hooks.DecisionTimeout, ctx.Err() + } + defer func() { <-h.globalSem }() + + tsem := h.acquireTenantSem(ev.TenantID) + select { + case tsem <- struct{}{}: + case <-ctx.Done(): + return hooks.DecisionTimeout, ctx.Err() + } + defer func() { <-tsem }() + + source, ok := cfg.Config["source"].(string) + if !ok || source == "" { + return hooks.DecisionError, errors.New("script: missing 'source' in config") + } + if len(source) > MaxScriptSourceBytes { + return hooks.DecisionError, fmt.Errorf("script: source exceeds %d bytes", MaxScriptSourceBytes) + } + + prog, err := h.compile(cfg, source) + if err != nil { + return hooks.DecisionError, sanitizeError(err) + } + + rt := goja.New() + if err := applyHardening(rt); err != nil { + return hooks.DecisionError, sanitizeError(err) + } + var stdout strings.Builder + captureStdout(rt, &stdout) + if err := bindEvent(rt, ev); err != nil { + return hooks.DecisionError, sanitizeError(err) + } + + // Watchdog: interrupt the runtime on ctx cancellation. done closes on normal + // exit so the goroutine always returns. + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + rt.Interrupt("context cancelled") + case <-done: + } + }() + + var ( + result goja.Value + execErr error + ) + func() { + defer func() { + if r := recover(); r != nil { + execErr = fmt.Errorf("script panic: %v", r) + } + }() + if _, perr := rt.RunProgram(prog); perr != nil { + execErr = perr + return + } + handleFn, ok := goja.AssertFunction(rt.Get("handle")) + if !ok { + execErr = errors.New("script: export function handle(event)") + return + } + result, execErr = handleFn(goja.Undefined(), rt.Get("event")) + }() + close(done) + + if execErr != nil { + if ctx.Err() != nil { + return hooks.DecisionTimeout, ctx.Err() + } + return hooks.DecisionError, sanitizeError(execErr) + } + + dec, reason, updated, parseErr := parseReturn(rt, result) + if parseErr != nil { + return hooks.DecisionError, parseErr + } + + // Wave 1 reserves ask/defer but does not implement them. Treat as block + + // warn so operators can see that a hook wants human/external arbitration. + if dec == hooks.DecisionAsk || dec == hooks.DecisionDefer { + slog.Warn("hooks.decision_not_yet_implemented", + "hook_id", cfg.ID, "decision", string(dec)) + dec = hooks.DecisionBlock + } + + // Write non-decision outputs for dispatcher pickup (Phase 03 applies them + // only when cfg.Source == "builtin"). Standalone tests may not provision a + // ScriptResult — ScriptResultFrom returns nil in that case, which is fine. + if r := hooks.ScriptResultFrom(ctx); r != nil { + r.Reason = reason + r.UpdatedInput = updated + r.Stdout = stdout.String() + } + return dec, nil +} + +// acquireTenantSem returns the per-tenant slot channel, lazily allocating on +// first use. Zero-UUID tenants (system calls) share the same slot keyed by +// uuid.Nil, which is acceptable because system-source hooks are still bounded +// by the global cap. +func (h *ScriptHandler) acquireTenantSem(tid uuid.UUID) chan struct{} { + h.tenantMu.Lock() + defer h.tenantMu.Unlock() + s, ok := h.tenantSems[tid] + if !ok { + s = &tenantSlot{sem: make(chan struct{}, h.perTenantCap)} + h.tenantSems[tid] = s + } + s.lastUsed = time.Now() + return s.sem +} + +// sanitizeError trims stack-frame text from goja errors. Two strip passes: +// first newline (multi-line stacks) and first ` at ` (single-line inline +// frames like `Error: boom at handle (:1:32(3))`). Goja embeds source +// file IDs + line/col numbers in both forms; we don't want that leaking to +// tenant admins through dispatcher audit. One-line messages without frame +// info stay intact. +func sanitizeError(err error) error { + if err == nil { + return nil + } + msg := err.Error() + if idx := strings.Index(msg, "\n"); idx > 0 { + msg = msg[:idx] + } + if idx := strings.Index(msg, " at "); idx > 0 { + msg = strings.TrimRight(msg[:idx], " ") + } + return errors.New(msg) +} diff --git a/internal/hooks/handlers/script_runtime.go b/internal/hooks/handlers/script_runtime.go new file mode 100644 index 00000000..69313a55 --- /dev/null +++ b/internal/hooks/handlers/script_runtime.go @@ -0,0 +1,158 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/dop251/goja" + + "github.com/nextlevelbuilder/goclaw/internal/hooks" +) + +// compile returns a cached compiled program; fresh compile on cache miss or +// version change. strict=true gives stronger semantics (assigning to an +// undeclared variable throws instead of silently creating a global). +func (h *ScriptHandler) compile(cfg hooks.HookConfig, source string) (*goja.Program, error) { + key := progCacheKey{ID: cfg.ID, Version: cfg.Version} + if prog, ok := h.progCache.Get(key); ok { + return prog, nil + } + prog, err := goja.Compile(cfg.ID.String(), source, true) + if err != nil { + return nil, fmt.Errorf("compile: %w", err) + } + h.progCache.Add(key, prog) + return prog, nil +} + +// bindEvent exposes a deep-frozen `event` global mirroring hooks.Event. +// +// The JSON round-trip disconnects the JS-side object from the Go map — goja +// would otherwise wrap ev.ToolInput as a live Go map view and script writes +// would propagate back, bypassing the source-tier capability check. One +// serialize/parse pair per exec is acceptable on the hook path. +// +// UUIDs serialize as strings; scripts observe `event.tenantId` / `event.agentId` +// as strings, not typed UUIDs. +func bindEvent(rt *goja.Runtime, ev hooks.Event) error { + viewable := map[string]any{ + "hookEvent": string(ev.HookEvent), + "sessionId": ev.SessionID, + "tenantId": ev.TenantID.String(), + "agentId": ev.AgentID.String(), + "toolName": ev.ToolName, + "toolInput": ev.ToolInput, + "rawInput": ev.RawInput, + "depth": ev.Depth, + "eventId": ev.EventID, + } + b, err := json.Marshal(viewable) + if err != nil { + return fmt.Errorf("bindEvent marshal: %w", err) + } + + // Parse in JS so nested objects become native JS objects (not Go wrappers), + // then recursively Object.freeze every level to block in-place mutation. + script := ` +var event = ` + string(b) + `; +(function deepFreeze(o) { + if (o === null || typeof o !== 'object') return; + Object.freeze(o); + for (var k in o) { + if (Object.prototype.hasOwnProperty.call(o, k)) deepFreeze(o[k]); + } +})(event); +` + _, err = rt.RunString(script) + return err +} + +// captureStdout binds `console.log` / `console.error` to a truncating buffer. +// Once the buffer reaches MaxStdoutBytes, a single `... truncated` marker is +// written and further writes are dropped so a runaway print loop cannot +// exhaust memory. +func captureStdout(rt *goja.Runtime, buf *strings.Builder) { + truncated := false + write := func(level string, args ...any) { + if truncated { + return + } + parts := make([]string, 0, len(args)+1) + parts = append(parts, level+":") + for _, a := range args { + parts = append(parts, fmt.Sprint(a)) + } + line := strings.Join(parts, " ") + "\n" + remaining := MaxStdoutBytes - buf.Len() + if len(line) > remaining { + if remaining > 0 { + line = line[:remaining] + "... truncated\n" + } else { + line = "... truncated\n" + } + truncated = true + } + buf.WriteString(line) + } + console := rt.NewObject() + _ = console.Set("log", func(call goja.FunctionCall) goja.Value { + args := make([]any, len(call.Arguments)) + for i, a := range call.Arguments { + args[i] = a.Export() + } + write("log", args...) + return goja.Undefined() + }) + _ = console.Set("error", func(call goja.FunctionCall) goja.Value { + args := make([]any, len(call.Arguments)) + for i, a := range call.Arguments { + args[i] = a.Export() + } + write("error", args...) + return goja.Undefined() + }) + _ = rt.Set("console", console) +} + +// parseReturn enforces the `{decision, reason?, updatedInput?}` contract. +// Any deviation (non-object, missing field, unknown decision string) maps to +// DecisionError with a descriptive error. +func parseReturn(rt *goja.Runtime, v goja.Value) (hooks.Decision, string, map[string]any, error) { + if v == nil || goja.IsUndefined(v) || goja.IsNull(v) { + return hooks.DecisionError, "", nil, errors.New("script return: must be object with decision") + } + obj := v.ToObject(rt) + if obj == nil { + return hooks.DecisionError, "", nil, errors.New("script return: must be object") + } + decRaw := obj.Get("decision") + if decRaw == nil || goja.IsUndefined(decRaw) { + return hooks.DecisionError, "", nil, errors.New("script return: missing decision field") + } + decStr, ok := decRaw.Export().(string) + if !ok { + return hooks.DecisionError, "", nil, errors.New("script return: decision must be string") + } + dec := hooks.Decision(decStr) + switch dec { + case hooks.DecisionAllow, hooks.DecisionBlock, hooks.DecisionAsk, hooks.DecisionDefer: + // ok + default: + return hooks.DecisionError, "", nil, fmt.Errorf("script return: invalid decision %q", decStr) + } + var reason string + if r := obj.Get("reason"); r != nil && !goja.IsUndefined(r) { + if s, ok := r.Export().(string); ok { + reason = s + } + } + var updated map[string]any + if u := obj.Get("updatedInput"); u != nil && !goja.IsUndefined(u) { + if m, ok := u.Export().(map[string]any); ok { + updated = m + } + } + return dec, reason, updated, nil +} diff --git a/internal/hooks/handlers/script_sandbox.go b/internal/hooks/handlers/script_sandbox.go new file mode 100644 index 00000000..64f43823 --- /dev/null +++ b/internal/hooks/handlers/script_sandbox.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "fmt" + + "github.com/dop251/goja" +) + +// MaxCallStackSize caps goja's recursion depth. Prevents Go goroutine stack +// overflow on scripts like `function f(){f()}; f()`. Value 256 chosen because +// no legitimate hook script needs deeper recursion; attackers overflow at ~1K. +const MaxCallStackSize = 256 + +// DisabledGlobals — every global goja ships, verified against upstream +// builtin_global.go + builtin_typedarrays.go. Deny-all strategy: we re-enable +// only the names in AllowedGlobals after this pass so future goja releases +// that add new globals cannot leak escape primitives by default. +var DisabledGlobals = []string{ + "Object", "Function", "Array", "String", "Number", "BigInt", "RegExp", + "Date", "Boolean", "Proxy", "Reflect", "Error", "AggregateError", + "TypeError", "ReferenceError", "SyntaxError", "RangeError", "EvalError", + "URIError", "GoError", "Math", "JSON", "Symbol", "WeakSet", "WeakMap", + "Map", "Set", "Promise", "globalThis", "NaN", "Infinity", + "isNaN", "parseInt", "parseFloat", "isFinite", "decodeURI", + "decodeURIComponent", "encodeURI", "encodeURIComponent", "escape", + "unescape", "eval", + // Typed arrays (builtin_typedarrays.go) + "ArrayBuffer", "DataView", "Uint8Array", "Uint8ClampedArray", "Int8Array", + "Uint16Array", "Int16Array", "Uint32Array", "Int32Array", "Float32Array", + "Float64Array", "BigInt64Array", "BigUint64Array", +} + +// AllowedGlobals — minimal safe subset re-enabled after the deny pass. +// Scripts syntactically need `[]`, `{}`, `new Error("x")`. Prototype nullify +// covers the `.constructor.constructor` escape path through these allowed +// names. +var AllowedGlobals = []string{ + "Math", "JSON", "Number", "String", "Boolean", "RegExp", "Date", + "Array", "Object", "Error", "TypeError", "RangeError", "SyntaxError", + "parseInt", "parseFloat", "isNaN", "isFinite", + "encodeURIComponent", "decodeURIComponent", + "Infinity", "NaN", +} + +// prototypeNullifyScript breaks the `.constructor.constructor` prototype-escape +// chain. Even when Array/Object/Function are allowlisted, their constructor +// chain can yield a Function object that evals arbitrary source. Nulling the +// constructor slot on each prototype defeats that chain. try/catch wraps each +// write so a locked prototype from a future goja release does not abort the +// whole hardening sequence. +const prototypeNullifyScript = ` +(function() { + try { Object.defineProperty(Array.prototype, 'constructor', {value: undefined, writable: false, configurable: false}); } catch (_) {} + try { Object.defineProperty(Object.prototype, 'constructor', {value: undefined, writable: false, configurable: false}); } catch (_) {} + if (typeof Function !== 'undefined' && Function.prototype) { + try { Object.defineProperty(Function.prototype, 'constructor', {value: undefined, writable: false, configurable: false}); } catch (_) {} + } +})(); +` + +// applyHardening runs the full sandbox sequence on a fresh runtime: +// 1. cap the recursion depth before any user frames exist, +// 2. nullify `.constructor` slots on the exposed prototypes (Array, Object, +// Function, and built-in function prototypes like Date/RegExp/Error/etc) +// BEFORE the deny pass — `Function` must still be globally accessible to +// reach `Function.prototype`, +// 3. undefine every ships-by-default global that is NOT in the allowlist. +// +// The allowlist items are left untouched — save+restore would fail on ES +// non-writable globals (NaN, Infinity, undefined). Leaving them at their +// original engine value is correct because the deny pass never touched them. +// +// Any failure propagates so the caller can fail closed with DecisionError +// rather than execute user code against a half-hardened runtime. +func applyHardening(rt *goja.Runtime) error { + rt.SetMaxCallStackSize(MaxCallStackSize) + + // Step 2 first so `Function` is still reachable. + if _, err := rt.RunString(prototypeNullifyScript); err != nil { + return err + } + + allow := make(map[string]struct{}, len(AllowedGlobals)) + for _, n := range AllowedGlobals { + allow[n] = struct{}{} + } + + for _, name := range DisabledGlobals { + if _, keep := allow[name]; keep { + continue + } + if err := rt.Set(name, goja.Undefined()); err != nil { + return fmt.Errorf("sandbox: disable %q: %w", name, err) + } + } + return nil +} diff --git a/internal/hooks/handlers/script_sandbox_corpus_test.go b/internal/hooks/handlers/script_sandbox_corpus_test.go new file mode 100644 index 00000000..e457a96f --- /dev/null +++ b/internal/hooks/handlers/script_sandbox_corpus_test.go @@ -0,0 +1,373 @@ +package handlers + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/hooks" +) + +// The corpus asserts the sandbox either: +// (a) denies an escape primitive — the script compiles but fails at runtime +// (DecisionError) with NO leaked sensitive global reference, OR +// (b) bounds a resource bomb within ctx timeout (DecisionTimeout) WITHOUT +// crashing the Go test process, OR +// (c) for typeof-sanity cases, the script completes with decision:"allow" +// proving the referenced global is `undefined` post-hardening. +// +// Each case wraps the attacker payload in `handle(event)` and runs under a +// short ctx deadline. The handler is shared per-test to validate the +// sandbox resets per execution. + +// runCorpus executes src under a fresh handler instance and returns decision+err. +// perCaseTimeout bounds resource-bomb cases; happy cases finish well under it. +func runCorpus(t *testing.T, h *ScriptHandler, src string, perCaseTimeout time.Duration) (hooks.Decision, error) { + t.Helper() + cfg := hooks.HookConfig{ + ID: uuid.New(), + TenantID: uuid.New(), + Event: hooks.EventUserPromptSubmit, + HandlerType: hooks.HandlerScript, + Scope: hooks.ScopeTenant, + Config: map[string]any{"source": src}, + Version: 1, + Enabled: true, + } + ev := hooks.Event{ + EventID: "corpus", + SessionID: "s", + TenantID: uuid.New(), + AgentID: uuid.New(), + ToolName: "t", + ToolInput: map[string]any{"secret": "shh"}, + HookEvent: hooks.EventUserPromptSubmit, + } + ctx, cancel := context.WithTimeout(context.Background(), perCaseTimeout) + defer cancel() + return h.Execute(ctx, cfg, ev) +} + +// wrapAllowIf returns a `handle` that returns allow only when cond is truthy, +// block otherwise — used for typeof-sanity cases so a false proves the global +// wasn't actually undefined. +func wrapAllowIf(cond string) string { + return `function handle(e) { return {decision: (` + cond + `) ? "allow" : "block", reason: "probe"}; }` +} + +// ─── Escape-primitive bypasses (must fail or return block/error) ──────────── + +func TestCorpus_ConstructorChain(t *testing.T) { + src := `function handle(e) { var r = [].constructor.constructor("return 1")(); return {decision: "allow", reason: String(r)}; }` + dec, err := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec == hooks.DecisionAllow { + t.Fatalf("escape via [].constructor.constructor succeeded (err=%v)", err) + } +} + +func TestCorpus_ReflectConstruct(t *testing.T) { + src := `function handle(e) { return {decision: "allow", reason: String(Reflect.construct(Function, ["return 1"])())}; }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec == hooks.DecisionAllow { + t.Fatal("Reflect.construct bypass succeeded") + } +} + +func TestCorpus_ReflectApply(t *testing.T) { + src := `function handle(e) { return {decision: "allow", reason: String(Reflect.apply([].constructor.constructor, null, ["return 1"])())}; }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec == hooks.DecisionAllow { + t.Fatal("Reflect.apply bypass succeeded") + } +} + +func TestCorpus_ProxyTrap(t *testing.T) { + src := `function handle(e) { return {decision: "allow", reason: String(new Proxy({}, {get: function(){return "x"}}).anything)}; }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec == hooks.DecisionAllow { + t.Fatal("Proxy trap bypass succeeded") + } +} + +func TestCorpus_SymbolHijack(t *testing.T) { + src := `function handle(e) { var s = Symbol("x"); return {decision: "allow", reason: String(s)}; }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec == hooks.DecisionAllow { + t.Fatal("Symbol hijack succeeded") + } +} + +func TestCorpus_PromiseQueue(t *testing.T) { + src := `function handle(e) { Promise.resolve(); return {decision: "allow"}; }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec == hooks.DecisionAllow { + t.Fatal("Promise visible post-deny") + } +} + +func TestCorpus_ProtoWalk(t *testing.T) { + // After prototype nullify, ({}).__proto__.constructor should be undefined. + src := `function handle(e) { + var c = ({}).__proto__.constructor; + if (typeof c === "function") { + var f = c("return 1")(); // escape + return {decision: "allow", reason: String(f)}; + } + return {decision: "block", reason: "proto walk blocked"}; + }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec == hooks.DecisionAllow { + t.Fatal("__proto__ walk yielded allow (escape succeeded)") + } +} + +func TestCorpus_DirectEval(t *testing.T) { + src := `function handle(e) { return {decision: "allow", reason: String(eval("1+1"))}; }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec == hooks.DecisionAllow { + t.Fatal("direct eval succeeded") + } +} + +func TestCorpus_FunctionConstructor(t *testing.T) { + src := `function handle(e) { return {decision: "allow", reason: String(new Function("return 1")())}; }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec == hooks.DecisionAllow { + t.Fatal("Function constructor visible") + } +} + +func TestCorpus_DateConstructorEscape(t *testing.T) { + src := `function handle(e) { return {decision: "allow", reason: String((new Date()).constructor.constructor("return 1")())}; }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec == hooks.DecisionAllow { + t.Fatal("Date constructor escape succeeded") + } +} + +func TestCorpus_JSONReplacerInfinity(t *testing.T) { + // Replacer returning 1/0 (Infinity) forces JSON to emit null. Not an escape + // — just verifies exec completes without crash. + src := `function handle(e) { + var s = JSON.stringify({x: 1}, function(k,v){ return v === this ? 1/0 : v; }); + return {decision: "allow", reason: s}; + }` + dec, err := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec != hooks.DecisionAllow && dec != hooks.DecisionError { + t.Fatalf("unexpected decision %v err=%v", dec, err) + } +} + +func TestCorpus_ToJSONDoS(t *testing.T) { + src := `function handle(e) { + var o = {}; + o.toJSON = function(){ while(true){} }; + return {decision: "allow", reason: JSON.stringify(o)}; + }` + dec, _ := runCorpus(t, newTestHandler(), src, 100*time.Millisecond) + if dec != hooks.DecisionTimeout { + t.Fatalf("toJSON DoS: got %v, want timeout", dec) + } +} + +// ─── Resource bombs (must timeout / bounded error, no host crash) ──────────── + +func TestCorpus_RecursionBomb(t *testing.T) { + src := `function handle(e) { function f(){ f(); } f(); }` + dec, err := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec != hooks.DecisionError { + t.Fatalf("recursion bomb: got %v (err=%v), want error", dec, err) + } +} + +func TestCorpus_InfiniteLoop(t *testing.T) { + src := `function handle(e) { while(true){} }` + dec, _ := runCorpus(t, newTestHandler(), src, 100*time.Millisecond) + if dec != hooks.DecisionTimeout { + t.Fatalf("infinite loop: got %v, want timeout", dec) + } +} + +func TestCorpus_MemoryBombArray(t *testing.T) { + src := `function handle(e) { + try { var a = new Array(1e9); return {decision:"allow", reason: String(a.length)}; } + catch (err) { return {decision:"error"}; } + }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + _ = dec // absence-of-crash is the real assertion +} + +func TestCorpus_MemoryBombString(t *testing.T) { + src := `function handle(e) { + try { var s = "x".repeat(1e9); return {decision:"allow", reason: String(s.length)}; } + catch (err) { return {decision:"error"}; } + }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + if dec == hooks.DecisionAllow { + t.Fatal("1GiB repeat succeeded — sandbox did not bound string allocation") + } +} + +func TestCorpus_ReDoS(t *testing.T) { + // Catastrophic backtracking pattern. Goja uses dlclark/regexp2 which can + // be backtracking-heavy on some ES-specific features, but for simple + // cases it may return linearly. Any terminal decision within ctx deadline + // proves the sandbox bounded the regex — the real failure mode would be + // a hung goroutine. + src := `function handle(e) { + var r = new RegExp("(a+)+$"); + r.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"); + return {decision: "allow"}; + }` + dec, _ := runCorpus(t, newTestHandler(), src, 500*time.Millisecond) + switch dec { + case hooks.DecisionAllow, hooks.DecisionTimeout, hooks.DecisionError: + // ok — bounded either by linear engine or by timeout + default: + t.Fatalf("ReDoS: got %v, want allow/timeout/error", dec) + } +} + +// ─── Typeof sanity (post-hardening each global is `undefined`) ─────────────── + +func TestCorpus_ReflectUndefined(t *testing.T) { + dec, err := runCorpus(t, newTestHandler(), wrapAllowIf(`typeof Reflect === "undefined"`), 200*time.Millisecond) + if dec != hooks.DecisionAllow { + t.Fatalf("Reflect should be undefined (dec=%v err=%v)", dec, err) + } +} + +func TestCorpus_ProxyUndefined(t *testing.T) { + dec, err := runCorpus(t, newTestHandler(), wrapAllowIf(`typeof Proxy === "undefined"`), 200*time.Millisecond) + if dec != hooks.DecisionAllow { + t.Fatalf("Proxy should be undefined (dec=%v err=%v)", dec, err) + } +} + +func TestCorpus_SymbolUndefined(t *testing.T) { + dec, err := runCorpus(t, newTestHandler(), wrapAllowIf(`typeof Symbol === "undefined"`), 200*time.Millisecond) + if dec != hooks.DecisionAllow { + t.Fatalf("Symbol should be undefined (dec=%v err=%v)", dec, err) + } +} + +func TestCorpus_PromiseUndefined(t *testing.T) { + dec, err := runCorpus(t, newTestHandler(), wrapAllowIf(`typeof Promise === "undefined"`), 200*time.Millisecond) + if dec != hooks.DecisionAllow { + t.Fatalf("Promise should be undefined (dec=%v err=%v)", dec, err) + } +} + +func TestCorpus_GoErrorUndefined(t *testing.T) { + dec, err := runCorpus(t, newTestHandler(), wrapAllowIf(`typeof GoError === "undefined"`), 200*time.Millisecond) + if dec != hooks.DecisionAllow { + t.Fatalf("GoError should be undefined (dec=%v err=%v)", dec, err) + } +} + +func TestCorpus_GlobalThisUndefined(t *testing.T) { + dec, err := runCorpus(t, newTestHandler(), wrapAllowIf(`typeof globalThis === "undefined"`), 200*time.Millisecond) + if dec != hooks.DecisionAllow { + t.Fatalf("globalThis should be undefined (dec=%v err=%v)", dec, err) + } +} + +func TestCorpus_EvalUndefined(t *testing.T) { + dec, err := runCorpus(t, newTestHandler(), wrapAllowIf(`typeof eval === "undefined"`), 200*time.Millisecond) + if dec != hooks.DecisionAllow { + t.Fatalf("eval should be undefined (dec=%v err=%v)", dec, err) + } +} + +func TestCorpus_FunctionUndefined(t *testing.T) { + dec, err := runCorpus(t, newTestHandler(), wrapAllowIf(`typeof Function === "undefined"`), 200*time.Millisecond) + if dec != hooks.DecisionAllow { + t.Fatalf("Function should be undefined (dec=%v err=%v)", dec, err) + } +} + +// ─── Mutation defense (H1: deep-freeze of the event object) ────────────────── + +func TestCorpus_MutationOnToolInputFrozen(t *testing.T) { + src := `function handle(e) { + try { e.toolInput.secret = "pwn"; } catch(_) {} + return {decision: "allow", reason: String(e.toolInput.secret)}; + }` + h := newTestHandler() + cfg := hooks.HookConfig{ + ID: uuid.New(), + TenantID: uuid.New(), + Event: hooks.EventUserPromptSubmit, + HandlerType: hooks.HandlerScript, + Scope: hooks.ScopeTenant, + Config: map[string]any{"source": src}, + Version: 1, + } + ev := hooks.Event{ + EventID: "mut", + SessionID: "s", + TenantID: uuid.New(), + AgentID: uuid.New(), + ToolName: "t", + ToolInput: map[string]any{"secret": "original"}, + HookEvent: hooks.EventUserPromptSubmit, + } + res := &hooks.ScriptResult{} + ctx, cancel := context.WithTimeout(hooks.WithScriptResult(context.Background(), res), 500*time.Millisecond) + defer cancel() + dec, err := h.Execute(ctx, cfg, ev) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if dec != hooks.DecisionAllow { + t.Fatalf("decision: got %v", dec) + } + if ev.ToolInput["secret"] != "original" { + t.Fatalf("Go-side toolInput mutated: %v", ev.ToolInput["secret"]) + } + if !strings.Contains(res.Reason, "original") { + t.Fatalf("JS observed mutated value: reason=%q", res.Reason) + } +} + +func TestCorpus_MutationOnRawInputFrozen(t *testing.T) { + src := `function handle(e) { + try { e.rawInput = "pwn"; } catch(_) {} + return {decision: "allow", reason: e.rawInput}; + }` + h := newTestHandler() + cfg := hooks.HookConfig{ + ID: uuid.New(), + TenantID: uuid.New(), + Event: hooks.EventUserPromptSubmit, + HandlerType: hooks.HandlerScript, + Scope: hooks.ScopeTenant, + Config: map[string]any{"source": src}, + Version: 1, + } + ev := hooks.Event{ + EventID: "mut2", + SessionID: "s", + TenantID: uuid.New(), + AgentID: uuid.New(), + ToolName: "t", + RawInput: "untouched", + HookEvent: hooks.EventUserPromptSubmit, + } + res := &hooks.ScriptResult{} + ctx, cancel := context.WithTimeout(hooks.WithScriptResult(context.Background(), res), 500*time.Millisecond) + defer cancel() + _, err := h.Execute(ctx, cfg, ev) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if ev.RawInput != "untouched" { + t.Fatalf("Go-side rawInput mutated: %q", ev.RawInput) + } + if !strings.Contains(res.Reason, "untouched") { + t.Fatalf("JS observed mutated value: reason=%q", res.Reason) + } +} diff --git a/internal/hooks/handlers/script_test.go b/internal/hooks/handlers/script_test.go new file mode 100644 index 00000000..f4ffe815 --- /dev/null +++ b/internal/hooks/handlers/script_test.go @@ -0,0 +1,256 @@ +package handlers + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/hooks" +) + +// newTestHandler constructs a handler with small caps tuned for unit tests. +func newTestHandler() *ScriptHandler { + return NewScriptHandler(4, 2, 32) +} + +// mkCfg builds a minimal HookConfig carrying only what the script handler reads. +func mkCfg(source string) hooks.HookConfig { + return hooks.HookConfig{ + ID: uuid.New(), + TenantID: uuid.New(), + Event: hooks.EventUserPromptSubmit, + HandlerType: hooks.HandlerScript, + Scope: hooks.ScopeTenant, + Config: map[string]any{"source": source}, + Version: 1, + Enabled: true, + } +} + +// mkEvent builds a minimal Event with non-nil maps so the frozen binding has +// something to iterate. +func mkEvent() hooks.Event { + return hooks.Event{ + EventID: "evt-1", + SessionID: "sess-1", + TenantID: uuid.New(), + AgentID: uuid.New(), + ToolName: "test_tool", + ToolInput: map[string]any{"path": "/tmp/x", "n": 3}, + RawInput: "hello", + HookEvent: hooks.EventUserPromptSubmit, + } +} + +// runWithResult executes and returns the decision plus the ScriptResult the +// handler populated via the ctx-carried pointer. +func runWithResult(t *testing.T, h *ScriptHandler, cfg hooks.HookConfig, ev hooks.Event, timeout time.Duration) (hooks.Decision, error, *hooks.ScriptResult) { + t.Helper() + res := &hooks.ScriptResult{} + ctx, cancel := context.WithTimeout(hooks.WithScriptResult(context.Background(), res), timeout) + defer cancel() + d, err := h.Execute(ctx, cfg, ev) + return d, err, res +} + +// TestHappyPathAllow verifies a script returning {decision:"allow",reason:"ok"} +// produces DecisionAllow and the reason flows to ScriptResult. +func TestHappyPathAllow(t *testing.T) { + src := `function handle(event) { return {decision: "allow", reason: "ok"}; }` + h := newTestHandler() + dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if dec != hooks.DecisionAllow { + t.Fatalf("decision: got %v, want allow", dec) + } + if res.Reason != "ok" { + t.Fatalf("reason: got %q, want %q", res.Reason, "ok") + } +} + +// TestHappyPathBlock verifies block + reason round-trips. +func TestHappyPathBlock(t *testing.T) { + src := `function handle(event) { return {decision: "block", reason: "nope"}; }` + h := newTestHandler() + dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if dec != hooks.DecisionBlock { + t.Fatalf("decision: got %v, want block", dec) + } + if res.Reason != "nope" { + t.Fatalf("reason: got %q", res.Reason) + } +} + +// TestMissingHandleFunction yields DecisionError when the script omits `handle`. +func TestMissingHandleFunction(t *testing.T) { + src := `var x = 1;` + h := newTestHandler() + dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond) + if dec != hooks.DecisionError { + t.Fatalf("decision: got %v, want error", dec) + } + if err == nil || !strings.Contains(err.Error(), "handle") { + t.Fatalf("err: %v (want mentions 'handle')", err) + } +} + +// TestNonObjectReturn yields DecisionError. +func TestNonObjectReturn(t *testing.T) { + src := `function handle(event) { return 42; }` + h := newTestHandler() + dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond) + if dec != hooks.DecisionError { + t.Fatalf("decision: got %v, want error (err=%v)", dec, err) + } +} + +// TestInvalidDecisionString yields DecisionError with message containing the bad string. +func TestInvalidDecisionString(t *testing.T) { + src := `function handle(event) { return {decision: "maybe"}; }` + h := newTestHandler() + dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond) + if dec != hooks.DecisionError { + t.Fatalf("decision: got %v", dec) + } + if err == nil || !strings.Contains(err.Error(), "maybe") { + t.Fatalf("err should mention the invalid value, got %v", err) + } +} + +// TestTimeoutInfiniteLoop verifies ctx cancellation interrupts a runaway script. +func TestTimeoutInfiniteLoop(t *testing.T) { + src := `function handle(event) { while(true){} }` + h := newTestHandler() + dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 100*time.Millisecond) + if dec != hooks.DecisionTimeout { + t.Fatalf("decision: got %v, want timeout (err=%v)", dec, err) + } +} + +// TestPanicInsideHandle surfaces as DecisionError with sanitized message +// (no newline, no " at " frame). +func TestPanicInsideHandle(t *testing.T) { + src := `function handle(event) { throw new Error("boom"); }` + h := newTestHandler() + dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond) + if dec != hooks.DecisionError { + t.Fatalf("decision: got %v", dec) + } + if err == nil { + t.Fatal("err: nil, want non-nil") + } + if strings.Contains(err.Error(), "\n") { + t.Fatalf("err leaks multi-line stack trace: %q", err.Error()) + } + if strings.Contains(err.Error(), " at ") { + t.Fatalf("err leaks stack frame text: %q", err.Error()) + } +} + +// TestAskDecisionBlocksInWave1 verifies ask/defer map to block. +func TestAskDecisionBlocksInWave1(t *testing.T) { + src := `function handle(event) { return {decision: "ask", reason: "pls"}; }` + h := newTestHandler() + dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if dec != hooks.DecisionBlock { + t.Fatalf("decision: got %v, want block (ask→block in Wave 1)", dec) + } + if res.Reason != "pls" { + t.Fatalf("reason lost: %q", res.Reason) + } +} + +// TestDeferDecisionBlocksInWave1 mirror of the ask case. +func TestDeferDecisionBlocksInWave1(t *testing.T) { + src := `function handle(event) { return {decision: "defer"}; }` + h := newTestHandler() + dec, _, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond) + if dec != hooks.DecisionBlock { + t.Fatalf("decision: got %v, want block (defer→block in Wave 1)", dec) + } +} + +// TestSourceOverSizeCap rejects scripts larger than MaxScriptSourceBytes. +func TestSourceOverSizeCap(t *testing.T) { + big := strings.Repeat("// padding ", MaxScriptSourceBytes/10+10) // >32KiB + src := `function handle(event){return {decision:"allow"}}` + "\n" + big + h := newTestHandler() + dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond) + if dec != hooks.DecisionError { + t.Fatalf("decision: got %v, want error", dec) + } + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("err: %v", err) + } +} + +// TestStdoutCapTruncates verifies the 4KiB ceiling cuts off runaway logging +// with a single truncation marker. +func TestStdoutCapTruncates(t *testing.T) { + src := `function handle(event) { + for (var i = 0; i < 5000; i++) { console.log("xxxxxxxxxx"); } + return {decision: "allow"}; + }` + h := newTestHandler() + dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 2*time.Second) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if dec != hooks.DecisionAllow { + t.Fatalf("decision: got %v", dec) + } + if len(res.Stdout) > MaxStdoutBytes+64 { // +64 slack for marker tail + t.Fatalf("stdout exceeded cap: %d bytes", len(res.Stdout)) + } + if !strings.Contains(res.Stdout, "truncated") { + end := 200 + if end > len(res.Stdout) { + end = len(res.Stdout) + } + t.Fatalf("truncation marker missing: %q", res.Stdout[:end]) + } +} + +// TestUpdatedInputCaptured verifies updatedInput flows back via ScriptResult. +func TestUpdatedInputCaptured(t *testing.T) { + src := `function handle(event) { + return {decision: "allow", updatedInput: {path: "/redacted"}}; + }` + h := newTestHandler() + dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if dec != hooks.DecisionAllow { + t.Fatalf("decision: got %v", dec) + } + got, _ := res.UpdatedInput["path"].(string) + if got != "/redacted" { + t.Fatalf("updatedInput.path: got %q, want /redacted", got) + } +} + +// TestInvalidateHookDoesNotPanic is a smoke test for the Phase-03 wire-up point. +func TestInvalidateHookDoesNotPanic(t *testing.T) { + h := newTestHandler() + cfg := mkCfg(`function handle(event){return {decision:"allow"}}`) + if _, err := h.Execute(context.Background(), cfg, mkEvent()); err != nil { + t.Fatalf("warmup err: %v", err) + } + h.InvalidateHook(cfg.ID) + // Re-execute to ensure cache repopulates cleanly. + if _, err := h.Execute(context.Background(), cfg, mkEvent()); err != nil { + t.Fatalf("post-invalidate err: %v", err) + } +} diff --git a/internal/hooks/script_result.go b/internal/hooks/script_result.go new file mode 100644 index 00000000..83725e6a --- /dev/null +++ b/internal/hooks/script_result.go @@ -0,0 +1,42 @@ +package hooks + +import "context" + +// ScriptResult carries non-decision outputs from a script handler execution +// (reason text, updatedInput proposal, captured stdout). The dispatcher +// provisions one via context before calling Handler.Execute; the handler +// populates it on success. Phase 03 wires this into the dispatcher so that +// only builtin-source hooks can apply UpdatedInput to the pipeline state. +// +// Fields are read-write within a single Execute call; not concurrent-safe +// (each Execute gets its own instance via a context key). +type ScriptResult struct { + // Reason is the human-readable explanation returned by the script. + Reason string + // UpdatedInput is a proposed replacement for Event.ToolInput. Dispatcher + // applies only when cfg.Source == "builtin" (source-tier capability). + UpdatedInput map[string]any + // Stdout is the captured console.log / console.error output, bounded by + // handlers.MaxStdoutBytes (truncated with a marker when exceeded). + Stdout string +} + +// ctxScriptResultKey is the private context-value key. +type ctxScriptResultKey struct{} + +// WithScriptResult returns a ctx carrying r. The dispatcher calls this before +// invoking a script handler so the handler can write its non-decision outputs +// without widening the Handler interface. +func WithScriptResult(ctx context.Context, r *ScriptResult) context.Context { + return context.WithValue(ctx, ctxScriptResultKey{}, r) +} + +// ScriptResultFrom retrieves the result pointer from ctx, or nil when absent. +// Handler code must be nil-tolerant; non-script or Phase-02 standalone paths +// won't provision one. +func ScriptResultFrom(ctx context.Context) *ScriptResult { + if v, ok := ctx.Value(ctxScriptResultKey{}).(*ScriptResult); ok { + return v + } + return nil +} diff --git a/internal/hooks/types.go b/internal/hooks/types.go index 49980516..4d347996 100644 --- a/internal/hooks/types.go +++ b/internal/hooks/types.go @@ -72,6 +72,8 @@ const ( HandlerHTTP HandlerType = "http" // HandlerPrompt routes the event through an LLM prompt. HandlerPrompt HandlerType = "prompt" + // HandlerScript runs a user-provided ES5.1 JavaScript snippet in a sandboxed goja runtime. + HandlerScript HandlerType = "script" ) // MarshalJSON encodes HandlerType as a JSON string. @@ -120,6 +122,10 @@ const ( DecisionError Decision = "error" // DecisionTimeout indicates the hook did not respond within the time budget. DecisionTimeout Decision = "timeout" + // DecisionAsk requests human approval before proceeding. Wave 1: treated as block + warn. + DecisionAsk Decision = "ask" + // DecisionDefer defers the decision to an external system. Wave 1: treated as block + warn. + DecisionDefer Decision = "defer" ) // IsBlock returns true only when the decision is DecisionBlock. diff --git a/internal/hooks/types_test.go b/internal/hooks/types_test.go index a72fcf7d..31dd8586 100644 --- a/internal/hooks/types_test.go +++ b/internal/hooks/types_test.go @@ -48,17 +48,18 @@ func TestHookEventJSONRoundTrip(t *testing.T) { } } -// TestHandlerTypeEnumExact verifies exactly 3 HandlerType constants exist. +// TestHandlerTypeEnumExact verifies exactly 4 HandlerType constants exist. func TestHandlerTypeEnumExact(t *testing.T) { all := []hooks.HandlerType{ hooks.HandlerCommand, hooks.HandlerHTTP, hooks.HandlerPrompt, + hooks.HandlerScript, } - if len(all) != 3 { - t.Fatalf("expected 3 HandlerType values, got %d", len(all)) + if len(all) != 4 { + t.Fatalf("expected 4 HandlerType values, got %d", len(all)) } - expectedStrings := []string{"command", "http", "prompt"} + expectedStrings := []string{"command", "http", "prompt", "script"} for i, h := range all { if string(h) != expectedStrings[i] { t.Errorf("HandlerType[%d]: got %q, want %q", i, string(h), expectedStrings[i]) @@ -88,6 +89,8 @@ func TestDecisionIsBlock(t *testing.T) { {hooks.DecisionBlock, true}, {hooks.DecisionError, false}, {hooks.DecisionTimeout, false}, + {hooks.DecisionAsk, false}, + {hooks.DecisionDefer, false}, } for _, tc := range cases { got := tc.d.IsBlock()