Files
probo/pkg/vetting/assessment.go
Aurélien Sibiril 509d0c88b1 Add vendor assessment agent
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-04-24 08:43:14 +02:00

338 lines
14 KiB
Go

// 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 vetting
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"net/url"
"time"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/agent/tools/browser"
"go.probo.inc/probo/pkg/llm"
)
const (
// DefaultMaxTokens is the fallback max-tokens budget used when the
// vendor-assessor agent config does not specify a value. Sized to
// leave headroom above the orchestrator's thinking budget on
// Anthropic models.
DefaultMaxTokens = 16384
// AssessmentTimeout is the hard upper bound on a single assessment
// run. This is also the timeout the CLI client should use.
AssessmentTimeout = 20 * time.Minute
// extractionTimeout is the dedicated budget for the final
// vendor_info_extractor turn. It runs outside the orchestrator's
// budget so a slow orchestrator can't starve the extractor.
extractionTimeout = 5 * time.Minute
)
// vendorCategoryEnum is the canonical list of allowed values for
// VendorInfo.Category. It is duplicated into the jsonschema struct tag
// because Go struct tags must be compile-time string literals.
var vendorCategoryEnum = []string{
"ANALYTICS", "ACCOUNTING", "CLOUD_MONITORING", "CLOUD_PROVIDER",
"COLLABORATION", "CONSULTING", "CUSTOMER_SUPPORT",
"DATA_STORAGE_AND_PROCESSING", "DOCUMENT_MANAGEMENT",
"EMPLOYEE_MANAGEMENT", "ENGINEERING", "FINANCE", "IDENTITY_PROVIDER",
"IT", "LEGAL", "MARKETING", "OFFICE_OPERATIONS", "OTHER",
"PASSWORD_MANAGEMENT", "PRODUCT_AND_DESIGN", "PROFESSIONAL_SERVICES",
"RECRUITING", "SALES", "SECURITY", "STAFFING", "VERSION_CONTROL",
}
// vendorTypeEnum is the canonical list of allowed values for
// VendorInfo.VendorType.
var vendorTypeEnum = []string{
"SAAS", "INFRASTRUCTURE", "PROFESSIONAL_SERVICES", "STAFFING", "OTHER",
}
var (
//go:embed prompts/extraction.txt
extractionPrompt string
)
type (
Config struct {
Client *llm.Client
Model string
MaxTokens int
ChromeAddr string
SearchEndpoint string
Logger *log.Logger
}
Assessor struct {
cfg Config
}
Subprocessor struct {
Name string `json:"name"`
Country string `json:"country"`
Purpose string `json:"purpose"`
}
RiskScore struct {
Category string `json:"category"`
Rating string `json:"rating"`
Notes string `json:"notes"`
}
VendorInfo struct {
Name string `json:"name" jsonschema:"Vendor display name as shown on the website"`
Description string `json:"description" jsonschema:"One-sentence description of what the vendor does"`
Category string `json:"category" jsonschema:"Vendor category; one of vendorCategoryEnum"`
VendorType string `json:"vendor_type" jsonschema:"Vendor type; one of vendorTypeEnum"`
HeadquarterAddress string `json:"headquarter_address" jsonschema:"Vendor headquarters address (city, country) if mentioned"`
LegalName string `json:"legal_name" jsonschema:"Legal entity name if different from display name (e.g. 'Datadog, Inc.')"`
PrivacyPolicyURL string `json:"privacy_policy_url" jsonschema:"URL to the vendor's privacy policy page"`
ServiceLevelAgreementURL string `json:"service_level_agreement_url" jsonschema:"URL to the SLA page"`
DataProcessingAgreementURL string `json:"data_processing_agreement_url" jsonschema:"URL to the DPA page"`
BusinessAssociateAgreementURL string `json:"business_associate_agreement_url" jsonschema:"URL to the BAA page if HIPAA-eligible"`
SubprocessorsListURL string `json:"subprocessors_list_url" jsonschema:"URL to the public subprocessors list"`
SecurityPageURL string `json:"security_page_url" jsonschema:"URL to the vendor's security page"`
TrustPageURL string `json:"trust_page_url" jsonschema:"URL to the trust center"`
TermsOfServiceURL string `json:"terms_of_service_url" jsonschema:"URL to the terms of service"`
StatusPageURL string `json:"status_page_url" jsonschema:"URL to the vendor's status / uptime page"`
BugBountyURL string `json:"bug_bounty_url" jsonschema:"URL to the bug bounty or responsible disclosure program"`
IncidentResponseURL string `json:"incident_response_url" jsonschema:"URL to incident response or post-mortem documentation"`
DataLocations []string `json:"data_locations" jsonschema:"Countries or regions where data is processed or stored (e.g. 'United States', 'EU', 'Germany')"`
Certifications []string `json:"certifications" jsonschema:"Compliance certifications found (e.g. 'SOC 2 Type II', 'ISO 27001')"`
Subprocessors []Subprocessor `json:"subprocessors" jsonschema:"Sub-processors discovered with name, country, purpose"`
// Privacy classification (ISO 27701).
PrivacyRole string `json:"privacy_role" jsonschema:"Privacy role under ISO 27701: CONTROLLER, PROCESSOR, SUBPROCESSOR, NONE"`
ProcessesPII bool `json:"processes_pii" jsonschema:"Whether the vendor processes personal data"`
CrossBorderTransfer bool `json:"cross_border_transfer" jsonschema:"Whether cross-border data transfers occur"`
// Privacy risk fields.
DPAStatus string `json:"dpa_status" jsonschema:"DPA accessibility: AVAILABLE, AVAILABLE_ON_REQUEST, NOT_FOUND, BEHIND_LOGIN"`
DSARCapability string `json:"dsar_capability" jsonschema:"Brief summary of how the vendor handles Data Subject Access Requests"`
DataMinimization string `json:"data_minimization" jsonschema:"Brief summary of data minimization practices"`
PurposeLimitation string `json:"purpose_limitation" jsonschema:"Brief summary of purpose limitation commitments"`
RetentionPolicy string `json:"retention_policy" jsonschema:"Brief summary of data retention policy"`
DeletionPolicy string `json:"deletion_policy" jsonschema:"Brief summary of data deletion policy"`
// AI classification (ISO 42001).
InvolvesAI bool `json:"involves_ai" jsonschema:"Whether the vendor uses AI/ML in their product or service"`
AIUseCases []string `json:"ai_use_cases" jsonschema:"Array of AI use case descriptions (e.g. 'content generation', 'fraud detection')"`
// AI risk fields.
AIGovernanceDocURL string `json:"ai_governance_doc_url" jsonschema:"URL to AI governance or responsible AI documentation"`
AITransparency string `json:"ai_transparency" jsonschema:"Brief summary of model transparency findings"`
BiasControls string `json:"bias_controls" jsonschema:"Brief summary of bias detection and fairness measures"`
HumanOversight string `json:"human_oversight" jsonschema:"Brief summary of human oversight mechanisms for AI decisions"`
TrainingDataGovernance string `json:"training_data_governance" jsonschema:"Brief summary of training data governance"`
// Contractual clause analysis.
PrivacyClauses []string `json:"privacy_clauses" jsonschema:"Notable privacy contractual clauses found (e.g. '72-hour breach notification', 'SCCs included')"`
AIClauses []string `json:"ai_clauses" jsonschema:"Notable AI contractual clauses found (e.g. 'Customer data not used for training')"`
// Minimum acceptance baseline.
MinimumBaselineMet bool `json:"minimum_baseline_met" jsonschema:"Whether all hard-reject baseline criteria are met"`
BaselineFailures []string `json:"baseline_failures" jsonschema:"List of failed baseline criteria descriptions"`
// Risk scoring.
OverallRiskRating string `json:"overall_risk_rating" jsonschema:"Overall risk rating: Low, Medium, High"`
OverallRiskScore int `json:"overall_risk_score" jsonschema:"Overall risk score from the report (0-100)"`
Recommendation string `json:"recommendation" jsonschema:"Recommendation: APPROVE, APPROVE_WITH_CONDITIONS, ESCALATE, REJECT"`
RiskScores []RiskScore `json:"risk_scores" jsonschema:"Per-category risk scores from the Risk Summary table"`
SecurityRiskScore int `json:"security_risk_score" jsonschema:"Security pillar risk score (0-100)"`
PrivacyRiskScore int `json:"privacy_risk_score" jsonschema:"Privacy pillar risk score (0-100)"`
AIRiskScore int `json:"ai_risk_score" jsonschema:"AI pillar risk score (0-100), 0 if no AI"`
InformationGaps []string `json:"information_gaps" jsonschema:"Concise descriptions of information gaps from the report"`
ProfessionalLicenses []string `json:"professional_licenses" jsonschema:"Professional license descriptions for services firms (e.g. 'New York State Bar')"`
IndustryMemberships []string `json:"industry_memberships" jsonschema:"Industry body memberships (e.g. 'AICPA', 'American Bar Association')"`
InsuranceCoverage string `json:"insurance_coverage" jsonschema:"Description of professional liability or E&O insurance"`
}
Result struct {
Document string
Info VendorInfo
}
)
func NewAssessor(cfg Config) *Assessor {
return &Assessor{cfg: cfg}
}
func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure string, reporter agent.ProgressReporter) (*Result, error) {
u, err := url.Parse(websiteURL)
if err != nil {
return nil, fmt.Errorf("cannot parse website URL %q: %w", websiteURL, err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("website URL must use http or https, got %q", u.Scheme)
}
if u.Hostname() == "" {
return nil, fmt.Errorf("website URL %q has no host", websiteURL)
}
// Detach from the caller's context (typically the HTTP request) so
// that the assessment is not cancelled when the client disconnects.
// A dedicated timeout prevents the assessment from running forever.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), AssessmentTimeout)
defer cancel()
vendorBrowser := browser.NewBrowser(ctx, a.cfg.ChromeAddr)
defer vendorBrowser.Close()
vendorBrowser.SetAllowedDomain(u.Hostname())
// Create an unrestricted browser for web search agents that need to
// follow links to external sites (news, reviews, etc.).
researchBrowser := browser.NewBrowser(ctx, a.cfg.ChromeAddr)
defer researchBrowser.Close()
orchestrator, err := newOrchestratorAgent(
a.cfg.Client,
a.cfg.Model,
a.cfg.MaxTokens,
procedure,
a.cfg.Logger,
vendorBrowser,
researchBrowser,
a.cfg.SearchEndpoint,
reporter,
)
if err != nil {
return nil, fmt.Errorf("cannot create orchestrator agent: %w", err)
}
result, err := orchestrator.Run(
ctx,
[]llm.Message{
{
Role: llm.RoleUser,
Parts: []llm.Part{llm.TextPart{Text: websiteURL}},
},
},
)
if err != nil {
return nil, fmt.Errorf("cannot assess vendor: %w", err)
}
document := result.FinalMessage().Text()
reportProgress(ctx, reporter, "extract_vendor_info", agent.ProgressEventStepStarted)
info, err := a.extractVendorInfo(ctx, document)
if err != nil {
reportProgress(ctx, reporter, "extract_vendor_info", agent.ProgressEventStepFailed)
return nil, fmt.Errorf("cannot extract vendor info: %w", err)
}
reportProgress(ctx, reporter, "extract_vendor_info", agent.ProgressEventStepCompleted)
return &Result{
Document: document,
Info: *info,
}, nil
}
func (a *Assessor) extractVendorInfo(ctx context.Context, document string) (*VendorInfo, error) {
outputType, err := vendorInfoOutputType()
if err != nil {
return nil, fmt.Errorf("cannot build vendor info output type: %w", err)
}
// Run the extractor on its own timeout so a slow orchestrator
// cannot starve the final JSON conversion step. The extractor has
// no tools and produces one structured JSON output; a few minutes
// is more than enough even when streaming is forced.
extractCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx),
extractionTimeout,
)
defer cancel()
extractor := agent.New(
"vendor_info_extractor",
a.cfg.Client,
agent.WithInstructions(extractionPrompt),
agent.WithModel(a.cfg.Model),
agent.WithMaxTokens(a.cfg.MaxTokens),
agent.WithLogger(a.cfg.Logger),
agent.WithOutputType(outputType),
)
result, err := extractor.Run(
extractCtx,
[]llm.Message{
{
Role: llm.RoleUser,
Parts: []llm.Part{llm.TextPart{Text: document}},
},
},
)
if err != nil {
return nil, fmt.Errorf("cannot extract vendor info: %w", err)
}
var info VendorInfo
if err := json.Unmarshal([]byte(result.FinalMessage().Text()), &info); err != nil {
return nil, fmt.Errorf("cannot parse vendor info output: %w", err)
}
return &info, nil
}
// vendorInfoOutputType builds the VendorInfo structured output type and
// decorates its JSON Schema with explicit enum constraints on fields
// whose allowed values live in package-level slices. jsonschema-go only
// reads struct tags as free-form descriptions, so the enum list cannot
// be encoded in the tag itself.
func vendorInfoOutputType() (*agent.OutputType, error) {
outputType, err := agent.NewOutputType[VendorInfo]("vendor_info")
if err != nil {
return nil, fmt.Errorf("cannot create vendor info output type: %w", err)
}
var schema map[string]any
if err := json.Unmarshal(outputType.Schema, &schema); err != nil {
return nil, fmt.Errorf("cannot unmarshal vendor info schema: %w", err)
}
properties, ok := schema["properties"].(map[string]any)
if !ok {
return nil, fmt.Errorf("vendor info schema has no properties")
}
enums := map[string][]string{
"category": vendorCategoryEnum,
"vendor_type": vendorTypeEnum,
}
for field, values := range enums {
prop, ok := properties[field].(map[string]any)
if !ok {
return nil, fmt.Errorf("vendor info schema has no %q property", field)
}
prop["enum"] = values
}
decorated, err := json.Marshal(schema)
if err != nil {
return nil, fmt.Errorf("cannot marshal decorated vendor info schema: %w", err)
}
outputType.Schema = decorated
return outputType, nil
}