saveFetchToScratch writes fullContent to the session scratch dir when parsed.SaveToFile is set, returning the absolute path written (empty when no save was requested). All writes are confined to the scratch dir: only the base name of the caller-supplied save_path is honored, and the resolved path is
(parsed fetchArgs, fullContent string)
| 217 | // only the base name of the caller-supplied save_path is honored, and the |
| 218 | // resolved path is re-checked against scratch as defense-in-depth. |
| 219 | func saveFetchToScratch(parsed fetchArgs, fullContent string) (string, error) { |
| 220 | if !parsed.SaveToFile { |
| 221 | return "", nil |
| 222 | } |
| 223 | scratch := os.Getenv("CHATCLI_AGENT_TMPDIR") |
| 224 | if scratch == "" { |
| 225 | scratch = os.TempDir() |
| 226 | } |
| 227 | // Take only the base name so we can't be talked into writing /etc/passwd |
| 228 | // via an absolute path — matches gosec G703 guidance and how the coder |
| 229 | // engine validates agent paths. |
| 230 | baseName := filepath.Base(strings.TrimSpace(parsed.SavePath)) |
| 231 | if baseName == "" || baseName == "." || baseName == string(filepath.Separator) { |
| 232 | baseName = fmt.Sprintf("webfetch_%d.txt", time.Now().UnixNano()) |
| 233 | } |
| 234 | // Clean collapses any surviving ../ segments introduced by exotic |
| 235 | // basenames on platforms where Base keeps them. |
| 236 | cleaned := filepath.Clean(filepath.Join(scratch, baseName)) |
| 237 | absScratch, _ := filepath.Abs(scratch) |
| 238 | absCleaned, _ := filepath.Abs(cleaned) |
| 239 | if !strings.HasPrefix(absCleaned, absScratch+string(filepath.Separator)) && absCleaned != absScratch { |
| 240 | return "", fmt.Errorf("save_path %q escapes the session scratch directory", parsed.SavePath) |
| 241 | } |
| 242 | if err := os.WriteFile(cleaned, []byte(fullContent), 0o600); err != nil { //nolint:gosec // path confined to scratch dir above |
| 243 | return "", fmt.Errorf("saving response to %s: %w", cleaned, err) |
| 244 | } |
| 245 | return cleaned, nil |
| 246 | } |
| 247 | |
| 248 | // buildFetchOutput assembles the final string returned to the agent: |
| 249 | // applies the auto-save preview cap and max_length truncation, then |