diff --git a/docs/SPEC.md b/docs/SPEC.md index c385f9f..3283d79 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -449,6 +449,7 @@ color = "auto" [daemon] enabled = true idle_unload_after = "30m" +host = "127.0.0.1" # loopback only; remote hosts are rejected [cache] enabled = true @@ -469,6 +470,8 @@ model = "gpt-4o-mini" Project-level `.intentrc` (TOML) overrides global config for invocations whose cwd is at or below the file's directory. +For `llamafile-local`, the daemon-managed model HTTP endpoint is loopback-only by contract. `daemon.host` may be omitted or set to a loopback value such as `127.0.0.1`, `localhost`, or `::1`; non-loopback hosts are rejected. + ## 9. Update channel - `stable`: tagged releases of the form `vMAJOR.MINOR.PATCH`. diff --git a/internal/cli/backend.go b/internal/cli/backend.go index 1234331..cc27bc6 100644 --- a/internal/cli/backend.go +++ b/internal/cli/backend.go @@ -38,13 +38,9 @@ func buildBackend(name string, cfg *config.Config, modelOverride string) (model. // fall back to the mock backend so `i hello` doesn't hard-fail // for a brand-new install — instead the mock returns an honest // "the local model isn't installed yet" response. - host := cfg.Raw["daemon.host"] - if host == "" { - host = "127.0.0.1" - } - port := cfg.Raw["daemon.port"] - if port == "" { - port = "18080" + host, port, err := resolveLocalDaemonEndpoint(cfg) + if err != nil { + return nil, false, err } endpoint := fmt.Sprintf("http://%s:%s", host, port) if !endpointReachable(endpoint) { diff --git a/internal/cli/backend_test.go b/internal/cli/backend_test.go index 984c6a7..b58f9b0 100644 --- a/internal/cli/backend_test.go +++ b/internal/cli/backend_test.go @@ -59,6 +59,23 @@ func TestBuildBackend_LlamafileLocalFallsBackWhenUnreachable(t *testing.T) { } } +func TestBuildBackend_LlamafileLocalRejectsNonLoopbackHost(t *testing.T) { + clearBackendEnv(t) + cfg := minimalConfig() + cfg.Raw["daemon.host"] = "0.0.0.0" + + _, isFallback, err := buildBackend("llamafile-local", cfg, "") + if err == nil { + t.Fatal("expected error for non-loopback daemon host, got nil") + } + if isFallback { + t.Fatal("invalid daemon host should not silently fall back to mock") + } + if !strings.Contains(err.Error(), "loopback only") { + t.Fatalf("error = %q, want loopback hint", err) + } +} + func TestBuildBackend_UnknownBackendErrors(t *testing.T) { clearBackendEnv(t) _, _, err := buildBackend("nonexistent", minimalConfig(), "") diff --git a/internal/cli/config.go b/internal/cli/config.go index f85b887..078ac55 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -63,6 +63,10 @@ func cmdConfig(_ context.Context, args []string) int { errf("usage: i config set ") return 1 } + if err := validateConfigValue(args[1], args[2]); err != nil { + errf("config: %v", err) + return 1 + } cfg, err := config.Load(dirs.ConfigPath()) if err != nil { errf("config: %v", err) @@ -81,6 +85,16 @@ func cmdConfig(_ context.Context, args []string) int { } } +func validateConfigValue(key, value string) error { + switch key { + case "daemon.host": + _, err := normalizeLocalDaemonHost(value) + return err + default: + return nil + } +} + func lookupKnown(c *config.Config, key string) string { switch key { case "backend": diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index ae1d519..1b4da4c 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -153,6 +153,11 @@ func daemonRunForeground(ctx context.Context, dirs state.Dirs, cfg *config.Confi if id == "" { id = models.DefaultID } + host, port, err := resolveLocalDaemonEndpoint(cfg) + if err != nil { + errf("daemon: %v", err) + return 1 + } m := cat.Get(id) if m == nil { errf("daemon: current model %q not in catalog; run `i model list` and `i model use `", id) @@ -178,16 +183,8 @@ func daemonRunForeground(ctx context.Context, dirs state.Dirs, cfg *config.Confi } defer logF.Close() - port := cfg.Raw["daemon.port"] - if port == "" { - port = "18080" - } portNum := 18080 fmt.Sscanf(port, "%d", &portNum) - host := cfg.Raw["daemon.host"] - if host == "" { - host = "127.0.0.1" - } launcher := daemon.NewLauncher(mgr.LlamafilePath(), modelPath, host, portNum) launcher.StdoutLog = logF diff --git a/internal/cli/daemon_host.go b/internal/cli/daemon_host.go new file mode 100644 index 0000000..871faae --- /dev/null +++ b/internal/cli/daemon_host.go @@ -0,0 +1,57 @@ +package cli + +import ( + "fmt" + "net" + "strings" + + "github.com/CoreyRDean/intent/internal/config" +) + +const defaultLocalDaemonHost = "127.0.0.1" +const defaultLocalDaemonPort = "18080" + +// normalizeLocalDaemonHost accepts only loopback hosts for the local daemon. +// Any accepted value is canonicalized to 127.0.0.1 so the local backend never +// accidentally exposes the model server on a broader interface. +func normalizeLocalDaemonHost(raw string) (string, error) { + host := strings.TrimSpace(raw) + if host == "" { + return defaultLocalDaemonHost, nil + } + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") + } + if strings.EqualFold(host, "localhost") { + return defaultLocalDaemonHost, nil + } + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + return defaultLocalDaemonHost, nil + } + return "", fmt.Errorf("daemon.host %q must resolve to loopback only", strings.TrimSpace(raw)) +} + +func resolveLocalDaemonHost(cfg *config.Config) (string, error) { + if cfg == nil { + return normalizeLocalDaemonHost("") + } + return normalizeLocalDaemonHost(cfg.Raw["daemon.host"]) +} + +func resolveLocalDaemonPort(cfg *config.Config) string { + if cfg == nil { + return defaultLocalDaemonPort + } + if port := strings.TrimSpace(cfg.Raw["daemon.port"]); port != "" { + return port + } + return defaultLocalDaemonPort +} + +func resolveLocalDaemonEndpoint(cfg *config.Config) (host, port string, err error) { + host, err = resolveLocalDaemonHost(cfg) + if err != nil { + return "", "", err + } + return host, resolveLocalDaemonPort(cfg), nil +} diff --git a/internal/cli/daemon_host_test.go b/internal/cli/daemon_host_test.go new file mode 100644 index 0000000..22d7ade --- /dev/null +++ b/internal/cli/daemon_host_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/CoreyRDean/intent/internal/config" +) + +func TestNormalizeLocalDaemonHost(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr string + }{ + {name: "default empty host", raw: "", want: "127.0.0.1"}, + {name: "localhost", raw: "localhost", want: "127.0.0.1"}, + {name: "ipv4 loopback", raw: "127.0.0.1", want: "127.0.0.1"}, + {name: "ipv6 loopback", raw: "::1", want: "127.0.0.1"}, + {name: "bracketed ipv6 loopback", raw: "[::1]", want: "127.0.0.1"}, + {name: "non-loopback wildcard rejected", raw: "0.0.0.0", wantErr: "loopback only"}, + {name: "non-loopback ip rejected", raw: "192.168.1.10", wantErr: "loopback only"}, + {name: "hostname rejected", raw: "example.com", wantErr: "loopback only"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeLocalDaemonHost(tt.raw) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want substring %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("host = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveLocalDaemonEndpoint(t *testing.T) { + cfg := &config.Config{Raw: map[string]string{ + "daemon.host": " localhost ", + "daemon.port": " 19090 ", + }} + + host, port, err := resolveLocalDaemonEndpoint(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != "127.0.0.1" { + t.Fatalf("host = %q, want %q", host, "127.0.0.1") + } + if port != "19090" { + t.Fatalf("port = %q, want %q", port, "19090") + } +} + +func TestValidateConfigValueRejectsRemoteDaemonHost(t *testing.T) { + err := validateConfigValue("daemon.host", "0.0.0.0") + if err == nil { + t.Fatal("expected daemon.host validation error, got nil") + } + if !strings.Contains(err.Error(), "loopback only") { + t.Fatalf("error = %q, want loopback hint", err) + } +} diff --git a/internal/cli/smoke_test.go b/internal/cli/smoke_test.go index 19b9c0c..9d64025 100644 --- a/internal/cli/smoke_test.go +++ b/internal/cli/smoke_test.go @@ -298,6 +298,27 @@ func TestConfigRoundTrip(t *testing.T) { } } +func TestConfigSetRejectsRemoteDaemonHost(t *testing.T) { + stateDir := t.TempDir() + cacheDir := t.TempDir() + baseEnv := []string{ + "HOME=" + os.Getenv("HOME"), + "PATH=" + os.Getenv("PATH"), + "INTENT_STATE_DIR=" + stateDir, + "INTENT_CACHE_DIR=" + cacheDir, + } + + cmd := exec.Command(testBinary, "config", "set", "daemon.host", "0.0.0.0") + cmd.Env = baseEnv + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatal("expected config set daemon.host to fail, got nil error") + } + if !strings.Contains(string(out), "loopback only") { + t.Fatalf("expected loopback validation error, got %q", string(out)) + } +} + func TestConfigPath(t *testing.T) { stdout, _, exitCode := run(t, nil, "config", "path") if exitCode != 0 {