diff --git a/.env.example b/.env.example index f2235f2f6..5a2f0b705 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/GNUmakefile b/GNUmakefile index a5e9488e4..f265ed06d 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -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; \ diff --git a/apps/console/src/hooks/graph/ThirdPartyGraph.ts b/apps/console/src/hooks/graph/ThirdPartyGraph.ts index 6951b3191..07ae43356 100644 --- a/apps/console/src/hooks/graph/ThirdPartyGraph.ts +++ b/apps/console/src/hooks/graph/ThirdPartyGraph.ts @@ -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( diff --git a/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx b/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx index 45a684b80..5a74de0fd 100644 --- a/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx +++ b/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx @@ -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( + 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 (
+ {isVetting && ( +
+
+ {__("Vetting is in progress. Results will appear once the analysis is complete.")} +
+ )} + {isVettingFailed && ( +
+ {__("Vetting failed. You can start vetting again.")} +
+ )}
- {thirdParty.canAssess && ( - + {thirdParty.canVet && !isVetting && ( + - + )} {thirdParty.canDelete && ( diff --git a/apps/console/src/pages/organizations/third-parties/dialogs/ImportAssessmentDialog.tsx b/apps/console/src/pages/organizations/third-parties/dialogs/VettingDialog.tsx similarity index 58% rename from apps/console/src/pages/organizations/third-parties/dialogs/ImportAssessmentDialog.tsx rename to apps/console/src/pages/organizations/third-parties/dialogs/VettingDialog.tsx index 4ba7a1f57..66dee03aa 100644 --- a/apps/console/src/pages/organizations/third-parties/dialogs/ImportAssessmentDialog.tsx +++ b/apps/console/src/pages/organizations/third-parties/dialogs/VettingDialog.tsx @@ -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(vetMutation); - const onSubmit = async (data: z.infer) => { - await assess({ + const onSubmit = (data: z.infer) => { + 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) {
void handleSubmit(onSubmit)(e)}> -
diff --git a/apps/console/src/pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab.tsx b/apps/console/src/pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab.tsx index 0780c2e13..4f88b07b3 100644 --- a/apps/console/src/pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab.tsx +++ b/apps/console/src/pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab.tsx @@ -214,7 +214,9 @@ function AssessmentRow(props: AssessmentRowProps) { {__("Notes")} :
-

{assessment.notes}

+

+ {assessment.notes} +

diff --git a/contrib/helm/charts/probo/templates/deployment.yaml b/contrib/helm/charts/probo/templates/deployment.yaml index 03ec276f5..b1ed6f468 100644 --- a/contrib/helm/charts/probo/templates/deployment.yaml +++ b/contrib/helm/charts/probo/templates/deployment.yaml @@ -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 diff --git a/contrib/helm/charts/probo/values-production.yaml.example b/contrib/helm/charts/probo/values-production.yaml.example index e3788428c..ce60edc88 100644 --- a/contrib/helm/charts/probo/values-production.yaml.example +++ b/contrib/helm/charts/probo/values-production.yaml.example @@ -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" diff --git a/contrib/helm/charts/probo/values.yaml b/contrib/helm/charts/probo/values.yaml index ba7be9c3e..ead9d261e 100644 --- a/contrib/helm/charts/probo/values.yaml +++ b/contrib/helm/charts/probo/values.yaml @@ -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: "" diff --git a/e2e/console/third_party_test.go b/e2e/console/third_party_test.go index cb7208e82..7f1feb1e1 100644 --- a/e2e/console/third_party_test.go +++ b/e2e/console/third_party_test.go @@ -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) }) } diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts index 2f4fdedf9..37decb46b 100644 --- a/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts @@ -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, }; diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/vet.operation.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/vet.operation.ts new file mode 100644 index 000000000..4d3fc75a0 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/vet.operation.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { + 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 = { + id: thirdPartyId, + websiteUrl, + }; + + if (procedure) { + input.procedure = procedure; + } + + const responseData = await proboApiRequest.call(this, query, { input }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/pkg/agent/tool_test.go b/pkg/agent/tool_test.go index d30e7ce64..eea320376 100644 --- a/pkg/agent/tool_test.go +++ b/pkg/agent/tool_test.go @@ -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) diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index ea49a6bd6..aa480c76a 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -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), diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index c7e928231..295848d2d 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -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) diff --git a/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go b/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go index 928eb2dbe..431547b1c 100644 --- a/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go +++ b/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go @@ -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)) diff --git a/pkg/cmd/thirdpartymgmt/assess/assess.go b/pkg/cmd/thirdpartymgmt/vet/vet.go similarity index 57% rename from pkg/cmd/thirdpartymgmt/assess/assess.go rename to pkg/cmd/thirdpartymgmt/vet/vet.go index 1565a5887..aa56976d2 100644 --- a/pkg/cmd/thirdpartymgmt/assess/assess.go +++ b/pkg/cmd/thirdpartymgmt/vet/vet.go @@ -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 --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 --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 diff --git a/pkg/cookiebanner/pattern_analysis_worker_process_test.go b/pkg/cookiebanner/pattern_analysis_worker_process_test.go index 98a52be36..52f466bfc 100644 --- a/pkg/cookiebanner/pattern_analysis_worker_process_test.go +++ b/pkg/cookiebanner/pattern_analysis_worker_process_test.go @@ -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, diff --git a/pkg/coredata/migrations/20260601T120000Z.sql b/pkg/coredata/migrations/20260601T120000Z.sql new file mode 100644 index 000000000..371e85559 --- /dev/null +++ b/pkg/coredata/migrations/20260601T120000Z.sql @@ -0,0 +1,24 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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; diff --git a/pkg/coredata/third_party.go b/pkg/coredata/third_party.go index 43919b989..f6bc44f82 100644 --- a/pkg/coredata/third_party.go +++ b/pkg/coredata/third_party.go @@ -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 diff --git a/pkg/coredata/third_party_third_party.go b/pkg/coredata/third_party_third_party.go index ed0ed23f6..1da5261d3 100644 --- a/pkg/coredata/third_party_third_party.go +++ b/pkg/coredata/third_party_third_party.go @@ -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 diff --git a/pkg/coredata/third_party_vetting.go b/pkg/coredata/third_party_vetting.go new file mode 100644 index 000000000..c558b6e7a --- /dev/null +++ b/pkg/coredata/third_party_vetting.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/coredata/third_party_vetting_status.go b/pkg/coredata/third_party_vetting_status.go new file mode 100644 index 000000000..d9ace7db9 --- /dev/null +++ b/pkg/coredata/third_party_vetting_status.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 78eaf1a07..028b83fc4 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -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, diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 43a1e0db8..e5530151b 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -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 diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 2a95f0856..5639a4025 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -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{ diff --git a/pkg/probo/third_party_service.go b/pkg/probo/third_party_service.go index 7282be344..88d6fa3d4 100644 --- a/pkg/probo/third_party_service.go +++ b/pkg/probo/third_party_service.go @@ -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, diff --git a/pkg/probod/aliases.go b/pkg/probod/aliases.go index 9a81e4c2a..d2a30d28d 100644 --- a/pkg/probod/aliases.go +++ b/pkg/probod/aliases.go @@ -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 diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 619b6db75..f1246762c 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -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() diff --git a/pkg/probod/third_party_assessor.go b/pkg/probod/third_party_vetter.go similarity index 66% rename from pkg/probod/third_party_assessor.go rename to pkg/probod/third_party_vetter.go index cc1628062..ba041676a 100644 --- a/pkg/probod/third_party_assessor.go +++ b/pkg/probod/third_party_vetter.go @@ -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 } diff --git a/pkg/probodconfig/config.go b/pkg/probodconfig/config.go index 7eedc602f..2334d8d6f 100644 --- a/pkg/probodconfig/config.go +++ b/pkg/probodconfig/config.go @@ -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"` diff --git a/pkg/probodconfig/llm_config.go b/pkg/probodconfig/llm_config.go index 3cda73fde..2dda1df55 100644 --- a/pkg/probodconfig/llm_config.go +++ b/pkg/probodconfig/llm_config.go @@ -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"` } ) diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 49e83425c..2544e62e7 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -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, diff --git a/pkg/server/api/console/v1/graphql/third_party.graphql b/pkg/server/api/console/v1/graphql/third_party.graphql index 1f801f099..715aaac74 100644 --- a/pkg/server/api/console/v1/graphql/third_party.graphql +++ b/pkg/server/api/console/v1/graphql/third_party.graphql @@ -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 { diff --git a/pkg/server/api/console/v1/third_party_resolvers.go b/pkg/server/api/console/v1/third_party_resolvers.go index b4b879d93..4a4355195 100644 --- a/pkg/server/api/console/v1/third_party_resolvers.go +++ b/pkg/server/api/console/v1/third_party_resolvers.go @@ -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) diff --git a/pkg/server/api/console/v1/types/third_party.go b/pkg/server/api/console/v1/types/third_party.go index d9fd6af9f..fc2c13218 100644 --- a/pkg/server/api/console/v1/types/third_party.go +++ b/pkg/server/api/console/v1/types/third_party.go @@ -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 -} diff --git a/pkg/server/api/mcp/v1/resolver.go b/pkg/server/api/mcp/v1/resolver.go index edc0410b5..105a7e0b2 100644 --- a/pkg/server/api/mcp/v1/resolver.go +++ b/pkg/server/api/mcp/v1/resolver.go @@ -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 diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 7768f965d..e0cecd90b 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -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) { diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index e2b4d6d78..1db8fb95d 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -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: diff --git a/pkg/server/api/mcp/v1/types/third_party.go b/pkg/server/api/mcp/v1/types/third_party.go index 9832961bb..39f9d9cfa 100644 --- a/pkg/server/api/mcp/v1/types/third_party.go +++ b/pkg/server/api/mcp/v1/types/third_party.go @@ -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), - } -} diff --git a/pkg/server/api/mcp/v1/v1_handler.go b/pkg/server/api/mcp/v1/v1_handler.go index 8d15b5071..923ce188b 100644 --- a/pkg/server/api/mcp/v1/v1_handler.go +++ b/pkg/server/api/mcp/v1/v1_handler.go @@ -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, diff --git a/pkg/thirdparty/service.go b/pkg/thirdparty/service.go index 459eec661..1fd230456 100644 --- a/pkg/thirdparty/service.go +++ b/pkg/thirdparty/service.go @@ -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, } } diff --git a/pkg/thirdparty/vetting.go b/pkg/thirdparty/vetting.go new file mode 100644 index 000000000..aace68df5 --- /dev/null +++ b/pkg/thirdparty/vetting.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/thirdparty/vetting_test.go b/pkg/thirdparty/vetting_test.go new file mode 100644 index 000000000..17b07d2d6 --- /dev/null +++ b/pkg/thirdparty/vetting_test.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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{} +} diff --git a/pkg/thirdparty/vetting_worker.go b/pkg/thirdparty/vetting_worker.go new file mode 100644 index 000000000..74829088c --- /dev/null +++ b/pkg/thirdparty/vetting_worker.go @@ -0,0 +1,238 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 + }, + ) +} diff --git a/pkg/vetting/assessment.go b/pkg/vetting/assessment.go index 633b86fc0..a9413f06f 100644 --- a/pkg/vetting/assessment.go +++ b/pkg/vetting/assessment.go @@ -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 } diff --git a/pkg/vetting/country_codes.go b/pkg/vetting/country_codes.go new file mode 100644 index 000000000..ad15da22c --- /dev/null +++ b/pkg/vetting/country_codes.go @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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), " ") +} diff --git a/pkg/vetting/country_codes_test.go b/pkg/vetting/country_codes_test.go new file mode 100644 index 000000000..482b1c116 --- /dev/null +++ b/pkg/vetting/country_codes_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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"), + ) +} diff --git a/pkg/vetting/openai_schema.go b/pkg/vetting/openai_schema.go new file mode 100644 index 000000000..a27046d0b --- /dev/null +++ b/pkg/vetting/openai_schema.go @@ -0,0 +1,189 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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), ¶ms); 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) + } +} diff --git a/pkg/vetting/openai_schema_test.go b/pkg/vetting/openai_schema_test.go new file mode 100644 index 000000000..b4700b75f --- /dev/null +++ b/pkg/vetting/openai_schema_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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"]) +} diff --git a/pkg/vetting/orchestrator.go b/pkg/vetting/orchestrator.go index a28c1d736..838f81443 100644 --- a/pkg/vetting/orchestrator.go +++ b/pkg/vetting/orchestrator.go @@ -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 } diff --git a/pkg/vetting/persist.go b/pkg/vetting/persist.go new file mode 100644 index 000000000..4eca484f2 --- /dev/null +++ b/pkg/vetting/persist.go @@ -0,0 +1,448 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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: ¬es, + 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 +} diff --git a/pkg/vetting/persist_test.go b/pkg/vetting/persist_test.go new file mode 100644 index 000000000..c60ff0b9c --- /dev/null +++ b/pkg/vetting/persist_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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"}), + ) +} diff --git a/pkg/vetting/prompts/extraction.txt b/pkg/vetting/prompts/extraction.txt index 862be0e68..ed63988c6 100644 --- a/pkg/vetting/prompts/extraction.txt +++ b/pkg/vetting/prompts/extraction.txt @@ -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. diff --git a/pkg/vetting/prompts/orchestrator_base.txt b/pkg/vetting/prompts/orchestrator_base.txt index 7908d4de4..5e9d2a4b7 100644 --- a/pkg/vetting/prompts/orchestrator_base.txt +++ b/pkg/vetting/prompts/orchestrator_base.txt @@ -26,6 +26,13 @@ If `research_third_party_externally` is available, use it for incidents, regulat {procedure} + +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. + + - 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. diff --git a/pkg/vetting/sub_agent.go b/pkg/vetting/sub_agent.go index 3e331d8a7..e580ce9d1 100644 --- a/pkg/vetting/sub_agent.go +++ b/pkg/vetting/sub_agent.go @@ -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) } diff --git a/pkg/vetting/subprocessors.go b/pkg/vetting/subprocessors.go new file mode 100644 index 000000000..a76ee8cfe --- /dev/null +++ b/pkg/vetting/subprocessors.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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)) +} diff --git a/pkg/vetting/subprocessors_test.go b/pkg/vetting/subprocessors_test.go new file mode 100644 index 000000000..55ea17c6c --- /dev/null +++ b/pkg/vetting/subprocessors_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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), + ) +} diff --git a/pkg/vetting/tools.go b/pkg/vetting/tools.go new file mode 100644 index 000000000..ca1b52709 --- /dev/null +++ b/pkg/vetting/tools.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +}