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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest
Expand Down
4 changes: 3 additions & 1 deletion pkg/api/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package api
import (
"fmt"
"strings"

"github.com/flanksource/captain/pkg/collections"
)

// Model identifies which LLM serves a request plus the per-request inference
Expand Down Expand Up @@ -120,7 +122,7 @@ func (m Model) Candidates() []Model {
m = m.ExpandCSV()
primary := m
primary.Fallbacks = nil
out := make([]Model, 0, 1+len(m.Fallbacks))
out := make([]Model, 0, collections.SafeAdd(1, len(m.Fallbacks)))
out = append(out, primary)
for _, fb := range m.Fallbacks {
fb.Fallbacks = nil
Expand Down
40 changes: 34 additions & 6 deletions pkg/cli/permission_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
Expand All @@ -17,19 +18,46 @@ func handlePermissionCatalog(baseCwd string) http.HandlerFunc {
if dir == "" {
dir = strings.TrimSpace(r.URL.Query().Get("cwd"))
}
if dir == "" {
dir = baseCwd
}
if !filepath.IsAbs(dir) {
dir = filepath.Join(baseCwd, dir)
resolved, err := resolveCatalogDir(baseCwd, dir)
if err != nil {
http.Error(w, "invalid dir", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(buildPermissionCatalog(dir)); err != nil {
if err := json.NewEncoder(w).Encode(buildPermissionCatalog(resolved)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}

// resolveCatalogDir resolves the caller-supplied directory against baseCwd and
// guarantees the result stays within baseCwd. The dir value comes straight from
// an untrusted query parameter, so it must be confined to the workspace root to
// prevent path traversal (e.g. "../../etc") into arbitrary parts of the
// filesystem.
func resolveCatalogDir(baseCwd, dir string) (string, error) {
base, err := filepath.Abs(baseCwd)
if err != nil {
return "", err
}
base = filepath.Clean(base)

if dir == "" {
return base, nil
}

target := dir
if !filepath.IsAbs(target) {
target = filepath.Join(base, target)
}
target = filepath.Clean(target)

if target != base && !strings.HasPrefix(target, base+string(os.PathSeparator)) {
return "", fmt.Errorf("dir %q escapes workspace root", dir)
}
Comment on lines +55 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix path validation when baseCwd is a filesystem root.

If base is a root directory (e.g., / on Unix or C:\ on Windows), filepath.Clean(base) retains the trailing slash. Appending string(os.PathSeparator) results in a double slash (e.g., // or C:\\), which causes strings.HasPrefix to falsely reject valid subdirectories (e.g., /foo).

Ensure the separator is only appended if base does not already end with one.

🛠️ Proposed fix
-	if target != base && !strings.HasPrefix(target, base+string(os.PathSeparator)) {
+	prefix := base
+	if !strings.HasSuffix(prefix, string(os.PathSeparator)) {
+		prefix += string(os.PathSeparator)
+	}
+	if target != base && !strings.HasPrefix(target, prefix) {
 		return "", fmt.Errorf("dir %q escapes workspace root", dir)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if target != base && !strings.HasPrefix(target, base+string(os.PathSeparator)) {
return "", fmt.Errorf("dir %q escapes workspace root", dir)
}
prefix := base
if !strings.HasSuffix(prefix, string(os.PathSeparator)) {
prefix += string(os.PathSeparator)
}
if target != base && !strings.HasPrefix(target, prefix) {
return "", fmt.Errorf("dir %q escapes workspace root", dir)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/permission_catalog.go` around lines 55 - 57, Update the path
validation condition to append the path separator to base only when base does
not already end with one, while preserving the existing target != base check and
workspace-escape rejection. Apply this in the validation logic surrounding the
target and base variables so root paths such as / and C:\ correctly allow valid
subdirectories.

return target, nil
}

func buildPermissionCatalog(dir string) api.PermissionCatalog {
home, _ := os.UserHomeDir()
catalog := api.PermissionCatalog{
Expand Down
29 changes: 29 additions & 0 deletions pkg/cli/permission_catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,35 @@ func TestBuildPermissionCatalog(t *testing.T) {
}
}

func TestResolveCatalogDir(t *testing.T) {
base := t.TempDir()
nested := filepath.Join(base, "sub", "child")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatalf("mkdir nested: %v", err)
}

// Empty dir resolves to the workspace root.
if got, err := resolveCatalogDir(base, ""); err != nil || got != filepath.Clean(base) {
t.Fatalf("empty dir: got %q err %v, want %q", got, err, filepath.Clean(base))
}

// Relative paths that stay inside the workspace are allowed.
if got, err := resolveCatalogDir(base, filepath.Join("sub", "child")); err != nil || got != nested {
t.Fatalf("relative dir: got %q err %v, want %q", got, err, nested)
}

// Traversal attempts must be rejected.
for _, dir := range []string{
"../../etc",
filepath.Join("sub", "..", "..", "etc"),
"/etc",
} {
if got, err := resolveCatalogDir(base, dir); err == nil {
t.Fatalf("expected %q to be rejected, got %q", dir, got)
}
}
}

func mustWrite(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
Expand Down
9 changes: 5 additions & 4 deletions pkg/cli/prompt_entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
promptlib "github.com/flanksource/captain/pkg/ai/prompt"
"github.com/flanksource/captain/pkg/api"
"github.com/flanksource/captain/pkg/captainconfig"
"github.com/flanksource/captain/pkg/collections"
"github.com/flanksource/clicky"
clickyapi "github.com/flanksource/clicky/api"
clickyrpc "github.com/flanksource/clicky/rpc"
Expand Down Expand Up @@ -606,7 +607,7 @@ func mergeStringMaps(base, overlay map[string]string) map[string]string {
if len(overlay) == 0 {
return base
}
out := make(map[string]string, len(base)+len(overlay))
out := make(map[string]string, collections.SafeAdd(len(base), len(overlay)))
for k, v := range base {
out[k] = v
}
Expand All @@ -620,7 +621,7 @@ func mergeToolModes(base, overlay map[string]api.ToolMode) map[string]api.ToolMo
if len(overlay) == 0 {
return base
}
out := make(map[string]api.ToolMode, len(base)+len(overlay))
out := make(map[string]api.ToolMode, collections.SafeAdd(len(base), len(overlay)))
for k, v := range base {
out[k] = v
}
Expand All @@ -634,8 +635,8 @@ func mergePresets(base, overlay []api.Preset) []api.Preset {
if len(overlay) == 0 {
return base
}
seen := make(map[api.Preset]bool, len(base)+len(overlay))
out := make([]api.Preset, 0, len(base)+len(overlay))
seen := make(map[api.Preset]bool, collections.SafeAdd(len(base), len(overlay)))
out := make([]api.Preset, 0, collections.SafeAdd(len(base), len(overlay)))
for _, preset := range base {
if seen[preset] {
continue
Expand Down
12 changes: 7 additions & 5 deletions pkg/cli/webapp/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "node:path";
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
Expand Down Expand Up @@ -103,10 +103,10 @@ function clickySourceAliases(enabled: boolean) {
function clickyVersionDefines() {
const version = readClickyPackageVersion();
return {
__CLICKY_COMMIT__: JSON.stringify(git("rev-parse --short HEAD", "")),
__CLICKY_COMMIT__: JSON.stringify(git(["rev-parse", "--short", "HEAD"], "")),
__CLICKY_TAG__: JSON.stringify(`clicky-ui@${version}`),
__CLICKY_DATE__: JSON.stringify(new Date().toISOString()),
__CLICKY_DIRTY__: JSON.stringify(git("status --porcelain", "").length > 0),
__CLICKY_DIRTY__: JSON.stringify(git(["status", "--porcelain"], "").length > 0),
};
}

Expand All @@ -120,9 +120,11 @@ function readClickyPackageVersion() {
}
}

function git(args: string, fallback: string) {
function git(args: string[], fallback: string) {
try {
return execSync(`git -C ${JSON.stringify(clickyPackageRoot)} ${args}`, {
// Pass arguments as an argv array via execFileSync so no shell is spawned,
// avoiding shell interpretation of the (filesystem-derived) repo path.
return execFileSync("git", ["-C", clickyPackageRoot, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
Expand Down
16 changes: 16 additions & 0 deletions pkg/collections/alloc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package collections

import "math"

// SafeAdd returns a + b, saturating at math.MaxInt if the sum would overflow.
//
// It is intended for computing make() size or capacity hints from independent
// lengths (e.g. len(a)+len(b)) without risking an integer-overflow wraparound,
// which could otherwise yield a tiny allocation followed by out-of-bounds
// writes. Negative operands are treated as an overflow guard as well.
func SafeAdd(a, b int) int {
if a < 0 || b < 0 || a > math.MaxInt-b {
return math.MaxInt
}
return a + b
}
4 changes: 2 additions & 2 deletions pkg/collections/fuzzy.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ func Levenshtein(s1, s2 string) int {
return len(s1)
}

matrix := make([][]int, len(s1)+1)
matrix := make([][]int, SafeAdd(len(s1), 1))
for i := range matrix {
matrix[i] = make([]int, len(s2)+1)
matrix[i] = make([]int, SafeAdd(len(s2), 1))
Comment on lines +42 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

set -euo pipefail

printf '\n== fuzzy.go outline ==\n'
ast-grep outline pkg/collections/fuzzy.go --view expanded || true

printf '\n== fuzzy.go relevant lines ==\n'
cat -n pkg/collections/fuzzy.go | sed -n '1,140p'

printf '\n== SafeAdd definitions/usages ==\n'
rg -n "func SafeAdd|SafeAdd\(" -S .

printf '\n== integer type context ==\n'
sed -n '1,120p' pkg/collections/fuzzy.go | cat -n

Repository: flanksource/captain

Length of output: 4496


🏁 Script executed:

set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('pkg/collections/fuzzy.go')
text = p.read_text()
print(text)
PY

printf '\n== SafeAdd definition(s) ==\n'
rg -n "func SafeAdd|type .*SafeAdd|SafeAdd =" -S pkg . || true

Repository: flanksource/captain

Length of output: 1542


🏁 Script executed:

set -euo pipefail

printf '--- fuzzy.go ---\n'
nl -ba pkg/collections/fuzzy.go | sed -n '1,120p'

printf '\n--- SafeAdd references ---\n'
rg -n "SafeAdd" pkg -S

printf '\n--- go.mod ---\n'
cat -n go.mod | sed -n '1,120p'

Repository: flanksource/captain

Length of output: 248


🏁 Script executed:

set -euo pipefail

nl -ba pkg/collections/alloc.go | sed -n '1,120p'

printf '\n== SafeAdd callers in collections ==\n'
rg -n "SafeAdd\(" pkg/collections -S

printf '\n== Any tests for fuzzy/alloc ==\n'
rg -n "Levenshtein|SafeAdd" -S . --glob '*_test.go'

Repository: flanksource/captain

Length of output: 197


🏁 Script executed:

set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('pkg/collections/alloc.go')
print(p.read_text())
PY

Repository: flanksource/captain

Length of output: 692


🏁 Script executed:

set -euo pipefail

cat -n pkg/collections/alloc.go | sed -n '1,120p'

Repository: flanksource/captain

Length of output: 803


Use SafeAdd for the inner loop bound too.
matrix[i] is sized with SafeAdd(len(s2), 1), but for j := range len(s2) + 1 can still wrap at MaxInt and leave the first-row initialization out of sync with the allocated row. Use the same helper here.

Proposed fix
-	for j := range len(s2) + 1 {
+	for j := range SafeAdd(len(s2), 1) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/collections/fuzzy.go` around lines 42 - 44, Update the inner loop in the
matrix initialization to use SafeAdd(len(s2), 1) as its range bound, matching
the allocation of each matrix row. Keep the existing initialization behavior
unchanged while ensuring the loop cannot overflow independently of the allocated
row size.

matrix[i][0] = i
}
for j := range len(s2) + 1 {
Expand Down
Loading