a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
330 lines
12 KiB
Go
330 lines
12 KiB
Go
package analyzer
|
|
|
|
// LLM-backed adapter for the Temporal dispatch verification pass.
|
|
//
|
|
// PURPOSE — wire the deterministic verification core in
|
|
// internal/resolver/temporal_verify.go to (a) a real LLM provider, (b) on-disk
|
|
// source grounding, (c) a reproducibility cache, and (d) the canonical
|
|
// map[string]any output shape. Keeps the resolver core free of any LLM / I/O
|
|
// dependency; all the "actions" live here.
|
|
// RATIONALE — the verifier and source provider are injected interfaces, so this
|
|
// file holds the only LLM + filesystem coupling. The cache makes re-runs cheap
|
|
// and deterministic (same code + model → cached verdict).
|
|
// KEYWORDS — temporal, verify, llm, source, cache, adapter
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/llm"
|
|
"github.com/zzet/gortex/internal/llm/provider"
|
|
"github.com/zzet/gortex/internal/resolver"
|
|
)
|
|
|
|
// VerifyReportToMap converts a TemporalVerifyReport to the canonical
|
|
// map[string]any shape the CLI / MCP surfaces marshal. Field names are locked:
|
|
// checked, confirmed, rejected, uncertain, errors, details, totals.
|
|
func VerifyReportToMap(rep resolver.TemporalVerifyReport) map[string]any {
|
|
details := make([]map[string]any, 0, len(rep.Details))
|
|
for _, d := range rep.Details {
|
|
details = append(details, map[string]any{
|
|
"from": d.From,
|
|
"to": d.To,
|
|
"name": d.Name,
|
|
"kind": d.Kind,
|
|
"source": d.Source,
|
|
"verdict": string(d.Verdict),
|
|
"reason": d.Reason,
|
|
})
|
|
}
|
|
return map[string]any{
|
|
"checked": rep.Checked,
|
|
"confirmed": rep.Confirmed,
|
|
"rejected": rep.Rejected,
|
|
"uncertain": rep.Uncertain,
|
|
"errors": rep.Errors,
|
|
"details": details,
|
|
"totals": map[string]int{
|
|
"checked": rep.Checked,
|
|
"confirmed": rep.Confirmed,
|
|
"rejected": rep.Rejected,
|
|
"uncertain": rep.Uncertain,
|
|
"errors": rep.Errors,
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- File-backed source provider ------------------------------------------
|
|
|
|
// maxNodeSourceBytes caps the per-node source handed to the LLM so a giant
|
|
// function body can't blow the prompt budget.
|
|
const maxNodeSourceBytes = 6000
|
|
|
|
// FileSourceProvider reads a graph node's source from disk, slicing the file by
|
|
// the node's [StartLine, EndLine]. Files are cached in-memory for the run.
|
|
type FileSourceProvider struct {
|
|
root string
|
|
cache map[string]string
|
|
}
|
|
|
|
// NewFileSourceProvider returns a source provider rooted at the indexed repo.
|
|
func NewFileSourceProvider(root string) *FileSourceProvider {
|
|
return &FileSourceProvider{root: root, cache: map[string]string{}}
|
|
}
|
|
|
|
// NodeSource returns the source text of n's declaration, or ("", false).
|
|
func (p *FileSourceProvider) NodeSource(n *graph.Node) (string, bool) {
|
|
if n == nil || n.FilePath == "" {
|
|
return "", false
|
|
}
|
|
body, ok := p.fileBody(n.FilePath)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
lines := strings.Split(body, "\n")
|
|
start, end := n.StartLine, n.EndLine
|
|
if start < 1 {
|
|
start = 1
|
|
}
|
|
if start > len(lines) {
|
|
return "", false
|
|
}
|
|
if end < start || end > len(lines) {
|
|
end = len(lines)
|
|
}
|
|
src := strings.Join(lines[start-1:end], "\n")
|
|
if len(src) > maxNodeSourceBytes {
|
|
src = src[:maxNodeSourceBytes] + "\n// …truncated"
|
|
}
|
|
return src, true
|
|
}
|
|
|
|
func (p *FileSourceProvider) fileBody(rel string) (string, bool) {
|
|
if b, ok := p.cache[rel]; ok {
|
|
return b, b != ""
|
|
}
|
|
abs, ok := p.resolveWithinRoot(rel)
|
|
if !ok {
|
|
p.cache[rel] = ""
|
|
return "", false
|
|
}
|
|
raw, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
p.cache[rel] = ""
|
|
return "", false
|
|
}
|
|
p.cache[rel] = string(raw)
|
|
return string(raw), true
|
|
}
|
|
|
|
// resolveWithinRoot resolves rel — relative to p.root, or absolute — and
|
|
// confirms the result stays inside p.root. A node FilePath that escapes the
|
|
// indexed tree (via "..", or an absolute path elsewhere) is refused, so a
|
|
// crafted graph node can't make the verifier read arbitrary files off disk and
|
|
// ship them to the LLM. An empty root refuses everything (nothing to bound to).
|
|
func (p *FileSourceProvider) resolveWithinRoot(rel string) (string, bool) {
|
|
if p.root == "" || rel == "" {
|
|
return "", false
|
|
}
|
|
rootAbs, err := filepath.Abs(p.root)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
cand := rel
|
|
if !filepath.IsAbs(cand) {
|
|
cand = filepath.Join(rootAbs, cand)
|
|
}
|
|
cand = filepath.Clean(cand)
|
|
relToRoot, err := filepath.Rel(rootAbs, cand)
|
|
if err != nil || relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) {
|
|
return "", false
|
|
}
|
|
return cand, true
|
|
}
|
|
|
|
// --- LLM verifier ----------------------------------------------------------
|
|
|
|
const temporalVerifySystemPrompt = `You verify guesses made by a static analyzer for the Temporal workflow engine.
|
|
You are given a workflow function that dispatches an activity or child-workflow by a NAME the analyzer GUESSED (often from an env-var default, a naming convention, or a fuzzy match), plus the candidate target function the analyzer resolved that name to.
|
|
Decide whether the workflow really dispatches that exact candidate.
|
|
Reply with STRICT JSON ONLY, no prose: {"verdict":"confirmed|rejected|uncertain","reason":"<short>"}.
|
|
- "confirmed": the workflow does dispatch this exact activity/workflow.
|
|
- "rejected": it clearly does NOT — wrong/unrelated target, the name is overridden at runtime, or the candidate is a test stub.
|
|
- "uncertain": not enough evidence.
|
|
Be conservative: prefer "uncertain" over a wrong "confirmed".`
|
|
|
|
type llmTemporalVerifier struct {
|
|
p llm.Provider
|
|
}
|
|
|
|
// NewLLMTemporalVerifier builds a verifier from resolved LLM config. Returns a
|
|
// close func and (nil, nil, err) when no provider can be constructed — the
|
|
// caller treats that as "LLM unavailable" and skips verification.
|
|
func NewLLMTemporalVerifier(cfg llm.Config) (resolver.TemporalVerifier, func() error, error) {
|
|
cfg = cfg.ApplyDefaults()
|
|
if !cfg.IsEnabled() {
|
|
return nil, nil, fmt.Errorf("llm provider not enabled (set llm.provider)")
|
|
}
|
|
p, err := provider.New(cfg)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &llmTemporalVerifier{p: p}, p.Close, nil
|
|
}
|
|
|
|
// NewLLMTemporalVerifierFromProvider wraps an already-constructed provider.
|
|
//
|
|
// PURPOSE — let a host that already owns a live llm.Provider (e.g. the MCP
|
|
// server's shared LLM service) reuse it for temporal verification instead of
|
|
// spinning up a second provider from raw config via NewLLMTemporalVerifier.
|
|
// RATIONALE — the verifier is a thin Verify(req)→verdict adapter over
|
|
// Provider.Complete; binding it to an existing provider avoids duplicate model
|
|
// loads / API clients and respects the caller's provider lifecycle (no Close
|
|
// returned — the caller still owns p).
|
|
// KEYWORDS — temporal, verify, llm, provider, reuse
|
|
func NewLLMTemporalVerifierFromProvider(p llm.Provider) resolver.TemporalVerifier {
|
|
return &llmTemporalVerifier{p: p}
|
|
}
|
|
|
|
func (v *llmTemporalVerifier) Verify(ctx context.Context, req resolver.TemporalVerifyRequest) (resolver.TemporalVerifyResult, error) {
|
|
resp, err := v.p.Complete(ctx, llm.CompletionRequest{
|
|
Messages: []llm.Message{
|
|
{Role: llm.RoleSystem, Content: temporalVerifySystemPrompt},
|
|
{Role: llm.RoleUser, Content: buildTemporalVerifyPrompt(req)},
|
|
},
|
|
MaxTokens: 300,
|
|
Shape: llm.ShapeFreeform,
|
|
})
|
|
if err != nil {
|
|
return resolver.TemporalVerifyResult{}, err
|
|
}
|
|
return parseTemporalVerdict(resp.Text), nil
|
|
}
|
|
|
|
func buildTemporalVerifyPrompt(req resolver.TemporalVerifyRequest) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Dispatch name: %q (kind=%s, recognised via=%s)\n\n", req.DispatchName, req.Kind, req.Source)
|
|
fmt.Fprintf(&b, "Calling workflow %q:\n```go\n%s\n```\n\n", req.CallerName, req.CallerSource)
|
|
fmt.Fprintf(&b, "Candidate target %q:\n```go\n%s\n```\n\n", req.TargetName, req.TargetSource)
|
|
b.WriteString(`Does the workflow dispatch this candidate? Reply with strict JSON {"verdict":...,"reason":...}.`)
|
|
return b.String()
|
|
}
|
|
|
|
// parseTemporalVerdict tolerantly extracts a verdict from a possibly-wrapped
|
|
// LLM reply; an unparseable / unknown verdict degrades to "uncertain" so a
|
|
// flaky model never silently promotes or suppresses an edge.
|
|
func parseTemporalVerdict(raw string) resolver.TemporalVerifyResult {
|
|
var v struct {
|
|
Verdict string `json:"verdict"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.Unmarshal([]byte(extractJSONObject(raw)), &v); err != nil {
|
|
return resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdictUncertain, Reason: "unparseable verdict"}
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(v.Verdict)) {
|
|
case "confirmed":
|
|
return resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdictConfirmed, Reason: v.Reason}
|
|
case "rejected":
|
|
return resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdictRejected, Reason: v.Reason}
|
|
default:
|
|
return resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdictUncertain, Reason: v.Reason}
|
|
}
|
|
}
|
|
|
|
// extractJSONObject returns the first {...} span of s (models often wrap JSON in
|
|
// prose or code fences), or s unchanged when no braces are present.
|
|
func extractJSONObject(s string) string {
|
|
i := strings.IndexByte(s, '{')
|
|
j := strings.LastIndexByte(s, '}')
|
|
if i >= 0 && j > i {
|
|
return s[i : j+1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// --- Reproducibility cache -------------------------------------------------
|
|
|
|
// CachingVerifier wraps a TemporalVerifier with a disk-persisted verdict cache
|
|
// keyed by a hash of (model, kind, dispatch name, caller source, target
|
|
// source). Re-runs over unchanged code + model hit the cache, so the pass is
|
|
// reproducible (important for CI) and cheap. Errors are never cached.
|
|
type CachingVerifier struct {
|
|
inner resolver.TemporalVerifier
|
|
model string
|
|
path string
|
|
cache map[string]cachedVerdict
|
|
dirty bool
|
|
}
|
|
|
|
type cachedVerdict struct {
|
|
Verdict string `json:"verdict"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// NewCachingVerifier wraps inner, loading any existing cache at path (a missing
|
|
// or malformed file is ignored). A "" path disables persistence (in-memory only).
|
|
func NewCachingVerifier(inner resolver.TemporalVerifier, model, path string) *CachingVerifier {
|
|
c := &CachingVerifier{inner: inner, model: model, path: path, cache: map[string]cachedVerdict{}}
|
|
c.load()
|
|
return c
|
|
}
|
|
|
|
func (c *CachingVerifier) key(req resolver.TemporalVerifyRequest) string {
|
|
h := sha256.New()
|
|
h.Write([]byte(c.model + "\x00" + req.Kind + "\x00" + req.DispatchName + "\x00" + req.CallerSource + "\x00" + req.TargetSource))
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// Verify returns a cached verdict when present, else delegates and caches the
|
|
// result. A delegate error is propagated and NOT cached.
|
|
func (c *CachingVerifier) Verify(ctx context.Context, req resolver.TemporalVerifyRequest) (resolver.TemporalVerifyResult, error) {
|
|
k := c.key(req)
|
|
if cv, ok := c.cache[k]; ok {
|
|
return resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdict(cv.Verdict), Reason: cv.Reason}, nil
|
|
}
|
|
res, err := c.inner.Verify(ctx, req)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
c.cache[k] = cachedVerdict{Verdict: string(res.Verdict), Reason: res.Reason}
|
|
c.dirty = true
|
|
return res, nil
|
|
}
|
|
|
|
func (c *CachingVerifier) load() {
|
|
if c.path == "" {
|
|
return
|
|
}
|
|
raw, err := os.ReadFile(c.path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var m map[string]cachedVerdict
|
|
if json.Unmarshal(raw, &m) == nil && m != nil {
|
|
c.cache = m
|
|
}
|
|
}
|
|
|
|
// Flush persists the cache to disk when dirty and a path is set. Safe to call
|
|
// once after the verification run.
|
|
func (c *CachingVerifier) Flush() error {
|
|
if !c.dirty || c.path == "" {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
raw, err := json.MarshalIndent(c.cache, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(c.path, raw, 0o644)
|
|
}
|