diff --git a/internal/engine/search/url_scraper.go b/internal/engine/search/url_scraper.go index 38f6722a..eb2a7784 100644 --- a/internal/engine/search/url_scraper.go +++ b/internal/engine/search/url_scraper.go @@ -13,7 +13,7 @@ import ( "sync" "time" - "github.com/GrayCodeAI/rho/internal/tool" + "github.com/GrayCodeAI/rho/internal/toolsafety" ) // URLScraper detects URLs in conversation text and fetches/extracts their content. @@ -113,7 +113,7 @@ func (s *URLScraper) Fetch(ctx context.Context, rawURL string) (*ScrapeResult, e // SSRF guard: reject private/link-local targets and pin the resolved IP // to prevent DNS rebinding. Without this, a scraped URL could reach cloud // metadata endpoints or internal services. - pinnedURL, origHost, err := tool.ValidateURLPublic(ctx, rawURL) + pinnedURL, origHost, err := toolsafety.ValidateURLPublic(ctx, rawURL) if err != nil { return nil, err } @@ -128,7 +128,7 @@ func (s *URLScraper) Fetch(ctx context.Context, rawURL string) (*ScrapeResult, e req.Header.Set("User-Agent", s.UserAgent) req.Header.Set("Accept", "text/html, application/json, text/plain, */*") - client := tool.SSRFSafeClient(ctx, s.Timeout) + client := toolsafety.SSRFSafeClient(ctx, s.Timeout) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetching URL: %w", err) diff --git a/internal/engine/search/url_scraper_test.go b/internal/engine/search/url_scraper_test.go index d597f2c3..744f27f8 100644 --- a/internal/engine/search/url_scraper_test.go +++ b/internal/engine/search/url_scraper_test.go @@ -10,7 +10,7 @@ import ( "time" "github.com/GrayCodeAI/rho/internal/testutil" - "github.com/GrayCodeAI/rho/internal/tool" + "github.com/GrayCodeAI/rho/internal/toolsafety" ) func TestDetectURLs_HTTPAndHTTPS(t *testing.T) { @@ -330,7 +330,7 @@ func TestCachePreventsRefetch(t *testing.T) { defer ts.Close() scraper := NewURLScraper() - ctx := tool.WithSSRFSkip(context.Background()) + ctx := toolsafety.WithSSRFSkip(context.Background()) // First fetch should hit server. _, err := scraper.Fetch(ctx, ts.URL) @@ -359,7 +359,7 @@ func TestFetch_HTMLContent(t *testing.T) { defer ts.Close() scraper := NewURLScraper() - result, err := scraper.Fetch(tool.WithSSRFSkip(context.Background()), ts.URL) + result, err := scraper.Fetch(toolsafety.WithSSRFSkip(context.Background()), ts.URL) if err != nil { t.Fatalf("fetch failed: %v", err) } @@ -386,7 +386,7 @@ func TestFetch_JSONContent(t *testing.T) { defer ts.Close() scraper := NewURLScraper() - result, err := scraper.Fetch(tool.WithSSRFSkip(context.Background()), ts.URL) + result, err := scraper.Fetch(toolsafety.WithSSRFSkip(context.Background()), ts.URL) if err != nil { t.Fatalf("fetch failed: %v", err) } @@ -494,7 +494,7 @@ func TestTokenEstimate(t *testing.T) { defer ts.Close() scraper := NewURLScraper() - result, err := scraper.Fetch(tool.WithSSRFSkip(context.Background()), ts.URL) + result, err := scraper.Fetch(toolsafety.WithSSRFSkip(context.Background()), ts.URL) if err != nil { t.Fatalf("fetch failed: %v", err) } diff --git a/internal/tool/bash.go b/internal/tool/bash.go index caa22db2..ac081468 100644 --- a/internal/tool/bash.go +++ b/internal/tool/bash.go @@ -180,118 +180,6 @@ func (BashTool) Parameters() map[string]interface{} { // bashSchema is the single source of truth for Bash's input schema. var bashSchema = BashTool{}.Schema() -// SegmentCommand splits a command string on &&, ||, ;, and | (respecting quotes -// and heredocs) into individual segments for independent analysis. -func SegmentCommand(cmd string) []string { - var segments []string - var current strings.Builder - inSingle, inDouble := false, false - inHeredoc := false - heredocDelim := "" - runes := []rune(cmd) - for i := 0; i < len(runes); i++ { - ch := runes[i] - - // If inside a heredoc body, consume until we find the delimiter on its own line - if inHeredoc { - current.WriteRune(ch) - if ch == '\n' { - lineStart := i + 1 - lineEnd := lineStart - for lineEnd < len(runes) && runes[lineEnd] != '\n' { - lineEnd++ - } - line := strings.TrimSpace(string(runes[lineStart:lineEnd])) - if line == heredocDelim { - for j := lineStart; j <= lineEnd && j < len(runes); j++ { - current.WriteRune(runes[j]) - } - i = lineEnd - inHeredoc = false - heredocDelim = "" - } - } - continue - } - - if ch == '\'' && !inDouble { - inSingle = !inSingle - current.WriteRune(ch) - continue - } - if ch == '"' && !inSingle { - inDouble = !inDouble - current.WriteRune(ch) - continue - } - if inSingle || inDouble { - current.WriteRune(ch) - continue - } - - // Detect heredoc: < delimStart { - heredocDelim = string(runes[delimStart:j]) - inHeredoc = true - for k := i; k < j; k++ { - current.WriteRune(runes[k]) - } - i = j - 1 - continue - } - } - } - - // Check for &&, || - if i+1 < len(runes) && ((ch == '&' && runes[i+1] == '&') || (ch == '|' && runes[i+1] == '|')) { - if s := strings.TrimSpace(current.String()); s != "" { - segments = append(segments, s) - } - current.Reset() - i++ // skip second char - continue - } - // Check for ; or single | - if ch == ';' || ch == '|' { - if s := strings.TrimSpace(current.String()); s != "" { - segments = append(segments, s) - } - current.Reset() - continue - } - current.WriteRune(ch) - } - if s := strings.TrimSpace(current.String()); s != "" { - segments = append(segments, s) - } - return segments -} - -// IsSuspicious returns true if the command needs a permission prompt. -// This is fail-closed: anything we can't confidently classify as safe gets flagged. func IsSuspicious(command string) bool { // Whole-command checks that apply regardless of segmentation if strings.Contains(command, "\r") { @@ -500,23 +388,23 @@ func (BashTool) Execute(ctx context.Context, input json.RawMessage) (string, err if tc := GetToolContext(ctx); tc != nil && tc.WorkingDir != "" { cmd.Dir = tc.WorkingDir } - // Use a limitedWriter to cap output at maxOutputBytes instead of + // Use a limitedWriter to cap output at MaxOutputBytes instead of // CombinedOutput, which buffers the entire output in memory. A command // like `yes` or `cat /dev/urandom` can produce GBs before the timeout // kills it; the limitedWriter keeps memory bounded while the command // continues to run (writes are silently discarded after the cap). var lw limitedWriter - // Cap one byte above maxOutputBytes so that TruncateOutput's > branch - // fires when the cap is reached. At exactly maxOutputBytes (no discard) + // Cap one byte above MaxOutputBytes so that TruncateOutput's > branch + // fires when the cap is reached. At exactly MaxOutputBytes (no discard) // TruncateOutput returns unchanged, which is correct. - lw.maxBytes = maxOutputBytes + 1 + lw.maxBytes = MaxOutputBytes + 1 cmd.Stdout = &lw cmd.Stderr = &lw err := cmd.Run() result := lw.buf.String() // Apply safety output truncation (50KB) — the limitedWriter may have - // captured up to maxOutputBytes (500KB), so we still truncate for the + // captured up to MaxOutputBytes (500KB), so we still truncate for the // final result returned to the model. result = TruncateOutput(result) result = strings.TrimRight(result, "\n") diff --git a/internal/tool/download.go b/internal/tool/download.go index dd5f8a9e..89d14004 100644 --- a/internal/tool/download.go +++ b/internal/tool/download.go @@ -50,12 +50,12 @@ func (DownloadTool) Execute(ctx context.Context, input json.RawMessage) (string, if reason := IsSensitivePath(p.Destination); reason != "" { return "", fmt.Errorf("write blocked: %s", reason) } - pinnedURL, origHost, err := validateURLPublic(ctx, p.URL) + pinnedURL, origHost, err := ValidateURLPublic(ctx, p.URL) if err != nil { return "", err } - client := ssrfSafeClient(ctx, 2*time.Minute) + client := SSRFSafeClient(ctx, 2*time.Minute) req, err := http.NewRequestWithContext(ctx, http.MethodGet, pinnedURL, nil) if err != nil { return "", fmt.Errorf("create request: %w", err) diff --git a/internal/tool/safety_integration_test.go b/internal/tool/safety_integration_test.go new file mode 100644 index 00000000..74fb7575 --- /dev/null +++ b/internal/tool/safety_integration_test.go @@ -0,0 +1,137 @@ +package tool + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// These tests exercise the file tools' use of the sensitive-path policy, so +// they live in package tool (the policy itself is tested in toolsafety). + +func TestFileToolsBlockFluxProviderConfig(t *testing.T) { + fluxDir := filepath.Join(t.TempDir(), "flux") + t.Setenv("FLUX_CONFIG_DIR", fluxDir) + providerPath := filepath.Join(fluxDir, "provider.json") + + readInput, _ := json.Marshal(map[string]string{"path": providerPath}) + editInput, _ := json.Marshal(map[string]string{ + "path": providerPath, "old_str": "old", "new_str": "new", + }) + writeInput, _ := json.Marshal(map[string]string{ + "path": providerPath, "content": "safe routing metadata", + }) + tests := []struct { + name string + run func() error + }{ + {name: "Read", run: func() error { + _, err := (FileReadTool{}).Execute(testCtx(), readInput) + return err + }}, + {name: "Edit", run: func() error { + _, err := (FileEditTool{}).Execute(testCtx(), editInput) + return err + }}, + {name: "Write", run: func() error { + _, err := (FileWriteTool{}).Execute(testCtx(), writeInput) + return err + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.run() + if err == nil || !strings.Contains(err.Error(), "blocked") { + t.Fatalf("%s error = %v, want sensitive-path block", tt.name, err) + } + }) + } +} + +// TestFileRead_BlocksSymlinkToSensitiveFile verifies the read tool resolves +// symlinks before opening (M13): reading through a symlink that points at a +// sensitive target is blocked, while a symlink to an ordinary file works. +func TestFileRead_BlocksSymlinkToSensitiveFile(t *testing.T) { + fluxDir := filepath.Join(t.TempDir(), "flux") + if err := os.MkdirAll(fluxDir, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("FLUX_CONFIG_DIR", fluxDir) + providerPath := filepath.Join(fluxDir, "provider.json") + if err := os.WriteFile(providerPath, []byte(`{"key":"x"}`), 0o600); err != nil { + t.Fatal(err) + } + + workDir := t.TempDir() + link := filepath.Join(workDir, "readme.md") + if err := os.Symlink(providerPath, link); err != nil { + t.Fatal(err) + } + in, _ := json.Marshal(map[string]string{"path": link}) + _, err := (FileReadTool{}).Execute(testCtx(), in) + if err == nil || !strings.Contains(err.Error(), "blocked") { + t.Fatalf("expected sensitive-path block for symlinked provider config, got %v", err) + } + + // A symlink to an ordinary file must still read fine. + plain := filepath.Join(workDir, "plain.txt") + if err := os.WriteFile(plain, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + link2 := filepath.Join(workDir, "link2.txt") + if err := os.Symlink(plain, link2); err != nil { + t.Fatal(err) + } + in2, _ := json.Marshal(map[string]string{"path": link2}) + out, err := (FileReadTool{}).Execute(testCtx(), in2) + if err != nil { + t.Fatalf("expected symlinked plain file to read, got %v", err) + } + if !strings.Contains(out, "hello") { + t.Fatalf("expected content through symlink, got %q", out) + } +} + +// TestIsSensitivePath_SecretBasenames verifies the expanded basename blocklist +// (secrets.txt, .git-credentials, private keys, …) applies anywhere, not just +// under the home directory. + +func TestBashSensitivePathIntegration(t *testing.T) { + if !IsSuspicious("cat ~/.ssh/id_rsa") { + t.Error("IsSuspicious should flag reads of SSH private keys") + } + if !isHardDeny("cat ~/.aws/credentials") { + t.Error("isHardDeny should block credential reads when prompts are bypassed") + } + if isHardDeny("go build ./...") { + t.Error("isHardDeny should not block ordinary commands") + } +} + +func TestCommandReferencesSensitivePath_FluxConfigDir(t *testing.T) { + fluxDir := filepath.Join(t.TempDir(), "flux config with spaces") + t.Setenv("FLUX_CONFIG_DIR", fluxDir) + + commands := []string{ + `cat "` + filepath.Join(fluxDir, "provider.json") + `"`, + `cat "$FLUX_CONFIG_DIR/provider.json"`, + `cat "${FLUX_CONFIG_DIR}/provider.json"`, + `cat "${FLUX_CONFIG_DIR%/}/provider.json"`, + `printf '%s\n' "$FLUX_CONFIG_DIR"`, + "cat " + strings.ReplaceAll(filepath.Join(fluxDir, "provider.json"), " ", `\ `), + } + for _, command := range commands { + if reason := CommandReferencesSensitivePath(command); reason == "" { + t.Fatalf("CommandReferencesSensitivePath(%q) = empty, want blocked", command) + } + if !IsSuspicious(command) { + t.Fatalf("IsSuspicious(%q) = false, want Bash prompt", command) + } + if !isHardDeny(command) { + t.Fatalf("isHardDeny(%q) = false, want Bash block", command) + } + } +} diff --git a/internal/tool/safety_reexports.go b/internal/tool/safety_reexports.go new file mode 100644 index 00000000..a51abf6d --- /dev/null +++ b/internal/tool/safety_reexports.go @@ -0,0 +1,62 @@ +package tool + +import ( + "context" + "net/http" + "time" + + "github.com/GrayCodeAI/rho/internal/toolsafety" +) + +// This file re-exports the low-level safety helpers that now live in +// internal/toolsafety. Keeping the tool-package names stable lets existing +// callers migrate incrementally; new code should import toolsafety directly. + +// BinaryIndicator is returned instead of binary file content. +const BinaryIndicator = toolsafety.BinaryIndicator + +// MaxOutputBytes is the output cap applied by TruncateOutput. +const MaxOutputBytes = toolsafety.MaxOutputBytes + +// ToolTimeout returns the default timeout for a given tool name. +func ToolTimeout(toolName string) time.Duration { return toolsafety.ToolTimeout(toolName) } + +// TruncateOutput trims output to the safety cap and appends an indicator. +func TruncateOutput(s string) string { return toolsafety.TruncateOutput(s) } + +// IsDestructiveCommand reports whether a shell command is destructive. +func IsDestructiveCommand(command string) bool { return toolsafety.IsDestructiveCommand(command) } + +// DetectCredentials returns a description when content looks like a credential. +func DetectCredentials(content string) string { return toolsafety.DetectCredentials(content) } + +// IsSensitivePath returns a reason when path is a blocked sensitive file. +func IsSensitivePath(path string) string { return toolsafety.IsSensitivePath(path) } + +// CommandReferencesSensitivePath returns a reason when a shell command +// references a blocked sensitive path. +func CommandReferencesSensitivePath(command string) string { + return toolsafety.CommandReferencesSensitivePath(command) +} + +// ResolvePath returns the absolute, symlink-resolved path. +func ResolvePath(path string) (string, error) { return toolsafety.ResolvePath(path) } + +// IsBinaryContent reports whether data appears to be binary. +func IsBinaryContent(data []byte) bool { return toolsafety.IsBinaryContent(data) } + +// SegmentCommand splits a command string on shell operators. +func SegmentCommand(cmd string) []string { return toolsafety.SegmentCommand(cmd) } + +// WithSSRFSkip returns a context that skips SSRF URL validation. +func WithSSRFSkip(ctx context.Context) context.Context { return toolsafety.WithSSRFSkip(ctx) } + +// ValidateURLPublic rejects private/link-local URLs and pins the resolved IP. +func ValidateURLPublic(ctx context.Context, rawURL string) (pinnedURL, originalHost string, err error) { + return toolsafety.ValidateURLPublic(ctx, rawURL) +} + +// SSRFSafeClient returns an http.Client that validates redirect targets. +func SSRFSafeClient(ctx context.Context, timeout time.Duration) *http.Client { + return toolsafety.SSRFSafeClient(ctx, timeout) +} diff --git a/internal/tool/web_fetch.go b/internal/tool/web_fetch.go index 41abdd57..1fe5ff21 100644 --- a/internal/tool/web_fetch.go +++ b/internal/tool/web_fetch.go @@ -49,7 +49,7 @@ func (WebFetchTool) Execute(ctx context.Context, input json.RawMessage) (string, if p.URL == "" { return "", fmt.Errorf("url is required") } - pinnedURL, origHost, err := validateURLPublic(ctx, p.URL) + pinnedURL, origHost, err := ValidateURLPublic(ctx, p.URL) if err != nil { return "", err } @@ -70,7 +70,7 @@ func (WebFetchTool) Execute(ctx context.Context, input json.RawMessage) (string, req.Host = origHost } - client := ssrfSafeClient(ctx, 30*time.Second) + client := SSRFSafeClient(ctx, 30*time.Second) resp, err := client.Do(req) if err != nil { return "", err diff --git a/internal/tool/safety.go b/internal/toolsafety/toolsafety.go similarity index 87% rename from internal/tool/safety.go rename to internal/toolsafety/toolsafety.go index f5964967..ca9470f9 100644 --- a/internal/tool/safety.go +++ b/internal/toolsafety/toolsafety.go @@ -1,4 +1,4 @@ -package tool +package toolsafety import ( "context" @@ -40,15 +40,15 @@ func ToolTimeout(toolName string) time.Duration { // 2. Output size limiting // ────────────────────────────────────────────────────────────────────────────── -const maxOutputBytes = 500_000 // 500 KB — tune this if your tool outputs are routinely larger +const MaxOutputBytes = 500_000 // 500 KB — tune this if your tool outputs are routinely larger -// TruncateOutput trims output to maxOutputBytes and appends an indicator. +// TruncateOutput trims output to MaxOutputBytes and appends an indicator. func TruncateOutput(s string) string { - if len(s) <= maxOutputBytes { + if len(s) <= MaxOutputBytes { return s } // Truncate at rune boundary to avoid splitting multi-byte UTF-8 characters. - truncated := s[:maxOutputBytes] + truncated := s[:MaxOutputBytes] for i := len(truncated) - 1; i >= 0; i-- { b := truncated[i] if b&0xC0 != 0x80 { @@ -505,3 +505,114 @@ func ssrfSafeClient(ctx context.Context, timeout time.Duration) *http.Client { }, } } + +func SegmentCommand(cmd string) []string { + var segments []string + var current strings.Builder + inSingle, inDouble := false, false + inHeredoc := false + heredocDelim := "" + runes := []rune(cmd) + for i := 0; i < len(runes); i++ { + ch := runes[i] + + // If inside a heredoc body, consume until we find the delimiter on its own line + if inHeredoc { + current.WriteRune(ch) + if ch == '\n' { + lineStart := i + 1 + lineEnd := lineStart + for lineEnd < len(runes) && runes[lineEnd] != '\n' { + lineEnd++ + } + line := strings.TrimSpace(string(runes[lineStart:lineEnd])) + if line == heredocDelim { + for j := lineStart; j <= lineEnd && j < len(runes); j++ { + current.WriteRune(runes[j]) + } + i = lineEnd + inHeredoc = false + heredocDelim = "" + } + } + continue + } + + if ch == '\'' && !inDouble { + inSingle = !inSingle + current.WriteRune(ch) + continue + } + if ch == '"' && !inSingle { + inDouble = !inDouble + current.WriteRune(ch) + continue + } + if inSingle || inDouble { + current.WriteRune(ch) + continue + } + + // Detect heredoc: < delimStart { + heredocDelim = string(runes[delimStart:j]) + inHeredoc = true + for k := i; k < j; k++ { + current.WriteRune(runes[k]) + } + i = j - 1 + continue + } + } + } + + // Check for &&, || + if i+1 < len(runes) && ((ch == '&' && runes[i+1] == '&') || (ch == '|' && runes[i+1] == '|')) { + if s := strings.TrimSpace(current.String()); s != "" { + segments = append(segments, s) + } + current.Reset() + i++ // skip second char + continue + } + // Check for ; or single | + if ch == ';' || ch == '|' { + if s := strings.TrimSpace(current.String()); s != "" { + segments = append(segments, s) + } + current.Reset() + continue + } + current.WriteRune(ch) + } + if s := strings.TrimSpace(current.String()); s != "" { + segments = append(segments, s) + } + return segments +} + +// IsSuspicious returns true if the command needs a permission prompt. +// This is fail-closed: anything we can't confidently classify as safe gets flagged. diff --git a/internal/tool/safety_test.go b/internal/toolsafety/toolsafety_test.go similarity index 82% rename from internal/tool/safety_test.go rename to internal/toolsafety/toolsafety_test.go index 54c8d75f..a366df01 100644 --- a/internal/tool/safety_test.go +++ b/internal/toolsafety/toolsafety_test.go @@ -1,7 +1,6 @@ -package tool +package toolsafety import ( - "encoding/json" "os" "path/filepath" "strings" @@ -281,46 +280,6 @@ func TestIsSensitivePath_FluxConfigDir(t *testing.T) { } } -func TestFileToolsBlockFluxProviderConfig(t *testing.T) { - fluxDir := filepath.Join(t.TempDir(), "flux") - t.Setenv("FLUX_CONFIG_DIR", fluxDir) - providerPath := filepath.Join(fluxDir, "provider.json") - - readInput, _ := json.Marshal(map[string]string{"path": providerPath}) - editInput, _ := json.Marshal(map[string]string{ - "path": providerPath, "old_str": "old", "new_str": "new", - }) - writeInput, _ := json.Marshal(map[string]string{ - "path": providerPath, "content": "safe routing metadata", - }) - tests := []struct { - name string - run func() error - }{ - {name: "Read", run: func() error { - _, err := (FileReadTool{}).Execute(testCtx(), readInput) - return err - }}, - {name: "Edit", run: func() error { - _, err := (FileEditTool{}).Execute(testCtx(), editInput) - return err - }}, - {name: "Write", run: func() error { - _, err := (FileWriteTool{}).Execute(testCtx(), writeInput) - return err - }}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.run() - if err == nil || !strings.Contains(err.Error(), "blocked") { - t.Fatalf("%s error = %v, want sensitive-path block", tt.name, err) - } - }) - } -} - func TestIsSensitivePath_RhoConfigDirEnv(t *testing.T) { cfgDir := t.TempDir() t.Setenv("RHO_CONFIG_DIR", cfgDir) @@ -336,53 +295,6 @@ func TestIsSensitivePath_RhoConfigDirEnv(t *testing.T) { } } -// TestFileRead_BlocksSymlinkToSensitiveFile verifies the read tool resolves -// symlinks before opening (M13): reading through a symlink that points at a -// sensitive target is blocked, while a symlink to an ordinary file works. -func TestFileRead_BlocksSymlinkToSensitiveFile(t *testing.T) { - fluxDir := filepath.Join(t.TempDir(), "flux") - if err := os.MkdirAll(fluxDir, 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("FLUX_CONFIG_DIR", fluxDir) - providerPath := filepath.Join(fluxDir, "provider.json") - if err := os.WriteFile(providerPath, []byte(`{"key":"x"}`), 0o600); err != nil { - t.Fatal(err) - } - - workDir := t.TempDir() - link := filepath.Join(workDir, "readme.md") - if err := os.Symlink(providerPath, link); err != nil { - t.Fatal(err) - } - in, _ := json.Marshal(map[string]string{"path": link}) - _, err := (FileReadTool{}).Execute(testCtx(), in) - if err == nil || !strings.Contains(err.Error(), "blocked") { - t.Fatalf("expected sensitive-path block for symlinked provider config, got %v", err) - } - - // A symlink to an ordinary file must still read fine. - plain := filepath.Join(workDir, "plain.txt") - if err := os.WriteFile(plain, []byte("hello"), 0o600); err != nil { - t.Fatal(err) - } - link2 := filepath.Join(workDir, "link2.txt") - if err := os.Symlink(plain, link2); err != nil { - t.Fatal(err) - } - in2, _ := json.Marshal(map[string]string{"path": link2}) - out, err := (FileReadTool{}).Execute(testCtx(), in2) - if err != nil { - t.Fatalf("expected symlinked plain file to read, got %v", err) - } - if !strings.Contains(out, "hello") { - t.Fatalf("expected content through symlink, got %q", out) - } -} - -// TestIsSensitivePath_SecretBasenames verifies the expanded basename blocklist -// (secrets.txt, .git-credentials, private keys, …) applies anywhere, not just -// under the home directory. func TestIsSensitivePath_SecretBasenames(t *testing.T) { for _, name := range []string{ "secrets.txt", "secrets.yaml", ".git-credentials", ".htpasswd", @@ -582,20 +494,20 @@ func TestTruncateOutput(t *testing.T) { t.Errorf("short output should not be truncated, got len %d", len(got)) } - long := strings.Repeat("A", maxOutputBytes+1000) + long := strings.Repeat("A", MaxOutputBytes+1000) got := TruncateOutput(long) if !strings.HasSuffix(got, "[output truncated — showing first 500KB]") { t.Error("expected truncation indicator") } - // The prefix should be exactly maxOutputBytes of the original. - prefix := got[:maxOutputBytes] - if prefix != long[:maxOutputBytes] { + // The prefix should be exactly MaxOutputBytes of the original. + prefix := got[:MaxOutputBytes] + if prefix != long[:MaxOutputBytes] { t.Error("truncated prefix does not match original") } } func TestTruncateOutput_ExactBoundary(t *testing.T) { - exact := strings.Repeat("B", maxOutputBytes) + exact := strings.Repeat("B", MaxOutputBytes) if got := TruncateOutput(exact); got != exact { t.Error("output at exact boundary should not be truncated") } @@ -611,7 +523,7 @@ func TestTruncateOutput_UTF8Multibyte(t *testing.T) { // Build a string of multi-byte UTF-8 chars that exceeds the limit // 'é' is 2 bytes in UTF-8 base := "é" - long := strings.Repeat(base, maxOutputBytes) // much more than maxOutputBytes + long := strings.Repeat(base, MaxOutputBytes) // much more than MaxOutputBytes got := TruncateOutput(long) if !strings.HasSuffix(got, "[output truncated — showing first 500KB]") { t.Error("expected truncation indicator for UTF-8 content") @@ -625,14 +537,14 @@ func TestTruncateOutput_UTF8Multibyte(t *testing.T) { } func TestTruncateOutput_JustUnderBoundary(t *testing.T) { - s := strings.Repeat("x", maxOutputBytes-1) + s := strings.Repeat("x", MaxOutputBytes-1) if got := TruncateOutput(s); got != s { t.Error("output just under boundary should not be truncated") } } func TestTruncateOutput_JustOverBoundary(t *testing.T) { - s := strings.Repeat("x", maxOutputBytes+1) + s := strings.Repeat("x", MaxOutputBytes+1) got := TruncateOutput(s) if !strings.Contains(got, "[output truncated") { t.Error("output just over boundary should be truncated") @@ -859,40 +771,3 @@ func TestCommandReferencesSensitivePath(t *testing.T) { } } } - -func TestCommandReferencesSensitivePath_FluxConfigDir(t *testing.T) { - fluxDir := filepath.Join(t.TempDir(), "flux config with spaces") - t.Setenv("FLUX_CONFIG_DIR", fluxDir) - - commands := []string{ - `cat "` + filepath.Join(fluxDir, "provider.json") + `"`, - `cat "$FLUX_CONFIG_DIR/provider.json"`, - `cat "${FLUX_CONFIG_DIR}/provider.json"`, - `cat "${FLUX_CONFIG_DIR%/}/provider.json"`, - `printf '%s\n' "$FLUX_CONFIG_DIR"`, - "cat " + strings.ReplaceAll(filepath.Join(fluxDir, "provider.json"), " ", `\ `), - } - for _, command := range commands { - if reason := CommandReferencesSensitivePath(command); reason == "" { - t.Fatalf("CommandReferencesSensitivePath(%q) = empty, want blocked", command) - } - if !IsSuspicious(command) { - t.Fatalf("IsSuspicious(%q) = false, want Bash prompt", command) - } - if !isHardDeny(command) { - t.Fatalf("isHardDeny(%q) = false, want Bash block", command) - } - } -} - -func TestBashSensitivePathIntegration(t *testing.T) { - if !IsSuspicious("cat ~/.ssh/id_rsa") { - t.Error("IsSuspicious should flag reads of SSH private keys") - } - if !isHardDeny("cat ~/.aws/credentials") { - t.Error("isHardDeny should block credential reads when prompts are bypassed") - } - if isHardDeny("go build ./...") { - t.Error("isHardDeny should not block ordinary commands") - } -}