detached d109cbf

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 (
77 "crypto/sha256"
88 "encoding/hex"
99 "encoding/json"
10+ "errors"
1011 "fmt"
1112 "io"
1213 "net/http"
@@ -767,11 +768,12 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
767768 if !term.IsTerminal(int(os.Stdin.Fd())) {
768769 return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow")
769770 }
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 {
775777 return fmt.Errorf("declined")
776778 }
777779 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
@@ -788,6 +790,50 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
788790 return nil
789791 }
790792
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+
791837 func permissionSummary(p tools.PermissionSet) string {
792838 var sb strings.Builder
793839 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 string772+ 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 nil790 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.Builder838 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 (
44 "archive/tar"
55 "bytes"
66 "compress/gzip"
7+ "errors"
8+ "io"
79 "net/http"
810 "net/http/httptest"
911 "net/url"
@@ -15,6 +17,43 @@ import (
1517 "github.com/patriceckhart/zot/packages/agent/tools"
1618 )
1719
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+
1857 func writeTestZotfile(t *testing.T, manifest string) string {
1958 t.Helper()
2059 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()

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