ajhahn.de
← eeco
Go 180 lines
package cockpit

import (
	"errors"
	"fmt"
	"path/filepath"
	"strings"
)

// Renderer translates a neutral Playbook into one harness target's config
// bytes. A Renderer is the only component that knows a target's file layout
// (RelPath) and permission spelling (Render). Render must be deterministic:
// the same Playbook yields byte-identical output, so re-emit is idempotent
// and drift detection is a sha comparison.
//
// A per-playbook target (Claude, Cursor) emits one file per Playbook. An
// aggregate target (AGENTS.md, GEMINI.md) emits one shared file for the whole
// selected set and additionally implements AggregateRenderer; the per-playbook
// Render/RelPath on an aggregate renderer are defined for interface
// completeness but are not on the emit path (Generate rejects aggregate
// targets — see emit.go).
type Renderer interface {
	Target() string                    // the target name, e.g. "claude"
	RelPath(p Playbook) string         // artifact path relative to the user dir
	Render(p Playbook) ([]byte, error) // deterministic config bytes
}

// AggregateRenderer is the optional contract for a target whose config is a
// single shared file describing the whole selected playbook set (AGENTS.md,
// GEMINI.md) rather than one file per playbook. It is discovered by
// type-assertion (isAggregate); a renderer that does not implement it is
// per-playbook. RenderAll must be deterministic for a given set regardless of
// input order (it sorts by Name internally) so re-emit stays sha-idempotent.
type AggregateRenderer interface {
	Renderer
	AggRelPath() string                      // single shared artifact path, playbook-independent
	RenderAll(ps []Playbook) ([]byte, error) // deterministic bytes for the whole set
}

// isAggregate reports whether r emits one shared file for a set rather than
// one file per playbook, returning the aggregate view when it does.
func isAggregate(r Renderer) (AggregateRenderer, bool) {
	a, ok := r.(AggregateRenderer)
	return a, ok
}

// Enforcement describes the harness-runtime side of a target's safety
// guarantee: whether the harness itself enforces the playbook's allowlist
// (Claude's allowed-tools) or the emitted artifact is only advisory text the
// AI is asked to honor (AGENTS.md, GEMINI.md, Cursor rules). It says nothing
// about eeco's own generate-time gate, which is always enforced for every
// target — "advisory" never relaxes the safety invariant, it only describes
// what the downstream harness does with the file.
type Enforcement int

const (
	// EnforcementEnforced means the harness enforces the allowlist at runtime.
	EnforcementEnforced Enforcement = iota
	// EnforcementAdvisory means the artifact is advisory text only — the
	// harness does not enforce tool permissions from it.
	EnforcementAdvisory
)

// String renders the enforcement level for banners, fidelity lines, and
// status output.
func (e Enforcement) String() string {
	if e == EnforcementEnforced {
		return "enforced"
	}
	return "advisory"
}

// Fidelity is the optional contract a Renderer implements to declare its
// harness-runtime enforcement. A renderer that does not implement it is
// treated as advisory (fail-honest default — never claim enforcement that
// isn't there).
type Fidelity interface {
	Enforcement() Enforcement
}

// fidelityOf returns r's declared enforcement, defaulting to advisory for a
// renderer that does not declare one (fail-honest).
func fidelityOf(r Renderer) Enforcement {
	if f, ok := r.(Fidelity); ok {
		return f.Enforcement()
	}
	return EnforcementAdvisory
}

// relUnder validates that a renderer-supplied artifact path stays inside the
// user dir before it is joined to cfg.UserDir — the write-scope-floor guard.
// It rejects an absolute path or one that escapes via "..", mirroring the
// path guards in internal/config, and returns the cleaned relative path.
func relUnder(rel string) (string, error) {
	if strings.TrimSpace(rel) == "" {
		return "", errors.New("empty artifact path")
	}
	// A leading "/" or "\" is not absolute on Windows (Go needs a volume
	// name), so guard the rooted forms explicitly alongside filepath.IsAbs.
	if filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, `\`) {
		return "", fmt.Errorf("artifact path %q must be relative to the user dir", rel)
	}
	clean := filepath.Clean(rel)
	if slash := filepath.ToSlash(clean); slash == ".." || strings.HasPrefix(slash, "../") {
		return "", fmt.Errorf("artifact path %q escapes the user dir", rel)
	}
	return clean, nil
}

// rendererFor returns the renderer for a target name. Claude is enforced and
// per-playbook; Cursor is advisory and per-playbook; AGENTS.md and GEMINI.md
// are advisory and aggregate.
func rendererFor(target string) (Renderer, bool) {
	switch target {
	case "claude":
		return claudeRenderer{}, true
	case "cursor":
		return cursorRenderer{}, true
	case "agents":
		return agentsRenderer{}, true
	case "gemini":
		return geminiRenderer{}, true
	default:
		return nil, false
	}
}

// Targets lists the renderer target names eeco can emit, for usage and error
// messages. Order is stable (Claude first as the enforced reference target).
func Targets() []string {
	return []string{"claude", "cursor", "agents", "gemini"}
}

// unknownTargetErr is the shared "unknown target" error naming the known set,
// so every entry point words it identically.
func unknownTargetErr(target string) error {
	return fmt.Errorf("unknown target %q (known: %s)", target, strings.Join(Targets(), ", "))
}

// IsAggregateTarget reports whether target emits one shared file for the whole
// set (AGENTS.md, GEMINI.md) rather than one file per playbook. Unknown
// targets report false. The cmd layer uses it to route generate/verify/off
// down the per-playbook or aggregate path.
func IsAggregateTarget(target string) bool {
	r, ok := rendererFor(target)
	if !ok {
		return false
	}
	_, agg := isAggregate(r)
	return agg
}

// TargetFidelity returns target's harness-runtime enforcement and whether the
// target is known, for `eeco cockpit target list` and status.
func TargetFidelity(target string) (Enforcement, bool) {
	r, ok := rendererFor(target)
	if !ok {
		return EnforcementAdvisory, false
	}
	return fidelityOf(r), true
}

// MachineryFidelity reports whether target can host the auto-firing
// deterministic machinery — the PreToolUse git-write guard and the other
// runtime hook events the cockpit emits. It is EnforcementEnforced only for a
// harness with a real runtime hook channel (claude), EnforcementAdvisory
// otherwise; the second return is false for an unknown target. It is the
// machinery analog of TargetFidelity (which describes the emitted playbook
// allowlist), letting the cmd layer print honest fidelity without the hooks
// package importing target logic.
func MachineryFidelity(target string) (Enforcement, bool) {
	if _, ok := rendererFor(target); !ok {
		return EnforcementAdvisory, false
	}
	if target == "claude" {
		return EnforcementEnforced, true
	}
	return EnforcementAdvisory, true
}