Case study 02 / Independent agents in Teams
Independent agents in Teams
Korvu designed an Azure architecture in which Project Controls sends an estimating task to a second agent and returns the result in Teams.
Proposed architecture; not deployed or integration-tested.
How the request moves
Arrows show inbound messages, agent handoffs, private resources, and outbound replies.
Project Controls replies and credits Estimating.
Public messaging route. Agent APIs, tools, and health routes stay private.
Each agent authorizes its own resource and tool access
Private resources
Private endpoints + DNS, where supported
- Key Vault
- Azure OpenAI
- Service Bus
- Enterprise tools
The Teams SDK validates activities. Each peer API authorizes its caller.
App separation is not network isolation. Private Azure traffic can bypass the firewall; external providers use controlled egress. Required human approvals still apply.
Follow the request
Model provider adapters
The harness selects the model. Teams message handling stays separate.
MODEL_PROVIDER=anthropic MODEL_NAME=<approved-claude-model> # Teams transport stays the same.Illustrative configuration; validate the model before deployment.
Implementation details
Technical reference
Architecture notes, configuration examples, and code skeletons.
Documentation checked 4 October 2026. Pin dependencies and validate tenant configuration before implementation.
01The reference architectureWhat is required, what is a design choice, and where the trust boundaries sit
Required by Teams A registered bot needs a reachable HTTPS messaging endpoint. Azure-specific This reference places that endpoint on Application Gateway WAF v2 and keeps its Container Apps environment internal. Recommended hardening Every agent has its own image, application, identities, data permissions, tool policy and audit trail. Optional Service Bus adds durable background work when a request cannot finish promptly.
USERS IN MICROSOFT TEAMS
|
| Microsoft Teams / Azure Bot channel delivery
| HTTPS + signed bearer-token validation
v
PUBLIC APPLICATION BOUNDARY
+---------------------------------------------------------+
| Application Gateway WAF v2 |
| Public DNS + trusted TLS certificate |
| Only approved bot messaging paths; deny other routes |
+----------------------------+----------------------------+
| HTTPS 443, private backend
VNET 10.40.0.0/16 v
+---------------------------------------------------------+
| Internal Azure Container Apps workload-profiles env |
| |
| +----------------------+ +-------------------------+ |
| | Project Controls app |-->| Estimating app | |
| | Teams transport | | Teams transport | |
| | Independent harness |<--| Independent harness | |
| | Own identity/tools | | Own identity/tools | |
| +----------+-----------+ +-------------+-----------+ |
| | authenticated private API / A2A |
| +----------------+----------------------------|
+-----------------------------|---------------------------+
|
PRIVATE ENDPOINTS | CONTROLLED EGRESS
+--------------------+ | +------------------+
| Key Vault |<---+------->| Azure Firewall |
| Azure OpenAI | | UDR | explicit allow |
| ACR + data store | | | lists + logs |
| Service Bus (opt.) | | +--------+---------+
+--------------------+ | |
| approved Microsoft,
Central logs / metrics <--+ model + enterprise APIs
A2A / tool execution / model calls do not gain public routes.
The firewall may have an outbound public IP; the gateway is
the sole intended public APPLICATION ingress.
Azure resource inventory
| Resource | Recommended scope | Purpose |
|---|---|---|
| Resource groups | Separate production, nonproduction and shared networking | Bound deployment access, budgets, policy and operational ownership. |
| Azure Bot registration + Teams app package | One per independently addressable Teams agent | Channel registration, messaging URL, bot identity, manifest and installation policy. |
| User-assigned managed identities | Per agent; separate deployment and gateway identities | Separate bot authentication, runtime resource access, image pull and certificate access where practical. |
| Application Gateway WAF v2 + public IP + DNS | Shared edge for one trust zone | TLS, request filtering, path routing and backend health. |
| VNet, NSGs, route table, Azure Firewall and DNS | Network boundary | Private reachability and controlled internet egress. |
| Internal workload-profiles Container Apps environment | One per trust zone and environment | Hosts separate Container Apps, with private environment ingress. |
| Container App | One per agent | Independent images, revisions, limits, scaling, probes and identities. |
| Azure Container Registry | Private registry; repository per agent | Immutable image digests, signatures and supply-chain artifacts. Select a tier supporting required private networking. |
| Key Vault | Per application or trust boundary, per environment | Provider credentials, SaaS secrets and certificate references. |
| Azure OpenAI + model deployments | Optional provider, private endpoint | Managed-identity model access; independent of the Teams transport. |
| State store and optional Service Bus | Private, partitioned per tenant/agent | Sessions, idempotency, job state, queues, locks and durable handoffs. |
| Azure Monitor, Log Analytics, Application Insights | Centralized with role-scoped access | Redacted traces, audit events, errors, alerts and operational dashboards. |
| Defender for Cloud + release runner | Applicable plans; private, ephemeral CI runner | Posture and registry vulnerability assessment, verified delivery without exposing private services. |
Sharing a Container Apps environment is a cost and operations choice, suitable for agents in the same trust zone. Separate Container Apps are separate deployment units, but do not create Kubernetes network-policy boundaries between mutually distrustful workloads. Use separate environments/subnets, and where needed subscriptions, for stronger tenant or security isolation.
Sources: Microsoft bot endpoint requirements; Container Apps networking; Application Gateway with an internal environment.
02Identities and permissionsTeams registration, managed identities, API roles, and Azure RBAC.
Required by Teams Azure-specific Recommended hardening
Give each Teams-facing agent its own app package, bot registration, and messaging endpoint. A private specialist that users never address in Teams does not need a Teams registration. Keep each service’s permissions, tools, budgets, and durable state separate.
Register each Teams-facing agent
- Choose the tenant and Teams scopes. Start with single-tenant, personal-scope access. Decide which users may install and invoke each agent. Group chats, channels, and multitenant use need separate consent and isolation review.
- Create the bot identity. Prefer a user-assigned managed identity (UAMI) for direct bot authentication when this flow fits. Alternatively, use an Entra application registration with a federated trust in a UAMI. Both avoid a stored client secret.
- Create its Azure Bot registration. Set the supported application type to match the identity pattern, configure the matching application/client ID and tenant, and enable the Microsoft Teams channel. Point the messaging endpoint at the approved public gateway URL, such as
https://controls.example.com/api/messages. - Create the Teams app package. Use Teams Developer Portal or Microsoft 365 Agents Toolkit. Include its own manifest
id, branding, icons, scopes, valid domains, privacy/support links, and onebotsentry whosebotIdmatches the bot client ID. Validate it and distribute through the tenant’s approved catalog and installation policy. - Configure additional API or sign-in identities only when needed. A custom Entra-protected peer API needs an app registration with its application roles. User SSO needs its own documented scope, redirect/consent, and token-exchange configuration. A managed identity alone has no application object for those settings.
- Attach the selected UAMIs to the Container App and grant resource access. Attaching an identity makes token acquisition available; Azure RBAC or an API app-role assignment separately authorizes the target resource. Test denied cross-agent access before rollout.
Sources: Direct UAMI bot setup · Teams manifest bot schema · Teams SDK authentication selection
Keep the identities distinct
| Identity | Purpose | Identifier to use |
|---|---|---|
| Teams app package | Catalog, installation, branding and declared capabilities | Manifest top-level id |
| Bot identity | Authenticates the Teams-facing application and outbound messages | Manifest bots[0].botId, Azure Bot msaAppId, SDK CLIENT_ID |
| Azure Bot resource | Channel registration and messaging URL | Azure Resource Manager resource ID |
| Entra application | Application definition, exposed roles/scopes, optional sign-in and federation | Application/client ID; application-object ID is different |
| Enterprise application | Tenant-local service principal receiving grants | Service-principal object ID |
| User-assigned managed identity | Azure-issued, short-lived workload tokens; optionally the direct bot identity | Client ID selects the credential; principal/object ID receives grants; resource ID attaches it to compute |
| Container process | Linux filesystem and process permissions | Numeric UID/GID, for example 10001:10001; not an Entra identity |
| Human and conversation | User authorization and bot-specific reply routing | (tenantId, aadObjectId) plus the observed bot-specific conversation and service URL |
An application registration has an application object and a service principal; a managed identity has only the service principal. Azure supplies short-lived tokens for identities attached to the workload. The recipient validates the token and authorizes access. Linux UID/GID controls process permissions, not Azure access.
Entra object model · Managed identity FAQ · Container Apps managed identities
Two secretless bot-authentication patterns
| Pattern | Registration | SDK configuration |
|---|---|---|
| Direct UAMI | Azure Bot application type: User Assigned Managed Identity | CLIENT_ID = bot UAMI client ID; TENANT_ID set; CLIENT_SECRET absent; MANAGED_IDENTITY_CLIENT_ID absent or the same client ID |
| Application + federation | Single Tenant Azure Bot and conventional Entra app; application trusts attached UAMI | CLIENT_ID = application client ID; MANAGED_IDENTITY_CLIENT_ID = different UAMI client ID; TENANT_ID set; CLIENT_SECRET absent |
For application federation, the application and UAMI must be in the same tenant. Configure the federated identity credential with the UAMI’s principal/object ID as the subject. The Teams guide’s CLI example uses a misleading client-ID variable for that field; follow the authoritative Entra requirement below. Cloud-specific token-exchange audiences differ.
{
"name": "bot-runtime-production",
"issuer": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"subject": "<uami-principal-object-id>",
"audiences": ["api://AzureADTokenExchange"]
}
Teams federation setup · Authoritative Entra federation fields
Bootstrap authority is separate from runtime authority
| Actor | Scoped permission | Boundary |
|---|---|---|
| Application creator/owner | Application Developer when tenant policy prevents ordinary users registering apps; app ownership or a scoped custom role for subsequent changes | Only the relevant registrations |
| Consent administrator | Cloud Application Administrator or approved equivalent when needed; Microsoft Graph application consent needs a more privileged authorized role such as Privileged Role Administrator | Review each requested grant; no blanket consent |
| Teams app administrator | Teams Administrator or other authorized Teams app/catalog administration; installation policies are separate from Entra app registration roles | Approve the required app packages and users |
| Azure provisioning workflow | Scoped resource deployment rights; Managed Identity Contributor to create identities; Managed Identity Operator where attaching an existing identity requires it | Named resource group/resources; no directory administrator role for running agents |
| RBAC provisioning workflow | Role Based Access Control Administrator, User Access Administrator, or a constrained custom role where assignment is necessary | Separate protected workflow; resource and role conditions where possible; Azure Contributor cannot assign RBAC roles |
| Agent runtime UAMI | Only required data-plane roles and peer API application roles | No Owner, Contributor, tenant administrator, or role-assignment authority |
Use time-limited, audited provisioning privileges and remove them afterward. Application administrators can change credentials and impersonate applications. Managed Identity Operator permits assignment; Managed Identity Contributor manages identity lifecycle. CI/CD uses workload federation instead of stored Azure client secrets.
Delegating application administration · Entra built-in roles and consent limits · Azure identity roles
API permissions and user consent
Basic bot conversations need no broad Graph permissions. Prefer Teams resource-specific consent (RSC) when it covers the feature. API application roles, delegated user scopes, RSC, and Azure RBAC are separate grants. Define an application role such as Agent.Invoke with allowedMemberTypes: ["Application"]; assign it to the caller UAMI’s service principal. Request api://<peer-api-client-id>/.default. Validate the recipient audience and required roles claim. A body field containing a user ID does not provide delegated user authority; user-scoped operations require the appropriate sign-in/OBO flow and scp permissions.
An app-role assignment uses the caller service-principal object ID as principalId, the target API service-principal object ID as resourceId, and the role-definition GUID as appRoleId. These are not client IDs. Azure resource access separately uses roles such as Key Vault Secrets User, Cognitive Services OpenAI User, and narrowly scoped Service Bus Data Sender/Receiver.
Assign API roles to managed identities · Token claims · Application token scopes
Workload permissions
| Principal | Minimum intended access | Scope / restriction |
|---|---|---|
| Bot identity per agent | Registered bot authentication | Only its bot/channel identity; no general Azure Contributor or Graph permissions. |
| Agent runtime managed identity | Key Vault Secrets User when reading secrets in code | Agent-specific vault preferred; only its approved secrets. No secret creation, deletion or role-assignment permission. |
| Agent runtime managed identity | Cognitive Services OpenAI User, when Azure OpenAI is used | Only the necessary Azure OpenAI resource. Do not grant service management rights to inference code. |
| Calling agent identity | A narrowly defined target API application role such as Estimate.Invoke | Assigned on the target API service principal; target checks audience, issuer, tenant, caller and role. Azure Contributor is unrelated. |
| Queue producer / consumer identities | Azure Service Bus Data Sender / Azure Service Bus Data Receiver | Only the relevant queue or topic/subscription scope. No shared namespace owner key. |
| Image-pull managed identity | AcrPull for a normal RBAC registry; Container Registry Repository Reader for an ABAC-enabled registry | Registry scope in standard mode; repository condition in ABAC mode. Legacy AcrPull is not honored in ABAC mode. |
| Platform secret-reference identity | Key Vault Secrets User | Only the app's vault/secrets. If used solely by Container Apps references, hide it from container code with lifecycle None. |
| Application Gateway identity | Key Vault Secrets User for its certificate secret | A dedicated certificate vault or scoped certificate secret; no agent model credentials. |
| Build identity | AcrPush, or ABAC Container Registry Repository Writer | Approved registry/repository only. Never attach this identity to agent workloads. |
| Release identity | A scoped custom deploy role, or resource-scoped Container Apps management role | Only intended apps and read access to their environment. No role assignment or secret-value read by default. |
| Infrastructure provisioning operator | Resource-group Contributor; Managed Identity Operator where identity assignment requires it | Only approved resources. A separate authorized role-assignment administrator handles RBAC; Contributor alone cannot grant access. |
| RBAC administrator | Role Based Access Control Administrator, with constraints where supported | Time-bound, narrow scope; separate from runtime and routine releases. |
At runtime, Azure Identity obtains a short-lived token from the Container Apps managed-identity endpoint and caches/refreshes it. Request the downstream service's audience: for example https://vault.azure.net/.default, https://cognitiveservices.azure.com/.default, or the target inter-agent API's configured api://…/.default. The service still evaluates its own RBAC or application role. A valid token is not sufficient authorization. Let the Teams SDK handle its supported bot-token audience and validation flow.
Use distinct managed identities for bot authentication, runtime data access and platform image pull where operationally feasible. The separation reduces permission reuse, but bot and runtime identities exposed to one container remain within that container's compromise boundary. CI/CD uses workload-identity federation or a tightly controlled runner identity; no long-lived client secret is needed. If an integration cannot avoid a credential, use a short lifetime, Key Vault storage, a documented owner and tested rotation.
Sources: Managed identity concepts; Identity availability in Container Apps; Key Vault data-plane roles; ACR repository ABAC.
03Independent, hardened agent containersRepository layout, reproducible builds and the limits of managed hosting
Recommended hardening One agent per image and per Container App. Each contains a thin Teams transport and its independent agent harness. Share versioned libraries if useful; keep deployment, memory permissions, tool allow lists and model policy independent.
agents-platform/
├── agents/
│ ├── project-controls/
│ │ ├── src/
│ │ │ ├── transport.ts # Teams + private API wiring
│ │ │ ├── application/ # durable inbox/outbox + worker host
│ │ │ ├── transport/ # activity/context normalization
│ │ │ ├── harness/ # reasoning and orchestration
│ │ │ ├── providers/ # model adapters
│ │ │ ├── tools/ # explicitly authorized tools
│ │ │ └── contracts/ # validated request/result schemas
│ │ ├── tests/ # auth, boundaries, handoff behavior
│ │ ├── package.json
│ │ ├── package-lock.json # checked in; exact dependency graph
│ │ ├── tsconfig.json
│ │ ├── .dockerignore
│ │ └── Dockerfile
│ └── estimating/ # same layout; independent image
├── teams/ # one manifest/package per bot
├── infra/
│ ├── network.bicep # VNet, gateway, WAF, firewall, DNS
│ ├── identities.bicep # MI + scoped role assignments
│ ├── agent.bicep # reusable Container App module
│ └── environments/ # non-secret parameters only
├── policy/ # tool, handoff and egress policies
├── ci/ # scan, verify, release and rollback
└── .github/workflows/release.yml
Multi-stage Dockerfile
This template uses a Debian-compatible build/runtime pair. Supply reviewed digest-pinned image references: for example node:24-trixie-slim@sha256:… and gcr.io/distroless/nodejs24-debian13:nonroot@sha256:…. Digests are required release inputs, not invented values. A compatible Alpine runtime is an option only after testing musl/native dependencies.
# Dockerfile; build context is one agent directory.
ARG NODE_BUILD_IMAGE
ARG NODE_RUNTIME_IMAGE
FROM ${NODE_BUILD_IMAGE} AS build
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN npm prune --omit=dev --ignore-scripts
RUN chmod -R a-w /build/dist /build/node_modules /build/package.json
FROM ${NODE_RUNTIME_IMAGE} AS runtime
WORKDIR /app
ENV NODE_ENV=production PORT=8080
COPY --from=build --chown=0:0 /build/package.json ./
COPY --from=build --chown=0:0 /build/node_modules ./node_modules
COPY --from=build --chown=0:0 /build/dist ./dist
USER 65532:65532
EXPOSE 8080
# Distroless Node supplies its Node entrypoint; no shell needed.
CMD ["dist/transport.js"]
Keep application files owned by root and readable by the non-root process, with no application write permission. Avoid broad chmod changes. Review dependencies that require install scripts and explicitly accommodate only trusted necessary steps; do not silently disable a needed native build. Ensure the compiled output path matches the TypeScript build. Distroless removes a shell/package manager, but does not replace dependency scanning or access control.
# .dockerignore
.git
node_modules
dist
coverage
.env
.env.*
*.pem
*.key
*.pfx
*.p12
npm-debug.log
secrets/
test-results/
Never COPY credentials, certificates, tokens, local Azure state or configuration containing secrets into any build stage. A later deletion does not remove them from earlier layers. Use BuildKit secret mounts for a necessary private package-feed credential; do not use build arguments or ordinary environment variables for it. The same signed image moves through environments, with configuration supplied at deployment.
Sources: Docker build guidance; Current distroless images and entrypoints.
Runtime enforcement: what the platform actually supports
Azure Container Apps limitation: the reviewed Microsoft.App/containerApps@2026-07-01 schema does not expose Kubernetes securityContext, readOnlyRootFilesystem, capability-drop or allowPrivilegeEscalation fields. Do not paste these into ACA YAML and report them as enforced. Set the non-root user in the image, use minimal dependencies and file permissions, and validate the resulting runtime. If explicit read-only rootfs, dropped capabilities and no-new-privileges are mandatory policy gates, choose AKS or another platform that exposes and enforces those controls.
The following command demonstrates enforcement on Docker for development/security verification; these flags are not an ACA deployment configuration.
docker run --rm --name controls-agent \
--user 65532:65532 \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
--pids-limit=128 --cpus=1 --memory=2g \
-p 127.0.0.1:8080:8080 \
"$APP_IMAGE_DIGEST"
For AKS, apply runAsNonRoot: true, a fixed UID/GID, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, capabilities.drop: [ALL] and seccompProfile.type: RuntimeDefault, plus resource limits, network policy and admission policy. Mount only a bounded scratch volume if needed. Model-generated code should never execute in the agent's privileged identity context; use a separate sandbox with a separate identity and network policy.
Serve /health/live for process health, /health/ready for accepting work and /health/startup while initialization completes. Liveness should not fail just because a model provider is briefly unavailable. Keep health responses minimal. Drain in-flight work on SIGTERM, stop claiming new jobs, persist task state and allow the grace period to complete.
Sources: Reviewed ACA schema; Docker runtime controls; Kubernetes security context; ACA probes.
04One public edge, controlled private pathsGateway, private DNS, subnets, egress and exact request flows
Azure-specific Use an internal workload-profiles environment. Gateway-facing apps use ingress.external: true: this permits traffic from outside the Container Apps environment, while the internal environment still exposes its endpoint only privately. external: false restricts ingress to the environment and prevents an Application Gateway elsewhere in the VNet from reaching it.
Recommended subnet plan
The following nonoverlapping addresses are illustrative. Confirm peering, on-premises routes, service CIDRs and capacity before creating the environment.
| Subnet | Example CIDR | Configuration |
|---|---|---|
AppGatewaySubnet | 10.40.0.0/24 | Dedicated to Application Gateway. Preserve required infrastructure and load-balancer connectivity. |
ContainerAppsSubnet | 10.40.2.0/23 | Dedicated; delegate to Microsoft.App/environments. Route internet egress through Azure Firewall. /23 provides headroom; workload-profiles environments have a /27 minimum, not /23. |
PrivateEndpointsSubnet | 10.40.4.0/24 | Key Vault, Azure OpenAI, ACR, data and Service Bus private endpoints where the selected tiers support them. |
AzureFirewallSubnet | 10.40.5.0/26 | Reserved required name; firewall instances and managed connectivity. |
ReleaseRunnerSubnet | 10.40.6.0/24 | Optional ephemeral CI runners with private registry and deployment reachability; separate from application subnets. |
Sources: Ingress scope; Subnet delegation and capacity; Application Gateway infrastructure rules.
Gateway implementation
- Resolve the public name to the gateway. Point a dedicated bot hostname at the gateway's public frontend. Install a trusted certificate matching that name; store its certificate secret in Key Vault and grant the gateway identity read access. Use a current TLS policy, renew certificates automatically and alert well before expiry. With a versionless Key Vault reference, Application Gateway checks for renewed certificates at four-hour intervals; test renewal before the old certificate expires.
- Resolve private backends correctly. Link a private DNS zone matching the environment's
defaultDomainto the gateway/workload VNet. Set wildcard and apex A records to the internal environment'sstaticIp. If using a custom DNS resolver, forward the relevant zones and test resolution from the gateway's network. - Route only approved messaging paths. Create a backend pool per agent using its Container App FQDN. Match the public agent path and rewrite it to
/api/messages, or use separate hostnames with that unchanged path. Unknown hosts/paths go to a deny response, never to a permissive default app. - Re-encrypt the backend connection. Use HTTPS port 443 to ACA ingress with the backend FQDN as matching Host/SNI, certificate-name validation enabled, and trusted certificate validation. This sample then uses HTTP port 8080 on the platform-managed ingress-to-container hop; it does not claim application-process TLS. Validate another supported design if policy requires TLS on that final hop.
- Probe privately. Configure an HTTPS custom gateway probe to
/health/ready, with the backend host and a narrow success status. Do not put health routes in the public route allow list. Runtime liveness and readiness probes are separate from the gateway probe. - Enable WAF prevention after tuning. Use managed rules plus block rules for unknown hosts, paths, disallowed methods and excessive bodies. For an endpoint with only messaging, use separate Block rules for method not POST and URI not the approved messaging path; combining both with AND would leave gaps. Match the public path before rewrites. Apply narrow documented exclusions for legitimate Teams payloads. Avoid a broad custom Allow rule that bypasses subsequent inspection.
- Retain application authentication. The WAF cannot validate a Teams activity's identity or authorize an enterprise tool. The SDK validates the signed activity; the application validates the tenant and user/context before dispatch. Do not rely on a static list of Teams source IPs. Preserve Authorization and required headers; trust proxy headers only from the intended proxy chain.
Use NSGs to bound allowed subnet flows while preserving documented ACA and gateway platform dependencies. For internal workload-profiles HTTPS ingress, Microsoft's current NSG guidance includes TCP 443 and 31443 from the permitted client/gateway source, plus the documented Azure Load Balancer probe ports. These are internal infrastructure rules, not additional internet listeners. Preserve required intra-environment and DNS traffic. Do not apply the ACA default route blindly to the gateway subnet: gateway control-plane routing has its own requirements. Where supported, add ACA ingress IP restrictions for the gateway subnet and only explicitly needed private callers. Separate app-level endpoint authorization still applies inside the environment.
Sources: Private backend and DNS pattern; Key Vault certificates; ACA network security dependencies; Bot connectivity and inbound authentication.
Outbound allow-list policy
Associate a route table with the ACA subnet: 0.0.0.0/0 → Virtual appliance → firewall private IP. Use Azure Firewall DNS proxy or a consistent resolver design, deny unmatched internet traffic, log denied attempts and validate from a deployed test revision. A default route does not automatically force private endpoint or same-VNet traffic through the firewall; more-specific routes take precedence. A shared environment's firewall rules are not inherently per-agent policy.
| Destination | Policy starting point | Important boundary |
|---|---|---|
| Microsoft identity + ACA platform | HTTPS to the documented identity endpoints, including login.microsoftonline.com and required identity subdomains; ACA artifact/system destinations including mcr.microsoft.com, *.data.mcr.microsoft.com, packages.aks.azure.com and acs-mirror.azureedge.net. | Import the complete current region/cloud-specific Microsoft dependencies. Test both deployment and steady state. Application-only lists cannot replace platform requirements. |
| Teams channel replies / metadata | Approved Bot service and Teams service URL hosts, such as *.botframework.com and the validated Teams service's smba.trafficmanager.net family; required identity/discovery endpoints. | Derive the final list from current channel documentation and validated service URLs. The hostname “botframework” does not require using the retired Bot Framework SDK. Keep Direct Line/Web Chat channels disabled when unused. Do not forward credentials to arbitrary URLs from user content. |
| Azure OpenAI | Private endpoint for the selected resource, with private DNS and public network access disabled. | Use its original service hostname resolving privately. Inference RBAC remains necessary. |
| OpenAI API | api.openai.com:443 only when that provider is enabled. | Public provider traffic passes the firewall. Data residency and retention follow the selected provider/account contract. |
| Anthropic Claude | api.anthropic.com:443 only when direct Claude API use is enabled. | A Claude deployment through another cloud has different authentication and destinations. |
| GLM or another provider | The exact hostname of the approved provider endpoint in deployment configuration. | GLM is a model family, not one universal API hostname. Confirm vendor, region and endpoint before allowing egress. |
| ACR, Key Vault, Service Bus, state | Prefer private endpoints and corresponding private DNS. Permit required authentication and platform flows separately. | Do not replace a private-endpoint design with unrestricted *.azure.com or * rules. |
| Telemetry and enterprise APIs | Named regional ingestion endpoints, approved MCP servers, enterprise API hosts and ports. | Disable unused exporters. Do not log provider credentials, bearer tokens or full confidential prompts. |
| Package feeds / supply-chain services | Build-runner-only package registry, vulnerability database and signing/provenance endpoints. | Agents should not install packages at runtime. Build access belongs in a different policy from production agent egress. |
Sources: UDR setup; Required ACA egress destinations; Microsoft service-host and outbound-validation guidance; Private Azure OpenAI networking.
Network flow table
| From → to | Port / route | Authentication and control |
|---|---|---|
| Microsoft channel → gateway | HTTPS 443; approved messaging URL | WAF, request limits; signed activity validated by the target SDK. |
| Gateway → agent ingress | Private HTTPS 443; agent FQDN | Validated backend TLS, correct SNI/Host, private routing and inbound restrictions. |
| ACA ingress → container | Managed internal HTTP 8080 | Application activity validation; no public container port exposure. |
| Agent → agent | Private HTTPS API / A2A route | Entra API token, caller role and agent policy; no gateway public route. |
| Agent → Service Bus | Private AMQP/TLS 5671, or HTTPS/WebSockets 443 | Managed identity with scoped sender/receiver role; use one deliberate transport. |
| Agent → vault / Azure OpenAI / state | Private service endpoints, normally HTTPS 443 | Managed identity and data-plane permissions. |
| Agent → external provider / tool | HTTPS 443 via firewall | Allow-listed destination, Key Vault credential or supported federation, tool authorization. |
| Agent → Teams response service | Outbound HTTPS via firewall | SDK bot token and validated service endpoint; response is not a new public inbound port. |
| Release runner → ACR / ARM | Private registry HTTPS; approved management HTTPS | Short-lived deployment identity; image signature/provenance verification. |
05Secrets, private endpoints & rotationProvider independence without credential sprawl
Azure-specific Use Key Vault RBAC, a private endpoint and private DNS. Recommended hardening Separate production vaults and agent permissions, enable soft-delete/purge protection, audit access and disable public network access after private access is tested.
Store OpenAI, Anthropic, GLM and third-party SaaS credentials, signing secrets and other sensitive configuration as separately owned secrets. Use managed identity directly for supported Azure services instead of creating an API key merely to store it. Build pipelines normally need secret URIs, not secret values; never print secrets or include them in deployment parameters, tracing, Docker history or image labels.
Two supported consumption patterns
| Pattern | How it works | Rotation behavior |
|---|---|---|
| Container Apps Key Vault reference | Set configuration.secrets[].keyVaultUrl and its managed identity, then reference the ACA secret in the container environment. | A versionless URI picks up a new Key Vault version within 30 minutes; active revisions that consume it as an environment variable restart. A versioned URI is explicit and requires an intentional update. |
| Runtime Azure SDK retrieval | The harness uses an explicit managed identity with SecretClient to fetch its allowed secret into memory. | The application owns refresh cadence, caching, client replacement, retries and coordinated rotation. Creating a new Key Vault version does not magically refresh an already-created provider client. |
import { ManagedIdentityCredential } from '@azure/identity';
import { SecretClient } from '@azure/keyvault-secrets';
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing configuration: ${name}`);
return value;
}
// Explicit production credential: do not fall back to a developer login.
const credential = new ManagedIdentityCredential({
clientId: required('WORKLOAD_MI_CLIENT_ID'),
});
const vault = new SecretClient(required('KEY_VAULT_URL'), credential);
export async function loadProviderKey(): Promise<string> {
// The name is approved configuration, never user/model supplied.
const name = required('MODEL_API_KEY_SECRET_NAME');
const version = process.env.MODEL_API_KEY_SECRET_VERSION;
const secret = await vault.getSecret(name, version ? { version } : {});
if (!secret.value || secret.properties.enabled === false) {
throw new Error('Model credential unavailable');
}
return secret.value; // Keep in memory; never log or persist.
}
// At startup: construct provider client with await loadProviderKey().
// On controlled refresh: load new key, validate, atomically swap client.
// Bound retries/deadlines and keep readiness false until initialized.
Private DNS for Key Vault normally uses privatelink.vaultcore.azure.net. Azure OpenAI uses its documented private DNS zone, commonly privatelink.openai.azure.com for the OpenAI resource endpoint. ACR needs the registry and regional data endpoint records; Service Bus uses its namespace's private zone. Use service-provided DNS configuration rather than hardcoding IPs in the application. Link zones to every VNet needing access, including the release runner, and validate from inside the runtime network.
A private endpoint alone does not disable the public endpoint. Explicitly disable public network access where supported. For Azure OpenAI, create/approve the resource private endpoint, configure name resolution and disable public access; use a managed identity with the inference role. Keep Microsoft identity/authentication reachability available as required.
A rotation runbook that can be tested
- Create the new credential at the actual model/SaaS provider; Key Vault stores credentials but does not independently rotate a vendor API key.
- Add a new secret version, recording owner, purpose and expiry. Preserve a short overlap window where the provider supports simultaneous active credentials.
- Refresh the runtime client or permit/restart the ACA reference consumers. Test a low-risk real request from each active revision without recording the credential.
- Verify successful calls and secret-version telemetry, then revoke the old provider credential. Retain the audit record, not the plaintext value.
- Test an emergency revocation and a failed-rotation recovery. A rollback to an old image will not restore a revoked external credential.
Sources: Key Vault RBAC; Key Vault private access and DNS; ACA secret references and refresh; Azure OpenAI private endpoints; SecretClient API.
06Registry, deployment & rollbackSeparate applications, managed image pulls and gradual revision promotion
Azure-specific Create the internal workload-profiles environment, private endpoints, DNS and scoped identities through reviewed infrastructure as code. Deploy each agent independently. Keep administrative registry credentials disabled. Use a private-network-connected release runner when registry public access is disabled.
- Build from a clean source revision with exact package lockfiles and digest-pinned bases. Produce an SBOM and provenance record.
- Scan and test the image. Tag it with an immutable release label such as
controls:1.4.0-abcdef123456; never deploy a mutablelatesttag. - Authenticate the build identity to ACR using its short-lived Azure identity; push the approved image and attach its signature/attestations.
- Record and deploy the content digest
registry.azurecr.io/agents/controls@sha256:…. The image-pull identity needs the correct standard RBAC or ABAC repository role. ACR must accept ARM-audience tokens for managed-identity image pulls. - Create a new ACA revision at zero HTTP ingress traffic in multiple-revision mode, with production job consumption paused. Exercise its private revision endpoint using authenticated, synthetic requests and an isolated test queue/state store. HTTP weights do not prevent its running replicas from polling a queue.
- Promote HTTP ingress through 5% → 25% → 100% health gates. Promote workers separately: an application-owned durable release fence must authorize the exact revision before it can claim production jobs. Drain the previous consumer generation and fence stale workers; a queue split needs explicit queue/subscription routing, not HTTP percentages. Monitor queue age, duplicate side effects, latency and model/tool failures.
- If checks fail, restore the healthy HTTP revision and separately revoke/drain the failed worker generation before authorizing the previous one. Retain compatible task/result schemas and versioned prompts/configuration. Revision rollback does not revert application-scoped ingress, identity, secret, database or external side-effect changes; those need their own recovery plan.
# Release runner already authenticated through federation or managed identity.
# All variables below are non-secret, reviewed release configuration.
az acr login --name "$ACR_NAME"
docker build --pull \
--build-arg NODE_BUILD_IMAGE="$NODE_BUILD_IMAGE" \
--build-arg NODE_RUNTIME_IMAGE="$NODE_RUNTIME_IMAGE" \
--tag "$ACR_LOGIN_SERVER/agents/controls:$RELEASE_VERSION-$GIT_SHA" \
agents/project-controls
trivy image --scanners vuln,secret --severity HIGH,CRITICAL \
--exit-code 1 "$ACR_LOGIN_SERVER/agents/controls:$RELEASE_VERSION-$GIT_SHA"
docker push "$ACR_LOGIN_SERVER/agents/controls:$RELEASE_VERSION-$GIT_SHA"
# Resolve the pushed digest; deploy that digest after signature verification.
Sources: Managed image pulls and ARM audience; Repository permission modes; Revision scope and traffic management.
Example Container App configuration
This Bicep module assumes the environment and role assignments already exist. The example targets the Consumption workload profile inside a workload-profiles environment, not a legacy consumption-only environment. Multiple-revision mode keeps the stable revision at 100% of HTTP traffic while a candidate is tested. Leave stableRevision empty only for the first bootstrap; every later release must supply the currently approved stable revision name. The model value is an approved deployment parameter, not an assertion that a particular model version is universally available.
Worker promotion is a separate application control. WORKER_START_PAUSED=true is configuration for the application-owned worker host, not an ACA security feature. Implement and test a durable authorization fence keyed to the release revision before connecting this skeleton to a production queue. Starting a revision, passing readiness or assigning HTTP traffic must not enable consumption automatically. Keep the same tested revision and authorize it through that controlled release record; changing environment variables would create another revision.
param location string
param environmentId string
param agentName string = 'project-controls'
param imageDigest string // Full registry/repository@sha256:... reference
param registryServer string
param botIdentityId string
param botClientId string
param runtimeIdentityId string
param runtimeClientId string
param pullIdentityId string
param secretReferenceIdentityId string
param tenantId string
param provider string = 'anthropic'
param modelName string
param modelSecretUri string // Key Vault URI; never the secret value
param revisionSuffix string // Unique release suffix, e.g. r-abcdef123456
param stableRevision string = '' // Empty ONLY on first bootstrap.
resource agent 'Microsoft.App/containerApps@2026-07-01' = {
name: agentName
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${botIdentityId}': {}
'${runtimeIdentityId}': {}
'${pullIdentityId}': {}
'${secretReferenceIdentityId}': {}
}
}
properties: {
environmentId: environmentId
workloadProfileName: 'Consumption'
configuration: {
activeRevisionsMode: 'Multiple'
identitySettings: [
{
identity: botIdentityId
lifecycle: 'Main'
}
{
identity: runtimeIdentityId
lifecycle: 'Main'
}
{
identity: pullIdentityId
lifecycle: 'None'
}
{
identity: secretReferenceIdentityId
lifecycle: 'None'
}
]
ingress: {
external: true // Private because ENVIRONMENT is internal.
targetPort: 8080
transport: 'auto'
allowInsecure: false
traffic: empty(stableRevision) ? [
{
latestRevision: true
weight: 100
}
] : [
{
revisionName: stableRevision
weight: 100
}
]
}
registries: [
{
server: registryServer
identity: pullIdentityId
}
]
secrets: [
{
name: 'model-api-key'
keyVaultUrl: modelSecretUri
identity: secretReferenceIdentityId
}
]
}
template: {
revisionSuffix: revisionSuffix
containers: [
{
name: 'agent'
image: imageDigest
resources: {
cpu: 1
memory: '2Gi'
}
env: [
{
name: 'PORT'
value: '8080'
}
{
name: 'AGENT_KEY'
value: agentName
}
{
name: 'DEPLOYMENT_REVISION'
value: '${agentName}--${revisionSuffix}'
}
{
name: 'WORKER_START_PAUSED'
value: 'true'
}
{
name: 'CLIENT_ID'
value: botClientId
}
{
name: 'TENANT_ID'
value: tenantId
}
{
name: 'WORKLOAD_MI_CLIENT_ID'
value: runtimeClientId
}
{
name: 'MODEL_PROVIDER'
value: provider
}
{
name: 'MODEL_NAME'
value: modelName
}
{
name: 'MODEL_API_KEY'
secretRef: 'model-api-key'
}
]
probes: [
{
type: 'Startup'
httpGet: {
path: '/health/startup'
port: 8080
}
periodSeconds: 5
failureThreshold: 10
}
{
type: 'Liveness'
httpGet: {
path: '/health/live'
port: 8080
}
periodSeconds: 15
timeoutSeconds: 2
failureThreshold: 3
}
{
type: 'Readiness'
httpGet: {
path: '/health/ready'
port: 8080
}
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
}
]
}
]
scale: {
minReplicas: 2
maxReplicas: 10
rules: [
{
name: 'http-concurrency'
http: {
metadata: {
concurrentRequests: '20'
}
}
}
]
}
terminationGracePeriodSeconds: 60
}
}
}
These are starting limits, not measured capacity. Scaling limits apply to each active revision; overlapping stable and candidate revisions can exceed ten replicas in total. Load-test replica count, concurrency, provider quotas and backpressure together, and enforce shared downstream budgets. Keep conversation/job state outside the container so autoscaling and rollbacks do not lose it. Two replicas reduce single-instance interruption; zone redundancy requires explicit environment/profile/region configuration and is not implied by this module.
Background scaling is a separate choice. This module shows HTTP scaling. Microsoft's current guidance calls for activeRevisionsMode: 'Single' with non-HTTP event scale rules. If queue depth must drive capacity, a separately deployed worker Container App can use a managed-identity Service Bus scale rule and Single mode. That requires its own release, drain and recovery plan. Do not simply add a queue rule to this Multiple-mode HTTP canary example; HTTP weights still do not control production job consumption, and single-revision startup overlap still requires safe claims and idempotent side effects.
Only the platform needs the image-pull and secret-reference identities, hence lifecycle None. If the application fetches Key Vault directly instead, give its runtime identity the required data permission and keep that identity available in Main. This module demonstrates platform secret injection: pass MODEL_API_KEY to the provider factory in the harness. To use the direct-retrieval TypeScript example instead, remove that injection/reference and configure KEY_VAULT_URL plus MODEL_API_KEY_SECRET_NAME, with Key Vault access on the workload identity. Choose one consumption pattern. For an Azure OpenAI-only deployment, remove the provider API-key reference and use the Azure token credential flow.
Sources: ACA 2026-07-01 resource schema; Managed identity lifecycle; Scaling rules; Probe behavior.
07The Teams transportCurrent SDK request handling, durable acceptance, and private health endpoints.
Required by Teams Recommended hardening
Use the current Teams SDK as a thin authenticated transport. The current TypeScript entry point is @microsoft/teams.apps: new App(), app.on('message', ...), and app.start(port). For an application-owned Express HTTP server, supply ExpressAdapter and call initialize(). The authenticated default messaging route is POST /api/messages.
The public HTTPS gateway is reachable by the Teams/Bot service; the Container App remains private behind it. Route only approved messaging and explicitly required OAuth callback/API paths through the gateway. Keep health, peer APIs, management, and debug routes private. A browser-login redirect in front of the messaging route breaks service-to-service delivery; the Teams SDK validates incoming service tokens there.
Current SDK, with deprecated patterns called out
The Teams SDK itself is current. Its @microsoft/teams.ai, teams.openai, teams.mcp, teams.mcpclient, and teams.a2a wrapper packages are marked deprecated in the current repository in favor of direct framework/protocol libraries. Avoid old @microsoft/teams-ai patterns and new code based on BotFrameworkAdapter or CloudAdapter from botbuilder. The optional BotBuilder migration plugin is not needed for a clean implementation.
Microsoft’s published API reference identified stable version 2.0.16 when reviewed. The sample below uses the matching tagged App/HTTP API. Pin exact production versions in package-lock.json and recheck release notes before implementation; a repository’s main branch may include newer or preview surfaces.
Current package status · Published SDK reference · Legacy SDK guidance
Express-compatible TypeScript server
Reference skeleton, not a complete runnable service. ingress and worker below are application-owned modules, not Microsoft SDK methods. Implement their durable transaction, schema validation, inbox/outbox, readiness, locking, and shutdown behavior. The worker runs the independent agent harness in this same container. Splitting it into separately scaled workers is an optional later deployment change.
// transport.ts — ESM TypeScript; same container also runs the harness.
import http from 'node:http';
import { createHash } from 'node:crypto';
import express from 'express';
import { App, ExpressAdapter } from '@microsoft/teams.apps';
import { ingress, worker } from './application/host.js'; // Implement these.
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
const agentKey = required('AGENT_KEY');
const tenantId = required('TENANT_ID');
const deploymentRevision = required('DEPLOYMENT_REVISION');
if (required('WORKER_START_PAUSED') !== 'true') {
throw new Error('Worker must start paused until its release fence authorizes it');
}
const web = express();
web.disable('x-powered-by');
const server = http.createServer(web);
let initialized = false;
let draining = false;
// Minimal private probe responses; never disclose configuration or secrets.
web.get('/health/startup', (_req, res) => {
res.sendStatus(initialized ? 200 : 503);
});
web.get('/health/ready', (_req, res) => {
res.sendStatus(initialized && !draining && worker.ready() ? 200 : 503);
});
web.get('/health/live', (_req, res) => {
res.sendStatus(200); // Do not make a model call in the liveness probe.
});
const teams = new App({
clientId: required('CLIENT_ID'),
tenantId,
// No clientSecret. Direct UAMI, or configured UAMI federation.
// Reuse this Express app; the Node HTTP server lifecycle stays external.
httpServerAdapter: new ExpressAdapter(web),
dangerouslyAllowUnauthenticatedRequests: false,
activity: { mentions: { stripText: true } },
});
teams.on('message', async ({ activity }) => {
const incomingTenant = activity.channelData?.tenant?.id
?? activity.conversation.tenantId;
if (incomingTenant !== tenantId) throw new Error('Tenant not allowed');
if (!activity.id || !activity.from.aadObjectId) {
throw new Error('Required activity identity is missing');
}
const key = createHash('sha256').update(JSON.stringify([
agentKey, incomingTenant, activity.conversation.id, activity.id,
])).digest('hex');
// Reached only after Teams SDK authentication. The application then
// checks user/tool authorization and enforces payload limits.
await ingress.accept({
key,
agentKey,
tenantId: incomingTenant,
userAadObjectId: activity.from.aadObjectId,
text: activity.text ?? '',
trustedRoute: {
serviceUrl: activity.serviceUrl,
conversation: activity.conversation,
activityId: activity.id,
replyToId: activity.replyToId,
recipient: activity.recipient,
},
});
// Return after durable acceptance; never launch unawaited model work.
});
await teams.initialize();
// Application-owned contract: initialize healthy, but do not claim jobs yet.
await worker.start({ startPaused: true, revision: deploymentRevision });
initialized = true;
server.listen(Number(process.env.PORT ?? '8080'), '0.0.0.0');
process.once('SIGTERM', () => {
draining = true;
const deadline = setTimeout(() => process.exit(1), 50_000);
const httpClosed = new Promise<void>((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
});
void Promise.all([httpClosed, worker.drain({ timeoutMs: 45_000 })])
.then(() => { clearTimeout(deadline); process.exit(0); })
.catch(() => {
clearTimeout(deadline);
console.error('Graceful shutdown failed'); // No payloads/secrets.
process.exit(1);
});
});
Tagged App source · Tagged HTTP implementation · Express adapter and server ownership
Worker release control is an unimplemented application contract in this skeleton. worker.ready() means initialization and dependencies are healthy, including while consumption is paused, so a candidate can pass private smoke tests. Starting a revision never grants permission to consume production jobs. Implement a durable, server-owned release fence that authorizes the exact DEPLOYMENT_REVISION; check it when claiming work and enforce its lease/fencing token at state commits and consequential tool calls. Promotion grants that authority; rollback revokes it and drains the old consumer. HTTP traffic percentages do not distribute queue work. Inbox/outbox, durable acceptance, deduplication and these fencing guarantees require implementation and failure tests before production.
Configure a redacting SDK logger; test debug and error output with sensitive sample data. The tagged SDK passes activity bodies to debug logging and Axios response data to its default error logger: HTTP handler · App error handler.
Transport responsibilities
The transport validates activities, records tenant/user/conversation context, handles Teams events and cards, and passes work to the harness. The harness owns prompts, planning, memory, provider calls, tools, and delegation. Install/remove events, Adaptive Card invokes, user authentication, and conversation updates need explicit handlers; the example only covers messages.
Set request-body and header limits at the gateway and supported SDK/server configuration, with tested limits for legitimate Teams cards. Apply per-tenant/user/agent rate limits after authentication. Do not blindly insert a body parser or forwarding-header trust setting that changes the SDK’s authenticated request path. Store conversation routes server-side and restrict accepted service URLs to verified Teams contexts; models and peers must never select arbitrary callback destinations.
For a bounded immediate reply, the current handler exposes reply. Long model/tool execution belongs in durable work; stream only using the selected SDK’s supported Teams streaming mechanisms and test cancellation, updates, attribution, and proxy timeouts.
// Bounded immediate work only. answer() is an application-owned service.
teams.on('message', async ({ activity, reply }) => {
await reply(await answer(activity.text));
});
For delayed delivery, the original bot’s publisher loads its own saved service URL and conversation route. In the reviewed SDK, an App configured with that trusted serviceUrl can send(conversationId, text) or reply(conversationId, threadRootId, text). Reuse appropriately scoped publishers. Derive the thread root from the captured Teams context; a group chat is not a channel thread. Record delivery separately from task completion.
Current message/reply example · Proactive-message context requirements
08An independent agent runtimeProvider adapters, controlled tools, and configuration-led model changes.
Optional preference Recommended hardening
The agent harness does not need Microsoft Copilot. Use Microsoft Agent Framework, LangChain/LangGraph, a custom loop, direct OpenAI or Anthropic SDKs, or another evaluated runtime. Teams remains the platform interface. The selected runtime must pass the same authorization, isolation, reliability, and evaluation gates.
| Layer | Owns | Does not confer |
|---|---|---|
| Teams transport | Activities, cards, events, verified user context, reply routing | Permission to use every enterprise tool |
| Agent harness | Reasoning loop, prompt versions, model calls, tool policy, memory, context and orchestration | Authority merely because a model requests an action |
| Model provider | Inference, supported modalities, tool and output semantics | Teams installation or Azure authorization |
| Tool layer | MCP servers, enterprise APIs, databases, search and project systems | Broader rights than the authenticated user/agent policy |
| Peer layer | A2A or an explicit versioned task/result contract | Another bot’s conversation or document access |
Each container packages transport and harness together, but the source boundary remains clear. Runtime state lives in durable, tenant/agent-partitioned storage. A global in-memory conversation map loses history on restart and leaks boundaries in a multi-replica design. Do not rely on prompt text to enforce tool restrictions.
A small provider contract
The following adapters deliberately support text completion only. They illustrate a real provider boundary without claiming equivalent tool calling or streaming behavior. Set explicit timeouts and retry budgets; the examples disable SDK retries so the harness owns the retry decision.
// providers/text-model.ts
export type CompletionInput = { instructions: string; text: string };
export type Completion = { text: string; providerRequestId?: string };
export interface TextModel {
complete(input: CompletionInput, signal: AbortSignal): Promise<Completion>;
}// providers/openai.ts
import OpenAI from 'openai';
import type { CompletionInput, TextModel } from './text-model.js';
export class OpenAITextModel implements TextModel {
constructor(private client: OpenAI, private model: string) {}
async complete(input: CompletionInput, signal: AbortSignal) {
const result = await this.client.responses.create({
model: this.model,
instructions: input.instructions,
input: input.text,
max_output_tokens: 1500,
store: false,
}, { signal });
if (result.status !== 'completed') throw new Error('Incomplete model result');
return { text: result.output_text, providerRequestId: result._request_id };
}
}// providers/anthropic.ts
import Anthropic from '@anthropic-ai/sdk';
import type { CompletionInput, TextModel } from './text-model.js';
export class AnthropicTextModel implements TextModel {
constructor(private client: Anthropic, private model: string) {}
async complete(input: CompletionInput, signal: AbortSignal) {
const result = await this.client.messages.create({
model: this.model,
system: input.instructions,
max_tokens: 1500,
messages: [{ role: 'user', content: input.text }],
}, { signal });
if (result.stop_reason !== 'end_turn') throw new Error('Incomplete model result');
return {
text: result.content.flatMap(
block => block.type === 'text' ? [block.text] : [],
).join('\n'),
};
}
}
Official OpenAI Node SDK · Official Anthropic TypeScript SDK · Anthropic message/stop semantics
Configuration changes the provider
# Deploy one of these reviewed configurations; no secret values here.
MODEL_PROVIDER=anthropic
MODEL_NAME=<approved-claude-model>
MODEL_API_KEY_SECRET_NAME=anthropic-agent-key
# Or:
MODEL_PROVIDER=openai
MODEL_NAME=<approved-openai-model>
MODEL_API_KEY_SECRET_NAME=openai-agent-key// providers/factory.ts — loaded only by the harness, never transport.ts.
import OpenAI from 'openai';
import Anthropic from '@anthropic-ai/sdk';
import { OpenAITextModel } from './openai.js';
import { AnthropicTextModel } from './anthropic.js';
import { GLMTextModel } from './glm.js';
import type { TextModel } from './text-model.js';
export function createModel(config: {
provider: 'openai' | 'anthropic' | 'glm'; model: string; apiKey: string;
}): TextModel {
switch (config.provider) {
case 'openai':
return new OpenAITextModel(new OpenAI({
apiKey: config.apiKey, maxRetries: 0, timeout: 60_000,
}), config.model);
case 'anthropic':
return new AnthropicTextModel(new Anthropic({
apiKey: config.apiKey, maxRetries: 0, timeout: 60_000,
}), config.model);
case 'glm':
return new GLMTextModel(new OpenAI({
apiKey: config.apiKey,
baseURL: 'https://api.z.ai/api/paas/v4/',
maxRetries: 0, timeout: 60_000,
}), config.model);
}
}
Validate provider/model choices against a deployment allowlist before the factory runs. A provider switch also needs regression evaluations and policy review. Tool schemas and continuation records, streaming deltas, structured-output guarantees, authentication, tokenization, context limits, error classes, and usage reporting can require provider-specific adapters. Preserve native tool/reasoning continuation data inside each adapter; flattening it to text can break follow-up calls.
GLM: compatible endpoint, explicit adapter
The standard Z.AI endpoint supports an OpenAI Chat Completions-compatible client; do not send Responses requests to it without documented support. The coding-plan endpoint and entitlements are separate. Select an approved model and plan for this deployment; the endpoint, credential reference and secret name come from trusted deployment configuration, never from a model or user prompt.
// providers/glm.ts — standard Z.AI API, not the coding-plan endpoint.
import OpenAI from 'openai';
import type { CompletionInput, TextModel } from './text-model.js';
export class GLMTextModel implements TextModel {
constructor(private client: OpenAI, private model: string) {}
async complete(input: CompletionInput, signal: AbortSignal) {
const result = await this.client.chat.completions.create({
model: this.model,
messages: [
{ role: 'system', content: input.instructions },
{ role: 'user', content: input.text },
],
max_tokens: 1500,
}, { signal });
const choice = result.choices[0];
if (!choice || choice.finish_reason !== 'stop') {
throw new Error('Incomplete model result');
}
return { text: choice.message.content ?? '' };
}
}
Azure OpenAI needs an adapter not included in this factory. Use its private endpoint and an explicitly selected managed identity with Cognitive Services OpenAI User access; configure the token audience and client for the chosen API version. For any additional vendor, add a reviewed adapter and verify its actual endpoint, authentication, feature support, and data handling. Similar HTTP shapes are not proof of compatibility.
Runtime Key Vault integration
This direct-retrieval alternative uses a workload UAMI with Key Vault Secrets User access to this agent’s approved secret scope. The vault and DNS should be private. The identity client ID and vault URL are configuration, not secrets. Never log the returned value. Azure-hosted production should fail closed rather than silently fall back to a developer credential.
import { ManagedIdentityCredential } from '@azure/identity';
import { SecretClient } from '@azure/keyvault-secrets';
import { createModel } from './providers/factory.js';
const credential = new ManagedIdentityCredential({
clientId: required('WORKLOAD_MI_CLIENT_ID'),
});
const vault = new SecretClient(required('KEY_VAULT_URL'), credential);
const provider = required('MODEL_PROVIDER');
if (provider !== 'openai' && provider !== 'anthropic' && provider !== 'glm') {
throw new Error('Provider is not enabled for this deployment');
}
const secret = await vault.getSecret(required('MODEL_API_KEY_SECRET_NAME'));
if (!secret.value) throw new Error('Provider credential unavailable');
const model = createModel({
provider,
model: required('MODEL_NAME'),
apiKey: secret.value,
});
// Bound each harness request; do not log credential/configuration objects.
const result = await model.complete({
instructions: 'Return a source-grounded draft for human review.',
text: validatedUserText,
}, AbortSignal.timeout(25_000));
required() is the helper shown in the transport example; validatedUserText is an application-owned validated input. Direct retrieval needs a bounded cache/refresh and rotation strategy. Container Apps Key Vault-backed secrets are an alternative: a separate reference identity can fetch the secret for injection while remaining unavailable to runtime code. An injected secret is still available to that container, and changing a vault version does not itself rotate the vendor’s credential.
ManagedIdentityCredential · Key Vault SecretClient · Container Apps Key Vault references · Azure OpenAI managed identity
09Agent-to-agent contractsAuthenticated discovery, explicit authority, and durable task boundaries.
Optional preference Recommended hardening
Use explicit backend messages for collaboration. An agent should never coordinate by watching another bot’s Teams posts. A2A is useful for interoperable capability discovery and task lifecycle; an internal versioned API or Service Bus contract can be simpler when all agents share an operating team.
Start with an application-owned handoff contract
This is a domain contract, not a standard A2A schema. Enforce it with a versioned runtime JSON Schema, not TypeScript types alone. Bound every string/array, reject unknown capabilities, and validate deadline, tenant, source, target, and disclosure policy.
export type AgentRequest = {
schemaVersion: '1';
requestId: string; // Stable across retries.
correlationId: string; // End-to-end work identifier.
causationId?: string;
sessionRef: string; // Opaque, agent-scoped durable session record.
fromAgent: string; // Descriptive only; token identifies actual caller.
toAgent: string;
capability: string; // E.g. estimate.review; checked against policy.
mode: 'delegate-and-return' | 'visible-transfer';
subject: { tenantId: string; aadObjectId: string };
input: {
task: string;
summary: string; // Untrusted context, never a system instruction.
evidence: Array<{ sourceId: string; locator: string }>;
};
routeRef: string; // Server-side route, not an arbitrary callback URL.
authorityRef?: string; // Server-side approval/grant, never a bearer token.
deadline: string; // ISO timestamp; recipient may only shorten it.
maxHops: number;
visitedAgents: string[];
traceparent?: string;
};
export type AgentResult = {
schemaVersion: '1';
requestId: string;
correlationId: string;
state: 'accepted' | 'working' | 'completed' | 'needs-input'
| 'failed' | 'cancelled';
output?: {
text: string;
evidence: Array<{ sourceId: string; locator: string }>;
};
error?: { code: string; retryable: boolean };
delivery?: {
state: 'not-requested' | 'pending' | 'sent' | 'failed';
activityId?: string;
};
};
The recipient rechecks that this peer may invoke the capability for this tenant/subject, that the data may be shared, and that the authority record covers consequential actions. A source locator grants no access by itself. accepted, completed, and delivery.sent mean different things: durable acceptance, finished work, and an acknowledged Teams send.
Authenticate the caller and authorize the task
Give the receiving API an Entra application role such as Agent.Invoke. The caller acquires a token for that API using its own UAMI. The recipient verifies signature against the tenant’s signing keys, issuer, exact audience, expiry/not-before, tenant, required application role, and allowed caller principal. Bind policy to authenticated claims, never to fromAgent. Decoding a JWT is not validation. Keep peer endpoints private and validate TLS; use mTLS additionally if the deployment requires it.
import { ManagedIdentityCredential } from '@azure/identity';
const peerCredential = new ManagedIdentityCredential({
clientId: required('WORKLOAD_MI_CLIENT_ID'),
});
const token = await peerCredential.getToken(
`api://${required('ESTIMATING_API_CLIENT_ID')}/.default`,
);
// Endpoint comes from trusted configuration/discovery, never a model URL.
const response = await fetch(required('ESTIMATING_API_URL'), {
method: 'POST',
headers: {
Authorization: `Bearer ${token.token}`,
'Content-Type': 'application/json',
'Idempotency-Key': request.requestId,
},
body: JSON.stringify(request),
signal: AbortSignal.timeout(10_000),
});
// Application must validate status/schema and persist returned task IDs.
// A timeout requires reconciliation, not blind replay of side effects.
The request value is a validated AgentRequest; a production endpoint must actually implement durable idempotency for the header. An app-only token cannot impersonate the human named in the envelope. Re-evaluate user/document entitlements and approvals on the server.
Token claims validation · Managed-identity app-role assignments
Agent Cards and A2A version discipline
A private, approved registry can distribute capability cards. Cards advertise protocol interfaces, capabilities, and authentication requirements; they do not grant access. Pin trusted origins, cache with expiry, restrict redirects, and protect discovery when metadata is sensitive. Use signed cards where your trust model requires them.
The current A2A JavaScript repository documents A2A 1.0, while Microsoft’s Teams tutorial uses 0.3-era examples. Do not combine their wire formats. In 1.0, interfaces are advertised in supportedInterfaces; select a compatible binding/version. The REST shape differs from generated SDK union types, so use the selected SDK serializer. This minimal card advertises a private HTTP+JSON interface:
{
"name": "Estimating Agent",
"description": "Reviews scoped estimate assumptions for an authorized team.",
"version": "1.0.0",
"supportedInterfaces": [{
"url": "https://estimating.internal.example.com/a2a",
"protocolBinding": "HTTP+JSON",
"protocolVersion": "1.0"
}],
"capabilities": { "streaming": false, "pushNotifications": false },
"defaultInputModes": ["application/json"],
"defaultOutputModes": ["application/json"],
"securitySchemes": {
"entra": {
"openIdConnectSecurityScheme": {
"openIdConnectUrl": "https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration"
}
}
},
"securityRequirements": [{ "schemes": { "entra": { "list": [] } } }],
"skills": [{
"id": "estimate-review",
"name": "Estimate review",
"description": "Returns an attributed review with source references.",
"tags": ["estimating", "review"]
}]
}
Configure the API audience and required Agent.Invoke role in the verifier; the card alone cannot enforce them. Keep task records durable. If the request returns an ongoing task, persist its task ID and recover status/results until terminal state. Outputs can be returned as artifacts. A2A’s client-to-server ROLE_USER is protocol direction, not proof of a delegated human identity.
A2A 1.0 specification · Normative Agent Card schema · Official A2A JavaScript SDK · SDK version migration
Choose synchronous calls or a durable queue deliberately
| Use | Best fit | Design requirement |
|---|---|---|
| Synchronous API/A2A | Short, bounded specialist work with a responsive peer | Deadline, cancellation, circuit breaker, task reconciliation, bounded retries |
| Azure Service Bus | Long work, bursts, offline peers, backpressure and dead-letter operations | Domain contract, durable inbox/outbox, idempotent consumer, explicit result route |
| A2A plus Service Bus | Interoperable agent interface backed by internal durable execution | One request identity across protocol task, queue message, and final delivery |
import { ManagedIdentityCredential } from '@azure/identity';
import { ServiceBusClient } from '@azure/service-bus';
import type { AgentRequest } from './contracts.js';
const credential = new ManagedIdentityCredential({
clientId: required('WORKLOAD_MI_CLIENT_ID'),
});
const bus = new ServiceBusClient(required('SERVICEBUS_NAMESPACE'), credential);
const sender = bus.createSender('specialist-requests');
export async function delegate(request: AgentRequest): Promise<void> {
await sender.sendMessages({
body: request,
contentType: 'application/json',
messageId: request.requestId,
correlationId: request.correlationId,
subject: 'agent.request.v1',
});
}
SERVICEBUS_NAMESPACE is the fully qualified namespace, not a connection string. Grant Data Sender only on the required queue/topic and Data Receiver only on required queues/subscriptions; avoid Data Owner. Use private networking where supported. A scale-rule identity needs its own relevant grants if distinct from the runtime.
Enable duplicate detection and reuse messageId across retries; its window-limited send deduplication is not exactly-once processing. Receive in peek-lock mode, commit durable state/result before completing, renew legitimate long locks, and deliberately dead-letter poison messages. Use sessions or database serialization for conversation ordering. A broker does not authenticate a body’s fromAgent; isolate producer permissions/queues or verify signed envelopes.
Service Bus managed identity and RBAC · Duplicate detection · Delivery and duplicate processing
10Handoff & conversation ownershipHow collaboration appears in Teams, and how retries remain safe.
Required by Teams Recommended hardening
Two different kinds of handoff
| Behavior | Backend action | Teams identity |
|---|---|---|
| Delegate and return | Project Controls calls Estimating privately, receives structured work, and replies to its original user | Project Controls remains the sender; content attributes “Analysis by Estimating Agent” |
| Visible transfer | Estimating receives authorized context and opens/reuses its own valid conversation | Estimating uses its own bot identity and installation, usually its own personal DM |
Another bot cannot simply adopt the first bot’s conversation. A’s bot-scoped user ID is not portable to B. For personal proactive contact, use the tenant and Entra aadObjectId, with B installed in personal scope; otherwise B’s attempt can fail with 403 ForbiddenOperationException. Save B’s own service URL and conversation reference. Test installation removal, blocks, and delivery failures.
In shared chats or channels, every bot needs valid installation/context. Do not assume each bot sees every message. Broader visibility through ChannelMessage.Read.Group or ChatMessage.Read.Chat requires resource-specific consent and is separate from peer authorization. A2A does not bypass either rule. Microsoft’s A2A tutorial explicitly leaves peer authentication out of its sample; add real authentication before production.
Proactive messaging and installation · Teams RSC · Conversation visibility · Teams A2A sample and warning
Request lifecycle: Project Controls → Estimating → user
- Receive. Teams sends an activity through the registered messaging endpoint and gateway. The SDK validates service authentication. Project Controls checks tenant/user policy and stores a durable request plus its trusted Teams route.
- Plan within authority. Its harness loads only that agent’s authorized context, determines the estimate-review capability is needed, and checks disclosure, tool, cost, and human-approval policy.
- Delegate. It sends a minimized AgentRequest to the approved Estimating endpoint or queue, using its own workload identity. The stable request ID, deadline, trace, session reference, and hop budget travel with it.
- Execute independently. Estimating authenticates the caller, validates schema and policy, deduplicates the request, and applies its own model, tool allowlist, memory boundary, and budget. It returns source-linked results or a recoverable task ID.
- Review the result. Project Controls validates the response schema and references, records attribution, and routes uncertain or consequential decisions through the required human gate. Peer output remains untrusted input.
- Deliver and audit. Project Controls publishes in its original Teams context: “Analysis by Estimating Agent.” Its outbox separately records the Teams activity ID/delivery state. Visible transfer instead requires the recipient bot’s own installed conversation and a reviewed disclosure/contact policy.
Bound the conversation, retries, and side effects
Choose policy values from measured SLOs. An illustrative starting point is a 10-second synchronous peer timeout, two transient retries with exponential backoff and jitter, a per-request wall-clock deadline, and a maximum of three delegation hops. These are design choices, not Teams limits. Respect Retry-After, stop when the remaining deadline cannot accommodate another attempt, and use asynchronous tasks for longer work.
- Prevent loops: check a server-enforced maximum depth, reject repeated agents in
visitedAgents, and maintain durable lineage so a peer cannot reset the budget in a new payload. - Make retries safe: persist a uniqueness key such as tenant + caller + request ID; reuse it through retries; record every external mutation’s idempotency key. Do not retry authorization, validation, or approval failures.
- Handle uncertain outcomes: a timeout, lost lock, or disconnected stream does not mean no work occurred. Reconcile by request/task ID before replaying a consequential action.
- Separate delivery from completion: a Teams send can succeed before its database acknowledgement is stored. Treat that window as ambiguous delivery; do not claim exactly-once messaging.
- Propagate cancellation: cancel model, tool and peer calls where supported; make irreversible effects explicit and preserve their audit record.
- Attribute visibly: identify the responding agent, any specialist contribution, pending handoff, and failures in plain language. Never impersonate a peer or claim delivery before acknowledgement.
11Verifiable delivery & continuous scanningScan, sign, verify and promote an immutable image
Recommended hardening Treat the release pipeline as a privileged system. Separate untrusted pull-request checks from production releases, restrict branch/environment access, pin actions and tools, and keep private runners ephemeral. Use OIDC federation for Azure login or a dedicated runner managed identity; never give a pull-request job the production identity or access to production vaults.
The example below splits a private build job from a protected release job. The build identity can push only approved repositories; the release identity can read images and update only the target apps. Use distinct ephemeral runners so each job cannot request the other identity. Their images supply reviewed, pinned versions of Docker/Buildx, Node/npm, Trivy, Azure CLI and Cosign. Replace the explicit action-SHA placeholder and the policy-script placeholders before use; this is a reference, not a ready-to-run workflow in this website.
name: agent-release
on:
workflow_dispatch:
push:
branches: [main]
paths: ['agents/**', 'ci/**', '.github/workflows/release.yml']
permissions:
contents: read
concurrency:
group: production-agent-release
cancel-in-progress: false
jobs:
build:
runs-on: [self-hosted, linux, x64, private-build]
permissions:
contents: read
id-token: write # Cosign keyless signing.
strategy:
matrix:
agent: [project-controls, estimating]
env:
AGENT: ${{ matrix.agent }}
ACR_NAME: ${{ vars.ACR_NAME }}
ACR_HOST: ${{ vars.ACR_LOGIN_SERVER }}
BUILD_MI: ${{ vars.BUILD_MI_CLIENT_ID }}
NODE_BUILD_IMAGE: ${{ vars.PINNED_NODE_BUILD_IMAGE }}
NODE_RUNTIME_IMAGE: ${{ vars.PINNED_NODE_RUNTIME_IMAGE }}
GIT_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@<reviewed-full-commit-sha>
with:
persist-credentials: false
- name: Test and build
shell: bash
run: |
set -euo pipefail
./ci/validate-release-inputs.sh
npm ci --ignore-scripts --prefix "agents/$AGENT"
npm test --prefix "agents/$AGENT"
npm run build --prefix "agents/$AGENT"
trivy fs --scanners vuln,secret,misconfig \
--severity HIGH,CRITICAL --exit-code 1 "agents/$AGENT"
docker build --pull \
--build-arg NODE_BUILD_IMAGE="$NODE_BUILD_IMAGE" \
--build-arg NODE_RUNTIME_IMAGE="$NODE_RUNTIME_IMAGE" \
-t "$ACR_HOST/agents/$AGENT:$GIT_SHA" "agents/$AGENT"
trivy image --scanners vuln,secret --image-config-scanners misconfig,secret \
--severity HIGH,CRITICAL --exit-code 1 \
"$ACR_HOST/agents/$AGENT:$GIT_SHA"
- name: Push, scan exact digest, attest and sign
shell: bash
run: |
set -euo pipefail
az login --identity --client-id "$BUILD_MI" --output none
az acr login --name "$ACR_NAME"
docker push "$ACR_HOST/agents/$AGENT:$GIT_SHA"
DIGEST=$(az acr manifest show-metadata \
--registry "$ACR_NAME" --name "agents/$AGENT:$GIT_SHA" \
--query digest --output tsv)
IMAGE="$ACR_HOST/agents/$AGENT@$DIGEST"
# Scan/attest the exact remote digest that deployment will use.
trivy image --scanners vuln,secret --image-config-scanners misconfig,secret \
--severity HIGH,CRITICAL --exit-code 1 "$IMAGE"
trivy image --format cyclonedx --output sbom.json "$IMAGE"
cosign sign --yes "$IMAGE"
cosign attest --yes --type cyclonedx \
--predicate sbom.json "$IMAGE"
./ci/create-and-attest-provenance.sh "$IMAGE"
printf '%s\n' "$IMAGE" > release-image.txt
- uses: actions/upload-artifact@<reviewed-full-commit-sha>
with:
name: release-${{ matrix.agent }}
path: |
release-image.txt
sbom.json
if-no-files-found: error
deploy:
needs: build
environment: production
runs-on: [self-hosted, linux, x64, private-release]
strategy:
max-parallel: 1
matrix:
agent: [project-controls, estimating]
env:
AGENT: ${{ matrix.agent }}
ACR_NAME: ${{ vars.ACR_NAME }}
DEPLOY_MI: ${{ vars.DEPLOY_MI_CLIENT_ID }}
steps:
- uses: actions/checkout@<reviewed-full-commit-sha>
with:
persist-credentials: false
- uses: actions/download-artifact@<reviewed-full-commit-sha>
with:
name: release-${{ matrix.agent }}
path: release
- name: Verify and promote
shell: bash
run: |
set -euo pipefail
az login --identity --client-id "$DEPLOY_MI" --output none
az acr login --name "$ACR_NAME"
IMAGE=$(cat release/release-image.txt)
# Reject unexpected registry, repository, digest or source commit.
./ci/validate-image-reference.sh "$AGENT" "$IMAGE"
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity \
"https://github.com/$GITHUB_REPOSITORY/.github/workflows/release.yml@refs/heads/main" \
"$IMAGE"
./ci/verify-attestations.sh "$IMAGE"
./ci/deploy-zero-traffic.sh "$AGENT" "$IMAGE"
# Candidate worker remains paused. Test with isolated jobs/state.
./ci/authenticated-smoke-test.sh "$AGENT"
./ci/promote-with-health-gates.sh "$AGENT" 5 25 100
Implement the named scripts to validate exact pins and image references, produce provenance for the actual build, verify attestation subjects/build identity, create a zero-HTTP-traffic revision with paused consumption, test authenticated routes using isolated jobs/state, and stop/rollback on a failed health gate. The promotion script must separately authorize and fence the exact worker revision, drain the previous consumer generation and verify business-side-effect idempotency. Its HTTP percentages do not control queue consumption. These application-specific gates are required work, not functionality supplied by this example. Generate build provenance using the trusted builder's supported attestation mechanism; an SBOM alone is not build provenance. Capture the source commit, base digests, dependency lock hash and tested output digest. Keep artifacts and vulnerability exceptions with expiry and owner.
Cosign keyless signing requires approved identity, certificate and transparency services. Decide whether public transparency metadata is acceptable; use a supported private signing arrangement when it is not. Verify issuer, exact workflow identity and image digest before deployment. Do not assume ACA has a Kubernetes-style native signature admission controller: enforce this in the deployment pipeline and prevent alternate deployment paths with RBAC.
Scanning after release
Run daily rescans against every currently deployed digest and retained rollback digest, using an updated vulnerability database. A passing scan at build time becomes stale. Enable applicable Defender for Cloud registry vulnerability assessment and route findings to an owner with response targets. Microsoft's initial registry scans can take hours. Its daily rescans cover qualifying images pushed/pulled in the last 30 days and images running in monitored Kubernetes clusters; do not assume every dormant ACR image is scanned forever.
# Separate nightly workflow; scanner identity has read-only registry access.
name: deployed-image-rescan
on:
schedule:
- cron: '17 3 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
rescan:
runs-on: [self-hosted, linux, x64, private-scanner]
env:
SCANNER_MI: ${{ vars.SCANNER_MI_CLIENT_ID }}
ACR_NAME: ${{ vars.ACR_NAME }}
steps:
- uses: actions/checkout@<reviewed-full-commit-sha>
with:
persist-credentials: false
- shell: bash
run: |
set -euo pipefail
az login --identity --client-id "$SCANNER_MI" --output none
az acr login --name "$ACR_NAME"
# Release automation maintains this digest-only inventory.
# Reconcile it with active ACA revisions before scanning.
./ci/reconcile-image-inventory.sh
failed=0
while IFS= read -r image; do
test -n "$image" || continue
trivy image --scanners vuln --severity HIGH,CRITICAL \
--exit-code 1 "$image" || failed=1
done < ci/active-and-rollback-images.txt
exit "$failed"
Reconcile the inventory automatically on every release and against deployed revisions; alert on an empty or stale inventory, failed scanner, or stale vulnerability database. Retain scan reports and page an owner on a failed check. The nightly example leaves that organization's incident-delivery integration configurable.
For a private ACR, Defender scanning can require a reviewed trusted-Microsoft-services network exception. If policy prohibits that exception, do not claim Defender scans the blocked registry; retain a permitted private scanner and verify its coverage. For ACA, current Defender CSPM documents discovery/posture in supported commercial clouds, not runtime threat detection and response. Kubernetes sensors are not automatically installed inside managed Container Apps. Complement applicable coverage with ACA logs, Azure activity logs, Key Vault access events, firewall/WAF logs and application-level audit events.
Sources: Trivy scanning and SBOM; GitHub OIDC with Azure; Cosign container signing; Defender registry rescanning behavior; Private ACR scanner access; ACA posture coverage.
12Before productionTests and release evidence required before enabling users.
Recommended hardening Assign an owner and retain results for each acceptance test.
Identity and entry point
- Each Teams-facing agent has its own app package and bot identity. Each service has its own permissions, tool policy, and state boundary. Verify manifest, tenant, messaging URL, and identity mappings with a real Teams installation.
- Reject missing, malformed, expired, wrong-audience and wrong-issuer tokens. Test tenant/user authorization, unauthorized peers, and missing API roles separately from Teams transport authentication.
- Application Gateway is the only public application route. Probe from the internet: direct ACA endpoints, health endpoints, agent cards, A2A, admin and metrics routes must not be publicly usable.
- WAF prevention, host/path/method restrictions, TLS, certificate renewal and request-size limits work with legitimate Teams cards/events. Oversized/malformed bodies fail before expensive model work.
- Apply authenticated rate/concurrency limits, schema validation, token/time budgets, and output limits per tenant, user, agent, and tool. Source-IP limits alone are unreliable behind Microsoft service infrastructure.
- Remove bootstrap grants from bot/runtime identities. Exclude Owner, Contributor, broad Graph access, and role-assignment authority. Review inherited permissions and rights to deploy code, read secrets, or execute inside containers.
Containers and supply chain
- Each image has an exact dependency lock, digest-pinned maintained bases, non-root USER, minimum packages and no embedded credentials or source-control history.
- Run Trivy dependency, image, secret and infrastructure checks; generate SBOM/provenance, sign the digest and verify release identity before deploying.
- Set CPU/memory, minimum/maximum replicas, concurrency/backpressure, startup/readiness/liveness probes and graceful shutdown. Test scale-out, timeout, provider throttling and deployment interruption.
- Verify application files are not writable by the process. Record ACA's lack of exposed read-only-rootfs, capability-drop and no-new-privileges settings. If those controls are mandatory, enforce them on AKS or another suitable runtime before acceptance.
- Private release runners are ephemeral and isolated from untrusted PR jobs. Schedule deployed/rollback-image rescans, base-image refreshes and dependency updates.
Network and secrets
- Validate internal environment ingress, correct gateway Host/SNI and private DNS. Check NSGs and effective routes without breaking required Azure infrastructure traffic.
- Verify allowed and denied outbound calls through Azure Firewall. Audit every wildcard exception; ensure runtime agents cannot reach package feeds or arbitrary internet destinations.
- Confirm private endpoint resolution and disabled public access for Key Vault, Azure OpenAI, ACR and other supported dependencies. Document which same-VNet/private flows bypass the firewall by design.
- Separate agent tool/data permissions and secret access. Verify one compromised agent identity cannot read another agent's vault or invoke an unassigned capability.
- Rotate a Key Vault-backed provider credential, revoke the old key, and verify recovery. Verify that images, CI variables, SDK/app/provider logs, traces, and handoff envelopes omit secrets and sensitive content.
Reasoning, tools and handoff safety
- Treat user messages, retrieved documents, web pages, tool output and another agent's response as untrusted content. They cannot change system policy, tool grants or the authorization subject.
- Enforce tool allow lists and argument schemas outside the model; check capability, resource, operation, and user authority. Required human approvals bind exact parameters and expire; models and peers cannot grant approval.
- Prevent SSRF and credential forwarding: restrict targets, redirects, DNS resolution, metadata/internal addresses, and deadlines. Models cannot choose credential destinations; approved egress does not authorize every payload.
- Partition memory, task state, evidence lookup, and caches by tenant/user/conversation and agent. Test isolation across replicas and restarts. Apply access controls before retrieval and minimize forwarded context.
- Validate private Agent Cards/capability registrations against an approved catalog. Do not let a prompt add an agent endpoint or change its declared privileges.
- Authenticate every handoff and authorize capability/caller pairs. Require correlation ID, parent task, session/tenant binding, idempotency key, deadline and a bounded handoff depth.
- Test cycles, duplicate deliveries, retries, delayed results, cancellations, dead-letter recovery and exhausted budgets. Protect each consequential tool action with a stable business idempotency key and durable result record, including the crash-after-write/before-acknowledgement case. Broker duplicate detection does not guarantee exactly-once external side effects; where the target API cannot deduplicate, require reconciliation or human resolution before retrying an uncertain write.
- Show agent attribution and pending/failure states. Test visible transfers with the destination installed, absent, removed, and blocked; return failed-contact status through the original agent.
Operations and release
- Join activity, task, model request, tool execution, approval, and delivery IDs in redacted audit records. Include actor, tenant, capability, source/destination, policy version, timestamps, outcome, and approval reference.
- Centralize WAF/firewall, Key Vault, Azure activity and app telemetry. Limit log access, set retention deliberately and alert on authentication failures, unusual tool activity, denied egress, cost spikes and missing audit events.
- Confirm applicable Defender for Cloud posture/registry features, vulnerability ownership and remediation targets. Do not claim unsupported runtime coverage.
- Test canary release, rollback, shutdown, restoration, and outbox recovery with pending jobs and compatible schemas. Verify zero-HTTP-traffic candidates cannot claim production jobs before authorization. Test stale-worker fencing; restore HTTP traffic, worker authority, and application-scoped settings separately.
- Document incident response, credential revocation, restore procedures, queue poison-message handling, service objectives, cost ceilings and provider-outage behavior.
- Complete staging, threat-model review, adversarial input tests, user acceptance, and an end-to-end Teams handoff before enabling production traffic.
Enforce authorization at the tool boundary; prompts and WAF rules cannot establish it. The Microsoft 365 Agents SDK source below supplies security guidance, not APIs for the separate Teams SDK.
Release evidence
Keep a release record with the immutable image digest, source revision, lockfile, SBOM and scan evidence; the exact SDK/protocol/model versions; tested identity grants and network rules; prompt/tool policy versions; failure-mode results; and named operational owners. Record expected latency, quality, cost, recovery and escalation thresholds before promoting a pilot.
Entra validation guidance · Container Apps security responsibilities · Teams SDK source and package status
Sources: OWASP prompt-injection risk and mitigations; Microsoft inbound and outbound agent security guidance; Container Apps operational probes; Container vulnerability management.