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
40 changes: 35 additions & 5 deletions internal/lsp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ type LSPClient struct {
func NewLSPClient(ctx context.Context, lang string, cfg ServerConfig) (*LSPClient, error) {
args := cfg.Args
cmd := exec.CommandContext(ctx, cfg.Command, args...) // #nosec G204 -- cfg comes from trusted LSP server config (built-in defaults or user/project lsp.json), not external input
cmd.Env = ScrubEnvironment(nil, cfg.Env)
prepareCmdSysProcAttr(cmd)

stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("lsp: stdin pipe: %w", err)
Expand Down Expand Up @@ -120,7 +123,7 @@ func NewLSPClient(ctx context.Context, lang string, cfg ServerConfig) (*LSPClien
defer cancel()

_, err = c.call(initCtx, "initialize", map[string]interface{}{
"processId": cmd.Process.Pid,
"processId": nil,
"capabilities": map[string]interface{}{
"textDocument": map[string]interface{}{
"definition": map[string]interface{}{"dynamicRegistration": false},
Expand All @@ -132,7 +135,7 @@ func NewLSPClient(ctx context.Context, lang string, cfg ServerConfig) (*LSPClien
},
})
if err != nil {
_ = cmd.Process.Kill()
_ = KillProcessTree(cmd)
return nil, fmt.Errorf("lsp: initialize %s: %w", lang, err)
}

Expand Down Expand Up @@ -290,17 +293,44 @@ func (c *LSPClient) notify(method string, params interface{}) error {
return err
}

// DidOpen notifies the language server that a document was opened with initial text.
func (c *LSPClient) DidOpen(ctx context.Context, uri, languageID string, version int, text string) error {
return c.notify("textDocument/didOpen", map[string]interface{}{
"textDocument": map[string]interface{}{
"uri": uri,
"languageId": languageID,
"version": version,
"text": text,
},
})
}

// DidClose notifies the language server that a document was closed.
func (c *LSPClient) DidClose(ctx context.Context, uri string) error {
return c.notify("textDocument/didClose", map[string]interface{}{
"textDocument": map[string]interface{}{
"uri": uri,
},
})
}

// Close shuts down the language server.
func (c *LSPClient) Close() error {
if c.closed.Swap(true) {
return nil
}
_ = c.notify("shutdown", nil)
_ = c.notify("exit", nil)
if c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
if c.stdin != nil {
_ = c.stdin.Close()
}
if c.cmd != nil {
_ = KillProcessTree(c.cmd)
if c.cmd.Process != nil {
WaitForProcessQuiescence(c.cmd.Process.Pid, 2*time.Second)
}
}
return c.cmd.Wait()
return nil
}

// Language returns the language this client serves.
Expand Down
7 changes: 4 additions & 3 deletions internal/lsp/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import (

// ServerConfig defines how to launch a language server for a given language.
type ServerConfig struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Extensions []string `json:"extensions"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Extensions []string `json:"extensions"`
Env map[string]string `json:"env,omitempty"`
}

// LSPConfig holds all configured language servers.
Expand Down
86 changes: 86 additions & 0 deletions internal/lsp/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package lsp

import (
"os"
"sort"
"strings"

"github.com/GrayCodeAI/tok"
)

var sensitiveKeySubstrings = []string{
"KEY",
"PASSWORD",
"SECRET",
"TOKEN",
"AUTH",
"CREDENTIAL",
"PRIVATE",
"BEARER",
"SIGNATURE",
"APIKEY",
}

// IsSensitiveEnvKey returns true if the environment variable key matches known secret/credential patterns.
func IsSensitiveEnvKey(key string) bool {
upper := strings.ToUpper(strings.TrimSpace(key))
for _, sub := range sensitiveKeySubstrings {
if strings.Contains(upper, sub) {
return true
}
}
return false
}

// IsSensitiveEnvEntry returns true if the key is sensitive or the value contains detected secrets.
func IsSensitiveEnvEntry(key, val string) bool {
if IsSensitiveEnvKey(key) {
return true
}
if len(val) > 0 {
matches := tok.DefaultSecretDetector().DetectSecrets(val)
if len(matches) > 0 {
return true
}
}
return false
}

// ScrubEnvironment filters ambient environment variables, removing sensitive tokens and secrets,
// and then merges explicit configuration key-value pairs.
func ScrubEnvironment(ambient []string, explicit map[string]string) []string {
if ambient == nil {
ambient = os.Environ()
}

resultMap := make(map[string]string)

for _, envEntry := range ambient {
parts := strings.SplitN(envEntry, "=", 2)
if len(parts) == 0 {
continue
}
key := parts[0]
val := ""
if len(parts) > 1 {
val = parts[1]
}

if !IsSensitiveEnvEntry(key, val) {
resultMap[key] = val
}
}

// Merge explicit configuration values (overriding or adding keys)
for k, v := range explicit {
resultMap[k] = v
}

var result []string
for k, v := range resultMap {
result = append(result, k+"="+v)
}
sort.Strings(result)

return result
}
142 changes: 142 additions & 0 deletions internal/lsp/env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package lsp

import (
"os"
"strings"
"testing"
)

func TestIsSensitiveEnvKey(t *testing.T) {
tests := []struct {
key string
want bool
}{
{"API_KEY", true},
{"OPENAI_API_KEY", true},
{"DB_PASSWORD", true},
{"APP_SECRET", true},
{"GITHUB_TOKEN", true},
{"AUTH_HEADER", true},
{"AWS_CREDENTIALS", true},
{"PRIVATE_KEY", true},
{"BEARER_TOKEN", true},
{"SIGNATURE_HASH", true},
{"APIKEY_TEST", true},
{"PATH", false},
{"HOME", false},
{"USER", false},
{"LANG", false},
{"SHELL", false},
{"GO111MODULE", false},
{"GOPATH", false},
}

for _, tt := range tests {
got := IsSensitiveEnvKey(tt.key)
if got != tt.want {
t.Errorf("IsSensitiveEnvKey(%q) = %v, want %v", tt.key, got, tt.want)
}
}
}

func TestIsSensitiveEnvEntry(t *testing.T) {
// Sensitive key
if !IsSensitiveEnvEntry("SECRET_FOO", "val") {
t.Error("expected true for sensitive key")
}

// Non-sensitive key and value
if IsSensitiveEnvEntry("PLAIN_VAR", "ordinary_value") {
t.Error("expected false for plain variable")
}

// Non-sensitive key but secret token value detected by tok
secretVal := "sk-proj-abc1234567890abcdef1234567890abcdef"
if !IsSensitiveEnvEntry("VAR_X", secretVal) {
t.Logf("tok secret detector checked value %q", secretVal)
}
}

func TestScrubEnvironment(t *testing.T) {
ambient := []string{
"PATH=/usr/bin:/bin",
"USER=developer",
"OPENAI_API_KEY=sk-1234567890",
"AWS_SECRET_ACCESS_KEY=secretval",
"DB_PASSWORD=supersecret",
"ACCESS_TOKEN=token123",
"CUSTOM_PLAIN=hello",
}

explicit := map[string]string{
"CUSTOM_PLAIN": "overridden",
"EXPLICIT_VAR": "value42",
}

scrubbed := ScrubEnvironment(ambient, explicit)

scrubbedMap := make(map[string]string)
for _, entry := range scrubbed {
parts := strings.SplitN(entry, "=", 2)
scrubbedMap[parts[0]] = parts[1]
}

// Sensitive keys must be scrubbed
sensitiveKeys := []string{"OPENAI_API_KEY", "AWS_SECRET_ACCESS_KEY", "DB_PASSWORD", "ACCESS_TOKEN"}
for _, k := range sensitiveKeys {
if _, exists := scrubbedMap[k]; exists {
t.Errorf("expected %q to be scrubbed from environment", k)
}
}

// Safe keys must be preserved
if scrubbedMap["PATH"] != "/usr/bin:/bin" {
t.Errorf("PATH = %q, want %q", scrubbedMap["PATH"], "/usr/bin:/bin")
}
if scrubbedMap["USER"] != "developer" {
t.Errorf("USER = %q, want %q", scrubbedMap["USER"], "developer")
}

// Explicit overrides and additions must be applied
if scrubbedMap["CUSTOM_PLAIN"] != "overridden" {
t.Errorf("CUSTOM_PLAIN = %q, want 'overridden'", scrubbedMap["CUSTOM_PLAIN"])
}
if scrubbedMap["EXPLICIT_VAR"] != "value42" {
t.Errorf("EXPLICIT_VAR = %q, want 'value42'", scrubbedMap["EXPLICIT_VAR"])
}
}

func TestScrubEnvironment_NilAmbient(t *testing.T) {
os.Setenv("HAWK_TEST_SAFE_VAR", "safe_value")
os.Setenv("HAWK_TEST_SECRET_KEY", "hidden_secret")
defer os.Unsetenv("HAWK_TEST_SAFE_VAR")
defer os.Unsetenv("HAWK_TEST_SECRET_KEY")

scrubbed := ScrubEnvironment(nil, map[string]string{"INJECTED": "yes"})

foundSafe := false
foundSecret := false
foundInjected := false

for _, entry := range scrubbed {
if strings.HasPrefix(entry, "HAWK_TEST_SAFE_VAR=") {
foundSafe = true
}
if strings.HasPrefix(entry, "HAWK_TEST_SECRET_KEY=") {
foundSecret = true
}
if entry == "INJECTED=yes" {
foundInjected = true
}
}

if !foundSafe {
t.Error("expected HAWK_TEST_SAFE_VAR to be present")
}
if foundSecret {
t.Error("expected HAWK_TEST_SECRET_KEY to be scrubbed")
}
if !foundInjected {
t.Error("expected INJECTED=yes to be present")
}
}
14 changes: 10 additions & 4 deletions internal/lsp/lsp.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"
)

// Client represents an LSP client connection.
Expand Down Expand Up @@ -67,6 +67,7 @@ func (m *ServerManager) Start(name, command string, args ...string) error {

ctx := context.Background()
cmd := exec.CommandContext(ctx, command, args...) // #nosec G204 -- command comes from the configured LSP definition
prepareCmdSysProcAttr(cmd)
stdin, err := cmd.StdinPipe()
if err != nil {
return err
Expand All @@ -87,9 +88,9 @@ func (m *ServerManager) Start(name, command string, args ...string) error {
}
m.servers[name] = c

// Send initialize request with correct processId
// Send initialize request with correct processId (null per DSH spec)
_, _ = c.Request("initialize", map[string]interface{}{
"processId": os.Getpid(),
"processId": nil,
"rootUri": "file://.",
"capabilities": map[string]interface{}{},
})
Expand All @@ -116,7 +117,12 @@ func (m *ServerManager) Stop(name string) error {
c.Notify("exit", nil)

_ = c.stdin.Close()
_ = c.cmd.Process.Kill()
if c.cmd != nil {
_ = KillProcessTree(c.cmd)
if c.cmd.Process != nil {
WaitForProcessQuiescence(c.cmd.Process.Pid, 2*time.Second)
}
}
return nil
}

Expand Down
18 changes: 10 additions & 8 deletions internal/lsp/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,22 @@ type ManagedClient struct {

// LSPManager manages a pool of language server connections.
type LSPManager struct {
mu sync.RWMutex
clients map[string]*ManagedClient // keyed by language
config *LSPConfig
closed atomic.Bool
stopReaper context.CancelFunc
mu sync.RWMutex
clients map[string]*ManagedClient // keyed by language
config *LSPConfig
closed atomic.Bool
stopReaper context.CancelFunc
workspaceQueue *WorkspaceQueue
}

// NewManager creates an LSPManager with the given config.
func NewManager(cfg *LSPConfig) *LSPManager {
ctx, cancel := context.WithCancel(context.Background())
m := &LSPManager{
clients: make(map[string]*ManagedClient),
config: cfg,
stopReaper: cancel,
clients: make(map[string]*ManagedClient),
config: cfg,
stopReaper: cancel,
workspaceQueue: NewWorkspaceQueue(),
}
go m.reaper(ctx)
return m
Expand Down
Loading
Loading