Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/wiki/Compat-V15.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ The table is generated. Never hand-edit it; run `compat-markers.sh --write-wiki`
| D-0676 | bare stream path streams as path:// with a notice | refused | `internal/backup/stream_dest_compat.go` |
| D-0685 | credential guard before E138 for a missing minio plugin | guard only for plugin-backed MinIO | `internal/config/validator_ports_services.go` |
| D-0686 | S3_SECRET_KEY accepted at any strength | must be at least 16 characters when object storage is enabled | `internal/config/validator.go` |
| D-0737 | legacy MCP nself_config_set refuses secret keys | the generated gated v1.5 tool handles them | `internal/config/secretkeys.go` |
| D-0752 | plugin disable never confirmed | prod-class and hand-edit confirmation before the marker is written. | `cmd/commands/plugin_disable_gate.go` |
| D-0757 | nginx/sites may be a symlink out of the project and the build writes through it | v1.5 refuses a project's own nginx/sites that resolves outside the project | `internal/build/nginx_sites_dir.go` |
| D-0843 | a downgrade only warns | refused unless --allow-downgrade is given | `internal/plugin/installer_downgrade.go` |
Expand Down
2 changes: 1 addition & 1 deletion cmd/commands/license_migrate_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func fetchMigrationStatus(ctx context.Context, key string, pingURL string) (*mig

resp, err := httptimeout.License.Do(req)
if err != nil {
return nil, err
return nil, httptimeout.StripURL(err)
}
defer func() { _ = resp.Body.Close() }()

Expand Down
47 changes: 47 additions & 0 deletions cmd/commands/license_migrate_api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package commands

// Purpose: fetchMigrationStatus keeps the key out of the request URL (it goes
// in X-License-Key) and never lets the key or the endpoint escape through a
// transport error, which net/http otherwise builds from the full request URL.
// Inputs: a synthetic key and httptest servers. Outputs: pass/fail.
// Constraints: loopback only.

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

const migrateTestKey = "synthetic key&x=1#frag"

func TestFetchMigrationStatusEncodesKey(t *testing.T) {
var gotHeader, gotPath, gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeader, gotPath, gotQuery = r.Header.Get("X-License-Key"), r.URL.Path, r.URL.RawQuery
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()
if _, err := fetchMigrationStatus(context.Background(), migrateTestKey, srv.URL); err != nil {
t.Fatal(err)
}
if gotPath != "/license/migration-status" || gotQuery != "" || gotHeader != migrateTestKey {
t.Errorf("server saw path %q query %q header %q", gotPath, gotQuery, gotHeader)
}
}

func TestFetchMigrationStatusTransportErrorOmitsKey(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
url := srv.URL
srv.Close() // connection refused
_, err := fetchMigrationStatus(context.Background(), migrateTestKey, url)
if err == nil {
t.Fatal("want a transport error")
}
for _, leak := range []string{migrateTestKey, "synthetic", "key=", "migration-status"} {
if strings.Contains(err.Error(), leak) {
t.Errorf("transport error leaks %q: %v", leak, err)
}
}
}
28 changes: 28 additions & 0 deletions cmd/commands/license_refresh_down_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package commands

// Purpose: `license refresh` against an unreachable ping_api prints only the
// fixed failure code, never the key, its mask, its hash or the request URL.
// Inputs: the grace fixture with its server shut down. Outputs: pass/fail.
// Constraints: loopback only.

import (
"strings"
"testing"

"github.com/nself-org/cli/internal/errs"
"github.com/nself-org/cli/internal/license"
)

func TestLicenseRefreshTransportFailureIsFixedCode(t *testing.T) {
f := newGraceFixture(t)
t.Setenv("NSELF_PING_API_URL", "http://127.0.0.1:1") // nothing listens
stdout, stderr, err := runRefresh(t, f.project, map[string]string{"project": f.project, "quiet": "true"})
if stdout != "" || stderr != "entitlement refresh failed: ping-unavailable\n" || errs.ExitCodeFor(err) != 1 {
t.Fatalf("stdout %q stderr %q exit %d", stdout, stderr, errs.ExitCodeFor(err))
}
for _, s := range []string{graceKey, license.MaskKey(graceKey), license.HashKey(graceKey), "127.0.0.1"} {
if strings.Contains(stdout+stderr, s) {
t.Errorf("output contains %q", s)
}
}
}
8 changes: 4 additions & 4 deletions cmd/commands/mcp_tools_mutate.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ package commands

import (
"context"
"errors"
"fmt"
"strings"

"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nself-org/cli/internal/config"
"github.com/nself-org/cli/internal/plugin"
)

Expand Down Expand Up @@ -78,7 +80,7 @@ func mcpConfigSetHandler() server.ToolHandlerFunc {
value, _ := args["value"].(string)
envFlag, _ := args["env"].(string)

if err := validateConfigKey(key); err != nil {
if err := errors.Join(validateConfigKey(key), config.RefuseLegacyMCPSecretSet(key)); err != nil { // D-0737
return mcpErrorResult("Error: %v", err)
}
if err := validateConfigValue(value); err != nil {
Expand All @@ -97,9 +99,7 @@ func mcpConfigSetHandler() server.ToolHandlerFunc {
if err != nil {
return mcpErrorResult("Error: %v", err)
}
return mcp.NewToolResultStructuredOnly(ConfigSetResult{
Key: key, File: envFile, Created: !updated,
}), nil
return mcp.NewToolResultStructuredOnly(ConfigSetResult{Key: key, File: envFile, Created: !updated}), nil
}
}

Expand Down
78 changes: 78 additions & 0 deletions cmd/commands/mcp_tools_mutate_secret_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package commands

// Purpose: proves the v1.4 legacy nself_config_set tool refuses secret keys
// (D-0737) and still writes ordinary keys; in v1.5 the generated gated tool
// replaces it, so the legacy guard does not apply there.
// Inputs: synthetic keys and a temp project. Outputs: pass/fail.
// Constraints: no network, temp dirs only.

import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/mark3labs/mcp-go/mcp"

"github.com/nself-org/cli/internal/compat/compattest"
)

func TestMCPConfigSetRefusesSecretKeyInLegacyMode(t *testing.T) {
compattest.Set(t, false)
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
if err := os.WriteFile(envPath, []byte("PROJECT_NAME=demo\n"), 0o600); err != nil {
t.Fatal(err)
}
defer chdir(t, dir)()

for _, key := range []string{"POSTGRES_PASSWORD", "HASURA_GRAPHQL_ADMIN_SECRET", "S3_SECRET_KEY", "BASIC_AUTH"} {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{"key": key, "value": "synthetic-value"}
res, err := mcpConfigSetHandler()(context.Background(), req)
if err != nil {
t.Fatal(err)
}
text := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "requires the v1.5 gated tool") {
t.Errorf("%s: want a secret-key refusal, got %q", key, text)
}
}
got, _ := os.ReadFile(envPath)
if string(got) != "PROJECT_NAME=demo\n" {
t.Errorf(".env changed by a refused call: %q", got)
}

// A non-secret key still passes through.
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{"key": "ABC", "value": "v"}
if res, err := mcpConfigSetHandler()(context.Background(), req); err != nil || !isConfigSetOK(res) {
t.Fatalf("non-secret key refused: %v %#v", err, res)
}
}

func TestMCPConfigSetSecretGuardIsLegacyOnly(t *testing.T) {
compattest.Set(t, true)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("PROJECT_NAME=demo\n"), 0o600); err != nil {
t.Fatal(err)
}
defer chdir(t, dir)()

req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{"key": "POSTGRES_PASSWORD", "value": "synthetic-value"}
res, err := mcpConfigSetHandler()(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if !isConfigSetOK(res) {
t.Errorf("v1.5 hit the legacy guard: %#v", res)
}
}

// isConfigSetOK reports whether the handler took the success path.
func isConfigSetOK(res *mcp.CallToolResult) bool {
_, ok := res.StructuredContent.(ConfigSetResult)
return ok
}
7 changes: 6 additions & 1 deletion cmd/commands/migrate_supabase_v15_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"regexp"
"strings"
"testing"

Expand All @@ -15,6 +16,10 @@ import (
"github.com/spf13/pflag"
)

// generatedStamp matches the one-second-resolution timestamp the generator embeds in
// supabase-migration.sql; two Generate calls straddling a second boundary differ only there.
var generatedStamp = regexp.MustCompile(`(?m)^-- Generated: \d+$`)

func resetSupabaseFlags(t *testing.T) {
t.Helper()
migrateSupabaseCmd.Flags().VisitAll(func(f *pflag.Flag) {
Expand Down Expand Up @@ -91,7 +96,7 @@ func TestMigrateSupabaseModes(t *testing.T) {
want := spmigrate.Generate("abcdefghijklmnopqrst", &spmigrate.PullResult{Tables: []spmigrate.Table{{Schema: "public", Name: "notes"}}})
for name, content := range map[string]string{"supabase-migration.sql": want.MigrationSQL, "hasura-metadata.yaml": want.HasuraMetadata, "import-auth-users.sh": want.AuthImportScript} {
got, rerr := os.ReadFile(filepath.Join(out, name))
if rerr != nil || string(got) != content {
if rerr != nil || generatedStamp.ReplaceAllString(string(got), "-- Generated: <ts>") != generatedStamp.ReplaceAllString(content, "-- Generated: <ts>") {
t.Fatalf("v1.4 artifact %s differs from the generator output (err=%v)", name, rerr)
}
}
Expand Down
15 changes: 15 additions & 0 deletions internal/config/secretkeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ package config
// side: a match only redacts. internal/env must not keep a second list.

import (
"fmt"
"strings"

"github.com/nself-org/cli/internal/compat"
"github.com/nself-org/cli/internal/credmask"
)

Expand All @@ -33,6 +35,19 @@ func IsSecretKey(key string) bool {
return upper == "AUTH" || strings.HasSuffix(upper, "_AUTH")
}

// RefuseLegacyMCPSecretSet is the D-0737 guard of the v1.4 nself_config_set
// MCP tool, which skips the invoke.Gate round trip of the generated v1.5 tool:
// in v1.4 mode it refuses secret keys so a token-less loopback client cannot
// write POSTGRES_PASSWORD or any other secret unconfirmed. Nil in v1.5 mode,
// where the generated gated tool replaces the legacy one (P7-SURF-03).
func RefuseLegacyMCPSecretSet(key string) error {
// compat.V15(D-0737): legacy MCP nself_config_set refuses secret keys -> the generated gated v1.5 tool handles them
if compat.V15() || !IsSecretKey(key) {
return nil
}
return fmt.Errorf("setting secret key %q requires the v1.5 gated tool; upgrade or use `nself config set` interactively", key)
}

// MaskURLCredentials masks the credentials of a value that holds a URL, a Go
// DSN, a bare user:pass@host or password=/pwd= pairs. The rules and their
// bounds are documented on credmask.URL.
Expand Down
5 changes: 4 additions & 1 deletion internal/docker/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ func (c *Compose) ComposeDown(ctx context.Context, workdir string, opts DownOpti
if opts.Timeout > 0 {
args = append(args, "-t", fmt.Sprintf("%d", opts.Timeout))
}
return c.Run(ctx, workdir, args...)
if err := c.Run(ctx, workdir, args...); err != nil || !opts.RemoveVolumes || VolumesRemovedHook == nil {
return err
}
return VolumesRemovedHook(ctx, workdir)
}

// ComposeUpNoDeps runs `docker compose up -d --no-deps [services...]`.
Expand Down
8 changes: 8 additions & 0 deletions internal/docker/compose_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,11 @@ func RunPreDownHooks(ctx context.Context, workdir string) (release func()) {
}
}
}

// VolumesRemovedHook, when set at init, runs after a successful
// `docker compose down -v` in workdir and its error fails ComposeDown. The
// postgres image switch sets it (internal/postgres/imageswitch cannot be
// imported from here) to remove the old data volume a switch kept, which the
// current compose file no longer names, so reset, stop --volumes and
// uninstall --purge really wipe the project's data (D-0802).
var VolumesRemovedHook func(ctx context.Context, workdir string) error
38 changes: 38 additions & 0 deletions internal/docker/compose_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package docker

import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -81,3 +82,40 @@ func TestComposeDownRemoveImages(t *testing.T) {
t.Errorf("args = %q, want %q", got, want)
}
}

func TestVolumesRemovedHook(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake docker is a POSIX shell script")
}
dir := t.TempDir()
fail := filepath.Join(dir, "fail")
script := "#!/bin/sh\n[ -e '" + fail + "' ] && exit 1\nexit 0\n"
bin := filepath.Join(dir, "docker")
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
c := &Compose{DockerPath: bin}
var calls []string
hookErr := error(nil)
old := VolumesRemovedHook
VolumesRemovedHook = func(_ context.Context, workdir string) error { calls = append(calls, workdir); return hookErr }
t.Cleanup(func() { VolumesRemovedHook = old })

if err := c.ComposeDown(context.Background(), dir, DownOptions{}); err != nil || len(calls) != 0 {
t.Fatalf("down without -v: err %v calls %v", err, calls)
}
if err := c.ComposeDown(context.Background(), dir, DownOptions{RemoveVolumes: true}); err != nil || !slices.Equal(calls, []string{dir}) {
t.Fatalf("down -v: err %v calls %v", err, calls)
}
hookErr = errors.New("volume is in use")
if err := c.ComposeDown(context.Background(), dir, DownOptions{RemoveVolumes: true}); err == nil || !strings.Contains(err.Error(), "in use") {
t.Fatalf("a hook failure must fail the down, got %v", err)
}
calls = nil
if err := os.WriteFile(fail, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := c.ComposeDown(context.Background(), dir, DownOptions{RemoveVolumes: true}); err == nil || len(calls) != 0 {
t.Fatalf("hook ran after a failed down: err %v calls %v", err, calls)
}
}
27 changes: 27 additions & 0 deletions internal/docker/volume.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,30 @@ func VolumeLabels(ctx context.Context, name string) (labels map[string]string, e
}
return labels, true, nil
}

// VolumeListByLabels returns the names of the volumes that carry every given
// label filter ("key" or "key=value"; docker ANDs repeated filters). An empty
// result is not an error. Docker daemon unavailability is returned as an error
// so callers can distinguish "no matches" from "cannot list". With no filters
// it returns nothing, never every volume on the host.
func VolumeListByLabels(ctx context.Context, labels ...string) ([]string, error) {
if len(labels) == 0 {
return nil, nil
}
args := []string{"volume", "ls"}
for _, l := range labels {
args = append(args, "--filter", "label="+l)
}
out, err := runCapture(ctx, append(args, "--format", "{{.Name}}")...)
if err != nil {
return nil, err
}
var names []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line != "" {
names = append(names, line)
}
}
return names, nil
}
Loading
Loading