m365-agent-cli 1.2.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 (92) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +916 -0
  3. package/package.json +50 -0
  4. package/src/cli.ts +100 -0
  5. package/src/commands/auto-reply.ts +182 -0
  6. package/src/commands/calendar.ts +576 -0
  7. package/src/commands/counter.ts +87 -0
  8. package/src/commands/create-event.ts +544 -0
  9. package/src/commands/delegates.ts +286 -0
  10. package/src/commands/delete-event.ts +321 -0
  11. package/src/commands/drafts.ts +502 -0
  12. package/src/commands/files.ts +532 -0
  13. package/src/commands/find.ts +195 -0
  14. package/src/commands/findtime.ts +270 -0
  15. package/src/commands/folders.ts +177 -0
  16. package/src/commands/forward-event.ts +49 -0
  17. package/src/commands/graph-calendar.ts +217 -0
  18. package/src/commands/login.ts +195 -0
  19. package/src/commands/mail.ts +950 -0
  20. package/src/commands/oof.ts +263 -0
  21. package/src/commands/outlook-categories.ts +173 -0
  22. package/src/commands/outlook-graph.ts +880 -0
  23. package/src/commands/planner.ts +1678 -0
  24. package/src/commands/respond.ts +291 -0
  25. package/src/commands/rooms.ts +210 -0
  26. package/src/commands/rules.ts +511 -0
  27. package/src/commands/schedule.ts +109 -0
  28. package/src/commands/send.ts +204 -0
  29. package/src/commands/serve.ts +14 -0
  30. package/src/commands/sharepoint.ts +179 -0
  31. package/src/commands/site-pages.ts +163 -0
  32. package/src/commands/subscribe.ts +103 -0
  33. package/src/commands/subscriptions.ts +29 -0
  34. package/src/commands/suggest.ts +155 -0
  35. package/src/commands/todo.ts +2092 -0
  36. package/src/commands/update-event.ts +608 -0
  37. package/src/commands/update.ts +88 -0
  38. package/src/commands/verify-token.ts +62 -0
  39. package/src/commands/whoami.ts +74 -0
  40. package/src/index.ts +190 -0
  41. package/src/lib/atomic-write.ts +20 -0
  42. package/src/lib/attach-link-spec.test.ts +24 -0
  43. package/src/lib/attach-link-spec.ts +70 -0
  44. package/src/lib/attachments.ts +79 -0
  45. package/src/lib/auth.ts +192 -0
  46. package/src/lib/calendar-range.test.ts +41 -0
  47. package/src/lib/calendar-range.ts +103 -0
  48. package/src/lib/dates.test.ts +74 -0
  49. package/src/lib/dates.ts +137 -0
  50. package/src/lib/delegate-client.test.ts +74 -0
  51. package/src/lib/delegate-client.ts +322 -0
  52. package/src/lib/ews-client.ts +3418 -0
  53. package/src/lib/git-commit.ts +4 -0
  54. package/src/lib/glitchtip-eligibility.ts +220 -0
  55. package/src/lib/glitchtip.ts +253 -0
  56. package/src/lib/global-env.ts +3 -0
  57. package/src/lib/graph-auth.ts +223 -0
  58. package/src/lib/graph-calendar-client.test.ts +118 -0
  59. package/src/lib/graph-calendar-client.ts +112 -0
  60. package/src/lib/graph-client.test.ts +107 -0
  61. package/src/lib/graph-client.ts +1058 -0
  62. package/src/lib/graph-constants.ts +12 -0
  63. package/src/lib/graph-directory.ts +116 -0
  64. package/src/lib/graph-event.ts +134 -0
  65. package/src/lib/graph-schedule.ts +173 -0
  66. package/src/lib/graph-subscriptions.ts +94 -0
  67. package/src/lib/graph-user-path.ts +13 -0
  68. package/src/lib/jwt-utils.ts +34 -0
  69. package/src/lib/markdown.test.ts +21 -0
  70. package/src/lib/markdown.ts +174 -0
  71. package/src/lib/mime-type.ts +106 -0
  72. package/src/lib/oof-client.test.ts +59 -0
  73. package/src/lib/oof-client.ts +122 -0
  74. package/src/lib/outlook-graph-client.test.ts +146 -0
  75. package/src/lib/outlook-graph-client.ts +649 -0
  76. package/src/lib/outlook-master-categories.ts +145 -0
  77. package/src/lib/package-info.ts +59 -0
  78. package/src/lib/places-client.ts +144 -0
  79. package/src/lib/planner-client.ts +1226 -0
  80. package/src/lib/rules-client.ts +178 -0
  81. package/src/lib/sharepoint-client.ts +101 -0
  82. package/src/lib/site-pages-client.ts +73 -0
  83. package/src/lib/todo-client.test.ts +298 -0
  84. package/src/lib/todo-client.ts +1309 -0
  85. package/src/lib/url-validation.ts +40 -0
  86. package/src/lib/utils.ts +45 -0
  87. package/src/lib/webhook-server.ts +51 -0
  88. package/src/test/auth.test.ts +104 -0
  89. package/src/test/cli.integration.test.ts +1083 -0
  90. package/src/test/ews-client.test.ts +268 -0
  91. package/src/test/mocks/index.ts +375 -0
  92. package/src/test/mocks/responses.ts +861 -0
@@ -0,0 +1,40 @@
1
+ import { isIP } from 'node:net';
2
+ /**
3
+ * Validates a URL is safe for use as an API endpoint.
4
+ * Blocks SSRF vectors: non-HTTPS protocols, localhost, link-local, and internal IPs.
5
+ */
6
+ export function validateUrl(urlString: string, name: string): string {
7
+ let url: URL;
8
+ try {
9
+ url = new URL(urlString);
10
+ } catch {
11
+ throw new Error(`Invalid URL for ${name}: "${urlString}"`);
12
+ }
13
+
14
+ if (url.protocol !== 'https:') {
15
+ throw new Error(`${name} must use HTTPS, got: "${urlString}"`);
16
+ }
17
+
18
+ let hostname = url.hostname.toLowerCase();
19
+
20
+ // Strip brackets from IPv6 addresses before validation
21
+ // WHATWG URL returns IPv6 addresses with brackets (e.g., [::1]), but net.isIP expects bare IPs
22
+ hostname = hostname.replace(/^\[|\]$/g, '');
23
+
24
+ // Block localhost variants
25
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
26
+ throw new Error(`${name} must not point to localhost: "${urlString}"`);
27
+ }
28
+
29
+ // Block bare IPv4 addresses — reject all IP literals to prevent internal network access
30
+ if (isIP(hostname)) {
31
+ throw new Error(`${name} must not be an IP address (use hostname): "${urlString}"`);
32
+ }
33
+
34
+ // Block cloud metadata endpoints (common SSRF target)
35
+ if (hostname === 'metadata.google.internal' || hostname.startsWith('169.254.')) {
36
+ throw new Error(`${name} must not point to link-local/metadata endpoint: "${urlString}"`);
37
+ }
38
+
39
+ return urlString;
40
+ }
@@ -0,0 +1,45 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ export function loadGlobalEnv() {
6
+ const globalEnvPath = join(homedir(), '.config', 'm365-agent-cli', '.env');
7
+ if (existsSync(globalEnvPath)) {
8
+ const content = readFileSync(globalEnvPath, 'utf8');
9
+ for (const line of content.split('\n')) {
10
+ const match = line.match(/^\s*([^#\s=]+)\s*=\s*(.*)$/);
11
+ if (match) {
12
+ const key = match[1];
13
+ let val = match[2].trim();
14
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
15
+ val = val.slice(1, -1);
16
+ }
17
+ if (process.env[key] === undefined) {
18
+ process.env[key] = val;
19
+ }
20
+ }
21
+ }
22
+ }
23
+ }
24
+
25
+ export function checkReadOnly(cmdOrOptions?: any) {
26
+ let isReadOnly = process.env.READ_ONLY_MODE === 'true';
27
+
28
+ if (cmdOrOptions) {
29
+ // If it's a Commander Command instance
30
+ if (typeof cmdOrOptions.optsWithGlobals === 'function') {
31
+ if (cmdOrOptions.optsWithGlobals().readOnly) {
32
+ isReadOnly = true;
33
+ }
34
+ }
35
+ // If it's just an options object
36
+ else if (cmdOrOptions.readOnly) {
37
+ isReadOnly = true;
38
+ }
39
+ }
40
+
41
+ if (isReadOnly) {
42
+ console.error('Error: Command blocked. The CLI is running in read-only mode.');
43
+ process.exit(1);
44
+ }
45
+ }
@@ -0,0 +1,51 @@
1
+ import { serve } from 'bun';
2
+
3
+ export function startWebhookServer(port: number = 3000) {
4
+ console.log(`Starting webhook receiver on http://localhost:${port}/webhooks/m365-agent-cli`);
5
+ serve({
6
+ port,
7
+ async fetch(req) {
8
+ const url = new URL(req.url);
9
+ if (url.pathname === '/webhooks/m365-agent-cli' || url.pathname === '/webhooks/clippy') {
10
+ // Microsoft Graph sends validationToken as a query param on POST (not GET)
11
+ const validationToken = url.searchParams.get('validationToken');
12
+ if (validationToken) {
13
+ console.log(`[${new Date().toISOString()}] Received validation token request. Replaying token...`);
14
+ return new Response(validationToken, {
15
+ status: 200,
16
+ headers: { 'Content-Type': 'text/plain' }
17
+ });
18
+ }
19
+ if (req.method === 'POST') {
20
+ try {
21
+ const body = await req.json();
22
+
23
+ // Validate clientState if configured
24
+ const expectedClientState = process.env.GRAPH_CLIENT_STATE;
25
+ const notifications = Array.isArray((body as any).value) ? (body as any).value : null;
26
+ if (expectedClientState) {
27
+ const allClientStatesValid =
28
+ !!notifications &&
29
+ notifications.length > 0 &&
30
+ notifications.every((n: any) => n && n.clientState === expectedClientState);
31
+ if (!allClientStatesValid) {
32
+ console.warn(
33
+ `[${new Date().toISOString()}] Received Graph notification with invalid or missing clientState.`
34
+ );
35
+ return new Response('Invalid clientState', { status: 401 });
36
+ }
37
+ }
38
+
39
+ console.log(`[${new Date().toISOString()}] Received Graph notification:`);
40
+ console.log(JSON.stringify(body, null, 2));
41
+ return new Response('Accepted', { status: 202 });
42
+ } catch (err) {
43
+ console.error('Error parsing notification body:', err);
44
+ return new Response('Bad Request', { status: 400 });
45
+ }
46
+ }
47
+ }
48
+ return new Response('Not Found', { status: 404 });
49
+ }
50
+ });
51
+ }
@@ -0,0 +1,104 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import { resolveAuth } from '../lib/auth.js';
3
+
4
+ const mockRead = mock();
5
+ const mockWrite = mock();
6
+ const mockFetch = mock();
7
+
8
+ mock.module('node:fs/promises', () => ({
9
+ readFile: mockRead,
10
+ writeFile: mockWrite,
11
+ mkdir: mock(() => Promise.resolve()),
12
+ rename: mock(() => Promise.resolve()),
13
+ unlink: mock(() => Promise.resolve())
14
+ }));
15
+
16
+ const mockGetJwtExpiration = mock(() => Date.now() + 3600_000);
17
+ const mockIsValidJwtStructure = mock(() => true);
18
+ const mockGetMicrosoftTenantPathSegment = mock(() => 'common');
19
+
20
+ mock.module('../lib/jwt-utils.js', () => ({
21
+ getJwtExpiration: mockGetJwtExpiration,
22
+ isValidJwtStructure: mockIsValidJwtStructure,
23
+ getMicrosoftTenantPathSegment: mockGetMicrosoftTenantPathSegment
24
+ }));
25
+
26
+ describe('auth resolution', () => {
27
+ let originalEnv: NodeJS.ProcessEnv;
28
+
29
+ beforeEach(() => {
30
+ originalEnv = { ...process.env };
31
+ global.fetch = mockFetch as any as any;
32
+ mockRead.mockClear();
33
+ mockWrite.mockClear();
34
+ mockFetch.mockClear();
35
+ mockGetJwtExpiration.mockClear();
36
+ mockIsValidJwtStructure.mockClear();
37
+ });
38
+
39
+ afterEach(() => {
40
+ process.env = originalEnv;
41
+ });
42
+
43
+ test('uses explicit token when provided', async () => {
44
+ const result = await resolveAuth({ token: 'my-explicit-token' });
45
+ expect(result.success).toBe(true);
46
+ expect(result.token).toBe('my-explicit-token');
47
+ });
48
+
49
+ test('returns error if client ID or refresh token missing', async () => {
50
+ delete process.env.EWS_CLIENT_ID;
51
+ delete process.env.EWS_REFRESH_TOKEN;
52
+ const result = await resolveAuth();
53
+ expect(result.success).toBe(false);
54
+ expect(result.error).toContain('Missing EWS_CLIENT_ID or EWS_REFRESH_TOKEN');
55
+ });
56
+
57
+ test('uses valid cached token', async () => {
58
+ process.env.EWS_CLIENT_ID = 'client';
59
+ process.env.EWS_REFRESH_TOKEN = 'refresh';
60
+
61
+ mockRead.mockResolvedValue(
62
+ JSON.stringify({
63
+ accessToken: 'cached-access-token',
64
+ refreshToken: 'cached-refresh-token',
65
+ expiresAt: Date.now() + 1000_000
66
+ })
67
+ );
68
+
69
+ const result = await resolveAuth();
70
+ expect(result.success).toBe(true);
71
+ expect(result.token).toBe('cached-access-token');
72
+ expect(mockFetch).not.toHaveBeenCalled();
73
+ });
74
+
75
+ test('fetches new token if cache expired', async () => {
76
+ process.env.EWS_CLIENT_ID = 'client';
77
+ process.env.EWS_REFRESH_TOKEN = 'refresh';
78
+
79
+ mockRead.mockResolvedValue(
80
+ JSON.stringify({
81
+ accessToken: 'expired-access-token',
82
+ refreshToken: 'cached-refresh-token',
83
+ expiresAt: Date.now() - 1000_000
84
+ })
85
+ );
86
+
87
+ mockFetch.mockResolvedValue(
88
+ new Response(
89
+ JSON.stringify({
90
+ access_token: 'new-access-token',
91
+ refresh_token: 'new-refresh-token',
92
+ expires_in: 3600
93
+ }),
94
+ { status: 200 }
95
+ )
96
+ );
97
+
98
+ const result = await resolveAuth();
99
+ expect(result.success).toBe(true);
100
+ expect(result.token).toBe('new-access-token');
101
+ expect(mockFetch).toHaveBeenCalled();
102
+ expect(mockWrite).toHaveBeenCalled();
103
+ });
104
+ });