Refine Zotfile consent prompt
- commit
- 159a2d353ed935fc0f8b23d32b2ab783833f8555
- author
- patriceckhart · · 2026-07-29 15:40 UTC
- parent
- d109cbf
- signature
- unsigned
- refs
- tag: v0.3.23
2 files · +175 -44
M packages/agent/zotfile.go +115 -36
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | 24 | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 25 | 25 | "github.com/patriceckhart/zot/packages/provider" | |
| 26 | 26 | "github.com/patriceckhart/zot/packages/provider/auth" | |
| 27 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 27 | 28 | "golang.org/x/term" | |
| 28 | 29 | ) | |
| 29 | 30 | ||
| @@ -128,9 +129,13 @@ func runLocalZotfile(ref string, args Args, version string) error { | |||
| 128 | 129 | return err | |
| 129 | 130 | } | |
| 130 | 131 | perms := zf.Manifest.Permissions.Expand(args.CWD, agentData) | |
| 131 | - | if err := consentZotfile(zf, perms); err != nil { | |
| 132 | + | allowed, err := consentZotfile(zf, perms) | |
| 133 | + | if err != nil { | |
| 132 | 134 | return err | |
| 133 | 135 | } | |
| 136 | + | if !allowed { | |
| 137 | + | return nil | |
| 138 | + | } | |
| 134 | 139 | agentPath := filepath.Join(zf.Dir, "AGENT.md") | |
| 135 | 140 | agentPrompt, err := os.ReadFile(agentPath) | |
| 136 | 141 | if err != nil { | |
| @@ -750,9 +755,9 @@ func checkZotfileRequirements(zf zotfileLoaded, version string) error { | |||
| 750 | 755 | ||
| 751 | 756 | var execLookPath = exec.LookPath | |
| 752 | 757 | ||
| 753 | - | func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | |
| 758 | + | func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) { | |
| 754 | 759 | if os.Getenv("ZOT_AGENT_CONSENT") == "1" { | |
| 755 | - | return nil | |
| 760 | + | return true, nil | |
| 756 | 761 | } | |
| 757 | 762 | // "ask" deliberately requires approval on every launch. Other consent | |
| 758 | 763 | // is durable only for this exact artifact digest, so any package change | |
| @@ -760,42 +765,68 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | |||
| 760 | 765 | consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json") | |
| 761 | 766 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" { | |
| 762 | 767 | if _, err := os.Stat(consentPath); err == nil { | |
| 763 | - | return nil | |
| 768 | + | return true, nil | |
| 764 | 769 | } | |
| 765 | 770 | } | |
| 766 | - | fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version) | |
| 767 | - | fmt.Print(permissionSummary(perms)) | |
| 768 | - | if !term.IsTerminal(int(os.Stdin.Fd())) { | |
| 769 | - | return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow") | |
| 771 | + | interactive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) | |
| 772 | + | th := tui.Dark | |
| 773 | + | if interactive { | |
| 774 | + | cfg, _ := LoadConfig() | |
| 775 | + | th, _, _ = tui.DetectThemeWithCustom(ZotHome(), cfg.Theme, 80*time.Millisecond) | |
| 770 | 776 | } | |
| 771 | - | fmt.Print("\nAllow? [y/n] ") | |
| 772 | - | allowed, err := readZotfileConsent(os.Stdin, os.Stdout) | |
| 773 | - | if err != nil { | |
| 774 | - | return err | |
| 777 | + | fmt.Print(formatZotfileConsent(zf, perms, th, interactive)) | |
| 778 | + | if !interactive { | |
| 779 | + | return false, fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow") | |
| 775 | 780 | } | |
| 776 | - | if !allowed { | |
| 777 | - | return fmt.Errorf("declined") | |
| 781 | + | allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th) | |
| 782 | + | if err != nil || !allowed { | |
| 783 | + | return allowed, err | |
| 778 | 784 | } | |
| 779 | 785 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" { | |
| 780 | - | return nil | |
| 786 | + | return true, nil | |
| 781 | 787 | } | |
| 782 | 788 | if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil { | |
| 783 | - | return fmt.Errorf("save agent consent: %w", err) | |
| 789 | + | return false, fmt.Errorf("save agent consent: %w", err) | |
| 784 | 790 | } | |
| 785 | 791 | receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version} | |
| 786 | 792 | data, _ := json.MarshalIndent(receipt, "", " ") | |
| 787 | 793 | if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil { | |
| 788 | - | return fmt.Errorf("save agent consent: %w", err) | |
| 794 | + | return false, fmt.Errorf("save agent consent: %w", err) | |
| 789 | 795 | } | |
| 790 | - | return nil | |
| 796 | + | return true, nil | |
| 791 | 797 | } | |
| 792 | 798 | ||
| 793 | 799 | var errZotfileConsentInterrupted = errors.New("interrupted") | |
| 794 | 800 | ||
| 795 | - | // readZotfileConsent reads an immediate single-key y/n answer without echoing | |
| 801 | + | func formatZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string { | |
| 802 | + | style := func(c int, text string) string { | |
| 803 | + | if !color { | |
| 804 | + | return text | |
| 805 | + | } | |
| 806 | + | return th.FG256(c, text) | |
| 807 | + | } | |
| 808 | + | ||
| 809 | + | var sb strings.Builder | |
| 810 | + | sb.WriteString(style(th.Assistant, "Agent")) | |
| 811 | + | sb.WriteByte(' ') | |
| 812 | + | sb.WriteString(style(th.Accent, zf.Manifest.Name+"@"+zf.Manifest.Version)) | |
| 813 | + | sb.WriteString(style(th.FG, " wants to run.")) | |
| 814 | + | sb.WriteString("\n\n") | |
| 815 | + | sb.WriteString(permissionSummaryStyled(perms, th, color)) | |
| 816 | + | if color { | |
| 817 | + | sb.WriteByte('\n') | |
| 818 | + | sb.WriteString(style(th.Assistant, "Allow?")) | |
| 819 | + | sb.WriteByte(' ') | |
| 820 | + | sb.WriteString(style(th.Muted, "[y/n]")) | |
| 821 | + | sb.WriteByte(' ') | |
| 822 | + | } | |
| 823 | + | return sb.String() | |
| 824 | + | } | |
| 825 | + | ||
| 826 | + | // readZotfileConsent accepts one y/n answer followed by Enter without echoing | |
| 796 | 827 | // unrelated input. Raw mode also turns Ctrl+C into a byte so the terminal can | |
| 797 | 828 | // be restored before the command aborts. | |
| 798 | - | func readZotfileConsent(in *os.File, out io.Writer) (bool, error) { | |
| 829 | + | func readZotfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error) { | |
| 799 | 830 | fd := int(in.Fd()) | |
| 800 | 831 | state, err := term.MakeRaw(fd) | |
| 801 | 832 | if err != nil { | |
| @@ -803,7 +834,13 @@ func readZotfileConsent(in *os.File, out io.Writer) (bool, error) { | |||
| 803 | 834 | } | |
| 804 | 835 | defer func() { _ = term.Restore(fd, state) }() | |
| 805 | 836 | ||
| 806 | - | answer, err := readZotfileConsentKey(in) | |
| 837 | + | answer, err := readZotfileConsentKey(in, func(answer byte) { | |
| 838 | + | if answer == 0 { | |
| 839 | + | fmt.Fprint(out, "\b \b") | |
| 840 | + | return | |
| 841 | + | } | |
| 842 | + | fmt.Fprint(out, th.FG256(th.FG, string(answer))) | |
| 843 | + | }) | |
| 807 | 844 | switch { | |
| 808 | 845 | case errors.Is(err, errZotfileConsentInterrupted): | |
| 809 | 846 | fmt.Fprint(out, "^C\r\n") | |
| @@ -812,22 +849,37 @@ func readZotfileConsent(in *os.File, out io.Writer) (bool, error) { | |||
| 812 | 849 | fmt.Fprint(out, "\r\n") | |
| 813 | 850 | return false, fmt.Errorf("read agent consent: %w", err) | |
| 814 | 851 | default: | |
| 815 | - | fmt.Fprintf(out, "%c\r\n", answer) | |
| 852 | + | fmt.Fprint(out, "\r\n") | |
| 816 | 853 | return answer == 'y', nil | |
| 817 | 854 | } | |
| 818 | 855 | } | |
| 819 | 856 | ||
| 820 | - | func readZotfileConsentKey(r io.Reader) (byte, error) { | |
| 857 | + | func readZotfileConsentKey(r io.Reader, echo func(byte)) (byte, error) { | |
| 858 | + | var answer byte | |
| 821 | 859 | var b [1]byte | |
| 822 | 860 | for { | |
| 823 | 861 | if _, err := io.ReadFull(r, b[:]); err != nil { | |
| 824 | 862 | return 0, err | |
| 825 | 863 | } | |
| 826 | 864 | switch b[0] { | |
| 827 | - | case 'y', 'Y': | |
| 828 | - | return 'y', nil | |
| 829 | - | case 'n', 'N': | |
| 830 | - | return 'n', nil | |
| 865 | + | case 'y', 'Y', 'n', 'N': | |
| 866 | + | if answer == 0 { | |
| 867 | + | answer = b[0] | 0x20 | |
| 868 | + | if echo != nil { | |
| 869 | + | echo(answer) | |
| 870 | + | } | |
| 871 | + | } | |
| 872 | + | case '\r', '\n': | |
| 873 | + | if answer != 0 { | |
| 874 | + | return answer, nil | |
| 875 | + | } | |
| 876 | + | case '\b', 0x7f: | |
| 877 | + | if answer != 0 { | |
| 878 | + | answer = 0 | |
| 879 | + | if echo != nil { | |
| 880 | + | echo(0) | |
| 881 | + | } | |
| 882 | + | } | |
| 831 | 883 | case 3: // Ctrl+C in raw mode. | |
| 832 | 884 | return 0, errZotfileConsentInterrupted | |
| 833 | 885 | } | |
| @@ -835,31 +887,58 @@ func readZotfileConsentKey(r io.Reader) (byte, error) { | |||
| 835 | 887 | } | |
| 836 | 888 | ||
| 837 | 889 | func permissionSummary(p tools.PermissionSet) string { | |
| 890 | + | return permissionSummaryStyled(p, tui.Theme{}, false) | |
| 891 | + | } | |
| 892 | + | ||
| 893 | + | func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string { | |
| 894 | + | style := func(c int, text string) string { | |
| 895 | + | if !color { | |
| 896 | + | return text | |
| 897 | + | } | |
| 898 | + | return th.FG256(c, text) | |
| 899 | + | } | |
| 900 | + | permissionValue := func(value string) string { return style(th.FG, value) } | |
| 901 | + | ||
| 838 | 902 | var sb strings.Builder | |
| 903 | + | sb.WriteString(style(th.Muted, " fs read: ")) | |
| 839 | 904 | if len(p.FS.Read) > 0 { | |
| 840 | - | fmt.Fprintf(&sb, " fs read: %s\n", strings.Join(p.FS.Read, ", ")) | |
| 905 | + | sb.WriteString(permissionValue(strings.Join(p.FS.Read, ", "))) | |
| 841 | 906 | } else { | |
| 842 | - | fmt.Fprintln(&sb, " fs read: none") | |
| 907 | + | sb.WriteString(style(th.Muted, "none")) | |
| 843 | 908 | } | |
| 909 | + | sb.WriteByte('\n') | |
| 910 | + | sb.WriteString(style(th.Muted, " fs write: ")) | |
| 844 | 911 | if len(p.FS.Write) > 0 { | |
| 845 | - | fmt.Fprintf(&sb, " fs write: %s\n", strings.Join(p.FS.Write, ", ")) | |
| 912 | + | sb.WriteString(permissionValue(strings.Join(p.FS.Write, ", "))) | |
| 846 | 913 | } else { | |
| 847 | - | fmt.Fprintln(&sb, " fs write: none") | |
| 914 | + | sb.WriteString(style(th.Muted, "none")) | |
| 848 | 915 | } | |
| 916 | + | sb.WriteByte('\n') | |
| 849 | 917 | mode := p.Bash.Mode | |
| 850 | 918 | if mode == "" { | |
| 851 | 919 | mode = "none" | |
| 852 | 920 | } | |
| 853 | - | fmt.Fprintf(&sb, " bash: %s", mode) | |
| 921 | + | sb.WriteString(style(th.Muted, " bash: ")) | |
| 922 | + | modeColor := th.FG | |
| 923 | + | if mode == "ask" { | |
| 924 | + | modeColor = th.Warning | |
| 925 | + | } | |
| 926 | + | sb.WriteString(style(modeColor, mode)) | |
| 854 | 927 | if len(p.Bash.Allow) > 0 { | |
| 855 | - | fmt.Fprintf(&sb, " (%s)", strings.Join(p.Bash.Allow, ", ")) | |
| 928 | + | sb.WriteString(style(th.Muted, " (")) | |
| 929 | + | sb.WriteString(permissionValue(strings.Join(p.Bash.Allow, ", "))) | |
| 930 | + | sb.WriteString(style(th.Muted, ")")) | |
| 856 | 931 | } | |
| 857 | - | fmt.Fprintln(&sb) | |
| 932 | + | sb.WriteByte('\n') | |
| 858 | 933 | if len(p.Net.Allow) > 0 { | |
| 859 | - | fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", ")) | |
| 934 | + | sb.WriteString(style(th.Muted, " net: ")) | |
| 935 | + | sb.WriteString(permissionValue(strings.Join(p.Net.Allow, ", "))) | |
| 936 | + | sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n")) | |
| 860 | 937 | } | |
| 861 | 938 | if len(p.Env.Read) > 0 { | |
| 862 | - | fmt.Fprintf(&sb, " env read: %s (declared, not enforced in this build)\n", strings.Join(p.Env.Read, ", ")) | |
| 939 | + | sb.WriteString(style(th.Muted, " env read: ")) | |
| 940 | + | sb.WriteString(permissionValue(strings.Join(p.Env.Read, ", "))) | |
| 941 | + | sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n")) | |
| 863 | 942 | } | |
| 864 | 943 | return sb.String() | |
| 865 | 944 | } | |
| @@ -24,6 +24,7 @@ import ( | |||||
| 24 | "github.com/patriceckhart/zot/packages/agent/tools" | 24 | "github.com/patriceckhart/zot/packages/agent/tools" | ||
| 25 | "github.com/patriceckhart/zot/packages/provider" | 25 | "github.com/patriceckhart/zot/packages/provider" | ||
| 26 | "github.com/patriceckhart/zot/packages/provider/auth" | 26 | "github.com/patriceckhart/zot/packages/provider/auth" | ||
| 27 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 27 | "golang.org/x/term" | 28 | "golang.org/x/term" | ||
| 28 | ) | 29 | ) | ||
| 29 | 30 | ||||
| @@ -128,9 +129,13 @@ func runLocalZotfile(ref string, args Args, version string) error { | |||||
| 128 | return err | 129 | return err | ||
| 129 | } | 130 | } | ||
| 130 | perms := zf.Manifest.Permissions.Expand(args.CWD, agentData) | 131 | perms := zf.Manifest.Permissions.Expand(args.CWD, agentData) | ||
| 131 | - | if err := consentZotfile(zf, perms); err != nil { | 132 | + | allowed, err := consentZotfile(zf, perms) |
| 133 | + | if err != nil { | |||
| 132 | return err | 134 | return err | ||
| 133 | } | 135 | } | ||
| 136 | + | if !allowed { | |||
| 137 | + | return nil | |||
| 138 | + | } | |||
| 134 | agentPath := filepath.Join(zf.Dir, "AGENT.md") | 139 | agentPath := filepath.Join(zf.Dir, "AGENT.md") | ||
| 135 | agentPrompt, err := os.ReadFile(agentPath) | 140 | agentPrompt, err := os.ReadFile(agentPath) | ||
| 136 | if err != nil { | 141 | if err != nil { | ||
| @@ -750,9 +755,9 @@ func checkZotfileRequirements(zf zotfileLoaded, version string) error { | |||||
| 750 | 755 | ||||
| 751 | var execLookPath = exec.LookPath | 756 | var execLookPath = exec.LookPath | ||
| 752 | 757 | ||||
| 753 | - | func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | 758 | + | func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) { |
| 754 | if os.Getenv("ZOT_AGENT_CONSENT") == "1" { | 759 | if os.Getenv("ZOT_AGENT_CONSENT") == "1" { | ||
| 755 | - | return nil | 760 | + | return true, nil |
| 756 | } | 761 | } | ||
| 757 | // "ask" deliberately requires approval on every launch. Other consent | 762 | // "ask" deliberately requires approval on every launch. Other consent | ||
| 758 | // is durable only for this exact artifact digest, so any package change | 763 | // is durable only for this exact artifact digest, so any package change | ||
| @@ -760,42 +765,68 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | |||||
| 760 | consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json") | 765 | consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json") | ||
| 761 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" { | 766 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" { | ||
| 762 | if _, err := os.Stat(consentPath); err == nil { | 767 | if _, err := os.Stat(consentPath); err == nil { | ||
| 763 | - | return nil | 768 | + | return true, nil |
| 764 | } | 769 | } | ||
| 765 | } | 770 | } | ||
| 766 | - | fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version) | 771 | + | interactive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) |
| 767 | - | fmt.Print(permissionSummary(perms)) | 772 | + | th := tui.Dark |
| 768 | - | if !term.IsTerminal(int(os.Stdin.Fd())) { | 773 | + | if interactive { |
| 769 | - | return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow") | 774 | + | cfg, _ := LoadConfig() |
| 775 | + | th, _, _ = tui.DetectThemeWithCustom(ZotHome(), cfg.Theme, 80*time.Millisecond) | |||
| 770 | } | 776 | } | ||
| 771 | - | fmt.Print("\nAllow? [y/n] ") | 777 | + | fmt.Print(formatZotfileConsent(zf, perms, th, interactive)) |
| 772 | - | allowed, err := readZotfileConsent(os.Stdin, os.Stdout) | 778 | + | if !interactive { |
| 773 | - | if err != nil { | 779 | + | return false, fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow") |
| 774 | - | return err | |||
| 775 | } | 780 | } | ||
| 776 | - | if !allowed { | 781 | + | allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th) |
| 777 | - | return fmt.Errorf("declined") | 782 | + | if err != nil || !allowed { |
| 783 | + | return allowed, err | |||
| 778 | } | 784 | } | ||
| 779 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" { | 785 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" { | ||
| 780 | - | return nil | 786 | + | return true, nil |
| 781 | } | 787 | } | ||
| 782 | if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil { | 788 | if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil { | ||
| 783 | - | return fmt.Errorf("save agent consent: %w", err) | 789 | + | return false, fmt.Errorf("save agent consent: %w", err) |
| 784 | } | 790 | } | ||
| 785 | receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version} | 791 | receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version} | ||
| 786 | data, _ := json.MarshalIndent(receipt, "", " ") | 792 | data, _ := json.MarshalIndent(receipt, "", " ") | ||
| 787 | if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil { | 793 | if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil { | ||
| 788 | - | return fmt.Errorf("save agent consent: %w", err) | 794 | + | return false, fmt.Errorf("save agent consent: %w", err) |
| 789 | } | 795 | } | ||
| 790 | - | return nil | 796 | + | return true, nil |
| 791 | } | 797 | } | ||
| 792 | 798 | ||||
| 793 | var errZotfileConsentInterrupted = errors.New("interrupted") | 799 | var errZotfileConsentInterrupted = errors.New("interrupted") | ||
| 794 | 800 | ||||
| 795 | - | // readZotfileConsent reads an immediate single-key y/n answer without echoing | 801 | + | func formatZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string { |
| 802 | + | style := func(c int, text string) string { | |||
| 803 | + | if !color { | |||
| 804 | + | return text | |||
| 805 | + | } | |||
| 806 | + | return th.FG256(c, text) | |||
| 807 | + | } | |||
| 808 | + | ||||
| 809 | + | var sb strings.Builder | |||
| 810 | + | sb.WriteString(style(th.Assistant, "Agent")) | |||
| 811 | + | sb.WriteByte(' ') | |||
| 812 | + | sb.WriteString(style(th.Accent, zf.Manifest.Name+"@"+zf.Manifest.Version)) | |||
| 813 | + | sb.WriteString(style(th.FG, " wants to run.")) | |||
| 814 | + | sb.WriteString("\n\n") | |||
| 815 | + | sb.WriteString(permissionSummaryStyled(perms, th, color)) | |||
| 816 | + | if color { | |||
| 817 | + | sb.WriteByte('\n') | |||
| 818 | + | sb.WriteString(style(th.Assistant, "Allow?")) | |||
| 819 | + | sb.WriteByte(' ') | |||
| 820 | + | sb.WriteString(style(th.Muted, "[y/n]")) | |||
| 821 | + | sb.WriteByte(' ') | |||
| 822 | + | } | |||
| 823 | + | return sb.String() | |||
| 824 | + | } | |||
| 825 | + | ||||
| 826 | + | // readZotfileConsent accepts one y/n answer followed by Enter without echoing | |||
| 796 | // unrelated input. Raw mode also turns Ctrl+C into a byte so the terminal can | 827 | // unrelated input. Raw mode also turns Ctrl+C into a byte so the terminal can | ||
| 797 | // be restored before the command aborts. | 828 | // be restored before the command aborts. | ||
| 798 | - | func readZotfileConsent(in *os.File, out io.Writer) (bool, error) { | 829 | + | func readZotfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error) { |
| 799 | fd := int(in.Fd()) | 830 | fd := int(in.Fd()) | ||
| 800 | state, err := term.MakeRaw(fd) | 831 | state, err := term.MakeRaw(fd) | ||
| 801 | if err != nil { | 832 | if err != nil { | ||
| @@ -803,7 +834,13 @@ func readZotfileConsent(in *os.File, out io.Writer) (bool, error) { | |||||
| 803 | } | 834 | } | ||
| 804 | defer func() { _ = term.Restore(fd, state) }() | 835 | defer func() { _ = term.Restore(fd, state) }() | ||
| 805 | 836 | ||||
| 806 | - | answer, err := readZotfileConsentKey(in) | 837 | + | answer, err := readZotfileConsentKey(in, func(answer byte) { |
| 838 | + | if answer == 0 { | |||
| 839 | + | fmt.Fprint(out, "\b \b") | |||
| 840 | + | return | |||
| 841 | + | } | |||
| 842 | + | fmt.Fprint(out, th.FG256(th.FG, string(answer))) | |||
| 843 | + | }) | |||
| 807 | switch { | 844 | switch { | ||
| 808 | case errors.Is(err, errZotfileConsentInterrupted): | 845 | case errors.Is(err, errZotfileConsentInterrupted): | ||
| 809 | fmt.Fprint(out, "^C\r\n") | 846 | fmt.Fprint(out, "^C\r\n") | ||
| @@ -812,22 +849,37 @@ func readZotfileConsent(in *os.File, out io.Writer) (bool, error) { | |||||
| 812 | fmt.Fprint(out, "\r\n") | 849 | fmt.Fprint(out, "\r\n") | ||
| 813 | return false, fmt.Errorf("read agent consent: %w", err) | 850 | return false, fmt.Errorf("read agent consent: %w", err) | ||
| 814 | default: | 851 | default: | ||
| 815 | - | fmt.Fprintf(out, "%c\r\n", answer) | 852 | + | fmt.Fprint(out, "\r\n") |
| 816 | return answer == 'y', nil | 853 | return answer == 'y', nil | ||
| 817 | } | 854 | } | ||
| 818 | } | 855 | } | ||
| 819 | 856 | ||||
| 820 | - | func readZotfileConsentKey(r io.Reader) (byte, error) { | 857 | + | func readZotfileConsentKey(r io.Reader, echo func(byte)) (byte, error) { |
| 858 | + | var answer byte | |||
| 821 | var b [1]byte | 859 | var b [1]byte | ||
| 822 | for { | 860 | for { | ||
| 823 | if _, err := io.ReadFull(r, b[:]); err != nil { | 861 | if _, err := io.ReadFull(r, b[:]); err != nil { | ||
| 824 | return 0, err | 862 | return 0, err | ||
| 825 | } | 863 | } | ||
| 826 | switch b[0] { | 864 | switch b[0] { | ||
| 827 | - | case 'y', 'Y': | 865 | + | case 'y', 'Y', 'n', 'N': |
| 828 | - | return 'y', nil | 866 | + | if answer == 0 { |
| 829 | - | case 'n', 'N': | 867 | + | answer = b[0] | 0x20 |
| 830 | - | return 'n', nil | 868 | + | if echo != nil { |
| 869 | + | echo(answer) | |||
| 870 | + | } | |||
| 871 | + | } | |||
| 872 | + | case '\r', '\n': | |||
| 873 | + | if answer != 0 { | |||
| 874 | + | return answer, nil | |||
| 875 | + | } | |||
| 876 | + | case '\b', 0x7f: | |||
| 877 | + | if answer != 0 { | |||
| 878 | + | answer = 0 | |||
| 879 | + | if echo != nil { | |||
| 880 | + | echo(0) | |||
| 881 | + | } | |||
| 882 | + | } | |||
| 831 | case 3: // Ctrl+C in raw mode. | 883 | case 3: // Ctrl+C in raw mode. | ||
| 832 | return 0, errZotfileConsentInterrupted | 884 | return 0, errZotfileConsentInterrupted | ||
| 833 | } | 885 | } | ||
| @@ -835,31 +887,58 @@ func readZotfileConsentKey(r io.Reader) (byte, error) { | |||||
| 835 | } | 887 | } | ||
| 836 | 888 | ||||
| 837 | func permissionSummary(p tools.PermissionSet) string { | 889 | func permissionSummary(p tools.PermissionSet) string { | ||
| 890 | + | return permissionSummaryStyled(p, tui.Theme{}, false) | |||
| 891 | + | } | |||
| 892 | + | ||||
| 893 | + | func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string { | |||
| 894 | + | style := func(c int, text string) string { | |||
| 895 | + | if !color { | |||
| 896 | + | return text | |||
| 897 | + | } | |||
| 898 | + | return th.FG256(c, text) | |||
| 899 | + | } | |||
| 900 | + | permissionValue := func(value string) string { return style(th.FG, value) } | |||
| 901 | + | ||||
| 838 | var sb strings.Builder | 902 | var sb strings.Builder | ||
| 903 | + | sb.WriteString(style(th.Muted, " fs read: ")) | |||
| 839 | if len(p.FS.Read) > 0 { | 904 | if len(p.FS.Read) > 0 { | ||
| 840 | - | fmt.Fprintf(&sb, " fs read: %s\n", strings.Join(p.FS.Read, ", ")) | 905 | + | sb.WriteString(permissionValue(strings.Join(p.FS.Read, ", "))) |
| 841 | } else { | 906 | } else { | ||
| 842 | - | fmt.Fprintln(&sb, " fs read: none") | 907 | + | sb.WriteString(style(th.Muted, "none")) |
| 843 | } | 908 | } | ||
| 909 | + | sb.WriteByte('\n') | |||
| 910 | + | sb.WriteString(style(th.Muted, " fs write: ")) | |||
| 844 | if len(p.FS.Write) > 0 { | 911 | if len(p.FS.Write) > 0 { | ||
| 845 | - | fmt.Fprintf(&sb, " fs write: %s\n", strings.Join(p.FS.Write, ", ")) | 912 | + | sb.WriteString(permissionValue(strings.Join(p.FS.Write, ", "))) |
| 846 | } else { | 913 | } else { | ||
| 847 | - | fmt.Fprintln(&sb, " fs write: none") | 914 | + | sb.WriteString(style(th.Muted, "none")) |
| 848 | } | 915 | } | ||
| 916 | + | sb.WriteByte('\n') | |||
| 849 | mode := p.Bash.Mode | 917 | mode := p.Bash.Mode | ||
| 850 | if mode == "" { | 918 | if mode == "" { | ||
| 851 | mode = "none" | 919 | mode = "none" | ||
| 852 | } | 920 | } | ||
| 853 | - | fmt.Fprintf(&sb, " bash: %s", mode) | 921 | + | sb.WriteString(style(th.Muted, " bash: ")) |
| 922 | + | modeColor := th.FG | |||
| 923 | + | if mode == "ask" { | |||
| 924 | + | modeColor = th.Warning | |||
| 925 | + | } | |||
| 926 | + | sb.WriteString(style(modeColor, mode)) | |||
| 854 | if len(p.Bash.Allow) > 0 { | 927 | if len(p.Bash.Allow) > 0 { | ||
| 855 | - | fmt.Fprintf(&sb, " (%s)", strings.Join(p.Bash.Allow, ", ")) | 928 | + | sb.WriteString(style(th.Muted, " (")) |
| 929 | + | sb.WriteString(permissionValue(strings.Join(p.Bash.Allow, ", "))) | |||
| 930 | + | sb.WriteString(style(th.Muted, ")")) | |||
| 856 | } | 931 | } | ||
| 857 | - | fmt.Fprintln(&sb) | 932 | + | sb.WriteByte('\n') |
| 858 | if len(p.Net.Allow) > 0 { | 933 | if len(p.Net.Allow) > 0 { | ||
| 859 | - | fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", ")) | 934 | + | sb.WriteString(style(th.Muted, " net: ")) |
| 935 | + | sb.WriteString(permissionValue(strings.Join(p.Net.Allow, ", "))) | |||
| 936 | + | sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n")) | |||
| 860 | } | 937 | } | ||
| 861 | if len(p.Env.Read) > 0 { | 938 | if len(p.Env.Read) > 0 { | ||
| 862 | - | fmt.Fprintf(&sb, " env read: %s (declared, not enforced in this build)\n", strings.Join(p.Env.Read, ", ")) | 939 | + | sb.WriteString(style(th.Muted, " env read: ")) |
| 940 | + | sb.WriteString(permissionValue(strings.Join(p.Env.Read, ", "))) | |||
| 941 | + | sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n")) | |||
| 863 | } | 942 | } | ||
| 864 | return sb.String() | 943 | return sb.String() | ||
| 865 | } | 944 | } | ||
M packages/agent/zotfile_test.go +60 -8
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "testing" | |
| 16 | 16 | ||
| 17 | 17 | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 18 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | func TestReadZotfileConsentKeyAcceptsOnlyYesNo(t *testing.T) { | |
| @@ -23,33 +24,59 @@ func TestReadZotfileConsentKeyAcceptsOnlyYesNo(t *testing.T) { | |||
| 23 | 24 | input []byte | |
| 24 | 25 | want byte | |
| 25 | 26 | }{ | |
| 26 | - | {name: "lowercase yes", input: []byte{'y'}, want: 'y'}, | |
| 27 | - | {name: "uppercase yes", input: []byte{'Y'}, want: 'y'}, | |
| 28 | - | {name: "lowercase no", input: []byte{'n'}, want: 'n'}, | |
| 29 | - | {name: "uppercase no", input: []byte{'N'}, want: 'n'}, | |
| 30 | - | {name: "ignore unrelated keys", input: append([]byte("x\r\n\x1b[A"), 'y'), want: 'y'}, | |
| 27 | + | {name: "lowercase yes", input: []byte{'y', '\r'}, want: 'y'}, | |
| 28 | + | {name: "uppercase yes", input: []byte{'Y', '\n'}, want: 'y'}, | |
| 29 | + | {name: "lowercase no", input: []byte{'n', '\r'}, want: 'n'}, | |
| 30 | + | {name: "uppercase no", input: []byte{'N', '\n'}, want: 'n'}, | |
| 31 | + | {name: "ignore unrelated keys", input: append([]byte("x\r\n\x1b[A"), 'y', '\r'), want: 'y'}, | |
| 32 | + | {name: "keep first valid answer", input: []byte{'n', 'y', '\r'}, want: 'n'}, | |
| 33 | + | {name: "backspace changes answer", input: []byte{'y', 0x7f, 'n', '\r'}, want: 'n'}, | |
| 34 | + | {name: "ctrl h changes answer", input: []byte{'n', '\b', 'y', '\r'}, want: 'y'}, | |
| 35 | + | {name: "backspace without answer is ignored", input: []byte{0x7f, 'y', '\r'}, want: 'y'}, | |
| 31 | 36 | } | |
| 32 | 37 | for _, tt := range tests { | |
| 33 | 38 | t.Run(tt.name, func(t *testing.T) { | |
| 34 | - | got, err := readZotfileConsentKey(bytes.NewReader(tt.input)) | |
| 39 | + | var out bytes.Buffer | |
| 40 | + | got, err := readZotfileConsentKey(bytes.NewReader(tt.input), func(answer byte) { | |
| 41 | + | if answer == 0 { | |
| 42 | + | out.Truncate(out.Len() - 1) | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | out.WriteByte(answer) | |
| 46 | + | }) | |
| 35 | 47 | if err != nil { | |
| 36 | 48 | t.Fatal(err) | |
| 37 | 49 | } | |
| 38 | 50 | if got != tt.want { | |
| 39 | 51 | t.Fatalf("answer = %q, want %q", got, tt.want) | |
| 40 | 52 | } | |
| 53 | + | if out.String() != string(tt.want) { | |
| 54 | + | t.Fatalf("echo = %q, want %q", out.String(), tt.want) | |
| 55 | + | } | |
| 41 | 56 | }) | |
| 42 | 57 | } | |
| 43 | 58 | } | |
| 44 | 59 | ||
| 60 | + | func TestReadZotfileConsentKeyWaitsForEnter(t *testing.T) { | |
| 61 | + | var out bytes.Buffer | |
| 62 | + | if _, err := readZotfileConsentKey(strings.NewReader("y"), func(answer byte) { | |
| 63 | + | out.WriteByte(answer) | |
| 64 | + | }); !errors.Is(err, io.EOF) { | |
| 65 | + | t.Fatalf("error = %v, want EOF before Enter", err) | |
| 66 | + | } | |
| 67 | + | if out.String() != "y" { | |
| 68 | + | t.Fatalf("echo = %q, want y", out.String()) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 45 | 72 | func TestReadZotfileConsentKeyInterruptsOnCtrlC(t *testing.T) { | |
| 46 | - | if _, err := readZotfileConsentKey(bytes.NewReader([]byte{'x', 3, 'y'})); !errors.Is(err, errZotfileConsentInterrupted) { | |
| 73 | + | if _, err := readZotfileConsentKey(bytes.NewReader([]byte{'x', 3, 'y'}), nil); !errors.Is(err, errZotfileConsentInterrupted) { | |
| 47 | 74 | t.Fatalf("error = %v, want interrupted", err) | |
| 48 | 75 | } | |
| 49 | 76 | } | |
| 50 | 77 | ||
| 51 | 78 | func TestReadZotfileConsentKeyReportsInputError(t *testing.T) { | |
| 52 | - | if _, err := readZotfileConsentKey(strings.NewReader("xxx")); !errors.Is(err, io.EOF) { | |
| 79 | + | if _, err := readZotfileConsentKey(strings.NewReader("xxx"), nil); !errors.Is(err, io.EOF) { | |
| 53 | 80 | t.Fatalf("error = %v, want EOF", err) | |
| 54 | 81 | } | |
| 55 | 82 | } | |
| @@ -249,6 +276,31 @@ func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) { | |||
| 249 | 276 | } | |
| 250 | 277 | } | |
| 251 | 278 | ||
| 279 | + | func TestFormatZotfileConsentUsesThemeColors(t *testing.T) { | |
| 280 | + | th := tui.Theme{FG: 1, Muted: 2, Accent: 3, Assistant: 4, Warning: 5} | |
| 281 | + | zf := zotfileLoaded{} | |
| 282 | + | zf.Manifest.Name = "reviewer" | |
| 283 | + | zf.Manifest.Version = "1.2.3" | |
| 284 | + | perms := tools.PermissionSet{} | |
| 285 | + | perms.FS.Read = []string{"/workspace"} | |
| 286 | + | perms.Bash.Mode = "ask" | |
| 287 | + | ||
| 288 | + | got := formatZotfileConsent(zf, perms, th, true) | |
| 289 | + | for _, want := range []string{ | |
| 290 | + | th.FG256(th.Assistant, "Agent"), | |
| 291 | + | th.FG256(th.Accent, "reviewer@1.2.3"), | |
| 292 | + | th.FG256(th.Muted, " fs read: "), | |
| 293 | + | th.FG256(th.FG, "/workspace"), | |
| 294 | + | th.FG256(th.Warning, "ask"), | |
| 295 | + | th.FG256(th.Assistant, "Allow?"), | |
| 296 | + | th.FG256(th.Muted, "[y/n]"), | |
| 297 | + | } { | |
| 298 | + | if !strings.Contains(got, want) { | |
| 299 | + | t.Fatalf("themed consent missing %q:\n%s", want, got) | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 252 | 304 | func TestPermissionSummaryShowsDeniedScopes(t *testing.T) { | |
| 253 | 305 | got := permissionSummary(tools.PermissionSet{}) | |
| 254 | 306 | if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") { | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "testing" | 15 | "testing" | ||
| 16 | 16 | ||||
| 17 | "github.com/patriceckhart/zot/packages/agent/tools" | 17 | "github.com/patriceckhart/zot/packages/agent/tools" | ||
| 18 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | func TestReadZotfileConsentKeyAcceptsOnlyYesNo(t *testing.T) { | 21 | func TestReadZotfileConsentKeyAcceptsOnlyYesNo(t *testing.T) { | ||
| @@ -23,33 +24,59 @@ func TestReadZotfileConsentKeyAcceptsOnlyYesNo(t *testing.T) { | |||||
| 23 | input []byte | 24 | input []byte | ||
| 24 | want byte | 25 | want byte | ||
| 25 | }{ | 26 | }{ | ||
| 26 | - | {name: "lowercase yes", input: []byte{'y'}, want: 'y'}, | 27 | + | {name: "lowercase yes", input: []byte{'y', '\r'}, want: 'y'}, |
| 27 | - | {name: "uppercase yes", input: []byte{'Y'}, want: 'y'}, | 28 | + | {name: "uppercase yes", input: []byte{'Y', '\n'}, want: 'y'}, |
| 28 | - | {name: "lowercase no", input: []byte{'n'}, want: 'n'}, | 29 | + | {name: "lowercase no", input: []byte{'n', '\r'}, want: 'n'}, |
| 29 | - | {name: "uppercase no", input: []byte{'N'}, want: 'n'}, | 30 | + | {name: "uppercase no", input: []byte{'N', '\n'}, want: 'n'}, |
| 30 | - | {name: "ignore unrelated keys", input: append([]byte("x\r\n\x1b[A"), 'y'), want: 'y'}, | 31 | + | {name: "ignore unrelated keys", input: append([]byte("x\r\n\x1b[A"), 'y', '\r'), want: 'y'}, |
| 32 | + | {name: "keep first valid answer", input: []byte{'n', 'y', '\r'}, want: 'n'}, | |||
| 33 | + | {name: "backspace changes answer", input: []byte{'y', 0x7f, 'n', '\r'}, want: 'n'}, | |||
| 34 | + | {name: "ctrl h changes answer", input: []byte{'n', '\b', 'y', '\r'}, want: 'y'}, | |||
| 35 | + | {name: "backspace without answer is ignored", input: []byte{0x7f, 'y', '\r'}, want: 'y'}, | |||
| 31 | } | 36 | } | ||
| 32 | for _, tt := range tests { | 37 | for _, tt := range tests { | ||
| 33 | t.Run(tt.name, func(t *testing.T) { | 38 | t.Run(tt.name, func(t *testing.T) { | ||
| 34 | - | got, err := readZotfileConsentKey(bytes.NewReader(tt.input)) | 39 | + | var out bytes.Buffer |
| 40 | + | got, err := readZotfileConsentKey(bytes.NewReader(tt.input), func(answer byte) { | |||
| 41 | + | if answer == 0 { | |||
| 42 | + | out.Truncate(out.Len() - 1) | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | out.WriteByte(answer) | |||
| 46 | + | }) | |||
| 35 | if err != nil { | 47 | if err != nil { | ||
| 36 | t.Fatal(err) | 48 | t.Fatal(err) | ||
| 37 | } | 49 | } | ||
| 38 | if got != tt.want { | 50 | if got != tt.want { | ||
| 39 | t.Fatalf("answer = %q, want %q", got, tt.want) | 51 | t.Fatalf("answer = %q, want %q", got, tt.want) | ||
| 40 | } | 52 | } | ||
| 53 | + | if out.String() != string(tt.want) { | |||
| 54 | + | t.Fatalf("echo = %q, want %q", out.String(), tt.want) | |||
| 55 | + | } | |||
| 41 | }) | 56 | }) | ||
| 42 | } | 57 | } | ||
| 43 | } | 58 | } | ||
| 44 | 59 | ||||
| 60 | + | func TestReadZotfileConsentKeyWaitsForEnter(t *testing.T) { | |||
| 61 | + | var out bytes.Buffer | |||
| 62 | + | if _, err := readZotfileConsentKey(strings.NewReader("y"), func(answer byte) { | |||
| 63 | + | out.WriteByte(answer) | |||
| 64 | + | }); !errors.Is(err, io.EOF) { | |||
| 65 | + | t.Fatalf("error = %v, want EOF before Enter", err) | |||
| 66 | + | } | |||
| 67 | + | if out.String() != "y" { | |||
| 68 | + | t.Fatalf("echo = %q, want y", out.String()) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 45 | func TestReadZotfileConsentKeyInterruptsOnCtrlC(t *testing.T) { | 72 | func TestReadZotfileConsentKeyInterruptsOnCtrlC(t *testing.T) { | ||
| 46 | - | if _, err := readZotfileConsentKey(bytes.NewReader([]byte{'x', 3, 'y'})); !errors.Is(err, errZotfileConsentInterrupted) { | 73 | + | if _, err := readZotfileConsentKey(bytes.NewReader([]byte{'x', 3, 'y'}), nil); !errors.Is(err, errZotfileConsentInterrupted) { |
| 47 | t.Fatalf("error = %v, want interrupted", err) | 74 | t.Fatalf("error = %v, want interrupted", err) | ||
| 48 | } | 75 | } | ||
| 49 | } | 76 | } | ||
| 50 | 77 | ||||
| 51 | func TestReadZotfileConsentKeyReportsInputError(t *testing.T) { | 78 | func TestReadZotfileConsentKeyReportsInputError(t *testing.T) { | ||
| 52 | - | if _, err := readZotfileConsentKey(strings.NewReader("xxx")); !errors.Is(err, io.EOF) { | 79 | + | if _, err := readZotfileConsentKey(strings.NewReader("xxx"), nil); !errors.Is(err, io.EOF) { |
| 53 | t.Fatalf("error = %v, want EOF", err) | 80 | t.Fatalf("error = %v, want EOF", err) | ||
| 54 | } | 81 | } | ||
| 55 | } | 82 | } | ||
| @@ -249,6 +276,31 @@ func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) { | |||||
| 249 | } | 276 | } | ||
| 250 | } | 277 | } | ||
| 251 | 278 | ||||
| 279 | + | func TestFormatZotfileConsentUsesThemeColors(t *testing.T) { | |||
| 280 | + | th := tui.Theme{FG: 1, Muted: 2, Accent: 3, Assistant: 4, Warning: 5} | |||
| 281 | + | zf := zotfileLoaded{} | |||
| 282 | + | zf.Manifest.Name = "reviewer" | |||
| 283 | + | zf.Manifest.Version = "1.2.3" | |||
| 284 | + | perms := tools.PermissionSet{} | |||
| 285 | + | perms.FS.Read = []string{"/workspace"} | |||
| 286 | + | perms.Bash.Mode = "ask" | |||
| 287 | + | ||||
| 288 | + | got := formatZotfileConsent(zf, perms, th, true) | |||
| 289 | + | for _, want := range []string{ | |||
| 290 | + | th.FG256(th.Assistant, "Agent"), | |||
| 291 | + | th.FG256(th.Accent, "reviewer@1.2.3"), | |||
| 292 | + | th.FG256(th.Muted, " fs read: "), | |||
| 293 | + | th.FG256(th.FG, "/workspace"), | |||
| 294 | + | th.FG256(th.Warning, "ask"), | |||
| 295 | + | th.FG256(th.Assistant, "Allow?"), | |||
| 296 | + | th.FG256(th.Muted, "[y/n]"), | |||
| 297 | + | } { | |||
| 298 | + | if !strings.Contains(got, want) { | |||
| 299 | + | t.Fatalf("themed consent missing %q:\n%s", want, got) | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | ||||
| 252 | func TestPermissionSummaryShowsDeniedScopes(t *testing.T) { | 304 | func TestPermissionSummaryShowsDeniedScopes(t *testing.T) { | ||
| 253 | got := permissionSummary(tools.PermissionSet{}) | 305 | got := permissionSummary(tools.PermissionSet{}) | ||
| 254 | if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") { | 306 | if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") { | ||