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.
- package/LICENSE +22 -0
- package/README.md +916 -0
- package/package.json +50 -0
- package/src/cli.ts +100 -0
- package/src/commands/auto-reply.ts +182 -0
- package/src/commands/calendar.ts +576 -0
- package/src/commands/counter.ts +87 -0
- package/src/commands/create-event.ts +544 -0
- package/src/commands/delegates.ts +286 -0
- package/src/commands/delete-event.ts +321 -0
- package/src/commands/drafts.ts +502 -0
- package/src/commands/files.ts +532 -0
- package/src/commands/find.ts +195 -0
- package/src/commands/findtime.ts +270 -0
- package/src/commands/folders.ts +177 -0
- package/src/commands/forward-event.ts +49 -0
- package/src/commands/graph-calendar.ts +217 -0
- package/src/commands/login.ts +195 -0
- package/src/commands/mail.ts +950 -0
- package/src/commands/oof.ts +263 -0
- package/src/commands/outlook-categories.ts +173 -0
- package/src/commands/outlook-graph.ts +880 -0
- package/src/commands/planner.ts +1678 -0
- package/src/commands/respond.ts +291 -0
- package/src/commands/rooms.ts +210 -0
- package/src/commands/rules.ts +511 -0
- package/src/commands/schedule.ts +109 -0
- package/src/commands/send.ts +204 -0
- package/src/commands/serve.ts +14 -0
- package/src/commands/sharepoint.ts +179 -0
- package/src/commands/site-pages.ts +163 -0
- package/src/commands/subscribe.ts +103 -0
- package/src/commands/subscriptions.ts +29 -0
- package/src/commands/suggest.ts +155 -0
- package/src/commands/todo.ts +2092 -0
- package/src/commands/update-event.ts +608 -0
- package/src/commands/update.ts +88 -0
- package/src/commands/verify-token.ts +62 -0
- package/src/commands/whoami.ts +74 -0
- package/src/index.ts +190 -0
- package/src/lib/atomic-write.ts +20 -0
- package/src/lib/attach-link-spec.test.ts +24 -0
- package/src/lib/attach-link-spec.ts +70 -0
- package/src/lib/attachments.ts +79 -0
- package/src/lib/auth.ts +192 -0
- package/src/lib/calendar-range.test.ts +41 -0
- package/src/lib/calendar-range.ts +103 -0
- package/src/lib/dates.test.ts +74 -0
- package/src/lib/dates.ts +137 -0
- package/src/lib/delegate-client.test.ts +74 -0
- package/src/lib/delegate-client.ts +322 -0
- package/src/lib/ews-client.ts +3418 -0
- package/src/lib/git-commit.ts +4 -0
- package/src/lib/glitchtip-eligibility.ts +220 -0
- package/src/lib/glitchtip.ts +253 -0
- package/src/lib/global-env.ts +3 -0
- package/src/lib/graph-auth.ts +223 -0
- package/src/lib/graph-calendar-client.test.ts +118 -0
- package/src/lib/graph-calendar-client.ts +112 -0
- package/src/lib/graph-client.test.ts +107 -0
- package/src/lib/graph-client.ts +1058 -0
- package/src/lib/graph-constants.ts +12 -0
- package/src/lib/graph-directory.ts +116 -0
- package/src/lib/graph-event.ts +134 -0
- package/src/lib/graph-schedule.ts +173 -0
- package/src/lib/graph-subscriptions.ts +94 -0
- package/src/lib/graph-user-path.ts +13 -0
- package/src/lib/jwt-utils.ts +34 -0
- package/src/lib/markdown.test.ts +21 -0
- package/src/lib/markdown.ts +174 -0
- package/src/lib/mime-type.ts +106 -0
- package/src/lib/oof-client.test.ts +59 -0
- package/src/lib/oof-client.ts +122 -0
- package/src/lib/outlook-graph-client.test.ts +146 -0
- package/src/lib/outlook-graph-client.ts +649 -0
- package/src/lib/outlook-master-categories.ts +145 -0
- package/src/lib/package-info.ts +59 -0
- package/src/lib/places-client.ts +144 -0
- package/src/lib/planner-client.ts +1226 -0
- package/src/lib/rules-client.ts +178 -0
- package/src/lib/sharepoint-client.ts +101 -0
- package/src/lib/site-pages-client.ts +73 -0
- package/src/lib/todo-client.test.ts +298 -0
- package/src/lib/todo-client.ts +1309 -0
- package/src/lib/url-validation.ts +40 -0
- package/src/lib/utils.ts +45 -0
- package/src/lib/webhook-server.ts +51 -0
- package/src/test/auth.test.ts +104 -0
- package/src/test/cli.integration.test.ts +1083 -0
- package/src/test/ews-client.test.ts +268 -0
- package/src/test/mocks/index.ts +375 -0
- 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
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -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
|
+
});
|