ajhahn.de
← eeco
Go 160 lines
package cockpit

import (
	"fmt"
	"strings"
)

// SelfConsistencyResult is the outcome of an advisory artifact's
// render -> parse-back -> assert check. OK is true only when the rendered
// bytes still surface every playbook's forbidden content, carry the advisory
// banner, keep the step/output structure, and leak no write-git verb into an
// Allowed block. Notes name each failed assertion.
type SelfConsistencyResult struct {
	OK    bool
	Notes []string
}

// CheckSelfConsistency renders pb for a per-playbook advisory target (cursor)
// and asserts the rendered bytes preserve the safety contract. Parity stays
// Claude-only (the new targets have no FlashOS answer key); this is the
// answer-key-free substitute — a renderer that silently dropped the Forbidden
// block or leaked a write verb fails here.
func CheckSelfConsistency(pb Playbook, target string) (SelfConsistencyResult, error) {
	r, ok := rendererFor(target)
	if !ok {
		return SelfConsistencyResult{}, unknownTargetErr(target)
	}
	out, err := r.Render(pb)
	if err != nil {
		return SelfConsistencyResult{}, err
	}
	return checkSelfConsistencyBytes(out, []Playbook{pb}), nil
}

// CheckSelfConsistencyAll renders set for an aggregate advisory target
// (agents, gemini) and asserts the rendered document preserves every
// playbook's safety contract.
func CheckSelfConsistencyAll(set []Playbook, target string) (SelfConsistencyResult, error) {
	r, ok := rendererFor(target)
	if !ok {
		return SelfConsistencyResult{}, unknownTargetErr(target)
	}
	agg, ok := isAggregate(r)
	if !ok {
		return SelfConsistencyResult{}, fmt.Errorf("target %q is not aggregate", target)
	}
	out, err := agg.RenderAll(set)
	if err != nil {
		return SelfConsistencyResult{}, err
	}
	return checkSelfConsistencyBytes(out, set), nil
}

// checkSelfConsistencyBytes is the shared assertion core: given rendered bytes
// and the playbook set they should describe, it verifies the banner, every
// forbidden verb/phrase, the step/output structure, and zero leaked write-git
// verbs in any Allowed block. It runs on the literal bytes — at verify time
// the caller passes the on-disk bytes (sub-decision S4) so a hand-edit that
// strips the Forbidden block fails.
func checkSelfConsistencyBytes(out []byte, set []Playbook) SelfConsistencyResult {
	text := string(out)
	var notes []string

	if !strings.Contains(text, advisoryBanner) {
		notes = append(notes, "ADVISORY banner missing")
	}

	for _, pb := range set {
		for _, v := range pb.Intent.forbiddenVerbs() {
			if !strings.Contains(text, "git "+v) {
				notes = append(notes, fmt.Sprintf("%s: forbidden verb %q not surfaced", pb.Name, v))
			}
		}
		for _, ph := range pb.Intent.Forbidden {
			if !strings.Contains(text, ph) {
				notes = append(notes, fmt.Sprintf("%s: forbidden phrase %q not surfaced", pb.Name, ph))
			}
		}
	}

	wantSteps, wantOutputs := 0, 0
	for _, pb := range set {
		wantSteps += len(pb.Steps)
		if strings.TrimSpace(pb.OutputFormat) != "" {
			wantOutputs++
		}
	}
	gotSteps, gotOutputs := countHeadings(text)
	if gotSteps < wantSteps {
		notes = append(notes, fmt.Sprintf("step headings %d < expected %d", gotSteps, wantSteps))
	}
	if gotOutputs < wantOutputs {
		notes = append(notes, fmt.Sprintf("output sections %d < expected %d", gotOutputs, wantOutputs))
	}

	// Defense-in-depth: re-scan every Allowed block for a leaked write-git
	// verb, against the union of the set's denylists.
	if hits := ScanAllowlistForWriteGitVerbs(parseAdvisoryAllowed(text), unionForbidden(set)); len(hits) > 0 {
		notes = append(notes, "write-git verb leaked into an Allowed block: "+strings.Join(hits, ", "))
	}

	return SelfConsistencyResult{OK: len(notes) == 0, Notes: notes}
}

// countHeadings counts the Markdown headings (at any depth) whose text starts
// with the step marker, and those equal to the output heading.
func countHeadings(text string) (steps, outputs int) {
	for _, raw := range strings.Split(text, "\n") {
		line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
		if !strings.HasPrefix(line, "#") {
			continue
		}
		h := strings.TrimSpace(strings.TrimLeft(line, "#"))
		switch {
		case strings.HasPrefix(h, headingStep):
			steps++
		case h == headingOutput:
			outputs++
		}
	}
	return steps, outputs
}

// parseAdvisoryAllowed collects the bullet entries under every "Allowed"
// heading in an advisory artifact (cursor .mdc / aggregate Markdown) so the
// composed allowlist can be re-scanned for leaked write-git verbs. Bullets
// under any other heading (Forbidden, Fidelity report) are ignored.
func parseAdvisoryAllowed(text string) []string {
	var out []string
	inAllowed := false
	for _, raw := range strings.Split(text, "\n") {
		trimmed := strings.TrimSpace(strings.TrimRight(raw, "\r"))
		if strings.HasPrefix(trimmed, "#") {
			h := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
			inAllowed = strings.HasPrefix(h, "Allowed")
			continue
		}
		if inAllowed && strings.HasPrefix(trimmed, "- ") {
			out = append(out, strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")))
		}
	}
	return out
}

// unionForbidden returns the de-duplicated union of the set's git-verb
// denylists, for the leaked-verb scan.
func unionForbidden(set []Playbook) []string {
	seen := make(map[string]bool)
	var out []string
	for _, pb := range set {
		for _, v := range pb.Intent.forbiddenVerbs() {
			if !seen[v] {
				seen[v] = true
				out = append(out, v)
			}
		}
	}
	return out
}