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
}
|