@open-everywhere/provider-teams 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +34 -4
- package/README.md +1 -1
- package/agent/app-manifest.ts +133 -0
- package/agent/channels/teams-engagement.ts +1 -1
- package/agent/channels/teams-front.ts +1 -1
- package/agent/channels/teams-graph-boundary.ts +14 -55
- package/agent/channels/teams-graph-roles.ts +62 -0
- package/agent/channels/teams-read.ts +1 -1
- package/agent/channels/teams.ts +4 -1
- package/agent/docs-generated.ts +1 -1
- package/agent/ops.ts +41 -3
- package/agent/register.ts +2 -2
- package/dist/agent/app-manifest.d.ts +23 -0
- package/dist/agent/app-manifest.d.ts.map +1 -0
- package/dist/agent/app-manifest.js +113 -0
- package/dist/agent/app-manifest.js.map +1 -0
- package/dist/agent/channels/teams-engagement.js +1 -1
- package/dist/agent/channels/teams-engagement.js.map +1 -1
- package/dist/agent/channels/teams-front.js +1 -1
- package/dist/agent/channels/teams-front.js.map +1 -1
- package/dist/agent/channels/teams-graph-boundary.d.ts +2 -22
- package/dist/agent/channels/teams-graph-boundary.d.ts.map +1 -1
- package/dist/agent/channels/teams-graph-boundary.js +8 -51
- package/dist/agent/channels/teams-graph-boundary.js.map +1 -1
- package/dist/agent/channels/teams-graph-roles.d.ts +25 -0
- package/dist/agent/channels/teams-graph-roles.d.ts.map +1 -0
- package/dist/agent/channels/teams-graph-roles.js +58 -0
- package/dist/agent/channels/teams-graph-roles.js.map +1 -0
- package/dist/agent/channels/teams-read.js +1 -1
- package/dist/agent/channels/teams-read.js.map +1 -1
- package/dist/agent/channels/teams.d.ts.map +1 -1
- package/dist/agent/channels/teams.js +4 -1
- package/dist/agent/channels/teams.js.map +1 -1
- package/dist/agent/docs-generated.js +1 -1
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/ops.d.ts +2 -2
- package/dist/agent/ops.d.ts.map +1 -1
- package/dist/agent/ops.js +27 -3
- package/dist/agent/ops.js.map +1 -1
- package/dist/agent/register.js +2 -2
- package/dist/agent/register.js.map +1 -1
- package/package.json +12 -4
package/NOTICE
CHANGED
|
@@ -8,10 +8,40 @@ This product is licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
|
8
8
|
Built on Vercel Eve (https://github.com/vercel/eve), licensed under the
|
|
9
9
|
Apache License, Version 2.0.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
Modifications to eve
|
|
14
|
+
|
|
15
|
+
The @open-everywhere/core package distributes patches/eve+0.71.0.patch. That
|
|
16
|
+
patch file is a modification of eve 0.71.0. Vercel licenses eve 0.71.0 under the
|
|
17
|
+
Apache License, Version 2.0. The copyright notice of eve 0.71.0 reads
|
|
18
|
+
"Copyright 2026 Vercel, Inc. and contributors". The Open Everywhere authors made
|
|
19
|
+
the changes in the patch file.
|
|
20
|
+
|
|
21
|
+
The patch modifies four files of eve 0.71.0:
|
|
22
|
+
|
|
23
|
+
dist/src/execution/web-fetch/request.js
|
|
24
|
+
dist/src/harness/tool-loop.js
|
|
25
|
+
dist/src/internal/authored-package-boundary.js
|
|
26
|
+
dist/src/protocol/message.d.ts
|
|
27
|
+
|
|
28
|
+
Open Everywhere does not ship the eve package. eve is a peer dependency, so the
|
|
29
|
+
consumer installs eve 0.71.0 from the registry. The patch file carries the
|
|
30
|
+
modified portions of eve. The oe-eve-patch command, which
|
|
31
|
+
@open-everywhere/core ships, applies them to the eve that the consumer
|
|
32
|
+
installed. The command patches no file outside the consumer's own project. To
|
|
33
|
+
restore an eve that holds an older Open Everywhere patch, it also writes to npm's
|
|
34
|
+
cache and to a temporary directory.
|
|
35
|
+
|
|
36
|
+
The attribution notice of eve 0.71.0 follows, as section 4(d) of the Apache
|
|
37
|
+
License, Version 2.0 requires. The notices for eve's bundled code-mode runtime
|
|
38
|
+
are not reproduced, because the patch modifies no part of that runtime.
|
|
39
|
+
|
|
40
|
+
eve
|
|
41
|
+
Copyright 2026 Vercel, Inc. and contributors
|
|
42
|
+
|
|
43
|
+
This product includes software developed at Vercel, Inc.
|
|
44
|
+
(https://vercel.com/).
|
|
15
45
|
|
|
16
46
|
---
|
|
17
47
|
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ the hosts `graph.microsoft.com` and `smba.trafficmanager.net`. Core's registries
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
npm i @open-everywhere/core @open-everywhere/provider-teams eve@0.
|
|
18
|
+
npm i @open-everywhere/core @open-everywhere/provider-teams eve@0.71.0
|
|
19
19
|
npx oe-eve-patch
|
|
20
20
|
```
|
|
21
21
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// THE Teams app manifest — rendered, never static (OPE-2334).
|
|
2
|
+
//
|
|
3
|
+
// This used to be the static `teams-app/manifest.json`, with the brand written into `name.short`,
|
|
4
|
+
// `name.full` and `description.full`. A deployment is meant to be locally namable, so the manifest
|
|
5
|
+
// is now a TEMPLATE the deployment's configured name renders, the same way the Slack manifest is.
|
|
6
|
+
// `agent/ops.ts` registers it with core's `registerSurfaceAppManifest`, so the ops MCP tool
|
|
7
|
+
// `surface_manifest { surface: "teams" }` serves it with this deployment's host substituted.
|
|
8
|
+
//
|
|
9
|
+
// 🔴 THIS MODULE IMPORTS NOTHING, DELIBERATELY — the same rule as the Slack bundle's manifest. The
|
|
10
|
+
// caller reads the settings and passes the name in; this file holds only the vendor's own knowledge.
|
|
11
|
+
//
|
|
12
|
+
// TWO PLACEHOLDERS STAY FOR A HUMAN. `{{MICROSOFT_APP_ID}}` is the Azure Bot's application id and
|
|
13
|
+
// `{{DEVELOPER_NAME}}` is the publishing org. Neither is a deployment setting this plane can read,
|
|
14
|
+
// so `teams-app/README.md` walks the operator through both. The host is the shared
|
|
15
|
+
// `YOUR-DEPLOY-URL` placeholder, which `surface_manifest` substitutes.
|
|
16
|
+
|
|
17
|
+
/** The token the URLs carry where the deployment's own host belongs (core's DEPLOY_HOST_PLACEHOLDER). */
|
|
18
|
+
export const TEAMS_DEPLOY_HOST_PLACEHOLDER = "YOUR-DEPLOY-URL";
|
|
19
|
+
|
|
20
|
+
/** The app name the manifest carries when neither an explicit name nor `identity.name` is given. */
|
|
21
|
+
export const TEAMS_DEFAULT_APP_NAME = "Open Everywhere";
|
|
22
|
+
|
|
23
|
+
/** Teams' limit on `name.short`. A longer one fails the package upload. */
|
|
24
|
+
export const TEAMS_APP_NAME_MAX = 30;
|
|
25
|
+
|
|
26
|
+
/** Teams' limit on `name.full`. */
|
|
27
|
+
const TEAMS_APP_FULL_NAME_MAX = 100;
|
|
28
|
+
|
|
29
|
+
/** The manifest `version`. RSC consent is captured at install time, so a permission change needs a
|
|
30
|
+
* bump (scripts/teams-provider-integration-test.ts pins it past the bumps that carried each grant). */
|
|
31
|
+
const TEAMS_APP_MANIFEST_VERSION = "1.0.3";
|
|
32
|
+
|
|
33
|
+
/** Options for {@link teamsAppManifestTemplate}. */
|
|
34
|
+
export interface TeamsAppManifestOptions {
|
|
35
|
+
/** An EXPLICIT app name (the ops tool's `appName`). It wins over {@link agentName}. */
|
|
36
|
+
readonly appName?: string;
|
|
37
|
+
/** The deployment's configured name — the `identity.name` setting. Absent ⇒ {@link TEAMS_DEFAULT_APP_NAME}. */
|
|
38
|
+
readonly agentName?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The app name a manifest carries: the explicit name, else the configured name cut to Teams' limit,
|
|
42
|
+
* else the default. */
|
|
43
|
+
function teamsManifestAppName(opts: TeamsAppManifestOptions = {}): string {
|
|
44
|
+
const explicit = opts.appName?.trim();
|
|
45
|
+
if (explicit) return explicit;
|
|
46
|
+
const configured = opts.agentName?.trim();
|
|
47
|
+
if (configured) return configured.slice(0, TEAMS_APP_NAME_MAX).trim();
|
|
48
|
+
return TEAMS_DEFAULT_APP_NAME;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The ready-to-zip Teams app manifest (schema 1.17), named for this deployment. */
|
|
52
|
+
export function teamsAppManifestTemplate(opts: TeamsAppManifestOptions = {}): string {
|
|
53
|
+
const name = teamsManifestAppName(opts);
|
|
54
|
+
const fullNamed = `${name} — autonomous teammate`;
|
|
55
|
+
const host = TEAMS_DEPLOY_HOST_PLACEHOLDER;
|
|
56
|
+
return JSON.stringify(
|
|
57
|
+
{
|
|
58
|
+
$schema: "https://developer.microsoft.com/en-us/json-schemas/teams/v1.17/MicrosoftTeams.schema.json",
|
|
59
|
+
manifestVersion: "1.17",
|
|
60
|
+
version: TEAMS_APP_MANIFEST_VERSION,
|
|
61
|
+
id: "{{MICROSOFT_APP_ID}}",
|
|
62
|
+
developer: {
|
|
63
|
+
name: "{{DEVELOPER_NAME}}",
|
|
64
|
+
websiteUrl: `https://${host}`,
|
|
65
|
+
// The deployment serves both pages itself (packages/core/agent/pages/policy-pages.ts).
|
|
66
|
+
privacyUrl: `https://${host}/privacy`,
|
|
67
|
+
termsOfUseUrl: `https://${host}/terms`,
|
|
68
|
+
},
|
|
69
|
+
name: {
|
|
70
|
+
short: name,
|
|
71
|
+
full: fullNamed.length <= TEAMS_APP_FULL_NAME_MAX ? fullNamed : name,
|
|
72
|
+
},
|
|
73
|
+
description: {
|
|
74
|
+
short: "An autonomous teammate with per-conversation memory.",
|
|
75
|
+
// OPE-1954: the agent acts under ONE shared bot credential, never on a person's behalf.
|
|
76
|
+
full:
|
|
77
|
+
`${name} runs as a Teams bot: @mention it in a team channel, drop it into a group chat, or DM it 1:1. ` +
|
|
78
|
+
"It acts under one bot identity shared by this deployment — never under your account and never with your " +
|
|
79
|
+
"permissions — and its memory is scoped per tenant and per conversation. Learnings stay siloed to a " +
|
|
80
|
+
"conversation until that channel is confirmed team-wide.",
|
|
81
|
+
},
|
|
82
|
+
icons: { color: "color.png", outline: "outline.png" },
|
|
83
|
+
accentColor: "#5865F2",
|
|
84
|
+
bots: [
|
|
85
|
+
{
|
|
86
|
+
botId: "{{MICROSOFT_APP_ID}}",
|
|
87
|
+
scopes: ["team", "groupChat", "personal"],
|
|
88
|
+
supportsFiles: false,
|
|
89
|
+
isNotificationOnly: false,
|
|
90
|
+
supportsCalling: false,
|
|
91
|
+
supportsVideo: false,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
permissions: ["identity", "messageTeamMembers"],
|
|
95
|
+
// REQUIRED whenever `authorization` is present: without it the upload fails with a generic
|
|
96
|
+
// "couldn't parse the app manifest".
|
|
97
|
+
webApplicationInfo: { id: "{{MICROSOFT_APP_ID}}", resource: `api://${host}/{{MICROSOFT_APP_ID}}` },
|
|
98
|
+
// The RSC grants that make Microsoft deliver non-@mention activities (teams-app/README.md).
|
|
99
|
+
authorization: {
|
|
100
|
+
permissions: {
|
|
101
|
+
resourceSpecific: [
|
|
102
|
+
{ name: "ChannelMessage.Read.Group", type: "Application" },
|
|
103
|
+
{ name: "ChatMessage.Read.Chat", type: "Application" },
|
|
104
|
+
{ name: "ChannelMember.Read.Group", type: "Application" },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
validDomains: [host],
|
|
109
|
+
},
|
|
110
|
+
null,
|
|
111
|
+
2,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Where the operator uploads the package. */
|
|
116
|
+
export const TEAMS_APP_CONSOLE_URL = "https://admin.teams.microsoft.com";
|
|
117
|
+
|
|
118
|
+
/** The ordered steps, from the manifest to an installed app. */
|
|
119
|
+
export const TEAMS_APP_MANIFEST_STEPS: readonly string[] = [
|
|
120
|
+
"Save the manifest below as manifest.json in the teams-app/ directory, beside color.png and outline.png.",
|
|
121
|
+
"Replace {{MICROSOFT_APP_ID}} with the Azure Bot's Application (client) ID (MICROSOFT_APP_ID) — it appears three times and must match everywhere.",
|
|
122
|
+
"Replace {{DEVELOPER_NAME}} with your org's name.",
|
|
123
|
+
"Zip it flat: zip -j app.zip manifest.json color.png outline.png (Teams requires the manifest at the zip root).",
|
|
124
|
+
"Upload it: Teams → Apps → Manage your apps → Upload a custom app for one team, or the Teams Admin Center → Manage apps → Upload new app for the org.",
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
/** What the operator brings back. Teams needs no value from the package itself: the bot's
|
|
128
|
+
* credentials come from the Azure Bot resource (docs/deploy/surfaces/teams.md). */
|
|
129
|
+
export const TEAMS_APP_MANIFEST_COLLECT: readonly string[] = [
|
|
130
|
+
"Azure Bot → Configuration → Microsoft App ID = MICROSOFT_APP_ID",
|
|
131
|
+
"The app registration's client secret = MICROSOFT_APP_PASSWORD",
|
|
132
|
+
"The Entra tenant id = MICROSOFT_TENANT_ID",
|
|
133
|
+
];
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// 🔴 THE PLATFORM CONSTRAINT, AND WHY IT IS NOT A DEFECT. Microsoft delivers a NON-MENTION message
|
|
17
17
|
// to a bot only where the tenant has consented to the matching resource-specific-consent (RSC)
|
|
18
18
|
// grant: `ChannelMessage.Read.Group` for a team channel, `ChatMessage.Read.Chat` for a chat (both
|
|
19
|
-
// are in `
|
|
19
|
+
// are in the rendered Teams app manifest, `agent/app-manifest.ts`). Without it, only @mention activities ever arrive, so the seam
|
|
20
20
|
// is armed but nothing can exercise it — the surface answers once per mention, exactly as before.
|
|
21
21
|
// There is NO API that reports whether a tenant granted an RSC permission, so this module states
|
|
22
22
|
// the dependency out loud instead of guessing, and logs the named reason ONCE per scope while no
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
// domain becomes a judged channel-watch wake in the shared window. NOTE the platform reality:
|
|
34
34
|
// Teams only delivers non-mention messages to a bot whose app carries the matching RSC permission
|
|
35
35
|
// — `ChannelMessage.Read.Group` for a team channel, `ChatMessage.Read.Chat` for a chat (both are
|
|
36
|
-
// in
|
|
36
|
+
// in the rendered Teams app manifest, agent/app-manifest.ts). Without the relevant one, only @mention activities arrive and Leg A
|
|
37
37
|
// is simply never exercised (no code path depends on it firing).
|
|
38
38
|
//
|
|
39
39
|
// CHAT SCOPE IS DELIBERATELY NOT LEG A. The plan's privacy floor (teams-supervisor.ts
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// - the PER-TEAM RSC grant `ChannelMessage.Read.Group` — resource-specific consent, granted by a
|
|
7
7
|
// team owner when the Teams app is installed INTO that team. Microsoft evaluates it per
|
|
8
8
|
// request against the team in the URL, so the app can read that team's channels and no others.
|
|
9
|
-
// This is the shipped default (
|
|
9
|
+
// This is the shipped default (the rendered Teams app manifest, agent/app-manifest.ts, declares it).
|
|
10
10
|
// - a TENANT-WIDE application permission on the Entra app registration. Microsoft then applies
|
|
11
11
|
// no team boundary at all: the app can read every channel message in the tenant, and the ONLY
|
|
12
12
|
// remaining fence is Open Everywhere's own domain allowlist (teams-read.ts's
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
// 🔴 KNOWN LIMIT, STATED RATHER THAN HIDDEN: a denylist reports "per-team" for any FUTURE
|
|
47
47
|
// tenant-wide role Microsoft adds to those endpoints. An allowlist is not available (point 1 —
|
|
48
48
|
// there is nothing positive to match). Re-check the two API pages when Graph changes; the list
|
|
49
|
-
//
|
|
49
|
+
// in teams-graph-roles.ts (TENANT_WIDE_CHANNEL_READ_ROLES) is the whole of the claim, and
|
|
50
|
+
// TEAMS_READ_BOUNDARY_SOURCES below records where it came from.
|
|
50
51
|
//
|
|
51
52
|
// NO SIGNATURE VERIFICATION, ON PURPOSE. We decode a token WE minted, over TLS, from
|
|
52
53
|
// login.microsoftonline.com, seconds earlier. It is not attacker-supplied, and the decoded value
|
|
@@ -55,16 +56,18 @@
|
|
|
55
56
|
// of the token itself.
|
|
56
57
|
|
|
57
58
|
import { appendAudit, deploymentOrg } from "@open-everywhere/core/agent/adapter-api.js";
|
|
59
|
+
import { boundaryFromRoles, decodeJwtRoles, tenantWideRolesIn, type TeamsReadBoundary } from "./teams-graph-roles.js";
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
// The pure predicate lives in teams-graph-roles.ts, which imports nothing from core (OPE-2219). The
|
|
62
|
+
// ops arm needs only the predicate, and this file's audit row needs the main core barrel, which
|
|
63
|
+
// costs about 1.9 s to load. Re-exported here so this file's public names stay the same.
|
|
64
|
+
export {
|
|
65
|
+
boundaryFromRoles,
|
|
66
|
+
boundaryOfGraphToken,
|
|
67
|
+
decodeJwtRoles,
|
|
68
|
+
TENANT_WIDE_CHANNEL_READ_ROLES,
|
|
69
|
+
type TeamsReadBoundary,
|
|
70
|
+
} from "./teams-graph-roles.js";
|
|
68
71
|
|
|
69
72
|
/** Where the denylist came from — quoted in the runbook so a future reader can re-verify it. */
|
|
70
73
|
export const TEAMS_READ_BOUNDARY_SOURCES: readonly string[] = [
|
|
@@ -76,50 +79,6 @@ export const TEAMS_READ_BOUNDARY_SOURCES: readonly string[] = [
|
|
|
76
79
|
/** The audit action that records a boundary determination. */
|
|
77
80
|
const TEAMS_READ_BOUNDARY_ACTION = "teams.read_boundary";
|
|
78
81
|
|
|
79
|
-
/**
|
|
80
|
-
* The `roles` claim of a JWT's payload, or `undefined` when the token does not parse as one.
|
|
81
|
-
*
|
|
82
|
-
* Returns an EMPTY array — not `undefined` — for a token that parses but carries no `roles` claim:
|
|
83
|
-
* that is a real, meaningful state (an app registration with no consented Graph application
|
|
84
|
-
* permissions), and it is exactly the per-team case. Only a genuine parse failure is `undefined`,
|
|
85
|
-
* which is what keeps "malformed" from being reported as "per-team". Pure.
|
|
86
|
-
*/
|
|
87
|
-
export function decodeJwtRoles(token: string): readonly string[] | undefined {
|
|
88
|
-
const parts = token.split(".");
|
|
89
|
-
if (parts.length !== 3 || parts[1].length === 0) return undefined;
|
|
90
|
-
let payload: unknown;
|
|
91
|
-
try {
|
|
92
|
-
payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
|
|
93
|
-
} catch {
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
// An ARRAY passes `typeof === "object"`, so it needs its own rejection: a JWT payload is a JSON
|
|
97
|
-
// object, and letting an array through would read its absent `roles` as [] — reporting "per-team"
|
|
98
|
-
// for a token that never parsed as a claims set at all.
|
|
99
|
-
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return undefined;
|
|
100
|
-
const roles = (payload as { roles?: unknown }).roles;
|
|
101
|
-
if (roles === undefined) return [];
|
|
102
|
-
if (!Array.isArray(roles)) return undefined;
|
|
103
|
-
return roles.filter((r): r is string => typeof r === "string");
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Every tenant-wide role the token actually carries (empty on the per-team path). Pure. */
|
|
107
|
-
function tenantWideRolesIn(roles: readonly string[]): readonly string[] {
|
|
108
|
-
return TENANT_WIDE_CHANNEL_READ_ROLES.filter((r) => roles.includes(r));
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** The boundary a decoded `roles` claim implies — see the header for the one-sided reasoning.
|
|
112
|
-
* `undefined` roles (a token that did not parse) yield `"unknown"`, never a guess. Pure. */
|
|
113
|
-
export function boundaryFromRoles(roles: readonly string[] | undefined): TeamsReadBoundary {
|
|
114
|
-
if (roles === undefined) return "unknown";
|
|
115
|
-
return tenantWideRolesIn(roles).length > 0 ? "tenant-wide" : "per-team";
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** The boundary a minted Graph token implies. Pure; never logs or returns any part of the token. */
|
|
119
|
-
export function boundaryOfGraphToken(token: string): TeamsReadBoundary {
|
|
120
|
-
return boundaryFromRoles(decodeJwtRoles(token));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
82
|
// --- the process-held determination + its audit row -------------------------------------------
|
|
124
83
|
|
|
125
84
|
let held: TeamsReadBoundary = "unknown";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// THE PURE HALF of the Teams channel-read boundary predicate (OPE-428): decode a Graph token's
|
|
2
|
+
// `roles` claim and map it to a boundary. teams-graph-boundary.ts holds the reasoning (why a
|
|
3
|
+
// denylist, what it can miss) and the process-held determination with its audit row.
|
|
4
|
+
//
|
|
5
|
+
// 🔴 NO CORE IMPORT, ON PURPOSE (OPE-2219). The ops arm (agent/ops.ts) runs this predicate in the
|
|
6
|
+
// standalone ops MCP server. teams-graph-boundary.ts imports the main core barrel for its audit row,
|
|
7
|
+
// and that barrel loads most of core: about 1.9 s on a warm Windows machine. Boundary rule 4d
|
|
8
|
+
// (packages/core/scripts/boundary-test.ts) refuses an ops arm that reaches the main barrel.
|
|
9
|
+
|
|
10
|
+
/** Which boundary governs this deployment's Teams channel reads.
|
|
11
|
+
* - `per-team` — Microsoft enforces the team boundary (the RSC path, shipped default).
|
|
12
|
+
* - `tenant-wide` — Microsoft enforces NO team boundary; only OE's domain allowlist remains.
|
|
13
|
+
* - `unknown` — not determined yet, or the minted token did not parse. */
|
|
14
|
+
export type TeamsReadBoundary = "per-team" | "tenant-wide" | "unknown";
|
|
15
|
+
|
|
16
|
+
/** The Graph APPLICATION permissions that read Teams channel messages TENANT-WIDE. See
|
|
17
|
+
* teams-graph-boundary.ts's header for why this is a denylist and what it can miss. */
|
|
18
|
+
export const TENANT_WIDE_CHANNEL_READ_ROLES: readonly string[] = ["ChannelMessage.Read.All", "Group.Read.All", "Group.ReadWrite.All"];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The `roles` claim of a JWT's payload, or `undefined` when the token does not parse as one.
|
|
22
|
+
*
|
|
23
|
+
* Returns an EMPTY array — not `undefined` — for a token that parses but carries no `roles` claim:
|
|
24
|
+
* that is a real, meaningful state (an app registration with no consented Graph application
|
|
25
|
+
* permissions), and it is exactly the per-team case. Only a genuine parse failure is `undefined`,
|
|
26
|
+
* which is what keeps "malformed" from being reported as "per-team". Pure.
|
|
27
|
+
*/
|
|
28
|
+
export function decodeJwtRoles(token: string): readonly string[] | undefined {
|
|
29
|
+
const parts = token.split(".");
|
|
30
|
+
if (parts.length !== 3 || parts[1].length === 0) return undefined;
|
|
31
|
+
let payload: unknown;
|
|
32
|
+
try {
|
|
33
|
+
payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
// An ARRAY passes `typeof === "object"`, so it needs its own rejection: a JWT payload is a JSON
|
|
38
|
+
// object, and letting an array through would read its absent `roles` as [] — reporting "per-team"
|
|
39
|
+
// for a token that never parsed as a claims set at all.
|
|
40
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return undefined;
|
|
41
|
+
const roles = (payload as { roles?: unknown }).roles;
|
|
42
|
+
if (roles === undefined) return [];
|
|
43
|
+
if (!Array.isArray(roles)) return undefined;
|
|
44
|
+
return roles.filter((r): r is string => typeof r === "string");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every tenant-wide role the token actually carries (empty on the per-team path). Pure. */
|
|
48
|
+
export function tenantWideRolesIn(roles: readonly string[]): readonly string[] {
|
|
49
|
+
return TENANT_WIDE_CHANNEL_READ_ROLES.filter((r) => roles.includes(r));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The boundary a decoded `roles` claim implies — see teams-graph-boundary.ts's header for the one-sided reasoning.
|
|
53
|
+
* `undefined` roles (a token that did not parse) yield `"unknown"`, never a guess. Pure. */
|
|
54
|
+
export function boundaryFromRoles(roles: readonly string[] | undefined): TeamsReadBoundary {
|
|
55
|
+
if (roles === undefined) return "unknown";
|
|
56
|
+
return tenantWideRolesIn(roles).length > 0 ? "tenant-wide" : "per-team";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The boundary a minted Graph token implies. Pure; never logs or returns any part of the token. */
|
|
60
|
+
export function boundaryOfGraphToken(token: string): TeamsReadBoundary {
|
|
61
|
+
return boundaryFromRoles(decodeJwtRoles(token));
|
|
62
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// registration must ALSO hold a Graph permission that can read channel messages
|
|
19
19
|
// (`ChannelMessage.Read.All`, admin-consented) OR already have the per-team RSC grant
|
|
20
20
|
// `ChannelMessage.Read.Group` this deployment's own Teams app manifest already requests for
|
|
21
|
-
// ambient watching (
|
|
21
|
+
// ambient watching (the rendered Teams app manifest agent/app-manifest.ts, docs/deploy/surfaces/teams.md) — when a team already
|
|
22
22
|
// has that grant, THIS adapter needs no extra Azure configuration at all. Undocumented/absent
|
|
23
23
|
// either way surfaces as a Graph 401/403, fail-loud (see below), never a silent empty read.
|
|
24
24
|
//
|
package/agent/channels/teams.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
authPrincipalId,
|
|
43
43
|
type ChatAuditPartition,
|
|
44
44
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
45
|
+
import { withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
|
|
45
46
|
|
|
46
47
|
// The audit partition for a terminal event, rebuilt from the LIVE session state the same
|
|
47
48
|
// way slack.ts rebuilds its own (the completion handler has no auth context): tenant →
|
|
@@ -267,4 +268,6 @@ const native = teamsChannel({
|
|
|
267
268
|
},
|
|
268
269
|
});
|
|
269
270
|
|
|
270
|
-
|
|
271
|
+
// OPE-2252 — `reply`/`no_reply` end the turn natively (`endsTurn`) and eve then emits no final message,
|
|
272
|
+
// so the wrapper decides such a turn at `turn.completed` (core `channels/ends-turn-final.ts`).
|
|
273
|
+
export default withEndsTurnFinalChannel(frontTeamsChannel(native));
|