Files
viettranx a556161d0d 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.
2026-04-16 14:17:47 +07:00

43 lines
1.7 KiB
Go

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
}