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
}