detached 159a2d3

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 (
2424 "github.com/patriceckhart/zot/packages/agent/tools"
2525 "github.com/patriceckhart/zot/packages/provider"
2626 "github.com/patriceckhart/zot/packages/provider/auth"
27+ "github.com/patriceckhart/zot/packages/tui"
2728 "golang.org/x/term"
2829 )
2930
@@ -128,9 +129,13 @@ func runLocalZotfile(ref string, args Args, version string) error {
128129 return err
129130 }
130131 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 {
132134 return err
133135 }
136+ if !allowed {
137+ return nil
138+ }
134139 agentPath := filepath.Join(zf.Dir, "AGENT.md")
135140 agentPrompt, err := os.ReadFile(agentPath)
136141 if err != nil {
@@ -750,9 +755,9 @@ func checkZotfileRequirements(zf zotfileLoaded, version string) error {
750755
751756 var execLookPath = exec.LookPath
752757
753-func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
758+func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
754759 if os.Getenv("ZOT_AGENT_CONSENT") == "1" {
755- return nil
760+ return true, nil
756761 }
757762 // "ask" deliberately requires approval on every launch. Other consent
758763 // is durable only for this exact artifact digest, so any package change
@@ -760,42 +765,68 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
760765 consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
761766 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" {
762767 if _, err := os.Stat(consentPath); err == nil {
763- return nil
768+ return true, nil
764769 }
765770 }
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)
770776 }
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")
775780 }
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
778784 }
779785 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
780- return nil
786+ return true, nil
781787 }
782788 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)
784790 }
785791 receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version}
786792 data, _ := json.MarshalIndent(receipt, "", " ")
787793 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)
789795 }
790- return nil
796+ return true, nil
791797 }
792798
793799 var errZotfileConsentInterrupted = errors.New("interrupted")
794800
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
796827 // unrelated input. Raw mode also turns Ctrl+C into a byte so the terminal can
797828 // 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) {
799830 fd := int(in.Fd())
800831 state, err := term.MakeRaw(fd)
801832 if err != nil {
@@ -803,7 +834,13 @@ func readZotfileConsent(in *os.File, out io.Writer) (bool, error) {
803834 }
804835 defer func() { _ = term.Restore(fd, state) }()
805836
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+ })
807844 switch {
808845 case errors.Is(err, errZotfileConsentInterrupted):
809846 fmt.Fprint(out, "^C\r\n")
@@ -812,22 +849,37 @@ func readZotfileConsent(in *os.File, out io.Writer) (bool, error) {
812849 fmt.Fprint(out, "\r\n")
813850 return false, fmt.Errorf("read agent consent: %w", err)
814851 default:
815- fmt.Fprintf(out, "%c\r\n", answer)
852+ fmt.Fprint(out, "\r\n")
816853 return answer == 'y', nil
817854 }
818855 }
819856
820-func readZotfileConsentKey(r io.Reader) (byte, error) {
857+func readZotfileConsentKey(r io.Reader, echo func(byte)) (byte, error) {
858+ var answer byte
821859 var b [1]byte
822860 for {
823861 if _, err := io.ReadFull(r, b[:]); err != nil {
824862 return 0, err
825863 }
826864 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+ }
831883 case 3: // Ctrl+C in raw mode.
832884 return 0, errZotfileConsentInterrupted
833885 }
@@ -835,31 +887,58 @@ func readZotfileConsentKey(r io.Reader) (byte, error) {
835887 }
836888
837889 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+
838902 var sb strings.Builder
903+ sb.WriteString(style(th.Muted, " fs read: "))
839904 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, ", ")))
841906 } else {
842- fmt.Fprintln(&sb, " fs read: none")
907+ sb.WriteString(style(th.Muted, "none"))
843908 }
909+ sb.WriteByte('\n')
910+ sb.WriteString(style(th.Muted, " fs write: "))
844911 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, ", ")))
846913 } else {
847- fmt.Fprintln(&sb, " fs write: none")
914+ sb.WriteString(style(th.Muted, "none"))
848915 }
916+ sb.WriteByte('\n')
849917 mode := p.Bash.Mode
850918 if mode == "" {
851919 mode = "none"
852920 }
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))
854927 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, ")"))
856931 }
857- fmt.Fprintln(&sb)
932+ sb.WriteByte('\n')
858933 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"))
860937 }
861938 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"))
863942 }
864943 return sb.String()
865944 }
@@ -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 err129 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 err134 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.LookPath756 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 nil760+ return true, nil
756 }761 }
757 // "ask" deliberately requires approval on every launch. Other consent762 // "ask" deliberately requires approval on every launch. Other consent
758 // is durable only for this exact artifact digest, so any package change763 // 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 nil768+ 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 nil786+ 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 nil796+ 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 echoing801+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 can827 // 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', nil853 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]byte859 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, err862 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', nil866+ if answer == 0 {
829- case 'n', 'N':867+ answer = b[0] | 0x20
830- return 'n', nil868+ 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, errZotfileConsentInterrupted884 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.Builder902 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.Mode917 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 (
1515 "testing"
1616
1717 "github.com/patriceckhart/zot/packages/agent/tools"
18+ "github.com/patriceckhart/zot/packages/tui"
1819 )
1920
2021 func TestReadZotfileConsentKeyAcceptsOnlyYesNo(t *testing.T) {
@@ -23,33 +24,59 @@ func TestReadZotfileConsentKeyAcceptsOnlyYesNo(t *testing.T) {
2324 input []byte
2425 want byte
2526 }{
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'},
3136 }
3237 for _, tt := range tests {
3338 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+ })
3547 if err != nil {
3648 t.Fatal(err)
3749 }
3850 if got != tt.want {
3951 t.Fatalf("answer = %q, want %q", got, tt.want)
4052 }
53+ if out.String() != string(tt.want) {
54+ t.Fatalf("echo = %q, want %q", out.String(), tt.want)
55+ }
4156 })
4257 }
4358 }
4459
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+
4572 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) {
4774 t.Fatalf("error = %v, want interrupted", err)
4875 }
4976 }
5077
5178 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) {
5380 t.Fatalf("error = %v, want EOF", err)
5481 }
5582 }
@@ -249,6 +276,31 @@ func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) {
249276 }
250277 }
251278
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+
252304 func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
253305 got := permissionSummary(tools.PermissionSet{})
254306 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 []byte24 input []byte
24 want byte25 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") {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close