Add vendor assessment agent
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
170
pkg/agent/tools/browser/browser.go
Normal file
170
pkg/agent/tools/browser/browser.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultToolTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
type Browser struct {
|
||||
addr string
|
||||
allocCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
allowedDomains []string
|
||||
}
|
||||
|
||||
func NewBrowser(ctx context.Context, addr string) *Browser {
|
||||
if !strings.HasPrefix(addr, "ws://") && !strings.HasPrefix(addr, "wss://") {
|
||||
addr = "ws://" + addr
|
||||
}
|
||||
|
||||
allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, addr)
|
||||
|
||||
return &Browser{
|
||||
addr: addr,
|
||||
allocCtx: allocCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAllowedDomain restricts navigation to URLs under the given domain and
|
||||
// its subdomains. For example, setting "getprobo.com" allows navigation to
|
||||
// getprobo.com, www.getprobo.com, and compliance.getprobo.com.
|
||||
// This replaces any previously set domains.
|
||||
func (b *Browser) SetAllowedDomain(domain string) {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
|
||||
// Strip "www." prefix so that setting either "www.example.com" or
|
||||
// "example.com" allows navigation to *.example.com.
|
||||
domain = strings.TrimPrefix(domain, "www.")
|
||||
|
||||
b.allowedDomains = []string{domain}
|
||||
}
|
||||
|
||||
// checkURL validates that the URL is allowed. It returns an error tool result
|
||||
// if the URL uses a disallowed scheme, resolves to a non-public IP, or is
|
||||
// outside the allowed domains.
|
||||
func (b *Browser) checkURL(rawURL string) *agent.ToolResult {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return &agent.ToolResult{
|
||||
Content: fmt.Sprintf("invalid URL: %s", err),
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return &agent.ToolResult{
|
||||
Content: fmt.Sprintf("cannot navigate to URL with scheme %q: only http and https are allowed", u.Scheme),
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Always reject URLs that resolve to non-public IPs, even when no
|
||||
// allowed-domain list is set. This closes the SSRF path on browsers
|
||||
// used for open-ended external research (e.g. the research browser
|
||||
// in vendor assessments).
|
||||
if err := netcheck.ValidatePublicURL(rawURL); err != nil {
|
||||
return &agent.ToolResult{
|
||||
Content: fmt.Sprintf("navigation blocked: %s", err),
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
if len(b.allowedDomains) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
host := strings.ToLower(u.Hostname())
|
||||
for _, allowed := range b.allowedDomains {
|
||||
if host == allowed || strings.HasSuffix(host, "."+allowed) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return &agent.ToolResult{
|
||||
Content: fmt.Sprintf("navigation blocked: %s is outside the allowed domains", host),
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// checkAlive returns a tool error result if the browser connection has been
|
||||
// lost. Call this at the start of every tool to fail fast with a clear
|
||||
// message instead of waiting for the tool timeout.
|
||||
func (b *Browser) checkAlive() *agent.ToolResult {
|
||||
if err := b.allocCtx.Err(); err != nil {
|
||||
return &agent.ToolResult{
|
||||
Content: "browser connection lost: the remote Chrome instance is no longer reachable",
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyError inspects the caller's timeout context and the browser's
|
||||
// allocator context to produce a human-readable error message. Without this,
|
||||
// both a tool timeout and a dropped Chrome connection appear as the opaque
|
||||
// "context canceled".
|
||||
func (b *Browser) classifyError(timeoutCtx context.Context, rawURL string, err error) string {
|
||||
if b.allocCtx.Err() != nil {
|
||||
return fmt.Sprintf(
|
||||
"browser connection lost while loading %s: the remote Chrome instance is no longer reachable",
|
||||
rawURL,
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
||||
return fmt.Sprintf(
|
||||
"page load timed out after %s for %s: the page may be too slow or unresponsive",
|
||||
defaultToolTimeout,
|
||||
rawURL,
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("cannot load %s: %s", rawURL, err)
|
||||
}
|
||||
|
||||
func (b *Browser) NewTab(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
tabCtx, tabCancel := chromedp.NewContext(b.allocCtx)
|
||||
|
||||
// Propagate the caller's cancellation to the Chrome tab so that
|
||||
// tool-level timeouts and context deadlines actually stop the browser.
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
tabCancel()
|
||||
case <-tabCtx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
return tabCtx, tabCancel
|
||||
}
|
||||
|
||||
func (b *Browser) Close() {
|
||||
b.cancel()
|
||||
}
|
||||
88
pkg/agent/tools/browser/click.go
Normal file
88
pkg/agent/tools/browser/click.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
type (
|
||||
clickParams struct {
|
||||
URL string `json:"url" jsonschema:"The URL to navigate to before clicking"`
|
||||
Selector string `json:"selector" jsonschema:"CSS selector of the element to click (e.g. button.next, a[href*=page])"`
|
||||
}
|
||||
)
|
||||
|
||||
func ClickElementTool(b *Browser) agent.Tool {
|
||||
return agent.FunctionTool(
|
||||
"click_element",
|
||||
"Navigate to a URL, click an element matching a CSS selector, and return the page text after the click. Useful for pagination buttons, 'show all' links, tabs, and other interactive elements.",
|
||||
func(ctx context.Context, p clickParams) (agent.ToolResult, error) {
|
||||
if r := b.checkAlive(); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
if r := b.checkURL(p.URL); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
ctx, timeoutCancel := withToolTimeout(ctx)
|
||||
defer timeoutCancel()
|
||||
|
||||
tabCtx, cancel := b.NewTab(ctx)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
text string
|
||||
postClickURL string
|
||||
)
|
||||
|
||||
err := chromedp.Run(
|
||||
tabCtx,
|
||||
chromedp.Navigate(p.URL),
|
||||
waitForPage(),
|
||||
chromedp.WaitVisible(p.Selector),
|
||||
chromedp.Click(p.Selector),
|
||||
waitForPage(),
|
||||
chromedp.Location(&postClickURL),
|
||||
chromedp.Evaluate(`document.body.innerText`, &text),
|
||||
)
|
||||
if err != nil {
|
||||
return agent.ResultError(b.classifyError(ctx, p.URL, err)), nil
|
||||
}
|
||||
|
||||
// Revalidate the post-click URL: a click may navigate
|
||||
// the page to a different host (redirect, JS navigation,
|
||||
// <a href>), bypassing the initial checkURL. Reject the
|
||||
// result if the new URL is outside the allowed scope or
|
||||
// resolves to a non-public IP.
|
||||
if postClickURL != "" && postClickURL != p.URL {
|
||||
if r := b.checkURL(postClickURL); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) > maxTextLength {
|
||||
text = string(runes[:maxTextLength])
|
||||
}
|
||||
|
||||
return agent.ToolResult{Content: text}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
157
pkg/agent/tools/browser/download_pdf.go
Normal file
157
pkg/agent/tools/browser/download_pdf.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
|
||||
)
|
||||
|
||||
type (
|
||||
downloadPDFParams struct {
|
||||
URL string `json:"url" jsonschema:"The URL of the PDF document to download and extract text from"`
|
||||
}
|
||||
|
||||
downloadPDFResult struct {
|
||||
Text string `json:"text"`
|
||||
PageCount int `json:"page_count"`
|
||||
ErrorDetail string `json:"error_detail,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
func DownloadPDFTool() agent.Tool {
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: netcheck.NewPinnedTransport(),
|
||||
}
|
||||
|
||||
return agent.FunctionTool(
|
||||
"download_pdf",
|
||||
"Download a PDF document from a URL and extract its text content. Use this for DPAs, SOC 2 reports, privacy policies, and other documents hosted as PDFs.",
|
||||
func(ctx context.Context, p downloadPDFParams) (agent.ToolResult, error) {
|
||||
if err := validatePublicURL(p.URL); err != nil {
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
ErrorDetail: fmt.Sprintf("URL not allowed: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
ErrorDetail: fmt.Sprintf("cannot create request: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
ErrorDetail: fmt.Sprintf("cannot download PDF: %s", err),
|
||||
}), nil
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
ErrorDetail: fmt.Sprintf("PDF download returned status %d", resp.StatusCode),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Read PDF into memory (max 20MB).
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 20*1024*1024))
|
||||
if err != nil {
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
ErrorDetail: fmt.Sprintf("cannot read PDF body: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Write to temp file for pdfcpu.
|
||||
tmpDir, err := os.MkdirTemp("", "pdf-extract-*")
|
||||
if err != nil {
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
ErrorDetail: fmt.Sprintf("cannot create temp dir: %s", err),
|
||||
}), nil
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpFile := filepath.Join(tmpDir, "input.pdf")
|
||||
if err := os.WriteFile(tmpFile, body, 0o600); err != nil {
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
ErrorDetail: fmt.Sprintf("cannot write temp file: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Get page count.
|
||||
conf := model.NewDefaultConfiguration()
|
||||
pageCount, err := api.PageCountFile(tmpFile)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
ErrorDetail: fmt.Sprintf("cannot read PDF: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Extract content to output dir.
|
||||
outDir := filepath.Join(tmpDir, "out")
|
||||
if err := os.MkdirAll(outDir, 0o700); err != nil {
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
ErrorDetail: fmt.Sprintf("cannot create output dir: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(body)
|
||||
if err := api.ExtractContent(reader, outDir, "content", nil, conf); err != nil {
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
ErrorDetail: fmt.Sprintf("cannot extract PDF content: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Read all extracted content files.
|
||||
var sb strings.Builder
|
||||
entries, _ := os.ReadDir(outDir)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(outDir, entry.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sb.Write(content)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
text := sb.String()
|
||||
if len(text) > maxTextLength {
|
||||
text = text[:maxTextLength] + "\n[... truncated]"
|
||||
}
|
||||
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
Text: text,
|
||||
PageCount: pageCount,
|
||||
}), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
81
pkg/agent/tools/browser/extract_links.go
Normal file
81
pkg/agent/tools/browser/extract_links.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
type (
|
||||
extractLinksParams struct {
|
||||
URL string `json:"url" jsonschema:"The URL to extract links from"`
|
||||
}
|
||||
|
||||
link struct {
|
||||
Href string `json:"href"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
)
|
||||
|
||||
func ExtractLinksTool(b *Browser) agent.Tool {
|
||||
return agent.FunctionTool(
|
||||
"extract_links",
|
||||
"Navigate to a URL and extract all links (<a> elements) with their href and text.",
|
||||
func(ctx context.Context, p extractLinksParams) (agent.ToolResult, error) {
|
||||
if r := b.checkAlive(); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(p.URL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return agent.ResultError("invalid URL scheme: only http and https are allowed"), nil
|
||||
}
|
||||
|
||||
if r := b.checkURL(p.URL); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
ctx, timeoutCancel := withToolTimeout(ctx)
|
||||
defer timeoutCancel()
|
||||
|
||||
tabCtx, cancel := b.NewTab(ctx)
|
||||
defer cancel()
|
||||
|
||||
var links []link
|
||||
|
||||
err = chromedp.Run(
|
||||
tabCtx,
|
||||
chromedp.Navigate(p.URL),
|
||||
waitForPage(),
|
||||
chromedp.Evaluate(
|
||||
`Array.from(document.querySelectorAll("a[href]")).map(a => ({
|
||||
href: a.href,
|
||||
text: a.innerText.trim().substring(0, 200)
|
||||
}))`,
|
||||
&links,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return agent.ResultError(b.classifyError(ctx, p.URL, err)), nil
|
||||
}
|
||||
|
||||
return agent.ResultJSON(links), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
95
pkg/agent/tools/browser/extract_text.go
Normal file
95
pkg/agent/tools/browser/extract_text.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTextLength = 32000
|
||||
)
|
||||
|
||||
type (
|
||||
extractTextParams struct {
|
||||
URL string `json:"url" jsonschema:"The URL to extract text from"`
|
||||
}
|
||||
)
|
||||
|
||||
func ExtractPageTextTool(b *Browser) agent.Tool {
|
||||
return agent.FunctionTool(
|
||||
"extract_page_text",
|
||||
"Navigate to a URL and extract the visible text content of the page, truncated to 32000 characters.",
|
||||
func(ctx context.Context, p extractTextParams) (agent.ToolResult, error) {
|
||||
if r := b.checkAlive(); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
if r := b.checkURL(p.URL); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
if r := checkPDF(p.URL); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
ctx, timeoutCancel := withToolTimeout(ctx)
|
||||
defer timeoutCancel()
|
||||
|
||||
tabCtx, cancel := b.NewTab(ctx)
|
||||
defer cancel()
|
||||
|
||||
var text string
|
||||
|
||||
// Cap the JS-side slice at 4 code units per rune so the
|
||||
// DevTools transfer stays bounded even for huge pages;
|
||||
// the Go-side rune truncation below then produces the
|
||||
// final exact-length output.
|
||||
jsMaxLen := maxTextLength * 4
|
||||
extractJS := fmt.Sprintf(
|
||||
`String(document.body?.innerText ?? '').slice(0, %d)`,
|
||||
jsMaxLen,
|
||||
)
|
||||
|
||||
err := chromedp.Run(
|
||||
tabCtx,
|
||||
chromedp.Navigate(p.URL),
|
||||
waitForPage(),
|
||||
// Scroll to bottom to trigger lazy-loaded content,
|
||||
// then back to top and wait briefly for rendering.
|
||||
chromedp.Evaluate(`window.scrollTo(0, document.body.scrollHeight)`, nil),
|
||||
chromedp.Sleep(500*time.Millisecond),
|
||||
chromedp.Evaluate(`window.scrollTo(0, 0)`, nil),
|
||||
chromedp.Sleep(200*time.Millisecond),
|
||||
chromedp.Evaluate(extractJS, &text),
|
||||
)
|
||||
if err != nil {
|
||||
return agent.ResultError(b.classifyError(ctx, p.URL, err)), nil
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) > maxTextLength {
|
||||
text = string(runes[:maxTextLength])
|
||||
}
|
||||
|
||||
return agent.ToolResult{Content: text}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
107
pkg/agent/tools/browser/fetch_robots.go
Normal file
107
pkg/agent/tools/browser/fetch_robots.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
type (
|
||||
robotsParams struct {
|
||||
Domain string `json:"domain" jsonschema:"The domain to fetch robots.txt from (e.g. example.com)"`
|
||||
}
|
||||
|
||||
robotsResult struct {
|
||||
Found bool `json:"found"`
|
||||
Sitemaps []string `json:"sitemaps,omitempty"`
|
||||
Disallowed []string `json:"disallowed_paths,omitempty"`
|
||||
ErrorDetail string `json:"error_detail,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
func FetchRobotsTxtTool() agent.Tool {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
return agent.FunctionTool(
|
||||
"fetch_robots_txt",
|
||||
"Fetch and parse the robots.txt file for a domain. Returns sitemap URLs and disallowed paths, which can reveal hidden pages the crawler might miss.",
|
||||
func(ctx context.Context, p robotsParams) (agent.ToolResult, error) {
|
||||
if err := validatePublicDomain(p.Domain); err != nil {
|
||||
return agent.ResultJSON(robotsResult{
|
||||
Found: false,
|
||||
ErrorDetail: fmt.Sprintf("domain not allowed: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
u := "https://" + p.Domain + "/robots.txt"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(robotsResult{
|
||||
Found: false,
|
||||
ErrorDetail: fmt.Sprintf("cannot create request: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(robotsResult{
|
||||
Found: false,
|
||||
ErrorDetail: fmt.Sprintf("cannot fetch robots.txt: %s", err),
|
||||
}), nil
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return agent.ResultJSON(robotsResult{
|
||||
Found: false,
|
||||
ErrorDetail: fmt.Sprintf("robots.txt returned status %d", resp.StatusCode),
|
||||
}), nil
|
||||
}
|
||||
|
||||
var result robotsResult
|
||||
result.Found = true
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Directive names are case-insensitive but values
|
||||
// (URLs, paths) are case-sensitive, so extract the
|
||||
// original-case suffix from the raw line rather than
|
||||
// reading it off the lowercased copy.
|
||||
if after, ok := strings.CutPrefix(strings.ToLower(line), "sitemap:"); ok {
|
||||
result.Sitemaps = append(result.Sitemaps, strings.TrimSpace(line[len(line)-len(after):]))
|
||||
}
|
||||
|
||||
if after, ok := strings.CutPrefix(strings.ToLower(line), "disallow:"); ok {
|
||||
path := strings.TrimSpace(line[len(line)-len(after):])
|
||||
if path != "" && len(result.Disallowed) < 50 {
|
||||
result.Disallowed = append(result.Disallowed, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return agent.ResultJSON(result), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
151
pkg/agent/tools/browser/fetch_sitemap.go
Normal file
151
pkg/agent/tools/browser/fetch_sitemap.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
type (
|
||||
sitemapParams struct {
|
||||
URL string `json:"url" jsonschema:"The full URL of the sitemap to fetch (e.g. https://example.com/sitemap.xml)"`
|
||||
}
|
||||
|
||||
sitemapResult struct {
|
||||
Found bool `json:"found"`
|
||||
URLs []string `json:"urls,omitempty"`
|
||||
URLCount int `json:"url_count"`
|
||||
ErrorDetail string `json:"error_detail,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
maxSitemapURLs = 200
|
||||
)
|
||||
|
||||
func FetchSitemapTool() agent.Tool {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
return agent.FunctionTool(
|
||||
"fetch_sitemap",
|
||||
"Fetch and parse a sitemap XML file. Returns discovered URLs which can reveal pages not linked from the main navigation (trust centers, legal docs, status pages).",
|
||||
func(ctx context.Context, p sitemapParams) (agent.ToolResult, error) {
|
||||
if err := validatePublicURL(p.URL); err != nil {
|
||||
return agent.ResultJSON(sitemapResult{
|
||||
Found: false,
|
||||
ErrorDetail: fmt.Sprintf("URL not allowed: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(sitemapResult{
|
||||
Found: false,
|
||||
ErrorDetail: fmt.Sprintf("cannot create request: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(sitemapResult{
|
||||
Found: false,
|
||||
ErrorDetail: fmt.Sprintf("cannot fetch sitemap: %s", err),
|
||||
}), nil
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return agent.ResultJSON(sitemapResult{
|
||||
Found: false,
|
||||
ErrorDetail: fmt.Sprintf("sitemap returned status %d", resp.StatusCode),
|
||||
}), nil
|
||||
}
|
||||
|
||||
var reader io.Reader = resp.Body
|
||||
if strings.HasSuffix(strings.ToLower(p.URL), ".gz") ||
|
||||
resp.Header.Get("Content-Encoding") == "gzip" {
|
||||
gz, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(sitemapResult{
|
||||
Found: false,
|
||||
ErrorDetail: fmt.Sprintf("cannot decompress gzipped sitemap: %s", err),
|
||||
}), nil
|
||||
}
|
||||
defer gz.Close()
|
||||
reader = gz
|
||||
}
|
||||
|
||||
// Limit read to 5MB.
|
||||
reader = io.LimitReader(reader, 5*1024*1024)
|
||||
|
||||
urls, err := parseSitemapXML(reader)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(sitemapResult{
|
||||
Found: false,
|
||||
ErrorDetail: fmt.Sprintf("cannot parse sitemap XML: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
result := sitemapResult{
|
||||
Found: true,
|
||||
URLCount: len(urls),
|
||||
}
|
||||
|
||||
if len(urls) > maxSitemapURLs {
|
||||
result.URLs = urls[:maxSitemapURLs]
|
||||
} else {
|
||||
result.URLs = urls
|
||||
}
|
||||
|
||||
return agent.ResultJSON(result), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func parseSitemapXML(r io.Reader) ([]string, error) {
|
||||
var urls []string
|
||||
decoder := xml.NewDecoder(r)
|
||||
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return urls, err
|
||||
}
|
||||
|
||||
if se, ok := tok.(xml.StartElement); ok && se.Name.Local == "loc" {
|
||||
var loc string
|
||||
if err := decoder.DecodeElement(&loc, &se); err == nil {
|
||||
loc = strings.TrimSpace(loc)
|
||||
if loc != "" {
|
||||
urls = append(urls, loc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
}
|
||||
97
pkg/agent/tools/browser/find_links.go
Normal file
97
pkg/agent/tools/browser/find_links.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
type (
|
||||
findLinksParams struct {
|
||||
URL string `json:"url" jsonschema:"The URL to search for links"`
|
||||
Pattern string `json:"pattern" jsonschema:"Keyword to filter links by (case-insensitive match on href or text)"`
|
||||
}
|
||||
)
|
||||
|
||||
func FindLinksMatchingTool(b *Browser) agent.Tool {
|
||||
return agent.FunctionTool(
|
||||
"find_links_matching",
|
||||
"Navigate to a URL and extract links whose href or text matches a keyword (case-insensitive).",
|
||||
func(ctx context.Context, p findLinksParams) (agent.ToolResult, error) {
|
||||
if r := b.checkAlive(); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
if r := b.checkURL(p.URL); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
if p.Pattern == "" {
|
||||
return agent.ResultError("pattern must not be empty"), nil
|
||||
}
|
||||
|
||||
ctx, timeoutCancel := withToolTimeout(ctx)
|
||||
defer timeoutCancel()
|
||||
|
||||
tabCtx, cancel := b.NewTab(ctx)
|
||||
defer cancel()
|
||||
|
||||
var links []link
|
||||
|
||||
patternJSON, err := json.Marshal(p.Pattern)
|
||||
if err != nil {
|
||||
return agent.ResultErrorf("cannot encode pattern: %s", err), nil
|
||||
}
|
||||
|
||||
js := fmt.Sprintf(
|
||||
`(() => {
|
||||
const pattern = JSON.parse(%s).toLowerCase();
|
||||
const normalize = s => s.replace(/[-_\s]+/g, "");
|
||||
const normalizedPattern = normalize(pattern);
|
||||
return Array.from(document.querySelectorAll("a[href]"))
|
||||
.filter(a => {
|
||||
const href = a.href.toLowerCase();
|
||||
const text = a.innerText.toLowerCase();
|
||||
return href.includes(pattern) || text.includes(pattern)
|
||||
|| normalize(href).includes(normalizedPattern)
|
||||
|| normalize(text).includes(normalizedPattern);
|
||||
})
|
||||
.map(a => ({
|
||||
href: a.href,
|
||||
text: a.innerText.trim().substring(0, 200)
|
||||
}));
|
||||
})()`,
|
||||
string(patternJSON),
|
||||
)
|
||||
|
||||
err = chromedp.Run(
|
||||
tabCtx,
|
||||
chromedp.Navigate(p.URL),
|
||||
waitForPage(),
|
||||
chromedp.Evaluate(js, &links),
|
||||
)
|
||||
if err != nil {
|
||||
return agent.ResultError(b.classifyError(ctx, p.URL, err)), nil
|
||||
}
|
||||
|
||||
return agent.ResultJSON(links), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
118
pkg/agent/tools/browser/helpers.go
Normal file
118
pkg/agent/tools/browser/helpers.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
// waitForPage returns chromedp actions that wait for the page to fully load,
|
||||
// including SPA content rendered by JavaScript. It first waits for the body to
|
||||
// be ready, then polls until the page content stabilizes (innerText stops
|
||||
// changing) with a short debounce. After stabilization, it attempts to dismiss
|
||||
// common cookie consent banners so they don't interfere with content
|
||||
// extraction.
|
||||
func waitForPage() chromedp.Action {
|
||||
return chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if err := chromedp.WaitReady("body").Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for SPA content to stabilize by checking if innerText
|
||||
// length stops changing over a 500ms window. Gives up after 5s.
|
||||
// EvaluateAsDevTools is required to await the Promise.
|
||||
if err := chromedp.EvaluateAsDevTools(`
|
||||
new Promise((resolve) => {
|
||||
let lastLen = -1;
|
||||
let stableCount = 0;
|
||||
const interval = setInterval(() => {
|
||||
const curLen = document.body.innerText.length;
|
||||
if (curLen === lastLen && curLen > 0) {
|
||||
stableCount++;
|
||||
} else {
|
||||
stableCount = 0;
|
||||
}
|
||||
lastLen = curLen;
|
||||
if (stableCount >= 2) {
|
||||
clearInterval(interval);
|
||||
resolve(true);
|
||||
}
|
||||
}, 250);
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve(true);
|
||||
}, 5000);
|
||||
})
|
||||
`, nil).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Dismiss common cookie consent banners. This is best-effort;
|
||||
// failures are silently ignored because not every page has a
|
||||
// banner and the selectors may not match.
|
||||
return chromedp.Evaluate(`
|
||||
(() => {
|
||||
const selectors = [
|
||||
"#onetrust-accept-btn-handler",
|
||||
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll",
|
||||
"#CybotCookiebotDialogBodyButtonAccept",
|
||||
".cky-btn-accept",
|
||||
"[data-testid='cookie-policy-dialog-accept-button']",
|
||||
"button.accept-cookies",
|
||||
"#cookie-accept",
|
||||
"#accept-cookies",
|
||||
".cc-accept",
|
||||
".cc-btn.cc-dismiss",
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const btn = document.querySelector(sel);
|
||||
if (btn) { btn.click(); return; }
|
||||
}
|
||||
const buttons = document.querySelectorAll(
|
||||
"button, a[role='button'], [role='button']"
|
||||
);
|
||||
const patterns = /^(accept all|accept|agree|i agree|allow all|allow|got it|ok|okay|consent)$/i;
|
||||
for (const btn of buttons) {
|
||||
if (patterns.test(btn.innerText.trim())) {
|
||||
btn.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
})()
|
||||
`, nil).Do(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// checkPDF returns an error tool result if the URL points to a PDF file,
|
||||
// which cannot be rendered by the headless browser.
|
||||
func checkPDF(rawURL string) *agent.ToolResult {
|
||||
if strings.HasSuffix(strings.ToLower(rawURL), ".pdf") {
|
||||
return &agent.ToolResult{
|
||||
Content: fmt.Sprintf("cannot load %s: PDF files are not supported by the browser", rawURL),
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func withToolTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(ctx, defaultToolTimeout)
|
||||
}
|
||||
92
pkg/agent/tools/browser/helpers_test.go
Normal file
92
pkg/agent/tools/browser/helpers_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCheckPDF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "lowercase .pdf returns error",
|
||||
url: "https://example.com/document.pdf",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "uppercase .PDF returns error",
|
||||
url: "https://example.com/document.PDF",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "mixed case .Pdf returns error",
|
||||
url: "https://example.com/document.Pdf",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "normal URL returns nil",
|
||||
url: "https://example.com/page",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "URL with .pdf in path but not at end returns nil",
|
||||
url: "https://example.com/pdf-viewer/document",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "URL with .pdf in query but not at end returns nil",
|
||||
url: "https://example.com/view?file=report.pdf&page=1",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "html URL returns nil",
|
||||
url: "https://example.com/page.html",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "URL ending with .pdf and path segments",
|
||||
url: "https://example.com/files/reports/annual.pdf",
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name,
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := checkPDF(tt.url)
|
||||
|
||||
if tt.wantError {
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content, "PDF files are not supported")
|
||||
} else {
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
90
pkg/agent/tools/browser/navigate.go
Normal file
90
pkg/agent/tools/browser/navigate.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
type (
|
||||
navigateParams struct {
|
||||
URL string `json:"url" jsonschema:"The URL to navigate to"`
|
||||
}
|
||||
|
||||
navigateResult struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
FinalURL string `json:"final_url"`
|
||||
}
|
||||
)
|
||||
|
||||
func NavigateToURLTool(b *Browser) agent.Tool {
|
||||
return agent.FunctionTool(
|
||||
"navigate_to_url",
|
||||
"Navigate to a URL and return the page title, meta description, and final URL after redirects.",
|
||||
func(ctx context.Context, p navigateParams) (agent.ToolResult, error) {
|
||||
if r := b.checkAlive(); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
if r := b.checkURL(p.URL); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
if r := checkPDF(p.URL); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
ctx, timeoutCancel := withToolTimeout(ctx)
|
||||
defer timeoutCancel()
|
||||
|
||||
tabCtx, cancel := b.NewTab(ctx)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
title string
|
||||
description string
|
||||
finalURL string
|
||||
)
|
||||
|
||||
err := chromedp.Run(
|
||||
tabCtx,
|
||||
chromedp.Navigate(p.URL),
|
||||
waitForPage(),
|
||||
chromedp.Title(&title),
|
||||
chromedp.Evaluate(
|
||||
`(() => {
|
||||
const meta = document.querySelector('meta[name="description"]');
|
||||
return meta ? meta.getAttribute("content") : "";
|
||||
})()`,
|
||||
&description,
|
||||
),
|
||||
chromedp.Location(&finalURL),
|
||||
)
|
||||
if err != nil {
|
||||
return agent.ResultError(b.classifyError(ctx, p.URL, err)), nil
|
||||
}
|
||||
|
||||
return agent.ResultJSON(navigateResult{
|
||||
Title: title,
|
||||
Description: description,
|
||||
FinalURL: finalURL,
|
||||
}), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
82
pkg/agent/tools/browser/select.go
Normal file
82
pkg/agent/tools/browser/select.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
type (
|
||||
selectParams struct {
|
||||
URL string `json:"url" jsonschema:"The URL to navigate to before selecting"`
|
||||
Selector string `json:"selector" jsonschema:"CSS selector of the select element"`
|
||||
Value string `json:"value" jsonschema:"The option value to select"`
|
||||
}
|
||||
)
|
||||
|
||||
func SelectOptionTool(b *Browser) agent.Tool {
|
||||
return agent.FunctionTool(
|
||||
"select_option",
|
||||
"Navigate to a URL, select an option from a <select> dropdown, and return the page text after selection. Useful for changing page size dropdowns (e.g. 'show 100 per page').",
|
||||
func(ctx context.Context, p selectParams) (agent.ToolResult, error) {
|
||||
if r := b.checkAlive(); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
if r := b.checkURL(p.URL); r != nil {
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
ctx, timeoutCancel := withToolTimeout(ctx)
|
||||
defer timeoutCancel()
|
||||
|
||||
tabCtx, cancel := b.NewTab(ctx)
|
||||
defer cancel()
|
||||
|
||||
var text string
|
||||
|
||||
err := chromedp.Run(
|
||||
tabCtx,
|
||||
chromedp.Navigate(p.URL),
|
||||
waitForPage(),
|
||||
chromedp.WaitVisible(p.Selector),
|
||||
chromedp.SetValue(p.Selector, p.Value),
|
||||
chromedp.Evaluate(
|
||||
fmt.Sprintf(
|
||||
`document.querySelector(%q).dispatchEvent(new Event('change', {bubbles: true}))`,
|
||||
p.Selector,
|
||||
),
|
||||
nil,
|
||||
),
|
||||
waitForPage(),
|
||||
chromedp.Evaluate(`document.body.innerText`, &text),
|
||||
)
|
||||
if err != nil {
|
||||
return agent.ResultError(b.classifyError(ctx, p.URL, err)), nil
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) > maxTextLength {
|
||||
text = string(runes[:maxTextLength])
|
||||
}
|
||||
|
||||
return agent.ToolResult{Content: text}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
191
pkg/agent/tools/browser/sitemap_test.go
Normal file
191
pkg/agent/tools/browser/sitemap_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseSitemapXML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"valid urlset with multiple URLs",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://example.com/</loc></url>
|
||||
<url><loc>https://example.com/about</loc></url>
|
||||
<url><loc>https://example.com/contact</loc></url>
|
||||
</urlset>`
|
||||
|
||||
urls, err := parseSitemapXML(strings.NewReader(xml))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, urls, 3)
|
||||
assert.Equal(t, "https://example.com/", urls[0])
|
||||
assert.Equal(t, "https://example.com/about", urls[1])
|
||||
assert.Equal(t, "https://example.com/contact", urls[2])
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"valid sitemapindex with sitemap locations",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap><loc>https://example.com/sitemap-pages.xml</loc></sitemap>
|
||||
<sitemap><loc>https://example.com/sitemap-posts.xml</loc></sitemap>
|
||||
</sitemapindex>`
|
||||
|
||||
urls, err := parseSitemapXML(strings.NewReader(xml))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, urls, 2)
|
||||
assert.Equal(t, "https://example.com/sitemap-pages.xml", urls[0])
|
||||
assert.Equal(t, "https://example.com/sitemap-posts.xml", urls[1])
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty urlset returns empty slice",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
</urlset>`
|
||||
|
||||
urls, err := parseSitemapXML(strings.NewReader(xml))
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, urls)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"malformed XML returns error",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
xml := `<urlset><url><loc>https://example.com/</loc></url`
|
||||
|
||||
_, err := parseSitemapXML(strings.NewReader(xml))
|
||||
|
||||
assert.Error(t, err)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"urlset without namespace",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset>
|
||||
<url><loc>https://example.com/page1</loc></url>
|
||||
<url><loc>https://example.com/page2</loc></url>
|
||||
</urlset>`
|
||||
|
||||
urls, err := parseSitemapXML(strings.NewReader(xml))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, urls, 2)
|
||||
assert.Equal(t, "https://example.com/page1", urls[0])
|
||||
assert.Equal(t, "https://example.com/page2", urls[1])
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"trims whitespace in loc elements",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset>
|
||||
<url><loc> https://example.com/padded </loc></url>
|
||||
</urlset>`
|
||||
|
||||
urls, err := parseSitemapXML(strings.NewReader(xml))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, urls, 1)
|
||||
assert.Equal(t, "https://example.com/padded", urls[0])
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"skips empty loc elements",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset>
|
||||
<url><loc></loc></url>
|
||||
<url><loc>https://example.com/valid</loc></url>
|
||||
<url><loc> </loc></url>
|
||||
</urlset>`
|
||||
|
||||
urls, err := parseSitemapXML(strings.NewReader(xml))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, urls, 1)
|
||||
assert.Equal(t, "https://example.com/valid", urls[0])
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty reader returns empty slice",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
urls, err := parseSitemapXML(strings.NewReader(""))
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, urls)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"urlset with additional elements besides loc",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://example.com/page</loc>
|
||||
<lastmod>2024-01-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
</urlset>`
|
||||
|
||||
urls, err := parseSitemapXML(strings.NewReader(xml))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, urls, 1)
|
||||
assert.Equal(t, "https://example.com/page", urls[0])
|
||||
},
|
||||
)
|
||||
}
|
||||
65
pkg/agent/tools/browser/toolset.go
Normal file
65
pkg/agent/tools/browser/toolset.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
// ReadOnlyToolset provides browser tools that only read page content.
|
||||
type ReadOnlyToolset struct {
|
||||
browser *Browser
|
||||
}
|
||||
|
||||
// NewReadOnlyToolset creates a read-only browser toolset.
|
||||
func NewReadOnlyToolset(b *Browser) *ReadOnlyToolset {
|
||||
return &ReadOnlyToolset{browser: b}
|
||||
}
|
||||
|
||||
func (t *ReadOnlyToolset) Tools() []agent.Tool {
|
||||
return []agent.Tool{
|
||||
NavigateToURLTool(t.browser),
|
||||
ExtractPageTextTool(t.browser),
|
||||
ExtractLinksTool(t.browser),
|
||||
FindLinksMatchingTool(t.browser),
|
||||
FetchRobotsTxtTool(),
|
||||
FetchSitemapTool(),
|
||||
DownloadPDFTool(),
|
||||
}
|
||||
}
|
||||
|
||||
// InteractiveToolset provides all browser tools including click and select.
|
||||
type InteractiveToolset struct {
|
||||
browser *Browser
|
||||
}
|
||||
|
||||
// NewInteractiveToolset creates an interactive browser toolset.
|
||||
func NewInteractiveToolset(b *Browser) *InteractiveToolset {
|
||||
return &InteractiveToolset{browser: b}
|
||||
}
|
||||
|
||||
func (t *InteractiveToolset) Tools() []agent.Tool {
|
||||
return []agent.Tool{
|
||||
NavigateToURLTool(t.browser),
|
||||
ExtractPageTextTool(t.browser),
|
||||
ExtractLinksTool(t.browser),
|
||||
FindLinksMatchingTool(t.browser),
|
||||
ClickElementTool(t.browser),
|
||||
SelectOptionTool(t.browser),
|
||||
FetchRobotsTxtTool(),
|
||||
FetchSitemapTool(),
|
||||
DownloadPDFTool(),
|
||||
}
|
||||
}
|
||||
33
pkg/agent/tools/browser/url_check.go
Normal file
33
pkg/agent/tools/browser/url_check.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
|
||||
)
|
||||
|
||||
// validatePublicURL checks that a URL uses an http(s) scheme and that its
|
||||
// host does not resolve to a private, loopback, or link-local IP address.
|
||||
// This prevents SSRF attacks where the LLM could be tricked into requesting
|
||||
// internal network endpoints.
|
||||
func validatePublicURL(rawURL string) error {
|
||||
return netcheck.ValidatePublicURL(rawURL)
|
||||
}
|
||||
|
||||
// validatePublicDomain checks that a domain does not resolve to a private,
|
||||
// loopback, or link-local IP address.
|
||||
func validatePublicDomain(domain string) error {
|
||||
return netcheck.ValidatePublicDomain(domain)
|
||||
}
|
||||
Reference in New Issue
Block a user