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
21 changes: 2 additions & 19 deletions internal/cloudflare/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ func (h *ConversationHistory) Save() error {
return fmt.Errorf("failed to create conversation directory: %w", err)
}

filename := filepath.Join(dir, fmt.Sprintf("cloudflare_%s.json", sanitizeFilename(h.AccountID)))
filename := filepath.Join(dir, fmt.Sprintf("cloudflare_%s.json", secfile.SafeSlug(h.AccountID)))
data, err := json.MarshalIndent(h, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal conversation history: %w", err)
Expand All @@ -180,7 +180,7 @@ func (h *ConversationHistory) Load() error {
return err
}

filename := filepath.Join(dir, fmt.Sprintf("cloudflare_%s.json", sanitizeFilename(h.AccountID)))
filename := filepath.Join(dir, fmt.Sprintf("cloudflare_%s.json", secfile.SafeSlug(h.AccountID)))
data, err := secfile.ReadPrivate(filename)
if err != nil {
if os.IsNotExist(err) {
Expand Down Expand Up @@ -217,23 +217,6 @@ func getConversationDir() (string, error) {
return filepath.Join(homeDir, ".clanker", "conversations"), nil
}

// sanitizeFilename replaces characters that are invalid in filenames
func sanitizeFilename(s string) string {
replacer := strings.NewReplacer(
"/", "_",
"\\", "_",
":", "_",
"*", "_",
"?", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
" ", "_",
)
return replacer.Replace(s)
}

// truncateText truncates text to maxLen characters, adding ellipsis if truncated
func truncateText(text string, maxLen int) string {
if len(text) <= maxLen {
Expand Down
19 changes: 1 addition & 18 deletions internal/flyio/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func (h *ConversationHistory) filePath() (string, error) {
if err != nil {
return "", err
}
return filepath.Join(dir, fmt.Sprintf("flyio_%s.json", sanitizeID(h.OrgSlug))), nil
return filepath.Join(dir, fmt.Sprintf("flyio_%s.json", secfile.SafeSlug(h.OrgSlug))), nil
}

// conversationDir returns ~/.clanker/conversations.
Expand All @@ -183,23 +183,6 @@ func conversationDir() (string, error) {
return filepath.Join(home, ".clanker", "conversations"), nil
}

// sanitizeID replaces characters that are invalid in filenames.
func sanitizeID(s string) string {
replacer := strings.NewReplacer(
"/", "_",
"\\", "_",
":", "_",
"*", "_",
"?", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
" ", "_",
)
return replacer.Replace(s)
}

// truncateAnswer truncates text to maxLen characters, adding ellipsis if truncated.
func truncateAnswer(text string, maxLen int) string {
if len(text) <= maxLen {
Expand Down
9 changes: 0 additions & 9 deletions internal/flyio/conversation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,6 @@ func TestSaveLoadRoundTrip(t *testing.T) {
}
}

func TestSanitizeID(t *testing.T) {
if got := sanitizeID("my/org name"); got != "my_org_name" {
t.Errorf("sanitizeID = %q, want my_org_name", got)
}
if got := sanitizeID("with:colon|pipe"); got != "with_colon_pipe" {
t.Errorf("sanitizeID = %q, want with_colon_pipe", got)
}
}

func TestTruncateAnswer(t *testing.T) {
if got := truncateAnswer("hello", 100); got != "hello" {
t.Errorf("short answer should pass through, got %q", got)
Expand Down
21 changes: 2 additions & 19 deletions internal/iam/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func (h *ConversationHistory) Save() error {
return fmt.Errorf("failed to create conversation directory: %w", err)
}

filename := filepath.Join(dir, fmt.Sprintf("iam_%s.json", sanitizeFilename(h.AccountID)))
filename := filepath.Join(dir, fmt.Sprintf("iam_%s.json", secfile.SafeSlug(h.AccountID)))
data, err := json.MarshalIndent(h, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal conversation history: %w", err)
Expand All @@ -175,7 +175,7 @@ func (h *ConversationHistory) Load() error {
return err
}

filename := filepath.Join(dir, fmt.Sprintf("iam_%s.json", sanitizeFilename(h.AccountID)))
filename := filepath.Join(dir, fmt.Sprintf("iam_%s.json", secfile.SafeSlug(h.AccountID)))
data, err := secfile.ReadPrivate(filename)
if err != nil {
if os.IsNotExist(err) {
Expand Down Expand Up @@ -212,23 +212,6 @@ func getConversationDir() (string, error) {
return filepath.Join(homeDir, ".clanker", "conversations"), nil
}

// sanitizeFilename replaces characters that are invalid in filenames
func sanitizeFilename(s string) string {
replacer := strings.NewReplacer(
"/", "_",
"\\", "_",
":", "_",
"*", "_",
"?", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
" ", "_",
)
return replacer.Replace(s)
}

// truncateText truncates text to maxLen characters, adding ellipsis if truncated
func truncateText(text string, maxLen int) string {
if len(text) <= maxLen {
Expand Down
21 changes: 2 additions & 19 deletions internal/k8s/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ func (h *ConversationHistory) Save() error {
return fmt.Errorf("failed to create conversation directory: %w", err)
}

filename := filepath.Join(dir, fmt.Sprintf("k8s_%s.json", sanitizeFilename(h.ClusterName)))
filename := filepath.Join(dir, fmt.Sprintf("k8s_%s.json", secfile.SafeSlug(h.ClusterName)))
data, err := json.MarshalIndent(h, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal conversation history: %w", err)
Expand All @@ -206,7 +206,7 @@ func (h *ConversationHistory) Load() error {
return err
}

filename := filepath.Join(dir, fmt.Sprintf("k8s_%s.json", sanitizeFilename(h.ClusterName)))
filename := filepath.Join(dir, fmt.Sprintf("k8s_%s.json", secfile.SafeSlug(h.ClusterName)))
data, err := secfile.ReadPrivate(filename)
if err != nil {
if os.IsNotExist(err) {
Expand Down Expand Up @@ -243,23 +243,6 @@ func getConversationDir() (string, error) {
return filepath.Join(homeDir, ".clanker", "conversations"), nil
}

// sanitizeFilename replaces characters that are invalid in filenames
func sanitizeFilename(s string) string {
replacer := strings.NewReplacer(
"/", "_",
"\\", "_",
":", "_",
"*", "_",
"?", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
" ", "_",
)
return replacer.Replace(s)
}

// truncateText truncates text to maxLen characters, adding ellipsis if truncated
func truncateText(text string, maxLen int) string {
if len(text) <= maxLen {
Expand Down
25 changes: 1 addition & 24 deletions internal/linear/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,29 +102,6 @@ func (h *ConversationHistory) GetAccountStatusContext() string {
)
}

// safeSlug strips anything outside [A-Za-z0-9_-] so a malicious workspaceID
// (e.g. "../../etc/passwd") can't escape the ~/.clanker directory when
// filepath.Join resolves the path. Linear workspace IDs are UUIDs so this
// is paranoia for the env-var/header case where an operator could pass
// an arbitrary string.
func safeSlug(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z',
c >= 'A' && c <= 'Z',
c >= '0' && c <= '9',
c == '-' || c == '_':
out = append(out, c)
}
}
if len(out) == 0 {
return "default"
}
return string(out)
}

func historyPath(workspaceID string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
Expand All @@ -134,7 +111,7 @@ func historyPath(workspaceID string) (string, error) {
if err := secfile.EnsurePrivateDir(dir); err != nil {
return "", err
}
return filepath.Join(dir, fmt.Sprintf("linear-%s.json", safeSlug(workspaceID))), nil
return filepath.Join(dir, fmt.Sprintf("linear-%s.json", secfile.SafeSlug(workspaceID))), nil
}

func (h *ConversationHistory) Load() error {
Expand Down
19 changes: 0 additions & 19 deletions internal/linear/conversation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,6 @@ func TestConversationHistory_RoundTrip(t *testing.T) {
}
}

func TestSafeSlug_BlocksPathTraversal(t *testing.T) {
cases := []struct {
in, want string
}{
{"acme", "acme"},
{"my-workspace_42", "my-workspace_42"},
{"../../etc/passwd", "etcpasswd"},
{"/absolute/path", "absolutepath"},
{"", "default"},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
if got := safeSlug(c.in); got != c.want {
t.Errorf("safeSlug(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}

func TestConversationHistory_TruncateAnswer(t *testing.T) {
h := NewConversationHistory("ws-abc")
long := strings.Repeat("x", MaxAnswerLengthInContext*2)
Expand Down
23 changes: 1 addition & 22 deletions internal/notion/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,27 +103,6 @@ func (h *ConversationHistory) GetAccountStatusContext() string {
)
}

// safeSlug strips anything outside [A-Za-z0-9_-] so a malicious workspace
// name (e.g. "../../etc/passwd") cannot escape the ~/.clanker directory
// when filepath.Join resolves the path.
func safeSlug(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z',
c >= 'A' && c <= 'Z',
c >= '0' && c <= '9',
c == '-' || c == '_':
out = append(out, c)
}
}
if len(out) == 0 {
return "default"
}
return string(out)
}

func historyPath(workspaceName string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
Expand All @@ -133,7 +112,7 @@ func historyPath(workspaceName string) (string, error) {
if err := secfile.EnsurePrivateDir(dir); err != nil {
return "", err
}
return filepath.Join(dir, fmt.Sprintf("notion-%s.json", safeSlug(workspaceName))), nil
return filepath.Join(dir, fmt.Sprintf("notion-%s.json", secfile.SafeSlug(workspaceName))), nil
}

func (h *ConversationHistory) Load() error {
Expand Down
17 changes: 0 additions & 17 deletions internal/notion/conversation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,23 +59,6 @@ func TestConversationHistory_RoundTrip(t *testing.T) {
}
}

func TestSafeSlug_PathTraversalDefense(t *testing.T) {
cases := map[string]string{
"normal-workspace": "normal-workspace",
"My Workspace": "MyWorkspace",
"../../etc/passwd": "etcpasswd",
"with/slashes": "withslashes",
"!!!": "default",
"": "default",
"abc123_DEF": "abc123_DEF",
}
for in, want := range cases {
if got := safeSlug(in); got != want {
t.Errorf("safeSlug(%q) = %q, want %q", in, got, want)
}
}
}

func TestConversationHistory_TrimsToMaxEntries(t *testing.T) {
h := NewConversationHistory("ws")
for range MaxHistoryEntries + 5 {
Expand Down
26 changes: 2 additions & 24 deletions internal/railway/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func (h *ConversationHistory) Save() error {
return fmt.Errorf("failed to create conversation directory: %w", err)
}

filename := filepath.Join(dir, fmt.Sprintf("railway_%s.json", sanitizeID(workspaceID)))
filename := filepath.Join(dir, fmt.Sprintf("railway_%s.json", secfile.SafeSlug(workspaceID)))

// Atomic write: temp + rename.
tmp := filename + ".tmp"
Expand Down Expand Up @@ -171,7 +171,7 @@ func (h *ConversationHistory) filePath() (string, error) {
if err != nil {
return "", err
}
return filepath.Join(dir, fmt.Sprintf("railway_%s.json", sanitizeID(h.WorkspaceID))), nil
return filepath.Join(dir, fmt.Sprintf("railway_%s.json", secfile.SafeSlug(h.WorkspaceID))), nil
}

// conversationDir returns ~/.clanker/conversations.
Expand All @@ -183,28 +183,6 @@ func conversationDir() (string, error) {
return filepath.Join(home, ".clanker", "conversations"), nil
}

// sanitizeID replaces characters that are invalid in filenames. An empty
// input yields the default "personal" bucket so callers never produce a
// filename like "railway_.json".
func sanitizeID(s string) string {
if s == "" {
return "personal"
}
replacer := strings.NewReplacer(
"/", "_",
"\\", "_",
":", "_",
"*", "_",
"?", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
" ", "_",
)
return replacer.Replace(s)
}

// truncateAnswer truncates text to maxLen characters, adding an ellipsis
// when truncated.
func truncateAnswer(text string, maxLen int) string {
Expand Down
Loading
Loading