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 }