Trigger phrases — any of these will activate this skill automatically:
- "add Purview DLP to my agent"
- "add data loss prevention to my A365 agent"
- "block credit cards / SSNs / PII before my agent's LLM sees them"
- "enforce compliance / audit on agent prompts and responses"
- "wire a DLP gate into my agent code"
- "call the Graph processContent API from my agent"
- "add Purview blocking to my Node.js / Python / .NET agent"
Add a Purview data-loss-prevention gate before an existing A365 agent's LLM call: each prompt
is evaluated via the Graph processContent API and blocked when an input-blocking policy
matches. Optional response checks provide audit, not response blocking, for the supplied
Applications policy. processContent sends the checked text to Microsoft Purview for evaluation
and audit. Designed for minimal, reversible changes to the customer's agent.
When to use
- "Add Purview DLP / data loss prevention to my agent."
- "Block credit cards / SSNs / PII before my agent's LLM sees them."
- "Enforce compliance / audit on agent prompts and responses."
- Any A365 agent — Node.js, Python, or .NET — that needs inline DLP.
When NOT to use
- Adding OpenTelemetry tracing / MAC Activity telemetry → use
instrument-observability. - Diagnosing why telemetry isn't reaching MAC Activity / Defender → use
a365-code-validator. - Provisioning the blueprint / Entra permissions from scratch → use
a365-setup/make-a365-agentfirst, then return here.
How it works
user turn ─► [INPUT gate] processContent(uploadText, /me) ─► block? ─► reply "blocked", STOP (LLM never called)
└─ allow ─► LLM ─► optional output audit ─► reply
- Token: the agent's agentic delegated Graph token, evaluated as
/me(the agent identity) — acquired via the agent's own auth handler: Node.jsGetAgenticUserToken, Pythonauthorization.exchange_token(…), .NETUserAuthorization.GetTurnTokenAsync(…). A365 blueprint apps cannot use app-only client-credentials here — Graph strips data-plane roles from that token. - Fail-closed by default: any Purview error/timeout blocks the turn.
- Content limit: delegated guards reject messages over 100,000 characters before sending them to Graph, even in fail-open mode. They never approve a full message based on a prefix.
- Output:
PURVIEW_CHECK_OUTPUT=truesubmits responses for audit. The supplied workload does not supportDownloadTextblocking; do not promise that it filters sensitive responses. With fail-closed mode, a failed output audit still withholds the reply. - Policy: a dedicated Purview DLP policy scoped to the agent's Entra app id (portal:
"Managed cloud apps" /
Applicationsworkload) with a RestrictAccess=Block rule.
S2S / app-only agents (autonomous, no AgentApplication)
For an agent that authenticates service-to-service (no signed-in user, no
@microsoft/agents-hosting AgentApplication — e.g. an Express / worker-loop agent using the A365
FMI client-credentials chain), use the S2S guard assets/purview-s2s.ts
instead of purview.ts.
Verified 2026-08-26: app-only DLP is supported — the "blueprint app-only tokens get stripped" rule is true for the blueprint app but not for the agent identity:
| Token source | roles in the Graph token |
|---|---|
| Blueprint app-only (client-credentials) | AgentIdentity.CreateAsManager — Content.Process. STRIPPED* |
| Agent identity via the FMI 3-hop chain | Content.Process.All RETAINED ✅ |
So the S2S guard (1) mints the agent identity's Graph token through the FMI chain
(Blueprint → fmi_path → Agent Identity → Graph), (2) calls the app-only endpoint
POST /beta/users/{sponsorUserId}/dataSecurityAndGovernance/processContent (app-only can't use
/me), and (3) passes an agents:[{"@odata.type":"aiAgentInfo", blueprintId, identifier, name}]
entry plus protectedAppMetadata.applicationLocation = the agent app id (the DLP policy scope).
Two steps differ from the delegated path:
- Permission: grant
Content.Process.All(Application) to the agent identity SP (not the blueprint) — runscripts/Grant-ContentProcessAppRole.ps1instead ofGrant-DelegatedGraphScope.ps1. - Wiring: import
purviewGuardfrom./purview-s2s.jsand callevaluatePrompt(text)/evaluateResponse(text)directly around the LLM call (no TurnContext / Authorization args). The guard prefers the existingAGENT365_TENANT_ID,AGENT365_AGENT_ID,AGENT365_CLIENT_ID, andAGENT365_CLIENT_SECRETcredentials;AGENT365_BLUEPRINT_IDsets the default policy app ID. Confirm these are configured:a365 setup alldoes not populate the lowercaseagent365Observability__clientId/agent365Observability__clientSecretcredentials. Use a real directory user object ID forPURVIEW_SPONSOR_USER_ID. Configure secrets directly in the environment or secret store, never in chat. This variant requires client-secret FMI; do not switch managed-identity-only agents to it.
Everything else — the DLP policy (Step 6), the [purview] log format, fail-closed semantics — is identical.
Before you start — read the A365 config, then ask for the rest
First, auto-discover from the project's A365 config. The guard and both scripts read these
automatically, but read them yourself to confirm values and drive the workflow. From the agent
project root, read a365.config.json and a365.generated.config.json:
| Value | Config source |
|---|---|
App (client) id → PURVIEW_APP_ID |
a365.generated.config.json → agentBlueprintId (or botMsaAppId) |
Blueprint id → PURVIEW_BLUEPRINT_ID |
a365.generated.config.json → agentBlueprintId |
| Agent SP object id (for the consent script) | Resolved by the script for the requested app ID in the signed-in tenant; cached SP IDs are not trusted. |
Display name → PURVIEW_APP_NAME |
a365.config.json → agentBlueprintDisplayName / agentDescription |
| Tenant id | a365.config.json → tenantId |
Current Graph scopes (is Content.Process.User already granted?) |
a365.generated.config.json → resourceConsents[] where resourceName == "Microsoft Graph" → scopes |
Then ask the customer only for what's not in config (use the ask-questions tool):
| Ask | Notes / default |
|---|---|
| DLP policy — new, existing, or skip | Always ask (unless the user specified it when invoking the skill): create a new policy (default), use an existing one, or skip (they'll add it manually / already have it). Drives Step 6. |
| Sensitive info type(s) to block | Default Credit Card Number. Any Purview SIT. Only needed when creating a new policy. |
| Admin UPN for alerts | Optional; recommended (agents are unaware of blocks). |
| Agentic auth handler name | The static authHandlerName on their AgentApplication (usually agentic). |
| Entry/handler file + LLM call site | Where the message handler + LLM call live (locate in code when possible). |
| Who does Purview admin? | Confirm they (or an admin) can enable billing/DSPM-for-AI + run the scripts. |
If the project has no a365 config (non-A365 or not yet provisioned), ask for the app id, display name, and tenant id directly and pass them explicitly to the scripts/env.
Procedure
Before Step 1, show the following checklist. In Claude Code, use TaskCreate and TaskUpdate;
in Copilot, show and update the Markdown checkboxes. Keep one item in progress and mark it
complete as soon as its numbered steps finish. Do not mark a permission or policy failure complete.
- Locate the handler and confirm its authentication (Steps 0-1)
- Copy the guard and wire the checks (Steps 2-3)
- Configure and verify enablement (Step 4)
- Grant and verify Graph permission (Step 5)
- Resolve policy choice and tenant prerequisites (Steps 6-7)
- Build and verify a blocked turn (Step 8)
0. Detect the agent language
For delegated agents, pick the guard/wiring by the agent's stack (all three share the SAME env vars,
PowerShell scripts, policy, and [purview] log format). For Node.js S2S, follow the S2S variant above
instead of the delegated guard/wiring steps below.
| Language | Detect by | Guard asset | Wiring |
|---|---|---|---|
| Node.js / TypeScript | package.json (@microsoft/agents-hosting) |
assets/purview.ts |
assets/wiring-snippet.ts |
| Python | pyproject.toml / requirements.txt (microsoft-agents-hosting-*) |
assets/purview.py |
assets/wiring-snippet.py |
| .NET | *.csproj (Microsoft.Agents.*) |
assets/purview.cs |
assets/wiring-snippet.cs |
Node.js and Python guards are verified against a live agent; the .NET guard is a best-effort port — verify the
ITurnContext/UserAuthorizationnamespaces against your SDK version.
1. Locate the agent code
Find the message handler (the method that calls the LLM) and the LLM call:
- Node.js: the
AgentApplicationsubclass,onActivity(ActivityTypes.Message, …). - Python: the
AgentInterfaceimplementation'sprocess_user_message(or the aiohttp host'son_message). - .NET: the
AgentApplicationsubclass'sOnMessageAsync.
Confirm the A365 runtime/hosting package is present (Node.js @microsoft/agents-a365-runtime,
Python microsoft-agents-hosting-core, .NET Microsoft.Agents.*) — standard for A365 agents.
Confirm the selected handler can obtain a delegated Graph token; for .NET, its configured
scopes must include https://graph.microsoft.com/.default. A plain bot without a supported
hosting/authentication path cannot be integrated just by supplying app IDs: stop before copying
the guard and route to a365-setup. Do not switch an existing S2S agent to delegated auth.
2. Add the guard (1 new file)
Copy the guard for your language into the agent's source folder (next to the agent class) — it is generic and env-driven, no edits needed:
- Node.js:
assets/purview.ts - Python:
assets/purview.py - .NET:
assets/purview.cs(adjust theusingnamespaces forITurnContext/UserAuthorizationto your SDK version)
3. Wire the two gates (minimal edits)
Apply the wiring snippet for your language to the message handler (and any notification/email handler that calls the LLM): import the guard, add the input gate before the LLM and the output gate before the reply is sent. Pass the agent's authorization handler + auth-handler name + turn context/id.
- Node.js:
assets/wiring-snippet.ts— passesthis.authorization. - Python:
assets/wiring-snippet.py— for response-returningprocess_user_messagehandlers; passesauth,auth_handler_name,context. The caller sends the returned text. If wiring directly into a hoston_messagecallback instead, send the block/withhold message withawait context.send_activity(...)and then return; returning a string alone does not send a reply. - .NET:
assets/wiring-snippet.cs— passesUserAuthorization, the agentic handler name,turnContext.
4. Add environment variables
Use the keys from assets/purview.env.example, preserving
existing values rather than appending duplicate keys:
- Node.js / Python: add missing keys to
.env, which their guards load. - .NET: set
PURVIEW_DLP_ENABLEDin the selectedProperties/launchSettings.jsonprofile'senvironmentVariables, or in the cloud host's process environment. A.envfile orappsettings.jsonkey alone is not loaded by this guard. For a one-session local run, PowerShell can set$env:PURVIEW_DLP_ENABLED = 'true'beforedotnet run.
On a delegated A365 project the only required guard key is PURVIEW_DLP_ENABLED=true — the guard
auto-reads PURVIEW_APP_ID / PURVIEW_APP_NAME / blueprint id from a365.config.json +
a365.generated.config.json. Set them explicitly only to override or for a non-A365 project.
Confirm the guard's isEnabled / is_enabled / IsEnabled is true in the running agent.
For Node.js S2S, confirm the canonical credentials listed above instead of relying on config auto-discovery.
5. Grant the delegated Graph scope
For Node.js S2S, use Grant-ContentProcessAppRole.ps1 as described above and skip this delegated step.
Run scripts/Grant-DelegatedGraphScope.ps1 from the
agent project folder (needs az login). -AppId is auto-discovered from
a365.generated.config.json (pass -AppId <app-id> to override, or -ConfigDir <path> if the
config lives elsewhere). It appends Content.Process.User to the agent's existing agentic
consent. Never run az ad app permission admin-consent on the blueprint app.
The scripts stop on failed CLI commands or failed verification. Surface that failure and resolve
the tenant/admin issue before claiming permission setup is complete; do not broaden permissions.
6. Choose the DLP policy — new, existing, or skip
Ask the user which policy to use (skip the prompt only if they already told you when invoking the skill, e.g. "use my existing 'Corp PII' policy", "create a new one", or "I'll add it manually"):
"Which Purview DLP policy should gate this agent?
- Create a new dedicated AI-app policy (default),
- Use an existing DLP policy you already have, or
- Skip — don't create one now; I'll add it manually (or already have one)."
The guard is policy-name-agnostic — it calls processContent with the agent's app id as the
applicationLocation, and Purview evaluates every policy whose Applications location includes
that app id. So this choice affects only this step, never the guard code.
Option A — Create a new policy (default):
Run scripts/New-AiAppDlpPolicy.ps1 from the agent project
folder (needs the ExchangeOnlineManagement module). -AppId / -AppName are auto-discovered
from the a365 config; typically you only pass -SensitiveInfoType "Credit Card Number" -NotifyUser <admin-upn>. Override with -AppId / -AppName for a non-A365 project. Or follow
references/purview-portal-guide.md to do it in the portal.
The script reuses existing names only when the app, enforcement mode, and enabled input-blocking
rule match. On a mismatch, it stops without modifying those objects. Ask the admin to review them
or use distinct names; do not silently rewrite an existing policy.
Option B — Use an existing policy:
- List candidates (read-only) to see which policies already cover this agent's app id:
It connects to Security & Compliance and prints every DLP policy, flagging whether the agent's app id is already COVERS THIS APP../scripts/New-AiAppDlpPolicy.ps1 -ListExisting - If the chosen policy already covers the app id — nothing to create; the guard will match it.
Confirm its
ModeisEnableand it has aRestrictAccess/Blockrule onUploadText. - If the chosen policy does not cover the app id — add this agent's app id to that policy's Applications (Managed cloud apps) location in the portal (purview-portal-guide.md), or fall back to Option A.
Option C — Skip (add the policy manually, or handle it later):
Create nothing now. The guard stays wired and enabled — it just won't block until a matching
policy exists (until then it logs allowed … 0 policyAction(s)). To add the policy yourself, it
must have ALL of the following or the guard won't block (full portal walkthrough:
purview-portal-guide.md):
| # | Requirement | Value / note |
|---|---|---|
| 1 | Location = Applications (portal: "Managed cloud apps") | scoped to the agent's Entra app (client) id (PURVIEW_APP_ID). Do not combine Exchange/SharePoint/OneDrive/Teams in the same policy — Purview rejects that mix for this workload. |
| 2 | Enforcement plane = Application |
NOT "Copilot experiences" (that's first-party Copilot). |
| 3 | Rule condition = Content contains sensitive info type | your SIT(s), e.g. Credit Card Number. |
| 4 | Rule action = Restrict access → Block on the prompt (UploadText) |
blocking the response (DownloadText) isn't supported for this workload → prompt/input gate only. |
| 5 | Mode = Enable | turn the policy on. |
| 6 | (optional) Notify an admin UPN | agents don't surface a block to a human. |
Equivalent Security & Compliance PowerShell (exactly what Option A automates — run it by hand if you prefer):
Connect-IPPSSession
$loc = '[{"Workload":"Applications","Location":"<APP_ID>","LocationDisplayName":"<name>","LocationSource":"Entra","LocationType":"Individual","Inclusions":[{"Type":"Tenant","Identity":"All"}]}]'
New-DlpCompliancePolicy -Name "<policy>" -Mode Enable -Locations $loc -EnforcementPlanes @('Application')
New-DlpComplianceRule -Name "<rule>" -Policy "<policy>" `
-ContentContainsSensitiveInformation @(@{ Name = 'Credit Card Number' }) `
-RestrictAccess @(@{ setting = 'UploadText'; value = 'Block' })
Judge success only by the agent's [purview] uploadText -> BLOCKED (… 1 policyAction(s) … errors=0)
log line — not by DistributionStatus (shows Pending even for live policies). Allow up to ~1 hour to propagate.
7. Confirm tenant prerequisites
DLP-for-AI is metered. If everything is configured but nothing blocks (with errors=0), verify
pay-as-you-go billing + DSPM-for-AI onboarding + licensing — see
references/purview-portal-guide.md §0.
8. Build, run, verify
Rebuild/restart the agent so it reloads .env (Node.js: npm run build && npm start;
Python: restart the existing Python command; .NET: dotnet run with the configured process
environment from Step 4). Send a message containing a Luhn-valid
test value, e.g. My credit card is 4111 1111 1111 1111. Watch the console.
Verify that a blocked prompt never reaches the LLM call, not just that a block log appears.
Also send an ordinary prompt and confirm it reaches the LLM. Output audit is not proof of
response blocking for this workload.
Verify — expected logs
[turn] in <- ... text="My credit card is 4111 1111 1111 1111"
[purview] uploadText -> BLOCKED (HTTP 200, 1 policyAction(s), scopeState=modified, errors=0)
- ✅
BLOCKED+1 policyAction(s)+errors=0→ working; the agent replies with the block message and the LLM is never called. allowed (… 0 policyAction(s) … errors=0)→ request fine, but no policy matched yet (scope/propagation/tenant enablement).REQUEST ERROR (… errors=1 …)→ malformed request (usually thenamefield) — see troubleshooting.
Full symptom→fix table: references/troubleshooting.md.
Dependencies (customer agent)
No extra Graph SDK / @azure/identity / MSAL needed for any language — the guard uses the agent's
own agentic auth handler + a plain HTTPS POST.
- Node.js:
@microsoft/agents-hosting+@microsoft/agents-a365-runtime, Node 18+ (globalfetch),dotenv. - Python:
microsoft-agents-hosting-core+httpx(both standard in A365 Python agents),python-dotenv. - .NET:
Microsoft.Agents.*hosting packages (standard); usesSystem.Net.Http+System.Text.Json(no extra NuGet).
Non-negotiable constraints (why it's built this way)
- Agentic delegated token +
/me(AgentApplication agents); agent-identity FMI token (S2S agents) — a blueprint app-only token is stripped of data-plane roles (Content.Process.*) by Graph, so an AgentApplication agent must use its agentic delegated token (Node.jsAgenticAuthenticationService.GetAgenticUserToken, Pythonauthorization.exchange_token(…), .NETUserAuthorization.GetTurnTokenAsync(…)). But an S2S / autonomous agent CAN do app-only DLP — using the agent identity's FMI-minted Graph token (not the blueprint's) against/users/{sponsor}/…instead of/me. See "S2S / app-only agents" above. Do not "fix" the AgentApplication path by switching it to client-credentials. contentEntry.nameis required — omitting it returns a permanentBadRequestthat looks like a clean allow. The guard always sets it and treatsprocessingErrorsas fail-closed.- Dedicated AI-app policy — the
Applications/Managed-cloud-apps location can't be combined with Exchange/SharePoint/OneDrive/Teams in one policy. RestrictAccessblock,EnforcementPlanes=Application— notBlockAccess, notCopilotExperiences(that's first-party Copilot).- Never
admin-consentthe blueprint app — it can narrow the agentic consent and break the agent's sign-in. Append the scope instead. - Trust the
[purview]log, notDistributionStatus(which readsPendingeven for live policies).
Files in this skill
| File | Purpose |
|---|---|
assets/purview.ts · purview.py · purview.cs |
Drop-in DLP guard (Node.js / Python / .NET — generic, env-driven). |
assets/purview-s2s.ts |
S2S (app-only) DLP guard for autonomous Node.js agents — agent-identity FMI Graph token, /users/{sponsor} endpoint. |
assets/wiring-snippet.ts · .py · .cs |
The minimal handler edits (Node.js / Python / .NET). |
assets/purview.env.example |
Environment variables (same for all three languages). |
scripts/Grant-DelegatedGraphScope.ps1 |
Append the delegated Graph scope (surgical, no admin-consent). |
scripts/Grant-ContentProcessAppRole.ps1 |
S2S: grant Content.Process.All (Application) to the agent identity SP. |
scripts/New-AiAppDlpPolicy.ps1 |
Create the AI-app DLP policy + block rule. |
references/purview-portal-guide.md |
Tenant enablement (billing/DSPM) + manual portal policy steps. |
references/troubleshooting.md |
Log reference + symptom→cause→fix table. |