Add async third-party vetting

Queue vetting on third_parties with PENDING, PROCESSING,
COMPLETED, and FAILED states. Expose enqueue and status through
GraphQL, MCP, CLI, and n8n, validate vet requests, tune the
worker via config, and poll the detail page while vetting runs.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-27 13:34:49 +02:00
parent 1a71d15bc5
commit 6e7c96732f
59 changed files with 3251 additions and 520 deletions

View File

@@ -73,6 +73,11 @@
# PROBO_AGENT_MODEL_NAME=claude-sonnet-4-6
# EVIDENCE_DESCRIBER_PROVIDER=openai
# EVIDENCE_DESCRIBER_MODEL_NAME=gpt-4o-mini
# AGENT_THIRD_PARTY_VETTER_PROVIDER=openai # inherits AGENT_DEFAULT_PROVIDER when unset
# AGENT_THIRD_PARTY_VETTER_MODEL_NAME=gpt-4o # inherits AGENT_DEFAULT_MODEL_NAME when unset
# THIRD_PARTY_VETTING_INTERVAL=10
# THIRD_PARTY_VETTING_STALE_AFTER=1500
# THIRD_PARTY_VETTING_MAX_CONCURRENCY=1
# ── OIDC sign-in providers ────────────────────────────────────────────
# AUTH_GOOGLE_CLIENT_ID=

View File

@@ -214,6 +214,7 @@ cfg/dev.yaml: bin/probod-bootstrap $(CFG_DEV_OAUTH2_KEY) compose/pebble/certs/ro
AWS_SECRET_ACCESS_KEY=thisisnotasecret; \
AWS_ENDPOINT=http://127.0.0.1:8333; \
OPENAI_API_KEY=thisisnotasecret; \
AGENT_THIRD_PARTY_VETTER_PROVIDER=openai; \
ACME_DIRECTORY=https://localhost:14000/dir; \
ACME_ROOT_CA="$$($(CAT) compose/pebble/certs/rootCA.pem)"; \
if [ -f $(DEV_ENV) ]; then . $(DEV_ENV); fi; \

View File

@@ -185,7 +185,8 @@ export const thirdPartyNodeQuery = graphql`
name
websiteUrl
firstLevel
canAssess: permission(action: "core:thirdParty:assess")
vettingStatus
canVet: permission(action: "core:thirdParty:vet")
canUpdate: permission(action: "core:thirdParty:update")
canDelete: permission(action: "core:thirdParty:delete")
canUploadComplianceReport: permission(

View File

@@ -26,13 +26,16 @@ import {
TabLink,
Tabs,
} from "@probo/ui";
import { useEffect, useRef } from "react";
import {
ConnectionHandler,
type PreloadedQuery,
useFragment,
usePreloadedQuery,
useRelayEnvironment,
} from "react-relay";
import { Outlet } from "react-router";
import { fetchQuery } from "relay-runtime";
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
@@ -43,7 +46,7 @@ import {
} from "#/hooks/graph/ThirdPartyGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { ImportAssessmentDialog } from "./dialogs/ImportAssessmentDialog";
import { VettingDialog } from "./dialogs/VettingDialog";
import { measuresFragment } from "./measures/ThirdPartyMeasuresPage";
import { complianceReportsFragment } from "./tabs/ThirdPartyComplianceTab";
@@ -54,9 +57,34 @@ type Props = {
};
export default function ThirdPartyDetailPage(props: Props) {
const environment = useRelayEnvironment();
const { node: thirdParty } = usePreloadedQuery(thirdPartyNodeQuery, props.queryRef);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const thirdPartyIdRef = useRef(thirdParty.id);
useEffect(() => {
thirdPartyIdRef.current = thirdParty.id;
}, [thirdParty.id]);
const isVetting = thirdParty.vettingStatus === "PENDING" || thirdParty.vettingStatus === "PROCESSING";
useEffect(() => {
if (!isVetting) return;
const interval = setInterval(() => {
if (document.hidden) return;
fetchQuery<ThirdPartyGraphNodeQuery>(
environment,
thirdPartyNodeQuery,
{ thirdPartyId: thirdPartyIdRef.current },
{ fetchPolicy: "network-only" },
).subscribe({});
}, 5000);
return () => clearInterval(interval);
}, [isVetting, environment]);
const deleteThirdParty = useDeleteThirdParty(
thirdParty,
@@ -74,8 +102,24 @@ export default function ThirdPartyDetailPage(props: Props) {
const baseThirdPartyUrl
= `/organizations/${organizationId}/third-parties/${thirdParty.id}`;
const isVettingFailed = thirdParty.vettingStatus === "FAILED";
return (
<div className="space-y-6">
{isVetting && (
<div className="flex items-center gap-3 rounded-lg bg-warning px-4 py-3 text-sm text-txt-warning">
<div
aria-hidden
className="size-4 shrink-0 animate-spin rounded-full border-2 border-border-warning/30 border-t-border-warning"
/>
{__("Vetting is in progress. Results will appear once the analysis is complete.")}
</div>
)}
{isVettingFailed && (
<div className="rounded-lg bg-danger px-4 py-3 text-sm text-txt-danger">
{__("Vetting failed. You can start vetting again.")}
</div>
)}
<Breadcrumb
items={[
{
@@ -104,12 +148,15 @@ export default function ThirdPartyDetailPage(props: Props) {
</div>
</div>
<div className="flex gap-2 items-center">
{thirdParty.canAssess && (
<ImportAssessmentDialog thirdPartyId={thirdParty.id}>
{thirdParty.canVet && !isVetting && (
<VettingDialog
thirdPartyId={thirdParty.id}
websiteUrl={thirdParty.websiteUrl}
>
<Button icon={IconPageTextLine} variant="secondary">
{__("Assessment From Website")}
{__("Start Vetting")}
</Button>
</ImportAssessmentDialog>
</VettingDialog>
)}
{thirdParty.canDelete && (
<ActionDropdown variant="secondary">

View File

@@ -12,6 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
@@ -20,25 +21,28 @@ import {
DialogFooter,
Field,
useDialogRef,
useToast,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { VettingDialogMutation } from "#/__generated__/core/VettingDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const schema = z.object({
url: z.string().url(),
});
const importAssessmentMutation = graphql`
mutation ImportAssessmentDialogMutation($input: AssessThirdPartyInput!) {
assessThirdParty(input: $input) {
const vetMutation = graphql`
mutation VettingDialogMutation($input: VetThirdPartyInput!) {
vetThirdParty(input: $input) {
thirdParty {
id
name
websiteUrl
vettingStatus
...useThirdPartyFormFragment
...ThirdPartyComplianceTabFragment
...ThirdPartyRiskAssessmentTabFragment
@@ -47,42 +51,64 @@ const importAssessmentMutation = graphql`
}
`;
type Props = {
interface VettingDialogProps {
thirdPartyId: string;
websiteUrl?: string | null;
children: ReactNode;
};
}
export function ImportAssessmentDialog({ thirdPartyId, children }: Props) {
export function VettingDialog({ thirdPartyId, websiteUrl, children }: VettingDialogProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const { register, handleSubmit, reset, formState } = useFormWithSchema(
schema,
{
defaultValues: {
url: "",
url: websiteUrl ?? "",
},
},
);
const [assess, isAssessing] = useMutationWithToasts(
importAssessmentMutation,
{
successMessage: __("Third party assessed successfully."),
errorMessage: __("Failed to assess third party"),
},
);
const [vet, isVetting] = useMutation<VettingDialogMutation>(vetMutation);
const onSubmit = async (data: z.infer<typeof schema>) => {
await assess({
const onSubmit = (data: z.infer<typeof schema>) => {
vet({
variables: {
input: {
id: thirdPartyId,
websiteUrl: data.url,
},
},
onSuccess: () => {
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(
__("Failed to start vetting."),
errors as GraphQLError[],
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("The third party is being vetted in the background."),
variant: "success",
});
dialogRef.current?.close();
reset();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to start vetting."),
error as GraphQLError,
),
variant: "error",
});
},
});
};
@@ -90,22 +116,22 @@ export function ImportAssessmentDialog({ thirdPartyId, children }: Props) {
<Dialog
ref={dialogRef}
trigger={children}
title={__("Assessment from website")}
title={__("Start Vetting")}
className="max-w-lg"
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<Field
required
label={__("URL")}
label={__("Website URL")}
type="text"
{...register("url")}
error={formState.errors.url?.message}
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isAssessing}>
{__("Assess")}
<Button type="submit" disabled={isVetting}>
{__("Start Vetting")}
</Button>
</DialogFooter>
</form>

View File

@@ -214,7 +214,9 @@ function AssessmentRow(props: AssessmentRowProps) {
{__("Notes")}
:
</div>
<p className="text-txt-secondary">{assessment.notes}</p>
<p className="text-sm text-txt-secondary whitespace-pre-wrap">
{assessment.notes}
</p>
</div>
</Td>
</Tr>

View File

@@ -247,6 +247,36 @@ spec:
name: {{ include "probo.fullname" . }}
key: firecrawl-api-key
{{- end }}
# Third-Party Vetter Agent
{{- if .Values.probo.thirdPartyVetter.provider }}
- name: AGENT_THIRD_PARTY_VETTER_PROVIDER
value: {{ .Values.probo.thirdPartyVetter.provider | quote }}
{{- end }}
{{- if .Values.probo.thirdPartyVetter.modelName }}
- name: AGENT_THIRD_PARTY_VETTER_MODEL_NAME
value: {{ .Values.probo.thirdPartyVetter.modelName | quote }}
{{- end }}
{{- if .Values.probo.thirdPartyVetter.temperature }}
- name: AGENT_THIRD_PARTY_VETTER_TEMPERATURE
value: {{ .Values.probo.thirdPartyVetter.temperature | quote }}
{{- end }}
{{- if .Values.probo.thirdPartyVetter.maxTokens }}
- name: AGENT_THIRD_PARTY_VETTER_MAX_TOKENS
value: {{ .Values.probo.thirdPartyVetter.maxTokens | quote }}
{{- end }}
# Third-Party Vetting Worker
{{- if .Values.probo.thirdPartyVettingWorker.interval }}
- name: THIRD_PARTY_VETTING_INTERVAL
value: {{ .Values.probo.thirdPartyVettingWorker.interval | quote }}
{{- end }}
{{- if .Values.probo.thirdPartyVettingWorker.maxConcurrency }}
- name: THIRD_PARTY_VETTING_MAX_CONCURRENCY
value: {{ .Values.probo.thirdPartyVettingWorker.maxConcurrency | quote }}
{{- end }}
{{- if .Values.probo.thirdPartyVettingWorker.staleAfter }}
- name: THIRD_PARTY_VETTING_STALE_AFTER
value: {{ .Values.probo.thirdPartyVettingWorker.staleAfter | quote }}
{{- end }}
# Tracker Mapping Agent
{{- if .Values.probo.trackerMapping.provider }}
- name: AGENT_TRACKER_MAPPING_PROVIDER

View File

@@ -169,6 +169,19 @@ probo:
# agentTools:
# firecrawlApiKey: "CHANGE_ME_FIRECRAWL_API_KEY"
# Third-party vetter agent (optional, AI-powered third-party vetting)
# thirdPartyVetter:
# provider: "openai"
# modelName: "gpt-4o"
# temperature: "0.3"
# maxTokens: "8192"
# Third-party vetting worker tuning (optional; seconds for interval/staleAfter).
# thirdPartyVettingWorker:
# interval: 10
# maxConcurrency: 1
# staleAfter: 1500
# Tracker mapping agent (optional, auto-links tracker patterns to vendors)
# trackerMapping:
# provider: "openai"

View File

@@ -266,9 +266,24 @@ probo:
# Agent tools (optional, shared across agents)
agentTools:
# Firecrawl web search API key (used by tracker mapping and third-party assessor agents)
# Firecrawl web search API key (used by tracker mapping and third-party vetter agents)
firecrawlApiKey: ""
# Third-party vetter agent (optional, requires openai.apiKey or anthropic key)
thirdPartyVetter:
provider: ""
modelName: ""
temperature: ""
maxTokens: ""
# Third-party vetting background worker tuning (optional). interval and
# staleAfter are in seconds. Keep concurrency modest to stay under LLM
# rate limits and the database connection pool.
thirdPartyVettingWorker:
interval: 10
maxConcurrency: 1
staleAfter: 1500
# Tracker mapping agent (optional, requires openai.apiKey or anthropic key)
trackerMapping:
provider: ""

View File

@@ -987,18 +987,17 @@ func TestThirdParty_OmittableWebsiteUrl(t *testing.T) {
})
}
// TestThirdParty_Assess exercises the assessThirdParty mutation through authorization
// and tenant-isolation paths without running the real LLM/browser pipeline.
// The e2e config deliberately omits `llm.third-party-assessor.provider`, so an
// authorized call reaches DisabledThirdPartyAssessor and surfaces a stable
// UNAVAILABLE error. Happy-path payload shape is covered by unit tests in
// pkg/probo.
func TestThirdParty_Assess(t *testing.T) {
// TestThirdParty_Vet exercises the vetThirdParty mutation through authorization
// and tenant-isolation paths without running the real LLM/browser pipeline to
// completion. The e2e config sets OPENAI_API_KEY and inherits the default
// agent provider, so authorized calls enqueue vetting and return the third
// party. Request validation is covered by unit tests in pkg/thirdparty.
func TestThirdParty_Vet(t *testing.T) {
t.Parallel()
const query = `
mutation AssessThirdParty($input: AssessThirdPartyInput!) {
assessThirdParty(input: $input) {
mutation VetThirdParty($input: VetThirdPartyInput!) {
vetThirdParty(input: $input) {
thirdParty {
id
}
@@ -1007,18 +1006,18 @@ func TestThirdParty_Assess(t *testing.T) {
`
type resultShape struct {
AssessThirdParty struct {
VetThirdParty struct {
ThirdParty struct {
ID string `json:"id"`
} `json:"thirdParty"`
} `json:"assessThirdParty"`
} `json:"vetThirdParty"`
}
t.Run("owner call surfaces the disabled error", func(t *testing.T) {
t.Run("owner call enqueues vetting", func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
thirdPartyID := factory.NewThirdParty(owner).WithName("Unconfigured assess").Create()
thirdPartyID := factory.NewThirdParty(owner).WithName("Unconfigured vet").Create()
var result resultShape
@@ -1028,15 +1027,16 @@ func TestThirdParty_Assess(t *testing.T) {
"websiteUrl": "https://thirdParty.example.com",
},
}, &result)
testutil.RequireErrorCode(t, err, "UNAVAILABLE")
require.NoError(t, err)
assert.Equal(t, thirdPartyID, result.VetThirdParty.ThirdParty.ID)
})
t.Run("admin call surfaces the disabled error", func(t *testing.T) {
t.Run("admin call enqueues vetting", func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
thirdPartyID := factory.NewThirdParty(owner).WithName("Admin-assessed thirdParty").Create()
thirdPartyID := factory.NewThirdParty(owner).WithName("Admin-vetted thirdParty").Create()
var result resultShape
@@ -1046,10 +1046,11 @@ func TestThirdParty_Assess(t *testing.T) {
"websiteUrl": "https://admin.example.com",
},
}, &result)
testutil.RequireErrorCode(t, err, "UNAVAILABLE")
require.NoError(t, err)
assert.Equal(t, thirdPartyID, result.VetThirdParty.ThirdParty.ID)
})
t.Run("viewer cannot assess a thirdParty", func(t *testing.T) {
t.Run("viewer cannot vet a thirdParty", func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
@@ -1067,7 +1068,7 @@ func TestThirdParty_Assess(t *testing.T) {
testutil.RequireForbiddenError(t, err)
})
t.Run("cannot assess thirdParty from another organization", func(t *testing.T) {
t.Run("cannot vet thirdParty from another organization", func(t *testing.T) {
t.Parallel()
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
@@ -1082,7 +1083,7 @@ func TestThirdParty_Assess(t *testing.T) {
"websiteUrl": "https://cross-tenant.example.com",
},
}, &result)
require.Error(t, err, "thirdParty assess must not cross tenant boundaries")
require.Error(t, err, "thirdParty vet must not cross tenant boundaries")
})
t.Run("procedure is accepted on the input", func(t *testing.T) {
@@ -1100,7 +1101,8 @@ func TestThirdParty_Assess(t *testing.T) {
"procedure": "Focus on SOC 2 controls and data residency",
},
}, &result)
testutil.RequireErrorCode(t, err, "UNAVAILABLE")
require.NoError(t, err)
assert.Equal(t, thirdPartyID, result.VetThirdParty.ThirdParty.ID)
})
}

View File

@@ -43,6 +43,7 @@ import * as linkThirdPartyOp from './linkThirdParty.operation';
import * as unlinkThirdPartyOp from './unlinkThirdParty.operation';
import * as listChildThirdPartiesOp from './listChildThirdParties.operation';
import * as publishOp from './publish.operation';
import * as vetOp from './vet.operation';
export const description: INodeProperties[] = [
{
@@ -236,6 +237,12 @@ export const description: INodeProperties[] = [
description: 'Update an existing third party service',
action: 'Update a third party service',
},
{
name: 'Vet',
value: 'vet',
description: 'Start AI-powered vetting of a third party from its website',
action: 'Vet a third party',
},
],
default: 'create',
},
@@ -269,6 +276,7 @@ export const description: INodeProperties[] = [
...unlinkThirdPartyOp.description,
...listChildThirdPartiesOp.description,
...publishOp.description,
...vetOp.description,
];
export {
@@ -302,4 +310,5 @@ export {
unlinkThirdPartyOp as unlinkThirdParty,
listChildThirdPartiesOp as listChildThirdParties,
publishOp as publish,
vetOp as vet,
};

View File

@@ -0,0 +1,102 @@
// 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.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'ThirdParty ID',
name: 'thirdPartyId',
type: 'string',
displayOptions: {
show: {
resource: ['thirdParty'],
operation: ['vet'],
},
},
default: '',
description: 'The ID of the third party to vet',
required: true,
},
{
displayName: 'Website URL',
name: 'websiteUrl',
type: 'string',
displayOptions: {
show: {
resource: ['thirdParty'],
operation: ['vet'],
},
},
default: '',
description: 'The website URL to crawl for vetting',
required: true,
},
{
displayName: 'Procedure',
name: 'procedure',
type: 'string',
typeOptions: {
rows: 4,
},
displayOptions: {
show: {
resource: ['thirdParty'],
operation: ['vet'],
},
},
default: '',
description: 'Optional custom vetting procedure instructions',
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string;
const websiteUrl = this.getNodeParameter('websiteUrl', itemIndex) as string;
const procedure = this.getNodeParameter('procedure', itemIndex, '') as string;
const query = `
mutation VetThirdParty($input: VetThirdPartyInput!) {
vetThirdParty(input: $input) {
thirdParty {
id
name
websiteUrl
vettingStatus
updatedAt
}
}
}
`;
const input: Record<string, unknown> = {
id: thirdPartyId,
websiteUrl,
};
if (procedure) {
input.procedure = procedure;
}
const responseData = await proboApiRequest.call(this, query, { input });
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -356,7 +356,7 @@ func TestFunctionTool_Execute(t *testing.T) {
)
t.Run(
"optional fields can be omitted",
"optional fields may be empty but must be present",
func(t *testing.T) {
t.Parallel()
@@ -373,7 +373,7 @@ func TestFunctionTool_Execute(t *testing.T) {
},
)
result, err := tool.Execute(context.Background(), `{"city":"Paris"}`)
result, err := tool.Execute(context.Background(), `{"city":"Paris","units":""}`)
require.NoError(t, err)
assert.False(t, result.IsError)
assert.Equal(t, "sunny in Paris", result.Content)

View File

@@ -206,6 +206,12 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
Temperature: b.getEnvFloatPtr("AGENT_EVIDENCE_DESCRIBER_TEMPERATURE"),
MaxTokens: b.getEnvIntPtr("AGENT_EVIDENCE_DESCRIBER_MAX_TOKENS"),
},
ThirdPartyVetter: probodconfig.LLMAgentConfig{
Provider: b.getEnvOrDefault("AGENT_THIRD_PARTY_VETTER_PROVIDER", ""),
ModelName: b.getEnvOrDefault("AGENT_THIRD_PARTY_VETTER_MODEL_NAME", ""),
Temperature: b.getEnvFloatPtr("AGENT_THIRD_PARTY_VETTER_TEMPERATURE"),
MaxTokens: b.getEnvIntPtr("AGENT_THIRD_PARTY_VETTER_MAX_TOKENS"),
},
TrackerMapping: probodconfig.LLMAgentConfig{
Provider: b.getEnvOrDefault("AGENT_TRACKER_MAPPING_PROVIDER", ""),
ModelName: b.getEnvOrDefault("AGENT_TRACKER_MAPPING_MODEL_NAME", ""),
@@ -246,6 +252,11 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
StaleAfter: b.getEnvIntOrDefault("EVIDENCE_DESCRIBER_STALE_AFTER", 300),
MaxConcurrency: b.getEnvIntOrDefault("EVIDENCE_DESCRIBER_MAX_CONCURRENCY", 10),
},
ThirdPartyVetting: probodconfig.ThirdPartyVettingWorkerConfig{
Interval: b.getEnvIntOrDefault("THIRD_PARTY_VETTING_INTERVAL", 10),
StaleAfter: b.getEnvIntOrDefault("THIRD_PARTY_VETTING_STALE_AFTER", 1500),
MaxConcurrency: b.getEnvIntOrDefault("THIRD_PARTY_VETTING_MAX_CONCURRENCY", 1),
},
TrackerMappingWorker: probodconfig.TrackerMappingWorkerConfig{
Interval: b.getEnvIntOrDefault("TRACKER_MAPPING_INTERVAL", 10),
MaxConcurrency: b.getEnvIntOrDefault("TRACKER_MAPPING_MAX_CONCURRENCY", 3),

View File

@@ -215,6 +215,10 @@ func TestBuilder_Build_Defaults(t *testing.T) {
assert.Empty(t, cfg.Probod.Agents.EvidenceDescriber.ModelName)
assert.Nil(t, cfg.Probod.Agents.EvidenceDescriber.Temperature)
assert.Nil(t, cfg.Probod.Agents.EvidenceDescriber.MaxTokens)
assert.Empty(t, cfg.Probod.Agents.ThirdPartyVetter.Provider)
assert.Empty(t, cfg.Probod.Agents.ThirdPartyVetter.ModelName)
assert.Nil(t, cfg.Probod.Agents.ThirdPartyVetter.Temperature)
assert.Nil(t, cfg.Probod.Agents.ThirdPartyVetter.MaxTokens)
assert.Empty(t, cfg.Probod.Agents.TrackerMapping.Provider)
assert.Empty(t, cfg.Probod.Agents.TrackerMapping.ModelName)
assert.Nil(t, cfg.Probod.Agents.TrackerMapping.Temperature)
@@ -231,6 +235,9 @@ func TestBuilder_Build_Defaults(t *testing.T) {
assert.Equal(t, 600, cfg.Probod.CommonPatternEnrichmentWorker.StaleAfter)
assert.Equal(t, 45, cfg.Probod.CommonPatternEnrichmentWorker.AgentTimeout)
assert.Equal(t, 10, cfg.Probod.CommonPatternEnrichmentWorker.AgentMaxTurns)
assert.Equal(t, 10, cfg.Probod.ThirdPartyVetting.Interval)
assert.Equal(t, 1500, cfg.Probod.ThirdPartyVetting.StaleAfter)
assert.Equal(t, 1, cfg.Probod.ThirdPartyVetting.MaxConcurrency)
// Custom domains config
assert.Equal(t, 3600, cfg.Probod.CustomDomains.RenewalInterval)
@@ -320,6 +327,11 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
env["AGENT_EVIDENCE_DESCRIBER_MODEL_NAME"] = "claude-sonnet-4-20250514"
env["AGENT_EVIDENCE_DESCRIBER_TEMPERATURE"] = "0.2"
env["AGENT_EVIDENCE_DESCRIBER_MAX_TOKENS"] = "4096"
// Agents — third-party-vetter override
env["AGENT_THIRD_PARTY_VETTER_PROVIDER"] = "openai"
env["AGENT_THIRD_PARTY_VETTER_MODEL_NAME"] = "gpt-4o"
env["AGENT_THIRD_PARTY_VETTER_TEMPERATURE"] = "0.3"
env["AGENT_THIRD_PARTY_VETTER_MAX_TOKENS"] = "8192"
// Agents — tracker-mapping override
env["AGENT_TRACKER_MAPPING_PROVIDER"] = "openai"
env["AGENT_TRACKER_MAPPING_MODEL_NAME"] = "gpt-4o-mini"
@@ -336,6 +348,9 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
env["COMMON_PATTERN_ENRICHMENT_STALE_AFTER"] = "900"
env["COMMON_PATTERN_ENRICHMENT_AGENT_TIMEOUT"] = "50"
env["COMMON_PATTERN_ENRICHMENT_AGENT_MAX_TURNS"] = "5"
env["THIRD_PARTY_VETTING_INTERVAL"] = "15"
env["THIRD_PARTY_VETTING_STALE_AFTER"] = "1800"
env["THIRD_PARTY_VETTING_MAX_CONCURRENCY"] = "2"
// Custom domains
env["CUSTOM_DOMAINS_RESOLVER_ADDR"] = "1.1.1.1:53"
env["ACME_ACCOUNT_KEY"] = "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----"
@@ -422,6 +437,11 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
assert.Equal(t, "claude-sonnet-4-20250514", cfg.Probod.Agents.EvidenceDescriber.ModelName)
assert.Equal(t, new(0.2), cfg.Probod.Agents.EvidenceDescriber.Temperature)
assert.Equal(t, new(4096), cfg.Probod.Agents.EvidenceDescriber.MaxTokens)
// Agents — third-party-vetter overrides
assert.Equal(t, "openai", cfg.Probod.Agents.ThirdPartyVetter.Provider)
assert.Equal(t, "gpt-4o", cfg.Probod.Agents.ThirdPartyVetter.ModelName)
assert.Equal(t, new(0.3), cfg.Probod.Agents.ThirdPartyVetter.Temperature)
assert.Equal(t, new(8192), cfg.Probod.Agents.ThirdPartyVetter.MaxTokens)
// Agents — tracker-mapping overrides
assert.Equal(t, "openai", cfg.Probod.Agents.TrackerMapping.Provider)
assert.Equal(t, "gpt-4o-mini", cfg.Probod.Agents.TrackerMapping.ModelName)
@@ -438,6 +458,9 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
assert.Equal(t, 900, cfg.Probod.CommonPatternEnrichmentWorker.StaleAfter)
assert.Equal(t, 50, cfg.Probod.CommonPatternEnrichmentWorker.AgentTimeout)
assert.Equal(t, 5, cfg.Probod.CommonPatternEnrichmentWorker.AgentMaxTurns)
assert.Equal(t, 15, cfg.Probod.ThirdPartyVetting.Interval)
assert.Equal(t, 1800, cfg.Probod.ThirdPartyVetting.StaleAfter)
assert.Equal(t, 2, cfg.Probod.ThirdPartyVetting.MaxConcurrency)
// Custom domains
assert.Equal(t, "1.1.1.1:53", cfg.Probod.CustomDomains.ResolverAddr)
assert.Equal(t, "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----", cfg.Probod.CustomDomains.ACME.AccountKey)

View File

@@ -17,7 +17,6 @@ package thirdpartymgmt
import (
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/assess"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/create"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/delete"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/link"
@@ -25,6 +24,7 @@ import (
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/publish"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/unlink"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/update"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/vet"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/view"
)
@@ -39,7 +39,7 @@ func NewCmdThirdParty(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(view.NewCmdView(f))
cmd.AddCommand(update.NewCmdUpdate(f))
cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(assess.NewCmdAssess(f))
cmd.AddCommand(vet.NewCmdVet(f))
cmd.AddCommand(publish.NewCmdPublish(f))
cmd.AddCommand(link.NewCmdLink(f))
cmd.AddCommand(unlink.NewCmdUnlink(f))

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package assess
package vet
import (
"encoding/json"
@@ -25,16 +25,10 @@ import (
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const assessMutation = `
mutation($input: AssessThirdPartyInput!) {
assessThirdParty(input: $input) {
report
subprocessors {
name
country
purpose
}
third_party {
const vetMutation = `
mutation($input: VetThirdPartyInput!) {
vetThirdParty(input: $input) {
thirdParty {
id
name
}
@@ -42,38 +36,32 @@ mutation($input: AssessThirdPartyInput!) {
}
`
type assessResponse struct {
AssessThirdParty struct {
Report string `json:"report"`
Subprocessors []struct {
Name string `json:"name"`
Country string `json:"country"`
Purpose string `json:"purpose"`
} `json:"subprocessors"`
type vetResponse struct {
VetThirdParty struct {
ThirdParty struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"third_party"`
} `json:"assessThirdParty"`
} `json:"thirdParty"`
} `json:"vetThirdParty"`
}
func NewCmdAssess(f *cmdutil.Factory) *cobra.Command {
func NewCmdVet(f *cmdutil.Factory) *cobra.Command {
var (
flagOutput *string
)
cmd := &cobra.Command{
Use: "assess <thirdParty-id> --url <website-url>",
Short: "Run AI assessment on a thirdParty from its website",
Long: "Analyze a thirdParty's website using AI agents to extract security, compliance, and business information.",
Example: ` # Assess a third_party by website URL
prb third_party assess VND_123 --url https://example.com
Use: "vet <thirdParty-id> --url <website-url>",
Short: "Start AI vetting of a third party from its website",
Long: "Queue a vetting job that crawls a third party's website using AI agents to extract security, compliance, and business information.",
Example: ` # Vet a third party by website URL
prb third-party vet VND_123 --url https://example.com
# Assess with a custom procedure file
prb third_party assess VND_123 --url https://example.com --procedure-file ./my-procedure.txt
# Vet with a custom procedure file
prb third-party vet VND_123 --url https://example.com --procedure-file ./my-procedure.txt
# Output as JSON
prb third_party assess VND_123 --url https://example.com -o json`,
prb third-party vet VND_123 --url https://example.com -o json`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil {
@@ -107,19 +95,17 @@ func NewCmdAssess(f *cmdutil.Factory) *cobra.Command {
input["procedure"] = string(data)
}
// The CLI timeout must outlast the server-side assessment
// timeout (vetting.AssessmentTimeout = 20m) plus HTTP overhead.
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
22*time.Minute,
30*time.Second,
)
_, _ = fmt.Fprintf(f.IOStreams.ErrOut, "Assessing thirdParty from %s (this may take a few minutes)...\n", flagURL)
_, _ = fmt.Fprintf(f.IOStreams.ErrOut, "Starting vetting for %s...\n", flagURL)
data, err := client.Do(
assessMutation,
vetMutation,
map[string]any{
"input": input,
},
@@ -128,24 +114,24 @@ func NewCmdAssess(f *cmdutil.Factory) *cobra.Command {
return err
}
var resp assessResponse
var resp vetResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
if *flagOutput == cmdutil.OutputJSON {
return cmdutil.PrintJSON(f.IOStreams.Out, resp.AssessThirdParty)
return cmdutil.PrintJSON(f.IOStreams.Out, resp.VetThirdParty)
}
_, _ = fmt.Fprintln(f.IOStreams.Out, resp.AssessThirdParty.Report)
_, _ = fmt.Fprintf(f.IOStreams.Out, "Vetting started for %s\n", resp.VetThirdParty.ThirdParty.Name)
return nil
},
}
cmd.Flags().String("url", "", "ThirdParty website URL to assess (required)")
cmd.Flags().String("url", "", "Third party website URL to vet (required)")
_ = cmd.MarkFlagRequired("url")
cmd.Flags().String("procedure-file", "", "Path to a custom assessment procedure file")
cmd.Flags().String("procedure-file", "", "Path to a custom vetting procedure file")
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd

View File

@@ -281,7 +281,6 @@ func seedThirdParty(t *testing.T, ctx context.Context, client *pg.Client, fx wor
party := coredata.ThirdParty{
ID: id,
TenantID: fx.scope.GetTenantID(),
OrganizationID: fx.organizationID,
Name: name,
Category: coredata.ThirdPartyCategoryAnalytics,

View File

@@ -0,0 +1,24 @@
-- 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.
CREATE TYPE third_party_vetting_status AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED');
ALTER TABLE third_parties
ADD COLUMN vetting_status third_party_vetting_status,
ADD COLUMN vetting_website_url TEXT,
ADD COLUMN vetting_procedure TEXT,
ADD COLUMN vetting_processing_started_at TIMESTAMPTZ,
ADD COLUMN vetting_error_message TEXT;
ALTER TABLE third_party_third_parties ADD COLUMN purpose TEXT;

View File

@@ -138,33 +138,37 @@ WHERE
type (
ThirdParty struct {
ID gid.GID `db:"id"`
TenantID gid.TenantID `db:"tenant_id"`
OrganizationID gid.GID `db:"organization_id"`
CommonThirdPartyID *gid.GID `db:"common_third_party_id"`
Name string `db:"name"`
Description *string `db:"description"`
Category ThirdPartyCategory `db:"category"`
HeadquarterAddress *string `db:"headquarter_address"`
LegalName *string `db:"legal_name"`
WebsiteURL *string `db:"website_url"`
PrivacyPolicyURL *string `db:"privacy_policy_url"`
ServiceLevelAgreementURL *string `db:"service_level_agreement_url"`
DataProcessingAgreementURL *string `db:"data_processing_agreement_url"`
BusinessAssociateAgreementURL *string `db:"business_associate_agreement_url"`
SubprocessorsListURL *string `db:"subprocessors_list_url"`
Certifications []string `db:"certifications"`
Countries CountryCodes `db:"countries"`
BusinessOwnerID *gid.GID `db:"business_owner_profile_id"`
SecurityOwnerID *gid.GID `db:"security_owner_profile_id"`
StatusPageURL *string `db:"status_page_url"`
TermsOfServiceURL *string `db:"terms_of_service_url"`
SecurityPageURL *string `db:"security_page_url"`
TrustPageURL *string `db:"trust_page_url"`
ShowOnTrustCenter bool `db:"show_on_trust_center"`
FirstLevel bool `db:"first_level"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
CommonThirdPartyID *gid.GID `db:"common_third_party_id"`
Name string `db:"name"`
Description *string `db:"description"`
Category ThirdPartyCategory `db:"category"`
HeadquarterAddress *string `db:"headquarter_address"`
LegalName *string `db:"legal_name"`
WebsiteURL *string `db:"website_url"`
PrivacyPolicyURL *string `db:"privacy_policy_url"`
ServiceLevelAgreementURL *string `db:"service_level_agreement_url"`
DataProcessingAgreementURL *string `db:"data_processing_agreement_url"`
BusinessAssociateAgreementURL *string `db:"business_associate_agreement_url"`
SubprocessorsListURL *string `db:"subprocessors_list_url"`
Certifications []string `db:"certifications"`
Countries CountryCodes `db:"countries"`
BusinessOwnerID *gid.GID `db:"business_owner_profile_id"`
SecurityOwnerID *gid.GID `db:"security_owner_profile_id"`
StatusPageURL *string `db:"status_page_url"`
TermsOfServiceURL *string `db:"terms_of_service_url"`
SecurityPageURL *string `db:"security_page_url"`
TrustPageURL *string `db:"trust_page_url"`
ShowOnTrustCenter bool `db:"show_on_trust_center"`
FirstLevel bool `db:"first_level"`
VettingStatus *ThirdPartyVettingStatus `db:"vetting_status"`
VettingWebsiteURL *string `db:"vetting_website_url"`
VettingProcedure *string `db:"vetting_procedure"`
VettingProcessingStartedAt *time.Time `db:"vetting_processing_started_at"`
VettingErrorMessage *string `db:"vetting_error_message"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
ThirdParties []*ThirdParty
@@ -231,7 +235,6 @@ func (v *ThirdParty) LoadByID(
q := `
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -255,6 +258,11 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
@@ -290,16 +298,15 @@ LIMIT 1;
return nil
}
func (v *ThirdParties) LoadByIDs(
func (v *ThirdParty) LoadByIDForUpdate(
ctx context.Context,
conn pg.Querier,
conn pg.Tx,
scope Scoper,
thirdPartyIDs []gid.GID,
thirdPartyID gid.GID,
) error {
q := `
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -323,6 +330,161 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
third_parties
WHERE
%s
AND id = @third_party_id
LIMIT 1
FOR UPDATE;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"third_party_id": thirdPartyID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query thirdParty: %w", err)
}
defer rows.Close()
thirdParty, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ThirdParty])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect thirdParty: %w", err)
}
*v = thirdParty
return nil
}
func (v *ThirdParty) LoadByNameAndOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
name string,
organizationID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
common_third_party_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
third_parties
WHERE
%s
AND organization_id = @organization_id
AND name = @name
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
"name": name,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query thirdParty by name: %w", err)
}
defer rows.Close()
thirdParty, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ThirdParty])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect thirdParty: %w", err)
}
*v = thirdParty
return nil
}
func (v *ThirdParties) LoadByIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
thirdPartyIDs []gid.GID,
) error {
q := `
SELECT
id,
organization_id,
common_third_party_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
@@ -385,6 +547,11 @@ INSERT INTO
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
)
@@ -414,6 +581,11 @@ VALUES (
@trust_page_url,
@show_on_trust_center,
@first_level,
@vetting_status,
@vetting_website_url,
@vetting_procedure,
@vetting_processing_started_at,
@vetting_error_message,
@created_at,
@updated_at
)
@@ -445,6 +617,11 @@ VALUES (
"trust_page_url": v.TrustPageURL,
"show_on_trust_center": v.ShowOnTrustCenter,
"first_level": v.FirstLevel,
"vetting_status": v.VettingStatus,
"vetting_website_url": v.VettingWebsiteURL,
"vetting_procedure": v.VettingProcedure,
"vetting_processing_started_at": v.VettingProcessingStartedAt,
"vetting_error_message": v.VettingErrorMessage,
"created_at": v.CreatedAt,
"updated_at": v.UpdatedAt,
}
@@ -517,7 +694,6 @@ func (v *ThirdParties) LoadAllByOrganizationID(
q := `
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -541,6 +717,11 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
@@ -581,7 +762,6 @@ func (v *ThirdParties) LoadByOrganizationID(
q := `
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -605,6 +785,11 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
@@ -667,6 +852,11 @@ SET
security_owner_profile_id = @security_owner_profile_id,
show_on_trust_center = @show_on_trust_center,
first_level = @first_level,
vetting_status = @vetting_status,
vetting_website_url = @vetting_website_url,
vetting_procedure = @vetting_procedure,
vetting_processing_started_at = @vetting_processing_started_at,
vetting_error_message = @vetting_error_message,
updated_at = @updated_at
WHERE %s
AND id = @third_party_id
@@ -698,13 +888,25 @@ WHERE %s
"security_owner_profile_id": v.SecurityOwnerID,
"show_on_trust_center": v.ShowOnTrustCenter,
"first_level": v.FirstLevel,
"vetting_status": v.VettingStatus,
"vetting_website_url": v.VettingWebsiteURL,
"vetting_procedure": v.VettingProcedure,
"vetting_processing_started_at": v.VettingProcessingStartedAt,
"vetting_error_message": v.VettingErrorMessage,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
result, err := conn.Exec(ctx, q, args)
if err != nil {
return err
}
return err
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
func (v ThirdParty) ExpireNonExpiredRiskAssessments(
@@ -816,6 +1018,11 @@ WITH vend AS (
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
v.vetting_processing_started_at,
v.vetting_error_message,
v.created_at,
v.updated_at
FROM
@@ -827,7 +1034,6 @@ WITH vend AS (
)
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -851,6 +1057,11 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
@@ -953,6 +1164,11 @@ WITH vend AS (
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
v.vetting_processing_started_at,
v.vetting_error_message,
v.created_at,
v.updated_at
FROM
@@ -964,7 +1180,6 @@ WITH vend AS (
)
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -988,6 +1203,11 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
@@ -1050,6 +1270,11 @@ WITH vend AS (
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
v.vetting_processing_started_at,
v.vetting_error_message,
v.created_at,
v.updated_at
FROM
@@ -1061,7 +1286,6 @@ WITH vend AS (
)
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -1085,6 +1309,11 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
@@ -1148,6 +1377,11 @@ WITH vend AS (
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
v.vetting_processing_started_at,
v.vetting_error_message,
v.created_at,
v.updated_at
FROM
@@ -1159,7 +1393,6 @@ WITH vend AS (
)
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -1183,6 +1416,11 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
@@ -1314,6 +1552,11 @@ WITH vend AS (
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
v.vetting_processing_started_at,
v.vetting_error_message,
v.created_at,
v.updated_at
FROM
@@ -1325,7 +1568,6 @@ WITH vend AS (
)
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -1349,6 +1591,11 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
@@ -1386,7 +1633,6 @@ func (v *ThirdParty) LoadByOrganizationIDAndCommonThirdPartyID(
q := `
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -1410,6 +1656,11 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
@@ -1525,6 +1776,11 @@ WITH tps AS (
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
v.vetting_processing_started_at,
v.vetting_error_message,
v.created_at,
v.updated_at
FROM
@@ -1536,7 +1792,6 @@ WITH tps AS (
)
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -1560,6 +1815,11 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM

View File

@@ -32,6 +32,7 @@ type (
ChildThirdPartyID gid.GID `db:"child_third_party_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
Purpose *string `db:"purpose"`
}
ThirdPartyThirdParties []*ThirdPartyThirdParty
@@ -43,14 +44,17 @@ INSERT INTO third_party_third_parties (
parent_third_party_id,
child_third_party_id,
tenant_id,
created_at
created_at,
purpose
) VALUES (
@parent_third_party_id,
@child_third_party_id,
@tenant_id,
@created_at
@created_at,
@purpose
)
ON CONFLICT (parent_third_party_id, child_third_party_id) DO NOTHING
ON CONFLICT (parent_third_party_id, child_third_party_id) DO UPDATE SET
purpose = COALESCE(EXCLUDED.purpose, third_party_third_parties.purpose)
`
args := pgx.StrictNamedArgs{
@@ -58,6 +62,7 @@ ON CONFLICT (parent_third_party_id, child_third_party_id) DO NOTHING
"child_third_party_id": r.ChildThirdPartyID,
"tenant_id": scope.GetTenantID(),
"created_at": r.CreatedAt,
"purpose": r.Purpose,
}
_, err := conn.Exec(ctx, q, args)
@@ -162,6 +167,11 @@ WITH children AS (
tp.trust_page_url,
tp.show_on_trust_center,
tp.first_level,
tp.vetting_status,
tp.vetting_website_url,
tp.vetting_procedure,
tp.vetting_processing_started_at,
tp.vetting_error_message,
tp.created_at,
tp.updated_at
FROM
@@ -173,7 +183,6 @@ WITH children AS (
)
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
@@ -197,6 +206,11 @@ SELECT
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM

View File

@@ -0,0 +1,126 @@
// 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 coredata
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
func (v *ThirdParty) LoadNextPendingVettingForUpdateSkipLocked(
ctx context.Context,
tx pg.Tx,
) error {
q := `
SELECT
id,
organization_id,
common_third_party_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
third_parties
WHERE
vetting_status = @vetting_status
AND vetting_website_url IS NOT NULL
ORDER BY
created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;
`
rows, err := tx.Query(
ctx,
q,
pgx.StrictNamedArgs{"vetting_status": ThirdPartyVettingStatusPending},
)
if err != nil {
return fmt.Errorf("cannot query third party vetting queue: %w", err)
}
thirdParty, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ThirdParty])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect third party: %w", err)
}
*v = thirdParty
return nil
}
func ResetStaleVettingProcessing(
ctx context.Context,
conn pg.Querier,
staleAfter time.Duration,
) error {
q := `
UPDATE third_parties
SET
vetting_status = @pending_status,
vetting_processing_started_at = NULL,
updated_at = @now
WHERE
vetting_status = @processing_status
AND vetting_processing_started_at < @stale_before;
`
_, err := conn.Exec(
ctx,
q,
pgx.StrictNamedArgs{
"pending_status": ThirdPartyVettingStatusPending,
"processing_status": ThirdPartyVettingStatusProcessing,
"now": time.Now(),
"stale_before": time.Now().Add(-staleAfter),
},
)
return err
}

View File

@@ -0,0 +1,87 @@
// 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 coredata
import (
"encoding"
"fmt"
)
type (
ThirdPartyVettingStatus string
)
const (
ThirdPartyVettingStatusPending ThirdPartyVettingStatus = "PENDING"
ThirdPartyVettingStatusProcessing ThirdPartyVettingStatus = "PROCESSING"
ThirdPartyVettingStatusCompleted ThirdPartyVettingStatus = "COMPLETED"
ThirdPartyVettingStatusFailed ThirdPartyVettingStatus = "FAILED"
)
var (
_ fmt.Stringer = ThirdPartyVettingStatus("")
_ encoding.TextMarshaler = ThirdPartyVettingStatus("")
_ encoding.TextUnmarshaler = (*ThirdPartyVettingStatus)(nil)
)
func ThirdPartyVettingStatuses() []ThirdPartyVettingStatus {
return []ThirdPartyVettingStatus{
ThirdPartyVettingStatusPending,
ThirdPartyVettingStatusProcessing,
ThirdPartyVettingStatusCompleted,
ThirdPartyVettingStatusFailed,
}
}
func (v ThirdPartyVettingStatus) IsValid() bool {
switch v {
case
ThirdPartyVettingStatusPending,
ThirdPartyVettingStatusProcessing,
ThirdPartyVettingStatusCompleted,
ThirdPartyVettingStatusFailed:
return true
}
return false
}
func (v ThirdPartyVettingStatus) IsActive() bool {
switch v {
case ThirdPartyVettingStatusPending, ThirdPartyVettingStatusProcessing:
return true
}
return false
}
func (v ThirdPartyVettingStatus) String() string {
return string(v)
}
func (v ThirdPartyVettingStatus) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
}
func (v *ThirdPartyVettingStatus) UnmarshalText(text []byte) error {
val := ThirdPartyVettingStatus(text)
if !val.IsValid() {
return fmt.Errorf("invalid ThirdPartyVettingStatus value: %q", string(text))
}
*v = val
return nil
}

View File

@@ -754,7 +754,6 @@ func (s *OrganizationService) CreateOrganization(
proboData := &coredata.ThirdParty{
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType),
TenantID: organization.TenantID,
OrganizationID: organization.ID,
Name: proboThirdParty.Name,
Description: &proboThirdParty.Description,

View File

@@ -89,7 +89,7 @@ const (
ActionThirdPartyCreate = "core:thirdParty:create"
ActionThirdPartyUpdate = "core:thirdParty:update"
ActionThirdPartyDelete = "core:thirdParty:delete"
ActionThirdPartyAssess = "core:thirdParty:assess"
ActionThirdPartyVet = "core:thirdParty:vet"
ActionThirdPartyPublish = "core:thirdParty:publish"
// ThirdPartyRelation actions

View File

@@ -84,7 +84,6 @@ type (
esign *esign.Service
connectorRegistry *connector.ConnectorRegistry
invitationTokenValidity time.Duration
thirdPartyAssessor ThirdPartyAssessor
Frameworks *FrameworkService
Measures *MeasureService
Tasks *TaskService
@@ -145,7 +144,6 @@ func NewService(
esignService *esign.Service,
connectorRegistry *connector.ConnectorRegistry,
invitationTokenValidity time.Duration,
thirdPartyAssessor ThirdPartyAssessor,
) (*Service, error) {
if bucket == "" {
return nil, fmt.Errorf("bucket is required")
@@ -170,7 +168,6 @@ func NewService(
esign: esignService,
connectorRegistry: connectorRegistry,
invitationTokenValidity: invitationTokenValidity,
thirdPartyAssessor: thirdPartyAssessor,
}
svc.Frameworks = &FrameworkService{

View File

@@ -16,56 +16,18 @@ package probo
import (
"context"
"errors"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
"go.probo.inc/probo/pkg/vetting"
"go.probo.inc/probo/pkg/webhook"
webhooktypes "go.probo.inc/probo/pkg/webhook/types"
)
// ErrThirdPartyAssessmentDisabled is returned by ThirdPartyAssessor.Assess when the
// deployment has not configured an LLM provider for thirdParty assessment.
var ErrThirdPartyAssessmentDisabled = errors.New("thirdParty assessment is not configured on this deployment")
// ThirdPartyAssessor produces a thirdParty assessment report from a website URL and
// an optional procedure description. Implementations that cannot perform
// assessment (missing LLM credentials, misconfigured provider) must return
// ErrThirdPartyAssessmentDisabled from Assess so callers can surface a stable
// "feature unavailable" error instead of a generic internal error.
type ThirdPartyAssessor interface {
Assess(
ctx context.Context,
websiteURL string,
procedure string,
reporter agent.ProgressReporter,
) (*vetting.Result, error)
}
// DisabledThirdPartyAssessor is the ThirdPartyAssessor implementation used when no
// LLM provider is configured for the third-party-assessor agent. Its Assess
// method always returns ErrThirdPartyAssessmentDisabled.
type DisabledThirdPartyAssessor struct{}
var _ ThirdPartyAssessor = DisabledThirdPartyAssessor{}
func (DisabledThirdPartyAssessor) Assess(
_ context.Context,
_ string,
_ string,
_ agent.ProgressReporter,
) (*vetting.Result, error) {
return nil, ErrThirdPartyAssessmentDisabled
}
type (
ThirdPartyService struct {
svc *Service
@@ -120,24 +82,6 @@ type (
FirstLevel *bool
}
AssessThirdPartyRequest struct {
ID gid.GID
WebsiteURL string
Procedure *string
}
AssessThirdPartyResult struct {
ThirdParty *coredata.ThirdParty
Report string
Subprocessors []Subprocessor
}
Subprocessor struct {
Name string
Country string
Purpose string
}
CreateThirdPartyRiskAssessmentRequest struct {
ThirdPartyID gid.GID
ExpiresAt time.Time
@@ -904,127 +848,6 @@ func (s ThirdPartyService) GetByRiskAssessmentID(
return thirdParty, nil
}
func (s ThirdPartyService) Assess(
ctx context.Context, scope coredata.Scoper,
req AssessThirdPartyRequest,
) (*AssessThirdPartyResult, error) {
result, err := s.svc.thirdPartyAssessor.Assess(ctx, req.WebsiteURL, ref.UnrefOrZero(req.Procedure), nil)
if err != nil {
return nil, fmt.Errorf("cannot assess thirdParty: %w", err)
}
thirdParty := &coredata.ThirdParty{}
err = s.svc.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := thirdParty.LoadByID(ctx, conn, scope, req.ID); err != nil {
return fmt.Errorf("cannot load thirdParty %q: %w", req.ID, err)
}
info := result.Info
if info.Name != "" {
thirdParty.Name = info.Name
}
thirdParty.WebsiteURL = &req.WebsiteURL
if info.Category != "" {
thirdParty.Category = coredata.ThirdPartyCategory(info.Category)
}
thirdParty.UpdatedAt = time.Now()
if info.Description != "" {
thirdParty.Description = &info.Description
}
if info.HeadquarterAddress != "" {
thirdParty.HeadquarterAddress = &info.HeadquarterAddress
}
if info.LegalName != "" {
thirdParty.LegalName = &info.LegalName
}
if info.PrivacyPolicyURL != "" {
thirdParty.PrivacyPolicyURL = &info.PrivacyPolicyURL
}
if info.ServiceLevelAgreementURL != "" {
thirdParty.ServiceLevelAgreementURL = &info.ServiceLevelAgreementURL
}
if info.DataProcessingAgreementURL != "" {
thirdParty.DataProcessingAgreementURL = &info.DataProcessingAgreementURL
}
if info.BusinessAssociateAgreementURL != "" {
thirdParty.BusinessAssociateAgreementURL = &info.BusinessAssociateAgreementURL
}
if info.SubprocessorsListURL != "" {
thirdParty.SubprocessorsListURL = &info.SubprocessorsListURL
}
if info.SecurityPageURL != "" {
thirdParty.SecurityPageURL = &info.SecurityPageURL
}
if info.TrustPageURL != "" {
thirdParty.TrustPageURL = &info.TrustPageURL
}
if info.TermsOfServiceURL != "" {
thirdParty.TermsOfServiceURL = &info.TermsOfServiceURL
}
if info.StatusPageURL != "" {
thirdParty.StatusPageURL = &info.StatusPageURL
}
if len(info.Certifications) > 0 {
thirdParty.Certifications = info.Certifications
}
if err := thirdParty.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update thirdParty: %w", err)
}
if err := webhook.InsertData(
ctx,
conn,
scope,
thirdParty.OrganizationID,
coredata.WebhookEventTypeThirdPartyUpdated,
webhooktypes.NewThirdParty(thirdParty),
); err != nil {
return fmt.Errorf("cannot insert webhook event: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
subprocessors := make([]Subprocessor, len(result.Info.Subprocessors))
for i, sp := range result.Info.Subprocessors {
subprocessors[i] = Subprocessor{
Name: sp.Name,
Country: sp.Country,
Purpose: sp.Purpose,
}
}
return &AssessThirdPartyResult{
ThirdParty: thirdParty,
Report: result.Document,
Subprocessors: subprocessors,
}, nil
}
func (s ThirdPartyService) CreateThirdPartyMapping(
ctx context.Context,
scope coredata.Scoper,

View File

@@ -17,30 +17,31 @@ package probod
import "go.probo.inc/probo/pkg/probodconfig"
type (
FullConfig = probodconfig.FullConfig
Config = probodconfig.Config
UnitConfig = probodconfig.UnitConfig
MetricsConfig = probodconfig.MetricsConfig
TracingConfig = probodconfig.TracingConfig
ESignConfig = probodconfig.ESignConfig
TrustCenterConfig = probodconfig.TrustCenterConfig
APIConfig = probodconfig.APIConfig
CorsConfig = probodconfig.CorsConfig
ProxyProtocolConfig = probodconfig.ProxyProtocolConfig
AuthConfig = probodconfig.AuthConfig
OAuth2ServerConfig = probodconfig.OAuth2ServerConfig
OAuth2SigningKeyConfig = probodconfig.OAuth2SigningKeyConfig
CookieConfig = probodconfig.CookieConfig
PasswordConfig = probodconfig.PasswordConfig
AWSConfig = probodconfig.AWSConfig
ConnectorConfig = probodconfig.ConnectorConfig
ConnectorConfigOAuth2 = probodconfig.ConnectorConfigOAuth2
CustomDomainsConfig = probodconfig.CustomDomainsConfig
ACMEConfig = probodconfig.ACMEConfig
LLMProviderConfig = probodconfig.LLMProviderConfig
LLMAgentConfig = probodconfig.LLMAgentConfig
EvidenceDescriberConfig = probodconfig.EvidenceDescriberConfig
AgentsConfig = probodconfig.AgentsConfig
FullConfig = probodconfig.FullConfig
Config = probodconfig.Config
UnitConfig = probodconfig.UnitConfig
MetricsConfig = probodconfig.MetricsConfig
TracingConfig = probodconfig.TracingConfig
ESignConfig = probodconfig.ESignConfig
TrustCenterConfig = probodconfig.TrustCenterConfig
APIConfig = probodconfig.APIConfig
CorsConfig = probodconfig.CorsConfig
ProxyProtocolConfig = probodconfig.ProxyProtocolConfig
AuthConfig = probodconfig.AuthConfig
OAuth2ServerConfig = probodconfig.OAuth2ServerConfig
OAuth2SigningKeyConfig = probodconfig.OAuth2SigningKeyConfig
CookieConfig = probodconfig.CookieConfig
PasswordConfig = probodconfig.PasswordConfig
AWSConfig = probodconfig.AWSConfig
ConnectorConfig = probodconfig.ConnectorConfig
ConnectorConfigOAuth2 = probodconfig.ConnectorConfigOAuth2
CustomDomainsConfig = probodconfig.CustomDomainsConfig
ACMEConfig = probodconfig.ACMEConfig
LLMProviderConfig = probodconfig.LLMProviderConfig
LLMAgentConfig = probodconfig.LLMAgentConfig
EvidenceDescriberConfig = probodconfig.EvidenceDescriberConfig
ThirdPartyVettingWorkerConfig = probodconfig.ThirdPartyVettingWorkerConfig
AgentsConfig = probodconfig.AgentsConfig
TrackerMappingWorkerConfig = probodconfig.TrackerMappingWorkerConfig
CommonPatternEnrichmentWorkerConfig = probodconfig.CommonPatternEnrichmentWorkerConfig

View File

@@ -177,6 +177,11 @@ func New() *Implm {
StaleAfter: 300,
MaxConcurrency: 10,
},
ThirdPartyVetting: ThirdPartyVettingWorkerConfig{
Interval: 10,
StaleAfter: 1500,
MaxConcurrency: 1,
},
},
}
}
@@ -309,7 +314,7 @@ func (impl *Implm) Run(
return err
}
thirdPartyAssessor, err := impl.buildThirdPartyAssessor(l, tp, r)
thirdPartyVetter, err := impl.buildThirdPartyVetter(l, tp, r)
if err != nil {
return err
}
@@ -522,7 +527,6 @@ func (impl *Implm) Run(
esignService,
defaultConnectorRegistry,
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
thirdPartyAssessor,
)
if err != nil {
return fmt.Errorf("cannot create probo service: %w", err)
@@ -552,7 +556,7 @@ func (impl *Implm) Run(
l.Named("access-review"),
)
thirdPartyService := thirdparty.NewService(pgClient, fileService)
thirdPartyService := thirdparty.NewService(pgClient, fileService, thirdPartyVetter)
riskManagementService := riskmanagement.NewService(pgClient)
serverHandler, err := server.NewServer(
@@ -818,6 +822,26 @@ func (impl *Implm) Run(
},
)
vettingWorker := thirdparty.NewVettingWorker(
pgClient,
thirdPartyVetter,
l.Named("vetting-worker"),
thirdparty.VettingWorkerConfig{
StaleAfter: time.Duration(impl.cfg.ThirdPartyVetting.StaleAfter) * time.Second,
},
worker.WithInterval(time.Duration(impl.cfg.ThirdPartyVetting.Interval)*time.Second),
worker.WithMaxConcurrency(impl.cfg.ThirdPartyVetting.MaxConcurrency),
)
vettingWorkerCtx, stopVettingWorker := context.WithCancel(context.Background())
wg.Go(
func() {
if err := vettingWorker.Run(vettingWorkerCtx); err != nil {
cancel(fmt.Errorf("vetting worker crashed: %w", err))
}
},
)
trustCenterServerCtx, stopTrustCenterServer := context.WithCancel(context.Background())
defer stopTrustCenterServer()
@@ -849,6 +873,7 @@ func (impl *Implm) Run(
stopTrackerMappingWorker()
stopCommonPatternEnrichmentWorker()
stopMailingListWorker()
stopVettingWorker()
stopEvidenceDescriptionWorker()
stopDocumentPDFWorker()
stopExportJobExporter()

View File

@@ -18,26 +18,19 @@ import (
"github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/log"
"go.opentelemetry.io/otel/trace"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/thirdparty"
"go.probo.inc/probo/pkg/vetting"
)
// buildThirdPartyAssessor wires the thirdParty assessment agent. It is an opt-in
// feature: deployments that do not set `llm.third-party-assessor.provider` get a
// DisabledThirdPartyAssessor that reports the feature as unavailable. The
// third-party-assessor does not inherit the default provider because its
// pipeline (LLM + browser + search) is expensive and should not be enabled
// implicitly.
func (impl *Implm) buildThirdPartyAssessor(
// buildThirdPartyVetter wires the third-party vetting agent. Unset
// third-party-vetter fields inherit from the default agent config
// (AGENT_DEFAULT_*), same as evidence-describer and probo.
func (impl *Implm) buildThirdPartyVetter(
l *log.Logger,
tp trace.TracerProvider,
r prometheus.Registerer,
) (probo.ThirdPartyAssessor, error) {
if impl.cfg.Agents.ThirdPartyAssessor.Provider == "" {
return probo.DisabledThirdPartyAssessor{}, nil
}
agentCfg, llmClient, err := impl.resolveAgentClient("third-party-assessor", impl.cfg.Agents.ThirdPartyAssessor, l, tp, r)
) (thirdparty.Vetter, error) {
agentCfg, llmClient, err := impl.resolveAgentClient("third-party-vetter", impl.cfg.Agents.ThirdPartyVetter, l, tp, r)
if err != nil {
return nil, err
}
@@ -53,6 +46,6 @@ func (impl *Implm) buildThirdPartyAssessor(
MaxTokens: maxTokens,
ChromeAddr: impl.cfg.ChromeDPAddr,
FirecrawlAPIKey: impl.cfg.Agents.Tools.FirecrawlAPIKey,
Logger: l.Named("third-party-assessor"),
Logger: l.Named("third-party-vetter"),
}), nil
}

View File

@@ -49,17 +49,18 @@ type (
// Config represents the probod application configuration.
Config struct {
BaseURL string `json:"base-url"`
EncryptionKey string `json:"encryption-key"`
Pg PgConfig `json:"pg"`
Api APIConfig `json:"api"`
Auth AuthConfig `json:"auth"`
TrustCenter TrustCenterConfig `json:"trust-center"`
AWS AWSConfig `json:"aws"`
Notifications NotificationsConfig `json:"notifications"`
Connectors []ConnectorConfig `json:"connectors"`
Agents AgentsConfig `json:"llm"`
EvidenceDescriber EvidenceDescriberConfig `json:"evidence-describer"`
BaseURL string `json:"base-url"`
EncryptionKey string `json:"encryption-key"`
Pg PgConfig `json:"pg"`
Api APIConfig `json:"api"`
Auth AuthConfig `json:"auth"`
TrustCenter TrustCenterConfig `json:"trust-center"`
AWS AWSConfig `json:"aws"`
Notifications NotificationsConfig `json:"notifications"`
Connectors []ConnectorConfig `json:"connectors"`
Agents AgentsConfig `json:"llm"`
EvidenceDescriber EvidenceDescriberConfig `json:"evidence-describer"`
ThirdPartyVetting ThirdPartyVettingWorkerConfig `json:"third-party-vetting-worker"`
TrackerMappingWorker TrackerMappingWorkerConfig `json:"tracker-mapping-worker"`
CommonPatternEnrichmentWorker CommonPatternEnrichmentWorkerConfig `json:"common-pattern-enrichment-worker"`

View File

@@ -40,6 +40,15 @@ type (
MaxConcurrency int `json:"max-concurrency"`
}
// ThirdPartyVettingWorkerConfig holds worker-side tuning for the
// third-party vetting background worker. LLM parameters for the
// vetter live under AgentsConfig.ThirdPartyVetter.
ThirdPartyVettingWorkerConfig struct {
Interval int `json:"interval"` // seconds between polls
StaleAfter int `json:"stale-after"` // seconds before a claim is recycled
MaxConcurrency int `json:"max-concurrency"`
}
// TrackerMappingWorkerConfig holds worker-side tuning for the
// tracker-mapping background worker. LLM parameters for the agents
// it runs live under AgentsConfig.TrackerMapping. AgentTimeout and
@@ -75,13 +84,13 @@ type (
// settings. Default is used as a fallback when an agent-specific field
// is zero-valued.
AgentsConfig struct {
Providers map[string]LLMProviderConfig `json:"providers"`
Default LLMAgentConfig `json:"defaults"`
Probo LLMAgentConfig `json:"probo"`
EvidenceDescriber LLMAgentConfig `json:"evidence-describer"`
ThirdPartyAssessor LLMAgentConfig `json:"third-party-assessor"`
TrackerMapping LLMAgentConfig `json:"tracker-mapping"`
Tools AgentToolsConfig `json:"tools"`
Providers map[string]LLMProviderConfig `json:"providers"`
Default LLMAgentConfig `json:"defaults"`
Probo LLMAgentConfig `json:"probo"`
EvidenceDescriber LLMAgentConfig `json:"evidence-describer"`
ThirdPartyVetter LLMAgentConfig `json:"third-party-vetter"`
TrackerMapping LLMAgentConfig `json:"tracker-mapping"`
Tools AgentToolsConfig `json:"tools"`
}
)

View File

@@ -212,6 +212,7 @@ func NewServer(cfg Config) (*Server, error) {
mcpHandler: mcp_v1.NewMux(
cfg.Logger.Named("mcp.v1"),
cfg.Probo,
cfg.ThirdParty,
cfg.IAM,
cfg.AccessReview,
cfg.CookieBanner,

View File

@@ -1,3 +1,23 @@
enum ThirdPartyVettingStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatus") {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusPending"
)
PROCESSING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusProcessing"
)
COMPLETED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusCompleted"
)
FAILED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ThirdPartyVettingStatusFailed"
)
}
enum ThirdPartyCategory
@goModel(model: "go.probo.inc/probo/pkg/coredata.ThirdPartyCategory") {
ANALYTICS
@@ -293,6 +313,8 @@ type ThirdParty implements Node {
orderBy: ThirdPartyOrder
): ThirdPartyConnection! @goField(forceResolver: true)
vettingStatus: ThirdPartyVettingStatus @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
@@ -480,7 +502,7 @@ extend type Mutation {
createThirdPartyRiskAssessment(
input: CreateThirdPartyRiskAssessmentInput!
): CreateThirdPartyRiskAssessmentPayload!
assessThirdParty(input: AssessThirdPartyInput!): AssessThirdPartyPayload!
vetThirdParty(input: VetThirdPartyInput!): VetThirdPartyPayload!
publishThirdPartyList(
input: PublishThirdPartyListInput!
): PublishThirdPartyListPayload!
@@ -652,18 +674,12 @@ input CreateThirdPartyRiskAssessmentInput {
notes: String
}
input AssessThirdPartyInput {
input VetThirdPartyInput {
id: ID!
websiteUrl: String!
procedure: String
}
type ThirdPartySubprocessor {
name: String!
country: String!
purpose: String!
}
type CreateThirdPartyPayload {
thirdPartyEdge: ThirdPartyEdge!
}
@@ -736,10 +752,8 @@ type CreateThirdPartyRiskAssessmentPayload {
thirdPartyRiskAssessmentEdge: ThirdPartyRiskAssessmentEdge!
}
type AssessThirdPartyPayload {
type VetThirdPartyPayload {
thirdParty: ThirdParty!
report: String!
subprocessors: [ThirdPartySubprocessor!]!
}
input CreateThirdPartyThirdPartyMappingInput {

View File

@@ -22,6 +22,7 @@ import (
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
"go.probo.inc/probo/pkg/server/api/console/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/thirdparty"
"go.probo.inc/probo/pkg/validator"
)
@@ -536,35 +537,45 @@ func (r *mutationResolver) CreateThirdPartyRiskAssessment(ctx context.Context, i
}, nil
}
// AssessThirdParty is the resolver for the assessThirdParty field.
func (r *mutationResolver) AssessThirdParty(ctx context.Context, input types.AssessThirdPartyInput) (*types.AssessThirdPartyPayload, error) {
scope, err := r.authorize(ctx, input.ID, probo.ActionThirdPartyAssess)
// VetThirdParty is the resolver for the vetThirdParty field.
func (r *mutationResolver) VetThirdParty(ctx context.Context, input types.VetThirdPartyInput) (*types.VetThirdPartyPayload, error) {
scope, err := r.authorize(ctx, input.ID, probo.ActionThirdPartyVet)
if err != nil {
return nil, err
}
result, err := r.probo.ThirdParties.Assess(
thirdParty, err := r.thirdParty.Vet(
ctx, scope,
probo.AssessThirdPartyRequest{
thirdparty.VetRequest{
ID: input.ID,
WebsiteURL: input.WebsiteURL,
Procedure: input.Procedure,
},
)
if err != nil {
if errors.Is(err, probo.ErrThirdPartyAssessmentDisabled) {
return nil, gqlutils.Unavailable(ctx, probo.ErrThirdPartyAssessmentDisabled)
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot assess thirdParty", log.Error(err))
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
if errors.Is(err, thirdparty.ErrVettingDisabled) {
return nil, gqlutils.Unavailable(ctx, thirdparty.ErrVettingDisabled)
}
if errors.Is(err, thirdparty.ErrVettingInProgress) {
return nil, gqlutils.Conflict(ctx, thirdparty.ErrVettingInProgress)
}
r.logger.ErrorCtx(ctx, "cannot vet thirdParty", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.AssessThirdPartyPayload{
ThirdParty: types.NewThirdParty(result.ThirdParty),
Report: result.Report,
Subprocessors: types.NewThirdPartySubprocessors(result.Subprocessors),
return &types.VetThirdPartyPayload{
ThirdParty: types.NewThirdParty(thirdParty),
}, nil
}
@@ -931,6 +942,22 @@ func (r *thirdPartyResolver) ChildThirdParties(ctx context.Context, obj *types.T
return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil
}
// VettingStatus is the resolver for the vettingStatus field.
func (r *thirdPartyResolver) VettingStatus(ctx context.Context, obj *types.ThirdParty) (*coredata.ThirdPartyVettingStatus, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet)
if err != nil {
return nil, err
}
status, err := r.thirdParty.VettingStatus(ctx, scope, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get vetting status", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return status, nil
}
// Permission is the resolver for the permission field.
func (r *thirdPartyResolver) Permission(ctx context.Context, obj *types.ThirdParty, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)

View File

@@ -18,7 +18,6 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
)
type (
@@ -107,16 +106,3 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
return object
}
func NewThirdPartySubprocessors(sps []probo.Subprocessor) []*ThirdPartySubprocessor {
result := make([]*ThirdPartySubprocessor, len(sps))
for i, sp := range sps {
result[i] = &ThirdPartySubprocessor{
Name: sp.Name,
Country: sp.Country,
Purpose: sp.Purpose,
}
}
return result
}

View File

@@ -32,10 +32,12 @@ import (
"go.probo.inc/probo/pkg/prosemirror"
"go.probo.inc/probo/pkg/riskmanagement"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/thirdparty"
)
type Resolver struct {
proboSvc *probo.Service
thirdPartySvc *thirdparty.Service
iamSvc *iam.Service
accessReview *accessreview.Service
cookieBanner *cookiebanner.Service

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/coredata"
@@ -23,6 +24,8 @@ import (
"go.probo.inc/probo/pkg/riskmanagement"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/mcp/v1/types"
"go.probo.inc/probo/pkg/thirdparty"
"go.probo.inc/probo/pkg/validator"
)
// ListOrganizationsTool handles the listOrganizations tool
@@ -5296,27 +5299,47 @@ func (r *Resolver) DeleteCustomDomainTool(ctx context.Context, req *mcp.CallTool
return nil, types.DeleteCustomDomainOutput{DeletedCustomDomain: deletedDomain}, nil
}
func (r *Resolver) AssessThirdPartyTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AssessThirdPartyInput) (*mcp.CallToolResult, types.AssessThirdPartyOutput, error) {
scope, err := r.Authorize(ctx, input.ID, probo.ActionThirdPartyAssess)
func (r *Resolver) VetThirdPartyTool(ctx context.Context, req *mcp.CallToolRequest, input *types.VetThirdPartyInput) (*mcp.CallToolResult, types.VetThirdPartyOutput, error) {
scope, err := r.Authorize(ctx, input.ID, probo.ActionThirdPartyVet)
if err != nil {
return nil, types.AssessThirdPartyOutput{}, err
return nil, types.VetThirdPartyOutput{}, err
}
svc := r.proboSvc
svc := r.thirdPartySvc
result, err := svc.ThirdParties.Assess(
thirdParty, err := svc.Vet(
ctx, scope,
probo.AssessThirdPartyRequest{
thirdparty.VetRequest{
ID: input.ID,
WebsiteURL: input.WebsiteURL,
Procedure: input.Procedure,
},
)
if err != nil {
return nil, types.AssessThirdPartyOutput{}, fmt.Errorf("cannot assess thirdParty: %w", err)
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, types.VetThirdPartyOutput{}, validationErrors
}
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("resource not found")
}
if errors.Is(err, thirdparty.ErrVettingDisabled) {
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("vetting is not configured")
}
if errors.Is(err, thirdparty.ErrVettingInProgress) {
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("vetting is already in progress")
}
r.logger.ErrorCtx(ctx, "cannot vet thirdParty", log.Error(err))
return nil, types.VetThirdPartyOutput{}, fmt.Errorf("internal server error")
}
return nil, types.NewAssessThirdPartyOutput(result), nil
return nil, types.VetThirdPartyOutput{
ThirdParty: types.NewThirdParty(thirdParty),
}, nil
}
func (r *Resolver) PublishFindingListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishFindingListInput) (*mcp.CallToolResult, types.PublishFindingListOutput, error) {

View File

@@ -1480,7 +1480,7 @@ components:
$ref: "#/components/schemas/GID"
description: Deleted thirdParty service ID
AssessThirdPartyInput:
VetThirdPartyInput:
type: object
required:
- id
@@ -1488,48 +1488,21 @@ components:
properties:
id:
$ref: "#/components/schemas/GID"
description: ThirdParty ID to assess
description: ThirdParty ID to vet
website_url:
type: string
description: ThirdParty website URL to crawl and assess
description: ThirdParty website URL to crawl and vet
procedure:
type: string
description: Optional custom assessment procedure (overrides the default)
description: Optional custom vetting procedure (overrides the default)
ThirdPartySubprocessor:
type: object
required:
- name
- country
- purpose
properties:
name:
type: string
description: Sub-processor name
country:
type: string
description: Country where the sub-processor operates
purpose:
type: string
description: Purpose of the sub-processor
AssessThirdPartyOutput:
VetThirdPartyOutput:
type: object
required:
- thirdParty
- report
- subprocessors
properties:
thirdParty:
$ref: "#/components/schemas/ThirdParty"
report:
type: string
description: Markdown-formatted thirdParty assessment report
subprocessors:
type: array
items:
$ref: "#/components/schemas/ThirdPartySubprocessor"
description: Sub-processors discovered during the assessment
GetUserInput:
type: object
@@ -12096,14 +12069,14 @@ tools:
$ref: "#/components/schemas/DeleteThirdPartyServiceInput"
outputSchema:
$ref: "#/components/schemas/DeleteThirdPartyServiceOutput"
- name: assessThirdParty
description: Run an AI-powered assessment on a thirdParty by crawling its website. Returns a markdown report, the discovered sub-processors, and an enriched thirdParty record. Long-running (up to 20 minutes).
- name: vetThirdParty
description: Start AI-powered vetting of a third party by crawling its website. Returns immediately; vetting runs in the background.
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/AssessThirdPartyInput"
$ref: "#/components/schemas/VetThirdPartyInput"
outputSchema:
$ref: "#/components/schemas/AssessThirdPartyOutput"
$ref: "#/components/schemas/VetThirdPartyOutput"
- name: listRisks
description: List all risks for the organization
hints:

View File

@@ -17,7 +17,6 @@ package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
)
func NewThirdPartyRiskAssessment(v *coredata.ThirdPartyRiskAssessment) *ThirdPartyRiskAssessment {
@@ -229,24 +228,3 @@ func NewListThirdPartyServicesOutput(p *page.Page[*coredata.ThirdPartyService, c
ThirdPartyServices: services,
}
}
func NewThirdPartySubprocessors(sps []probo.Subprocessor) []*ThirdPartySubprocessor {
result := make([]*ThirdPartySubprocessor, len(sps))
for i, sp := range sps {
result[i] = &ThirdPartySubprocessor{
Name: sp.Name,
Country: sp.Country,
Purpose: sp.Purpose,
}
}
return result
}
func NewAssessThirdPartyOutput(result *probo.AssessThirdPartyResult) AssessThirdPartyOutput {
return AssessThirdPartyOutput{
ThirdParty: NewThirdParty(result.ThirdParty),
Report: result.Report,
Subprocessors: NewThirdPartySubprocessors(result.Subprocessors),
}
}

View File

@@ -29,15 +29,26 @@ import (
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/mcp/mcputils"
"go.probo.inc/probo/pkg/server/api/mcp/v1/server"
"go.probo.inc/probo/pkg/thirdparty"
)
func NewMux(logger *log.Logger, proboSvc *probo.Service, iamSvc *iam.Service, accessReviewSvc *accessreview.Service, cookieBannerSvc *cookiebanner.Service, riskManagementSvc *riskmanagement.Service, tokenSecret string) *chi.Mux {
func NewMux(
logger *log.Logger,
proboSvc *probo.Service,
thirdPartySvc *thirdparty.Service,
iamSvc *iam.Service,
accessReviewSvc *accessreview.Service,
cookieBannerSvc *cookiebanner.Service,
riskManagementSvc *riskmanagement.Service,
tokenSecret string,
) *chi.Mux {
logger = logger.Named("mcp.v1")
logger.Info("initializing MCP server")
resolver := &Resolver{
proboSvc: proboSvc,
thirdPartySvc: thirdPartySvc,
iamSvc: iamSvc,
accessReview: accessReviewSvc,
cookieBanner: cookieBannerSvc,

View File

@@ -26,14 +26,20 @@ import (
)
type Service struct {
pg *pg.Client
file *file.Service
pg *pg.Client
file *file.Service
vetter Vetter
vettingEnabled bool
}
func NewService(pgClient *pg.Client, fileSvc *file.Service) *Service {
func NewService(pgClient *pg.Client, fileSvc *file.Service, vetter Vetter) *Service {
_, disabled := vetter.(DisabledVetter)
return &Service{
pg: pgClient,
file: fileSvc,
pg: pgClient,
file: fileSvc,
vetter: vetter,
vettingEnabled: !disabled,
}
}

171
pkg/thirdparty/vetting.go vendored Normal file
View File

@@ -0,0 +1,171 @@
// 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 thirdparty
import (
"context"
"errors"
"fmt"
"time"
"unicode/utf8"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/validator"
"go.probo.inc/probo/pkg/vetting"
)
const (
vettingErrorMessageMaxLen = 512
vettingWebsiteURLMaxLength = 2048
vettingProcedureMaxLength = 5000
)
var (
ErrVettingDisabled = errors.New("thirdParty vetting is not configured on this deployment")
ErrVettingInProgress = errors.New("a vetting job is already in progress for this third party")
)
type (
Vetter interface {
Assess(
ctx context.Context,
websiteURL string,
procedure string,
reporter agent.ProgressReporter,
extraTools []agent.Tool,
) (*vetting.Result, error)
}
DisabledVetter struct{}
VetRequest struct {
ID gid.GID
WebsiteURL string
Procedure *string
}
)
var _ Vetter = DisabledVetter{}
func (DisabledVetter) Assess(
_ context.Context,
_ string,
_ string,
_ agent.ProgressReporter,
_ []agent.Tool,
) (*vetting.Result, error) {
return nil, ErrVettingDisabled
}
func (req VetRequest) Validate() error {
v := validator.New()
v.Check(req.ID, "id", validator.Required(), validator.GID(coredata.ThirdPartyEntityType))
v.Check(req.WebsiteURL, "website_url", validator.Required(), validator.SafeText(vettingWebsiteURLMaxLength))
v.Check(req.Procedure, "procedure", validator.SafeText(vettingProcedureMaxLength))
return v.Error()
}
func sanitizeVettingError(err error) string {
msg := err.Error()
if len(msg) <= vettingErrorMessageMaxLen {
return msg
}
cut := vettingErrorMessageMaxLen
for cut > 0 && !utf8.RuneStart(msg[cut]) {
cut--
}
return msg[:cut] + "…"
}
func (s *Service) Vet(
ctx context.Context,
scope coredata.Scoper,
req VetRequest,
) (*coredata.ThirdParty, error) {
if err := req.Validate(); err != nil {
return nil, err
}
if !s.vettingEnabled {
return nil, ErrVettingDisabled
}
thirdParty := &coredata.ThirdParty{}
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := thirdParty.LoadByIDForUpdate(ctx, conn, scope, req.ID); err != nil {
return fmt.Errorf("cannot load thirdParty %q: %w", req.ID, err)
}
if thirdParty.VettingStatus != nil && thirdParty.VettingStatus.IsActive() {
return ErrVettingInProgress
}
pending := coredata.ThirdPartyVettingStatusPending
websiteURL := req.WebsiteURL
thirdParty.VettingStatus = &pending
thirdParty.VettingWebsiteURL = &websiteURL
thirdParty.VettingProcedure = req.Procedure
thirdParty.VettingProcessingStartedAt = nil
thirdParty.VettingErrorMessage = nil
thirdParty.UpdatedAt = time.Now()
if err := thirdParty.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot enqueue vetting: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return thirdParty, nil
}
func (s *Service) VettingStatus(
ctx context.Context,
scope coredata.Scoper,
thirdPartyID gid.GID,
) (*coredata.ThirdPartyVettingStatus, error) {
thirdParty := &coredata.ThirdParty{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return thirdParty.LoadByID(ctx, conn, scope, thirdPartyID)
},
)
if err != nil {
return nil, err
}
if thirdParty.VettingStatus == nil {
return nil, nil
}
return thirdParty.VettingStatus, nil
}

119
pkg/thirdparty/vetting_test.go vendored Normal file
View File

@@ -0,0 +1,119 @@
// 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 thirdparty
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/validator"
)
func TestVetRequest_Validate(t *testing.T) {
t.Parallel()
validID := gid.New(gid.NewTenantID(), coredata.ThirdPartyEntityType)
t.Run("accepts a valid request", func(t *testing.T) {
t.Parallel()
procedure := "Focus on SOC 2"
err := VetRequest{
ID: validID,
WebsiteURL: "https://example.com",
Procedure: &procedure,
}.Validate()
require.NoError(t, err)
})
t.Run("requires id", func(t *testing.T) {
t.Parallel()
err := VetRequest{
WebsiteURL: "https://example.com",
}.Validate()
require.Error(t, err)
validationErrors, ok := errors.AsType[validator.ValidationErrors](err)
require.True(t, ok)
assert.NotEmpty(t, validationErrors.ByField("id"))
})
t.Run("requires website url", func(t *testing.T) {
t.Parallel()
err := VetRequest{ID: validID}.Validate()
require.Error(t, err)
validationErrors, ok := errors.AsType[validator.ValidationErrors](err)
require.True(t, ok)
assert.NotEmpty(t, validationErrors.ByField("website_url"))
})
t.Run("rejects an invalid third party id", func(t *testing.T) {
t.Parallel()
err := VetRequest{
ID: gid.New(gid.NewTenantID(), coredata.OrganizationEntityType),
WebsiteURL: "https://example.com",
}.Validate()
require.Error(t, err)
validationErrors, ok := errors.AsType[validator.ValidationErrors](err)
require.True(t, ok)
assert.NotEmpty(t, validationErrors.ByField("id"))
})
}
func TestSanitizeVettingError(t *testing.T) {
t.Parallel()
t.Run("returns short messages unchanged", func(t *testing.T) {
t.Parallel()
assert.Equal(t, "cannot vet third party", sanitizeVettingError(errors.New("cannot vet third party")))
})
t.Run("truncates long messages on a rune boundary", func(t *testing.T) {
t.Parallel()
msg := strings.Repeat("x", vettingErrorMessageMaxLen+10)
sanitized := sanitizeVettingError(errors.New(msg))
assert.LessOrEqual(t, len(sanitized), vettingErrorMessageMaxLen+len("…"))
assert.True(t, strings.HasSuffix(sanitized, "…"))
})
}
func TestDisabledVetter_Assess(t *testing.T) {
t.Parallel()
_, err := DisabledVetter{}.Assess(context.Background(), "https://example.com", "", nil, nil)
require.ErrorIs(t, err, ErrVettingDisabled)
}
func TestDisabledVetter_ImplementsVetter(t *testing.T) {
t.Parallel()
var _ Vetter = DisabledVetter{}
}

238
pkg/thirdparty/vetting_worker.go vendored Normal file
View File

@@ -0,0 +1,238 @@
// 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 thirdparty
import (
"context"
"errors"
"fmt"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/kit/worker"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/vetting"
)
type (
vettingHandler struct {
pg *pg.Client
vetter Vetter
logger *log.Logger
staleAfter time.Duration
}
VettingWorkerConfig struct {
StaleAfter time.Duration
}
)
var (
_ worker.Handler[coredata.ThirdParty] = (*vettingHandler)(nil)
_ worker.StaleRecoverer = (*vettingHandler)(nil)
)
func NewVettingWorker(
pgClient *pg.Client,
vetter Vetter,
logger *log.Logger,
cfg VettingWorkerConfig,
opts ...worker.Option,
) *worker.Worker[coredata.ThirdParty] {
staleAfter := cfg.StaleAfter
if staleAfter <= 0 {
staleAfter = 25 * time.Minute
}
h := &vettingHandler{
pg: pgClient,
vetter: vetter,
logger: logger,
staleAfter: staleAfter,
}
return worker.New(
"vetting-worker",
h,
logger,
opts...,
)
}
func (h *vettingHandler) Claim(ctx context.Context) (coredata.ThirdParty, error) {
var thirdParty coredata.ThirdParty
if err := h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := thirdParty.LoadNextPendingVettingForUpdateSkipLocked(ctx, tx); err != nil {
return err
}
now := time.Now()
processing := coredata.ThirdPartyVettingStatusProcessing
thirdParty.VettingStatus = &processing
thirdParty.VettingProcessingStartedAt = &now
thirdParty.VettingErrorMessage = nil
thirdParty.UpdatedAt = now
if err := thirdParty.Update(ctx, tx, coredata.NewNoScope()); err != nil {
return fmt.Errorf("cannot update third party: %w", err)
}
return nil
},
); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return coredata.ThirdParty{}, worker.ErrNoTask
}
return coredata.ThirdParty{}, err
}
return thirdParty, nil
}
func (h *vettingHandler) Process(ctx context.Context, thirdParty coredata.ThirdParty) error {
if err := h.processThirdParty(ctx, &thirdParty); err != nil {
h.logger.ErrorCtx(
ctx,
"vetting worker failure",
log.Error(err),
log.String("third_party_id", thirdParty.ID.String()),
)
if failErr := h.failThirdParty(ctx, &thirdParty, err); failErr != nil {
h.logger.ErrorCtx(ctx, "cannot mark third party vetting as failed", log.Error(failErr))
}
return err
}
return nil
}
func (h *vettingHandler) RecoverStale(ctx context.Context) error {
return h.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := coredata.ResetStaleVettingProcessing(ctx, conn, h.staleAfter); err != nil {
return fmt.Errorf("cannot reset stale vetting processing: %w", err)
}
return nil
},
)
}
func (h *vettingHandler) processThirdParty(
ctx context.Context,
thirdParty *coredata.ThirdParty,
) error {
if thirdParty.VettingWebsiteURL == nil {
return fmt.Errorf("third party %s has no vetting website URL", thirdParty.ID)
}
procedure := ""
if thirdParty.VettingProcedure != nil {
procedure = *thirdParty.VettingProcedure
}
pc := &vetting.PersistenceContext{
PG: h.pg,
ThirdPartyID: thirdParty.ID,
OrganizationID: thirdParty.OrganizationID,
WebsiteURL: *thirdParty.VettingWebsiteURL,
}
// Assessment runs outside any database transaction. Persistence tools
// are not passed in so the agent cannot open DB transactions during
// the long LLM/browser phase; results are written afterward.
result, err := h.vetter.Assess(
ctx,
*thirdParty.VettingWebsiteURL,
procedure,
nil,
nil,
)
if err != nil {
return fmt.Errorf("cannot vet third party: %w", err)
}
if err := vetting.PersistAssessmentResult(ctx, pc, *result); err != nil {
return fmt.Errorf("cannot persist vetting results: %w", err)
}
return h.commitVettingOutcome(
ctx,
thirdParty.ID,
func(fresh *coredata.ThirdParty) {
completed := coredata.ThirdPartyVettingStatusCompleted
fresh.VettingStatus = &completed
fresh.VettingProcessingStartedAt = nil
fresh.VettingErrorMessage = nil
},
)
}
func (h *vettingHandler) failThirdParty(
ctx context.Context,
thirdParty *coredata.ThirdParty,
reason error,
) error {
errMsg := sanitizeVettingError(reason)
return h.commitVettingOutcome(
ctx,
thirdParty.ID,
func(fresh *coredata.ThirdParty) {
failed := coredata.ThirdPartyVettingStatusFailed
fresh.VettingStatus = &failed
fresh.VettingProcessingStartedAt = nil
fresh.VettingErrorMessage = &errMsg
},
)
}
func (h *vettingHandler) commitVettingOutcome(
ctx context.Context,
thirdPartyID gid.GID,
apply func(*coredata.ThirdParty),
) error {
return h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
thirdParty := &coredata.ThirdParty{}
if err := thirdParty.LoadByID(ctx, tx, coredata.NewNoScope(), thirdPartyID); err != nil {
return fmt.Errorf("cannot reload third party: %w", err)
}
apply(thirdParty)
thirdParty.UpdatedAt = time.Now()
if err := thirdParty.Update(ctx, tx, coredata.NewNoScope()); err != nil {
return fmt.Errorf("cannot update third party: %w", err)
}
return nil
},
)
}

View File

@@ -30,7 +30,7 @@ import (
const (
// DefaultMaxTokens is the fallback max-tokens budget used when the
// third-party-assessor agent config does not specify a value. Sized to
// third-party-vetter agent config does not specify a value. Sized to
// leave headroom above the orchestrator's thinking budget on
// Anthropic models.
DefaultMaxTokens = 16384
@@ -173,7 +173,7 @@ 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) {
func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure string, reporter agent.ProgressReporter, extraTools []agent.Tool) (*Result, error) {
u, err := url.Parse(websiteURL)
if err != nil {
return nil, fmt.Errorf("cannot parse website URL %q: %w", websiteURL, err)
@@ -193,15 +193,12 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), AssessmentTimeout)
defer cancel()
thirdPartyBrowser := browser.NewBrowser(ctx, a.cfg.ChromeAddr)
defer thirdPartyBrowser.Close()
thirdPartyBrowser.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()
// One shared remote Chrome allocator for all sub-agents. Sub-agents
// that need external links (subprocessor hosts, research) share it
// with vendor-site crawlers. Navigation is still gated by public-IP
// checks; we do not pin an allowed domain so external follows work.
webBrowser := browser.NewBrowser(ctx, a.cfg.ChromeAddr)
defer webBrowser.Close()
orchestrator, err := newOrchestratorAgent(
a.cfg.Client,
@@ -209,16 +206,16 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri
a.cfg.MaxTokens,
procedure,
a.cfg.Logger,
thirdPartyBrowser,
researchBrowser,
webBrowser,
a.cfg.FirecrawlAPIKey,
reporter,
extraTools,
)
if err != nil {
return nil, fmt.Errorf("cannot create orchestrator agent: %w", err)
}
result, err := orchestrator.Run(
orchestratorResult, err := orchestrator.Run(
ctx,
[]llm.Message{
{
@@ -231,7 +228,10 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri
return nil, fmt.Errorf("cannot assess thirdParty: %w", err)
}
document := result.FinalMessage().Text()
document := orchestratorResult.FinalMessage().Text()
// Extraction is LLM-only; release Chrome before it runs.
webBrowser.Close()
reportProgress(ctx, reporter, "extract_third_party_info", agent.ProgressEventStepStarted)
@@ -241,6 +241,13 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri
return nil, fmt.Errorf("cannot extract thirdParty info: %w", err)
}
toolSubprocessors := subprocessorsFromOrchestratorMessages(orchestratorResult.Messages)
info.Subprocessors = mergeSubprocessors(toolSubprocessors, info.Subprocessors)
if info.SubprocessorsListURL == "" {
info.SubprocessorsListURL = subprocessorListURLFromOrchestratorMessages(orchestratorResult.Messages)
}
reportProgress(ctx, reporter, "extract_third_party_info", agent.ProgressEventStepCompleted)
return &Result{
@@ -335,7 +342,12 @@ func thirdPartyInfoOutputType() (*agent.OutputType, error) {
return nil, fmt.Errorf("cannot marshal decorated thirdParty info schema: %w", err)
}
outputType.Schema = decorated
strict, err := enforceStrictJSONSchema(decorated)
if err != nil {
return nil, fmt.Errorf("cannot enforce strict thirdParty info schema: %w", err)
}
outputType.Schema = strict
return outputType, nil
}

View File

@@ -0,0 +1,164 @@
// 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 (
"strings"
"go.probo.inc/probo/pkg/coredata"
)
var countryAliases = map[string]coredata.CountryCode{
"global": coredata.CountryCodeGlobal,
"global presence": coredata.CountryCodeGlobal,
"worldwide": coredata.CountryCodeGlobal,
"international": coredata.CountryCodeGlobal,
"multiple regions": coredata.CountryCodeGlobal,
"eu": coredata.CountryCodeEU,
"european union": coredata.CountryCodeEU,
"europe": coredata.CountryCodeEU,
"united states": coredata.CountryCodeUS,
"united states usa": coredata.CountryCodeUS,
"usa": coredata.CountryCodeUS,
"u.s.": coredata.CountryCodeUS,
"u.s.a.": coredata.CountryCodeUS,
"us": coredata.CountryCodeUS,
"united kingdom": coredata.CountryCodeGB,
"uk": coredata.CountryCodeGB,
"great britain": coredata.CountryCodeGB,
"germany": coredata.CountryCodeDE,
"france": coredata.CountryCodeFR,
"canada": coredata.CountryCodeCA,
"australia": coredata.CountryCodeAU,
"japan": coredata.CountryCodeJP,
"china": coredata.CountryCodeCN,
"india": coredata.CountryCodeIN,
"ireland": coredata.CountryCodeIE,
"netherlands": coredata.CountryCodeNL,
"singapore": coredata.CountryCodeSG,
"switzerland": coredata.CountryCodeCH,
"sweden": coredata.CountryCodeSE,
"spain": coredata.CountryCodeES,
"italy": coredata.CountryCodeIT,
"brazil": coredata.CountryCodeBR,
"mexico": coredata.CountryCodeMX,
"south korea": coredata.CountryCodeKR,
"korea": coredata.CountryCodeKR,
}
func parseOptionalCountryCodes(raw string) coredata.CountryCodes {
code, ok := parseCountryLocation(raw)
if !ok {
return nil
}
return coredata.CountryCodes{code}
}
func countriesFromInfo(info ThirdPartyInfo) coredata.CountryCodes {
raw := append([]string{}, info.DataLocations...)
if info.HeadquarterAddress != "" {
raw = append(raw, info.HeadquarterAddress)
}
return parseCountryLocations(raw...)
}
func parseCountryLocations(raw ...string) coredata.CountryCodes {
seen := make(map[coredata.CountryCode]struct{})
out := make(coredata.CountryCodes, 0, len(raw))
for _, value := range raw {
for _, part := range splitCountryList(value) {
code, ok := parseCountryLocation(part)
if !ok {
continue
}
if _, exists := seen[code]; exists {
continue
}
seen[code] = struct{}{}
out = append(out, code)
}
}
return out
}
func parseCountryLocation(raw string) (coredata.CountryCode, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false
}
code := coredata.CountryCode(strings.ToUpper(raw))
if code.IsValid() {
return code, true
}
if mapped, ok := countryAliases[normalizeCountryKey(raw)]; ok {
return mapped, true
}
if strings.Contains(raw, ",") {
parts := strings.Split(raw, ",")
last := strings.TrimSpace(parts[len(parts)-1])
if mapped, ok := countryAliases[normalizeCountryKey(last)]; ok {
return mapped, true
}
lastCode := coredata.CountryCode(strings.ToUpper(last))
if lastCode.IsValid() {
return lastCode, true
}
}
return "", false
}
func splitCountryList(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
for _, sep := range []string{";", "|", "/", " and ", " & "} {
if strings.Contains(strings.ToLower(raw), sep) {
parts := strings.Split(raw, sep)
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
}
return []string{raw}
}
func normalizeCountryKey(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
raw = strings.TrimPrefix(raw, "the ")
return strings.Join(strings.Fields(raw), " ")
}

View File

@@ -0,0 +1,78 @@
// 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 (
"testing"
"github.com/stretchr/testify/assert"
"go.probo.inc/probo/pkg/coredata"
)
func TestParseCountryLocation(t *testing.T) {
t.Parallel()
tests := []struct {
raw string
expected coredata.CountryCode
}{
{raw: "US", expected: coredata.CountryCodeUS},
{raw: "usa", expected: coredata.CountryCodeUS},
{raw: "United States", expected: coredata.CountryCodeUS},
{raw: "Seattle, Washington, USA", expected: coredata.CountryCodeUS},
{raw: "Global presence", expected: coredata.CountryCodeGlobal},
{raw: "EU", expected: coredata.CountryCodeEU},
{raw: "Germany", expected: coredata.CountryCodeDE},
}
for _, tt := range tests {
t.Run(tt.raw, func(t *testing.T) {
t.Parallel()
code, ok := parseCountryLocation(tt.raw)
assert.True(t, ok)
assert.Equal(t, tt.expected, code)
})
}
}
func TestCountriesFromInfo(t *testing.T) {
t.Parallel()
countries := countriesFromInfo(ThirdPartyInfo{
HeadquarterAddress: "Seattle, Washington, USA",
DataLocations: []string{"Germany", "EU"},
})
assert.Equal(
t,
coredata.CountryCodes{
coredata.CountryCodeDE,
coredata.CountryCodeEU,
coredata.CountryCodeUS,
},
countries,
)
}
func TestParseOptionalCountryCodes(t *testing.T) {
t.Parallel()
assert.Equal(
t,
coredata.CountryCodes{coredata.CountryCodeFR},
parseOptionalCountryCodes("France"),
)
}

View File

@@ -0,0 +1,189 @@
// 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"
"encoding/json"
"fmt"
"slices"
"strings"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/llm"
)
type strictFunctionTool[P any] struct {
name string
description string
fn func(ctx context.Context, params P) (agent.ToolResult, error)
schema json.RawMessage
requiredFields []string
}
// jsonSchemaForTool builds an OpenAI strict-mode JSON schema for vetting tools
// and structured outputs. OpenAI requires every property in required and
// additionalProperties=false; the shared agent schema generator does not.
func jsonSchemaForTool[T any]() (json.RawMessage, error) {
outputType, err := agent.NewOutputType[T]("_")
if err != nil {
return nil, fmt.Errorf("cannot generate schema: %w", err)
}
return enforceStrictJSONSchema(outputType.Schema)
}
func newVettingOutputType[T any](name string) (*agent.OutputType, error) {
outputType, err := agent.NewOutputType[T](name)
if err != nil {
return nil, err
}
schema, err := enforceStrictJSONSchema(outputType.Schema)
if err != nil {
return nil, fmt.Errorf("cannot enforce strict schema for %q: %w", name, err)
}
outputType.Schema = schema
return outputType, nil
}
func vettingFunctionTool[P any](
name string,
description string,
fn func(ctx context.Context, params P) (agent.ToolResult, error),
) agent.Tool {
schema, err := jsonSchemaForTool[P]()
if err != nil {
panic(fmt.Sprintf("vetting: cannot generate JSON schema for tool %q: %s", name, err))
}
var parsed struct {
Required []string `json:"required"`
}
if err := json.Unmarshal(schema, &parsed); err != nil {
panic(fmt.Sprintf("vetting: cannot parse generated schema for tool %q: %s", name, err))
}
return &strictFunctionTool[P]{
name: name,
description: description,
fn: fn,
schema: schema,
requiredFields: parsed.Required,
}
}
func (t *strictFunctionTool[P]) Name() string { return t.name }
func (t *strictFunctionTool[P]) Definition() llm.Tool {
return llm.Tool{
Name: t.name,
Description: t.description,
Parameters: t.schema,
}
}
func (t *strictFunctionTool[P]) Execute(ctx context.Context, arguments string) (agent.ToolResult, error) {
if len(t.requiredFields) > 0 {
var fields map[string]json.RawMessage
if err := json.Unmarshal([]byte(arguments), &fields); err != nil {
return agent.ToolResult{
Content: fmt.Sprintf("Invalid parameters: %s", err.Error()),
IsError: true,
}, nil
}
var missing []string
for _, f := range t.requiredFields {
if _, ok := fields[f]; !ok {
missing = append(missing, f)
}
}
if len(missing) > 0 {
return agent.ToolResult{
Content: fmt.Sprintf(
"Missing required parameters: %s",
strings.Join(missing, ", "),
),
IsError: true,
}, nil
}
}
var params P
if err := json.Unmarshal([]byte(arguments), &params); err != nil {
return agent.ToolResult{
Content: fmt.Sprintf("Invalid parameters: %s", err.Error()),
IsError: true,
}, nil
}
return t.fn(ctx, params)
}
func enforceStrictJSONSchema(raw json.RawMessage) (json.RawMessage, error) {
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
return nil, fmt.Errorf("cannot unmarshal schema: %w", err)
}
normalizeStrictObject(schema)
data, err := json.Marshal(schema)
if err != nil {
return nil, fmt.Errorf("cannot marshal strict schema: %w", err)
}
return json.RawMessage(data), nil
}
func normalizeStrictObject(schema map[string]any) {
if schema == nil {
return
}
if props, ok := schema["properties"].(map[string]any); ok && len(props) > 0 {
required := make([]string, 0, len(props))
for name, prop := range props {
required = append(required, name)
if nested, ok := prop.(map[string]any); ok {
normalizeStrictObject(nested)
}
}
slices.Sort(required)
requiredAny := make([]any, len(required))
for i, name := range required {
requiredAny[i] = name
}
schema["required"] = requiredAny
schema["additionalProperties"] = false
}
if items, ok := schema["items"].(map[string]any); ok {
normalizeStrictObject(items)
}
if additional, ok := schema["additionalProperties"].(map[string]any); ok {
normalizeStrictObject(additional)
}
}

View File

@@ -0,0 +1,51 @@
// 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 (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestJSONSchemaForTool_EnforcesOpenAIStrictMode(t *testing.T) {
t.Parallel()
raw, err := jsonSchemaForTool[saveThirdPartyInfoToolParams]()
require.NoError(t, err)
var schema map[string]any
require.NoError(t, json.Unmarshal(raw, &schema))
required := schema["required"].([]any)
assert.Contains(t, required, "name")
assert.Contains(t, required, "description")
assert.Equal(t, false, schema["additionalProperties"])
}
func TestNewVettingOutputType_EnforcesOpenAIStrictMode(t *testing.T) {
t.Parallel()
outputType, err := newVettingOutputType[CrawlerOutput]("crawler")
require.NoError(t, err)
var schema map[string]any
require.NoError(t, json.Unmarshal(outputType.Schema, &schema))
assert.Equal(t, false, schema["additionalProperties"])
assert.NotEmpty(t, schema["required"])
}

View File

@@ -63,17 +63,16 @@ func newOrchestratorAgent(
maxTokens int,
procedure string,
logger *log.Logger,
thirdPartyBrowser *browser.Browser,
researchBrowser *browser.Browser,
webBrowser *browser.Browser,
firecrawlAPIKey string,
reporter agent.ProgressReporter,
extraTools []agent.Tool,
) (*agent.Agent, error) {
readOnlyBrowserTools := browser.NewReadOnlyToolset(thirdPartyBrowser).Tools()
readOnlyBrowserTools := browser.NewReadOnlyToolset(webBrowser).Tools()
// Unrestricted browser tools for sub-agents that need to follow links
// to external sites (subprocessor lists hosted on OneTrust/Transcend,
// research, thirdParty comparison).
unrestrictedBrowserTools := browser.NewInteractiveToolset(researchBrowser).Tools()
// Interactive browser tools for sub-agents that follow links off the
// vendor site (subprocessor lists on OneTrust/Transcend, research).
unrestrictedBrowserTools := browser.NewInteractiveToolset(webBrowser).Tools()
securityTools := security.NewToolset().Tools()
@@ -176,7 +175,7 @@ func newOrchestratorAgent(
// Optional sub-agents: only added when Firecrawl is configured.
if hasFirecrawl {
researchBrowserTools := browser.NewInteractiveToolset(researchBrowser).Tools()
researchBrowserTools := browser.NewInteractiveToolset(webBrowser).Tools()
searchTool := search.FirecrawlSearchTool(firecrawlAPIKey)
govDBTool := search.CheckGovernmentDBTool(firecrawlAPIKey)
@@ -228,7 +227,7 @@ func newOrchestratorAgent(
)
}
tools := make([]agent.Tool, 0, len(entries))
tools := make([]agent.Tool, 0, len(entries)+len(extraTools))
for _, e := range entries {
ag, err := e.build(client, model, e.tools, subAgentOpts(e.toolName)...)
if err != nil {
@@ -238,6 +237,8 @@ func newOrchestratorAgent(
tools = append(tools, ag.AsTool(e.toolName, e.description))
}
tools = append(tools, extraTools...)
if procedure == "" {
procedure = defaultProcedure
}

448
pkg/vetting/persist.go Normal file
View File

@@ -0,0 +1,448 @@
// 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"
"errors"
"fmt"
"strings"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
const (
vettingRiskAssessmentValidity = 365 * 24 * time.Hour
maxVettingNotesGaps = 5
)
// PersistAssessmentResult writes extracted assessment metadata onto the parent
// third party, links any sub-processors, and stores the risk assessment in one
// short transaction after the long assess phase completes. The assess run
// itself does not touch the database.
func PersistAssessmentResult(
ctx context.Context,
pc *PersistenceContext,
result Result,
) error {
scope := coredata.NewScopeFromObjectID(pc.ThirdPartyID)
return pc.PG.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
thirdParty := &coredata.ThirdParty{}
if err := thirdParty.LoadByID(ctx, conn, scope, pc.ThirdPartyID); err != nil {
return fmt.Errorf("cannot load third party: %w", err)
}
applySaveParams(thirdParty, pc.WebsiteURL, saveParamsFromInfo(result.Info))
thirdParty.UpdatedAt = time.Now()
if err := thirdParty.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update third party: %w", err)
}
for _, sub := range result.Info.Subprocessors {
if sub.Name == "" {
continue
}
if err := linkSubThirdParty(
ctx,
conn,
scope,
pc,
linkSubThirdPartyParams{
Name: sub.Name,
Country: sub.Country,
Purpose: sub.Purpose,
},
); err != nil {
return fmt.Errorf("cannot link sub third party %q: %w", sub.Name, err)
}
}
if err := persistVettingRiskAssessment(
ctx,
conn,
scope,
pc,
thirdParty,
result,
); err != nil {
return fmt.Errorf("cannot persist vetting risk assessment: %w", err)
}
return nil
},
)
}
func persistVettingRiskAssessment(
ctx context.Context,
conn pg.Tx,
scope coredata.Scoper,
pc *PersistenceContext,
thirdParty *coredata.ThirdParty,
result Result,
) error {
if err := thirdParty.ExpireNonExpiredRiskAssessments(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot expire existing risk assessments: %w", err)
}
now := time.Now()
notes := buildRiskAssessmentNotes(result.Info)
assessment := &coredata.ThirdPartyRiskAssessment{
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyRiskAssessmentEntityType),
OrganizationID: pc.OrganizationID,
ThirdPartyID: pc.ThirdPartyID,
ExpiresAt: now.Add(vettingRiskAssessmentValidity),
DataSensitivity: mapVettingDataSensitivity(result.Info),
BusinessImpact: mapVettingBusinessImpact(result.Info),
Notes: &notes,
CreatedAt: now,
UpdatedAt: now,
}
if err := assessment.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot insert risk assessment: %w", err)
}
return nil
}
func buildRiskAssessmentNotes(info ThirdPartyInfo) string {
var b strings.Builder
b.WriteString("Automated vetting\n\n")
switch {
case info.OverallRiskRating != "" && info.OverallRiskScore > 0:
fmt.Fprintf(
&b,
"Overall risk: %d/100 (%s)\n",
info.OverallRiskScore,
info.OverallRiskRating,
)
case info.OverallRiskScore > 0:
fmt.Fprintf(&b, "Overall risk: %d/100\n", info.OverallRiskScore)
case info.OverallRiskRating != "":
fmt.Fprintf(&b, "Overall risk: %s\n", info.OverallRiskRating)
}
if info.Recommendation != "" {
fmt.Fprintf(&b, "Recommendation: %s\n", formatVettingRecommendation(info.Recommendation))
}
var scoreParts []string
if info.SecurityRiskScore > 0 {
scoreParts = append(scoreParts, fmt.Sprintf("Security %d/100", info.SecurityRiskScore))
}
if info.PrivacyRiskScore > 0 {
scoreParts = append(scoreParts, fmt.Sprintf("Privacy %d/100", info.PrivacyRiskScore))
}
if info.InvolvesAI || info.AIRiskScore > 0 {
scoreParts = append(scoreParts, fmt.Sprintf("AI %d/100", info.AIRiskScore))
}
if len(scoreParts) > 0 {
b.WriteByte('\n')
b.WriteString(strings.Join(scoreParts, " · "))
b.WriteByte('\n')
}
if len(info.InformationGaps) > 0 {
b.WriteString("\nGaps\n")
gaps := info.InformationGaps
if len(gaps) > maxVettingNotesGaps {
gaps = gaps[:maxVettingNotesGaps]
}
for _, gap := range gaps {
fmt.Fprintf(&b, "· %s\n", strings.TrimSpace(gap))
}
}
return strings.TrimSpace(b.String())
}
func formatVettingRecommendation(recommendation string) string {
switch strings.ToUpper(strings.TrimSpace(recommendation)) {
case "APPROVE":
return "Approve"
case "APPROVE_WITH_CONDITIONS":
return "Approve with conditions"
case "ESCALATE":
return "Escalate"
case "REJECT":
return "Reject"
default:
return recommendation
}
}
func mapVettingDataSensitivity(info ThirdPartyInfo) coredata.DataSensitivity {
if !info.ProcessesPII && info.PrivacyRiskScore == 0 {
return coredata.DataSensitivityNone
}
score := info.PrivacyRiskScore
if score == 0 {
score = overallScoreFromRating(info.OverallRiskRating)
}
return scoreToDataSensitivity(score)
}
func mapVettingBusinessImpact(info ThirdPartyInfo) coredata.BusinessImpact {
score := info.OverallRiskScore
if score == 0 {
score = info.SecurityRiskScore
}
if score == 0 {
score = overallScoreFromRating(info.OverallRiskRating)
}
return scoreToBusinessImpact(score)
}
func overallScoreFromRating(rating string) int {
switch strings.ToLower(strings.TrimSpace(rating)) {
case "low":
return 25
case "medium":
return 50
case "high":
return 75
default:
return 0
}
}
func scoreToDataSensitivity(score int) coredata.DataSensitivity {
switch {
case score <= 0:
return coredata.DataSensitivityNone
case score <= 25:
return coredata.DataSensitivityLow
case score <= 50:
return coredata.DataSensitivityMedium
case score <= 75:
return coredata.DataSensitivityHigh
default:
return coredata.DataSensitivityCritical
}
}
func scoreToBusinessImpact(score int) coredata.BusinessImpact {
switch {
case score <= 25:
return coredata.BusinessImpactLow
case score <= 50:
return coredata.BusinessImpactMedium
case score <= 75:
return coredata.BusinessImpactHigh
default:
return coredata.BusinessImpactCritical
}
}
func saveParamsFromInfo(info ThirdPartyInfo) saveThirdPartyInfoParams {
return saveThirdPartyInfoParams{
saveThirdPartyInfoToolParams: saveThirdPartyInfoToolParams{
Name: info.Name,
Description: info.Description,
Category: info.Category,
HeadquarterAddress: info.HeadquarterAddress,
LegalName: info.LegalName,
PrivacyPolicyURL: info.PrivacyPolicyURL,
ServiceLevelAgreementURL: info.ServiceLevelAgreementURL,
DataProcessingAgreementURL: info.DataProcessingAgreementURL,
BusinessAssociateAgreementURL: info.BusinessAssociateAgreementURL,
SubprocessorsListURL: info.SubprocessorsListURL,
SecurityPageURL: info.SecurityPageURL,
TrustPageURL: info.TrustPageURL,
TermsOfServiceURL: info.TermsOfServiceURL,
StatusPageURL: info.StatusPageURL,
Certifications: info.Certifications,
},
Countries: countriesFromInfo(info),
}
}
func applySaveParams(
thirdParty *coredata.ThirdParty,
websiteURL string,
p saveThirdPartyInfoParams,
) {
if p.Name != "" {
thirdParty.Name = p.Name
}
thirdParty.WebsiteURL = &websiteURL
if p.Category != "" {
if category, err := parseThirdPartyCategory(p.Category); err == nil {
thirdParty.Category = category
}
}
if p.Description != "" {
thirdParty.Description = &p.Description
}
if p.HeadquarterAddress != "" {
thirdParty.HeadquarterAddress = &p.HeadquarterAddress
}
if p.LegalName != "" {
thirdParty.LegalName = &p.LegalName
}
if p.PrivacyPolicyURL != "" {
thirdParty.PrivacyPolicyURL = &p.PrivacyPolicyURL
}
if p.ServiceLevelAgreementURL != "" {
thirdParty.ServiceLevelAgreementURL = &p.ServiceLevelAgreementURL
}
if p.DataProcessingAgreementURL != "" {
thirdParty.DataProcessingAgreementURL = &p.DataProcessingAgreementURL
}
if p.BusinessAssociateAgreementURL != "" {
thirdParty.BusinessAssociateAgreementURL = &p.BusinessAssociateAgreementURL
}
if p.SubprocessorsListURL != "" {
thirdParty.SubprocessorsListURL = &p.SubprocessorsListURL
}
if p.SecurityPageURL != "" {
thirdParty.SecurityPageURL = &p.SecurityPageURL
}
if p.TrustPageURL != "" {
thirdParty.TrustPageURL = &p.TrustPageURL
}
if p.TermsOfServiceURL != "" {
thirdParty.TermsOfServiceURL = &p.TermsOfServiceURL
}
if p.StatusPageURL != "" {
thirdParty.StatusPageURL = &p.StatusPageURL
}
if len(p.Certifications) > 0 {
thirdParty.Certifications = p.Certifications
}
if len(p.Countries) > 0 {
thirdParty.Countries = p.Countries
}
}
func linkSubThirdParty(
ctx context.Context,
conn pg.Tx,
scope coredata.Scoper,
pc *PersistenceContext,
p linkSubThirdPartyParams,
) error {
if p.Name == "" {
return nil
}
child := &coredata.ThirdParty{}
err := child.LoadByNameAndOrganizationID(ctx, conn, scope, p.Name, pc.OrganizationID)
if err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot find child third party %q: %w", p.Name, err)
}
now := time.Now()
child = &coredata.ThirdParty{
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType),
OrganizationID: pc.OrganizationID,
Name: p.Name,
Category: coredata.ThirdPartyCategoryOther,
FirstLevel: false,
CreatedAt: now,
UpdatedAt: now,
}
if p.Description != "" {
child.Description = &p.Description
}
if p.Category != "" {
if category, err := parseThirdPartyCategory(p.Category); err == nil {
child.Category = category
}
}
if p.WebsiteURL != "" {
child.WebsiteURL = &p.WebsiteURL
}
if countries := parseOptionalCountryCodes(p.Country); len(countries) > 0 {
child.Countries = countries
}
if err := child.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot create child third party %q: %w", p.Name, err)
}
} else if countries := parseOptionalCountryCodes(p.Country); len(countries) > 0 && len(child.Countries) == 0 {
child.Countries = countries
child.UpdatedAt = time.Now()
if err := child.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update child third party %q countries: %w", p.Name, err)
}
}
if child.ID == pc.ThirdPartyID {
return nil
}
relation := &coredata.ThirdPartyThirdParty{
ParentThirdPartyID: pc.ThirdPartyID,
ChildThirdPartyID: child.ID,
CreatedAt: time.Now(),
}
if p.Purpose != "" {
relation.Purpose = &p.Purpose
}
if err := relation.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot insert third party relation: %w", err)
}
return nil
}

108
pkg/vetting/persist_test.go Normal file
View File

@@ -0,0 +1,108 @@
// 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 (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"go.probo.inc/probo/pkg/coredata"
)
func TestBuildRiskAssessmentNotes(t *testing.T) {
t.Parallel()
info := ThirdPartyInfo{
OverallRiskRating: "Medium",
OverallRiskScore: 62,
Recommendation: "APPROVE_WITH_CONDITIONS",
SecurityRiskScore: 45,
PrivacyRiskScore: 70,
AIRiskScore: 10,
InvolvesAI: true,
RiskScores: []RiskScore{
{Category: "Security", Rating: "Medium", Notes: "Missing SOC 2"},
},
InformationGaps: []string{"No public DPA", "Sub-processor list inaccessible"},
}
notes := buildRiskAssessmentNotes(info)
assert.Equal(
t,
`Automated vetting
Overall risk: 62/100 (Medium)
Recommendation: Approve with conditions
Security 45/100 · Privacy 70/100 · AI 10/100
Gaps
· No public DPA
· Sub-processor list inaccessible`,
notes,
)
assert.NotContains(t, notes, "**")
assert.NotContains(t, notes, "#")
}
func TestBuildRiskAssessmentNotes_LimitsGaps(t *testing.T) {
t.Parallel()
gaps := make([]string, maxVettingNotesGaps+2)
for i := range gaps {
gaps[i] = "gap"
}
notes := buildRiskAssessmentNotes(ThirdPartyInfo{InformationGaps: gaps})
assert.Equal(t, maxVettingNotesGaps, strings.Count(notes, "· gap"))
}
func TestFormatVettingRecommendation(t *testing.T) {
t.Parallel()
assert.Equal(t, "Approve with conditions", formatVettingRecommendation("APPROVE_WITH_CONDITIONS"))
assert.Equal(t, "Reject", formatVettingRecommendation("reject"))
}
func TestMapVettingRiskLevels(t *testing.T) {
t.Parallel()
assert.Equal(
t,
coredata.DataSensitivityNone,
mapVettingDataSensitivity(ThirdPartyInfo{ProcessesPII: false}),
)
assert.Equal(
t,
coredata.DataSensitivityHigh,
mapVettingDataSensitivity(ThirdPartyInfo{
ProcessesPII: true,
PrivacyRiskScore: 70,
}),
)
assert.Equal(
t,
coredata.BusinessImpactMedium,
mapVettingBusinessImpact(ThirdPartyInfo{OverallRiskScore: 40}),
)
assert.Equal(
t,
coredata.BusinessImpactHigh,
mapVettingBusinessImpact(ThirdPartyInfo{OverallRiskRating: "High"}),
)
}

View File

@@ -10,4 +10,6 @@ Given a third party assessment markdown report, extract the third party informat
- Extract only information explicitly present in the report.
- Use empty strings for fields not mentioned, empty arrays for missing lists, false for missing booleans.
- Never infer or fabricate; if the report does not state something, leave the field empty.
- Populate data_locations with countries or regions where data is processed or stored. Prefer ISO 3166-1 alpha-2 codes (US, DE, EU, GLOBAL) when the report states them; otherwise use the country or region names from the report.
- Include the headquarters country in data_locations when it is stated in the report.
</important>

View File

@@ -26,6 +26,13 @@ If `research_third_party_externally` is available, use it for incidents, regulat
{procedure}
</assessment_procedure>
<persistence>
After completing your analysis and writing the report:
1. Call `save_third_party_info` once with all metadata you discovered (name, description, category, URLs, certifications). Use an empty string for fields you could not find.
2. For each sub-processor or vendor dependency discovered, call `link_sub_third_party` with the name, description, category, website URL, country, and purpose. If a third party with the same name already exists it is linked without duplication; otherwise a new one is created with the info you provide.
</persistence>
<important>
- Only report information actually discovered through the tools — never fabricate URLs, certifications, or findings.
- Note tool failures and inaccessible pages in the report rather than omitting the section.

View File

@@ -58,7 +58,7 @@ func newSubAgent[T any](
tools []agent.Tool,
extraOpts ...agent.Option,
) (*agent.Agent, error) {
outputType, err := agent.NewOutputType[T](spec.outputName)
outputType, err := newVettingOutputType[T](spec.outputName)
if err != nil {
return nil, fmt.Errorf("cannot create output type %q: %w", spec.outputName, err)
}

View File

@@ -0,0 +1,167 @@
// 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 (
"encoding/json"
"strings"
"go.probo.inc/probo/pkg/llm"
)
const extractSubprocessorsToolName = "extract_subprocessors"
// subprocessorsFromOrchestratorMessages collects sub-processors from every
// extract_subprocessors sub-agent tool result in the orchestrator transcript.
// Later tool calls win when the same name appears more than once.
func subprocessorsFromOrchestratorMessages(messages []llm.Message) []Subprocessor {
toolNames := toolCallNamesByID(messages)
byName := make(map[string]Subprocessor)
order := make([]string, 0)
for _, msg := range messages {
if msg.Role != llm.RoleTool {
continue
}
if toolNames[msg.ToolCallID] != extractSubprocessorsToolName {
continue
}
text := strings.TrimSpace(msg.Text())
if text == "" || !json.Valid([]byte(text)) {
continue
}
var output SubprocessorOutput
if err := json.Unmarshal([]byte(text), &output); err != nil {
continue
}
for _, sub := range output.Subprocessors {
if sub.Name == "" {
continue
}
key := normalizeSubprocessorName(sub.Name)
if _, exists := byName[key]; !exists {
order = append(order, key)
}
byName[key] = sub
}
}
if len(order) == 0 {
return nil
}
subs := make([]Subprocessor, 0, len(order))
for _, key := range order {
subs = append(subs, byName[key])
}
return subs
}
// mergeSubprocessors prefers entries from primary (tool output). Names only
// present in secondary (markdown extraction) are appended afterward.
func mergeSubprocessors(primary, secondary []Subprocessor) []Subprocessor {
if len(primary) == 0 {
return secondary
}
if len(secondary) == 0 {
return primary
}
merged := make([]Subprocessor, len(primary), len(primary)+len(secondary))
copy(merged, primary)
seen := make(map[string]struct{}, len(primary))
for _, sub := range primary {
seen[normalizeSubprocessorName(sub.Name)] = struct{}{}
}
for _, sub := range secondary {
if sub.Name == "" {
continue
}
key := normalizeSubprocessorName(sub.Name)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
merged = append(merged, sub)
}
return merged
}
func subprocessorListURLFromOrchestratorMessages(messages []llm.Message) string {
toolNames := toolCallNamesByID(messages)
var source string
for _, msg := range messages {
if msg.Role != llm.RoleTool {
continue
}
if toolNames[msg.ToolCallID] != extractSubprocessorsToolName {
continue
}
text := strings.TrimSpace(msg.Text())
if text == "" || !json.Valid([]byte(text)) {
continue
}
var output SubprocessorOutput
if err := json.Unmarshal([]byte(text), &output); err != nil {
continue
}
if strings.TrimSpace(output.Source) != "" {
source = strings.TrimSpace(output.Source)
}
}
return source
}
func toolCallNamesByID(messages []llm.Message) map[string]string {
toolNames := make(map[string]string)
for _, msg := range messages {
if msg.Role != llm.RoleAssistant {
continue
}
for _, tc := range msg.ToolCalls {
toolNames[tc.ID] = tc.Function.Name
}
}
return toolNames
}
func normalizeSubprocessorName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}

View File

@@ -0,0 +1,178 @@
// 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 (
"testing"
"github.com/stretchr/testify/assert"
"go.probo.inc/probo/pkg/llm"
)
func TestSubprocessorsFromOrchestratorMessages(t *testing.T) {
t.Parallel()
toolJSON := `{"subprocessors":[{"name":"Amazon Web Services","country":"US","purpose":"Cloud hosting"}],"total_count":1,"source":"https://example.com/subprocessors","is_complete":true}`
messages := []llm.Message{
{
Role: llm.RoleAssistant,
ToolCalls: []llm.ToolCall{{
ID: "call-1",
Function: llm.FunctionCall{
Name: extractSubprocessorsToolName,
},
}},
},
{
Role: llm.RoleTool,
ToolCallID: "call-1",
Parts: []llm.Part{llm.TextPart{Text: toolJSON}},
},
}
subs := subprocessorsFromOrchestratorMessages(messages)
assert.Equal(
t,
[]Subprocessor{{
Name: "Amazon Web Services",
Country: "US",
Purpose: "Cloud hosting",
}},
subs,
)
}
func TestSubprocessorsFromOrchestratorMessages_LatestCallWins(t *testing.T) {
t.Parallel()
messages := []llm.Message{
{
Role: llm.RoleAssistant,
ToolCalls: []llm.ToolCall{
{
ID: "call-1",
Function: llm.FunctionCall{
Name: extractSubprocessorsToolName,
},
},
{
ID: "call-2",
Function: llm.FunctionCall{
Name: extractSubprocessorsToolName,
},
},
},
},
{
Role: llm.RoleTool,
ToolCallID: "call-1",
Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[{"name":"Stripe","country":"US","purpose":"Payments"}]}`}},
},
{
Role: llm.RoleTool,
ToolCallID: "call-2",
Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[{"name":"Stripe","country":"IE","purpose":"Payment processing"}]}`}},
},
}
subs := subprocessorsFromOrchestratorMessages(messages)
assert.Equal(
t,
[]Subprocessor{{
Name: "Stripe",
Country: "IE",
Purpose: "Payment processing",
}},
subs,
)
}
func TestSubprocessorsFromOrchestratorMessages_IgnoresOtherTools(t *testing.T) {
t.Parallel()
messages := []llm.Message{
{
Role: llm.RoleAssistant,
ToolCalls: []llm.ToolCall{{
ID: "call-1",
Function: llm.FunctionCall{
Name: "assess_security",
},
}},
},
{
Role: llm.RoleTool,
ToolCallID: "call-1",
Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[{"name":"Ignored"}]}`}},
},
}
assert.Nil(t, subprocessorsFromOrchestratorMessages(messages))
}
func TestMergeSubprocessors(t *testing.T) {
t.Parallel()
toolSubs := []Subprocessor{{
Name: "AWS",
Country: "US",
Purpose: "Hosting",
}}
extractedSubs := []Subprocessor{
{Name: "AWS", Country: "DE", Purpose: "Wrong"},
{Name: "SendGrid", Country: "US", Purpose: "Email"},
}
merged := mergeSubprocessors(toolSubs, extractedSubs)
assert.Equal(
t,
[]Subprocessor{
{Name: "AWS", Country: "US", Purpose: "Hosting"},
{Name: "SendGrid", Country: "US", Purpose: "Email"},
},
merged,
)
}
func TestSubprocessorListURLFromOrchestratorMessages(t *testing.T) {
t.Parallel()
messages := []llm.Message{
{
Role: llm.RoleAssistant,
ToolCalls: []llm.ToolCall{{
ID: "call-1",
Function: llm.FunctionCall{
Name: extractSubprocessorsToolName,
},
}},
},
{
Role: llm.RoleTool,
ToolCallID: "call-1",
Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[],"source":"https://example.com/legal/subprocessors"}`}},
},
}
assert.Equal(
t,
"https://example.com/legal/subprocessors",
subprocessorListURLFromOrchestratorMessages(messages),
)
}

146
pkg/vetting/tools.go Normal file
View File

@@ -0,0 +1,146 @@
// 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"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type (
saveThirdPartyInfoToolParams struct {
Name string `json:"name" jsonschema:"Third party display name"`
Description string `json:"description" jsonschema:"One-sentence description"`
Category string `json:"category" jsonschema:"Category: ANALYTICS, CLOUD_PROVIDER, SECURITY, etc."`
HeadquarterAddress string `json:"headquarter_address" jsonschema:"Headquarters city and country"`
LegalName string `json:"legal_name" jsonschema:"Legal entity name"`
PrivacyPolicyURL string `json:"privacy_policy_url" jsonschema:"Privacy policy URL"`
ServiceLevelAgreementURL string `json:"service_level_agreement_url" jsonschema:"SLA URL"`
DataProcessingAgreementURL string `json:"data_processing_agreement_url" jsonschema:"DPA URL"`
BusinessAssociateAgreementURL string `json:"business_associate_agreement_url" jsonschema:"BAA URL"`
SubprocessorsListURL string `json:"subprocessors_list_url" jsonschema:"Subprocessors list URL"`
SecurityPageURL string `json:"security_page_url" jsonschema:"Security page URL"`
TrustPageURL string `json:"trust_page_url" jsonschema:"Trust center URL"`
TermsOfServiceURL string `json:"terms_of_service_url" jsonschema:"Terms of service URL"`
StatusPageURL string `json:"status_page_url" jsonschema:"Status page URL"`
Certifications []string `json:"certifications" jsonschema:"Compliance certifications found"`
}
saveThirdPartyInfoParams struct {
saveThirdPartyInfoToolParams
Countries coredata.CountryCodes
}
linkSubThirdPartyParams struct {
Name string `json:"name" jsonschema:"Sub-third-party company name"`
Description string `json:"description,omitempty" jsonschema:"One-sentence description of what this third party does"`
Category string `json:"category,omitempty" jsonschema:"Category: ANALYTICS, CLOUD_PROVIDER, SECURITY, etc."`
WebsiteURL string `json:"website_url,omitempty" jsonschema:"Website URL if known"`
Country string `json:"country,omitempty" jsonschema:"Country where the sub-third-party operates"`
Purpose string `json:"purpose,omitempty" jsonschema:"Purpose or role of this sub-third-party"`
}
// PersistenceContext holds the DB and entity references the tools need.
PersistenceContext struct {
PG *pg.Client
ThirdPartyID gid.GID
OrganizationID gid.GID
WebsiteURL string
}
)
func SaveThirdPartyInfoTool(pc *PersistenceContext) agent.Tool {
return vettingFunctionTool(
"save_third_party_info",
"Persist the discovered third party metadata to the database. Call this once after completing the analysis. Use an empty string for any field you could not discover.",
func(ctx context.Context, p saveThirdPartyInfoToolParams) (agent.ToolResult, error) {
scope := coredata.NewScopeFromObjectID(pc.ThirdPartyID)
err := pc.PG.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
thirdParty := &coredata.ThirdParty{}
if err := thirdParty.LoadByID(ctx, conn, scope, pc.ThirdPartyID); err != nil {
return fmt.Errorf("cannot load third party: %w", err)
}
if p.Category != "" {
if _, err := parseThirdPartyCategory(p.Category); err != nil {
return err
}
}
applySaveParams(thirdParty, pc.WebsiteURL, saveThirdPartyInfoParams{
saveThirdPartyInfoToolParams: p,
})
thirdParty.UpdatedAt = time.Now()
if err := thirdParty.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update third party: %w", err)
}
return nil
},
)
if err != nil {
return agent.ToolResult{}, fmt.Errorf("cannot save third party info: %w", err)
}
return agent.ToolResult{Content: "Third party info saved successfully."}, nil
},
)
}
func LinkSubThirdPartyTool(pc *PersistenceContext) agent.Tool {
return vettingFunctionTool(
"link_sub_third_party",
"Link a discovered sub-third-party (sub-processor, vendor dependency) to the parent. If a third party with the same name already exists in the organization it is linked as-is; otherwise a new one is created with the provided info. Call once per sub-third-party discovered.",
func(ctx context.Context, p linkSubThirdPartyParams) (agent.ToolResult, error) {
if p.Name == "" {
return agent.ToolResult{Content: "Skipped: empty name."}, nil
}
scope := coredata.NewScopeFromObjectID(pc.ThirdPartyID)
err := pc.PG.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
return linkSubThirdParty(ctx, conn, scope, pc, p)
},
)
if err != nil {
return agent.ToolResult{}, fmt.Errorf("cannot link sub third party: %w", err)
}
return agent.ToolResult{Content: fmt.Sprintf("Linked %q as sub third party.", p.Name)}, nil
},
)
}
func parseThirdPartyCategory(raw string) (coredata.ThirdPartyCategory, error) {
category := coredata.ThirdPartyCategory(raw)
if !category.IsValid() {
return "", fmt.Errorf("invalid third party category %q", raw)
}
return category, nil
}