Download
304 lines 9.5 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
package tui

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"path/filepath"
	"slices"
	"strings"

	"blinker/internal/agent"
	"blinker/internal/diff"
	"blinker/internal/llm"
	"blinker/internal/policy"
	"blinker/internal/tooldef"
	"blinker/internal/tools"
)

// policyRequest has the model draft the policy change the user asked for
// and puts each proposal to the user, as a diff with what it means, until
// one is saved or the user stops. It runs in its own goroutine, like an
// agent turn, and ends with agent.TurnDone.
func policyRequest(ctx context.Context, d Deps, client *llm.Client, b *bridge, request string) error {
	env := d.SB.Env()
	store := d.SB.Store()
	loaded, err := store.Load(env.Project)
	if err != nil {
		return err
	}
	lintBefore, err := d.SB.Check(loaded)
	if err != nil {
		return fmt.Errorf("the policy as it is now: %w", err)
	}
	defs := d.SB.Tools()
	// lint checks f as src's new version, with the other policies.
	lint := func(src policy.Source, f *policy.File) ([]policy.Warning, error) {
		withProposal, err := store.With(loaded, src, f)
		if err != nil {
			return nil, err
		}
		return d.SB.Check(withProposal)
	}
	check := func(p agent.PolicyProposal) error {
		_, err := lint(p.Source, p.File)
		return err
	}
	draft := agent.NewPolicyDraft(client, loaded, env, defs, request)
	for {
		proposal, reply, err := draft.Propose(ctx, check)
		if err != nil {
			return err
		}
		if proposal.File == nil {
			if strings.TrimSpace(reply) == "" {
				return errors.New("the model proposed nothing")
			}
			b.send(eventMsg{agent.Token{Text: reply}})
			b.send(eventMsg{agent.ReplyDone{}})
			return nil
		}
		src, path := proposal.Source, proposal.Source.Path
		was, proposed := src.File.Marshal(), proposal.File.Marshal()
		if bytes.Equal(was, proposed) {
			return errors.New("the model proposed no change")
		}
		// The user may edit the proposal, so it is reviewed as text and
		// parsed again each time.
		text := string(proposed)
		for {
			parsed, about, valid := reviewPolicy(lint, env, defs, lintBefore, src, text)
			q := tools.Question{
				Tool:  "policy",
				Title: fmt.Sprintf("Change %s (%s)?", src.Name, path),
				Diff:  diff.Unified(filepath.Base(path), string(was), text, 3),
				About: about,
				Edits: []tools.Edit{{Name: filepath.Base(path), Old: string(was), New: text}},
			}
			if valid {
				q.Opts = append(q.Opts, tools.Option{Label: "Save it", Allow: true})
			}
			q.Opts = append(q.Opts,
				tools.Option{Label: "Edit it in vimdiff", Allow: true, Edit: true},
				tools.Option{Label: "Ask the model for changes", Explain: true},
				tools.Option{Label: "Cancel"},
			)
			a := ask(ctx, b, q)
			if err := ctx.Err(); err != nil {
				return err
			}
			switch {
			case a.Option.Edit:
				if len(a.Edited) != 1 {
					return errors.New("the editor returned no contents")
				}
				text = a.Edited[0]
				continue
			case a.Option.Explain:
				draft.Revise(a.Note)
			case a.Option.Allow:
				if err := store.Replace(env.Project, path, was, parsed); err != nil {
					return err
				}
				if err := d.SB.Reload(); err != nil {
					return fmt.Errorf("saved %s, but it does not load, so the previous policy stays in force: %w", path, err)
				}
				b.send(eventMsg{agent.ToolDone{Name: "policy", Result: tools.Result{Summary: "Saved " + src.Name + " (" + path + ")"}}})
				return nil
			default:
				b.send(eventMsg{agent.ToolDone{Name: "policy", Result: tools.Result{Summary: "Left the policy unchanged", Failed: true}}})
				return nil
			}
			break // to the model's revised proposal
		}
	}
}

// reviewPolicy parses a proposed new version of src, and says what it
// changes in plain words, with any new warnings, or why it is not valid.
func reviewPolicy(lint func(policy.Source, *policy.File) ([]policy.Warning, error), env policy.Env, defs []tooldef.Def, lintBefore []policy.Warning, src policy.Source, text string) (*policy.File, string, bool) {
	f, err := policy.Parse([]byte(text))
	var warnings []policy.Warning
	if err == nil {
		warnings, err = lint(src, f)
	}
	if err != nil {
		return nil, "Not valid, so it cannot be saved: " + err.Error(), false
	}
	lines := describeChange(src.File, f, env, defs, src.Mode)
	for _, w := range warnings {
		if !slices.ContainsFunc(lintBefore, w.Same) {
			lines = append(lines, "Warning: "+w.Text)
		}
	}
	return f, strings.Join(lines, "\n"), true
}

// describeChange lists the rules a change adds and removes, in plain
// words, and whether it moves rules, which matters since the last match
// wins.
func describeChange(before, after *policy.File, env policy.Env, defs []tooldef.Def, mode policy.Mode) []string {
	var out []string
	add := func(verb string, rules []string) {
		for _, r := range rules {
			out = append(out, verb+" "+r)
		}
	}
	moved := false
	diffRules := func(o, n []string) {
		add("Removes:", without(o, n))
		add("Adds:", without(n, o))
		if !slices.Equal(only(o, n), only(n, o)) {
			moved = true
		}
	}
	include := func(inc policy.Include) string { return "the rules of policy " + inc.Name }
	fileRule := func(r policy.FileRule) string { return describeFileRule(r, env, mode) }
	netRule := func(r policy.NetRule) string { return describeNetRule(r, mode) }
	toolRule := func(r policy.ToolRule) string { return describeToolRule(r, defs, mode) }
	envRule := func(r policy.EnvRule) string { return describeEnvRule(r, mode) }
	diffRules(describeEach(before.Includes, include), describeEach(after.Includes, include))
	diffRules(describeEach(before.Files, fileRule), describeEach(after.Files, fileRule))
	diffRules(describeEach(before.Network, netRule), describeEach(after.Network, netRule))
	diffRules(describeEach(before.Tools, toolRule), describeEach(after.Tools, toolRule))
	diffRules(describeEach(before.Env, envRule), describeEach(after.Env, envRule))
	if moved {
		out = append(out, "Moves rules. The last matching rule wins, so this can change what applies.")
	}
	if len(out) == 0 {
		out = append(out, "No rule changes; only formatting or notes.")
	}
	return out
}

func describeEach[R any](rules []R, describe func(R) string) []string {
	var out []string
	for _, r := range rules {
		out = append(out, describe(r))
	}
	return out
}

// without is the items of a not in b.
func without(a, b []string) []string {
	var out []string
	for _, s := range a {
		if !slices.Contains(b, s) {
			out = append(out, s)
		}
	}
	return out
}

// only is the items of a that are also in b, in a's order.
func only(a, b []string) []string {
	var out []string
	for _, s := range a {
		if slices.Contains(b, s) {
			out = append(out, s)
		}
	}
	return out
}

var (
	fileWords = map[policy.Perm]string{policy.None: "hidden", policy.Read: "can read", policy.Write: "can read and write"}
	netWords  = map[policy.NetPerm]string{policy.Deny: "blocked", policy.Ask: "asks you first", policy.Allow: "allowed"}
	envWords  = map[policy.NetPerm]string{policy.Deny: "hidden", policy.Allow: "passed"}
)

// who is what a mode's policy applies to, for people.
func who(mode policy.Mode) (files, bash string) {
	if mode == policy.UnsafeMode {
		return "once you approve", "Unsafe_Bash"
	}
	return "without asking", "Safe_Bash"
}

func describeFileRule(r policy.FileRule, env policy.Env, mode policy.Mode) string {
	what := r.Path
	if policy.IsEnvPath(r.Path) {
		what = "the directories in " + r.Path + " (except home and the project, and directories containing them)"
	} else if m, err := policy.NewMatcher(r.Path, env.Home, env.Project); err == nil {
		if m.IsGlob() {
			what = "paths under " + shortPath(m.Prefix, env.Home) + " matching " + r.Path + ", and everything in them"
		} else {
			what = shortPath(m.Path, env.Home) + " and everything in it"
		}
	}
	when, _ := who(mode)
	return fmt.Sprintf("%s: %s %s%s", what, when, fileWords[r.Access], note(r.Note))
}

func describeNetRule(r policy.NetRule, mode policy.Mode) string {
	what := r.Host
	switch {
	case r.Host == "*":
		what = "every host"
	case strings.HasPrefix(r.Host, "*."):
		what = "the subdomains of " + r.Host[2:] + " (not " + r.Host[2:] + " itself)"
	}
	_, bash := who(mode)
	access := netWords[r.Access]
	if mode == policy.SafeMode && r.Access == policy.Ask {
		access = "blocked (Safe_Bash never asks)"
	}
	return fmt.Sprintf("connections to %s: %s %s%s", what, bash, access, note(r.Note))
}

func describeEnvRule(r policy.EnvRule, mode policy.Mode) string {
	what := "the environment variable " + r.Name
	if policy.IsGlob(r.Name) {
		what = "environment variables matching " + r.Name
	}
	_, bash := who(mode)
	if r.Value != "" {
		return fmt.Sprintf("%s: set to %q for %s%s", what, r.Value, bash, note(r.Note))
	}
	return fmt.Sprintf("%s: to %s %s%s", what, bash, envWords[r.Access], note(r.Note))
}

func describeToolRule(r policy.ToolRule, defs []tooldef.Def, mode policy.Mode) string {
	s := "the " + r.Tool + " tool"
	if r.Tool == "*" {
		s = "every tool"
	}
	switch {
	case mode == policy.SafeMode && r.Access == policy.Allow:
		s += " runs without asking, with Safe_Bash's access"
	case mode == policy.SafeMode:
		s += " does not run without asking"
	case r.Access == policy.Allow:
		s += " runs once you approve each call, with Unsafe_Bash's access"
	default:
		s += " does not run once you approve, only if the safe policy lets it run without asking"
	}
	for _, t := range defs {
		if t.Name == r.Tool {
			if g := t.Grants(); g != "" {
				s += "; it may also " + g
			}
		}
	}
	return s + note(r.Note)
}

func note(n string) string {
	if n == "" {
		return ""
	}
	return " (note: " + n + ")"
}

// shortPath shows a path with ~ for home.
func shortPath(p, home string) string {
	rel, err := filepath.Rel(home, p)
	if err != nil || !filepath.IsLocal(rel) {
		return p
	}
	if rel == "." {
		return "~"
	}
	return "~/" + rel
}