From c5cf60464f5e0146f5a5368a48f739e31290a6ac Mon Sep 17 00:00:00 2001 From: Vishal T <54330666+vish727@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:32:49 -0400 Subject: [PATCH 1/3] feat: source field decides who may read a service nook's code A static nook's files reach every viewer's browser, so they are readable by anyone who can open it. A service nook's run code never reaches a browser, and should not become readable just because someone was shared the nook. source is "editors" (owner and editors) by default, or "viewers" to opt in to the static behaviour. Parse writes the default out explicitly, so a pull or an export of a service nook states who can read it rather than leaving it implied. nookd enforces this on /_nook/source, pull, and remix. --- manifest/manifest.go | 20 +++++++++++++++++++- manifest/manifest_test.go | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/manifest/manifest.go b/manifest/manifest.go index 4c7921f..f352e06 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -26,6 +26,7 @@ var ( hostRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) reserved = map[string]bool{"api": true, "www": true, "app": true, "admin": true, "static": true, "mail": true, "dashboard": true, "nook": true} types = map[string]bool{"static": true, "service": true} + sources = map[string]bool{"editors": true, "viewers": true} ) // A CronEntry runs `Run` inside the machine on `Schedule`, five-field cron in UTC. @@ -40,6 +41,9 @@ type Manifest struct { Entry string `json:"entry,omitempty"` // Service nooks only. See DESIGN.md ยง10b. + // Source is who may read the run code: "editors" (owner and editors, the default) or + // "viewers" (anyone who can open the nook, as a static nook's files always are). + Source string `json:"source,omitempty"` Run string `json:"run,omitempty"` Port int `json:"port,omitempty"` Secrets []string `json:"secrets,omitempty"` @@ -60,6 +64,10 @@ func Parse(b []byte) (*Manifest, error) { if m.Type == "static" && m.Entry == "" { m.Entry = "index.html" } + // Written out explicitly, so a pull or an export of a service nook states who can read it. + if m.Type == "service" && m.Source == "" { + m.Source = "editors" + } if err := m.Validate(); err != nil { return nil, err } @@ -85,7 +93,7 @@ func (m *Manifest) Validate() error { for _, f := range []struct { name string set bool - }{{"run", m.Run != ""}, {"port", m.Port != 0}, {"secrets", len(m.Secrets) > 0}, {"egress", len(m.Egress) > 0}, {"cron", len(m.Cron) > 0}} { + }{{"source", m.Source != ""}, {"run", m.Run != ""}, {"port", m.Port != 0}, {"secrets", len(m.Secrets) > 0}, {"egress", len(m.Egress) > 0}, {"cron", len(m.Cron) > 0}} { if f.set { return fmt.Errorf(`nook.json: %s only applies to a service nook; set "type": "service" or remove it`, f.name) } @@ -97,6 +105,9 @@ func (m *Manifest) validateService() error { if m.Entry != "" { return fmt.Errorf(`nook.json: entry is for static nooks; a service nook serves its own routes from run`) } + if m.Source != "" && !sources[m.Source] { + return fmt.Errorf(`nook.json: source %q must be "editors" (the default) or "viewers"`, m.Source) + } if err := command("run", m.Run); err != nil { return err } @@ -180,3 +191,10 @@ func command(field, s string) error { } return nil } + +// SourceVisibleToViewers says whether anyone who can open the nook may read its code. A static +// nook's files reach every viewer's browser anyway, so they always are; a service nook's do not, +// and stay with the owner and editors unless the manifest opts in. +func (m *Manifest) SourceVisibleToViewers() bool { + return !m.IsService() || m.Source == "viewers" +} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 673a8ee..1ee7004 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -21,6 +21,7 @@ func TestParseStatic(t *testing.T) { `{"name":"ok","secrets":["TOKEN"]}`, `{"name":"ok","egress":["example.com"]}`, `{"name":"ok","cron":[{"schedule":"* * * * *","run":"x"}]}`, + `{"name":"ok","source":"viewers"}`, } { if _, err := Parse([]byte(bad)); err == nil { t.Errorf("expected error for %s", bad) @@ -41,6 +42,10 @@ func TestParseService(t *testing.T) { if m.Entry != "" { t.Errorf("a service nook has no entry, got %q", m.Entry) } + // Unset means the run code stays with the owner and editors, and says so when written back. + if m.Source != "editors" || m.SourceVisibleToViewers() { + t.Errorf("source defaults closed, got %q", m.Source) + } if m.Egress[1] != "api.example.com" { t.Errorf("egress hosts are lowercased, got %q", m.Egress[1]) } @@ -68,6 +73,8 @@ func TestServiceValidation(t *testing.T) { "egress bare word": `{"name":"board","type":"service","run":"x","port":8000,"egress":["localhost"]}`, "cron schedule": `{"name":"board","type":"service","run":"x","port":8000,"cron":[{"schedule":"0 9 * *","run":"y"}]}`, "cron run blank": `{"name":"board","type":"service","run":"x","port":8000,"cron":[{"schedule":"* * * * *","run":""}]}`, + "source unknown": `{"name":"board","type":"service","run":"x","port":8000,"source":"everyone"}`, + "source public": `{"name":"board","type":"service","run":"x","port":8000,"source":"public"}`, } { if _, err := Parse([]byte(bad)); err == nil { t.Errorf("%s: expected an error for %s", name, bad) @@ -82,3 +89,18 @@ func TestServiceValidation(t *testing.T) { t.Errorf("egress is optional: %v", err) } } + +func TestSourceVisibility(t *testing.T) { + // A static nook's files reach the browser anyway, so they are always readable. + m, err := Parse([]byte(`{"name":"page"}`)) + if err != nil || !m.SourceVisibleToViewers() { + t.Errorf("static source is always visible: %+v %v", m, err) + } + if m.Source != "" { + t.Errorf("static nooks carry no source field, got %q", m.Source) + } + open, err := Parse([]byte(`{"name":"board","type":"service","run":"x","port":8000,"source":"viewers"}`)) + if err != nil || !open.SourceVisibleToViewers() { + t.Errorf(`"viewers" opts in: %+v %v`, open, err) + } +} From 80cc3cc4a2053ab4e28f757351fa179f61d01725 Mon Sep 17 00:00:00 2001 From: Vishal T <54330666+vish727@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:41:36 -0400 Subject: [PATCH 2/3] feat: nook secret set, list, and unset A value is never an argument: argv lands in shell history and is visible in ps. It comes from a pipe, or from a prompt with the terminal's echo off through stty, which keeps the CLI's dependency list empty for the sake of one prompt. Where echo cannot be turned off the CLI says so instead of quietly showing it. list shows what nook.json declares against what is set and by whom, so the gap that stops a nook starting is the thing you see. No command prints a value. --- cmd/nook/main.go | 3 + cmd/nook/secret.go | 147 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 cmd/nook/secret.go diff --git a/cmd/nook/main.go b/cmd/nook/main.go index 18c60fb..5478b62 100644 --- a/cmd/nook/main.go +++ b/cmd/nook/main.go @@ -75,6 +75,8 @@ func main() { err = importNook(args, asJSON) case "data": err = data(args, asJSON) + case "secret", "secrets": + err = secret(args, asJSON) case "delete": err = deleteNook(args, asJSON) case "mcp": @@ -122,6 +124,7 @@ more: transfer [nook] versions [nook] rollback [nook] [version] export [nook] [-o file] import [--name n] [--share] data list|get|create|update|delete|export|import ... + secret set|list|unset [nook] KEY values come from stdin or a prompt, never argv delete [nook] mcp (run the MCP server on stdio) account whoami|plan|upgrade|logout|stats diff --git a/cmd/nook/secret.go b/cmd/nook/secret.go new file mode 100644 index 0000000..6aa7378 --- /dev/null +++ b/cmd/nook/secret.go @@ -0,0 +1,147 @@ +package main + +import ( + "bufio" + "fmt" + "net/http" + "os" + "os/exec" + "strings" +) + +// A secret's value is never an argument: argv shows up in shell history and in ps. It comes from +// a pipe, or from a prompt with the terminal's echo off. It is never printed back, by any command. + +func secret(args []string, asJSON bool) error { + if len(args) == 0 { + return fmt.Errorf("usage: nook secret set|list|unset [nook] [KEY]") + } + sub, rest := args[0], args[1:] + name, rest, err := nookName(rest, isSecretName) + if err != nil { + return err + } + switch sub { + case "list", "ls": + out, err := call(http.MethodGet, "/v1/nooks/"+name+"/secrets", nil, "") + if err != nil { + return err + } + return emit(asJSON, out, secretLines(out)) + case "set": + if len(rest) == 0 { + return fmt.Errorf("usage: nook secret set [nook] KEY (the value comes from stdin or a prompt)") + } + value, err := readSecretValue(rest[0]) + if err != nil { + return err + } + out, err := callJSON(http.MethodPut, "/v1/nooks/"+name+"/secrets/"+rest[0], map[string]string{"value": value}) + if err != nil { + return err + } + return emit(asJSON, out, fmt.Sprintf("%s is set for %s. its value is never shown again.\n%s", rest[0], name, secretLines(out))) + case "unset", "rm": + if len(rest) == 0 { + return fmt.Errorf("usage: nook secret unset [nook] KEY") + } + out, err := call(http.MethodDelete, "/v1/nooks/"+name+"/secrets/"+rest[0], nil, "") + if err != nil { + return err + } + return emit(asJSON, out, fmt.Sprintf("%s is gone from %s\n%s", rest[0], name, secretLines(out))) + } + return fmt.Errorf("nook secret set|list|unset") +} + +// isSecretName keeps an uppercase KEY from being mistaken for the nook's name. +func isSecretName(a string) bool { + return a != "" && a == strings.ToUpper(a) && strings.Trim(a, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") == "" +} + +func readSecretValue(key string) (string, error) { + st, _ := os.Stdin.Stat() + piped := st != nil && st.Mode()&os.ModeCharDevice == 0 + if piped { + b, err := bufio.NewReader(os.Stdin).ReadString('\n') + if v := strings.TrimRight(b, "\r\n"); v != "" { + return v, nil + } + if err != nil { + return "", fmt.Errorf("no value on stdin for %s", key) + } + return "", fmt.Errorf("%s cannot be empty", key) + } + fmt.Fprintf(os.Stderr, "value for %s (not shown): ", key) + restore := echoOff() + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + restore() + fmt.Fprintln(os.Stderr) + if err != nil && strings.TrimSpace(line) == "" { + return "", fmt.Errorf("no value given for %s", key) + } + v := strings.TrimRight(line, "\r\n") + if v == "" { + return "", fmt.Errorf("%s cannot be empty", key) + } + return v, nil +} + +// echoOff hides typing through stty, so the CLI needs no dependency for one prompt. Where stty +// is not there the value echoes, which is worth saying rather than silently showing it. +func echoOff() func() { + if stty("-echo") != nil { + fmt.Fprint(os.Stderr, "\n(cannot hide input on this terminal; it will be visible) ") + return func() {} + } + return func() { stty("echo") } +} + +func stty(arg string) error { + cmd := exec.Command("stty", arg) + cmd.Stdin = os.Stdin // stty acts on the terminal it is given, which is the one being typed at + return cmd.Run() +} + +func secretLines(out map[string]any) string { + set := map[string]any{} + if list, ok := out["secrets"].([]any); ok { + for _, s := range list { + if m, ok := s.(map[string]any); ok { + set[fmt.Sprintf("%v", m["name"])] = m["updated_by"] + } + } + } + var b strings.Builder + seen := map[string]bool{} + if d, ok := out["declared"].([]any); ok { + for _, name := range d { + k := fmt.Sprintf("%v", name) + seen[k] = true + if by, ok := set[k]; ok { + fmt.Fprintf(&b, " %s set by %v\n", k, by) + } else { + fmt.Fprintf(&b, " %s not set\n", k) + } + } + } + for k, by := range set { + if !seen[k] { + fmt.Fprintf(&b, " %s set by %v, not declared in nook.json\n", k, by) + } + } + if b.Len() == 0 { + return " no secrets" + } + if m, ok := out["missing"].([]any); ok && len(m) > 0 { + fmt.Fprintf(&b, "\nthis nook cannot start until %d more %s set.", len(m), plural(len(m), "is", "are")) + } + return strings.TrimRight(b.String(), "\n") +} + +func plural(n int, one, many string) string { + if n == 1 { + return one + } + return many +} From c0cc380dd95692f8a4f17784976ef8caba6ac988 Mon Sep 17 00:00:00 2001 From: Vishal T <54330666+vish727@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:56:45 -0400 Subject: [PATCH 3/3] fix: keep a piped secret whole, restore echo on interrupt, check names here A piped value was read one line at a time, so a PEM key or a JSON credential stored its first line and reported success: the nook then fails at runtime with a value that looks set. Stdin is now read whole, with exactly one trailing newline removed, the one a shell or an editor adds. Ctrl+C at the prompt left the terminal with -echo, typing blind until stty echo. SIGINT is caught for the length of the prompt, restores, and exits 130. A name the server's regexp rejects is refused here instead of as a round trip to a 400. isSecretName stays looser on purpose, so "_FOO" is read as a misspelled key and named as one rather than taken for a nook. go test ./cmd/nook covers the pipe, since a stored value can never be read back. --- cmd/nook/secret.go | 61 ++++++++++++++++++++++++++---- cmd/nook/secret_test.go | 82 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 cmd/nook/secret_test.go diff --git a/cmd/nook/secret.go b/cmd/nook/secret.go index 6aa7378..5b59955 100644 --- a/cmd/nook/secret.go +++ b/cmd/nook/secret.go @@ -3,12 +3,18 @@ package main import ( "bufio" "fmt" + "io" "net/http" "os" "os/exec" + "os/signal" + "regexp" "strings" ) +// The server's rule, checked here so a bad name fails before the request rather than as a 400. +var secretNameRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`) + // A secret's value is never an argument: argv shows up in shell history and in ps. It comes from // a pipe, or from a prompt with the terminal's echo off. It is never printed back, by any command. @@ -32,6 +38,9 @@ func secret(args []string, asJSON bool) error { if len(rest) == 0 { return fmt.Errorf("usage: nook secret set [nook] KEY (the value comes from stdin or a prompt)") } + if err := checkSecretName(rest[0]); err != nil { + return err + } value, err := readSecretValue(rest[0]) if err != nil { return err @@ -45,6 +54,9 @@ func secret(args []string, asJSON bool) error { if len(rest) == 0 { return fmt.Errorf("usage: nook secret unset [nook] KEY") } + if err := checkSecretName(rest[0]); err != nil { + return err + } out, err := call(http.MethodDelete, "/v1/nooks/"+name+"/secrets/"+rest[0], nil, "") if err != nil { return err @@ -54,23 +66,39 @@ func secret(args []string, asJSON bool) error { return fmt.Errorf("nook secret set|list|unset") } -// isSecretName keeps an uppercase KEY from being mistaken for the nook's name. +// isSecretName keeps an uppercase KEY from being mistaken for the nook's name. It is looser than +// the rule below on purpose: "_FOO" should be read as a misspelled key and named as one, not +// silently taken for a nook. func isSecretName(a string) bool { return a != "" && a == strings.ToUpper(a) && strings.Trim(a, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") == "" } +func checkSecretName(name string) error { + if !secretNameRe.MatchString(name) { + return fmt.Errorf("%q is not a secret name: start with an uppercase letter, then letters, digits, and underscores, like SLACK_TOKEN", name) + } + if strings.HasPrefix(name, "NOOK_") { + return fmt.Errorf("%q is reserved: NOOK_ names are set by the runtime", name) + } + return nil +} + func readSecretValue(key string) (string, error) { st, _ := os.Stdin.Stat() piped := st != nil && st.Mode()&os.ModeCharDevice == 0 if piped { - b, err := bufio.NewReader(os.Stdin).ReadString('\n') - if v := strings.TrimRight(b, "\r\n"); v != "" { - return v, nil - } + b, err := io.ReadAll(os.Stdin) if err != nil { - return "", fmt.Errorf("no value on stdin for %s", key) + return "", fmt.Errorf("reading the value for %s: %w", key, err) } - return "", fmt.Errorf("%s cannot be empty", key) + // Exactly one trailing newline comes off, the one a shell or an editor adds. Everything + // else is kept: a PEM key and a JSON credential are both normal things to pipe in. + v := strings.TrimSuffix(string(b), "\n") + v = strings.TrimSuffix(v, "\r") + if v == "" { + return "", fmt.Errorf("%s cannot be empty", key) + } + return v, nil } fmt.Fprintf(os.Stderr, "value for %s (not shown): ", key) restore := echoOff() @@ -94,7 +122,24 @@ func echoOff() func() { fmt.Fprint(os.Stderr, "\n(cannot hide input on this terminal; it will be visible) ") return func() {} } - return func() { stty("echo") } + // Ctrl+C at the prompt must not leave the terminal echoless, typing blind until stty echo. + sig := make(chan os.Signal, 1) + done := make(chan struct{}) + signal.Notify(sig, os.Interrupt) + go func() { + select { + case <-sig: + stty("echo") + fmt.Fprintln(os.Stderr) + os.Exit(130) + case <-done: + } + }() + return func() { + signal.Stop(sig) + close(done) + stty("echo") + } } func stty(arg string) error { diff --git a/cmd/nook/secret_test.go b/cmd/nook/secret_test.go new file mode 100644 index 0000000..17608fe --- /dev/null +++ b/cmd/nook/secret_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// piped runs readSecretValue with b on stdin, the way a shell pipe delivers it. +func piped(t *testing.T, b string) (string, error) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = old }) + go func() { w.WriteString(b); w.Close() }() + return readSecretValue("KEY") +} + +func TestPipedValueIsNotTruncated(t *testing.T) { + pem := "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADAN\nBgkqhkiG9w0B\n-----END PRIVATE KEY-----\n" + got, err := piped(t, pem) + if err != nil { + t.Fatal(err) + } + // One trailing newline comes off; every other line stays. Truncating at the first newline + // would store a value that looks set and fails at runtime. + if want := strings.TrimSuffix(pem, "\n"); got != want { + t.Errorf("got %q, want %q", got, want) + } + if strings.Count(got, "\n") != 3 { + t.Errorf("lost lines: %q", got) + } +} + +func TestPipedValueKeepsWhatMatters(t *testing.T) { + for name, c := range map[string]struct{ in, want string }{ + "one trailing newline comes off": {"abc\n", "abc"}, + "only one comes off": {"abc\n\n", "abc\n"}, + "crlf comes off": {"abc\r\n", "abc"}, + "no newline is fine": {"abc", "abc"}, + "inner spacing is kept": {"a b\tc \n", "a b\tc "}, + "json survives": {"{\n \"k\": \"v\"\n}\n", "{\n \"k\": \"v\"\n}"}, + } { + got, err := piped(t, c.in) + if err != nil || got != c.want { + t.Errorf("%s: got %q (%v), want %q", name, got, err, c.want) + } + } + if _, err := piped(t, "\n"); err == nil { + t.Error("a newline alone is an empty value") + } + if _, err := piped(t, ""); err == nil { + t.Error("nothing piped is an empty value") + } +} + +func TestCheckSecretName(t *testing.T) { + for _, ok := range []string{"A", "SLACK_TOKEN", "A1", "A_B_C"} { + if err := checkSecretName(ok); err != nil { + t.Errorf("%q should be a name: %v", ok, err) + } + } + // These reached the server before and came back as a 400. + for _, bad := range []string{"", "_FOO", "123", "lower", "WITH-DASH", "WITH SPACE", "NOOK_TOKEN", strings.Repeat("A", 65)} { + if err := checkSecretName(bad); err == nil { + t.Errorf("%q should be refused here, not by the server", bad) + } + } + // Still recognised as an intended key, so the error names the key rather than the nook. + for _, k := range []string{"_FOO", "123", "SLACK_TOKEN"} { + if !isSecretName(k) { + t.Errorf("%q should be read as a key", k) + } + } + if isSecretName("board") { + t.Error("a nook name is not a key") + } +}