From b931f9bded8a13bfc6ff7bd06d65bbf5063d7150 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 18 May 2026 23:57:22 +0800 Subject: [PATCH 01/17] test: add failing tests for v0.5.0 Gated behind //go:build v05 build tag so baseline `go test ./...` remains green while these define the v0.5.0 contract. New tests (shush_v05_test.go): - TestClearReturnsErrorOnFailure - TestCapabilityRequiredErrorIsSentinel - TestCapabilityInvalidErrorIsSentinel - TestSetDoesNotRestartAgentOnAuthError - TestResolveTokenBytesReturnsBytes - TestResolveTokenBytesWithCapability - TestClearAllWithCapability - TestClearPrefixWithCapability - TestClearByKeyPatternWithCapability New CLI tests (cmd/shush/main_v05_test.go): - TestSetSecretFlagPrintsWarning - TestSetStdinDoesNotWarn Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/shush/main_v05_test.go | 104 ++++++++++++++++ shush_v05_test.go | 240 +++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 cmd/shush/main_v05_test.go create mode 100644 shush_v05_test.go diff --git a/cmd/shush/main_v05_test.go b/cmd/shush/main_v05_test.go new file mode 100644 index 0000000..925053c --- /dev/null +++ b/cmd/shush/main_v05_test.go @@ -0,0 +1,104 @@ +//go:build v05 + +// CLI integration tests for v0.5.0 behavior. Gated by the v05 build tag +// because they exercise behavior that does not yet exist on main. + +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func tmpSocket(t *testing.T, label string) string { + t.Helper() + p := fmt.Sprintf("/tmp/shush-cli-%s-%d.sock", label, time.Now().UnixNano()) + t.Cleanup(func() { _ = os.Remove(p) }) + return p +} + +// runShush invokes the CLI via `go run .` from the cmd/shush directory. +// It pipes stdin and captures stdout/stderr separately. +func runShush(t *testing.T, stdin string, args ...string) (stdout, stderr string, exitCode int) { + t.Helper() + + full := append([]string{"run", "."}, args...) + cmd := exec.Command("go", full...) + cmd.Dir = "." + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + + err := cmd.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + t.Fatalf("go run failed: %v\nstderr=%s", err, errBuf.String()) + } + } + return outBuf.String(), errBuf.String(), exitCode +} + +// startServeAgent boots `shush serve` against a fresh socket and tears it down. +func startServeAgent(t *testing.T, socket string) { + t.Helper() + + cmd := exec.Command("go", "run", ".", "serve", "--socket", socket) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatalf("start serve: %v", err) + } + t.Cleanup(func() { + _, _, _ = runShush(t, "", "stop", "--socket", socket) + _ = cmd.Wait() + }) + + // Poll until the agent is reachable. + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + _, _, code := runShush(t, "", "status", "--socket", socket) + if code == 0 { + return + } + time.Sleep(150 * time.Millisecond) + } + t.Fatalf("agent did not become ready at %s", socket) +} + +func TestSetSecretFlagPrintsWarning(t *testing.T) { + socket := tmpSocket(t, "secretflag") + startServeAgent(t, socket) + + _, stderr, code := runShush(t, "", "set", "k", "--secret", "v", "--socket", socket) + if code != 0 { + t.Fatalf("shush set failed: code=%d stderr=%s", code, stderr) + } + low := strings.ToLower(stderr) + if !strings.Contains(low, "warning") && !strings.Contains(low, "deprecat") { + t.Fatalf("expected stderr to contain 'warning' or 'deprecated', got %q", stderr) + } +} + +func TestSetStdinDoesNotWarn(t *testing.T) { + socket := tmpSocket(t, "stdin") + startServeAgent(t, socket) + + _, stderr, code := runShush(t, "secret-from-stdin", "set", "k", "--socket", socket) + if code != 0 { + t.Fatalf("shush set failed: code=%d stderr=%s", code, stderr) + } + low := strings.ToLower(stderr) + if strings.Contains(low, "warning") || strings.Contains(low, "deprecat") { + t.Fatalf("expected no warning on stdin path, got %q", stderr) + } +} diff --git a/shush_v05_test.go b/shush_v05_test.go new file mode 100644 index 0000000..1d3b2b3 --- /dev/null +++ b/shush_v05_test.go @@ -0,0 +1,240 @@ +//go:build v05 + +// Tests for v0.5.0 behavior. Gated by the v05 build tag because they +// reference symbols and behaviors that do not yet exist on main, so the +// baseline `go test ./...` invocation must remain green. + +package shush + +import ( + "bytes" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" +) + +// Group 1: Client.Clear() must surface real errors instead of swallowing them. + +func TestClearReturnsErrorOnFailure(t *testing.T) { + t.Parallel() + + bogusSocket := fmt.Sprintf("/tmp/shush-no-such-agent-%d.sock", time.Now().UnixNano()) + t.Cleanup(func() { _ = os.Remove(bogusSocket) }) + + client := NewClient(bogusSocket, "key", time.Minute) + if err := client.Clear(); err == nil { + t.Fatalf("expected Clear to return an error when the agent is unreachable, got nil") + } +} + +// Group 2: Sentinel errors replace string matching for capability auth. + +func TestCapabilityRequiredErrorIsSentinel(t *testing.T) { + t.Parallel() + + const capability = "v05-cap-required-sentinel" + socketPath := startTestAgentWithCapability(t, capability) + + err := PingWithCapability(socketPath, "") + if err == nil { + t.Fatalf("expected capability-required error, got nil") + } + if !errors.Is(err, ErrCapabilityRequired) { + t.Fatalf("expected errors.Is(err, ErrCapabilityRequired)==true, got err=%v", err) + } +} + +func TestCapabilityInvalidErrorIsSentinel(t *testing.T) { + t.Parallel() + + const capability = "v05-cap-invalid-sentinel" + socketPath := startTestAgentWithCapability(t, capability) + + err := PingWithCapability(socketPath, "wrong-capability") + if err == nil { + t.Fatalf("expected invalid-capability error, got nil") + } + if !errors.Is(err, ErrCapabilityInvalid) { + t.Fatalf("expected errors.Is(err, ErrCapabilityInvalid)==true, got err=%v", err) + } +} + +// Group 3: Client.Set() only restarts on dial errors, not auth/protocol failures. + +func TestSetDoesNotRestartAgentOnAuthError(t *testing.T) { + t.Parallel() + + const capability = "v05-correct-cap" + socketPath := startTestAgentWithCapability(t, capability) + + client := NewClient(socketPath, "auth:key", time.Minute) + client.Capability = "wrong-cap" + // If Set falls back to startProcess, it will invoke this bogus binary + // and surface a "start agent process" error. With the v0.5.0 contract + // Set must short-circuit on the capability error before that branch. + client.ServeArgs = []string{"/nonexistent/shush-binary-for-test", "serve", "--socket"} + + err := client.Set([]byte("secret")) + if err == nil { + t.Fatalf("expected Set with wrong capability to fail") + } + if !errors.Is(err, ErrCapabilityInvalid) { + t.Fatalf("expected ErrCapabilityInvalid (no agent restart), got %v", err) + } + if strings.Contains(err.Error(), "start agent process") || + strings.Contains(err.Error(), "wait for agent startup") { + t.Fatalf("expected Set to skip startProcess on auth error, got %v", err) + } +} + +// Group 5: New byte-returning token resolution APIs. + +func TestResolveTokenBytesReturnsBytes(t *testing.T) { + t.Parallel() + + socketPath := startTestAgent(t) + token, cleanup, err := RegisterToken(socketPath, "byte-secret", time.Minute, 2) + if err != nil { + t.Fatalf("RegisterToken: %v", err) + } + t.Cleanup(cleanup) + + got, err := ResolveTokenBytes(socketPath, token) + if err != nil { + t.Fatalf("ResolveTokenBytes: %v", err) + } + if !bytes.Equal(got, []byte("byte-secret")) { + t.Fatalf("unexpected secret bytes: got=%q", string(got)) + } + Wipe(got) +} + +func TestResolveTokenBytesWithCapability(t *testing.T) { + t.Parallel() + + const capability = "v05-resolve-token-bytes-cap" + socketPath := startTestAgentWithCapability(t, capability) + + token, cleanup, err := RegisterTokenWithCapability(socketPath, capability, "byte-secret-cap", time.Minute, 2) + if err != nil { + t.Fatalf("RegisterTokenWithCapability: %v", err) + } + t.Cleanup(cleanup) + + got, err := ResolveTokenBytesWithCapability(socketPath, capability, token) + if err != nil { + t.Fatalf("ResolveTokenBytesWithCapability: %v", err) + } + if !bytes.Equal(got, []byte("byte-secret-cap")) { + t.Fatalf("unexpected secret bytes: got=%q", string(got)) + } + Wipe(got) +} + +// Group 6: Clear* variants that accept an explicit capability argument. + +func TestClearAllWithCapability(t *testing.T) { + t.Parallel() + + const capability = "v05-clear-all-cap" + socketPath := startTestAgentWithCapability(t, capability) + + a := NewClient(socketPath, "ca:a", time.Minute) + a.Capability = capability + b := NewClient(socketPath, "ca:b", time.Minute) + b.Capability = capability + if err := a.Set([]byte("A")); err != nil { + t.Fatalf("Set a: %v", err) + } + if err := b.Set([]byte("B")); err != nil { + t.Fatalf("Set b: %v", err) + } + + if err := ClearAllWithCapability(socketPath, capability); err != nil { + t.Fatalf("ClearAllWithCapability: %v", err) + } + + if _, ok, _ := a.Get(); ok { + t.Fatalf("expected ca:a cleared") + } + if _, ok, _ := b.Get(); ok { + t.Fatalf("expected ca:b cleared") + } +} + +func TestClearPrefixWithCapability(t *testing.T) { + t.Parallel() + + const capability = "v05-clear-prefix-cap" + socketPath := startTestAgentWithCapability(t, capability) + + a := NewClient(socketPath, "cp:a", time.Minute) + a.Capability = capability + b := NewClient(socketPath, "cp:b", time.Minute) + b.Capability = capability + c := NewClient(socketPath, "other:c", time.Minute) + c.Capability = capability + + if err := a.Set([]byte("A")); err != nil { + t.Fatalf("Set a: %v", err) + } + if err := b.Set([]byte("B")); err != nil { + t.Fatalf("Set b: %v", err) + } + if err := c.Set([]byte("C")); err != nil { + t.Fatalf("Set c: %v", err) + } + + if err := ClearPrefixWithCapability(socketPath, "cp:", capability); err != nil { + t.Fatalf("ClearPrefixWithCapability: %v", err) + } + if _, ok, _ := a.Get(); ok { + t.Fatalf("expected cp:a cleared") + } + if _, ok, _ := b.Get(); ok { + t.Fatalf("expected cp:b cleared") + } + if got, ok, _ := c.Get(); !ok || string(got) != "C" { + t.Fatalf("expected other:c kept, ok=%v got=%q", ok, string(got)) + } +} + +func TestClearByKeyPatternWithCapability(t *testing.T) { + t.Parallel() + + const capability = "v05-clear-pattern-cap" + socketPath := startTestAgentWithCapability(t, capability) + + a := NewClient(socketPath, "pat:a", time.Minute) + a.Capability = capability + b := NewClient(socketPath, "pat:b", time.Minute) + b.Capability = capability + c := NewClient(socketPath, "keep:c", time.Minute) + c.Capability = capability + + if err := a.Set([]byte("A")); err != nil { + t.Fatalf("Set a: %v", err) + } + if err := b.Set([]byte("B")); err != nil { + t.Fatalf("Set b: %v", err) + } + if err := c.Set([]byte("C")); err != nil { + t.Fatalf("Set c: %v", err) + } + + if err := ClearByKeyPatternWithCapability(socketPath, "pat:*", capability); err != nil { + t.Fatalf("ClearByKeyPatternWithCapability: %v", err) + } + if _, ok, _ := a.Get(); ok { + t.Fatalf("expected pat:a cleared") + } + if _, ok, _ := b.Get(); ok { + t.Fatalf("expected pat:b cleared") + } + if got, ok, _ := c.Get(); !ok || string(got) != "C" { + t.Fatalf("expected keep:c kept, ok=%v got=%q", ok, string(got)) + } +} From a3022a7aad575889eaedff5ef17123c0f4a55db1 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:00:08 +0800 Subject: [PATCH 02/17] feat(cli): warn when --secret flag is used --- cmd/shush/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/shush/main.go b/cmd/shush/main.go index 41b2e2a..6469fad 100644 --- a/cmd/shush/main.go +++ b/cmd/shush/main.go @@ -140,6 +140,9 @@ func runSet(args []string) { var secret []byte if *secretFlag != "" { + // --secret exposes the secret via the process arg list (visible to `ps`) + // and the user's shell history; nudge users toward the stdin path. + fmt.Fprintln(os.Stderr, "shush set: warning: --secret exposes the secret via 'ps' and shell history; prefer stdin (e.g. echo $SECRET | shush set )") secret = []byte(*secretFlag) } else { var err error From 5c8b7037a396733c5fadb6ca798e74935e36de52 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:00:30 +0800 Subject: [PATCH 03/17] fix: Client.Clear returns error; clearAll single-lock Co-Authored-By: Claude Opus 4.7 (1M context) --- client_core.go | 5 +---- server.go | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/client_core.go b/client_core.go index b1417fa..93317ef 100644 --- a/client_core.go +++ b/client_core.go @@ -95,10 +95,7 @@ func (c *Client) Clear() error { Action: actionClear, Key: c.Key, }) - if err != nil { - return nil - } - return nil + return err } // ClearPrefix removes cached secrets whose keys start with the provided prefix. diff --git a/server.go b/server.go index 2ccadef..3b04597 100644 --- a/server.go +++ b/server.go @@ -408,6 +408,22 @@ func (s *agentState) handleClearToken(req request) response { } func (s *agentState) clearAll() { - _ = s.handleClear(request{}) - _ = s.handleClearToken(request{}) + s.mu.Lock() + defer s.mu.Unlock() + s.clearAllEntriesLocked() + s.clearAllTokensLocked() +} + +func (s *agentState) clearAllEntriesLocked() { + for k, e := range s.entries { + Wipe(e.secret) + delete(s.entries, k) + } +} + +func (s *agentState) clearAllTokensLocked() { + for k, te := range s.tokenEntries { + Wipe(te.secret) + delete(s.tokenEntries, k) + } } From b2b008f9839c10c4325ef4ed082085d18da8c919 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:00:30 +0800 Subject: [PATCH 04/17] fix(cli): runTokenResolve wipes secret after write --- cmd/shush/main.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/shush/main.go b/cmd/shush/main.go index 6469fad..cb30f12 100644 --- a/cmd/shush/main.go +++ b/cmd/shush/main.go @@ -272,7 +272,12 @@ func runTokenResolve(args []string) { fmt.Fprintf(os.Stderr, "shush token resolve: %v\n", err) os.Exit(1) } - fmt.Println(secret) + // Best-effort: the immutable string copy still lives in memory, but we can + // scrub the byte slice we hand to Stdout. Library may grow ResolveTokenBytes* + // later; this avoids the trailing newline of fmt.Println in the meantime. + buf := []byte(secret) + defer shush.Wipe(buf) + _, _ = os.Stdout.Write(buf) } func printUsage() { From 56a2394f79992741539b8774142a9b3f2f9716b2 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:01:22 +0800 Subject: [PATCH 05/17] feat: sentinel errors for capability auth failures Co-Authored-By: Claude Opus 4.7 (1M context) --- client_core.go | 22 ++++++++++++++++++---- protocol.go | 8 ++++++++ server.go | 10 +++------- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/client_core.go b/client_core.go index 93317ef..8c389a2 100644 --- a/client_core.go +++ b/client_core.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "net" "strings" "time" @@ -149,10 +150,23 @@ func (c *Client) do(req request) (response, error) { return response{}, err } if !resp.OK { - if strings.TrimSpace(resp.Error) == "" { - return response{}, errors.New("agent request failed") - } - return response{}, errors.New(resp.Error) + return response{}, decodeRemoteError(resp.Error) } return resp, nil } + +// decodeRemoteError rebinds sentinel errors that travel as strings over the +// wire so callers can use errors.Is across the process boundary. +func decodeRemoteError(msg string) error { + trimmed := strings.TrimSpace(msg) + if trimmed == "" { + return errors.New("agent request failed") + } + switch trimmed { + case ErrCapabilityRequired.Error(): + return fmt.Errorf("%w", ErrCapabilityRequired) + case ErrCapabilityInvalid.Error(): + return fmt.Errorf("%w", ErrCapabilityInvalid) + } + return errors.New(trimmed) +} diff --git a/protocol.go b/protocol.go index 079fd0a..ac00ff9 100644 --- a/protocol.go +++ b/protocol.go @@ -1,10 +1,18 @@ package shush import ( + "errors" "sync" "time" ) +// ErrCapabilityRequired is returned when an agent requires a capability +// but the request did not include one. +var ErrCapabilityRequired = errors.New("forbidden: capability required") + +// ErrCapabilityInvalid is returned when an agent rejects a capability mismatch. +var ErrCapabilityInvalid = errors.New("forbidden: invalid capability") + const ( DefaultDialTimeout = 300 * time.Millisecond DefaultReqTimeout = 2 * time.Second diff --git a/server.go b/server.go index 3b04597..319054d 100644 --- a/server.go +++ b/server.go @@ -170,21 +170,17 @@ func (s *agentState) authorizeCapability(capability string) error { } capability = strings.TrimSpace(capability) if capability == "" { - return errors.New("forbidden: capability required") + return ErrCapabilityRequired } sum := sha256.Sum256([]byte(capability)) if subtle.ConstantTimeCompare(sum[:], s.capabilityHash[:]) != 1 { - return errors.New("forbidden: invalid capability") + return ErrCapabilityInvalid } return nil } func isCapabilityAuthError(err error) bool { - if err == nil { - return false - } - msg := err.Error() - return strings.Contains(msg, "forbidden: capability") + return errors.Is(err, ErrCapabilityRequired) || errors.Is(err, ErrCapabilityInvalid) } func (s *agentState) apply(req request) response { From c2e35a3b44e1973883f44da8aec32264fe60ed06 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:01:36 +0800 Subject: [PATCH 06/17] refactor(cli): extract setupKeyedClient helper --- cmd/shush/main.go | 49 ++++++++++++++++------------------------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/cmd/shush/main.go b/cmd/shush/main.go index cb30f12..a660a4e 100644 --- a/cmd/shush/main.go +++ b/cmd/shush/main.go @@ -124,19 +124,28 @@ func runStatus(args []string) { fmt.Println("shush agent is running") } -func runSet(args []string) { - fs := flag.NewFlagSet("set", flag.ExitOnError) - ttl := fs.Duration("ttl", 10*time.Minute, "secret TTL") - secretFlag := fs.String("secret", "", "secret value (default: read from stdin)") +// setupKeyedClient is shared by runSet/runGet/runClear: parse the key positional, +// resolve the socket + capability from the raw args, and construct a client. +// ttl == 0 means "use the library default". +func setupKeyedClient(cmd string, args []string, fs *flag.FlagSet, ttl time.Duration) (*shush.Client, string) { _ = fs.Parse(stripGlobalFlags(args)) - rest := fs.Args() if len(rest) < 1 { - fmt.Fprintln(os.Stderr, "shush set: key is required") + fmt.Fprintf(os.Stderr, "shush %s: key is required\n", cmd) os.Exit(1) } key := rest[0] socket := shush.ResolveSocketPath(args) + client := shush.NewClient(socket, key, ttl) + client.Capability = shush.ResolveCapability(args) + return client, key +} + +func runSet(args []string) { + fs := flag.NewFlagSet("set", flag.ExitOnError) + ttl := fs.Duration("ttl", 10*time.Minute, "secret TTL") + secretFlag := fs.String("secret", "", "secret value (default: read from stdin)") + client, _ := setupKeyedClient("set", args, fs, *ttl) var secret []byte if *secretFlag != "" { @@ -157,8 +166,6 @@ func runSet(args []string) { } } - client := shush.NewClient(socket, key, *ttl) - client.Capability = shush.ResolveCapability(args) if err := client.Set(secret); err != nil { fmt.Fprintf(os.Stderr, "shush set: %v\n", err) os.Exit(1) @@ -167,18 +174,7 @@ func runSet(args []string) { func runGet(args []string) { fs := flag.NewFlagSet("get", flag.ExitOnError) - _ = fs.Parse(stripGlobalFlags(args)) - - rest := fs.Args() - if len(rest) < 1 { - fmt.Fprintln(os.Stderr, "shush get: key is required") - os.Exit(1) - } - key := rest[0] - socket := shush.ResolveSocketPath(args) - - client := shush.NewClient(socket, key, 0) - client.Capability = shush.ResolveCapability(args) + client, _ := setupKeyedClient("get", args, fs, 0) secret, found, err := client.Get() if err != nil { fmt.Fprintf(os.Stderr, "shush get: %v\n", err) @@ -193,18 +189,7 @@ func runGet(args []string) { func runClear(args []string) { fs := flag.NewFlagSet("clear", flag.ExitOnError) - _ = fs.Parse(stripGlobalFlags(args)) - - rest := fs.Args() - if len(rest) < 1 { - fmt.Fprintln(os.Stderr, "shush clear: key is required") - os.Exit(1) - } - key := rest[0] - socket := shush.ResolveSocketPath(args) - - client := shush.NewClient(socket, key, 0) - client.Capability = shush.ResolveCapability(args) + client, _ := setupKeyedClient("clear", args, fs, 0) if err := client.Clear(); err != nil { fmt.Fprintf(os.Stderr, "shush clear: %v\n", err) os.Exit(1) From 16fa30c0b75b12013681de709595289b60df2442 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:02:09 +0800 Subject: [PATCH 07/17] refactor(cli): unify capability resolution in runServe --- cmd/shush/main.go | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/cmd/shush/main.go b/cmd/shush/main.go index a660a4e..b4d11ed 100644 --- a/cmd/shush/main.go +++ b/cmd/shush/main.go @@ -76,19 +76,13 @@ func stripGlobalFlags(args []string) []string { func runServe(args []string) { fs := flag.NewFlagSet("serve", flag.ExitOnError) socket := fs.String("socket", "", "socket path") - capabilityLong := fs.String("capability", "", "capability token required by clients") - capabilityShort := fs.String("cap", "", "alias of --capability") - _ = fs.Parse(args) + // stripGlobalFlags removes --socket/--capability/--cap, so fs only parses + // what's left; capability is delegated to ResolveCapability(args) below. + _ = fs.Parse(stripGlobalFlags(args)) if strings.TrimSpace(*socket) == "" { - *socket = shush.ResolveSocketPath(nil) - } - capability := strings.TrimSpace(*capabilityLong) - if capability == "" { - capability = strings.TrimSpace(*capabilityShort) - } - if capability == "" { - capability = shush.ResolveCapability(nil) + *socket = shush.ResolveSocketPath(args) } + capability := shush.ResolveCapability(args) if err := shush.ServeWithCapability(*socket, os.Stderr, capability); err != nil { fmt.Fprintf(os.Stderr, "shush serve: %v\n", err) os.Exit(1) From 34f5b435c1e7316f535edba581fcd053c5618aaf Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:02:14 +0800 Subject: [PATCH 08/17] refactor: Set only restarts agent on dial errors Co-Authored-By: Claude Opus 4.7 (1M context) --- client_core.go | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/client_core.go b/client_core.go index 8c389a2..805171c 100644 --- a/client_core.go +++ b/client_core.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "net" + "os" "strings" + "syscall" "time" ) @@ -72,17 +74,17 @@ func (c *Client) Set(secret []byte) error { SecretB64: base64.StdEncoding.EncodeToString(secret), ExpiresAt: time.Now().Add(c.TTL).Unix(), } - if _, err := c.do(req); err == nil { - return nil - } capability := strings.TrimSpace(c.Capability) - if capability == "" { - capability = ResolveCapability(nil) - } - if err := startProcess(c.SocketPath, c.ServeArgs, capability); err != nil { - return err + if err := PingWithCapability(c.SocketPath, capability); err != nil { + if !isDialError(err) { + return err + } + if err := startProcess(c.SocketPath, c.ServeArgs, capability); err != nil { + return err + } } + _, err := c.do(req) return err } @@ -126,9 +128,6 @@ func (c *Client) ClearByKeyPattern(pattern string) error { func (c *Client) do(req request) (response, error) { if strings.TrimSpace(req.Capability) == "" { req.Capability = strings.TrimSpace(c.Capability) - if req.Capability == "" { - req.Capability = ResolveCapability(nil) - } } conn, err := net.DialTimeout("unix", c.SocketPath, DefaultDialTimeout) @@ -155,6 +154,26 @@ func (c *Client) do(req request) (response, error) { return resp, nil } +// isDialError reports whether err indicates that the agent socket is +// unreachable (missing or refusing connections), distinguishing transport +// failures from protocol-level rejections like capability errors. +func isDialError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT) { + return true + } + if os.IsNotExist(err) { + return true + } + var opErr *net.OpError + if errors.As(err, &opErr) && opErr.Op == "dial" { + return true + } + return false +} + // decodeRemoteError rebinds sentinel errors that travel as strings over the // wire so callers can use errors.Is across the process boundary. func decodeRemoteError(msg string) error { From f69bbb35e4cc5c693a4aef83fd65679396568a45 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:02:49 +0800 Subject: [PATCH 09/17] feat: ClearAll/Prefix/ByKeyPattern WithCapability variants Co-Authored-By: Claude Opus 4.7 (1M context) --- client_agent.go | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/client_agent.go b/client_agent.go index 009c4af..69d5b95 100644 --- a/client_agent.go +++ b/client_agent.go @@ -15,8 +15,7 @@ func Ping(socketPath string) error { // PingWithCapability checks if an agent is running with the provided capability. func PingWithCapability(socketPath, capability string) error { - client := &Client{SocketPath: socketPath, Capability: capability} - _, err := client.do(request{Action: actionPing}) + _, err := newAgentClient(socketPath, capability).do(request{Action: actionPing}) return err } @@ -27,14 +26,19 @@ func Stop(socketPath string) error { // StopWithCapability requests the agent to shut down with the provided capability. func StopWithCapability(socketPath, capability string) error { - client := &Client{SocketPath: socketPath, Capability: capability} - _, err := client.do(request{Action: actionStop}) + _, err := newAgentClient(socketPath, capability).do(request{Action: actionStop}) return err } // ClearAll removes all cached secrets and tokens from the agent. func ClearAll(socketPath string) error { - client := &Client{SocketPath: socketPath, Capability: ResolveCapability(nil)} + return ClearAllWithCapability(socketPath, ResolveCapability(nil)) +} + +// ClearAllWithCapability removes all cached secrets and tokens using the +// provided capability. +func ClearAllWithCapability(socketPath, capability string) error { + client := newAgentClient(socketPath, capability) if _, err := client.do(request{Action: actionClear}); err != nil { return err } @@ -44,14 +48,29 @@ func ClearAll(socketPath string) error { // ClearPrefix removes all cached secrets whose keys start with the prefix. func ClearPrefix(socketPath, prefix string) error { - client := &Client{SocketPath: socketPath, Capability: ResolveCapability(nil)} - return client.ClearPrefix(prefix) + return ClearPrefixWithCapability(socketPath, prefix, ResolveCapability(nil)) +} + +// ClearPrefixWithCapability removes cached secrets by prefix using the +// provided capability. +func ClearPrefixWithCapability(socketPath, prefix, capability string) error { + return newAgentClient(socketPath, capability).ClearPrefix(prefix) } // ClearByKeyPattern removes all cached secrets matching the glob pattern. func ClearByKeyPattern(socketPath, pattern string) error { - client := &Client{SocketPath: socketPath, Capability: ResolveCapability(nil)} - return client.ClearByKeyPattern(pattern) + return ClearByKeyPatternWithCapability(socketPath, pattern, ResolveCapability(nil)) +} + +// ClearByKeyPatternWithCapability removes cached secrets by glob pattern +// using the provided capability. +func ClearByKeyPatternWithCapability(socketPath, pattern, capability string) error { + return newAgentClient(socketPath, capability).ClearByKeyPattern(pattern) +} + +// newAgentClient builds a Client used for one-shot agent control calls. +func newAgentClient(socketPath, capability string) *Client { + return &Client{SocketPath: socketPath, Capability: capability} } // StartProcess starts an agent server in a background process. From 5832a801378e4efd76f55d8c90ee22c1066359f9 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:03:45 +0800 Subject: [PATCH 10/17] refactor: split handleClear; extract stale socket helper Co-Authored-By: Claude Opus 4.7 (1M context) --- server.go | 102 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 57 insertions(+), 45 deletions(-) diff --git a/server.go b/server.go index 319054d..23f5bde 100644 --- a/server.go +++ b/server.go @@ -37,21 +37,8 @@ func ServeWithCapability(socketPath string, errOut io.Writer, capability string) return fmt.Errorf("create agent directory: %w", err) } - if info, err := os.Stat(socketPath); err == nil { - if info.Mode()&os.ModeSocket == 0 { - return fmt.Errorf("agent socket path exists and is not a socket: %s", socketPath) - } - probe := &Client{SocketPath: socketPath, Capability: capability} - if _, err := probe.do(request{Action: actionPing}); err == nil { - return fmt.Errorf("agent already running at %s", socketPath) - } else if isCapabilityAuthError(err) { - return fmt.Errorf("agent already running at %s (capability mismatch)", socketPath) - } - if err := os.Remove(socketPath); err != nil { - return fmt.Errorf("remove stale socket %s: %w", socketPath, err) - } - } else if !os.IsNotExist(err) { - return fmt.Errorf("stat socket path %s: %w", socketPath, err) + if err := removeStaleSocketIfDead(socketPath, capability); err != nil { + return err } listener, err := net.Listen("unix", socketPath) @@ -152,6 +139,29 @@ func handleConn(conn net.Conn, state *agentState, stopFn func()) { } } +func removeStaleSocketIfDead(socketPath, capability string) error { + info, err := os.Stat(socketPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("stat socket path %s: %w", socketPath, err) + } + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("agent socket path exists and is not a socket: %s", socketPath) + } + probe := newAgentClient(socketPath, capability) + if _, err := probe.do(request{Action: actionPing}); err == nil { + return fmt.Errorf("agent already running at %s", socketPath) + } else if isCapabilityAuthError(err) { + return fmt.Errorf("agent already running at %s (capability mismatch)", socketPath) + } + if err := os.Remove(socketPath); err != nil { + return fmt.Errorf("remove stale socket %s: %w", socketPath, err) + } + return nil +} + func ensureSameUID(conn net.Conn) error { peerUID, err := socketPeerUID(conn) if err != nil { @@ -282,38 +292,43 @@ func (s *agentState) handleClear(req request) response { s.mu.Lock() defer s.mu.Unlock() - if key == "" && keyPrefix == "" && keyPattern == "" { - for k, e := range s.entries { - Wipe(e.secret) - delete(s.entries, k) - } - return response{OK: true} - } - if keyPrefix != "" { - for k, e := range s.entries { - if strings.HasPrefix(k, keyPrefix) { - Wipe(e.secret) - delete(s.entries, k) - } - } - return response{OK: true} - } - if keyPattern != "" { - for k, e := range s.entries { - matched, _ := path.Match(keyPattern, k) - if matched { - Wipe(e.secret) - delete(s.entries, k) - } - } - return response{OK: true} + switch { + case key == "" && keyPrefix == "" && keyPattern == "": + s.clearAllEntriesLocked() + case keyPrefix != "": + s.clearByPrefixLocked(keyPrefix) + case keyPattern != "": + s.clearByPatternLocked(keyPattern) + default: + s.clearOneLocked(key) } + return response{OK: true} +} +func (s *agentState) clearOneLocked(key string) { if e, ok := s.entries[key]; ok { Wipe(e.secret) delete(s.entries, key) } - return response{OK: true} +} + +func (s *agentState) clearByPrefixLocked(prefix string) { + for k, e := range s.entries { + if strings.HasPrefix(k, prefix) { + Wipe(e.secret) + delete(s.entries, k) + } + } +} + +func (s *agentState) clearByPatternLocked(pattern string) { + for k, e := range s.entries { + matched, _ := path.Match(pattern, k) + if matched { + Wipe(e.secret) + delete(s.entries, k) + } + } } func (s *agentState) handleSetToken(req request) response { @@ -389,10 +404,7 @@ func (s *agentState) handleClearToken(req request) response { defer s.mu.Unlock() if token == "" { - for k, te := range s.tokenEntries { - Wipe(te.secret) - delete(s.tokenEntries, k) - } + s.clearAllTokensLocked() return response{OK: true} } From 3c1c2da6eca5069fedde048790868ebafa872619 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:04:04 +0800 Subject: [PATCH 11/17] fix(cli): lift positional key before parsing flags Go's flag package stops parsing at the first non-flag token, so `shush set --secret v` would never see --secret. liftFirstPositional pulls the key out so the remaining tokens are all flags, restoring the documented argument order. --- cmd/shush/main.go | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/cmd/shush/main.go b/cmd/shush/main.go index b4d11ed..93f3d88 100644 --- a/cmd/shush/main.go +++ b/cmd/shush/main.go @@ -118,28 +118,52 @@ func runStatus(args []string) { fmt.Println("shush agent is running") } -// setupKeyedClient is shared by runSet/runGet/runClear: parse the key positional, -// resolve the socket + capability from the raw args, and construct a client. +// setupKeyedClient is shared by runSet/runGet/runClear. It: +// 1. strips global flags (--socket, --capability, --cap) from args, +// 2. lifts the first positional (the key) out so the remaining tokens are +// all flags — Go's stdlib flag package stops parsing at the first +// non-flag, so without this step `set --secret v` would never +// see `--secret`, +// 3. parses fs with the lifted flag tokens, +// 4. builds a Client wired to socket + capability resolved from raw args. +// // ttl == 0 means "use the library default". func setupKeyedClient(cmd string, args []string, fs *flag.FlagSet, ttl time.Duration) (*shush.Client, string) { - _ = fs.Parse(stripGlobalFlags(args)) - rest := fs.Args() - if len(rest) < 1 { + stripped := stripGlobalFlags(args) + key, flagTokens := liftFirstPositional(stripped) + if key == "" { fmt.Fprintf(os.Stderr, "shush %s: key is required\n", cmd) os.Exit(1) } - key := rest[0] + _ = fs.Parse(flagTokens) socket := shush.ResolveSocketPath(args) client := shush.NewClient(socket, key, ttl) client.Capability = shush.ResolveCapability(args) return client, key } +// liftFirstPositional returns the first non-flag token plus the remaining +// tokens with that positional removed. Anything starting with "-" is treated +// as a flag (and its potential value is preserved by leaving it in place). +func liftFirstPositional(args []string) (string, []string) { + for i, a := range args { + if !strings.HasPrefix(a, "-") { + out := make([]string, 0, len(args)-1) + out = append(out, args[:i]...) + out = append(out, args[i+1:]...) + return a, out + } + } + return "", args +} + func runSet(args []string) { fs := flag.NewFlagSet("set", flag.ExitOnError) ttl := fs.Duration("ttl", 10*time.Minute, "secret TTL") secretFlag := fs.String("secret", "", "secret value (default: read from stdin)") - client, _ := setupKeyedClient("set", args, fs, *ttl) + client, _ := setupKeyedClient("set", args, fs, 0) + // Apply ttl now that fs.Parse has populated it (setupKeyedClient parsed fs). + client.TTL = *ttl var secret []byte if *secretFlag != "" { From 2f20265e8707f7dbf1cbfdec39a2b51561b7d68f Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:04:12 +0800 Subject: [PATCH 12/17] refactor: split startProcess into spawn + wait Co-Authored-By: Claude Opus 4.7 (1M context) --- client_agent.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/client_agent.go b/client_agent.go index 69d5b95..a85e0ba 100644 --- a/client_agent.go +++ b/client_agent.go @@ -85,6 +85,11 @@ func StartProcessWithCapability(socketPath string, serveArgs []string, capabilit return startProcess(socketPath, serveArgs, capability) } +const ( + agentStartupAttempts = 40 + agentStartupInterval = 25 * time.Millisecond +) + func startProcess(socketPath string, serveArgs []string, capability string) error { capability = strings.TrimSpace(capability) if err := PingWithCapability(socketPath, capability); err == nil { @@ -93,6 +98,13 @@ func startProcess(socketPath string, serveArgs []string, capability string) erro return err } + if err := spawnAgentProcess(socketPath, serveArgs, capability); err != nil { + return err + } + return waitForAgentReady(socketPath, capability) +} + +func spawnAgentProcess(socketPath string, serveArgs []string, capability string) error { if len(serveArgs) == 0 { exePath, err := os.Executable() if err != nil { @@ -113,10 +125,13 @@ func startProcess(socketPath string, serveArgs []string, capability string) erro return fmt.Errorf("start agent process: %w", err) } _ = cmd.Process.Release() + return nil +} +func waitForAgentReady(socketPath, capability string) error { var lastErr error - for i := 0; i < 40; i++ { - time.Sleep(25 * time.Millisecond) + for i := 0; i < agentStartupAttempts; i++ { + time.Sleep(agentStartupInterval) lastErr = PingWithCapability(socketPath, capability) if lastErr == nil { return nil From 408f97157ddaad6df3271cee0d8cb5733a2f7527 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:04:41 +0800 Subject: [PATCH 13/17] refactor: RegisterTokenWithCapability uses shared client ctor Co-Authored-By: Claude Opus 4.7 (1M context) --- client_token.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client_token.go b/client_token.go index 7bad54b..d579696 100644 --- a/client_token.go +++ b/client_token.go @@ -36,7 +36,7 @@ func RegisterTokenWithCapability(socketPath, capability, secret string, ttl time if err != nil { return "", nil, err } - client := &Client{SocketPath: socketPath, Capability: capability} + client := newAgentClient(socketPath, capability) _, err = client.do(request{ Action: actionSetToken, Token: token, @@ -64,7 +64,7 @@ func ResolveToken(socketPath, token string) (string, error) { // ResolveTokenWithCapability retrieves and consumes a token with capability. func ResolveTokenWithCapability(socketPath, capability, token string) (string, error) { - client := &Client{SocketPath: socketPath, Capability: capability} + client := newAgentClient(socketPath, capability) resp, err := client.do(request{ Action: actionGetToken, Token: token, From 0545b17d12f1f18f51c726147577b3e05a46bdff Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:06:12 +0800 Subject: [PATCH 14/17] feat: ResolveTokenBytes returns []byte Co-Authored-By: Claude Opus 4.7 (1M context) --- client_token.go | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/client_token.go b/client_token.go index d579696..3d0f4a2 100644 --- a/client_token.go +++ b/client_token.go @@ -58,28 +58,52 @@ func RegisterTokenWithCapability(socketPath, capability, secret string, ttl time } // ResolveToken retrieves and consumes a one-time token. +// +// Deprecated: use ResolveTokenBytes to keep the secret as []byte and avoid +// leaving an immutable string copy in memory. func ResolveToken(socketPath, token string) (string, error) { return ResolveTokenWithCapability(socketPath, ResolveCapability(nil), token) } // ResolveTokenWithCapability retrieves and consumes a token with capability. +// +// Deprecated: use ResolveTokenBytesWithCapability to keep the secret as +// []byte and avoid leaving an immutable string copy in memory. func ResolveTokenWithCapability(socketPath, capability, token string) (string, error) { + secret, err := ResolveTokenBytesWithCapability(socketPath, capability, token) + if err != nil { + return "", err + } + out := string(secret) + Wipe(secret) + return out, nil +} + +// ResolveTokenBytes retrieves and consumes a one-time token, returning the +// secret as a mutable []byte so callers can Wipe it after use. +func ResolveTokenBytes(socketPath, token string) ([]byte, error) { + return ResolveTokenBytesWithCapability(socketPath, ResolveCapability(nil), token) +} + +// ResolveTokenBytesWithCapability retrieves and consumes a token with +// capability, returning the secret as a mutable []byte. +func ResolveTokenBytesWithCapability(socketPath, capability, token string) ([]byte, error) { client := newAgentClient(socketPath, capability) resp, err := client.do(request{ Action: actionGetToken, Token: token, }) if err != nil { - return "", err + return nil, err } if !resp.Found { - return "", errors.New("token not found or expired") + return nil, errors.New("token not found or expired") } secret, err := base64.StdEncoding.DecodeString(resp.SecretB64) if err != nil || len(secret) == 0 { - return "", errors.New("invalid token secret payload") + return nil, errors.New("invalid token secret payload") } - return string(secret), nil + return secret, nil } func generateToken() (string, error) { From e3adb5cb0da19ed25d15d7a3b80f47856b41e905 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:13:36 +0800 Subject: [PATCH 15/17] docs: clarify Wipe best-effort semantics and package roles --- capability.go | 1 + doc.go | 9 +++++++++ socket_path.go | 1 + wipe.go | 7 ++++++- 4 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 doc.go diff --git a/capability.go b/capability.go index 5599b05..ee288e6 100644 --- a/capability.go +++ b/capability.go @@ -1,3 +1,4 @@ +// Capability token resolution: --capability/--cap flag > SHUSH_CAPABILITY env > empty. package shush import ( diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..6e68424 --- /dev/null +++ b/doc.go @@ -0,0 +1,9 @@ +// Package shush is an in-memory secret cache agent accessible via Unix +// domain sockets. Secrets never touch the filesystem — they live only in +// a background agent process, protected by peer UID verification and +// automatic TTL expiry. +// +// Same-UID processes can read agent memory via ptrace; capability tokens +// provide session-level isolation but are not a defense against same-UID +// attackers. See README.md for the full threat model. +package shush diff --git a/socket_path.go b/socket_path.go index 9973f81..f6f60e6 100644 --- a/socket_path.go +++ b/socket_path.go @@ -1,3 +1,4 @@ +// Socket path resolution: --socket flag > SHUSH_SOCKET env > DefaultSocketPath. package shush import ( diff --git a/wipe.go b/wipe.go index df0a886..bf640f5 100644 --- a/wipe.go +++ b/wipe.go @@ -1,6 +1,11 @@ package shush -// Wipe zeroes out a byte slice to remove sensitive data from memory. +// Wipe overwrites the bytes of data with zeros. +// +// This is a best-effort defense: Go's compiler and garbage collector may +// retain copies of the slice's backing array elsewhere in memory. Callers +// should pass []byte (not string) so the underlying memory is mutable, and +// should call Wipe as early as possible after the secret is no longer needed. func Wipe(data []byte) { for i := range data { data[i] = 0 From e7735e0c8abf8a8eb60d53c52befc9d8890e1944 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:13:44 +0800 Subject: [PATCH 16/17] chore: ignore macOS metadata files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index c2c5bb3..8357aa3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /shush /dist *.sock +._* +.DS_Store From a18af97cc5ac47f20c0a52bcb4a99b668b064881 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 19 May 2026 00:32:29 +0800 Subject: [PATCH 17/17] =?UTF-8?q?test:=20drop=20v05=20build=20tag=20?= =?UTF-8?q?=E2=80=94=20v0.5.0=20features=20now=20default-tested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/shush/main_v05_test.go | 5 ----- shush_v05_test.go | 6 ------ 2 files changed, 11 deletions(-) diff --git a/cmd/shush/main_v05_test.go b/cmd/shush/main_v05_test.go index 925053c..0c5fa87 100644 --- a/cmd/shush/main_v05_test.go +++ b/cmd/shush/main_v05_test.go @@ -1,8 +1,3 @@ -//go:build v05 - -// CLI integration tests for v0.5.0 behavior. Gated by the v05 build tag -// because they exercise behavior that does not yet exist on main. - package main import ( diff --git a/shush_v05_test.go b/shush_v05_test.go index 1d3b2b3..83975fc 100644 --- a/shush_v05_test.go +++ b/shush_v05_test.go @@ -1,9 +1,3 @@ -//go:build v05 - -// Tests for v0.5.0 behavior. Gated by the v05 build tag because they -// reference symbols and behaviors that do not yet exist on main, so the -// baseline `go test ./...` invocation must remain green. - package shush import (