@libai168/dsh-tool-google-drive 0.2.0 → 0.4.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/README.md CHANGED
@@ -34,6 +34,8 @@ Recommended read-only OAuth scopes:
34
34
  - Docs read: `https://www.googleapis.com/auth/documents.readonly`
35
35
  - Sheets read: `https://www.googleapis.com/auth/spreadsheets.readonly`
36
36
 
37
+ Both `baseUrl` and `tokenUrl` overrides must be absolute `http://` or `https://` root URLs. Only publicly reachable hosts are allowed: localhost, loopback, private, link-local, CGNAT, multicast, reserved/documentation/benchmark ranges, and every IANA special-purpose block are rejected, and a hostname whose DNS results contain any such address fails closed before the request is sent. Credentials, query strings, fragments, and non-root paths are not allowed.
38
+
37
39
  ## OAuth helper
38
40
 
39
41
  This package includes a small no-dependency helper that generates a Google OAuth consent URL, captures the loopback callback, exchanges the authorization code, and prints a ready-to-copy Cordis config snippet with a refresh token.
@@ -71,7 +73,7 @@ The helper requests offline access with consent prompting so Google can return a
71
73
 
72
74
  | Tool | Description | Write |
73
75
  |---|---|---|
74
- | `gdrive_auth_test` | Verify Google Drive credentials and return token metadata | no |
76
+ | `gdrive_auth_test` | Verify Google Drive credentials without returning token material | no |
75
77
  | `gdrive_list_files` | List or search Drive files by query and pagination | no |
76
78
  | `gdrive_get_file` | Get one Drive file's metadata by file ID | no |
77
79
  | `gdrive_list_permissions` | List file/folder/shared-drive sharing permissions with pagination | no |
@@ -115,6 +117,8 @@ gsheets_get_values({ spreadsheetId: 'spreadsheet_id', range: 'Sheet1!A1:D20' })
115
117
 
116
118
  Permission and revision listings return an opaque `nextPageToken`; pass it back as `pageToken` to continue. `gdrive_list_permissions` can include shared-drive and domain-admin flags when the OAuth principal has those permissions. Permission email addresses, revision download URLs, and exported content are returned only by explicitly requested read tools; treat them as sensitive workspace data.
117
119
 
120
+ Export, Docs, and Sheets content is bounded in the client result layer. Export output accepts maxBytes (default 1 MiB, capped at 10 MiB); Docs accepts maxBytes/maxTabs; Sheets accepts maxRows/maxColumns/maxCells. Results include truncated and contentLength so callers can detect an imposed limit.
121
+
118
122
  ## Development
119
123
 
120
124
  ```sh
package/README.zh.md CHANGED
@@ -34,6 +34,8 @@ npm install @libai168/dsh-tool-google-drive
34
34
  - Docs 读取:`https://www.googleapis.com/auth/documents.readonly`
35
35
  - Sheets 读取:`https://www.googleapis.com/auth/spreadsheets.readonly`
36
36
 
37
+ `baseUrl` 与 `tokenUrl` 两个覆盖都必须是绝对的 `http://` 或 `https://` 根地址。只允许公网可达主机:localhost、环回、私有、链路本地、CGNAT、组播、保留/文档/基准测试网段以及全部 IANA 特殊用途地址段都会被拒绝;DNS 结果包含任一此类地址时会在发出请求前 fail closed。不允许 credentials、query、fragment 或非根路径。
38
+
37
39
  ## OAuth 授权辅助脚本
38
40
 
39
41
  本包内置一个无额外依赖的辅助脚本:生成 Google OAuth 授权 URL、监听本机回调、用授权码换取 token,并输出可直接复制到 Cordis 配置里的 `refreshToken` 片段。
@@ -71,7 +73,7 @@ npm install @libai168/dsh-tool-google-drive
71
73
 
72
74
  | 工具 | 说明 | 写操作 |
73
75
  |---|---|---|
74
- | `gdrive_auth_test` | 验证 Google Drive 凭证并返回 token 元信息 | 否 |
76
+ | `gdrive_auth_test` | 验证 Google Drive 凭证但不返回 token 内容 | 否 |
75
77
  | `gdrive_list_files` | 按查询与分页列出或搜索 Drive 文件 | 否 |
76
78
  | `gdrive_get_file` | 按文件 ID 获取 Drive 文件元数据 | 否 |
77
79
  | `gdrive_list_permissions` | 分页列出文件、文件夹或共享云端硬盘的共享权限 | 否 |
@@ -115,6 +117,8 @@ gsheets_get_values({ spreadsheetId: 'spreadsheet_id', range: 'Sheet1!A1:D20' })
115
117
 
116
118
  权限和修订列表会返回不透明的 `nextPageToken`;把它作为 `pageToken` 传回即可继续分页。`gdrive_list_permissions` 支持在 OAuth 主体具备权限时传入共享云端硬盘和域管理员参数。权限邮箱、修订下载 URL 以及导出内容只会由明确调用的只读工具返回,请将它们视为敏感工作区数据。
117
119
 
120
+ 导出、Docs 和 Sheets 内容会在客户端结果层限额。导出支持 maxBytes(默认 1 MiB,最多 10 MiB),Docs 支持 maxBytes/maxTabs,Sheets 支持 maxRows/maxColumns/maxCells。结果包含 truncated 和 contentLength,调用方可识别是否触发限额。
121
+
118
122
  ## 开发
119
123
 
120
124
  ```sh
package/lib/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Google Drive API client with injected fetch for testability. */
2
+ import { assertSafeUrl, EndpointSecurityError, normalizeBaseUrl } from './url-security.js';
2
3
  export class GoogleDriveError extends Error {
3
4
  code;
4
5
  constructor(message, code) {
@@ -33,6 +34,23 @@ function toJson(value) {
33
34
  return '{}';
34
35
  }
35
36
  }
37
+ function clampBytes(value, fallback = 1024 * 1024, maximum = 10 * 1024 * 1024) {
38
+ if (!Number.isFinite(value))
39
+ return fallback;
40
+ return Math.max(1, Math.min(maximum, Math.trunc(value)));
41
+ }
42
+ function clampCount(value, fallback, maximum) {
43
+ if (!Number.isFinite(value))
44
+ return fallback;
45
+ return Math.max(1, Math.min(maximum, Math.trunc(value)));
46
+ }
47
+ function limitUtf8(value, maxBytes) {
48
+ const source = Buffer.from(value, 'utf8');
49
+ if (source.byteLength <= maxBytes)
50
+ return { value, contentLength: source.byteLength, truncated: false };
51
+ const limited = source.subarray(0, maxBytes).toString('utf8');
52
+ return { value: limited, contentLength: Buffer.byteLength(limited, 'utf8'), truncated: true };
53
+ }
36
54
  function mapOwners(value) {
37
55
  return asArray(value).map(item => {
38
56
  const record = asRecord(item);
@@ -186,9 +204,27 @@ function mapSpreadsheet(data) {
186
204
  sheets: asArray(r.sheets).map(item => mapSheetProperties(asRecord(item).properties ?? item)),
187
205
  };
188
206
  }
189
- function mapSheetValues(data, spreadsheetId, fallbackRange = '') {
207
+ function mapSheetValues(data, spreadsheetId, fallbackRange = '', options = {}) {
190
208
  const r = asRecord(data);
191
- const values = asArray(r.values).map(row => asArray(row).map(cell => cell == null ? '' : String(cell)));
209
+ const maxRows = clampCount(options.maxRows, 1000, 10000);
210
+ const maxColumns = clampCount(options.maxColumns, 100, 1000);
211
+ const maxCells = clampCount(options.maxCells, 100000, 1000000);
212
+ const rawValues = asArray(r.values);
213
+ let truncated = rawValues.length > maxRows;
214
+ let remaining = maxCells;
215
+ const values = [];
216
+ for (const row of rawValues.slice(0, maxRows)) {
217
+ const rawRow = asArray(row);
218
+ if (rawRow.length > maxColumns || rawRow.length > remaining)
219
+ truncated = true;
220
+ const limitedRow = rawRow.slice(0, Math.min(maxColumns, remaining)).map(cell => cell == null ? '' : String(cell));
221
+ values.push(limitedRow);
222
+ remaining -= limitedRow.length;
223
+ if (remaining <= 0 && values.length < rawValues.length) {
224
+ truncated = true;
225
+ break;
226
+ }
227
+ }
192
228
  return {
193
229
  spreadsheetId,
194
230
  range: asString(r, 'range') || fallbackRange,
@@ -196,6 +232,8 @@ function mapSheetValues(data, spreadsheetId, fallbackRange = '') {
196
232
  rowCount: values.length,
197
233
  columnCount: values.reduce((max, row) => Math.max(max, row.length), 0),
198
234
  values,
235
+ contentLength: values.reduce((total, row) => total + row.length, 0),
236
+ truncated,
199
237
  };
200
238
  }
201
239
  export class GoogleDriveClient {
@@ -207,16 +245,32 @@ export class GoogleDriveClient {
207
245
  tokenUrl;
208
246
  timeoutMs;
209
247
  fetchImpl;
248
+ lookupImpl;
210
249
  tokenCache = null;
211
250
  constructor(options = {}) {
212
251
  this.staticToken = options.accessToken ?? '';
213
252
  this.clientId = options.clientId ?? '';
214
253
  this.clientSecret = options.clientSecret ?? '';
215
254
  this.refreshToken = options.refreshToken ?? '';
216
- this.baseUrl = (options.baseUrl ?? 'https://www.googleapis.com/drive/v3').replace(/\/+$/, '');
217
- this.tokenUrl = options.tokenUrl ?? 'https://oauth2.googleapis.com/token';
255
+ try {
256
+ this.baseUrl = normalizeBaseUrl(options.baseUrl, 'https://www.googleapis.com/drive/v3');
257
+ }
258
+ catch (error) {
259
+ if (error instanceof EndpointSecurityError)
260
+ throw new GoogleDriveError(error.message, 400);
261
+ throw error;
262
+ }
263
+ try {
264
+ this.tokenUrl = normalizeBaseUrl(options.tokenUrl, 'https://oauth2.googleapis.com/token');
265
+ }
266
+ catch (error) {
267
+ if (error instanceof EndpointSecurityError)
268
+ throw new GoogleDriveError(error.message, 400);
269
+ throw error;
270
+ }
218
271
  this.timeoutMs = options.timeoutMs ?? 15000;
219
272
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
273
+ this.lookupImpl = options.lookupImpl;
220
274
  }
221
275
  hasCredentials() {
222
276
  return Boolean(this.staticToken || (this.clientId && this.clientSecret && this.refreshToken));
@@ -260,6 +314,14 @@ export class GoogleDriveClient {
260
314
  const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
261
315
  const timer = this.timeoutMs > 0 ? setTimeout(() => controller.abort(), this.timeoutMs) : undefined;
262
316
  try {
317
+ try {
318
+ await assertSafeUrl(new URL(this.tokenUrl), this.lookupImpl);
319
+ }
320
+ catch (error) {
321
+ if (error instanceof EndpointSecurityError)
322
+ throw new GoogleDriveError(error.message, 400);
323
+ throw error;
324
+ }
263
325
  const response = await this.fetchImpl(this.tokenUrl, {
264
326
  method: 'POST',
265
327
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
@@ -296,6 +358,14 @@ export class GoogleDriveClient {
296
358
  const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
297
359
  const timer = this.timeoutMs > 0 ? setTimeout(() => controller.abort(), this.timeoutMs) : undefined;
298
360
  try {
361
+ try {
362
+ await assertSafeUrl(new URL(url), this.lookupImpl);
363
+ }
364
+ catch (error) {
365
+ if (error instanceof EndpointSecurityError)
366
+ throw new GoogleDriveError(error.message, 400);
367
+ throw error;
368
+ }
299
369
  const response = await this.fetchImpl(url, {
300
370
  method,
301
371
  headers,
@@ -318,8 +388,8 @@ export class GoogleDriveClient {
318
388
  }
319
389
  }
320
390
  async authTest(signal) {
321
- const token = await this.getAccessToken(signal);
322
- return { ok: true, authMethod: this.staticToken ? 'access_token' : 'refresh_token', tokenPreview: `${token.slice(0, 8)}...` };
391
+ await this.getAccessToken(signal);
392
+ return { ok: true, authMethod: this.staticToken ? 'access_token' : 'refresh_token' };
323
393
  }
324
394
  async listFiles(options = {}) {
325
395
  const data = asRecord(await this.request('GET', '/files', {
@@ -389,10 +459,20 @@ export class GoogleDriveClient {
389
459
  responseType: responseEncoding === 'base64' ? 'arrayBuffer' : 'text',
390
460
  signal: options.signal,
391
461
  });
392
- const content = responseEncoding === 'base64'
393
- ? Buffer.from(raw).toString('base64')
394
- : String(raw);
395
- return { fileId, exportMimeType, encoding: responseEncoding, content, contentLength: content.length };
462
+ const maxBytes = clampBytes(options.maxBytes);
463
+ let content;
464
+ let truncated = false;
465
+ if (responseEncoding === 'base64') {
466
+ const bytes = Buffer.from(raw);
467
+ truncated = bytes.byteLength > maxBytes;
468
+ content = bytes.subarray(0, maxBytes).toString('base64');
469
+ }
470
+ else {
471
+ const limited = limitUtf8(String(raw), maxBytes);
472
+ content = limited.value;
473
+ truncated = limited.truncated;
474
+ }
475
+ return { fileId, exportMimeType, encoding: responseEncoding, content, contentLength: content.length, truncated };
396
476
  }
397
477
  async listSharedDrives(options = {}) {
398
478
  const data = asRecord(await this.request('GET', '/drives', {
@@ -432,14 +512,19 @@ export class GoogleDriveClient {
432
512
  signal: options.signal,
433
513
  });
434
514
  const extracted = extractDocumentContent(data);
515
+ const limitedText = limitUtf8(extracted.text, clampBytes(options.maxBytes));
516
+ const maxTabs = clampCount(options.maxTabs, 100, 1000);
517
+ const tabs = extracted.tabs.slice(0, maxTabs);
435
518
  const r = asRecord(data);
436
519
  return {
437
520
  documentId: asString(r, 'documentId') || documentId,
438
521
  title: asString(r, 'title'),
439
522
  revisionId: asString(r, 'revisionId'),
440
- text: extracted.text,
523
+ text: limitedText.value,
441
524
  tabCount: extracted.tabs.length,
442
- tabs: extracted.tabs,
525
+ tabs,
526
+ contentLength: limitedText.contentLength,
527
+ truncated: limitedText.truncated || extracted.tabs.length > tabs.length,
443
528
  };
444
529
  }
445
530
  async getSpreadsheet(spreadsheetId, options = {}) {
@@ -462,6 +547,6 @@ export class GoogleDriveClient {
462
547
  },
463
548
  signal: options.signal,
464
549
  });
465
- return mapSheetValues(data, spreadsheetId, range);
550
+ return mapSheetValues(data, spreadsheetId, range, options);
466
551
  }
467
552
  }
package/lib/index.js CHANGED
@@ -90,14 +90,14 @@ export function createTools(client) {
90
90
  return [
91
91
  defineTool({
92
92
  name: 'gdrive_auth_test',
93
- description: 'Verify Google Drive credentials and return token metadata.',
93
+ description: 'Verify Google Drive credentials without returning token material.',
94
94
  parameters: {},
95
95
  output: {
96
96
  schema: {
97
97
  type: 'object', additionalProperties: false,
98
- properties: { ok: { type: 'boolean' }, reason: { type: 'string' }, authMethod: { type: 'string' }, tokenPreview: { type: 'string' } },
98
+ properties: { ok: { type: 'boolean' }, reason: { type: 'string' }, authMethod: { type: 'string' } },
99
99
  },
100
- render: (_args, value) => value.ok ? text(`authMethod: ${value.authMethod}\ntoken: ${value.tokenPreview}`) : text(`Google Drive auth failed: ${value.reason}`),
100
+ render: (_args, value) => value.ok ? text('authMethod: ' + value.authMethod) : text('Google Drive auth failed: ' + value.reason),
101
101
  },
102
102
  presentCall() { return { card: 'generic', title: 'Verify Google Drive credentials', kind: 'read' }; },
103
103
  async execute(_args, exec) {
@@ -224,14 +224,14 @@ export function createTools(client) {
224
224
  defineTool({
225
225
  name: 'gdrive_export_file',
226
226
  description: 'Export a Google Workspace file to text or another MIME type. Read-only operation.',
227
- parameters: { fileId: { type: 'string', required: true, description: 'Google Drive file ID' }, exportMimeType: { type: 'string', description: 'Export MIME type, default text/plain' }, responseEncoding: { type: 'string', description: 'Output encoding: text or base64 (default text)' }, supportsAllDrives: { type: 'boolean', description: 'Enable shared drive support' } },
228
- output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, fileId: { type: 'string' }, exportMimeType: { type: 'string' }, encoding: { type: 'string' }, content: { type: 'string' }, contentLength: { type: 'number' } } }, render: (_args, value) => value.found ? text((value.content ?? '').slice(0, 4000)) : text(value.reason ?? 'Google Drive is not configured.') },
227
+ parameters: { fileId: { type: 'string', required: true, description: 'Google Drive file ID' }, exportMimeType: { type: 'string', description: 'Export MIME type, default text/plain' }, responseEncoding: { type: 'string', description: 'Output encoding: text or base64 (default text)' }, maxBytes: { type: 'integer', description: 'Maximum source bytes to return, default 1 MiB, capped at 10 MiB' }, supportsAllDrives: { type: 'boolean', description: 'Enable shared drive support' } },
228
+ output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, fileId: { type: 'string' }, exportMimeType: { type: 'string' }, encoding: { type: 'string' }, content: { type: 'string' }, contentLength: { type: 'number' }, truncated: { type: 'boolean' } } }, render: (_args, value) => value.found ? text((value.content ?? '').slice(0, 4000) + (value.truncated ? '\n[content truncated by maxBytes]' : '')) : text(value.reason ?? 'Google Drive is not configured.') },
229
229
  presentCall(args) { return { card: 'generic', title: `Export Google Drive file ${args.fileId ?? ''}`, kind: 'read' }; },
230
230
  async execute(args, exec) {
231
231
  if (!client.hasCredentials())
232
232
  return unavailable('Google Drive accessToken or refresh token credentials are not configured.');
233
233
  try {
234
- return { found: true, ...await client.exportFile(args.fileId, { exportMimeType: args.exportMimeType, responseEncoding: args.responseEncoding, supportsAllDrives: args.supportsAllDrives, signal: exec.signal }) };
234
+ return { found: true, ...await client.exportFile(args.fileId, { exportMimeType: args.exportMimeType, responseEncoding: args.responseEncoding, maxBytes: args.maxBytes, supportsAllDrives: args.supportsAllDrives, signal: exec.signal }) };
235
235
  }
236
236
  catch (error) {
237
237
  if (error instanceof GoogleDriveError)
@@ -281,14 +281,14 @@ export function createTools(client) {
281
281
  defineTool({
282
282
  name: 'gdocs_get_document',
283
283
  description: 'Read a Google Docs document structure and extracted text by document ID.',
284
- parameters: { documentId: { type: 'string', required: true, description: 'Google Docs document ID' }, includeTabsContent: { type: 'boolean', description: 'Include tab content, default true' }, suggestionsViewMode: { type: 'string', description: 'Optional suggestions view mode' }, commentsViewMode: { type: 'string', description: 'Optional comments view mode' }, fields: { type: 'string', description: 'Optional field mask' } },
285
- output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, documentId: { type: 'string' }, title: { type: 'string' }, revisionId: { type: 'string' }, text: { type: 'string' }, tabCount: { type: 'number' }, tabs: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { tabId: { type: 'string' }, title: { type: 'string' }, index: { type: 'number' }, textLength: { type: 'number' } } } } } }, render: (_args, value) => value.found ? renderDocument(value) : text(value.reason ?? 'Google Docs is not configured.') },
284
+ parameters: { documentId: { type: 'string', required: true, description: 'Google Docs document ID' }, includeTabsContent: { type: 'boolean', description: 'Include tab content, default true' }, maxBytes: { type: 'integer', description: 'Maximum UTF-8 document bytes, default 1 MiB, capped at 10 MiB' }, maxTabs: { type: 'integer', description: 'Maximum tab metadata records, default 100' }, suggestionsViewMode: { type: 'string', description: 'Optional suggestions view mode' }, commentsViewMode: { type: 'string', description: 'Optional comments view mode' }, fields: { type: 'string', description: 'Optional field mask' } },
285
+ output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, documentId: { type: 'string' }, title: { type: 'string' }, revisionId: { type: 'string' }, text: { type: 'string' }, contentLength: { type: 'number' }, truncated: { type: 'boolean' }, tabCount: { type: 'number' }, tabs: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { tabId: { type: 'string' }, title: { type: 'string' }, index: { type: 'number' }, textLength: { type: 'number' } } } } } }, render: (_args, value) => value.found ? renderDocument(value) : text(value.reason ?? 'Google Docs is not configured.') },
286
286
  presentCall(args) { return { card: 'generic', title: `Google Doc ${args.documentId ?? ''}`, kind: 'read' }; },
287
287
  async execute(args, exec) {
288
288
  if (!client.hasCredentials())
289
289
  return unavailable('Google Drive accessToken or refresh token credentials are not configured.');
290
290
  try {
291
- return { found: true, ...await client.getDocument(args.documentId, { includeTabsContent: args.includeTabsContent, suggestionsViewMode: args.suggestionsViewMode, commentsViewMode: args.commentsViewMode, fields: args.fields, signal: exec.signal }) };
291
+ return { found: true, ...await client.getDocument(args.documentId, { includeTabsContent: args.includeTabsContent, maxBytes: args.maxBytes, maxTabs: args.maxTabs, suggestionsViewMode: args.suggestionsViewMode, commentsViewMode: args.commentsViewMode, fields: args.fields, signal: exec.signal }) };
292
292
  }
293
293
  catch (error) {
294
294
  if (error instanceof GoogleDriveError)
@@ -319,14 +319,14 @@ export function createTools(client) {
319
319
  defineTool({
320
320
  name: 'gsheets_get_values',
321
321
  description: 'Read values from one Google Sheets A1 range.',
322
- parameters: { spreadsheetId: { type: 'string', required: true, description: 'Google Sheets spreadsheet ID' }, range: { type: 'string', required: true, description: 'A1 notation range, e.g. Sheet1!A1:D20' }, valueRenderOption: { type: 'string', description: 'FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA' }, dateTimeRenderOption: { type: 'string', description: 'SERIAL_NUMBER or FORMATTED_STRING' }, majorDimension: { type: 'string', description: 'ROWS or COLUMNS' } },
323
- output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, spreadsheetId: { type: 'string' }, range: { type: 'string' }, majorDimension: { type: 'string' }, rowCount: { type: 'number' }, columnCount: { type: 'number' }, values: { type: 'array', items: { type: 'array', items: { type: 'string' } } } } }, render: (_args, value) => value.found ? renderValues(value) : text(value.reason ?? 'Google Sheets is not configured.') },
322
+ parameters: { spreadsheetId: { type: 'string', required: true, description: 'Google Sheets spreadsheet ID' }, range: { type: 'string', required: true, description: 'A1 notation range, e.g. Sheet1!A1:D20' }, valueRenderOption: { type: 'string', description: 'FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA' }, dateTimeRenderOption: { type: 'string', description: 'SERIAL_NUMBER or FORMATTED_STRING' }, majorDimension: { type: 'string', description: 'ROWS or COLUMNS' }, maxRows: { type: 'integer', description: 'Maximum rows, default 1000' }, maxColumns: { type: 'integer', description: 'Maximum columns, default 100' }, maxCells: { type: 'integer', description: 'Maximum cells, default 100000' } },
323
+ output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, spreadsheetId: { type: 'string' }, range: { type: 'string' }, majorDimension: { type: 'string' }, rowCount: { type: 'number' }, columnCount: { type: 'number' }, contentLength: { type: 'number' }, truncated: { type: 'boolean' }, values: { type: 'array', items: { type: 'array', items: { type: 'string' } } } } }, render: (_args, value) => value.found ? renderValues(value) : text(value.reason ?? 'Google Sheets is not configured.') },
324
324
  presentCall(args) { return { card: 'generic', title: `Google Sheet values ${args.range ?? ''}`, kind: 'read' }; },
325
325
  async execute(args, exec) {
326
326
  if (!client.hasCredentials())
327
327
  return unavailable('Google Drive accessToken or refresh token credentials are not configured.');
328
328
  try {
329
- return { found: true, ...await client.getSheetValues(args.spreadsheetId, args.range, { valueRenderOption: args.valueRenderOption, dateTimeRenderOption: args.dateTimeRenderOption, majorDimension: args.majorDimension, signal: exec.signal }) };
329
+ return { found: true, ...await client.getSheetValues(args.spreadsheetId, args.range, { valueRenderOption: args.valueRenderOption, dateTimeRenderOption: args.dateTimeRenderOption, majorDimension: args.majorDimension, maxRows: args.maxRows, maxColumns: args.maxColumns, maxCells: args.maxCells, signal: exec.signal }) };
330
330
  }
331
331
  catch (error) {
332
332
  if (error instanceof GoogleDriveError)
@@ -1,4 +1,5 @@
1
1
  /** Google Drive API client with injected fetch for testability. */
2
+ import { type LookupImpl } from './url-security.js';
2
3
  export interface GoogleDriveClientOptions {
3
4
  accessToken?: string;
4
5
  clientId?: string;
@@ -8,6 +9,8 @@ export interface GoogleDriveClientOptions {
8
9
  tokenUrl?: string;
9
10
  timeoutMs?: number;
10
11
  fetchImpl?: typeof fetch;
12
+ /** Test-only DNS lookup override; production uses node:dns/promises. */
13
+ lookupImpl?: LookupImpl;
11
14
  }
12
15
  export declare class GoogleDriveError extends Error {
13
16
  readonly code: number;
@@ -85,6 +88,8 @@ export interface GoogleDocumentInfo {
85
88
  text: string;
86
89
  tabCount: number;
87
90
  tabs: GoogleDocTabInfo[];
91
+ contentLength: number;
92
+ truncated: boolean;
88
93
  }
89
94
  export interface GoogleSheetPropertiesInfo {
90
95
  sheetId: number;
@@ -111,6 +116,8 @@ export interface GoogleSheetValuesInfo {
111
116
  rowCount: number;
112
117
  columnCount: number;
113
118
  values: string[][];
119
+ contentLength: number;
120
+ truncated: boolean;
114
121
  }
115
122
  export declare class GoogleDriveClient {
116
123
  private readonly staticToken;
@@ -121,6 +128,7 @@ export declare class GoogleDriveClient {
121
128
  private readonly tokenUrl;
122
129
  private readonly timeoutMs;
123
130
  private readonly fetchImpl;
131
+ private readonly lookupImpl;
124
132
  private tokenCache;
125
133
  constructor(options?: GoogleDriveClientOptions);
126
134
  hasCredentials(): boolean;
@@ -130,7 +138,6 @@ export declare class GoogleDriveClient {
130
138
  authTest(signal?: AbortSignal): Promise<{
131
139
  ok: boolean;
132
140
  authMethod: string;
133
- tokenPreview: string;
134
141
  }>;
135
142
  listFiles(options?: {
136
143
  query?: string;
@@ -180,6 +187,7 @@ export declare class GoogleDriveClient {
180
187
  exportMimeType?: string;
181
188
  responseEncoding?: 'text' | 'base64';
182
189
  supportsAllDrives?: boolean;
190
+ maxBytes?: number;
183
191
  signal?: AbortSignal;
184
192
  }): Promise<{
185
193
  fileId: string;
@@ -187,6 +195,7 @@ export declare class GoogleDriveClient {
187
195
  encoding: string;
188
196
  content: string;
189
197
  contentLength: number;
198
+ truncated: boolean;
190
199
  }>;
191
200
  listSharedDrives(options?: {
192
201
  pageSize?: number;
@@ -208,6 +217,8 @@ export declare class GoogleDriveClient {
208
217
  suggestionsViewMode?: string;
209
218
  commentsViewMode?: string;
210
219
  fields?: string;
220
+ maxBytes?: number;
221
+ maxTabs?: number;
211
222
  signal?: AbortSignal;
212
223
  }): Promise<GoogleDocumentInfo>;
213
224
  getSpreadsheet(spreadsheetId: string, options?: {
@@ -220,6 +231,9 @@ export declare class GoogleDriveClient {
220
231
  valueRenderOption?: string;
221
232
  dateTimeRenderOption?: string;
222
233
  majorDimension?: string;
234
+ maxRows?: number;
235
+ maxColumns?: number;
236
+ maxCells?: number;
223
237
  signal?: AbortSignal;
224
238
  }): Promise<GoogleSheetValuesInfo>;
225
239
  }
@@ -0,0 +1,12 @@
1
+ import type { LookupAddress } from 'node:dns';
2
+ export type LookupImpl = (hostname: string, options: {
3
+ all: true;
4
+ }) => Promise<LookupAddress[]>;
5
+ export declare class EndpointSecurityError extends Error {
6
+ constructor(message: string);
7
+ }
8
+ export declare function isBlockedAddress(address: string): boolean;
9
+ /** Normalize a configured endpoint to origin + optional path prefix. */
10
+ export declare function normalizeBaseUrl(value: string | undefined, fallback?: string): string;
11
+ /** Validate the final request destination, resolving ordinary hostnames fail-closed. */
12
+ export declare function assertSafeUrl(url: URL, lookupImpl?: LookupImpl): Promise<void>;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Fail-closed destination validation for an operator-configured API endpoint.
3
+ *
4
+ * GENERATED FILE — instantiated from .verify/url-security.template.ts.
5
+ * Every copy of url-security.ts must stay byte-identical apart from LABEL, so
6
+ * the implementations cannot drift the way they did before. Behaviour that must
7
+ * hold across all copies is pinned by security-vectors.json in each repository.
8
+ */
9
+ import { lookup as dnsLookup } from 'node:dns/promises';
10
+ import { isIP } from 'node:net';
11
+ const LABEL = 'Google Drive';
12
+ export class EndpointSecurityError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = 'EndpointSecurityError';
16
+ }
17
+ }
18
+ const defaultLookup = async (hostname, options) => dnsLookup(hostname, options);
19
+ /** Aligned with the IANA IPv4 Special-Purpose Address Registry. */
20
+ const IPV4_BLOCKED_RANGES = [
21
+ ['0.0.0.0', 8], // this network / unspecified
22
+ ['10.0.0.0', 8], // private
23
+ ['100.64.0.0', 10], // carrier-grade NAT
24
+ ['127.0.0.0', 8], // loopback
25
+ ['169.254.0.0', 16], // link-local
26
+ ['172.16.0.0', 12], // private
27
+ ['192.0.0.0', 24], // IETF protocol assignments
28
+ ['192.0.2.0', 24], // TEST-NET-1
29
+ ['192.31.196.0', 24], // AS112-v4
30
+ ['192.52.193.0', 24], // AMT
31
+ ['192.88.99.0', 24], // deprecated 6to4 relay anycast
32
+ ['192.168.0.0', 16], // private
33
+ ['192.175.48.0', 24], // AS112-v4 direct delegation
34
+ ['198.18.0.0', 15], // benchmarking
35
+ ['198.51.100.0', 24], // TEST-NET-2
36
+ ['203.0.113.0', 24], // TEST-NET-3
37
+ ['224.0.0.0', 4], // multicast
38
+ ['240.0.0.0', 4], // reserved / future use
39
+ ];
40
+ /** Aligned with the IANA IPv6 Special-Purpose Address Registry. */
41
+ const IPV6_BLOCKED_RANGES = [
42
+ ['::', 96], // unspecified and IPv4-compatible
43
+ ['::ffff:0:0', 96], // IPv4-mapped
44
+ ['64:ff9b::', 96], // well-known NAT64
45
+ ['64:ff9b:1::', 48], // local-use NAT64
46
+ ['100::', 64], // discard-only
47
+ ['100:0:0:1::', 64], // dummy IPv6 prefix (RFC 9780)
48
+ ['2001::', 23], // IETF protocol assignments: Teredo, AMT, AS112-v6, benchmarking, ORCHID/ORCHIDv2, DRiP
49
+ ['2001:db8::', 32], // documentation
50
+ ['2002::', 16], // 6to4
51
+ ['2620:4f:8000::', 48], // direct delegation AS112 service
52
+ ['3fff::', 20], // documentation (RFC 9637)
53
+ ['5f00::', 16], // segment routing (SRv6) SIDs
54
+ ['fc00::', 7], // unique local
55
+ ['fe80::', 10], // link-local
56
+ ['fec0::', 10], // deprecated site-local
57
+ ['ff00::', 8], // multicast
58
+ ];
59
+ function parseIpv4(address) {
60
+ const parts = address.split('.');
61
+ if (parts.length !== 4 || parts.some(part => !/^\d{1,3}$/.test(part)))
62
+ return null;
63
+ let value = 0n;
64
+ for (const part of parts) {
65
+ const octet = Number(part);
66
+ if (octet > 255)
67
+ return null;
68
+ value = (value << 8n) | BigInt(octet);
69
+ }
70
+ return value;
71
+ }
72
+ function parseIpv6(address) {
73
+ const normalized = address.toLowerCase();
74
+ if (normalized.includes('%'))
75
+ return null;
76
+ const sections = normalized.split('::');
77
+ if (sections.length > 2)
78
+ return null;
79
+ const parseSection = (section) => {
80
+ if (!section)
81
+ return [];
82
+ const parts = section.split(':');
83
+ const result = [];
84
+ for (const [index, part] of parts.entries()) {
85
+ if (part.includes('.')) {
86
+ if (index !== parts.length - 1)
87
+ return null;
88
+ const ipv4 = parseIpv4(part);
89
+ if (ipv4 === null)
90
+ return null;
91
+ result.push((ipv4 >> 16n).toString(16), (ipv4 & 0xffffn).toString(16));
92
+ }
93
+ else if (/^[0-9a-f]{1,4}$/.test(part)) {
94
+ result.push(part);
95
+ }
96
+ else {
97
+ return null;
98
+ }
99
+ }
100
+ return result;
101
+ };
102
+ const head = parseSection(sections[0]);
103
+ const tail = sections.length === 2 ? parseSection(sections[1]) : [];
104
+ if (!head || !tail)
105
+ return null;
106
+ const parts = [...head, ...tail];
107
+ if (sections.length === 1) {
108
+ if (parts.length !== 8)
109
+ return null;
110
+ }
111
+ else {
112
+ const zeroCount = 8 - parts.length;
113
+ if (zeroCount < 1)
114
+ return null;
115
+ parts.splice(head.length, 0, ...Array.from({ length: zeroCount }, () => '0'));
116
+ }
117
+ if (parts.length !== 8)
118
+ return null;
119
+ let value = 0n;
120
+ for (const part of parts)
121
+ value = (value << 16n) | BigInt(Number.parseInt(part, 16));
122
+ return value;
123
+ }
124
+ function inRange(address, network, bits, width) {
125
+ const hostBits = width - bits;
126
+ const mask = hostBits === width ? 0n : ((1n << BigInt(width)) - 1n) ^ ((1n << BigInt(hostBits)) - 1n);
127
+ return (address & mask) === (network & mask);
128
+ }
129
+ function isBlockedIpv4(address) {
130
+ const value = parseIpv4(address);
131
+ if (value === null)
132
+ return true;
133
+ return IPV4_BLOCKED_RANGES.some(([network, bits]) => {
134
+ const parsed = parseIpv4(network);
135
+ return parsed !== null && inRange(value, parsed, bits, 32);
136
+ });
137
+ }
138
+ function isBlockedIpv6(address) {
139
+ const value = parseIpv6(address);
140
+ if (value === null)
141
+ return true;
142
+ return IPV6_BLOCKED_RANGES.some(([network, bits]) => {
143
+ const parsed = parseIpv6(network);
144
+ return parsed !== null && inRange(value, parsed, bits, 128);
145
+ });
146
+ }
147
+ export function isBlockedAddress(address) {
148
+ const family = isIP(address);
149
+ if (family === 4)
150
+ return isBlockedIpv4(address);
151
+ if (family === 6)
152
+ return isBlockedIpv6(address);
153
+ return true;
154
+ }
155
+ function hostnameIsLocal(hostname) {
156
+ const normalized = hostname.toLowerCase().replace(/\.+$/, '');
157
+ return normalized === 'localhost'
158
+ || normalized.endsWith('.localhost')
159
+ || normalized === 'localhost.localdomain'
160
+ || normalized.endsWith('.localhost.localdomain')
161
+ || normalized === 'local'
162
+ || normalized.endsWith('.local');
163
+ }
164
+ /** Normalize a configured endpoint to origin + optional path prefix. */
165
+ export function normalizeBaseUrl(value, fallback = '') {
166
+ const input = value ?? fallback;
167
+ // An empty endpoint means "not configured"; each client keeps its own behaviour.
168
+ if (!input)
169
+ return '';
170
+ if (typeof input !== 'string' || input.trim() !== input || /[\u0000-\u001f\u007f]/.test(input)) {
171
+ throw new EndpointSecurityError(LABEL + ' base URL is invalid.');
172
+ }
173
+ let url;
174
+ try {
175
+ url = new URL(input);
176
+ }
177
+ catch {
178
+ throw new EndpointSecurityError(LABEL + ' base URL is invalid.');
179
+ }
180
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname || url.username || url.password || url.search || url.hash) {
181
+ throw new EndpointSecurityError(LABEL + ' base URL is invalid.');
182
+ }
183
+ const pathPrefix = url.pathname.replace(/\/+$/, '');
184
+ return `${url.origin}${pathPrefix}`;
185
+ }
186
+ /** Validate the final request destination, resolving ordinary hostnames fail-closed. */
187
+ export async function assertSafeUrl(url, lookupImpl = defaultLookup) {
188
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
189
+ throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
190
+ }
191
+ const hostname = url.hostname.replace(/^\[|\]$/g, '').replace(/\.+$/, '').toLowerCase();
192
+ if (!hostname || hostnameIsLocal(hostname)) {
193
+ throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
194
+ }
195
+ const literalFamily = isIP(hostname);
196
+ if (literalFamily) {
197
+ if (isBlockedAddress(hostname)) {
198
+ throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
199
+ }
200
+ return;
201
+ }
202
+ let addresses;
203
+ try {
204
+ addresses = await lookupImpl(hostname, { all: true });
205
+ }
206
+ catch {
207
+ throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
208
+ }
209
+ if (!Array.isArray(addresses) || addresses.length === 0) {
210
+ throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
211
+ }
212
+ for (const result of addresses) {
213
+ if (!result || (result.family !== 4 && result.family !== 6) || isIP(result.address) !== result.family || isBlockedAddress(result.address)) {
214
+ throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
215
+ }
216
+ }
217
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@libai168/dsh-tool-google-drive",
3
3
  "description": "Google Workspace read-only tools for DeepSeek Harness: Drive files, permissions, revisions, Docs, Sheets, and Shared Drives",
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -32,7 +32,8 @@
32
32
  "build": "tsc -p tsconfig.json",
33
33
  "typecheck": "tsc --noEmit -p tsconfig.test.json && tsc --noEmit -p tsconfig.json",
34
34
  "test": "vitest run",
35
- "auth:google": "node scripts/auth-google.mjs"
35
+ "auth:google": "node scripts/auth-google.mjs",
36
+ "prepare": "npm run build"
36
37
  },
37
38
  "publishConfig": {
38
39
  "access": "public"