Restrict Zotfile consent input
- commit
- d109cbf58437534f90153cbc065b42eff3871ac5
- author
- patriceckhart · · 2026-07-29 15:21 UTC
- parent
- 10bb5d0
- signature
- unsigned
2 files · +90 -5
M packages/agent/zotfile.go +51 -5
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "crypto/sha256" | |
| 8 | 8 | "encoding/hex" | |
| 9 | 9 | "encoding/json" | |
| 10 | + | "errors" | |
| 10 | 11 | "fmt" | |
| 11 | 12 | "io" | |
| 12 | 13 | "net/http" | |
| @@ -767,11 +768,12 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | |||
| 767 | 768 | if !term.IsTerminal(int(os.Stdin.Fd())) { | |
| 768 | 769 | return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow") | |
| 769 | 770 | } | |
| 770 | - | fmt.Print("\nAllow? [y/N] ") | |
| 771 | - | var answer string | |
| 772 | - | _, _ = fmt.Fscanln(os.Stdin, &answer) | |
| 773 | - | answer = strings.ToLower(strings.TrimSpace(answer)) | |
| 774 | - | if answer != "y" && answer != "yes" { | |
| 771 | + | fmt.Print("\nAllow? [y/n] ") | |
| 772 | + | allowed, err := readZotfileConsent(os.Stdin, os.Stdout) | |
| 773 | + | if err != nil { | |
| 774 | + | return err | |
| 775 | + | } | |
| 776 | + | if !allowed { | |
| 775 | 777 | return fmt.Errorf("declined") | |
| 776 | 778 | } | |
| 777 | 779 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" { | |
| @@ -788,6 +790,50 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | |||
| 788 | 790 | return nil | |
| 789 | 791 | } | |
| 790 | 792 | ||
| 793 | + | var errZotfileConsentInterrupted = errors.New("interrupted") | |
| 794 | + | ||
| 795 | + | // readZotfileConsent reads an immediate single-key y/n answer without echoing | |
| 796 | + | // unrelated input. Raw mode also turns Ctrl+C into a byte so the terminal can | |
| 797 | + | // be restored before the command aborts. | |
| 798 | + | func readZotfileConsent(in *os.File, out io.Writer) (bool, error) { | |
| 799 | + | fd := int(in.Fd()) | |
| 800 | + | state, err := term.MakeRaw(fd) | |
| 801 | + | if err != nil { | |
| 802 | + | return false, fmt.Errorf("read agent consent: %w", err) | |
| 803 | + | } | |
| 804 | + | defer func() { _ = term.Restore(fd, state) }() | |
| 805 | + | ||
| 806 | + | answer, err := readZotfileConsentKey(in) | |
| 807 | + | switch { | |
| 808 | + | case errors.Is(err, errZotfileConsentInterrupted): | |
| 809 | + | fmt.Fprint(out, "^C\r\n") | |
| 810 | + | return false, err | |
| 811 | + | case err != nil: | |
| 812 | + | fmt.Fprint(out, "\r\n") | |
| 813 | + | return false, fmt.Errorf("read agent consent: %w", err) | |
| 814 | + | default: | |
| 815 | + | fmt.Fprintf(out, "%c\r\n", answer) | |
| 816 | + | return answer == 'y', nil | |
| 817 | + | } | |
| 818 | + | } | |
| 819 | + | ||
| 820 | + | func readZotfileConsentKey(r io.Reader) (byte, error) { | |
| 821 | + | var b [1]byte | |
| 822 | + | for { | |
| 823 | + | if _, err := io.ReadFull(r, b[:]); err != nil { | |
| 824 | + | return 0, err | |
| 825 | + | } | |
| 826 | + | switch b[0] { | |
| 827 | + | case 'y', 'Y': | |
| 828 | + | return 'y', nil | |
| 829 | + | case 'n', 'N': | |
| 830 | + | return 'n', nil | |
| 831 | + | case 3: // Ctrl+C in raw mode. | |
| 832 | + | return 0, errZotfileConsentInterrupted | |
| 833 | + | } | |
| 834 | + | } | |
| 835 | + | } | |
| 836 | + | ||
| 791 | 837 | func permissionSummary(p tools.PermissionSet) string { | |
| 792 | 838 | var sb strings.Builder | |
| 793 | 839 | if len(p.FS.Read) > 0 { | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "crypto/sha256" | 7 | "crypto/sha256" | ||
| 8 | "encoding/hex" | 8 | "encoding/hex" | ||
| 9 | "encoding/json" | 9 | "encoding/json" | ||
| 10 | + | "errors" | |||
| 10 | "fmt" | 11 | "fmt" | ||
| 11 | "io" | 12 | "io" | ||
| 12 | "net/http" | 13 | "net/http" | ||
| @@ -767,11 +768,12 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | |||||
| 767 | if !term.IsTerminal(int(os.Stdin.Fd())) { | 768 | if !term.IsTerminal(int(os.Stdin.Fd())) { | ||
| 768 | return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow") | 769 | return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow") | ||
| 769 | } | 770 | } | ||
| 770 | - | fmt.Print("\nAllow? [y/N] ") | 771 | + | fmt.Print("\nAllow? [y/n] ") |
| 771 | - | var answer string | 772 | + | allowed, err := readZotfileConsent(os.Stdin, os.Stdout) |
| 772 | - | _, _ = fmt.Fscanln(os.Stdin, &answer) | 773 | + | if err != nil { |
| 773 | - | answer = strings.ToLower(strings.TrimSpace(answer)) | 774 | + | return err |
| 774 | - | if answer != "y" && answer != "yes" { | 775 | + | } |
| 776 | + | if !allowed { | |||
| 775 | return fmt.Errorf("declined") | 777 | return fmt.Errorf("declined") | ||
| 776 | } | 778 | } | ||
| 777 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" { | 779 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" { | ||
| @@ -788,6 +790,50 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | |||||
| 788 | return nil | 790 | return nil | ||
| 789 | } | 791 | } | ||
| 790 | 792 | ||||
| 793 | + | var errZotfileConsentInterrupted = errors.New("interrupted") | |||
| 794 | + | ||||
| 795 | + | // readZotfileConsent reads an immediate single-key y/n answer without echoing | |||
| 796 | + | // unrelated input. Raw mode also turns Ctrl+C into a byte so the terminal can | |||
| 797 | + | // be restored before the command aborts. | |||
| 798 | + | func readZotfileConsent(in *os.File, out io.Writer) (bool, error) { | |||
| 799 | + | fd := int(in.Fd()) | |||
| 800 | + | state, err := term.MakeRaw(fd) | |||
| 801 | + | if err != nil { | |||
| 802 | + | return false, fmt.Errorf("read agent consent: %w", err) | |||
| 803 | + | } | |||
| 804 | + | defer func() { _ = term.Restore(fd, state) }() | |||
| 805 | + | ||||
| 806 | + | answer, err := readZotfileConsentKey(in) | |||
| 807 | + | switch { | |||
| 808 | + | case errors.Is(err, errZotfileConsentInterrupted): | |||
| 809 | + | fmt.Fprint(out, "^C\r\n") | |||
| 810 | + | return false, err | |||
| 811 | + | case err != nil: | |||
| 812 | + | fmt.Fprint(out, "\r\n") | |||
| 813 | + | return false, fmt.Errorf("read agent consent: %w", err) | |||
| 814 | + | default: | |||
| 815 | + | fmt.Fprintf(out, "%c\r\n", answer) | |||
| 816 | + | return answer == 'y', nil | |||
| 817 | + | } | |||
| 818 | + | } | |||
| 819 | + | ||||
| 820 | + | func readZotfileConsentKey(r io.Reader) (byte, error) { | |||
| 821 | + | var b [1]byte | |||
| 822 | + | for { | |||
| 823 | + | if _, err := io.ReadFull(r, b[:]); err != nil { | |||
| 824 | + | return 0, err | |||
| 825 | + | } | |||
| 826 | + | switch b[0] { | |||
| 827 | + | case 'y', 'Y': | |||
| 828 | + | return 'y', nil | |||
| 829 | + | case 'n', 'N': | |||
| 830 | + | return 'n', nil | |||
| 831 | + | case 3: // Ctrl+C in raw mode. | |||
| 832 | + | return 0, errZotfileConsentInterrupted | |||
| 833 | + | } | |||
| 834 | + | } | |||
| 835 | + | } | |||
| 836 | + | ||||
| 791 | func permissionSummary(p tools.PermissionSet) string { | 837 | func permissionSummary(p tools.PermissionSet) string { | ||
| 792 | var sb strings.Builder | 838 | var sb strings.Builder | ||
| 793 | if len(p.FS.Read) > 0 { | 839 | if len(p.FS.Read) > 0 { | ||
M packages/agent/zotfile_test.go +39 -0
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "archive/tar" | |
| 5 | 5 | "bytes" | |
| 6 | 6 | "compress/gzip" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 7 | 9 | "net/http" | |
| 8 | 10 | "net/http/httptest" | |
| 9 | 11 | "net/url" | |
| @@ -15,6 +17,43 @@ import ( | |||
| 15 | 17 | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| 20 | + | func TestReadZotfileConsentKeyAcceptsOnlyYesNo(t *testing.T) { | |
| 21 | + | tests := []struct { | |
| 22 | + | name string | |
| 23 | + | input []byte | |
| 24 | + | want byte | |
| 25 | + | }{ | |
| 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'}, | |
| 31 | + | } | |
| 32 | + | for _, tt := range tests { | |
| 33 | + | t.Run(tt.name, func(t *testing.T) { | |
| 34 | + | got, err := readZotfileConsentKey(bytes.NewReader(tt.input)) | |
| 35 | + | if err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | if got != tt.want { | |
| 39 | + | t.Fatalf("answer = %q, want %q", got, tt.want) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestReadZotfileConsentKeyInterruptsOnCtrlC(t *testing.T) { | |
| 46 | + | if _, err := readZotfileConsentKey(bytes.NewReader([]byte{'x', 3, 'y'})); !errors.Is(err, errZotfileConsentInterrupted) { | |
| 47 | + | t.Fatalf("error = %v, want interrupted", err) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestReadZotfileConsentKeyReportsInputError(t *testing.T) { | |
| 52 | + | if _, err := readZotfileConsentKey(strings.NewReader("xxx")); !errors.Is(err, io.EOF) { | |
| 53 | + | t.Fatalf("error = %v, want EOF", err) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 18 | 57 | func writeTestZotfile(t *testing.T, manifest string) string { | |
| 19 | 58 | t.Helper() | |
| 20 | 59 | dir := t.TempDir() | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "archive/tar" | 4 | "archive/tar" | ||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | "compress/gzip" | 6 | "compress/gzip" | ||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 7 | "net/http" | 9 | "net/http" | ||
| 8 | "net/http/httptest" | 10 | "net/http/httptest" | ||
| 9 | "net/url" | 11 | "net/url" | ||
| @@ -15,6 +17,43 @@ import ( | |||||
| 15 | "github.com/patriceckhart/zot/packages/agent/tools" | 17 | "github.com/patriceckhart/zot/packages/agent/tools" | ||
| 16 | ) | 18 | ) | ||
| 17 | 19 | ||||
| 20 | + | func TestReadZotfileConsentKeyAcceptsOnlyYesNo(t *testing.T) { | |||
| 21 | + | tests := []struct { | |||
| 22 | + | name string | |||
| 23 | + | input []byte | |||
| 24 | + | want byte | |||
| 25 | + | }{ | |||
| 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'}, | |||
| 31 | + | } | |||
| 32 | + | for _, tt := range tests { | |||
| 33 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 34 | + | got, err := readZotfileConsentKey(bytes.NewReader(tt.input)) | |||
| 35 | + | if err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | if got != tt.want { | |||
| 39 | + | t.Fatalf("answer = %q, want %q", got, tt.want) | |||
| 40 | + | } | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestReadZotfileConsentKeyInterruptsOnCtrlC(t *testing.T) { | |||
| 46 | + | if _, err := readZotfileConsentKey(bytes.NewReader([]byte{'x', 3, 'y'})); !errors.Is(err, errZotfileConsentInterrupted) { | |||
| 47 | + | t.Fatalf("error = %v, want interrupted", err) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestReadZotfileConsentKeyReportsInputError(t *testing.T) { | |||
| 52 | + | if _, err := readZotfileConsentKey(strings.NewReader("xxx")); !errors.Is(err, io.EOF) { | |||
| 53 | + | t.Fatalf("error = %v, want EOF", err) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 18 | func writeTestZotfile(t *testing.T, manifest string) string { | 57 | func writeTestZotfile(t *testing.T, manifest string) string { | ||
| 19 | t.Helper() | 58 | t.Helper() | ||
| 20 | dir := t.TempDir() | 59 | dir := t.TempDir() | ||