Add vendor assessment agent

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-04-22 22:36:14 +02:00
parent 25c590ffe6
commit 509d0c88b1
108 changed files with 9445 additions and 645 deletions

View 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()
}

View 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
},
)
}

View 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
},
)
}

View 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
},
)
}

View 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
},
)
}

View 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
},
)
}

View 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
}

View 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
},
)
}

View 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)
}

View 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)
}
},
)
}
}

View 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
},
)
}

View 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
},
)
}

View 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])
},
)
}

View 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(),
}
}

View 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)
}