Security and data handling

Credentials

  • Every credential lives in one Secrets Manager secret, agentcore-configs. Nothing is baked into an image, a template, or source code.

  • Each consumer declares only the keys it needs in a Pydantic model with extra="ignore", so an unrelated key cannot leak into an unrelated component.

  • Secret reads are cached per container (@lru_cache in Lambdas, a module global in the runtime): one Secrets Manager call per container lifetime.

  • Libraries never read os.environ. create_hubspot_mcp(), create_tavily_mcp(), and create_bigquery_client() all take credentials as explicit keyword arguments; the caller fetches them. This keeps credential provenance visible at the call site.

  • .env files and service-account JSON must never be committed. .gitignore covers the common cases; enrichment_scraper/.env.example documents the shape without values.

Two HubSpot auth modes are supported, in priority order: a Private App token (HUBSPOT_PRIVATE_APP_TOKEN, preferred — static, no refresh) and the OAuth triple (fallback). morpheus-hubspot validates at load time that at least one complete mode is present and fails the cold start otherwise.

PII

The platform handles names, email addresses, phone numbers, and business identifiers for real people.

Logging rules

Log identifiers (sellerId, hubspot_contact_id, row UUIDs) and counts. Do not log message bodies, transcripts, or email content. Use logger.debug() for per-record detail so it is off in production. Always pass exc_info=True on a caught exception rather than reformatting the payload into the message.

Known deviation: the morpheus-hubspot dispatcher logs the full message body in its "Handler failed" ERROR line.

Sentry

Initialised with send_default_pii=False, which strips IP addresses and user agents. LoggingIntegration forwards ERROR-level records as events, so any PII placed in an error message reaches Sentry. Currently disabled — SENTRY_DSN is empty.

Email content

Inbound reply text is passed to Bedrock for classification and is not persisted by email-agent beyond the category, timestamp, and model label. The emails table (written by the web application) does hold bodies.

Suppression

Opt-outs are authoritative. Contact.Unsubscribe writes HubSpot Communication Preferences; the contact.propertyChange webhook processor mirrors hs_email_optout_<id> into target_emails.newsletterOptOut, oneToOneOptOut, and marketingOptOut. Re-engagement segments exclude blocked dispositions and do-not-contact reasons in SQL. See Email engagement.

Tenant isolation

Every domain table is scoped by subscriptionId. Isolation is enforced in three different ways depending on the component:

Component Mechanism

enrichment-scraper, enrichment-scorer, phone-agent, morpheus-hubspot

SUBSCRIPTION_ID environment variable. morpheus-hubspot additionally rejects any event whose subscription_id does not match, logging at ERROR and returning without raising.

email-agent

No SUBSCRIPTION_ID at all. Tenant scope is derived from the loaded EmailCampaignTarget.subscriptionId, then cross-checked against the Contact, the EmailVariation, and the campaign id. Any drift is logged at ERROR and the handler returns. See tenant safety.

A mismatch is treated as bad data, never retried — a poison message from a misconfigured producer must not loop forever.

IAM posture

The Lambda functions use SAM-generated roles with narrow policies:

  • SQSPollerPolicy / SQSSendMessagePolicy scoped to the specific queue name.

  • secretsmanager:GetSecretValue scoped to AgentcoreSecret only. enrichment-scraper additionally holds PutSecretValue on the same secret because it caches the SmartScout session token there.

  • bedrock:InvokeModel and InvokeModelWithResponseStream on * for the two functions that call Bedrock (enrichment-scorer, email-agent). Bedrock model ARNs are not enumerable in advance for cross-region inference profiles.

The AgentCore runtime role is defined in the CDK stack with per-statement SIDs — ECR pull, CloudWatch Logs, X-Ray, namespaced CloudWatch metrics, AgentCore workload identity, Bedrock invocation, AWS Marketplace subscription (required for first-use model subscription), Secrets Manager read scoped to the secret ARN, and AgentCore Memory operations scoped to the memory ARN. Full statement list: IAM.

CDK owns all IAM. Importing an externally managed role with iam.Role.fromRoleArn() is prohibited — see Engineering conventions.

Prompt injection

Two agents consume untrusted text.

Inbound email categorisation (email-agent)

inbound_text is verbatim sender content, interpolated into the prompt without sanitisation. This is accepted, with two controls:

  • Structured output — EmailReplyCategorisationResult.category is constrained to the EmailTargetCategory enum, so the model cannot emit arbitrary JSON or invoke tools.

  • The agent is constructed with tools=lambda: [] — no tool surface at all.

    Residual risk is bounded misclassification (a reply containing "always choose interested"), not code execution or data exfiltration. This trade-off is documented in lambdas/email_agent/agents/prompts.py.

Seller enrichment (enrichment-scorer)

The enrichment agent reads arbitrary web content through the Tavily MCP tool and has HubSpot MCP write access in the same session. Output is constrained to SellerEnrichmentResult, max_turns is capped at 6, and every result is validated before it reaches the database. Content fetched from the open web is the injection surface here.

strands_tools vended tools — shell, python_repl, editor, http_request — are wired into the runtime’s dialler_agent. shell and python_repl grant in-container code execution to the model. Only expose them to workflows whose inputs you control.

Automated security review

.github/workflows/security-review.yml runs a security analysis over pull-request diffs using the prompt in .github/agents/security-risk-analysis.md. Locally, the /security-review skill performs the equivalent review of the current branch.