feat(hooks/script): goja ES5.1 script handler + deny-all sandbox

Wave 1 Phase 02 — pure runtime + sandbox. Dispatcher wiring, edition gate,
and DB migration land in Phase 03.

Handler:
- internal/hooks/handlers/script.go — ScriptHandler with two-layer semaphore
  (global 10 / per-tenant 3), hashicorp/golang-lru v2 program cache (cap 500),
  InvalidateHook hook for Phase 03, watchdog goroutine interrupting on ctx
  cancel, sanitized error text (strips \n + goja " at <frame>" inline tails)
- internal/hooks/handlers/script_sandbox.go — deny-all/allowlist hardening,
  SetMaxCallStackSize(256), prototype-chain nullify running BEFORE deny pass
  so Function.prototype.constructor can still be reached
- internal/hooks/handlers/script_runtime.go — LRU compile, JSON round-trip +
  deep-freeze event binding, 4KiB truncating stdout capture, strict return
  parser enforcing {decision, reason?, updatedInput?}

Types:
- HandlerScript HandlerType = "script"
- DecisionAsk / DecisionDefer reserved (Wave 1 treats as block + warn)
- hooks.ScriptResult + WithScriptResult / ScriptResultFrom ctx helpers for
  Phase 03 dispatcher to apply builtin-source UpdatedInput

Tests (go test -race green):
- script_test.go — 12 unit cases (happy/error/timeout/ask/defer/truncate/H1)
- script_sandbox_corpus_test.go — 27 cases: escape primitives (constructor
  chain, Reflect, Proxy, Symbol, Promise, __proto__ walk, eval, Function,
  Date chain, JSON replacer, toJSON DoS), resource bombs (recursion, loop,
  memory, ReDoS), typeof sanity (Reflect/Proxy/Symbol/Promise/GoError/
  globalThis/eval/Function undefined), mutation defense (toolInput/rawInput
  frozen, Go-side map unchanged)

Deps: github.com/dop251/goja, github.com/hashicorp/golang-lru/v2.

Gates: go vet clean, go test -race clean, go build ./... + sqliteonly clean.
This commit is contained in:
viettranx committed 2026-04-16 14:17:47 +07:00
1 parent 3a25358049
commit a556161d0d
10 files changed
+1203 -8

No files matched your search

+5 -1
View File
@@ -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
+11 -3
View File
@@ -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=
+248
View File
@@ -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 (<id>: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)
}
+158
View File
@@ -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
}
+97
View File
@@ -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
}
@@ -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)
}
}
+256
View File
@@ -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)
}
}
+42
View File
@@ -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
}
+6
View File
@@ -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.
+7 -4
View File
@@ -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()