@gpzhang2001/sharpkit-proxy 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,598 @@
1
+ /**
2
+ * Caido proxy tools — TS port of strix tools/proxy/tools.py (schemas and
3
+ * output shaping verbatim; the client half lives in client.ts, replay pure
4
+ * helpers in replay.ts). Five tools: list_requests / view_request /
5
+ * repeat_request / list_sitemap / view_sitemap_entry. The client resolves
6
+ * lazily from the sandbox session's Caido bootstrap (concurrent with scan
7
+ * start; the first tool call pays the login wait — strix lazy parity).
8
+ * @module @gpzhang2001/sharpkit-proxy
9
+ */
10
+
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type Schema from '@deepseek-ai/schemastery'
13
+ import z from '@deepseek-ai/schemastery'
14
+ import { defineTool } from '@deepseek-ai/dsh-tools'
15
+ import { CaidoClient, type SortBy } from './client.ts'
16
+
17
+ /** Deployment-tunable configuration. */
18
+ export interface Config {
19
+ /** Sandbox session cache key shared with the other tool packages. */
20
+ readonly scanId?: string
21
+ /** list_requests page size default (strix first=50). */
22
+ readonly listPageSize?: number
23
+ /** Replay dispatch deadline (strix 30s). */
24
+ readonly replayTimeoutMs?: number
25
+ /** Client-level per-call timeout (strix @function_tool timeout=60/120). */
26
+ readonly caidoTimeoutMs?: number
27
+ /** Ask-approval gate on repeat_request (fail-closed without an approval service). */
28
+ readonly requireApproval?: boolean
29
+ /** Authorized target hosts/values; repeat_request refuses hosts outside this list (exact or dot-suffix subdomain). */
30
+ readonly authorizedTargets?: string[]
31
+ }
32
+
33
+ export const name = 'pentest-tool-proxy'
34
+
35
+ export const inject = ['tools', 'pentestSandbox']
36
+
37
+ export const Config: Schema<Config> = z.object({
38
+ scanId: z.string().default('pentest'),
39
+ listPageSize: z.number().default(50),
40
+ replayTimeoutMs: z.number().default(30_000),
41
+ caidoTimeoutMs: z.number().default(60_000),
42
+ requireApproval: z.boolean().default(true),
43
+ authorizedTargets: z.array(z.string()),
44
+ })
45
+
46
+ /**
47
+ * Whether a repeat target host is inside the authorized list. Entries may be
48
+ * bare hosts (`example.com`), wildcard subdomains (`*.example.com`), or full
49
+ * URLs (hostname taken); a candidate matches on exact host or a dot-suffix
50
+ * subdomain. An empty list allows everything (fail-open; warned once).
51
+ * @param host - the replay target hostname.
52
+ * @param authorized - the configured target entries.
53
+ */
54
+ export function targetHostAllowed(host: string, authorized: readonly string[]): boolean {
55
+ if (authorized.length === 0) return true
56
+ const candidate = host.toLowerCase()
57
+ return authorized.some(entry => {
58
+ let allowed = entry.trim().toLowerCase()
59
+ if (allowed === '') return false
60
+ if (allowed.includes('://')) {
61
+ try {
62
+ allowed = new URL(allowed).hostname
63
+ } catch {
64
+ // keep the raw entry
65
+ }
66
+ }
67
+ allowed = allowed.replace(/^\*\./, '').replace(/\/.*$/, '')
68
+ return allowed !== '' && (candidate === allowed || candidate.endsWith(`.${allowed}`))
69
+ })
70
+ }
71
+
72
+ /**
73
+ * Resolve the replay target hostname: an explicit url modification wins;
74
+ * otherwise the stored request's host.
75
+ */
76
+ export function resolveRepeatTargetHost(storedHost: string, modifications: Readonly<Record<string, unknown>> | undefined): string {
77
+ const override = modifications?.['url']
78
+ if (typeof override === 'string' && override !== '') {
79
+ try {
80
+ return new URL(override).hostname
81
+ } catch {
82
+ // fall through to the stored host
83
+ }
84
+ }
85
+ return storedHost
86
+ }
87
+
88
+ /** Sitemap page size (strix parity: 30 entries per page). */
89
+ export const SITEMAP_PAGE_SIZE = 30
90
+
91
+ /**
92
+ * Slice one sitemap connection payload to a 1-indexed page of edges (the
93
+ * total count is preserved). Payloads without a recognized connection pass
94
+ * through untouched.
95
+ */
96
+ export function pageSitemapPayload(payload: Record<string, unknown>, page: number): Record<string, unknown> {
97
+ for (const key of ['sitemapRootEntries', 'sitemapDescendantEntries']) {
98
+ const connection = payload[key]
99
+ if (typeof connection !== 'object' || connection === null) continue
100
+ const edges = (connection as { edges?: unknown }).edges
101
+ if (!Array.isArray(edges)) continue
102
+ const start = Math.max(0, (page - 1) * SITEMAP_PAGE_SIZE)
103
+ const sliced = edges.slice(start, start + SITEMAP_PAGE_SIZE)
104
+ return { ...payload, [key]: { ...(connection as Record<string, unknown>), edges: sliced, page, page_size: SITEMAP_PAGE_SIZE, has_more: start + SITEMAP_PAGE_SIZE < edges.length } }
105
+ }
106
+ return payload
107
+ }
108
+
109
+ /** strix view_request search hits: ≤20 matches with ±40 chars of context. */
110
+ interface SearchHit {
111
+ readonly match: string
112
+ readonly position: number
113
+ readonly before: string
114
+ readonly after: string
115
+ }
116
+
117
+ /**
118
+ * Compact regex hits over raw content (strix `_format_search_hits`).
119
+ * @param content - the raw text.
120
+ * @param pattern - the model-supplied regex.
121
+ * @returns the hits payload, or an error entry when the regex is invalid.
122
+ */
123
+ export function formatSearchHits(content: string, pattern: string): { readonly hits: SearchHit[]; readonly totalHits: number; readonly error?: string } {
124
+ let regex: RegExp
125
+ try {
126
+ regex = new RegExp(pattern, 'g')
127
+ } catch (error) {
128
+ return { hits: [], totalHits: 0, error: `Invalid regex: ${String(error)}` }
129
+ }
130
+ const hits: SearchHit[] = []
131
+ for (const match of content.matchAll(regex)) {
132
+ const start = match.index ?? 0
133
+ const end = start + match[0].length
134
+ hits.push({ match: match[0], position: start, before: content.slice(Math.max(0, start - 40), start), after: content.slice(end, end + 40) })
135
+ if (hits.length >= 20) break
136
+ }
137
+ return { hits, totalHits: hits.length }
138
+ }
139
+
140
+ /**
141
+ * Line-paginated raw content page (strix `_format_text_page`).
142
+ * @param content - the raw text.
143
+ * @param page - 1-indexed page number.
144
+ * @param pageSize - lines per page.
145
+ */
146
+ export function formatTextPage(content: string, page: number, pageSize: number): {
147
+ readonly content: string
148
+ readonly page: number
149
+ readonly pageSize: number
150
+ readonly totalLines: number
151
+ readonly hasMore: boolean
152
+ } {
153
+ const lines = content.split('\n')
154
+ const start = Math.max(0, (page - 1) * pageSize)
155
+ const end = start + pageSize
156
+ return { content: lines.slice(start, end).join('\n'), page, pageSize, totalLines: lines.length, hasMore: end < lines.length }
157
+ }
158
+
159
+ /** Strip undefined values so an object satisfies the JsonValue index contract. */
160
+ function structuredToPlain(value: Record<string, unknown>): { [key: string]: JsonValueLike } {
161
+ const plain: { [key: string]: JsonValueLike } = JSON.parse(JSON.stringify(value)) as { [key: string]: JsonValueLike }
162
+ return plain
163
+ }
164
+
165
+ /** Local structural twin of the tool-runtime JsonValue (type-only import). */
166
+ type JsonValueLike = string | number | boolean | null | JsonValueLike[] | { [key: string]: JsonValueLike }
167
+
168
+ interface ToolRunContextLike {
169
+ readonly signal: AbortSignal
170
+ }
171
+
172
+ /** Unwrap the GraphQL envelope for one scope row. */
173
+ function plainScope(envelope: unknown): Record<string, unknown> {
174
+ const record = envelope as Record<string, unknown>
175
+ for (const key of ['scope', 'createScope', 'updateScope']) {
176
+ const inner = record[key]
177
+ if (typeof inner === 'object' && inner !== null) {
178
+ const scope = (inner as Record<string, unknown>)['scope']
179
+ if (typeof scope === 'object' && scope !== null) return scope as Record<string, unknown>
180
+ }
181
+ }
182
+ return record
183
+ }
184
+
185
+ /** Unwrap a scopes list envelope. */
186
+ function plainScopes(rows: unknown[]): Record<string, unknown>[] {
187
+ return rows.filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null)
188
+ }
189
+
190
+ /** Canonical value of view_request (oneOf the two modes + error). */
191
+ type ViewRequestValue = {
192
+ readonly kind: 'hits'
193
+ readonly hits: SearchHit[]
194
+ readonly total_hits: number
195
+ } | {
196
+ readonly kind: 'page'
197
+ readonly content: string
198
+ readonly page: number
199
+ readonly page_size: number
200
+ readonly total_lines: number
201
+ readonly has_more: boolean
202
+ } | {
203
+ readonly kind: 'error'
204
+ readonly error: string
205
+ }
206
+
207
+ export function apply(ctx: Context, config: Config = {}): void {
208
+ const scanId = config.scanId ?? 'pentest'
209
+ const listPageSize = config.listPageSize ?? 50
210
+ const replayTimeoutMs = config.replayTimeoutMs ?? 30_000
211
+ const caidoTimeoutMs = config.caidoTimeoutMs ?? 60_000
212
+ let clientPromise: Promise<CaidoClient> | undefined
213
+ let warnedNoAllowlist = false
214
+
215
+ /** Authorized targets: Config first, else the preset's scan targets. Empty = fail-open (warned once). */
216
+ const authorizedTargets = (): string[] => {
217
+ if (config.authorizedTargets !== undefined) return config.authorizedTargets
218
+ const preset = ctx.get('pentestPreset') as { authorizedTargets?: Array<{ value: string }> } | undefined
219
+ const values = preset?.authorizedTargets?.map(target => target.value) ?? []
220
+ if (values.length === 0 && !warnedNoAllowlist) {
221
+ warnedNoAllowlist = true
222
+ ctx.logger.warn('pentest-proxy: no authorizedTargets configured; repeat_request target enforcement is inactive (fail-open)')
223
+ }
224
+ return values
225
+ }
226
+
227
+ /** Lazy client: resolves the session's Caido bootstrap on first use (strix parity). */
228
+ const client = (signal: AbortSignal): Promise<CaidoClient> => {
229
+ // A failed bootstrap stays failed (strix caido_handle parity: every caller
230
+ // sees the same degraded state); the wording is stable so the model gets
231
+ // consistent guidance instead of the raw exception.
232
+ clientPromise ??= (async () => {
233
+ const session = await ctx.pentestSandbox.createSession({ scanId })
234
+ const endpoint = await session.caidoEndpoint()
235
+ return new CaidoClient({ baseUrl: endpoint.baseUrl, token: endpoint.token })
236
+ })().catch(error => {
237
+ throw new Error(`Caido client not available in run context: ${String(error instanceof Error ? error.message : error)}`)
238
+ })
239
+ const settled = clientPromise
240
+ if (settled === undefined) throw new Error('unreachable: client promise just assigned')
241
+ const timeout = new Promise<never>((_resolve, reject) => {
242
+ signal.addEventListener('abort', () => reject(new Error('caido call aborted')), { once: true })
243
+ const timer = setTimeout(() => reject(new Error(`caido call timed out after ${String(caidoTimeoutMs)}ms`)), caidoTimeoutMs)
244
+ void settled.then(() => clearTimeout(timer), () => clearTimeout(timer))
245
+ })
246
+ return Promise.race([settled, timeout])
247
+ }
248
+
249
+ if (config.requireApproval !== false) {
250
+ void ctx.on('tools/pre-execute', async (exec, next) => {
251
+ if (exec.name === 'repeat_request') return { kind: 'ask' as const, reason: 'pentest: replay a captured request against the target' }
252
+ return next()
253
+ })
254
+ }
255
+
256
+ ctx.tools.register(defineTool({
257
+ name: 'list_requests',
258
+ description: `List captured HTTP requests from the Caido proxy with HTTPQL filtering. HTTPQL: integer fields (resp.code, req.port, id, roundtrip) use eq/gt/gte/lt/lte/ne e.g. 'resp.code.gte:400'; text fields (req.method, req.host, req.path, req.query, req.ext, req.raw) use regex/cont/eq e.g. 'req.path.cont:"/api/"'; dates use gt/lt with ISO e.g. 'req.created_at.gt:"2024-01-01T00:00:00Z"'; combine with AND/OR; no NOT — use ne/ncont/nregex. String values MUST be quoted, integers MUST NOT. A bare quoted string searches req.raw and resp.raw. Pagination: pass page_info.end_cursor as after.`,
259
+ parameters: {
260
+ httpql_filter: { type: 'string', description: 'Caido HTTPQL query (optional).' },
261
+ first: { type: 'integer', description: `Entries per page (default ${String(listPageSize)}).` },
262
+ after: { type: 'string', description: "Cursor from a previous response's page_info.end_cursor." },
263
+ sort_by: { type: 'string', enum: ['timestamp', 'host', 'method', 'path', 'status_code', 'response_time', 'response_size', 'source'], description: 'Sort key (default timestamp).' },
264
+ sort_order: { type: 'string', enum: ['asc', 'desc'], description: 'Sort order (default desc).' },
265
+ scope_id: { type: 'string', description: 'Restrict to a Caido scope.' },
266
+ },
267
+ output: {
268
+ schema: {
269
+ type: 'object',
270
+ properties: {
271
+ success: { type: 'boolean', required: true },
272
+ entries: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } },
273
+ page_info: { type: 'object', properties: {}, additionalProperties: true },
274
+ error: { type: 'string' },
275
+ },
276
+ additionalProperties: false,
277
+ },
278
+ render: (_args, value) => {
279
+ const result = value as { success: boolean; entries: unknown[]; error?: string }
280
+ if (!result.success) return [{ type: 'text', text: `list_requests failed: ${result.error ?? 'unknown'}` }]
281
+ return [{ type: 'text', text: `${String(result.entries.length)} entries (cursor pagination via page_info)` }]
282
+ },
283
+ },
284
+ execute: async (args, exec) => {
285
+ try {
286
+ const caido = await client(exec.signal)
287
+ const connection = await caido.listRequests({
288
+ httpqlFilter: args.httpql_filter,
289
+ first: args.first ?? listPageSize,
290
+ after: args.after,
291
+ sortBy: args.sort_by as SortBy | undefined,
292
+ sortOrder: args.sort_order,
293
+ scopeId: args.scope_id,
294
+ }, exec.signal)
295
+ return {
296
+ success: true,
297
+ entries: connection.entries.map(entry => ({
298
+ cursor: entry.cursor,
299
+ request: {
300
+ id: entry.request.id,
301
+ host: entry.request.host,
302
+ port: entry.request.port,
303
+ method: entry.request.method,
304
+ path: entry.request.path,
305
+ query: entry.request.query,
306
+ is_tls: entry.request.tls,
307
+ created_at: entry.request.createdAt,
308
+ },
309
+ response: entry.response === null ? null : {
310
+ id: entry.response.id,
311
+ status_code: entry.response.statusCode,
312
+ length: entry.response.length,
313
+ created_at: entry.response.createdAt,
314
+ },
315
+ })),
316
+ page_info: {
317
+ has_next_page: connection.pageInfo.hasNextPage,
318
+ has_previous_page: connection.pageInfo.hasPreviousPage,
319
+ start_cursor: connection.pageInfo.startCursor,
320
+ end_cursor: connection.pageInfo.endCursor,
321
+ },
322
+ }
323
+ } catch (error) {
324
+ return { success: false, entries: [], page_info: {}, error: String(error instanceof Error ? error.message : error) }
325
+ }
326
+ },
327
+ }))
328
+
329
+ ctx.tools.register(defineTool({
330
+ name: 'view_request',
331
+ description: `View a captured request or its response raw content, optionally regex-searched. With search_pattern: up to 20 compact hits with before/after context (hunting reflections, leaked URLs, hidden parameters). Without: full content line-paginated (page, page_size). Patterns like '/api/[a-zA-Z0-9._/-]+' (endpoints), 'https?://[^\\s<>"]+' (URLs), '[?&][a-zA-Z0-9_]+=([^&\\s]+)' (query params).`,
332
+ parameters: {
333
+ request_id: { type: 'string', required: true, description: 'Request ID from list_requests.' },
334
+ part: { type: 'string', enum: ['request', 'response'], description: 'Which raw half to view (default request).' },
335
+ search_pattern: { type: 'string', description: 'Optional regex; switches to compact hits mode.' },
336
+ page: { type: 'integer', description: '1-indexed page (only without search_pattern).' },
337
+ page_size: { type: 'integer', description: 'Lines per page (default 50).' },
338
+ },
339
+ output: {
340
+ schema: {
341
+ type: 'object',
342
+ properties: {
343
+ kind: { type: 'string', required: true, enum: ['hits', 'page', 'error'] },
344
+ hits: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } },
345
+ total_hits: { type: 'integer' },
346
+ content: { type: 'string' },
347
+ page: { type: 'integer' },
348
+ page_size: { type: 'integer' },
349
+ total_lines: { type: 'integer' },
350
+ has_more: { type: 'boolean' },
351
+ error: { type: 'string' },
352
+ },
353
+ additionalProperties: false,
354
+ },
355
+ render: (_args, value) => {
356
+ const result = value as ViewRequestValue
357
+ if (result.kind === 'error') return [{ type: 'text', text: `view_request failed: ${result.error}` }]
358
+ if (result.kind === 'hits') return [{ type: 'text', text: `${String(result.total_hits)} regex hit(s)` }]
359
+ return [{ type: 'text', text: `page ${String(result.page)}/${String(Math.ceil(result.total_lines / Math.max(1, result.page_size)))} of ${String(result.total_lines)} lines` }]
360
+ },
361
+ },
362
+ execute: async (args, exec) => {
363
+ try {
364
+ const caido = await client(exec.signal)
365
+ const stored = await caido.getRequest(args.request_id, exec.signal)
366
+ if (stored === null) return { kind: 'error' as const, error: `Request ${args.request_id} not found` }
367
+ const raw = args.part === 'response' ? stored.responseRaw : stored.requestRaw
368
+ if (raw === null) return { kind: 'error' as const, error: `No raw ${args.part === 'response' ? 'response' : 'request'} for ${args.request_id}` }
369
+ if (args.search_pattern !== undefined && args.search_pattern !== '') {
370
+ const hits = formatSearchHits(raw, args.search_pattern)
371
+ if (hits.error !== undefined) return { kind: 'error' as const, error: hits.error }
372
+ return {
373
+ kind: 'hits' as const,
374
+ hits: hits.hits.map(hit => ({ match: hit.match, position: hit.position, before: hit.before, after: hit.after })),
375
+ total_hits: hits.totalHits,
376
+ }
377
+ }
378
+ const textPage = formatTextPage(raw, args.page ?? 1, args.page_size ?? 50)
379
+ return {
380
+ kind: 'page' as const,
381
+ content: textPage.content,
382
+ page: textPage.page,
383
+ page_size: textPage.pageSize,
384
+ total_lines: textPage.totalLines,
385
+ has_more: textPage.hasMore,
386
+ }
387
+ } catch (error) {
388
+ return { kind: 'error' as const, error: String(error instanceof Error ? error.message : error) }
389
+ }
390
+ },
391
+ }))
392
+
393
+ ctx.tools.register(defineTool({
394
+ name: 'repeat_request',
395
+ description: `Repeat a captured request, optionally patching fields — the browse→capture→modify→test flow. modifications keys: url (replace), params (query additions), headers (additions), body (replace), cookies (additions). Inherits everything else from the original.`,
396
+ parameters: {
397
+ request_id: { type: 'string', required: true, description: 'ID of the original request (from list_requests).' },
398
+ modifications: {
399
+ type: 'object',
400
+ properties: {
401
+ url: { type: 'string', description: 'Replace the URL.' },
402
+ params: { type: 'object', properties: {}, additionalProperties: true, description: 'Query-string keys to add/update.' },
403
+ headers: { type: 'object', properties: {}, additionalProperties: true, description: 'Headers to add/update.' },
404
+ body: { type: 'string', description: 'Replace the body.' },
405
+ cookies: { type: 'object', properties: {}, additionalProperties: true, description: 'Cookies to add/update.' },
406
+ },
407
+ additionalProperties: false,
408
+ description: 'Patch dict overlaying the original request.',
409
+ },
410
+ },
411
+ output: {
412
+ schema: {
413
+ type: 'object',
414
+ properties: {
415
+ success: { type: 'boolean', required: true },
416
+ status: { type: 'string' },
417
+ session_id: { type: 'string' },
418
+ elapsed_ms: { type: 'integer' },
419
+ response: { type: 'object', properties: {}, additionalProperties: true },
420
+ error: { type: 'string' },
421
+ },
422
+ additionalProperties: false,
423
+ },
424
+ render: (_args, value) => {
425
+ const result = value as { success: boolean; status?: string; error?: string; response?: { status_code?: number } | null }
426
+ if (!result.success) return [{ type: 'text', text: `repeat_request failed: ${result.error ?? result.status ?? 'unknown'}` }]
427
+ return [{ type: 'text', text: `replay ${result.status ?? 'DONE'}${result.response?.status_code === undefined ? '' : ` → HTTP ${String(result.response.status_code)}`}` }]
428
+ },
429
+ },
430
+ execute: async (args, exec) => {
431
+ try {
432
+ const caido = await client(exec.signal)
433
+ const stored = await caido.getRequest(args.request_id, exec.signal)
434
+ if (stored === null) return { success: false, status: 'ERROR', error: `Request ${args.request_id} not found` }
435
+ // Scope enforcement (beyond strix parity): the replay target must be
436
+ // inside the authorized target list before anything is dispatched.
437
+ const allowlist = authorizedTargets()
438
+ const targetHost = resolveRepeatTargetHost(stored.host, args.modifications as Readonly<Record<string, unknown>> | undefined)
439
+ if (!targetHostAllowed(targetHost, allowlist)) {
440
+ return { success: false, status: 'ERROR', error: `repeat_request refused: target host '${targetHost}' is outside this scan's authorized targets` }
441
+ }
442
+ const replay = await caido.replayRequest(args.request_id, args.modifications, exec.signal, { dispatchTimeoutMs: replayTimeoutMs, pollIntervalMs: 500 })
443
+ if (replay === null) return { success: false, status: 'ERROR', error: `Request ${args.request_id} not found` }
444
+ return {
445
+ success: replay.status === 'DONE',
446
+ status: replay.status,
447
+ session_id: replay.sessionId,
448
+ elapsed_ms: replay.elapsedMs,
449
+ response: replay.response === null ? {} : {
450
+ status_code: replay.response.statusCode,
451
+ length: replay.response.length,
452
+ headers: replay.response.headers,
453
+ body: replay.response.body,
454
+ body_truncated: replay.response.bodyTruncated,
455
+ },
456
+ ...(replay.error !== undefined ? { error: replay.error } : {}),
457
+ }
458
+ } catch (error) {
459
+ return { success: false, status: 'ERROR', error: String(error instanceof Error ? error.message : error) }
460
+ }
461
+ },
462
+ }))
463
+
464
+ ctx.tools.register(defineTool({
465
+ name: 'list_sitemap',
466
+ description: `Browse Caido's hierarchical sitemap: DOMAIN → DIRECTORY → REQUEST → REQUEST_BODY/REQUEST_QUERY. Start with no parent_id for root domains (optionally scope-filtered); drill in by passing an entry's id as parent_id (depth DIRECT or ALL). Pair with view_sitemap_entry.`,
467
+ parameters: {
468
+ scope_id: { type: 'string', description: 'Limit roots to a Caido scope (only without parent_id).' },
469
+ parent_id: { type: 'string', description: 'Entry ID to expand; omit for root domains.' },
470
+ depth: { type: 'string', enum: ['DIRECT', 'ALL'], description: 'DIRECT children or the full subtree (default DIRECT).' },
471
+ page: { type: 'integer', description: '1-indexed page (30 entries per page, strix parity).' },
472
+ },
473
+ output: {
474
+ schema: {
475
+ type: 'object',
476
+ properties: {
477
+ success: { type: 'boolean', required: true },
478
+ payload: { type: 'object', properties: {}, additionalProperties: true },
479
+ error: { type: 'string' },
480
+ },
481
+ additionalProperties: false,
482
+ },
483
+ render: (_args, value) => {
484
+ const result = value as { success: boolean; error?: string }
485
+ return [{ type: 'text', text: result.success ? 'sitemap payload returned' : `list_sitemap failed: ${result.error ?? 'unknown'}` }]
486
+ },
487
+ },
488
+ execute: async (args, exec) => {
489
+ try {
490
+ const caido = await client(exec.signal)
491
+ const payload = await caido.listSitemap({ scopeId: args.scope_id, parentId: args.parent_id, depth: args.depth }, exec.signal) as Record<string, unknown>
492
+ return { success: true as const, payload: structuredToPlain(pageSitemapPayload(payload, args.page ?? 1)) }
493
+ } catch (error) {
494
+ return { success: false as const, payload: {}, error: String(error instanceof Error ? error.message : error) }
495
+ }
496
+ },
497
+ }))
498
+
499
+ ctx.tools.register(defineTool({
500
+ name: 'scope_rules',
501
+ description: "Manage Caido scope rules: get/list/create/update/delete. Glob patterns (*, ?, [abc], [a-z], [^abc]); an EMPTY allowlist ALLOWS ALL; the denylist overrides the allowlist. Scopes feed list_requests' scope_id.",
502
+ parameters: {
503
+ action: { type: 'string', required: true, enum: ['get', 'list', 'create', 'update', 'delete'], description: 'Scope action.' },
504
+ allowlist: { type: 'array', items: { type: 'string' }, description: 'Allow glob patterns (create/update).' },
505
+ denylist: { type: 'array', items: { type: 'string' }, description: 'Deny glob patterns; overrides the allowlist (create/update).' },
506
+ scope_id: { type: 'string', description: 'Target scope id (get/update/delete).' },
507
+ scope_name: { type: 'string', description: 'Scope name (create/update).' },
508
+ },
509
+ output: {
510
+ schema: {
511
+ type: 'object',
512
+ properties: {
513
+ success: { type: 'boolean', required: true },
514
+ scopes: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } },
515
+ scope: { type: 'object', properties: {}, additionalProperties: true },
516
+ deleted: { type: 'string' },
517
+ message: { type: 'string' },
518
+ error: { type: 'string' },
519
+ },
520
+ additionalProperties: false,
521
+ },
522
+ render: (_args, value) => {
523
+ const result = value as { success: boolean; error?: string }
524
+ return [{ type: 'text', text: result.success ? 'scope action succeeded' : `scope_rules failed: ${result.error ?? 'unknown'}` }]
525
+ },
526
+ },
527
+ execute: (async (rawArgs: never, rawExec: never) => {
528
+ const args = rawArgs as never as { action: string; allowlist?: string[]; denylist?: string[]; scope_id?: string; scope_name?: string }
529
+ const exec = rawExec as never as ToolRunContextLike
530
+ try {
531
+ const caido = await client(exec.signal)
532
+ const allowlist = args.allowlist ?? []
533
+ const denylist = args.denylist ?? []
534
+ switch (args.action) {
535
+ case 'list': {
536
+ const payload = await caido.scopeList(exec.signal) as { scopes?: unknown[] }
537
+ return { success: true, scopes: plainScopes(payload.scopes ?? []) }
538
+ }
539
+ case 'get': {
540
+ if (args.scope_id === undefined || args.scope_id === '') return { success: false, error: "Scope_id is required for action='get'" }
541
+ return { success: true, scope: plainScope(await caido.scopeGet(args.scope_id, exec.signal)) }
542
+ }
543
+ case 'create': {
544
+ if (args.scope_name === undefined || args.scope_name === '') return { success: false, error: "Scope_name is required for action='create'" }
545
+ return { success: true, scope: plainScope(await caido.scopeCreate(args.scope_name, allowlist, denylist, exec.signal)) }
546
+ }
547
+ case 'update': {
548
+ if (args.scope_id === undefined || args.scope_id === '' || args.scope_name === undefined || args.scope_name === '') {
549
+ return { success: false, error: "Scope_id and scope_name are required for action='update'" }
550
+ }
551
+ return { success: true, scope: plainScope(await caido.scopeUpdate(args.scope_id, args.scope_name, allowlist, denylist, exec.signal)) }
552
+ }
553
+ case 'delete': {
554
+ if (args.scope_id === undefined || args.scope_id === '') return { success: false, error: "Scope_id is required for action='delete'" }
555
+ const payload = await caido.scopeDelete(args.scope_id, exec.signal) as { deleteScope?: { deletedId?: string } }
556
+ return { success: true, deleted: payload.deleteScope?.deletedId ?? args.scope_id, message: `Scope ${args.scope_id} deleted` }
557
+ }
558
+ default:
559
+ return { success: false, error: `Unknown action: ${String(args.action)}` }
560
+ }
561
+ } catch (error) {
562
+ return { success: false, error: `scope_rules failed: ${String(error instanceof Error ? error.message : error)}` }
563
+ }
564
+ }) as never,
565
+ }))
566
+
567
+ ctx.tools.register(defineTool({
568
+ name: 'view_sitemap_entry',
569
+ description: "Full detail for a sitemap entry plus its most recent 30 related requests. Pick entry_id from list_sitemap.",
570
+ parameters: {
571
+ entry_id: { type: 'string', required: true, description: 'ID from list_sitemap (or any nested entry).' },
572
+ },
573
+ output: {
574
+ schema: {
575
+ type: 'object',
576
+ properties: {
577
+ success: { type: 'boolean', required: true },
578
+ payload: { type: 'object', properties: {}, additionalProperties: true },
579
+ error: { type: 'string' },
580
+ },
581
+ additionalProperties: false,
582
+ },
583
+ render: (_args, value) => {
584
+ const result = value as { success: boolean; error?: string }
585
+ return [{ type: 'text', text: result.success ? 'sitemap entry payload returned' : `view_sitemap_entry failed: ${result.error ?? 'unknown'}` }]
586
+ },
587
+ },
588
+ execute: async (args, exec) => {
589
+ try {
590
+ const caido = await client(exec.signal)
591
+ const payload = await caido.viewSitemapEntry(args.entry_id, exec.signal) as Record<string, unknown>
592
+ return { success: true as const, payload: structuredToPlain(payload) }
593
+ } catch (error) {
594
+ return { success: false as const, payload: {}, error: String(error instanceof Error ? error.message : error) }
595
+ }
596
+ },
597
+ }))
598
+ }