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, } // PostModelResponse-specific fields if ev.HookEvent == hooks.EventPostModelResponse { viewable["modelResponse"] = ev.ModelResponse viewable["thinking"] = ev.Thinking toolCalls := make([]map[string]any, len(ev.ToolCalls)) for i, tc := range ev.ToolCalls { toolCalls[i] = map[string]any{ "id": tc.ID, "name": tc.Name, "arguments": tc.Arguments, "metadata": tc.Metadata, "parseError": tc.ParseError, } } viewable["toolCalls"] = toolCalls } 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?, additionalContext?, 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, 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 additionalContext string if a := obj.Get("additionalContext"); a != nil && !goja.IsUndefined(a) { if s, ok := a.Export().(string); ok { additionalContext = 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, additionalContext, updated, nil }