Skip to content

Add Purview DLP integration skill for Agent 365 - #80

Merged
dbezic (dbezic) merged 12 commits into
mainfrom
users/dominikbezic/add-purview-skill
Sep 15, 2026
Merged

dbezic (dbezic) merged 12 commits into
mainfrom
users/dominikbezic/add-purview-skill

Conversation

@dbezic

Copy link
Copy Markdown
Contributor

Summary

Adds /agent365:purview-dlp-integration to protect agent prompts and responses using Microsoft Purview DLP through Microsoft Graph processContent.

Changes

  • Add input and optional output guards for Node.js, Python, and .NET, with fail-closed defaults and configurable timeouts.
  • Support autonomous S2S agents through a TypeScript guard using the agent identity’s FMI authentication chain.
  • Include PowerShell scripts for permission grants and dedicated AI-app DLP policies.
  • Provide configuration discovery, policy-selection guidance, verification, and troubleshooting documentation.
  • Add an advisory stop-hook validator, eight unit tests, and evaluation scenarios.
  • Update plugin metadata and skill discovery documentation.

Validation

  • npm test: 132 passed, zero failures.
  • Live-tenant integration was not tested during PR preparation.
  • The .NET guard is a best-effort port requiring target-SDK verification.

@dbezic
dbezic (dbezic) requested review from a team and a lite review from Copilot September 8, 2026 13:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

It includes a few concrete policy/convention issues (hardcoded tenant identifier in docs/comments, unexpected default permission scope, and a confusing validator exclusion path) that should be corrected before merge.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a new purview-dlp-integration skill to the Agent 365 skills plugin, providing a drop-in Microsoft Purview DLP “gate” around an agent’s LLM call (prompt input, and optional response output) using Microsoft Graph processContent, along with scripts, docs, evals, and a report-first validator.

Changes:

  • Introduces Purview DLP guard templates for Node.js, Python, and .NET (plus an S2S Node.js guard) and wiring snippets for input/output gating.
  • Adds PowerShell automation for delegated scope grant and AI-app DLP policy creation, plus portal/troubleshooting documentation.
  • Adds a report-first stop-hook validator, unit tests, eval scenarios, and updates plugin/docs metadata to include the new skill.
File summaries
File Description
tests/validate-purview-dlp-integration.test.js Adds unit tests for the report-first Purview DLP validator behavior.
README.md Documents the new standalone skill and its trigger phrases.
plugins/agent365/skills/purview-dlp-integration/SKILL.md Defines the new skill workflow, constraints, and user prompts.
plugins/agent365/skills/purview-dlp-integration/scripts/New-AiAppDlpPolicy.ps1 Adds script to create/list Purview AI-app DLP policies and rules.
plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1 Adds script to append delegated Graph scopes needed for DLP evaluation.
plugins/agent365/skills/purview-dlp-integration/scripts/Grant-ContentProcessAppRole.ps1 Adds script for S2S/app-only scenario to grant Content.Process.All to the agent identity SP.
plugins/agent365/skills/purview-dlp-integration/references/troubleshooting.md Adds troubleshooting guide keyed off [purview] log output.
plugins/agent365/skills/purview-dlp-integration/references/purview-portal-guide.md Adds tenant prerequisites + portal steps for enabling/configuring AI-app DLP.
plugins/agent365/skills/purview-dlp-integration/README.md Adds user-facing skill documentation and conceptual overview.
plugins/agent365/skills/purview-dlp-integration/assets/wiring-snippet.ts Provides reference wiring snippet for Node.js agents (input/output gate).
plugins/agent365/skills/purview-dlp-integration/assets/wiring-snippet.py Provides reference wiring snippet for Python agents (input/output gate).
plugins/agent365/skills/purview-dlp-integration/assets/wiring-snippet.cs Provides reference wiring snippet for .NET agents (input/output gate).
plugins/agent365/skills/purview-dlp-integration/assets/purview.ts Adds Node.js guard implementation calling Graph processContent with agentic delegated auth.
plugins/agent365/skills/purview-dlp-integration/assets/purview.py Adds Python guard implementation calling Graph processContent with agentic delegated auth.
plugins/agent365/skills/purview-dlp-integration/assets/purview.env.example Documents environment variables shared across language variants.
plugins/agent365/skills/purview-dlp-integration/assets/purview.cs Adds .NET guard (best-effort port) calling Graph processContent.
plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts Adds Node.js S2S/app-only guard using FMI chain + app-only processContent endpoint.
plugins/agent365/hooks/stop/validate-purview-dlp-integration.js Adds report-first static validator to detect guard presence, wiring, and enablement.
plugins/agent365/.claude-plugin/plugin.json Updates plugin metadata/keywords to include Purview DLP.
package.json Updates repository package metadata and validate script to include the new validator.
evals/README.md Documents the new skill’s eval folder and prerequisites.
evals/agent365/purview-dlp-integration/evals.json Adds eval scenarios and trigger-boundary tests for the new skill.
CLAUDE.md Updates repository structure/docs to include the new skill and validator.
AGENTS.md Updates contributor guidance and skill inventory to include Purview DLP integration.
.github/copilot-instructions.md Adds the new skill to Copilot instructions and documents its constraints/markers.
Review details
  • Files reviewed: 25/25 changed files
  • Comments generated: 5
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread plugins/agent365/hooks/stop/validate-purview-dlp-integration.js
Comment thread plugins/agent365/skills/purview-dlp-integration/SKILL.md Outdated
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts Outdated
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.cs Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: dbezic <50911161+dbezic@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 8, 2026 14:04
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: dbezic <50911161+dbezic@users.noreply.github.com>
dbezic (dbezic) and others added 2 commits September 8, 2026 15:05
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: dbezic <50911161+dbezic@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: dbezic <50911161+dbezic@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new Node.js S2S Purview guard does not align with the repo’s existing S2S env-var conventions and the delegated-scope grant script appears to over-grant by default.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:36

  • Grant-DelegatedGraphScope.ps1 claims to grant the delegated Graph scope(s) the DLP guard needs (Content.Process.User), but the default $Scope array also includes ProtectionScopes.Compute.User, which isn’t referenced anywhere else in this repo and broadens permissions beyond what the skill documents. If the extra scope isn’t required for processContent, it should be removed from the default (users can still pass it explicitly if needed).
  [string[]] $Scope     = @('Content.Process.User', 'ProtectionScopes.Compute.User'),
  • Files reviewed: 25/25 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts Outdated
Copilot AI review requested due to automatic review settings September 8, 2026 14:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The PR introduces at least one over-permission default in the grant script and a timestamp formatting issue in the S2S guard that can break Graph requests.

Review details

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:194

  • createdDateTime/modifiedDateTime are generated by stripping the trailing Z from toISOString(), producing a timestamp without an explicit timezone. Keep the full ISO-8601 UTC timestamp (including Z) to avoid the Graph API rejecting or misinterpreting the datetime fields.
    plugins/agent365/skills/purview-dlp-integration/SKILL.md:144
  • In the auto-discovery table, PURVIEW_APP_ID and PURVIEW_BLUEPRINT_ID both point to agentBlueprintId, which can read like two different values even though they default to the same thing for A365 projects. Clarify that PURVIEW_BLUEPRINT_ID defaults to PURVIEW_APP_ID (set only to override) so users don’t mistakenly hunt for a second identifier.

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:37

  • The default scope list grants ProtectionScopes.Compute.User in addition to Content.Process.User, but the Purview DLP guard and docs only require Content.Process.User. Granting extra delegated permissions by default is unnecessary and weakens least-privilege; keep the default minimal and let users opt-in to additional scopes via -Scope if needed.
param(
  [string]   $AppId,                            # auto-discovered from a365 config if omitted
  [string[]] $Scope     = @('Content.Process.User', 'ProtectionScopes.Compute.User'),
  [string]   $ConfigDir = '.'                    # folder containing a365.config.json / a365.generated.config.json
  • Files reviewed: 25/25 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: dbezic <50911161+dbezic@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate issues affect S2S configuration, guard behavior, language assets, permission automation, and workflow correctness.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (40)

Previously missed (4) — in code that hasn't changed since the last review.

plugins/agent365/skills/purview-dlp-integration/SKILL.md:161

  • The documented non-A365 flow asks for a tenant ID and says to pass it to the scripts/environment, but the guards do not read a PURVIEW_TENANT_ID value and neither script accepts a tenant parameter. A user following this path cannot actually provide the requested tenant value; remove it from the required inputs or add a real consumption path.
    plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:194
  • This uses the blueprint/client app id as aiAgentInfo.identifier for every .NET turn. Existing A365 conventions resolve the runtime agent identity from turnContext.Activity.GetAgenticInstanceId(); using _appId can attribute DLP events to the blueprint instead of the active agent instance. Resolve the runtime identity here and only use the app id as a deliberate fallback when no agentic identity is available.
    plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:69
  • This lookup selects the first Graph grant for the service principal without requiring consentType = 'AllPrincipals'. In a tenant with both principal-specific and app-wide grants, the script can patch the wrong grant and leave the agent-wide permission unchanged. Filter the lookup to the AllPrincipals grant.
    plugins/agent365/skills/purview-dlp-integration/scripts/New-AiAppDlpPolicy.ps1:116
  • When a policy with the generated name already exists, this branch skips it without checking that its Applications location contains this app, that its enforcement plane is Application, or that it is enabled. A name collision or partially configured prior run can therefore produce false success with no matching policy; validate or reconcile the existing policy before reporting success.

.github/copilot-instructions.md:330

  • These generated Copilot instructions describe only the delegated /me path and say app-only client credentials are never valid, but this PR adds the S2S purview-s2s.ts path that deliberately uses the agent identity's FMI-minted app-only token and a /users/{sponsor} endpoint. Copilot users following this instruction will reject or misconfigure the advertised S2S feature; document the explicit blueprint-vs-agent-identity exception here.
**Non-negotiable constraints:** agentic delegated token + `/me` (never app-only client credentials on a blueprint app — Graph strips data-plane roles); `contentEntry.name` always set + `processingErrors` fail-closed; dedicated AI-app policy; `RestrictAccess` block on `UploadText`; never `admin-consent` the blueprint app; trust the `[purview]` log, not `DistributionStatus`.

AGENTS.md:58

  • The contributor guide has the same delegated-only description even though the new skill includes an S2S guard and separate application-role grant. This makes the repository documentation internally inconsistent and can cause future contributors to remove or bypass the supported S2S path; add the agent-identity FMI/app-only exception and the S2S script to this mirrored entry.
`purview-dlp-integration` is **additive** and independent of observability / WorkIQ. It auto-discovers the app (client) id, display name, blueprint id, and current Graph scopes from `a365.config.json` + `a365.generated.config.json`, then asks only for what's missing (DLP policy choice, sensitive info type, admin UPN, agentic auth handler name). It detects the agent language and copies ONE generic env-driven guard (`assets/purview.ts` / `purview.py` / `purview.cs` — the .NET guard is a best-effort port), then applies minimal wiring: an **INPUT gate** that calls the Graph `processContent` API before the LLM (blocks the turn → the LLM is never called) and an optional **OUTPUT gate** before the reply. The guard uses the agent's own **agentic delegated** token evaluated as `/me` (never app-only client credentials on a blueprint app), always sets `contentEntry.name`, and fails closed. It guides the delegated `Content.Process.User` scope grant (`scripts/Grant-DelegatedGraphScope.ps1` — appends, never `admin-consent` on the blueprint app) and a DLP-policy choice (new via `scripts/New-AiAppDlpPolicy.ps1`, existing via `-ListExisting`, or skip). Success is judged by the `[purview] uploadText -> BLOCKED (… errors=0)` log, not `DistributionStatus`. The stop-hook validator (`validate-purview-dlp-integration.js`) is report-first (advisory findings, never blocks) because the skill supports a legitimate "start disabled / skip policy" bring-up state.

CLAUDE.md:120

  • These instructions describe only the delegated /me path and say app-only client credentials are never valid, but this PR adds the S2S purview-s2s.ts path that deliberately uses the agent identity's FMI-minted app-only token and /users/{sponsor}. Users following this instruction will reject or misconfigure S2S; document the explicit blueprint-vs-agent-identity exception here.
   the LLM + an optional OUTPUT gate. The guard uses the agent's **agentic delegated** token
   evaluated as `/me` (never app-only client credentials on a blueprint app), always sets
   `contentEntry.name`, and fails closed. Never run `az ad app permission admin-consent` on the
   blueprint app — append the `Content.Process.User` scope instead. Its validator

evals/README.md:54

  • This new exception explicitly allows a code-editing skill to bypass .a365-workspace-detection.local.json, contradicting the repository-wide requirement that the cache exists before any code edit. Either make the skill cache-aware (and update this exception) or formally update the shared invariant and validators; leaving only this eval note creates an inconsistent contract.
- `purview-dlp-integration` can run without the cache — it reads `a365.config.json` /
  `a365.generated.config.json` directly (or takes the app id / display name / tenant id from you).

plugins/agent365/hooks/stop/validate-purview-dlp-integration.js:103

  • The validator treats any handler file containing the guard symbol as wired, so a bare import or unused reference passes without an input or output gate. That makes the advisory purview-guard-not-wired finding miss the common case where the asset was copied and imported but never called; require an evaluatePrompt/evaluateResponse (or language-equivalent) invocation instead of only the symbol.
const wiredFiles = codeFiles.filter(f => {
  if (guardFiles.includes(f)) return false;
  const c = read(f);
  return GUARD_SYMBOL.test(c);
});

plugins/agent365/hooks/stop/validate-purview-dlp-integration.js:46

  • appSettingsFiles is collected here but never consulted by envHasAny, so the .NET validator reports purview-env-flag-missing-dotnet even when PURVIEW_DLP_ENABLED is configured in appsettings.json, which this diagnostic message explicitly treats as a supported location. Either scan the supported settings files or remove them from the guidance.
const envFiles = filterByName(allFiles, '.env', '.env.local', '.env.production', '.env.development');
const appSettingsFiles = filterByName(allFiles, 'appsettings.json', 'appsettings.Development.json', 'appsettings.Production.json');

plugins/agent365/skills/purview-dlp-integration/README.md:53

  • A blocked message is still sent from the agent to Microsoft Purview through Graph for evaluation and auditing; only the LLM call is skipped. Saying the sensitive text “never leaves your agent” is therefore false and conflicts with the documented processContent flow. Limit this claim to “never sent to the AI model” (or equivalent).
> **Key point:** when a message is blocked, **the AI model is never called** — the
> sensitive text never leaves your agent. Every checked message is also written to
> Microsoft Purview's audit log, so you get compliance records automatically.

plugins/agent365/skills/purview-dlp-integration/SKILL.md:209

  • The statement that PURVIEW_DLP_ENABLED is the only required key is true for the delegated AgentApplication path, but not for the S2S guard described above: it also needs tenant, runtime agent identity, blueprint/client credentials or managed identity, and sponsor user configuration. This guidance causes S2S users to start with incomplete configuration and fail closed; split the prerequisites by guard type.
### 4. Add environment variables
Append the keys from [`assets/purview.env.example`](./assets/purview.env.example) to the agent's
`.env`. On an A365 project the only key you **must** set 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.

plugins/agent365/skills/purview-dlp-integration/SKILL.md:169

  • This new multi-phase skill enters the procedure without the visible TODO/task checklist required for every skill in this repository. In Copilot/CLI mode there is no TaskCreate fallback, so users cannot see or track phase completion; add the checklist before the procedure and mark each phase complete as it runs.
## Procedure

### 0. Detect the agent language
Pick the guard/wiring by the agent's stack (all three share the SAME env vars, PowerShell scripts,
policy, and `[purview]` log format — only the guard file + wiring differ):

plugins/agent365/skills/purview-dlp-integration/SKILL.md:138

  • This skill proceeds directly to config discovery and code-edit steps without the repository-mandated Phase 0A cache guard. On a project with code it can bypass a365-setup and leave no .a365-workspace-detection.local.json, violating the shared invariant that must hold before any code edit. Add the hard-stop/cache-writing phase or update the repository-wide contract consistently.
## 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`:

plugins/agent365/skills/purview-dlp-integration/SKILL.md:201

  • This procedure is written only for AgentApplication turns and always passes authorization, handler name, and TurnContext. For the S2S variant introduced above, the required wiring is direct evaluatePrompt(text)/evaluateResponse(text) calls from purview-s2s.ts; following this step wires the wrong contract and fails. Add an explicit S2S branch here.
### 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`](./assets/wiring-snippet.ts) — passes `this.authorization`.

plugins/agent365/skills/purview-dlp-integration/SKILL.md:215

  • This permission step unconditionally instructs the user to grant delegated Content.Process.User. For S2S, the guard requires Content.Process.All on the agent identity and Grant-ContentProcessAppRole.ps1; following this step leaves the S2S token without its required role and every check fails closed. Branch the permission step on auth mode.
### 5. Grant the delegated Graph scope
Run [`scripts/Grant-DelegatedGraphScope.ps1`](./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

plugins/agent365/skills/purview-dlp-integration/SKILL.md:110

  • The PR description says live-tenant integration was not tested during preparation, but this new section claims S2S app-only DLP was “Verified 2026-08-26 in a live tenant.” Clarify the provenance or remove the live-tenant claim; otherwise the skill presents an unverified permission/token behavior as established fact.
**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**:

plugins/agent365/skills/purview-dlp-integration/SKILL.md:193

  • The procedure's guard-copy step lists only the delegated purview.ts/.py/.cs assets, while the S2S section requires purview-s2s.ts instead of purview.ts. An S2S user following the numbered workflow will copy the incompatible guard; add the S2S asset as an explicit branch here.
### 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`](./assets/purview.ts)
- **Python:** [`assets/purview.py`](./assets/purview.py)

plugins/agent365/skills/purview-dlp-integration/SKILL.md:175

  • The skill's top-level language table omits the S2S guard even though S2S is advertised as supported. This makes detection select the AgentApplication wiring assets for an autonomous Node.js agent; include the S2S variant and its direct-call wiring in the selection table.
| 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` |

plugins/agent365/skills/purview-dlp-integration/SKILL.md:264

  • This conflicts with the policy guidance below: the policy created here only supports RestrictAccess on UploadText, and the skill says DownloadText blocking is unsupported, yet the guard defaults PURVIEW_CHECK_OUTPUT to true and the documented flow promises output withholding. The default therefore performs an unenforceable output check and can withhold responses on output errors; default it off or document the output path as audit-only.
| # | 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. |

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:143

  • Hop 1+2 is hard-coded to client-secret authentication. The repository's canonical Node.js S2S configuration defaults AGENT365_USE_MANAGED_IDENTITY=true and permits no secret in production, so this guard cannot work in the documented Azure deployment and every turn fails closed. Add the managed-identity branch, or explicitly require a client secret and remove the MSI-compatible S2S claim.
    // Hop 1+2: Blueprint (client secret) → T1 via FMI path. MSAL doesn't serialize fmi_path,
    // so use a direct form POST (same workaround as the observability token service).

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:100

  • The fallback agent365Observability__agentId is not the runtime agent identity: the repository documents that setup-all stamps the blueprint ID into this variable. This value is used both as FMI fmi_path and as aiAgentInfo.identifier, so S2S can request a token for the wrong principal and misattribute the DLP event. Only accept the runtime AGENT365_AGENT_ID/agenticAppId value and fail closed when it is absent.
    this.agentId = process.env.agent365Observability__agentId ?? process.env.AGENT365_AGENT_ID ?? "";

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:205

  • this.appId is the DLP application scope and can be overridden by PURVIEW_APP_ID, but the request's aiAgentInfo.blueprintId must remain the blueprint ID. With the documented app-id override, this sends the policy app ID as blueprintId, producing an inconsistent S2S request; keep a separate resolved blueprint ID and use it here.
                "@odata.type": "microsoft.graph.aiAgentInfo",
                blueprintId: this.appId,
                identifier: this.agentId,

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:253

  • Unlike both delegated guards, this path parses JSON for every successful response. processContent may return 202/204 without a body; res.json() then throws, and fail-closed mode blocks a valid request. Handle 202/204 as accepted before parsing, as the other guards do.
      const json = (await res.json()) as ProcessContentResponse;
      if (this.debug) console.log(`[purview] ${activity} raw:`, JSON.stringify(json));

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:12

  • This asset repeats a “verified in a live tenant” claim even though the PR validation notes say live-tenant integration was not tested. That claim is material because the token roles and endpoint behavior determine whether the new S2S path is safe; reconcile the evidence or label this as unverified/best effort.
// ── WHY A SEPARATE GUARD (verified 2026-08-26 in a live tenant) ─────────────
//  The delegated guard (purview.ts) needs an agentic `/me` token, which an S2S agent does
//  not have. The blocker in the base skill — "blueprint app-only tokens get Content.Process
//  stripped" — is REAL for the *blueprint* app, but NOT for the *agent identity*:
//    • Blueprint app-only Graph token  → roles: AgentIdentity.CreateAsManager   (STRIPPED)

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:94

  • The procedure documents DownloadText restriction as unsupported and creates only an UploadText block rule, yet this defaults response checking on for every turn. Since processingErrors are fail-closed, an unsupported or invalid response check can withhold every otherwise-allowed answer even though output checking is described as optional. Default this to false and require explicit opt-in.
    this.checkOutput = envBool("PURVIEW_CHECK_OUTPUT", true);

plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:274

  • The guard's config loader never reads blueprintId, even though a365.config.json uses that field in this repository. When the generated config is absent or incomplete, the documented A365 auto-discovery path leaves appId/blueprintId empty and the guard fails closed; include the blueprintId fallback for the second config file.
                outp["appId"] = outp.GetValueOrDefault("appId") ?? Str(r, "agentBlueprintId") ?? Str(r, "botMsaAppId") ?? Str(r, "botId");
                outp["blueprintId"] = outp.GetValueOrDefault("blueprintId") ?? Str(r, "agentBlueprintId");

plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:78

  • The procedure documents DownloadText restriction as unsupported and creates only an UploadText block rule, yet this defaults response checking on for every turn. Since processingErrors are fail-closed, an unsupported or invalid response check can withhold every otherwise-allowed answer even though output checking is described as optional. Default this to false and require explicit opt-in.
        _checkOutput = EnvBool("PURVIEW_CHECK_OUTPUT", true);

plugins/agent365/skills/purview-dlp-integration/assets/purview.py:95

  • int(...) runs while the module is imported, so a malformed PURVIEW_TIMEOUT_MS crashes the entire Python agent before it can start or fall back to the documented safe default. Parse the value defensively and use the default for non-numeric or non-positive input, matching the Node.js and .NET guards.
        self.timeout_ms = int(os.getenv("PURVIEW_TIMEOUT_MS") or 2000)

plugins/agent365/skills/purview-dlp-integration/assets/purview.py:85

  • The Python loader has two discovery problems: setdefault keeps empty values from the generated file, preventing fallback to a365.config.json, and it never reads that file's repository-standard blueprintId field. A partial/generated config can therefore hide valid configuration and make the guard fail closed; retain only non-empty values and include blueprintId in the candidates.
        out.setdefault("appId", j.get("agentBlueprintId") or j.get("botMsaAppId") or j.get("botId"))
        out.setdefault("blueprintId", j.get("agentBlueprintId"))
        out.setdefault("appName", j.get("agentBlueprintDisplayName") or j.get("agentDescription") or j.get("agentIdentityDisplayName"))
        out.setdefault("tenantId", j.get("tenantId"))

plugins/agent365/skills/purview-dlp-integration/assets/purview.py:94

  • The procedure documents DownloadText restriction as unsupported and creates only an UploadText block rule, yet this defaults response checking on for every turn. Since processingErrors are fail-closed, an unsupported or invalid response check can withhold every otherwise-allowed answer even though output checking is described as optional. Default this to false and require explicit opt-in.
        self.check_output = _env_bool("PURVIEW_CHECK_OUTPUT", True)

plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:112

  • The guard's config loader never reads blueprintId, even though a365.config.json uses that field in this repository. When the generated config is absent or incomplete, the documented A365 auto-discovery path leaves appId/blueprintId empty and the guard fails closed; include the blueprintId fallback for the second config file.
      out.appId ??= (j.agentBlueprintId ?? j.botMsaAppId ?? j.botId) as string | undefined;
      out.blueprintId ??= j.agentBlueprintId as string | undefined;

plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:137

  • The procedure documents DownloadText restriction as unsupported and creates only an UploadText block rule, yet this defaults response checking on for every turn. Since processingErrors are fail-closed, an unsupported or invalid response check can withhold every otherwise-allowed answer even though output checking is described as optional. Default this to false and require explicit opt-in.
    this.checkOutput = envBool('PURVIEW_CHECK_OUTPUT', true);

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:98

  • The verification lookup repeats the same unfiltered selection, so it can report MISSING for a principal-specific grant even after the correct AllPrincipals grant was updated. Apply the same consentType -eq 'AllPrincipals' filter here.
$after = (az rest --method GET --url "https://graph.microsoft.com/v1.0/servicePrincipals/$bpSpId/oauth2PermissionGrants" | ConvertFrom-Json).value |
  Where-Object { $_.resourceId -eq $graphSpId } | Select-Object -First 1

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:104

  • The script prints MISSING but still exits successfully when a requested scope is absent after the grant operation. That lets the skill continue as if permission setup succeeded, while the guard will fail closed on every token request. Make verification throw or return a non-zero exit code when any scope is missing.
foreach ($s in $Scope) {
  $has = ($after.scope -split '\s+') -contains $s
  Write-Host ("{0,-32} -> {1}" -f $s, ($(if ($has) { 'PRESENT' } else { 'MISSING' })))
}

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:55

  • The documented config discovery includes a365.config.json, whose repository schema stores the blueprint under blueprintId, but this key is not searched. A project with only that config reaches the explicit AppId error despite following the documented A365 setup path; include blueprintId in the key list.
if (-not $AppId) { $AppId = Get-A365Value $ConfigDir @('a365.generated.config.json','a365.config.json') @('agentBlueprintId','botMsaAppId','botId') }

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:36

  • The script's documented contract and all guard code require only Content.Process.User, but the default also appends ProtectionScopes.Compute.User to the agentic delegated grant. That silently broadens consent beyond this integration and contradicts the README's claim that one permission is added; remove the extra scope from the default unless the guard actually uses it and document the reason.
  [string[]] $Scope     = @('Content.Process.User', 'ProtectionScopes.Compute.User'),

plugins/agent365/skills/purview-dlp-integration/scripts/New-AiAppDlpPolicy.ps1:65

  • The documented config discovery includes a365.config.json, whose repository schema stores the blueprint under blueprintId, but this key is not searched. A project with only that config can create no policy and fails at the explicit AppId check even though it has valid A365 configuration; include blueprintId in the key list.
if (-not $AppId)   { $AppId   = Get-A365Value $ConfigDir @('a365.generated.config.json','a365.config.json') @('agentBlueprintId','botMsaAppId','botId') }

plugins/agent365/skills/purview-dlp-integration/scripts/New-AiAppDlpPolicy.ps1:128

  • This idempotency shortcut accepts any existing rule with this name without verifying the required SIT condition or RestrictAccess=Block on UploadText. A pre-existing or partially created rule can make the script claim success while the agent remains unprotected; validate or reconcile the existing rule before skipping creation.
if (Get-DlpComplianceRule -Identity $RuleName -ErrorAction SilentlyContinue) {
  Write-Host "Rule already exists: $RuleName"
} else {
  $sit = @($SensitiveInfoType | ForEach-Object { @{ Name = $_ } })
  $ruleParams = @{

tests/validate-purview-dlp-integration.test.js:54

  • The PR description says this validator has eight unit tests, but this file currently defines seven test(...) cases. Add the missing case or correct the description so the validation claim matches the committed test suite.
describe('validate-purview-dlp-integration', () => {
  • Files reviewed: 25/25 changed files
  • Comments generated: 4
  • Review effort level: Lite

Comment thread plugins/agent365/skills/purview-dlp-integration/SKILL.md Outdated
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts Outdated
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.cs Outdated
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.py
Copilot AI review requested due to automatic review settings September 11, 2026 14:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate issues affect fail-closed enforcement, identity handling, timeouts, policy provisioning, and validation behavior.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (28)

Previously missed (12) — in code that hasn't changed since the last review.

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:95

  • The S2S variant has the same mismatch: it enables output evaluation by default although the policy guidance says DownloadText blocking is unsupported. This adds unnecessary per-response latency and can fail closed on every reply if the endpoint rejects output evaluation; default to false and require explicit opt-in.
    plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:205
  • When PURVIEW_APP_ID is overridden, this writes the policy-scope app ID into aiAgentInfo.blueprintId. Those are separate fields, so the supported override sends a non-blueprint identifier as the blueprint metadata and can misattribute or reject the request. Keep the blueprint ID separate and use appId only for protectedAppMetadata.applicationLocation.
    plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:78
  • This .NET guard defaults the optional output path on even though the skill documents that DownloadText blocking is unsupported. Every reply will therefore make an extra Graph call and can be withheld by fail-closed error handling; use false as the default and require explicit opt-in.
    plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:194
  • _appId is the configured application/blueprint ID, not the per-turn agent instance ID. This causes every .NET request to identify the blueprint even though the repository's A365 contract requires GetAgenticInstanceId() for agentic turns and Utility.ResolveAgentIdentity(turnContext, token) for OBO turns; resolve that runtime identity before building the request.
    plugins/agent365/skills/purview-dlp-integration/assets/purview.py:97
  • The documented workload only supports blocking UploadText, yet this guard enables the DownloadText check by default. That adds a second call on every turn and, under fail-closed error handling, can suppress all replies when output processing is unsupported; default this option to false and require explicit opt-in.
    plugins/agent365/skills/purview-dlp-integration/assets/purview.py:98
  • An invalid PURVIEW_TIMEOUT_MS value raises during module import, before the agent can start and before the guard's fail-closed path is available. Match the other implementations by catching parse errors and falling back to the 2000 ms default (also reject non-positive values).
    plugins/agent365/skills/purview-dlp-integration/assets/purview.py:162
  • A new httpx.AsyncClient is created and closed for every DLP evaluation. Since this runs for both input and optional output on every turn, the agent loses connection pooling and repeats TCP/TLS setup, adding avoidable latency and load; keep a client on the guard instance and close it during application shutdown.
    plugins/agent365/skills/purview-dlp-integration/assets/purview.py:225
  • When recipient.agentic_app_id is absent on a non-agentic/OBO or Playground turn, this fallback sends the configured app/blueprint ID as aiAgentInfo.identifier. Purview then receives the wrong runtime identity; resolve the per-turn identity from the activity or acquired token and pass it into _build_body instead of using self.app_id as a fallback.
    plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:137
  • Unlike the delegated guards, this path defaults output checking on, but the skill's policy guidance says DownloadText blocking is unsupported and describes output checking as optional. This makes every response incur an unsupported second Graph evaluation and can withhold all responses on errors; default to false and require explicit opt-in for output auditing.
    plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:297
  • recipient.agenticAppId is not guaranteed on non-agentic/OBO or Playground turns, so this fallback sends the configured blueprint/client app ID as aiAgentInfo.identifier. That misattributes the Purview record to the blueprint instead of the runtime agent identity; resolve the ID from GetAgenticInstanceId() or the acquired token and pass it into buildBody rather than falling back to this.appId.
    plugins/agent365/skills/purview-dlp-integration/scripts/New-AiAppDlpPolicy.ps1:109
  • $AppName is interpolated directly into a JSON string. A valid display name containing a quote or backslash produces malformed -Locations JSON and prevents policy creation; build the location as a PowerShell object and serialize it with ConvertTo-Json instead of manual escaping.
    plugins/agent365/skills/purview-dlp-integration/scripts/New-AiAppDlpPolicy.ps1:117
  • The idempotency check reuses any policy with the requested name without verifying that its Applications location contains $AppId or that it uses the Application enforcement plane. A same-named policy for another agent will therefore be reported as ready while this agent remains uncovered; validate the existing policy's scope before reusing it or generate a unique name.

plugins/agent365/hooks/stop/validate-purview-dlp-integration.js:72

  • The validator accepts on as a truthy value in the guards, but this regex does not. A valid configuration such as PURVIEW_DLP_ENABLED=on will therefore produce a false purview-env-flag-missing finding; include on in the accepted values (and keep the parser consistent with the guard).
  const truthy = new RegExp(`^\\s*${name}\\s*=\\s*(true|1|yes)\\s*$`, 'im');

plugins/agent365/hooks/stop/validate-purview-dlp-integration.js:137

  • appSettingsFiles is collected but never consulted, so a .NET agent that correctly configures PURVIEW_DLP_ENABLED in appsettings.json or launchSettings.json still receives purview-env-flag-missing-dotnet. That contradicts the validator's own message and creates a false advisory finding; inspect the documented .NET settings sources before reporting the flag missing.
  if (!envHasAny('PURVIEW_DLP_ENABLED')) {
    if (isDotnet) {
      add(
        'medium',
        'purview-env-flag-missing-dotnet',

plugins/agent365/skills/purview-dlp-integration/SKILL.md:170

  • This skill explicitly allows proceeding in a project without .a365-workspace-detection.local.json, but repository guidance requires every code-editing skill to perform the cache triage and route through a365-setup before copying a guard or modifying a handler. Add the Phase 0A hard-stop/a365-setup path instead of proceeding directly from config discovery.
> 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.

plugins/agent365/skills/purview-dlp-integration/SKILL.md:179

  • Before the first code-editing procedure, this skill has no visible TODO checklist or task creation/update flow. Repository skill conventions require every skill to show its task list before Phase 1 work and mark phases complete as they finish; add that checklist before the detection/edit steps.
## Procedure

### 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.

plugins/agent365/skills/purview-dlp-integration/SKILL.md:94

  • This flow says the output gate can block/withhold a response, but the policy this skill creates only has RestrictAccess on UploadText and the procedure explicitly says DownloadText restriction is unsupported. With the generated policy, evaluateResponse can audit but will never return a block; clarify the output path as audit-only or provide a supported output-policy path.
                                                            └─ allow ─► LLM ─► [OUTPUT gate] processContent(downloadText) ─► block? ─► withhold

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:214

  • The S2S path sends the full prompt/response and hard-codes isTruncated: false, while the delegated guards cap content at MAX_CONTENT_CHARS. Large agent messages can exceed the Graph payload limit and fail closed unnecessarily; apply the same bounded truncation and set isTruncated from the actual request data.
            content: { "@odata.type": "microsoft.graph.textContent", data: text },
            agents: [
              {
                "@odata.type": "microsoft.graph.aiAgentInfo",
                blueprintId: this.appId,
                identifier: this.agentId,
                name: this.agentName,
                version: "1.0",
              },
            ],
            name: `${this.agentName} ${activity}`, // REQUIRED non-empty name
            correlationId: randomUUID(),
            sequenceNumber: activity === "uploadText" ? 0 : 1,
            isTruncated: false,

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:145

  • Both FMI token requests run before the request-level abort timer is created at line 233. If either token endpoint hangs, PURVIEW_TIMEOUT_MS does not bound the DLP check and the autonomous worker can remain stuck before reaching Graph; apply one deadline/signal to all three token and Graph requests.
    const r12 = await fetch(authority, {

plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:120

  • PURVIEW_TIMEOUT_MS is documented as a per-call hard timeout, but GetTokenAsync runs before the linked cancellation source is created. A hung auth/token exchange can therefore hold the turn indefinitely; create the deadline before token acquisition and pass it through the whole operation.
            var token = await GetTokenAsync(authorization, authHandlerName, turnContext, ct);

plugins/agent365/skills/purview-dlp-integration/assets/purview.py:87

  • setdefault stores None when the generated config has no display-name fields (the normal case), so the later a365.config.json pass cannot populate appName; the guard then silently uses AI Agent despite the documented auto-discovery. Assign the first non-empty value instead of treating None as final.
        out.setdefault("appName", j.get("agentBlueprintDisplayName") or j.get("agentDescription") or j.get("agentIdentityDisplayName"))

plugins/agent365/skills/purview-dlp-integration/assets/purview.py:160

  • PURVIEW_TIMEOUT_MS is documented as a per-call hard timeout, but token acquisition occurs before the HTTP client's timeout is applied. A hung auth/token exchange can therefore hold the turn indefinitely; wrap token acquisition and the Graph request in one deadline or pass a cancellation signal.
            token = await self._get_token(authorization, auth_handler_name, context)

plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:145

  • Because ?? treats an empty string as a value, a common PURVIEW_APP_ID= environment entry overrides the discovered A365 config and leaves appId empty, causing every enabled turn to fail closed. Ignore blank/whitespace overrides when selecting the configured app id, as the Python and .NET guards already do.
      process.env.PURVIEW_APP_ID ??

plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:213

  • PURVIEW_TIMEOUT_MS is documented as a per-call hard timeout, but token acquisition occurs before postWithTimeout creates its AbortController. A hung auth/token exchange can therefore hold the turn indefinitely; wrap token acquisition and the Graph request in one deadline or pass a cancellation signal.
      const token = await this.getToken(authorization, ctx);

plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:235

  • postWithTimeout clears its timer as soon as fetch() resolves, before res.json() consumes the response body. A server that sends headers and then stalls can therefore hang this fail-closed gate indefinitely; keep the abort signal active through body parsing or use a whole-operation deadline.
      const json = (await res.json()) as ProcessContentResponse;

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:36

  • The skill and reference docs say this script grants only Content.Process.User, but the default also requests ProtectionScopes.Compute.User. That extra delegated permission is not needed for the documented processContent call and can cause unnecessary consent or violate least privilege; keep the default to the single required scope.
  [string[]] $Scope     = @('Content.Process.User', 'ProtectionScopes.Compute.User'),

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:69

  • The script says it is finding the existing agentic AllPrincipals grant, but this filter checks only the Graph resource. If a user/principal grant is returned first, the PATCH adds the DLP scopes to the wrong consent and leaves the agentic grant unchanged. Filter the selection by consentType -eq 'AllPrincipals'.
# Find the existing delegated (AllPrincipals) grant to Microsoft Graph.
$grants = (az rest --method GET --url "https://graph.microsoft.com/v1.0/servicePrincipals/$bpSpId/oauth2PermissionGrants" | ConvertFrom-Json).value
$g = $grants | Where-Object { $_.resourceId -eq $graphSpId } | Select-Object -First 1

tests/validate-purview-dlp-integration.test.js:55

  • The PR description says this change adds eight unit tests, but this file defines seven test(...) cases. Please either add the missing scenario or correct the validation summary so the PR metadata matches the submitted tests.
describe('validate-purview-dlp-integration', () => {
  test('always returns ok:true (report-first)', () => {
  • Files reviewed: 25/25 changed files
  • Comments generated: 6
  • Review effort level: Lite

Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.cs Outdated
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.cs Outdated
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.py Outdated
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.py Outdated
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.ts Outdated
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.ts Outdated
Copilot AI review requested due to automatic review settings September 14, 2026 12:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved implementation issues affect S2S identity binding, configuration discovery, validation, and permission automation.

Review details

Suppressed comments (24)

Previously missed (1) — in code that hasn't changed since the last review.

plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:196

  • The comment says to resolve the per-instance identity with GetAgenticInstanceId(), but the implementation always assigns _appId instead. This sends a configuration/blueprint value as aiAgentInfo.identifier for every .NET request, so runtime identity binding is lost; resolve turnContext.Activity.GetAgenticInstanceId() and fail closed if it is missing.

.github/copilot-instructions.md:326

  • This Copilot-facing summary omits the S2S branch described in SKILL.md: it always selects the delegated purview.ts path, Content.Process.User, and /me, while autonomous S2S agents require purview-s2s.ts, the FMI chain, and Content.Process.All. A Copilot invocation for an S2S agent can therefore follow this summary and wire a guard that cannot authenticate. Add the S2S branch here and keep the duplicated AGENTS.md/CLAUDE.md summaries consistent.
2. Detects the agent language and picks the matching guard + wiring: Node.js → `assets/purview.ts`, Python → `assets/purview.py`, .NET → `assets/purview.cs` (best-effort port). All three share the same env vars, PowerShell scripts, DLP policy, and `[purview]` log format.
3. Copies ONE generic, env-driven guard file into the agent source (no edits needed).
4. Wires two gates into the message handler: the **INPUT gate** calls `processContent(uploadText)` before the LLM (blocks the turn → the LLM is never called), and the optional **OUTPUT gate** calls `processContent(downloadText)` before the reply. Passes the agent's own **agentic delegated** auth handler + handler name + turn context — evaluated as `/me` (the agent identity).
5. Appends env vars to `.env` — `PURVIEW_DLP_ENABLED` is the only required key on an A365 project (app id / display name / blueprint id are auto-read from the a365 config).
6. Guides the delegated scope grant via `scripts/Grant-DelegatedGraphScope.ps1` — **appends** `Content.Process.User` to the agent's agentic consent (never `az ad app permission admin-consent` on the blueprint app).

README.md:296

  • This description is now incomplete for the S2S path added by this PR: purview-s2s.ts uses the agent identity's FMI app-only token and /users/{sponsor}/... with Content.Process.All, rather than a delegated /me token. Distinguish the AgentApplication and S2S variants here so users are not directed to the wrong guard and permission flow.
gate wiring. The guard uses the agent's own **agentic delegated** token evaluated as `/me` (never
app-only client credentials), always sets `contentEntry.name`, and fails closed by default.

plugins/agent365/hooks/stop/validate-purview-dlp-integration.js:103

  • wiredFiles is considered non-empty when any non-guard file merely mentions purviewGuard / purview_guard / PurviewGuard. An unused import or a diagnostic reference therefore suppresses purview-guard-not-wired even when no evaluatePrompt/evaluateResponse gate exists or the gate is after the LLM call. Inspect the language-specific gate calls and ordering rather than treating a symbol mention as proof of wiring.
const wiredFiles = codeFiles.filter(f => {
  if (guardFiles.includes(f)) return false;
  const c = read(f);
  return GUARD_SYMBOL.test(c);
});

plugins/agent365/hooks/stop/validate-purview-dlp-integration.js:46

  • launchSettings.json is an explicitly supported .NET location for PURVIEW_DLP_ENABLED, but it is neither included in envFiles nor read here; appSettingsFiles is also never used. A project using the documented launch profile will still receive purview-env-flag-missing-dotnet on every run. Parse the launch profile's environment variables or suppress this finding when that supported location contains the key.
const envFiles = filterByName(allFiles, '.env', '.env.local', '.env.production', '.env.development');
const appSettingsFiles = filterByName(allFiles, 'appsettings.json', 'appsettings.Development.json', 'appsettings.Production.json');

plugins/agent365/skills/purview-dlp-integration/README.md:140

  • The user-facing file still says every integration adds Content.Process.User, but the S2S variant introduced here requires Content.Process.All assigned to the agent-identity service principal. Please qualify this row for delegated AgentApplication versus Node.js S2S agents so the setup guidance does not grant the wrong permission.
| **1 Microsoft Graph permission** | `Content.Process.User`, added to your agent's existing permissions (nothing broadened). |

plugins/agent365/skills/purview-dlp-integration/SKILL.md:176

  • The no-config path asks for a tenant ID and says to pass it to the scripts/env, but the delegated guards do not consume tenantId and neither PowerShell script accepts or verifies a tenant. Both scripts operate on whichever tenant the current Azure CLI/IPPSSession targets, so a user can grant consent or create the policy in a different tenant than the one they entered. Make tenant selection explicit and verify it before running the scripts, or remove this unsupported input path.
> 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.

plugins/agent365/skills/purview-dlp-integration/SKILL.md:184

  • This procedure starts code edits without the repository-required Phase 0A cache triage. AGENTS.md:324-332 requires every skill to create and load .a365-workspace-detection.local.json before editing, but this skill neither checks nor writes it and its stop hook does not enforce that requirement. Add the cache guard/write before Step 0 or route this workflow through a365-setup.
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.

plugins/agent365/skills/purview-dlp-integration/SKILL.md:158

  • The discovery table only documents agentBlueprintId in a365.generated.config.json, but reuse projects use blueprintId in a365.config.json (validate-make-a365-agent.js:66-69). This makes the documented auto-discovery incomplete and leads users to supply an unnecessary manual override. Document both fields and their file-specific names.
| App (client) id → `PURVIEW_APP_ID` | `a365.generated.config.json` → `agentBlueprintId` (or `botMsaAppId`) |
| Blueprint id → `PURVIEW_BLUEPRINT_ID` | `a365.generated.config.json` → `agentBlueprintId` |

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:100

  • The canonical S2S configuration keeps AGENT365_CLIENT_ID (the blueprint client credential used for FMI Hop 1) separate from AGENT365_BLUEPRINT_ID (the blueprint identifier). Assigning the former to aiAgentInfo.blueprintId sends the client ID in the blueprint field whenever the optional blueprint env var is absent, so Purview cannot associate the request with the intended blueprint. Read AGENT365_BLUEPRINT_ID / agent365Observability__agentBlueprintId for this field and fail closed if it is missing.
    this.blueprintId = process.env.AGENT365_CLIENT_ID || process.env.agent365Observability__clientId || "";

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:105

  • appId is emitted as protectedAppMetadata.applicationLocation, which must be the Entra application/client ID used to scope the DLP policy. The canonical S2S settings keep that value in AGENT365_CLIENT_ID; this fallback instead uses AGENT365_BLUEPRINT_ID, so a default-configured S2S agent sends the blueprint identifier and no policy matches. Default this field from AGENT365_CLIENT_ID (or the lowercase client-id setting), leaving the blueprint ID only in aiAgentInfo.blueprintId.
    const blueprintIdForPolicyScope = process.env.AGENT365_BLUEPRINT_ID ?? process.env.agent365Observability__agentBlueprintId;
    this.appId = process.env.PURVIEW_APP_ID ?? blueprintIdForPolicyScope ?? this.blueprintId;

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:221

  • Every S2S request is recorded with ipAddress: 127.0.0.1 and Linux/unknown metadata, even when the worker is deployed elsewhere. That makes the Purview audit record inaccurate for the autonomous-agent scenario this guard is intended to support. Omit deviceMetadata if it is optional, or derive actual host metadata instead of emitting a fixed loopback address.
        deviceMetadata: {
          operatingSystemSpecifications: { operatingSystemPlatform: "Linux", operatingSystemVersion: "unknown" },
          ipAddress: "127.0.0.1",
        },

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:102

  • These fallbacks are not valid A365 S2S identity values: agent365Observability__agentId is the blueprint ID, while this field is used as fmi_path/the Hop 3 client; agent365Observability__clientId and __clientSecret are not written by the CLI. A normal project using the documented environment therefore sends the blueprint ID as the target agent and the token exchange fails or targets the wrong principal. Use the canonical AGENT365_* variables here (or fail fast when they are missing).
    this.blueprintId = process.env.AGENT365_CLIENT_ID || process.env.agent365Observability__clientId || "";
    this.agentId = process.env.AGENT365_AGENT_ID || process.env.agent365Observability__agentId || "";
    this.clientSecret = process.env.AGENT365_CLIENT_SECRET || process.env.agent365Observability__clientSecret || "";

plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:274

  • Reuse-mode projects can contain only a365.config.json, whose schema uses blueprintId (as documented by validate-make-a365-agent.js:66-69). This loader checks only agentBlueprintId/bot*, so _appId falls through to an empty value and an enabled guard fails closed even with a valid config. Include blueprintId in both fallbacks.
                outp["appId"] = outp.GetValueOrDefault("appId") ?? Str(r, "agentBlueprintId") ?? Str(r, "botMsaAppId") ?? Str(r, "botId");
                outp["blueprintId"] = outp.GetValueOrDefault("blueprintId") ?? Str(r, "agentBlueprintId");

plugins/agent365/skills/purview-dlp-integration/assets/purview.py:240

  • This fallback has the same identity-binding problem as the TypeScript guard: when the activity has no agentic_app_id, the request identifies the agent with self.app_id from configuration. Use the runtime Agent Identity from the recipient and fail closed when it is unavailable; do not send a blueprint/config ID as aiAgentInfo.identifier.
        agent_id = getattr(recipient, "agentic_app_id", None) or self.app_id

plugins/agent365/skills/purview-dlp-integration/assets/purview.py:89

  • Reuse-mode projects can contain only a365.config.json, whose schema uses blueprintId (as documented by validate-make-a365-agent.js:66-69). This loader checks only agentBlueprintId/bot*, so appId and blueprintId remain empty and an enabled guard fails closed with missing PURVIEW_APP_ID despite having a valid config. Include blueprintId in both fallbacks.
        out["appId"] = out.get("appId") or j.get("agentBlueprintId") or j.get("botMsaAppId") or j.get("botId")
        out["blueprintId"] = out.get("blueprintId") or j.get("agentBlueprintId")

plugins/agent365/skills/purview-dlp-integration/assets/purview.py:216

  • Creating and closing an AsyncClient for every input/output check defeats HTTP connection pooling; with output checks enabled each turn pays for two new client/transport setups and creates avoidable socket churn. Keep a shared AsyncClient for the guard singleton (with an explicit shutdown path) or otherwise reuse a transport.
        async with httpx.AsyncClient(timeout=self.timeout_ms / 1000.0) as client:

plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:298

  • When recipient.agenticAppId is unavailable, this falls back to this.appId, which is configuration-derived rather than the runtime Agent Identity. That makes aiAgentInfo.identifier contain the blueprint/application value and breaks the identity binding for requests that do not carry the recipient field; resolve the runtime ID from the activity and fail closed if it is absent instead of substituting config.
    const agentId = (recipient?.agenticAppId as string) ?? this.appId;

plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:112

  • Reuse-mode projects can contain only a365.config.json, whose schema uses blueprintId (as documented by validate-make-a365-agent.js:66-69). This loader checks only agentBlueprintId/bot*, so appId and blueprintId remain empty and an enabled guard fails closed with missing PURVIEW_APP_ID despite having a valid config. Include blueprintId in both fallbacks.
      out.appId ??= (j.agentBlueprintId ?? j.botMsaAppId ?? j.botId) as string | undefined;
      out.blueprintId ??= j.agentBlueprintId as string | undefined;

plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:279

  • timer is explicitly typed as ReturnType<typeof setTimeout> | undefined above. With the Node TypeScript typings used by strict customer projects, passing that union directly to clearTimeout does not match the overload, so copying this guard can fail tsc before it runs. Guard the cleanup when the timer was created.
      clearTimeout(timer);

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-ContentProcessAppRole.ps1:69

  • az rest uses --url for the request URI (the delegated-scope script in this same skill uses that spelling); --uri is not a valid Azure CLI option. The S2S permission script therefore fails before it can inspect the existing assignment. Replace this option with --url (and make the same correction on the POST below).
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$agentSpId/appRoleAssignments" `

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-ContentProcessAppRole.ps1:84

  • This POST repeats the invalid --uri option, so even after the read path is fixed the role assignment cannot be created. Use Azure CLI's --url parameter here as well.
    --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$agentSpId/appRoleAssignments" `

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:55

  • The consent script also omits blueprintId, even though that is the ID field in a365.config.json. Running the documented command from a reuse-mode project therefore fails discovery before it can grant Content.Process.User; include the same fallback used by the other A365 readers.
if (-not $AppId) { $AppId = Get-A365Value $ConfigDir @('a365.generated.config.json','a365.config.json') @('agentBlueprintId','botMsaAppId','botId') }

plugins/agent365/skills/purview-dlp-integration/scripts/New-AiAppDlpPolicy.ps1:65

  • The policy script has the same reuse-config gap: a365.config.json stores the blueprint under blueprintId, but this lookup omits that key. In a reuse-mode project the documented no-argument command therefore throws AppId not provided and cannot create or list the policy. Add blueprintId to the discovery keys.
if (-not $AppId)   { $AppId   = Get-A365Value $ConfigDir @('a365.generated.config.json','a365.config.json') @('agentBlueprintId','botMsaAppId','botId') }
  • Files reviewed: 25/25 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 14, 2026 12:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate findings affect permission scripts, identity handling, configuration discovery, and validation.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (19)

Previously missed (7) — in code that hasn't changed since the last review.

plugins/agent365/hooks/stop/validate-purview-dlp-integration.js:103

  • The scanner treats any non-guard file containing the guard symbol as wired, including a file that only imports purviewGuard and never calls an evaluation method. That produces a clean report for an actually unprotected handler; require an evaluatePrompt/evaluateResponse (language-appropriate) call and add a regression fixture for import-only code.
    plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:100
  • AGENT365_CLIENT_ID is the blueprint client credential used for the FMI request, but this property is later serialized as agents[0].blueprintId (line 203). The repository keeps AGENT365_BLUEPRINT_ID (the blueprint identifier) distinct from AGENT365_CLIENT_ID; when those values differ, Purview receives the wrong blueprint metadata. Keep a separate client-ID property for Hop 1+2 and populate this metadata field from AGENT365_BLUEPRINT_ID.

This issue also appears on line 101 of the same file.
plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:273

  • This loader does not read blueprintId, the field used by a365.config.json; only agentBlueprintId from the generated config is handled. When the generated file is absent, a valid config-only .NET agent falls back to an empty app/blueprint ID and cannot make a valid Purview request.
    plugins/agent365/skills/purview-dlp-integration/assets/purview.py:89
  • The loader omits blueprintId, which is the field used by a365.config.json; it only understands agentBlueprintId from the generated config. With a valid config-only project, the guard cannot discover the policy app/blueprint IDs and every enabled check fails closed.
    plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:112
  • This discovery path never reads blueprintId, even though a365.config.json uses that field (the generated file uses agentBlueprintId). A project with only a365.config.json therefore loses both the app ID and blueprint ID and the enabled guard fails closed instead of using the documented auto-discovery. Include the config-file field in both fallbacks.
    plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:55
  • The auto-discovery key list omits blueprintId, although that is the supported field in a365.config.json. As a result, this permission script throws AppId not provided for config-only projects unless the operator manually supplies an ID.
    plugins/agent365/skills/purview-dlp-integration/scripts/New-AiAppDlpPolicy.ps1:65
  • This policy script has the same config-discovery gap: a365.config.json stores the blueprint under blueprintId, but the lookup only checks agentBlueprintId and bot IDs. It therefore fails to create a policy automatically for a valid config-only A365 project.

plugins/agent365/skills/purview-dlp-integration/README.md:152

  • This absolute privacy statement is misleading: the DLP check goes to Microsoft Purview, but allowed text still follows the existing agent's LLM/model-provider path and may go to a third party. Clarify that only the DLP check is sent to Microsoft so users do not infer that this integration controls the agent's model destination.
- It does **not** change your AI's answers — it only allows or blocks a turn.
- It does **not** send your data to any third party — the check goes to **Microsoft
  Purview** through Microsoft Graph.

plugins/agent365/skills/purview-dlp-integration/SKILL.md:184

  • This mutating skill goes straight from the checklist into code discovery and editing without the required workspace-cache preflight. The repository convention in CLAUDE.md:88-94 requires .a365-workspace-detection.local.json before any code-edit phase; add a Phase 0A that invokes a365-setup/writes the cache and hard-stops before copying or wiring when it is absent.
## 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.

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:101

  • agent365Observability__agentId is the blueprint ID stamped by this repository, not the runtime agent identity. Falling back to it makes the FMI fmi_path/Hop 3 client and aiAgentInfo.identifier target the blueprint whenever AGENT365_AGENT_ID is absent, so S2S authentication and attribution are wrong. Require the canonical AGENT365_AGENT_ID instead of using this fallback.
    this.agentId = process.env.AGENT365_AGENT_ID || process.env.agent365Observability__agentId || "";

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:105

  • When AGENT365_BLUEPRINT_ID is omitted, this fallback uses this.blueprintId, which is actually AGENT365_CLIENT_ID (the blueprint client credential). That value is then used as the DLP policy's applicationLocation, so the guard can scope policy evaluation to the wrong app. Resolve the blueprint/app ID from AGENT365_BLUEPRINT_ID or the repository's blueprint metadata and fail before requests if it is unavailable; never substitute the client credential.
    const blueprintIdForPolicyScope = process.env.AGENT365_BLUEPRINT_ID ?? process.env.agent365Observability__agentBlueprintId;
    this.appId = process.env.PURVIEW_APP_ID ?? blueprintIdForPolicyScope ?? this.blueprintId;

plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:113

  • AGENT365_CLIENT_SECRET is required by the FMI form post below, but it is omitted from this completeness check. A missing secret therefore produces no startup warning and causes a doomed token request on every turn before failing closed; include !this.clientSecret and name it in the warning.
    if (this.enabled && (!this.tenantId || !this.agentId || !this.blueprintId || !this.sponsorUserId)) {
      console.warn(
        "[purview] PURVIEW_DLP_ENABLED=true but S2S config incomplete (need tenantId, agentId, " +
          "blueprintId, sponsorUserId). Every turn will fail-closed.",

plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:195

  • This fallback has the same identity mix-up as the other guards: GetAgenticInstanceId() is the runtime identity, while _appId is the blueprint/policy app ID. Using _appId when no instance ID is present sends blueprint metadata as aiAgentInfo.identifier; resolve the runtime identity or fail closed instead.
        var agentId = FirstNonEmpty(turnContext?.Activity?.GetAgenticInstanceId()) ?? _appId;

plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:168

  • Because the cancellation token is linked into cts, this catches both the guard's own timeout and cancellation of the host request, converting cancellation into an error verdict. The handler may then try to send a block/withhold activity on an already-cancelled turn; only handle the internal timeout and rethrow caller cancellation.
        catch (OperationCanceledException)

plugins/agent365/skills/purview-dlp-integration/assets/purview.py:240

  • If recipient.agentic_app_id is absent, the guard uses self.app_id as agents[0].identifier. That is the blueprint/policy app ID, not the runtime agent identity required by the A365 metadata contract, so OBO/non-agentic turns can be misattributed or rejected. Resolve the runtime identity or fail closed instead of using this fallback.
        agent_id = getattr(recipient, "agentic_app_id", None) or self.app_id

plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:298

  • When an inbound activity has no recipient.agenticAppId, this substitutes the blueprint/policy app ID for the runtime agent identifier. A365 keeps the runtime AUID and blueprint ID separate; sending the blueprint here misattributes OBO/non-agentic turns and can make Purview reject or misapply the agent metadata. Resolve a runtime identity for the turn or fail closed rather than falling back to this.appId.
    const agentId = (recipient?.agenticAppId as string) ?? this.appId;

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-ContentProcessAppRole.ps1:86

  • The create call repeats the invalid --uri option. Even after fixing the lookup above, Azure CLI will reject this POST instead of assigning Content.Process.All; use the supported --url option here as well.
  $response = az rest --method POST `
    --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$agentSpId/appRoleAssignments" `
    --headers "Content-Type=application/json" `
    --body "@$tmp" -o json

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-DelegatedGraphScope.ps1:85

  • If no existing Graph AllPrincipals grant is found, this path creates a grant containing only Content.Process.User, despite the script's stated contract of appending to the agent's existing agentic consent. Running it before A365 setup/admin consent can leave the app with a partial grant and break agentic authentication. For the supported flow, fail and direct the user to complete setup instead of creating a partial grant.
if (-not $grant) {
  Write-Host "No existing Graph delegated grant found — creating a new AllPrincipals grant."
  $body = @{ clientId = $bpSpId; consentType = 'AllPrincipals'; resourceId = $graphSpId; scope = ($Scope -join ' ') } | ConvertTo-Json -Compress
  $method = 'POST'
  $url = 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants'

tests/validate-purview-dlp-integration.test.js:55

  • The PR description says this change adds eight unit tests, but this file defines seven test(...) cases. Please add the missing case or correct the validation summary so the stated coverage is accurate.
describe('validate-purview-dlp-integration', () => {
  test('always returns ok:true (report-first)', () => {
  • Files reviewed: 25/25 changed files
  • Comments generated: 2
  • Review effort level: Lite

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: dbezic <50911161+dbezic@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 15, 2026 10:07
Fix incorrect usage of az rest command by removing duplicate --uri option.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: dbezic <50911161+dbezic@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate findings affect configuration discovery, identity handling, validation, and S2S setup.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (12)

Previously missed (3) — in code that hasn't changed since the last review.

plugins/agent365/hooks/stop/validate-purview-dlp-integration.js:139

  • The validator declares appSettingsFiles but never consults it, and it does not scan Properties/launchSettings.json. Consequently a .NET agent configured through the documented launch-settings profile still receives purview-env-flag-missing-dotnet; the diagnostic is not checking the supported static configuration source. Either inspect launchSettings consistently or make the finding explicitly limited to projects where no supported configuration source is present.
    plugins/agent365/skills/purview-dlp-integration/SKILL.md:193
  • This skill edits an existing agent but starts directly at the checklist/detection steps without loading or enforcing .a365-workspace-detection.local.json. The other code-edit skills require that cache before mutations so language/authentication routing and reruns are stateful; this skill's always-true validator also cannot catch a skipped triage. Add the same workspace-triage/cache guard before Step 0, or route through setup before allowing source edits.
    plugins/agent365/skills/purview-dlp-integration/assets/purview-s2s.ts:114
  • This variant explicitly requires a client-secret FMI configuration, but the startup completeness check does not include this.clientSecret. A missing AGENT365_CLIENT_SECRET therefore produces no warning and only fails later on every token request, making configuration troubleshooting unnecessarily opaque. Include the secret in the predicate and diagnostic text.

AGENTS.md:104

  • The contributor-tree entry omits the newly added Grant-ContentProcessAppRole.ps1, even though the skill now documents and depends on it for S2S. This leaves the repository inventory incomplete; include the S2S grant helper in the listed scripts.
│   │   └── scripts/             # Grant-DelegatedGraphScope.ps1, New-AiAppDlpPolicy.ps1

README.md:140

  • This summary lists only the delegated Content.Process.User permission, but the same PR documents an S2S path that requires the Content.Process.All application role on the agent identity. A user following this table for the S2S variant could run the wrong grant script; distinguish the delegated scope from the S2S app role here.
    plugins/agent365/skills/purview-dlp-integration/README.md:141
  • This requirements table is written as universal, but the skill also supports the S2S variant, which requires Content.Process.All on the agent-identity service principal and Grant-ContentProcessAppRole.ps1, not delegated Content.Process.User. As written, an S2S user following this README is directed toward the wrong permission and may wire the delegated path; scope these rows to delegated agents or add the S2S alternative.
| Change | What it is |
|---|---|
| **1 new file** | The DLP guard — a drop-in file, no editing needed. |
| **A few lines in your message handler** | The two checkpoints (before the AI, and optionally before the reply). |
| **A few settings** | Mainly a single on/off switch: `PURVIEW_DLP_ENABLED`. |
| **1 Microsoft Graph permission** | `Content.Process.User`, added to your agent's existing permissions (nothing broadened). |
| **1 Purview DLP policy** | Created for you, or reuse one you already have, or skip and add it later. |

plugins/agent365/skills/purview-dlp-integration/SKILL.md:200

  • The documented S2S variant explicitly has no @microsoft/agents-hosting AgentApplication, but this is the only Node.js detection rule. A worker with the documented canonical AGENT365_* credentials can therefore be missed or rejected before purview-s2s.ts is selected. Add an explicit Node.js S2S detection/selection branch separate from the delegated hosting check.
| **Node.js / TypeScript** | `package.json` (`@microsoft/agents-hosting`) | `assets/purview.ts` | `assets/wiring-snippet.ts` |

plugins/agent365/skills/purview-dlp-integration/assets/purview.cs:195

  • When GetAgenticInstanceId() returns no value (for example on a non-agentic/OBO turn), this substitutes the configured app/blueprint ID for aiAgentInfo.identifier. That is not the runtime agent identity; the repository's A365 contract requires resolving agent IDs from the turn context/token rather than configuration (shared/agent-detection.md:602 and instrument-observability/references/dotnet-observability.md:596-610). This can attribute DLP activity to the wrong identity; use the same runtime resolver or fail rather than sending the blueprint ID as the instance identifier.
        var agentId = FirstNonEmpty(turnContext?.Activity?.GetAgenticInstanceId()) ?? _appId;

plugins/agent365/skills/purview-dlp-integration/assets/purview.py:216

  • This creates a new AsyncClient for every Purview check. The guard is on the per-turn hot path and can make two checks per turn, so each call discards its connection pool and loses HTTP keep-alive/TLS reuse. Keep a shared client on PurviewGuard (and close it with the host lifecycle) or otherwise reuse a client to avoid unnecessary connection and latency overhead.
        async with httpx.AsyncClient(timeout=self.timeout_ms / 1000.0) as client:

plugins/agent365/skills/purview-dlp-integration/assets/purview.py:240

  • When recipient.agentic_app_id is absent (for example on a non-agentic/OBO or Playground turn), this substitutes the configured app/blueprint ID for aiAgentInfo.identifier. That is not the runtime agent identity; the repository's A365 contract requires resolving agent IDs from the turn context/token rather than configuration (shared/agent-detection.md:602 and instrument-observability/references/python-observability.md:434-456). This can attribute DLP activity to the wrong identity; use the same runtime resolver or fail rather than sending the blueprint ID as the instance identifier.
        agent_id = getattr(recipient, "agentic_app_id", None) or self.app_id

plugins/agent365/skills/purview-dlp-integration/assets/purview.ts:298

  • When recipient.agenticAppId is absent (for example on a non-agentic/OBO or Playground turn), this substitutes the configured app/blueprint ID for aiAgentInfo.identifier. That is not the runtime agent identity; the repository's A365 contract requires resolving agent IDs from the turn context/token rather than configuration (shared/agent-detection.md:602 and instrument-observability/references/nodejs-observability.md:168-175). This can attribute DLP activity to the wrong identity; use the same runtime resolver or fail rather than sending the blueprint ID as the instance identifier.
    const agentId = (recipient?.agenticAppId as string) ?? this.appId;

plugins/agent365/skills/purview-dlp-integration/scripts/Grant-ContentProcessAppRole.ps1:84

  • az rest uses --url for the endpoint; --uri is not a supported option for this command. As written, the S2S permission-grant path exits before making the app-role assignment, so the documented setup cannot work.
    --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$agentSpId/appRoleAssignments" `
  • Files reviewed: 25/25 changed files
  • Comments generated: 7
  • Review effort level: Lite

Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.cs
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.py
Comment thread plugins/agent365/skills/purview-dlp-integration/assets/purview.ts
@dbezic
dbezic (dbezic) merged commit 07a0304 into main Sep 15, 2026
15 checks passed
@dbezic
dbezic (dbezic) deleted the users/dominikbezic/add-purview-skill branch September 15, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants