@agent-native/core 0.203.2-nightly-20261007213109 → 0.204.0

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.
Files changed (121) hide show
  1. package/dist/a2a/handlers.js +57 -1
  2. package/dist/a2a/server.js +37 -1
  3. package/dist/action.d.ts +1 -0
  4. package/dist/action.js +26 -3
  5. package/dist/agent/contain-principal.d.ts +9 -0
  6. package/dist/agent/contain-principal.js +53 -0
  7. package/dist/agent/production-agent.js +32 -2
  8. package/dist/agent/run-manager.js +14 -2
  9. package/dist/agent/run-store.js +66 -12
  10. package/dist/audit/actions/export-audit-events.d.ts +1 -1
  11. package/dist/audit/actions/export-audit-events.js +2 -2
  12. package/dist/audit/actions/export-audit-ocsf.d.ts +11 -0
  13. package/dist/audit/actions/export-audit-ocsf.js +183 -0
  14. package/dist/audit/actions/list-audit-events.d.ts +1 -1
  15. package/dist/audit/actions/list-audit-events.js +3 -3
  16. package/dist/audit/config.d.ts +1 -1
  17. package/dist/audit/config.js +5 -1
  18. package/dist/audit/index.d.ts +1 -0
  19. package/dist/audit/index.js +1 -0
  20. package/dist/audit/ocsf.d.ts +70 -0
  21. package/dist/audit/ocsf.js +228 -0
  22. package/dist/audit/record.js +1 -1
  23. package/dist/audit/store.js +9 -2
  24. package/dist/audit/types.d.ts +12 -1
  25. package/dist/cli/agent.js +2 -11
  26. package/dist/cli/connect.js +7 -2
  27. package/dist/client/use-db-sync.d.ts +13 -0
  28. package/dist/client/use-db-sync.js +22 -1
  29. package/dist/coding-tools/run-code.js +22 -1
  30. package/dist/coding-tools/sandbox/local-child-process-adapter.js +20 -0
  31. package/dist/collab/client.js +36 -3
  32. package/dist/collab/routes.d.ts +9 -4
  33. package/dist/collab/routes.js +15 -1
  34. package/dist/collab/struct-routes.d.ts +1 -1
  35. package/dist/deploy/build.js +15 -8
  36. package/dist/deploy/workspace-deploy.js +88 -13
  37. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  38. package/dist/file-upload/builder.js +18 -4
  39. package/dist/framework-tools.js +3 -0
  40. package/dist/identity/rekey.js +6 -0
  41. package/dist/jobs/actions/manage-recurring-job.d.ts +1 -1
  42. package/dist/localization/core-messages/supplemental/ar-SA.js +15 -0
  43. package/dist/localization/core-messages/supplemental/de-DE.js +15 -0
  44. package/dist/localization/core-messages/supplemental/en-US.js +15 -0
  45. package/dist/localization/core-messages/supplemental/es-ES.js +15 -0
  46. package/dist/localization/core-messages/supplemental/fr-FR.js +15 -0
  47. package/dist/localization/core-messages/supplemental/hi-IN.js +15 -0
  48. package/dist/localization/core-messages/supplemental/ja-JP.js +15 -0
  49. package/dist/localization/core-messages/supplemental/ko-KR.js +15 -0
  50. package/dist/localization/core-messages/supplemental/pt-BR.js +15 -0
  51. package/dist/localization/core-messages/supplemental/zh-CN.js +15 -0
  52. package/dist/localization/core-messages/supplemental/zh-TW.js +15 -0
  53. package/dist/localization/mcp-settings-messages.d.ts +15 -0
  54. package/dist/mcp/actions/create-org-service-token.d.ts +8 -0
  55. package/dist/mcp/actions/create-org-service-token.js +93 -4
  56. package/dist/mcp/actions/list-org-service-tokens.d.ts +2 -0
  57. package/dist/mcp/actions/list-org-service-tokens.js +36 -8
  58. package/dist/mcp/actions/service-principal-input.d.ts +48 -0
  59. package/dist/mcp/actions/service-principal-input.js +102 -0
  60. package/dist/mcp/actions/set-service-principal-lifecycle.d.ts +15 -0
  61. package/dist/mcp/actions/set-service-principal-lifecycle.js +150 -0
  62. package/dist/mcp/actions/set-service-principal-policy.d.ts +14 -0
  63. package/dist/mcp/actions/set-service-principal-policy.js +105 -0
  64. package/dist/mcp/build-server.js +117 -13
  65. package/dist/mcp/connect-route.js +116 -4
  66. package/dist/mcp/connect-store.d.ts +7 -0
  67. package/dist/mcp/connect-store.js +21 -14
  68. package/dist/mcp-client/oauth-routes.js +2 -1
  69. package/dist/notifications/routes.d.ts +3 -3
  70. package/dist/observability/routes.d.ts +7 -7
  71. package/dist/org/index.d.ts +2 -2
  72. package/dist/org/service-principal-guard.d.ts +64 -0
  73. package/dist/org/service-principal-guard.js +140 -0
  74. package/dist/org/service-principal-policy.d.ts +104 -0
  75. package/dist/org/service-principal-policy.js +272 -0
  76. package/dist/provider-api/actions/custom-provider-registration.d.ts +5 -5
  77. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  78. package/dist/scripts/dev/index.js +2 -8
  79. package/dist/scripts/parse-args.d.ts +7 -0
  80. package/dist/scripts/parse-args.js +76 -0
  81. package/dist/scripts/utils.d.ts +1 -1
  82. package/dist/scripts/utils.js +1 -1
  83. package/dist/secrets/routes.d.ts +11 -11
  84. package/dist/server/action-discovery.js +16 -42
  85. package/dist/server/agent-chat/script-entries.js +3 -8
  86. package/dist/server/agent-chat-plugin.js +8 -42
  87. package/dist/server/agent-engine-default-model-route.d.ts +2 -2
  88. package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
  89. package/dist/server/auth.js +5 -2
  90. package/dist/server/core-routes-plugin.js +25 -6
  91. package/dist/server/google-oauth.js +8 -4
  92. package/dist/server/identity-sso.js +2 -1
  93. package/dist/server/index.d.ts +1 -0
  94. package/dist/server/index.js +1 -0
  95. package/dist/server/poll.d.ts +9 -0
  96. package/dist/server/poll.js +86 -7
  97. package/dist/server/query-echo-safe-redirect.d.ts +10 -0
  98. package/dist/server/query-echo-safe-redirect.js +102 -0
  99. package/dist/server/release-schema.js +4 -0
  100. package/dist/server/workspace-provider-oauth.js +2 -1
  101. package/dist/templates/chat/.agents/skills/actions/references/action-fields.md +1 -0
  102. package/dist/templates/default/.agents/skills/actions/references/action-fields.md +1 -0
  103. package/dist/templates/factory/.agents/skills/review-latest-feedback/SKILL.md +24 -25
  104. package/dist/templates/factory/.agents/skills/review-latest-feedback/references/slack-reaction-replies.md +33 -0
  105. package/dist/templates/headless/.agents/skills/actions/references/action-fields.md +1 -0
  106. package/dist/templates/workspace-core/.agents/skills/actions/references/action-fields.md +1 -0
  107. package/dist/triggers/actions/manage-automation.d.ts +7 -7
  108. package/dist/vite/client.js +1 -0
  109. package/docs/content/audit-log.mdx +15 -0
  110. package/docs/content/external-agents-catalog.mdx +19 -0
  111. package/docs/content/http-api.mdx +21 -2
  112. package/docs/content/pr-visual-recap.mdx +3 -1
  113. package/docs/content/template-clips-embed.mdx +1 -1
  114. package/docs/content/turn-into-app.mdx +118 -0
  115. package/package.json +4 -4
  116. package/src/templates/chat/.agents/skills/actions/references/action-fields.md +1 -0
  117. package/src/templates/default/.agents/skills/actions/references/action-fields.md +1 -0
  118. package/src/templates/factory/.agents/skills/review-latest-feedback/SKILL.md +24 -25
  119. package/src/templates/factory/.agents/skills/review-latest-feedback/references/slack-reaction-replies.md +33 -0
  120. package/src/templates/headless/.agents/skills/actions/references/action-fields.md +1 -0
  121. package/src/templates/workspace-core/.agents/skills/actions/references/action-fields.md +1 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Suspend, retire, or resume an org service principal.
3
+ *
4
+ * The committed lifecycle write is what blocks new work (admission, tool
5
+ * dispatch, and run start read it). Suspended and retired also contain the
6
+ * principal: in-flight agent runs it owns are aborted, and retired additionally
7
+ * revokes every active token. The write commits before containment runs, and a
8
+ * containment failure is returned (`containmentErrors`, `contained: false`)
9
+ * rather than swallowed: a partial containment must not read as complete.
10
+ *
11
+ * Owner/admin only, and not callable by the agent tool loop.
12
+ */
13
+ import { z } from "zod";
14
+ import { defineAction } from "../../action.js";
15
+ import { containServicePrincipal, prepareServicePrincipalContainment, } from "../../agent/contain-principal.js";
16
+ import { orgAdminAudit, recordOrgAdminAuditEvent, } from "../../audit/org-admin.js";
17
+ import { SERVICE_PRINCIPAL_LIFECYCLES, ServicePrincipalRetiredError, getServicePrincipalPolicy, setServicePrincipalLifecycle, withServicePrincipalLifecycleLock, } from "../../org/service-principal-policy.js";
18
+ import { ensureConnectTables, revokeServiceTokensByName, } from "../connect-store.js";
19
+ import { describeServicePrincipal, parseServiceName, requireKnownServicePrincipal, } from "./service-principal-input.js";
20
+ import { requireServiceTokenCaller, ServiceTokenError, } from "./service-token-access.js";
21
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
22
+ export default defineAction({
23
+ description: "Suspend, retire, or resume an org service principal. Suspended and retired principals are refused at MCP admission and run start, and their in-flight agent runs are aborted; retired also revokes every active token. The result reports abortedRuns, revokedTokens, and any containmentErrors (contained: false means containment is incomplete — retry). Org owner/admin only.",
24
+ schema: z.object({
25
+ serviceName: z
26
+ .string()
27
+ .min(1)
28
+ .max(64)
29
+ .describe("Service name from list-org-service-tokens"),
30
+ lifecycle: z.enum(SERVICE_PRINCIPAL_LIFECYCLES),
31
+ reason: z.string().trim().max(500).optional(),
32
+ }),
33
+ toolCallable: false,
34
+ audit: orgAdminAudit({
35
+ targetType: "service-principal",
36
+ targetId: (args) => String(args.serviceName ?? ""),
37
+ summary: (args, result) => {
38
+ const r = result;
39
+ const errors = r.containmentErrors?.length ?? 0;
40
+ return `Set ${r.serviceEmail ?? args.serviceName} to ${args.lifecycle}: ${r.abortedRuns ?? 0} runs aborted, ${r.revokedTokens ?? 0} tokens revoked${errors ? `, ${errors} containment errors` : ""}`;
41
+ },
42
+ }),
43
+ run: async (args, ctx) => {
44
+ const caller = await requireServiceTokenCaller({
45
+ userEmail: ctx?.userEmail,
46
+ orgId: ctx?.orgId,
47
+ level: "manage",
48
+ });
49
+ const serviceName = parseServiceName(args.serviceName);
50
+ await requireKnownServicePrincipal(caller.orgId, serviceName);
51
+ let policy;
52
+ let abortedRuns = 0;
53
+ let revokedTokens = 0;
54
+ let resumeBlocked = false;
55
+ let lifecycleCommitted = false;
56
+ const containmentErrors = [];
57
+ try {
58
+ if (args.lifecycle !== "active") {
59
+ await prepareServicePrincipalContainment();
60
+ if (args.lifecycle === "retired")
61
+ await ensureConnectTables();
62
+ policy = await withServicePrincipalLifecycleLock(caller.orgId, serviceName, (db) => setServicePrincipalLifecycle(caller.orgId, serviceName, args.lifecycle, { actorEmail: caller.email, reason: args.reason || null }, db));
63
+ lifecycleCommitted = true;
64
+ policy = await withServicePrincipalLifecycleLock(caller.orgId, serviceName, async (db) => {
65
+ const current = await getServicePrincipalPolicy(caller.orgId, serviceName, db);
66
+ if (!current || current.lifecycle !== args.lifecycle) {
67
+ throw new ServiceTokenError("The service principal lifecycle changed concurrently. Read its current state before retrying.", 409);
68
+ }
69
+ try {
70
+ const contained = await containServicePrincipal(caller.orgId, serviceName);
71
+ abortedRuns = contained.abortedRuns;
72
+ containmentErrors.push(...contained.containmentErrors);
73
+ }
74
+ catch (error) {
75
+ containmentErrors.push(`abort runs: ${errorMessage(error)}`);
76
+ }
77
+ if (args.lifecycle === "retired") {
78
+ try {
79
+ revokedTokens = await revokeServiceTokensByName(caller.orgId, serviceName);
80
+ }
81
+ catch (error) {
82
+ containmentErrors.push(`revoke tokens: ${errorMessage(error)}`);
83
+ }
84
+ }
85
+ return current;
86
+ });
87
+ }
88
+ else {
89
+ policy = await withServicePrincipalLifecycleLock(caller.orgId, serviceName, async (db) => {
90
+ const current = await getServicePrincipalPolicy(caller.orgId, serviceName, db);
91
+ if (current?.lifecycle === "retired") {
92
+ throw new ServicePrincipalRetiredError();
93
+ }
94
+ if (current?.lifecycle === "suspended") {
95
+ try {
96
+ const contained = await containServicePrincipal(caller.orgId, serviceName);
97
+ abortedRuns = contained.abortedRuns;
98
+ containmentErrors.push(...contained.containmentErrors);
99
+ }
100
+ catch (error) {
101
+ containmentErrors.push(`abort runs: ${errorMessage(error)}`);
102
+ }
103
+ if (containmentErrors.length > 0) {
104
+ resumeBlocked = true;
105
+ return current;
106
+ }
107
+ }
108
+ return setServicePrincipalLifecycle(caller.orgId, serviceName, "active", { actorEmail: caller.email, reason: args.reason || null }, db);
109
+ });
110
+ if (resumeBlocked) {
111
+ throw new ServiceTokenError("The service principal could not be resumed because containment is incomplete. Retry after its in-flight runs stop.", 503);
112
+ }
113
+ }
114
+ }
115
+ catch (error) {
116
+ if (error instanceof ServicePrincipalRetiredError) {
117
+ throw new ServiceTokenError(error.message, error.statusCode);
118
+ }
119
+ if (error instanceof ServiceTokenError)
120
+ throw error;
121
+ console.error("[service-principal] Lifecycle write failed:", error);
122
+ throw new ServiceTokenError(lifecycleCommitted
123
+ ? "The lifecycle changed, but containment could not be confirmed. Retry the lifecycle request to finish containment."
124
+ : "Could not change the service principal lifecycle. Nothing was changed; try again.", 503);
125
+ }
126
+ const principal = describeServicePrincipal(caller.orgId, serviceName, policy);
127
+ if (args.lifecycle !== "active") {
128
+ await recordOrgAdminAuditEvent({
129
+ action: "contain-service-principal",
130
+ targetType: "service-principal",
131
+ targetId: serviceName,
132
+ summary: `Contained ${principal.serviceEmail}: ${abortedRuns} runs aborted, ${revokedTokens} tokens revoked${containmentErrors.length ? `, ${containmentErrors.length} errors` : ""}`,
133
+ userEmail: caller.email,
134
+ orgId: caller.orgId,
135
+ status: containmentErrors.length ? "error" : "success",
136
+ caller: ctx?.caller,
137
+ });
138
+ }
139
+ return {
140
+ orgId: caller.orgId,
141
+ serviceEmail: principal.serviceEmail,
142
+ lifecycle: args.lifecycle,
143
+ principal,
144
+ abortedRuns,
145
+ revokedTokens,
146
+ contained: containmentErrors.length === 0,
147
+ containmentErrors,
148
+ };
149
+ },
150
+ });
@@ -0,0 +1,14 @@
1
+ declare const _default: import("../../action.js").ActionDefinition<{
2
+ serviceName: string;
3
+ ownerEmail?: string;
4
+ team?: string;
5
+ riskTier?: "high" | "low" | "medium";
6
+ purpose?: string;
7
+ allowedActions?: string[];
8
+ }, {
9
+ orgId: string;
10
+ serviceEmail: string;
11
+ principal: import("./service-principal-input.js").ServicePrincipalView;
12
+ changedFields: ("allowedActions" | "ownerEmail" | "purpose" | "riskTier" | "team")[];
13
+ }>;
14
+ export default _default;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Set who is accountable for an org service principal and which actions it may
3
+ * call. Fields left out keep their stored value; `null` clears one (and for
4
+ * `allowedActions`, `null` means unrestricted). Lifecycle (suspend/resume) is a
5
+ * separate action because it also contains in-flight work.
6
+ *
7
+ * Owner/admin only, and not callable by the agent tool loop: a prompt-injected
8
+ * agent must not be able to widen a grant.
9
+ */
10
+ import { z } from "zod";
11
+ import { defineAction } from "../../action.js";
12
+ import { orgAdminAudit } from "../../audit/org-admin.js";
13
+ import { upsertServicePrincipalPolicy, } from "../../org/service-principal-policy.js";
14
+ import { allowedActionsSchema, assertOwnerIsOrgMember, describeServicePrincipal, normalizeAllowedActions, ownerEmailSchema, parseServiceName, purposeSchema, requireKnownServicePrincipal, riskTierSchema, teamSchema, } from "./service-principal-input.js";
15
+ import { requireServiceTokenCaller, ServiceTokenError, } from "./service-token-access.js";
16
+ const FIELDS = [
17
+ "ownerEmail",
18
+ "team",
19
+ "riskTier",
20
+ "purpose",
21
+ "allowedActions",
22
+ ];
23
+ export default defineAction({
24
+ description: "Set the governance record of an org service principal: accountable owner (an org member), team, risk tier, purpose, and the action allow-list (null = unrestricted, [] = nothing, a trailing * matches a prefix). Omitted fields are unchanged; null clears a field. Org owner/admin only.",
25
+ schema: z.object({
26
+ serviceName: z
27
+ .string()
28
+ .min(1)
29
+ .max(64)
30
+ .describe("Service name from list-org-service-tokens"),
31
+ ownerEmail: ownerEmailSchema.nullable().optional(),
32
+ team: teamSchema.nullable().optional(),
33
+ riskTier: riskTierSchema.optional(),
34
+ purpose: purposeSchema.nullable().optional(),
35
+ allowedActions: allowedActionsSchema.nullable().optional(),
36
+ }),
37
+ toolCallable: false,
38
+ audit: orgAdminAudit({
39
+ targetType: "service-principal",
40
+ targetId: (args) => String(args.serviceName ?? ""),
41
+ // The grant list is only ever counted, never echoed, into the event.
42
+ recordInputs: false,
43
+ summary: (args, result) => {
44
+ const r = result;
45
+ const changed = r.changedFields ?? [];
46
+ const detail = changed.map((f) => f === "allowedActions"
47
+ ? args.allowedActions == null
48
+ ? "allowedActions: unrestricted"
49
+ : `allowedActions: ${args.allowedActions.length} entries`
50
+ : f);
51
+ return `Updated governance of ${r.serviceEmail ?? args.serviceName}: ${detail.length ? detail.join(", ") : "no changes"}`;
52
+ },
53
+ }),
54
+ run: async (args, ctx) => {
55
+ const caller = await requireServiceTokenCaller({
56
+ userEmail: ctx?.userEmail,
57
+ orgId: ctx?.orgId,
58
+ level: "manage",
59
+ });
60
+ const serviceName = parseServiceName(args.serviceName);
61
+ const existing = await requireKnownServicePrincipal(caller.orgId, serviceName);
62
+ const input = {};
63
+ if (args.ownerEmail !== undefined) {
64
+ input.ownerEmail =
65
+ args.ownerEmail === null
66
+ ? null
67
+ : await assertOwnerIsOrgMember(caller.orgId, args.ownerEmail);
68
+ }
69
+ if (args.team !== undefined)
70
+ input.team = args.team;
71
+ if (args.riskTier !== undefined)
72
+ input.riskTier = args.riskTier;
73
+ if (args.purpose !== undefined)
74
+ input.purpose = args.purpose;
75
+ if (args.allowedActions !== undefined) {
76
+ input.allowedActions = normalizeAllowedActions(args.allowedActions);
77
+ }
78
+ const before = {
79
+ ownerEmail: existing?.ownerEmail ?? null,
80
+ team: existing?.team ?? null,
81
+ riskTier: existing?.riskTier ?? "medium",
82
+ purpose: existing?.purpose ?? null,
83
+ allowedActions: existing?.allowedActions ?? null,
84
+ };
85
+ const changedFields = FIELDS.filter((field) => input[field] !== undefined &&
86
+ JSON.stringify(input[field]) !== JSON.stringify(before[field]));
87
+ let saved = existing;
88
+ if (Object.keys(input).length > 0) {
89
+ try {
90
+ saved = await upsertServicePrincipalPolicy(caller.orgId, serviceName, input);
91
+ }
92
+ catch (error) {
93
+ console.error("[service-principal] Policy write failed:", error);
94
+ throw new ServiceTokenError("Could not save the service principal policy. Nothing was changed; try again.", 503);
95
+ }
96
+ }
97
+ const principal = describeServicePrincipal(caller.orgId, serviceName, saved);
98
+ return {
99
+ orgId: caller.orgId,
100
+ serviceEmail: principal.serviceEmail,
101
+ principal,
102
+ changedFields,
103
+ };
104
+ },
105
+ });
@@ -6,6 +6,8 @@ import { isActionContractError, isActionExposedToExternalAgents, isActionHiddenF
6
6
  import { describeToolResultImages, extractAgentImagesFromActionResult, } from "../agent/tool-result-images.js";
7
7
  import { getAppConfig } from "../app-config/store.js";
8
8
  import { isMcpActionResult } from "../mcp-client/app-result.js";
9
+ import { assertServicePrincipalMayCall, assertServicePrincipalMayRun, recordServicePrincipalDenial, ServicePrincipalRefusedError, } from "../org/service-principal-guard.js";
10
+ import { isActionGranted } from "../org/service-principal-policy.js";
9
11
  import { writeActionChangeMarker } from "../server/action-change-marker-write.js";
10
12
  import { getConfiguredAppBasePath } from "../server/app-base-path.js";
11
13
  import { readDeployCredentialEnv } from "../server/credential-provider.js";
@@ -1843,10 +1845,44 @@ export async function createMCPServerForRequest(config, identity, requestMeta) {
1843
1845
  requestState,
1844
1846
  });
1845
1847
  }
1848
+ // Read per request, never cached: a principal suspended or re-scoped after
1849
+ // admission is stopped by the next list or call.
1850
+ async function resolveServiceGrant(actionName) {
1851
+ try {
1852
+ return await assertServicePrincipalMayRun(effectiveIdentity?.userEmail, typeof effectiveIdentity?.orgId === "string"
1853
+ ? effectiveIdentity.orgId
1854
+ : undefined);
1855
+ }
1856
+ catch (error) {
1857
+ if (actionName && error instanceof ServicePrincipalRefusedError) {
1858
+ await recordServicePrincipalDenial({
1859
+ email: effectiveIdentity?.userEmail,
1860
+ orgId: effectiveIdentity?.orgId,
1861
+ actionName,
1862
+ caller: "mcp",
1863
+ error,
1864
+ });
1865
+ }
1866
+ throw error;
1867
+ }
1868
+ }
1869
+ // MCP App widgets belong to their action: outside the grant, the resource is
1870
+ // as invisible as the tool.
1871
+ async function grantedAdvertisedActions(actionName) {
1872
+ const { allowedActions } = await resolveServiceGrant(actionName);
1873
+ return allowedActions === null
1874
+ ? advertisedActions
1875
+ : Object.fromEntries(Object.entries(advertisedActions).filter(([name]) => isActionGranted(allowedActions, name)));
1876
+ }
1877
+ async function grantedResourceActions(actionName) {
1878
+ return grantedAdvertisedActions(actionName);
1879
+ }
1846
1880
  server.setRequestHandler("tools/list", async (request, ctx) => {
1847
1881
  const startedAt = Date.now();
1882
+ const { allowedActions } = await resolveServiceGrant("mcp:tools/list");
1848
1883
  const result = await withCallerContext(async () => {
1849
1884
  const tools = await Promise.all(Object.entries(advertisedActions)
1885
+ .filter(([name]) => isActionGranted(allowedActions, name))
1850
1886
  .sort(([a], [b]) => compareMcpCatalogValues(a, b))
1851
1887
  .map(async ([name, entry]) => {
1852
1888
  const hasLink = typeof entry.link === "function";
@@ -1901,15 +1937,18 @@ export async function createMCPServerForRequest(config, identity, requestMeta) {
1901
1937
  entry.tool.description ??
1902
1938
  name;
1903
1939
  const title = agentNativeToolTitle(name, entry.tool.title);
1904
- const annotations = directoryCatalog
1905
- ? { title, ...entry.mcpAnnotations }
1906
- : {
1907
- title,
1908
- readOnlyHint: entry.readOnly === true,
1909
- destructiveHint: entry.publicAgent?.isConsequential === true ||
1910
- entry.needsApproval !== undefined,
1911
- openWorldHint: false,
1912
- };
1940
+ const annotations = {
1941
+ title,
1942
+ ...(entry.mcpAnnotations ??
1943
+ (directoryCatalog
1944
+ ? undefined
1945
+ : {
1946
+ readOnlyHint: entry.readOnly === true,
1947
+ destructiveHint: entry.publicAgent?.isConsequential === true ||
1948
+ entry.needsApproval !== undefined,
1949
+ openWorldHint: false,
1950
+ })),
1951
+ };
1913
1952
  if (directoryCatalog) {
1914
1953
  delete annotations["agent-native/producesOpenLink"];
1915
1954
  }
@@ -1942,6 +1981,7 @@ export async function createMCPServerForRequest(config, identity, requestMeta) {
1942
1981
  tools.push(widgetSessionTool);
1943
1982
  if (fullCatalogRequested &&
1944
1983
  config.askAgent &&
1984
+ isActionGranted(allowedActions, "ask-agent") &&
1945
1985
  hasMcpOAuthScope(effectiveIdentity?.oauthScopes, "mcp:write")) {
1946
1986
  tools.push({
1947
1987
  name: "ask-agent",
@@ -2004,6 +2044,31 @@ export async function createMCPServerForRequest(config, identity, requestMeta) {
2004
2044
  : undefined;
2005
2045
  const result = await withCallerContext(async () => {
2006
2046
  const { name, arguments: args } = request.params;
2047
+ try {
2048
+ const { allowedActions } = await resolveServiceGrant();
2049
+ assertServicePrincipalMayCall(allowedActions, name);
2050
+ }
2051
+ catch (error) {
2052
+ if (!(error instanceof ServicePrincipalRefusedError))
2053
+ throw error;
2054
+ if (error.statusCode !== 403)
2055
+ throw error;
2056
+ failure = {
2057
+ errorType: error.errorCode,
2058
+ errorMessage: error.message,
2059
+ };
2060
+ await recordServicePrincipalDenial({
2061
+ email: effectiveIdentity?.userEmail,
2062
+ orgId: getRequestOrgId(),
2063
+ actionName: name,
2064
+ caller: "mcp",
2065
+ error,
2066
+ });
2067
+ return {
2068
+ content: [{ type: "text", text: error.message }],
2069
+ isError: true,
2070
+ };
2071
+ }
2007
2072
  if (name === "ask-agent" && config.askAgent) {
2008
2073
  if (!fullCatalogRequested) {
2009
2074
  failure = {
@@ -2299,8 +2364,9 @@ export async function createMCPServerForRequest(config, identity, requestMeta) {
2299
2364
  if (supportsMcpApps) {
2300
2365
  server.setRequestHandler("resources/list", async (request, ctx) => {
2301
2366
  const startedAt = Date.now();
2367
+ const grantedActions = await grantedResourceActions("mcp:resources/list");
2302
2368
  const result = await withCallerContext(async () => {
2303
- const mcpAppResources = await getMcpAppResources(config, advertisedActions, effectiveIdentity, requestMeta);
2369
+ const mcpAppResources = await getMcpAppResources(config, grantedActions, effectiveIdentity, requestMeta);
2304
2370
  return {
2305
2371
  resources: mcpAppResources
2306
2372
  .sort((a, b) => compareMcpCatalogValues(a.uri, b.uri))
@@ -2323,11 +2389,12 @@ export async function createMCPServerForRequest(config, identity, requestMeta) {
2323
2389
  return result;
2324
2390
  });
2325
2391
  server.setRequestHandler("resources/templates/list", async () => {
2392
+ const grantedActions = await grantedResourceActions("mcp:resources/templates/list");
2326
2393
  if (config.catalogMode === "directory") {
2327
2394
  return withCallerContext(async () => ({ resourceTemplates: [] }));
2328
2395
  }
2329
2396
  return withCallerContext(async () => {
2330
- const mcpAppResources = await getMcpAppResources(config, advertisedActions, effectiveIdentity, requestMeta);
2397
+ const mcpAppResources = await getMcpAppResources(config, grantedActions, effectiveIdentity, requestMeta);
2331
2398
  return {
2332
2399
  resourceTemplates: mcpAppResources
2333
2400
  .sort((a, b) => compareMcpCatalogValues(a.uri, b.uri))
@@ -2359,11 +2426,12 @@ export async function createMCPServerForRequest(config, identity, requestMeta) {
2359
2426
  });
2360
2427
  };
2361
2428
  try {
2429
+ const grantedActions = await grantedResourceActions("mcp:resources/read");
2362
2430
  return await withCallerContext(async () => {
2363
2431
  const uri = request.params?.uri;
2364
2432
  let found = null;
2365
2433
  const resourceActions = mcpAppWidgetsEnabledForIdentity(config, effectiveIdentity)
2366
- ? Object.entries(advertisedActions)
2434
+ ? Object.entries(grantedActions)
2367
2435
  : [];
2368
2436
  const orderedResourceActions = config.catalogMode === "directory"
2369
2437
  ? resourceActions.sort(([a], [b]) => compareMcpCatalogValues(a, b))
@@ -2671,6 +2739,42 @@ function matchesStoredConnectTokenIdentity(resolution, input) {
2671
2739
  * organization.
2672
2740
  */
2673
2741
  async function admitIssuedCredential(result, requestOrigin, orgResolution, issuedAt) {
2742
+ const admitted = await checkIssuedCredential(result, requestOrigin, orgResolution, issuedAt);
2743
+ return admitted.authed ? admitServicePrincipal(admitted) : admitted;
2744
+ }
2745
+ /**
2746
+ * A service identity is admitted only while its governance record says it may
2747
+ * run. Every credential branch that can yield one ends here; a caller whose
2748
+ * email is not service-shaped returns before any database read.
2749
+ */
2750
+ async function admitServicePrincipal(result) {
2751
+ const email = result.identity?.userEmail;
2752
+ try {
2753
+ await assertServicePrincipalMayRun(email, typeof result.identity?.orgId === "string"
2754
+ ? result.identity.orgId
2755
+ : undefined);
2756
+ return result;
2757
+ }
2758
+ catch (error) {
2759
+ if (!(error instanceof ServicePrincipalRefusedError))
2760
+ throw error;
2761
+ if (error.statusCode === 403) {
2762
+ await recordServicePrincipalDenial({
2763
+ email,
2764
+ orgId: typeof result.identity?.orgId === "string"
2765
+ ? result.identity.orgId
2766
+ : undefined,
2767
+ actionName: "mcp:admission",
2768
+ caller: "mcp",
2769
+ error,
2770
+ });
2771
+ }
2772
+ return error.statusCode === 503
2773
+ ? { authed: false, unavailable: true }
2774
+ : { authed: false };
2775
+ }
2776
+ }
2777
+ async function checkIssuedCredential(result, requestOrigin, orgResolution, issuedAt) {
2674
2778
  const { checkCredentialEmailRetirement, checkCredentialOrgMembership } = await import("./credential-membership.js");
2675
2779
  if (result.identity.userEmail) {
2676
2780
  const retirement = await checkCredentialEmailRetirement({
@@ -2862,7 +2966,7 @@ export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
2862
2966
  // sibling app per call, so they are cross-app A2A tokens.
2863
2967
  const admitted = tokenScope === MCP_CONNECT_SCOPE && !firstPartyMcp
2864
2968
  ? await admitIssuedCredential(verified, options.requestOrigin, orgResolution, typeof payload.iat === "number" ? payload.iat : undefined)
2865
- : verified;
2969
+ : await admitServicePrincipal(verified);
2866
2970
  if (admitted.authed && tokenScope === MCP_CONNECT_SCOPE) {
2867
2971
  await markConnectTokenUsed(payload.jti);
2868
2972
  }
@@ -9,9 +9,11 @@ import { getOrgDomain } from "../org/context.js";
9
9
  import { getSession, getConfiguredLoginHtml, isLoopbackRequest, } from "../server/auth.js";
10
10
  import { CREDENTIAL_MEMBERSHIP_UNAVAILABLE_MESSAGE } from "../server/credential-membership-unavailable.js";
11
11
  import { readDeployCredentialEnv } from "../server/credential-provider.js";
12
+ import { publicFrameworkPath } from "../server/framework-route-prefix.js";
12
13
  import { readBody } from "../server/h3-helpers.js";
14
+ import { FRAMEWORK_INTERNAL_ROUTE_PREFIX } from "../shared/framework-route-prefix.js";
13
15
  import { MCP_CONNECT_MCP_URL_TEMPLATE, getMcpConnectGuides, getMcpStaticTokenFallback, interpolateMcpConnectTemplate, resolveMcpConnectGuideId, } from "../shared/mcp-connect-content.js";
14
- import { ensureConnectTables, recordMintedToken, listTokens, revokeToken, normalizeServiceName, serviceIdentityEmail, createDeviceCode, getDeviceCode, getDeviceCodeByUserCode, approveDeviceCode, consumeDeviceCode, claimDeviceCodeForMint, finishDeviceCodeMint, expireDeviceCode, MCP_CONNECT_OAUTH_CLIENT_ID, MCP_CONNECT_SCOPE, DEFAULT_TOKEN_TTL_DAYS, MIN_TOKEN_TTL_DAYS, MAX_TOKEN_TTL_DAYS, DEVICE_CODE_TTL_MS, } from "./connect-store.js";
16
+ import { ensureConnectTables, recordMintedToken, listTokens, revokeToken, normalizeServiceName, serviceIdentityEmail, createDeviceCode, getDeviceCode, getDeviceCodeByUserCode, approveDeviceCode, consumeDeviceCode, claimDeviceCodeForMint, finishDeviceCodeMint, expireDeviceCode, MCP_CONNECT_OAUTH_CLIENT_ID, MCP_CONNECT_SCOPE, DEFAULT_TOKEN_TTL_DAYS, MIN_TOKEN_TTL_DAYS, MAX_TOKEN_TTL_DAYS, MAX_SERVICE_TOKEN_TTL_DAYS, DEVICE_CODE_TTL_MS, } from "./connect-store.js";
15
17
  import { McpCredentialIssuanceError, withMcpCredentialIssuance, } from "./credential-issuance.js";
16
18
  import { MCP_OAUTH_DEFAULT_SCOPE, signMcpOAuthAccessToken, } from "./oauth-token.js";
17
19
  import { resolveMcpOrgChoices } from "./org-choice.js";
@@ -131,11 +133,11 @@ async function resolveOrgDomain(orgId) {
131
133
  return undefined;
132
134
  }
133
135
  }
134
- function clampTtlDays(input) {
136
+ function clampTtlDays(input, maxDays = MAX_TOKEN_TTL_DAYS) {
135
137
  const n = Number(input);
136
138
  if (!Number.isFinite(n))
137
139
  return DEFAULT_TOKEN_TTL_DAYS;
138
- return Math.min(MAX_TOKEN_TTL_DAYS, Math.max(MIN_TOKEN_TTL_DAYS, Math.floor(n)));
140
+ return Math.min(maxDays, Math.max(MIN_TOKEN_TTL_DAYS, Math.floor(n)));
139
141
  }
140
142
  /**
141
143
  * Mint a connect-scoped JWT and record it. The token value is returned to the
@@ -218,7 +220,7 @@ export async function mintOrgServiceToken(params) {
218
220
  const serviceName = normalizeServiceName(params.serviceName);
219
221
  const serviceEmail = serviceIdentityEmail(serviceName, params.orgId);
220
222
  const orgDomain = await resolveOrgDomain(params.orgId);
221
- const ttlDays = clampTtlDays(params.ttlDays ?? DEFAULT_TOKEN_TTL_DAYS);
223
+ const ttlDays = clampTtlDays(params.ttlDays ?? DEFAULT_TOKEN_TTL_DAYS, MAX_SERVICE_TOKEN_TTL_DAYS);
222
224
  await prepareConnectIssuance();
223
225
  return withMcpCredentialIssuance({
224
226
  email: params.createdBy,
@@ -605,6 +607,10 @@ function renderConnectPage(params) {
605
607
  .tok:last-child { border-bottom: none; }
606
608
  .tok .meta { color: var(--subtle); font-size: 0.74rem; margin-top: 0.1rem; }
607
609
  .tok.revoked { opacity: 0.45; }
610
+ .pstate { font-size: 0.68rem; padding: 0.05rem 0.4rem; margin-inline-start: 0.4rem;
611
+ border: 1px solid var(--border); border-radius: 999px; color: var(--subtle); }
612
+ .pstate.active { color: var(--ok); }
613
+ .pstate.suspended { color: var(--error); }
608
614
  .empty-state {
609
615
  color: var(--subtle); font-size: 0.78rem; line-height: 1.45;
610
616
  padding: 0.3rem 0 0.45rem;
@@ -821,11 +827,22 @@ function renderConnectPage(params) {
821
827
  </summary>
822
828
  <div id="tokenList" class="token-list"><div class="empty-state">${localize(connectMessages.checkingConnections)}</div></div>
823
829
  </details>
830
+
831
+ <details id="principals" class="connections hidden">
832
+ <summary>
833
+ <span class="connections-title">${localize(connectMessages.servicePrincipals)}</span>
834
+ <span id="principalsState" class="connections-state hidden" aria-live="polite"></span>
835
+ <span class="chev" aria-hidden="true"></span>
836
+ </summary>
837
+ <div id="principalMsg" class="empty-state hidden" role="status"></div>
838
+ <div id="principalList" class="token-list"></div>
839
+ </details>
824
840
  </div>
825
841
  <script>
826
842
  (function () {
827
843
  var BASE = ${JSON.stringify(joinAppPath(connectBasePath, MCP_PUBLIC_ROUTE_PREFIX + "/connect"))};
828
844
  var USER_CODE = ${JSON.stringify(safeUserCode || null)};
845
+ var ACTIONS = ${JSON.stringify(joinAppPath(connectBasePath, publicFrameworkPath(`${FRAMEWORK_INTERNAL_ROUTE_PREFIX}/actions`)))};
829
846
  var COPY = ${JSON.stringify(connectMessages)};
830
847
  var msgEl = document.getElementById("msg");
831
848
  var connectionsEl = document.getElementById("connections");
@@ -988,6 +1005,100 @@ function renderConnectPage(params) {
988
1005
  }
989
1006
  }
990
1007
 
1008
+ // Governance view over the org's service principals. Hidden when the caller
1009
+ // has no org (400/401/403); route failures are shown, never read as "none".
1010
+ var principalsEl = document.getElementById("principals");
1011
+ var principalsStateEl = document.getElementById("principalsState");
1012
+ var principalListEl = document.getElementById("principalList");
1013
+ var principalMsgEl = document.getElementById("principalMsg");
1014
+ var PRINCIPAL_STATES = {
1015
+ ungoverned: "principalUngoverned", active: "principalActive",
1016
+ suspended: "principalSuspended", retired: "principalRetired"
1017
+ };
1018
+ var RISK_TIERS = { low: "riskLow", medium: "riskMedium", high: "riskHigh" };
1019
+
1020
+ function principalNote(text) {
1021
+ principalMsgEl.textContent = text;
1022
+ principalMsgEl.classList.toggle("hidden", !text);
1023
+ }
1024
+
1025
+ async function setLifecycle(name, lifecycle, btn) {
1026
+ btn.disabled = true;
1027
+ var r = await postActionJson("set-service-principal-lifecycle", { serviceName: name, lifecycle: lifecycle });
1028
+ await loadPrincipals();
1029
+ if (!r.ok) principalNote(COPY.couldNotUpdatePrincipal);
1030
+ else if (r.data && r.data.contained === false) principalNote(COPY.containmentIncomplete);
1031
+ }
1032
+
1033
+ async function postActionJson(name, body) {
1034
+ var res = await fetch(ACTIONS + "/" + name, {
1035
+ method: "POST",
1036
+ headers: { "Content-Type": "application/json" },
1037
+ credentials: "same-origin",
1038
+ body: JSON.stringify(body)
1039
+ });
1040
+ var data = null;
1041
+ var unreadable = false;
1042
+ try { data = await res.json(); } catch (e) { unreadable = true; }
1043
+ return { ok: res.ok && !unreadable, status: res.status, data: data };
1044
+ }
1045
+
1046
+ async function loadPrincipals() {
1047
+ principalNote("");
1048
+ try {
1049
+ var res = await fetch(ACTIONS + "/list-org-service-tokens", { credentials: "same-origin" });
1050
+ if (res.status === 400 || res.status === 401 || res.status === 403) {
1051
+ principalsEl.classList.add("hidden");
1052
+ return;
1053
+ }
1054
+ if (!res.ok) throw new Error("HTTP " + res.status);
1055
+ var data = await res.json();
1056
+ var principals = (data && data.principals) || [];
1057
+ if (!principals.length) {
1058
+ principalsEl.classList.add("hidden");
1059
+ return;
1060
+ }
1061
+ principalsEl.classList.remove("hidden");
1062
+ var needAction = principals.filter(function (p) { return p.state === "ungoverned"; }).length;
1063
+ principalsStateEl.textContent = needAction ? String(needAction) : "";
1064
+ principalsStateEl.classList.toggle("hidden", needAction === 0);
1065
+ principalListEl.innerHTML = "";
1066
+ principals.forEach(function (p) {
1067
+ var div = document.createElement("div");
1068
+ div.className = "tok" + (p.state === "retired" ? " revoked" : "");
1069
+ var left = document.createElement("div");
1070
+ var title = document.createElement("div");
1071
+ title.textContent = p.serviceName;
1072
+ var badge = document.createElement("span");
1073
+ badge.className = "pstate " + p.state;
1074
+ badge.textContent = COPY[PRINCIPAL_STATES[p.state]] || p.state;
1075
+ title.appendChild(badge);
1076
+ var meta = document.createElement("div");
1077
+ meta.className = "meta";
1078
+ meta.textContent = p.state === "ungoverned"
1079
+ ? COPY.principalUngovernedHint
1080
+ : (p.ownerEmail ? COPY.principalOwner + ": " + p.ownerEmail + " · " : "") +
1081
+ COPY.principalRisk + ": " + (COPY[RISK_TIERS[p.riskTier]] || p.riskTier);
1082
+ left.appendChild(title); left.appendChild(meta);
1083
+ div.appendChild(left);
1084
+ var next = p.state === "suspended" ? "active" : p.state === "retired" ? null : "suspended";
1085
+ if (data.canManage && next) {
1086
+ var btn = document.createElement("button");
1087
+ btn.className = "ghost";
1088
+ btn.textContent = next === "active" ? COPY.resume : COPY.suspend;
1089
+ btn.onclick = function () { setLifecycle(p.serviceName, next, btn).catch(function () { btn.disabled = false; principalNote(COPY.couldNotUpdatePrincipal); }); };
1090
+ div.appendChild(btn);
1091
+ }
1092
+ principalListEl.appendChild(div);
1093
+ });
1094
+ } catch (e) {
1095
+ principalsEl.classList.remove("hidden");
1096
+ principalsStateEl.textContent = COPY.unavailable;
1097
+ principalsStateEl.classList.remove("hidden");
1098
+ principalNote(COPY.couldNotLoadConnections);
1099
+ }
1100
+ }
1101
+
991
1102
  document.getElementById("authorizeBtn").onclick = async function () {
992
1103
  var btn = this;
993
1104
  setButtonLoading(btn, USER_CODE ? COPY.authorizingDevice : COPY.creatingToken);
@@ -1078,6 +1189,7 @@ function renderConnectPage(params) {
1078
1189
  };
1079
1190
 
1080
1191
  loadTokens();
1192
+ if (!USER_CODE) loadPrincipals();
1081
1193
  })();
1082
1194
  </script>
1083
1195
  </body>