mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 10:18:42 +00:00
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:
1 parent
3a25358049
commit
a556161d0d
10 files changed
+1203
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user