@huaqiu/dsh-log 0.0.0-stage → 1.1.2

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/audit-core.ts ADDED
@@ -0,0 +1,1149 @@
1
+ /**
2
+ * Pure business logic for the dsh-log plugin (v1.1).
3
+ *
4
+ * Zero DSH/SDK imports so every rule — masking, truncation, whitelisting,
5
+ * path-traversal protection, timeout and process-group kill — is unit-testable
6
+ * on its own. The DSH adapter (`index.ts`) only wires this core to the tool
7
+ * registry and the audit sinks.
8
+ *
9
+ * @module dsh-log/audit-core
10
+ */
11
+
12
+ import { execFile, spawn, type ChildProcess } from 'node:child_process'
13
+ import { randomUUID } from 'node:crypto'
14
+ import { readdirSync, statSync } from 'node:fs'
15
+ import path from 'node:path'
16
+
17
+ /** Sensitive keywords for recursive log masking (spec §3). */
18
+ export const SENSITIVE_KEYWORDS = ['token', 'secret', 'password', 'ak', 'sk', 'accessKey', 'apiKey'] as const
19
+
20
+ /**
21
+ * Fold a key/parameter name to its comparison form: lowercased with separators
22
+ * removed. Exists because a separator must not be able to HIDE a credential —
23
+ * `api_key`, `api-key`, `x-api-key` and `apiKey` are the same parameter, but a
24
+ * raw `includes()` on the lowercase name misses three of those four.
25
+ */
26
+ function foldKeyName(value: string): string {
27
+ return value.toLowerCase().replace(/[^a-z0-9]/g, '')
28
+ }
29
+
30
+ /**
31
+ * True when a key or query-parameter NAME names a credential (spec §3).
32
+ * Matches case-insensitively and across separators (`_`, `-`, `.`).
33
+ * @param keyName - object key or URL query parameter name.
34
+ * @returns whether the value under this name must be masked.
35
+ */
36
+ export function isSensitiveName(keyName: string): boolean {
37
+ const folded = foldKeyName(keyName)
38
+ return SENSITIVE_KEYWORDS.some(keyword => folded.includes(foldKeyName(keyword)))
39
+ }
40
+
41
+ /** Unified error classification (spec §6). */
42
+ export const ERROR_TYPES = {
43
+ TIMEOUT: 'TIMEOUT',
44
+ NETWORK_ERROR: 'NETWORK_ERROR',
45
+ PERMISSION_DENIED: 'PERMISSION_DENIED',
46
+ SCRIPT_EXIT_ERROR: 'SCRIPT_EXIT_ERROR',
47
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
48
+ } as const
49
+
50
+ /** One member of the unified errorType enum. */
51
+ export type ErrorType = (typeof ERROR_TYPES)[keyof typeof ERROR_TYPES]
52
+
53
+ /** Log level of one audit record (spec §3: INFO / ERROR). */
54
+ export type AuditLevel = 'INFO' | 'ERROR'
55
+
56
+ /**
57
+ * Lifecycle phase an audit record reports (spec §4's start / finished / error
58
+ * event types). Carried explicitly so a log reader never has to infer the phase
59
+ * from which fields happen to be present — the trajectory channel only ever
60
+ * holds the terminal record, this channel is the only place the full lifecycle
61
+ * is observable.
62
+ */
63
+ export type AuditPhase = 'start' | 'finished' | 'error'
64
+
65
+ /**
66
+ * A losslessly JSON-serializable log segment. Structurally identical to DSH's
67
+ * `JsonValue` (kept local so this core module stays SDK-free); the adapter
68
+ * relies on that compatibility when embedding records in tool results.
69
+ */
70
+ export type AuditJsonValue = null | boolean | number | string | AuditJsonValue[] | { [key: string]: AuditJsonValue }
71
+
72
+ /** Audit record schema version — lets log consumers handle plugin upgrades compatibly. */
73
+ export const SCHEMA_VERSION = '1.0'
74
+
75
+ /**
76
+ * Unified audit log record (spec §3 data model, v1.0 mandatory fields).
77
+ * `level` is carried explicitly so every sink can route without re-deriving it.
78
+ * Declared as a TYPE ALIAS (not an interface) so it carries the implicit
79
+ * index signature required by DSH's JSON-value output schemas.
80
+ */
81
+ export type AuditRecord = {
82
+ /** DSH session id — top-level correlation key ('standalone' when no agent session owns the call). */
83
+ sessionId: string
84
+ /** Unique id of this tool invocation — a generated UUID v4 per spec §3. */
85
+ callId: string
86
+ /** The platform's own call identifier (DSH exec.callId) when one exists, for joining with tool/call|tool/result events. */
87
+ dshCallId?: string
88
+ /** Schema version of this record shape (log consumers use it for compatibility). */
89
+ schemaVersion: string
90
+ /** Tool name: `call_external_api` | `run_external_script`. */
91
+ tool: string
92
+ level: AuditLevel
93
+ /**
94
+ * 1-based index of this record within the invocation (1 = start, 2 = terminal).
95
+ * Makes a LOST record detectable: a call whose records are not `1, 2` lost one,
96
+ * which is exactly the failure mode a single-record channel (the trajectory)
97
+ * cannot observe. Absent on records written by plugin versions < 1.0.1.
98
+ */
99
+ seq?: number
100
+ /**
101
+ * Which lifecycle phase this record reports. Absent on records written by
102
+ * plugin versions < 1.0.1 (readers fall back to field-presence inference).
103
+ */
104
+ phase?: AuditPhase
105
+ /** Masked + truncated request parameters (JSON-serializable by construction); absent on records written before the request is known. */
106
+ request?: AuditJsonValue
107
+ /** Truncated response summary (JSON-serializable by construction); may be omitted on ERROR records. */
108
+ response?: AuditJsonValue
109
+ /** Execution cost in milliseconds. */
110
+ costMs: number
111
+ /** API: HTTP status code. Script: process exit code. Absent when no process/HTTP exchange completed. */
112
+ code?: number
113
+ /** Error classification; present only on ERROR records. */
114
+ errorType?: ErrorType
115
+ /** Exception detail; present only on ERROR records. */
116
+ error?: string
117
+ }
118
+
119
+ /** Plain-object guard for recursive masking (arrays handled separately). */
120
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
121
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
122
+ const proto = Object.getPrototypeOf(value)
123
+ return proto === Object.prototype || proto === null
124
+ }
125
+
126
+ /**
127
+ * Recursively replace values whose KEY contains any sensitive keyword with `***`
128
+ * (case-insensitive substring match, spec §3). Traverses plain objects and
129
+ * arrays at any depth. Returns a NEW structure — the input is never mutated,
130
+ * so masking only ever touches log copies, never the live request parameters.
131
+ *
132
+ * STRING values containing a URL additionally go through `maskUrlsInText`, so a
133
+ * credential is caught wherever it hides: a secret carried in `?token=…`, in
134
+ * userinfo, or echoed back inside a response body ("your request to
135
+ * `https://x/y?token=…` failed") is masked even though no KEY names it.
136
+ *
137
+ * Cyclic references are replaced by the `'[Circular]'` marker instead of
138
+ * overflowing the stack (defensive: DSH arguments normally cross a lossless-
139
+ * JSON boundary first, but the log pipeline must never crash on odd input).
140
+ * Keys whose value is `undefined` are dropped entirely — undefined is not
141
+ * JSON-serializable and would fail DSH's lossless-JSON result validation.
142
+ * @param obj - value to mask; may be any JSON-compatible value.
143
+ * @returns masked copy (primitives pass through unchanged).
144
+ */
145
+ export function maskSensitive(obj: unknown, seen = new WeakSet<object>()): unknown {
146
+ if (Array.isArray(obj)) {
147
+ if (seen.has(obj)) return '[Circular]'
148
+ seen.add(obj)
149
+ const out = obj.map(item => maskSensitive(item, seen))
150
+ seen.delete(obj)
151
+ return out
152
+ }
153
+ if (isPlainObject(obj)) {
154
+ if (seen.has(obj)) return '[Circular]'
155
+ seen.add(obj)
156
+ const out: Record<string, unknown> = {}
157
+ for (const [key, value] of Object.entries(obj)) {
158
+ if (value === undefined) continue // not JSON-serializable: drop the key (lossless-JSON boundary)
159
+ const sensitive = isSensitiveName(key)
160
+ out[key] = sensitive ? '***' : maskSensitive(value, seen)
161
+ }
162
+ seen.delete(obj)
163
+ return out
164
+ }
165
+ if (typeof obj === 'string' && (obj.includes('?') || obj.includes('://'))) return maskUrlsInText(obj)
166
+ return obj
167
+ }
168
+
169
+ /**
170
+ * UUID v4 call id (spec §3: callId is a per-invocation UUID). Prefers the
171
+ * platform generator; falls back to a manual v4 over crypto bytes.
172
+ * @returns a uuid v4 string.
173
+ */
174
+ export function generateCallId(): string {
175
+ try {
176
+ return randomUUID()
177
+ } catch {
178
+ const bytes = new Uint8Array(16)
179
+ for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256)
180
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
181
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
182
+ const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')).join('')
183
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Byte-bounded string truncation (spec §3: limits are BYTES). Cuts on a UTF-8
189
+ * character boundary and appends the `[TRUNCATED]` marker when the input
190
+ * exceeds `maxBytes`.
191
+ * @param str - original string.
192
+ * @param maxBytes - maximum allowed byte length of the kept prefix.
193
+ * @returns the original string, or `<prefix>[TRUNCATED]`.
194
+ */
195
+ export function truncateString(str: string, maxBytes: number): string {
196
+ if (maxBytes <= 0) return ''
197
+ const buf = Buffer.from(str, 'utf8')
198
+ if (buf.byteLength <= maxBytes) return str
199
+ let end = maxBytes
200
+ // Back off over UTF-8 continuation bytes so we never split a code point.
201
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) end -= 1
202
+ return `${buf.subarray(0, end).toString('utf8')}[TRUNCATED]`
203
+ }
204
+
205
+ /**
206
+ * Truncate one log segment of arbitrary JSON value to a byte budget: values
207
+ * that already fit are returned untouched (still structured); oversized values
208
+ * become the truncated serialized string with the `[TRUNCATED]` marker.
209
+ * @param value - the segment to bound (request, response, script output).
210
+ * @param maxBytes - byte budget from plugin config.
211
+ * @returns the original value or a truncated string.
212
+ */
213
+ export function truncateJson(value: unknown, maxBytes: number): unknown {
214
+ if (value === undefined) return undefined
215
+ let text: string
216
+ try {
217
+ text = JSON.stringify(value)
218
+ } catch {
219
+ text = String(value)
220
+ }
221
+ if (text === undefined) text = 'undefined'
222
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) return value
223
+ return truncateString(text, maxBytes)
224
+ }
225
+
226
+ /** Default ceiling for a per-call timeout override: 2 minutes. */
227
+ export const DEFAULT_MAX_TIMEOUT_MS = 120_000
228
+
229
+ /**
230
+ * Resolve the timeout budget of ONE invocation: prefer the caller's per-call
231
+ * `timeoutMs`, otherwise the configured `defaultTimeoutMs`, and never exceed
232
+ * `maxTimeoutMs`.
233
+ *
234
+ * Clamping (rather than refusing) is deliberate: a model that asks for an
235
+ * hour-long call still gets a bounded, useful call, and the audit record's
236
+ * `request.timeoutMs` shows the value that was actually applied — so nothing
237
+ * is hidden, but the plugin can never be parked forever (spec §5.4).
238
+ * @param requested - caller-supplied override; anything that is not a positive finite number is ignored.
239
+ * @param defaultMs - configured `defaultTimeoutMs`.
240
+ * @param maxMs - configured `maxTimeoutMs` ceiling.
241
+ * @returns the effective budget in whole milliseconds, always >= 1.
242
+ */
243
+ export function resolveTimeoutMs(requested: unknown, defaultMs: number, maxMs: number = DEFAULT_MAX_TIMEOUT_MS): number {
244
+ const ceiling = Number.isFinite(maxMs) && maxMs >= 1 ? Math.floor(maxMs) : DEFAULT_MAX_TIMEOUT_MS
245
+ const configured = Number.isFinite(defaultMs) && defaultMs >= 1 ? Math.floor(defaultMs) : 30_000
246
+ const fallback = Math.min(configured, ceiling)
247
+ if (typeof requested !== 'number' || !Number.isFinite(requested) || requested <= 0) return fallback
248
+ return Math.max(1, Math.min(Math.floor(requested), ceiling))
249
+ }
250
+
251
+ /** Dummy base used to parse a RELATIVE reference (`/p?token=…`) so it can be masked too. */
252
+ const RELATIVE_BASE = 'http://relative.invalid'
253
+
254
+ /**
255
+ * Replace credential-bearing parts of an already-parsed URL in place.
256
+ * @param parsed - the parsed URL to redact.
257
+ * @returns whether anything was replaced.
258
+ */
259
+ function redactParsedUrl(parsed: URL): boolean {
260
+ let touched = false
261
+ for (const key of [...parsed.searchParams.keys()]) {
262
+ if (isSensitiveName(key)) {
263
+ parsed.searchParams.set(key, '***')
264
+ touched = true
265
+ }
266
+ }
267
+ if (parsed.username !== '' || parsed.password !== '') {
268
+ parsed.username = '***'
269
+ parsed.password = '***'
270
+ touched = true
271
+ }
272
+ return touched
273
+ }
274
+
275
+ /**
276
+ * Mask credentials that hide inside a URL *string*. `maskSensitive` matches on
277
+ * object KEYS, so a secret smuggled in `?token=…` or in the userinfo part of a
278
+ * URL would otherwise sail straight into the log (spec §5.3: logs must never
279
+ * store credentials in plaintext). Query names are matched with the same
280
+ * keyword list as object keys, case-insensitively.
281
+ *
282
+ * Handles BOTH absolute URLs and bare relative references, because an API
283
+ * commonly echoes the failing request back as a path (`/v1/x?token=…`).
284
+ *
285
+ * Best effort: a string that is neither is returned unchanged (the whitelist
286
+ * gate rejects unparsable absolute URLs before any I/O anyway).
287
+ * @param url - the URL exactly as the caller supplied it, absolutely or relatively.
288
+ * @returns the URL with sensitive query values and any userinfo replaced by `***`,
289
+ * or the input unchanged when nothing sensitive was found.
290
+ */
291
+ export function maskUrlQuery(url: string): string {
292
+ if (typeof url !== 'string' || url === '') return url
293
+ try {
294
+ const parsed = new URL(url)
295
+ return redactParsedUrl(parsed) ? parsed.toString() : url
296
+ } catch {
297
+ // Not an absolute URL. Only a PATH-relative reference (`/p?token=…`) is worth
298
+ // re-parsing: prose that merely CONTAINS a URL is handled by `maskUrlsInText`,
299
+ // and re-parsing prose as a relative path would percent-encode and mangle it.
300
+ if (!url.includes('?') || !/^[./]/.test(url)) return url
301
+ try {
302
+ const parsed = new URL(url, RELATIVE_BASE)
303
+ if (!redactParsedUrl(parsed)) return url
304
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`
305
+ } catch {
306
+ return url
307
+ }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * A URL embedded in free text — either an absolute `http(s)://…` (with or
313
+ * without a query string) or a `/path?query` reference. Stops at whitespace,
314
+ * quotes, brackets and CSV punctuation so surrounding prose is left alone.
315
+ */
316
+ const URL_IN_TEXT = /(?:https?:\/\/|\/)[^\s"'`<>()[\]{},;]*\?[^\s"'`<>()[\]{},;]*|https?:\/\/[^\s"'`<>()[\]{},;]+/g
317
+
318
+ /**
319
+ * Mask credentials in a string that CONTAINS a URL (possibly inside prose), not
320
+ * just a bare URL. This is the everyday case on the response side: an API that
321
+ * reports failure usually quotes the offending request back —
322
+ * `request to https://api.example.com/v1?token=… failed`, or a bare
323
+ * `/v1/resource?token=…` — which carries no sensitive KEY at all, so key-based
324
+ * masking cannot see it (spec §5.3).
325
+ * @param text - arbitrary log-bound string.
326
+ * @returns the string with every embedded URL's credentials masked.
327
+ */
328
+ export function maskUrlsInText(text: string): string {
329
+ if (typeof text !== 'string' || text === '' || (!text.includes('?') && !text.includes('://'))) return text
330
+ const whole = maskUrlQuery(text)
331
+ if (whole !== text) return whole // the entire string was a URL/reference and something got masked
332
+ return text.replace(URL_IN_TEXT, match => maskUrlQuery(match))
333
+ }
334
+
335
+ /**
336
+ * Mask credentials in a free-text log FIELD (an error message, a stderr line).
337
+ * This is the safety net for text the plugin does NOT author: `fetch` failure
338
+ * messages and internal-exception messages routinely quote the offending URL —
339
+ * `request to https://api.example.com/v1?token=… failed` — and key-based masking
340
+ * is blind to them because there is no key to match (spec §5.3).
341
+ * Total function: accepts anything, always yields a string, never throws, so it
342
+ * can wrap every value on its way into an audit record.
343
+ * @param text - value bound for a log field; coerced to string when not one.
344
+ * @returns the masked string.
345
+ */
346
+ export function maskTextForLog(text: unknown): string {
347
+ const value = typeof text === 'string' ? text : String(text)
348
+ try {
349
+ return maskUrlsInText(value)
350
+ } catch {
351
+ // Unreachable in practice (maskUrlsInText swallows its own parse failures).
352
+ // Defence in depth: strip anything query- or userinfo-shaped rather than
353
+ // ever letting a raw credential reach the log.
354
+ return value.replace(/\?[^\s]*/g, '?***').replace(/\/\/[^/@\s]*@/g, '//***@')
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Merge caller-supplied query parameters into the request URL using the URL
360
+ * parser's own encoder — never string concatenation — so a value containing
361
+ * `&`, `=`, `#` or spaces cannot corrupt the request or smuggle extra
362
+ * parameters. Existing parameters are preserved; a name present in both the
363
+ * URL and `query` is overridden by `query`.
364
+ * @param url - base URL (callers gate this with `checkApiUrl` first, so it parses).
365
+ * @param query - normalized string map of extra parameters.
366
+ * @returns the effective request URL.
367
+ * @throws when `url` does not parse — unreachable for the gated call path.
368
+ */
369
+ export function buildRequestUrl(url: string, query?: Record<string, string>): string {
370
+ if (query === undefined) return url
371
+ const entries = Object.entries(query).filter(([, value]) => value !== undefined && value !== null)
372
+ if (entries.length === 0) return url
373
+ const parsed = new URL(url)
374
+ for (const [key, value] of entries) parsed.searchParams.set(key, value)
375
+ return parsed.toString()
376
+ }
377
+
378
+ /** Result of the API host-whitelist gate (spec §4.1/§5.1 — SSRF protection). */
379
+ export type UrlGate = { ok: true; host: string } | { ok: false; reason: string }
380
+
381
+ /**
382
+ * Validate a request URL against the configured host whitelist: only http/https
383
+ * schemes are accepted and the host must EXACTLY match (case-insensitive) one
384
+ * of `allowHosts`. Anything else is refused before any network I/O happens.
385
+ * @param url - candidate request URL.
386
+ * @param allowHosts - configured `api_allow_hosts` entries.
387
+ * @returns gate verdict with a human-readable refusal reason.
388
+ */
389
+ export function checkApiUrl(url: string, allowHosts: readonly string[]): UrlGate {
390
+ let parsed: URL
391
+ try {
392
+ parsed = new URL(url)
393
+ } catch {
394
+ return { ok: false, reason: `invalid url: ${JSON.stringify(url)}` }
395
+ }
396
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
397
+ return { ok: false, reason: `scheme "${parsed.protocol}" not allowed (only http/https)` }
398
+ }
399
+ const host = parsed.hostname.toLowerCase()
400
+ const allowed = allowHosts.map(entry => entry.trim().toLowerCase()).filter(entry => entry !== '')
401
+ if (!allowed.includes(host)) {
402
+ return { ok: false, reason: `host "${host}" not in api_allow_hosts whitelist (${allowed.join(', ') || 'empty'})` }
403
+ }
404
+ return { ok: true, host }
405
+ }
406
+
407
+ /**
408
+ * Why an `api_allow_hosts` entry deserves a second look before it is ever used.
409
+ * `loopback` is the ordinary local-verification case; the rest are the ones that
410
+ * turn "call an API" into "reach the intranet or the cloud metadata service".
411
+ */
412
+ export type HostRiskTier = 'metadata' | 'link-local' | 'private' | 'loopback'
413
+
414
+ /** One flagged whitelist entry. */
415
+ export interface HostRisk {
416
+ /** Normalised host exactly as it appears in `api_allow_hosts`. */
417
+ host: string
418
+ tier: HostRiskTier
419
+ /** Why this address space is a risk, in one line. */
420
+ reason: string
421
+ }
422
+
423
+ /**
424
+ * Severity per tier. Link-local/metadata (cloud instance credentials) and
425
+ * RFC1918/ULA/private-name targets (intranet services) are worth a warning;
426
+ * loopback is expected during local verification and gets an informational note.
427
+ */
428
+ export const HOST_RISK_SEVERITY: Record<HostRiskTier, 'WARN' | 'INFO'> = {
429
+ metadata: 'WARN',
430
+ 'link-local': 'WARN',
431
+ private: 'WARN',
432
+ loopback: 'INFO',
433
+ }
434
+
435
+ /**
436
+ * Cloud metadata endpoints reachable by NAME. Azure/AWS/OCI answer on the IP literal
437
+ * handled above; these are the aliases images and SDKs resolve for you.
438
+ */
439
+ const METADATA_HOSTS = new Set([
440
+ 'metadata',
441
+ 'metadata.google.internal',
442
+ 'metadata.goog',
443
+ 'instance-data',
444
+ 'instance-data.ec2.internal',
445
+ ])
446
+
447
+ /** Suffixes reserved for private use (RFC 6761 `.local`, RFC 8375 `.home.arpa`, ICANN `.internal`). */
448
+ const PRIVATE_NAME_SUFFIXES = ['.internal', '.local', '.home.arpa', '.lan', '.corp', '.intranet']
449
+
450
+ /**
451
+ * Parse a dotted-quad IPv4 literal. Returns null for anything that is not exactly
452
+ * four 0-255 octets, so `999.1.1.1` and `1.2.3` stay "names" rather than becoming
453
+ * half-classified addresses.
454
+ */
455
+ function parseIpv4(host: string): number[] | null {
456
+ const parts = host.split('.')
457
+ if (parts.length !== 4) return null
458
+ const octets: number[] = []
459
+ for (const part of parts) {
460
+ if (!/^\d{1,3}$/.test(part)) return null
461
+ const value = Number(part)
462
+ if (value > 255) return null
463
+ octets.push(value)
464
+ }
465
+ return octets
466
+ }
467
+
468
+ /**
469
+ * Classify an IPv4 literal against the special-purpose registries. Matching is done
470
+ * on OCTETS, never on the host string, so an IPv4-mapped IPv6 form
471
+ * (`::ffff:169.254.169.254`) is recognised as the same target.
472
+ * @param octets - four 0-255 values.
473
+ * @param host - the whitelist entry this address came from (label only).
474
+ */
475
+ function classifyIpv4(octets: number[], host: string): HostRisk | null {
476
+ const [a, b, c, d] = octets
477
+ if (a === 169 && b === 254) {
478
+ if ((c === 169 && d === 254) || (c === 170 && d === 2)) {
479
+ return { host, tier: 'metadata', reason: 'cloud instance metadata endpoint (IMDS) — reachable instance credentials' }
480
+ }
481
+ return { host, tier: 'link-local', reason: 'link-local address (169.254.0.0/16)' }
482
+ }
483
+ if (a === 0 && b === 0 && c === 0 && d === 0) {
484
+ return { host, tier: 'loopback', reason: 'wildcard address (0.0.0.0) — names every local interface' }
485
+ }
486
+ if (a === 127) return { host, tier: 'loopback', reason: 'loopback address (127.0.0.0/8)' }
487
+ if (a === 10) return { host, tier: 'private', reason: 'private range (10.0.0.0/8)' }
488
+ if (a === 172 && b >= 16 && b <= 31) return { host, tier: 'private', reason: 'private range (172.16.0.0/12)' }
489
+ if (a === 192 && b === 168) return { host, tier: 'private', reason: 'private range (192.168.0.0/16)' }
490
+ if (a === 100 && b >= 64 && b <= 127) return { host, tier: 'private', reason: 'carrier-grade NAT range (100.64.0.0/10)' }
491
+ return null
492
+ }
493
+
494
+ /** Classify an IPv6 literal (zone id and IPv4-mapped forms included). */
495
+ function classifyIpv6(address: string, host: string): HostRisk | null {
496
+ const addr = address.split('%')[0]
497
+ if (addr === '::' || addr === '::1') return { host, tier: 'loopback', reason: 'IPv6 unspecified/loopback address' }
498
+ if (addr.startsWith('::ffff:')) {
499
+ // IPv4-mapped (::ffff:10.0.0.1) reaches the same place as the v4 address it wraps.
500
+ const mapped = parseIpv4(addr.slice('::ffff:'.length))
501
+ return mapped === null ? null : classifyIpv4(mapped, host)
502
+ }
503
+ if (addr.startsWith('fd00:ec2::')) return { host, tier: 'metadata', reason: 'cloud instance metadata endpoint (IPv6 IMDS)' }
504
+ if (/^fe[89ab]/.test(addr)) return { host, tier: 'link-local', reason: 'IPv6 link-local address (fe80::/10)' }
505
+ if (addr.startsWith('fc') || addr.startsWith('fd')) return { host, tier: 'private', reason: 'IPv6 unique-local address (fc00::/7)' }
506
+ return null
507
+ }
508
+
509
+ /**
510
+ * Static classification of a whitelist entry (spec §5.1 hardening).
511
+ *
512
+ * Answering "is this host safe to whitelist?" needs no DNS: the dangerous targets
513
+ * are recognisable *lexically* — special-purpose IP literals and private-use name
514
+ * suffixes. A public-looking name that RESOLVES to a private address cannot be
515
+ * caught here (that would need a lookup at gate time), which is exactly why the
516
+ * gate itself stays a pure string comparison and this is a boot-time advisory.
517
+ *
518
+ * @param hostInput - one `api_allow_hosts` entry, with or without IPv6 brackets.
519
+ * @returns the risk finding, or null when the entry looks ordinary.
520
+ */
521
+ export function classifyHost(hostInput: string): HostRisk | null {
522
+ // `label` is what the finding reports back (the entry as configured, so the operator
523
+ // can grep their own yml); `host` is the matching form with IPv6 brackets removed.
524
+ const label = hostInput.trim().toLowerCase()
525
+ const host = label.replace(/^\[/, '').replace(/\]$/, '')
526
+ if (host === '') return null
527
+ const ipv4 = parseIpv4(host)
528
+ if (ipv4 !== null) return classifyIpv4(ipv4, label)
529
+ if (host.includes(':')) {
530
+ const ipv6 = classifyIpv6(host, label)
531
+ if (ipv6 !== null) return ipv6
532
+ }
533
+ if (METADATA_HOSTS.has(host)) {
534
+ return { host: label, tier: 'metadata', reason: 'well-known cloud instance metadata hostname' }
535
+ }
536
+ if (host === 'localhost' || host.endsWith('.localhost')) {
537
+ return { host: label, tier: 'loopback', reason: 'loopback hostname' }
538
+ }
539
+ if (PRIVATE_NAME_SUFFIXES.some(suffix => host.endsWith(suffix))) {
540
+ return { host: label, tier: 'private', reason: 'private-use name suffix — resolves inside the local network' }
541
+ }
542
+ // No public DNS name is dotless, so a single label necessarily resolves through the
543
+ // local search domain or hosts file. The `:` guard keeps address literals out of this
544
+ // rule: a global IPv6 literal has no dot either and is emphatically not "local".
545
+ if (!host.includes('.') && !host.includes(':')) {
546
+ return { host: label, tier: 'private', reason: 'single-label hostname — resolves locally, never on the public internet' }
547
+ }
548
+ return null
549
+ }
550
+
551
+ /**
552
+ * Classify every whitelist entry, preserving config order and collapsing duplicates.
553
+ * @param allowHosts - configured `api_allow_hosts` entries.
554
+ * @returns findings for the entries that are not ordinary public hostnames.
555
+ */
556
+ export function inspectAllowHosts(allowHosts: readonly string[]): HostRisk[] {
557
+ const seen = new Set<string>()
558
+ const risks: HostRisk[] = []
559
+ for (const entry of allowHosts) {
560
+ const risk = classifyHost(entry)
561
+ if (risk === null || seen.has(risk.host)) continue
562
+ seen.add(risk.host)
563
+ risks.push(risk)
564
+ }
565
+ return risks
566
+ }
567
+
568
+ /** Hostname of a URL, or null when it does not parse. Used for per-call risk tagging. */
569
+ export function hostOfUrl(url: string): string | null {
570
+ try {
571
+ return new URL(url).hostname.toLowerCase()
572
+ } catch {
573
+ return null
574
+ }
575
+ }
576
+
577
+ /** HTTP methods the tool accepts (spec §4.1's four plus PATCH/HEAD). */
578
+ export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const
579
+
580
+ /** One accepted HTTP method. */
581
+ export type HttpMethod = (typeof HTTP_METHODS)[number]
582
+
583
+ /** Methods that can carry a request body. */
584
+ const METHODS_WITH_BODY: ReadonlySet<string> = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
585
+
586
+ /** Parameters for one whitelisted HTTP call (spec §4.1). */
587
+ export interface ApiCallParams {
588
+ url: string
589
+ method?: HttpMethod
590
+ headers?: Record<string, string>
591
+ /** Extra query parameters merged into the URL with proper percent-encoding. */
592
+ query?: Record<string, string>
593
+ /**
594
+ * Request body. An object/array is serialized as JSON; a STRING is sent
595
+ * verbatim (so `application/x-www-form-urlencoded` and plain-text payloads
596
+ * work without double-encoding). Omitted entirely when undefined.
597
+ */
598
+ body?: unknown
599
+ /** Per-call timeout override; clamped to [1, maxTimeoutMs] by `resolveTimeoutMs`. */
600
+ timeoutMs?: number
601
+ }
602
+
603
+ /** Plugin config slice consumed by the API tool. */
604
+ export interface ApiConfig {
605
+ apiAllowHosts: readonly string[]
606
+ defaultTimeoutMs: number
607
+ /** Upper bound for a per-call `timeoutMs` override; undefined → DEFAULT_MAX_TIMEOUT_MS. */
608
+ maxTimeoutMs?: number
609
+ /** Max response BODY bytes to buffer (memory protection); undefined → DEFAULT_MAX_RESPONSE_BYTES. */
610
+ maxResponseBytes?: number
611
+ /** Max request BODY bytes to serialize and send; undefined → DEFAULT_MAX_REQUEST_BYTES. */
612
+ maxRequestBytes?: number
613
+ }
614
+
615
+ /** Default response-body memory cap: 10 MiB (spec §5 hardening, P0). */
616
+ export const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
617
+
618
+ /**
619
+ * Default request-body cap: 1 MiB. The counterpart of `maxResponseBytes` — the
620
+ * response side is bounded because a hostile endpoint controls it, the request
621
+ * side because the MODEL controls it. Without a bound, an oversized body is
622
+ * fully serialized in memory and then pushed onto the wire before anything can
623
+ * object. Checked before any I/O, so an oversized request costs nothing.
624
+ */
625
+ export const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024
626
+
627
+ /** Byte budget for the response-header summary returned to the caller: 8 KiB. */
628
+ export const MAX_RESPONSE_HEADER_BYTES = 8 * 1024
629
+
630
+ /**
631
+ * Response headers whose VALUE is always replaced by `[REDACTED]` before the
632
+ * header map is logged or handed back. `set-cookie` carries a live session
633
+ * credential that outlives this single call — unlike the request headers (which
634
+ * the caller supplied and already knows), it is pure leak with no upside: this
635
+ * tool has no cookie jar, so the caller cannot use it anyway.
636
+ */
637
+ const REDACTED_RESPONSE_HEADERS: ReadonlySet<string> = new Set(['set-cookie', 'set-cookie2'])
638
+
639
+ /** Placeholder written in place of a redacted response-header value. */
640
+ export const REDACTED_HEADER_VALUE = '[REDACTED]'
641
+
642
+ /** Outcome of one API call — transport-level; non-2xx responses are still `ok` (spec §6 has no HTTP_ERROR class). */
643
+ export interface ApiCallResult {
644
+ ok: boolean
645
+ status?: number
646
+ /** Response headers summary (byte-bounded); present when an HTTP response was received at all. */
647
+ headers?: Record<string, string>
648
+ /** Parsed response body (JSON when possible, raw text otherwise) — JSON-serializable by construction. */
649
+ data?: AuditJsonValue
650
+ errorType?: ErrorType
651
+ error?: string
652
+ }
653
+
654
+ /**
655
+ * Collect response headers under a byte budget, so an endpoint that answers
656
+ * with an enormous header set cannot inflate the tool result (and therefore
657
+ * the session stream) without limit. The last header that would overflow is
658
+ * truncated rather than dropped, so its name is still visible.
659
+ *
660
+ * Values of `set-cookie`/`set-cookie2` are replaced by `[REDACTED]` here, at the
661
+ * source — so the credential never reaches EITHER consumer (the audit log AND
662
+ * the result handed to the model). Doing it here rather than at each sink keeps
663
+ * the two from drifting apart (spec §5.3).
664
+ * @param resp - the received response.
665
+ * @returns a plain string map, at most MAX_RESPONSE_HEADER_BYTES of values.
666
+ */
667
+ function collectResponseHeaders(resp: Response): Record<string, string> {
668
+ const out: Record<string, string> = {}
669
+ let budget = MAX_RESPONSE_HEADER_BYTES
670
+ resp.headers.forEach((value, key) => {
671
+ if (budget <= 0) return
672
+ // Redact BEFORE measuring, so the budget reflects what actually ships.
673
+ const safeValue = REDACTED_RESPONSE_HEADERS.has(key.toLowerCase()) ? REDACTED_HEADER_VALUE : value
674
+ const cost = Buffer.byteLength(key, 'utf8') + Buffer.byteLength(safeValue, 'utf8')
675
+ if (cost > budget) {
676
+ out[key] = truncateString(safeValue, budget)
677
+ budget = 0
678
+ return
679
+ }
680
+ budget -= cost
681
+ out[key] = safeValue
682
+ })
683
+ return out
684
+ }
685
+
686
+ /**
687
+ * Perform the whitelisted HTTP request with AbortController timeout control
688
+ * (spec §4.1). Never throws: every failure path resolves to a classified
689
+ * result so the adapter can always emit an audit record.
690
+ * @param params - url/method/headers/body of the call.
691
+ * @param cfg - whitelist + timeout budget from plugin config.
692
+ * @param externalSignal - optional caller cancellation (DSH exec.signal).
693
+ * @returns classified outcome with status/data or errorType/error.
694
+ */
695
+ export async function callExternalApi(
696
+ params: ApiCallParams,
697
+ cfg: ApiConfig,
698
+ externalSignal?: AbortSignal,
699
+ ): Promise<ApiCallResult> {
700
+ const gate = checkApiUrl(params.url, cfg.apiAllowHosts)
701
+ if (!gate.ok) return { ok: false, errorType: ERROR_TYPES.PERMISSION_DENIED, error: gate.reason }
702
+
703
+ const method = (params.method ?? 'GET').toUpperCase()
704
+ const timeoutMs = resolveTimeoutMs(params.timeoutMs, cfg.defaultTimeoutMs, cfg.maxTimeoutMs)
705
+ // Query params are merged with the URL parser's encoder, AFTER the whitelist gate
706
+ // (which only looks at the origin, so merging first would be harmless too — but
707
+ // gating first keeps "refused before any I/O" literally true).
708
+ let target: string
709
+ try {
710
+ target = buildRequestUrl(params.url, params.query)
711
+ } catch (err) {
712
+ return { ok: false, errorType: ERROR_TYPES.PERMISSION_DENIED, error: `invalid url: ${err instanceof Error ? err.message : String(err)}` }
713
+ }
714
+
715
+ const controller = new AbortController()
716
+ let timedOut = false
717
+ const timer = setTimeout(() => {
718
+ timedOut = true
719
+ controller.abort(new Error(`request timed out after ${timeoutMs}ms`))
720
+ }, timeoutMs)
721
+ if (externalSignal !== undefined) {
722
+ if (externalSignal.aborted) controller.abort(new Error('aborted by caller'))
723
+ else externalSignal.addEventListener('abort', () => controller.abort(new Error('aborted by caller')), { once: true })
724
+ }
725
+
726
+ // A string body is sent verbatim (form-urlencoded / plain text); anything else is
727
+ // JSON-serialized. No body at all for GET/HEAD, or when the caller passed none.
728
+ const hasBody = METHODS_WITH_BODY.has(method) && params.body !== undefined
729
+
730
+ // Inside the try so the finally clause still clears the timeout timer on the
731
+ // early return below (an already-armed timer must never outlive the call).
732
+ try {
733
+ // Serialize ONCE so the size gate and the actual send can never disagree
734
+ // about what the body is.
735
+ let bodyText: string | undefined
736
+ if (hasBody) {
737
+ bodyText = typeof params.body === 'string' ? params.body : JSON.stringify(params.body)
738
+ if (bodyText !== undefined) {
739
+ // Symmetric to the response cap: bounded BEFORE any I/O, so an oversized
740
+ // body is never serialized onto the wire (memory + egress protection).
741
+ const limit = cfg.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES
742
+ const size = Buffer.byteLength(bodyText, 'utf8')
743
+ if (size > limit) {
744
+ return { ok: false, errorType: ERROR_TYPES.PERMISSION_DENIED, error: `request body is ${size} bytes, over maxRequestBytes (${limit}) — refused before any I/O` }
745
+ }
746
+ }
747
+ }
748
+
749
+ const resp = await fetch(target, {
750
+ method,
751
+ headers: params.headers,
752
+ ...(bodyText !== undefined ? { body: bodyText } : {}),
753
+ signal: controller.signal,
754
+ // P0 hardening: NEVER follow redirects. fetch's default redirect-following would let a
755
+ // whitelisted host 302-jump to an internal address and bypass the SSRF gate (only the
756
+ // INITIAL url is validated). A 3xx response is reported as-is (like any non-2xx) and
757
+ // its body is cancelled so no second request can happen.
758
+ redirect: 'manual',
759
+ })
760
+ const headers = collectResponseHeaders(resp)
761
+ if (resp.status >= 300 && resp.status < 400) {
762
+ void resp.body?.cancel().catch(() => {})
763
+ // Headers are kept: `location` is how a caller sees WHERE it was asked to go
764
+ // (without the plugin ever following it).
765
+ return { ok: true, status: resp.status, headers }
766
+ }
767
+ // P0 hardening: stream the body with a byte cap instead of buffering it whole —
768
+ // a malicious/faulty endpoint returning a huge body must not be able to OOM us.
769
+ const limit = cfg.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES
770
+ let text = ''
771
+ let bytes = 0
772
+ if (resp.body) {
773
+ const reader = resp.body.getReader()
774
+ const decoder = new TextDecoder('utf-8')
775
+ for (;;) {
776
+ const { done, value } = await reader.read()
777
+ if (done) break
778
+ bytes += value.byteLength
779
+ if (bytes > limit) {
780
+ await reader.cancel().catch(() => {})
781
+ return { ok: false, errorType: ERROR_TYPES.NETWORK_ERROR, error: `response body exceeded ${limit} bytes and was aborted (memory protection)` }
782
+ }
783
+ text += decoder.decode(value, { stream: true })
784
+ }
785
+ text += decoder.decode()
786
+ }
787
+ let data: AuditJsonValue = text
788
+ try {
789
+ data = JSON.parse(text) as AuditJsonValue
790
+ } catch {
791
+ // Non-JSON payload: keep the raw text as the response summary.
792
+ }
793
+ return { ok: true, status: resp.status, headers, data }
794
+ } catch (err) {
795
+ const message = err instanceof Error ? err.message : String(err)
796
+ if (timedOut) return { ok: false, errorType: ERROR_TYPES.TIMEOUT, error: `request timed out after ${timeoutMs}ms` }
797
+ if (externalSignal?.aborted === true) {
798
+ return { ok: false, errorType: ERROR_TYPES.INTERNAL_ERROR, error: 'call aborted by caller (USER_CANCEL tracking lands in v1.3)' }
799
+ }
800
+ return { ok: false, errorType: ERROR_TYPES.NETWORK_ERROR, error: `network failure: ${message}` }
801
+ } finally {
802
+ clearTimeout(timer)
803
+ }
804
+ }
805
+
806
+ /** Result of the script path-traversal gate (spec §4.2/§5.2). */
807
+ export type ScriptPathGate = { ok: true; fullPath: string } | { ok: false; reason: string }
808
+
809
+ /**
810
+ * Resolve a bare script file name inside the whitelisted directory, refusing
811
+ * absolute paths, separators, and `..` components outright (spec: scriptName is
812
+ * FILE NAME ONLY), then re-checking the resolved path stays under the base.
813
+ * Also requires the target to exist as a regular file — a missing script is
814
+ * reported as PERMISSION_DENIED at the audit level.
815
+ * @param allowDir - configured `script_allow_dir`.
816
+ * @param scriptName - bare file name supplied by the model.
817
+ * @returns resolved full path or a refusal reason.
818
+ */
819
+ export function resolveScriptPath(allowDir: string, scriptName: string): ScriptPathGate {
820
+ if (typeof scriptName !== 'string' || scriptName.trim() === '') {
821
+ return { ok: false, reason: 'scriptName is required and must be a bare file name' }
822
+ }
823
+ if (path.isAbsolute(scriptName)) return { ok: false, reason: `absolute path not allowed: ${scriptName}` }
824
+ if (scriptName.includes('/') || scriptName.includes('\\') || scriptName.split(/[\\/]+/).includes('..')) {
825
+ return { ok: false, reason: `path traversal blocked: "${scriptName}" must be a bare file name inside script_allow_dir` }
826
+ }
827
+ const base = path.resolve(allowDir)
828
+ const full = path.resolve(base, scriptName)
829
+ if (full !== base && !full.startsWith(base + path.sep)) {
830
+ return { ok: false, reason: `resolved path escapes script_allow_dir: ${scriptName}` }
831
+ }
832
+ try {
833
+ if (!statSync(full).isFile()) return { ok: false, reason: `script not found in allow dir: ${scriptName}` }
834
+ } catch {
835
+ return { ok: false, reason: `script not found in allow dir: ${scriptName}` }
836
+ }
837
+ return { ok: true, fullPath: full }
838
+ }
839
+
840
+ /**
841
+ * Ordered interpreter candidates per script extension. The first candidate that
842
+ * actually spawns wins, so the same whitelisted directory works on machines
843
+ * where only one of `python`/`python3`/`py` exists — and, crucially, on Windows
844
+ * where a `.py`/`.js`/`.sh` file is NOT directly executable by CreateProcess
845
+ * (Node's `spawn` would fail outright: Windows resolves interpreters through
846
+ * file associations only when a shell is involved, which spec §5.5 forbids).
847
+ *
848
+ * Names are looked up on PATH; the values come from THIS table, never from the
849
+ * model, so no caller-supplied string can ever become an interpreter.
850
+ * `.bat`/`.cmd` are handled separately (cmd.exe), `.ps1` needs `-File`.
851
+ */
852
+ export const SCRIPT_INTERPRETERS: Record<string, readonly string[]> = {
853
+ '.py': ['python', 'python3', 'py'],
854
+ '.js': ['node'],
855
+ '.mjs': ['node'],
856
+ '.cjs': ['node'],
857
+ '.sh': ['bash', 'sh'],
858
+ '.bash': ['bash'],
859
+ '.ps1': ['pwsh', 'powershell'],
860
+ }
861
+
862
+ /** How one resolved script file will be launched. */
863
+ export interface ScriptLaunchPlan {
864
+ /** Display name of the interpreter that will run the file (or the file itself when directly executable). */
865
+ interpreter: string
866
+ /** Ordered argv prefixes; every candidate ends with the gated script path. Args are appended after it. */
867
+ candidates: readonly (readonly string[])[]
868
+ }
869
+
870
+ /**
871
+ * Decide HOW a gated script file is launched, without spawning anything.
872
+ * Pure and inspectable so the decision is unit-testable and so a caller can
873
+ * audit what would run before it runs.
874
+ * @param fullPath - the gated absolute path inside the allow dir.
875
+ * @returns the interpreter label plus ordered argv-prefix candidates.
876
+ */
877
+ export function planScriptLaunch(fullPath: string): ScriptLaunchPlan {
878
+ const ext = path.extname(fullPath).toLowerCase()
879
+ if (process.platform === 'win32' && (ext === '.bat' || ext === '.cmd')) {
880
+ // Windows cannot CreateProcess a batch file (spawn EINVAL) — go through cmd.exe.
881
+ // Still a pure argv array, never a shell string (spec §5.5).
882
+ return { interpreter: 'cmd.exe', candidates: [['cmd.exe', '/d', '/s', '/c', fullPath]] }
883
+ }
884
+ const names = SCRIPT_INTERPRETERS[ext]
885
+ if (names !== undefined && names.length > 0) {
886
+ const candidates = names.map(name => (ext === '.ps1'
887
+ ? [name, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', fullPath]
888
+ : [name, fullPath]))
889
+ return { interpreter: names[0], candidates }
890
+ }
891
+ // No known extension → the file must be a real executable (.exe/.com on Windows,
892
+ // anything with the executable bit on POSIX, or a shebang'd file).
893
+ return { interpreter: path.basename(fullPath), candidates: [[fullPath]] }
894
+ }
895
+
896
+ /**
897
+ * List the regular files available in the script allow dir (sorted, capped).
898
+ * Used to make a refusal actionable: without it, a model that guesses a wrong
899
+ * file name gets only "not found" and has no way to discover what IS runnable.
900
+ * @param allowDir - configured `script_allow_dir`.
901
+ * @param limit - maximum entries returned (default 50).
902
+ * @returns file names, or an empty array when the directory is unreadable.
903
+ */
904
+ export function listAvailableScripts(allowDir: string, limit = 50): string[] {
905
+ try {
906
+ return readdirSync(allowDir, { withFileTypes: true })
907
+ .filter(entry => entry.isFile())
908
+ .map(entry => entry.name)
909
+ .sort()
910
+ .slice(0, limit)
911
+ } catch {
912
+ return []
913
+ }
914
+ }
915
+
916
+ /** Build the `(available in script_allow_dir: …)` suffix for a refusal message. */
917
+ function availableScriptsHint(allowDir: string): string {
918
+ const names = listAvailableScripts(allowDir)
919
+ return names.length === 0 ? '' : ` (available in script_allow_dir: ${names.join(', ')})`
920
+ }
921
+
922
+ /** Parameters for one whitelisted script execution (spec §4.2). */
923
+ export interface ScriptRunParams {
924
+ scriptName: string
925
+ args?: string[]
926
+ /** Per-call timeout override; clamped to [1, maxTimeoutMs] by `resolveTimeoutMs`. */
927
+ timeoutMs?: number
928
+ /** Text piped to the child's stdin, then EOF. Logged as a byte count only (it may hold a credential). */
929
+ stdin?: string
930
+ }
931
+
932
+ /** Plugin config slice consumed by the script tool. */
933
+ export interface ScriptConfig {
934
+ scriptAllowDir: string
935
+ defaultTimeoutMs: number
936
+ /** Byte budget for captured stdout/stderr (capture is capped at the same size as the log). */
937
+ maxOutputLogBytes: number
938
+ /** Upper bound for a per-call `timeoutMs` override; undefined → DEFAULT_MAX_TIMEOUT_MS. */
939
+ maxTimeoutMs?: number
940
+ /** Working directory for the child; defaults to `scriptAllowDir` when empty. */
941
+ scriptWorkingDir?: string
942
+ }
943
+
944
+ /** Outcome of one script execution — args pass through verbatim, outputs are byte-capped. */
945
+ export interface ScriptRunResult {
946
+ ok: boolean
947
+ exitCode?: number
948
+ stdout?: string
949
+ stderr?: string
950
+ /** Interpreter that actually ran the file (make the launch decision auditable). */
951
+ interpreter?: string
952
+ errorType?: ErrorType
953
+ error?: string
954
+ }
955
+
956
+ /** Append a chunk to a byte-capped buffer; returns the capped text plus an overflow flag. */
957
+ function appendCapped(current: string, chunk: string, maxBytes: number): { text: string; truncated: boolean } {
958
+ const combined = current + chunk
959
+ if (Buffer.byteLength(combined, 'utf8') <= maxBytes) return { text: combined, truncated: false }
960
+ const buf = Buffer.from(combined, 'utf8')
961
+ let end = maxBytes
962
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) end -= 1
963
+ return { text: buf.subarray(0, end).toString('utf8'), truncated: true }
964
+ }
965
+
966
+ /**
967
+ * Kill the whole process group of a spawned script so no zombie children
968
+ * survive a timeout (spec §4.2/§5.4). POSIX: SIGKILL to `-pid`. Windows has
969
+ * no process groups: kill the tree with `taskkill /t` (array arguments — never
970
+ * a shell string, per spec §5.5).
971
+ * @param proc - the spawned child to tear down.
972
+ */
973
+ export function killProcessGroup(proc: ChildProcess): void {
974
+ if (proc.pid === undefined) return
975
+ try {
976
+ if (process.platform !== 'win32') {
977
+ process.kill(-proc.pid, 'SIGKILL')
978
+ } else {
979
+ // /t kills the entire process tree; arguments are passed as an array — no shell interpolation.
980
+ execFile('taskkill', ['/pid', String(proc.pid), '/t', '/f'], () => {})
981
+ try { proc.kill() } catch { /* already gone */ }
982
+ }
983
+ } catch {
984
+ // The process may have exited between the timer firing and the kill — fine.
985
+ }
986
+ }
987
+
988
+ /**
989
+ * Execute a whitelisted script with process-group spawn, timeout kill, and
990
+ * byte-capped stdout/stderr capture (spec §4.2). Never rejects: every failure
991
+ * path resolves to a classified result so the adapter can always emit an audit
992
+ * record. Args are passed verbatim as argv — no shell string is ever built.
993
+ *
994
+ * Launch decision comes from `planScriptLaunch`, so a `.py`/`.js`/`.sh`/`.ps1`
995
+ * file runs under its interpreter instead of failing to spawn (the whole point
996
+ * on Windows, where such files are not directly executable). Interpreter
997
+ * candidates are tried in order until one starts.
998
+ * @param params - bare scriptName + args/stdin/timeout override from the model.
999
+ * @param cfg - allow dir, timeout budget, output byte cap, working dir from plugin config.
1000
+ * @param externalSignal - optional caller cancellation (DSH exec.signal).
1001
+ * @returns classified outcome with exitCode/stdout/stderr or errorType/error.
1002
+ */
1003
+ export function runExternalScript(
1004
+ params: ScriptRunParams,
1005
+ cfg: ScriptConfig,
1006
+ externalSignal?: AbortSignal,
1007
+ ): Promise<ScriptRunResult> {
1008
+ const gate = resolveScriptPath(cfg.scriptAllowDir, params.scriptName)
1009
+ if (!gate.ok) {
1010
+ // Make the refusal actionable: say what IS runnable instead of just "not found".
1011
+ return Promise.resolve({ ok: false, errorType: ERROR_TYPES.PERMISSION_DENIED, error: `${gate.reason}${availableScriptsHint(cfg.scriptAllowDir)}` })
1012
+ }
1013
+
1014
+ const plan = planScriptLaunch(gate.fullPath)
1015
+ const timeoutMs = resolveTimeoutMs(params.timeoutMs, cfg.defaultTimeoutMs, cfg.maxTimeoutMs)
1016
+ const cwd = typeof cfg.scriptWorkingDir === 'string' && cfg.scriptWorkingDir.trim() !== ''
1017
+ ? cfg.scriptWorkingDir.trim()
1018
+ : cfg.scriptAllowDir
1019
+ const stdinText = typeof params.stdin === 'string' ? params.stdin : undefined
1020
+ const scriptArgs = params.args ?? []
1021
+
1022
+ // Fail fast on a bad working directory: spawn reports a missing cwd as ENOENT too,
1023
+ // which would otherwise be mistaken for a missing interpreter and burn every candidate.
1024
+ try {
1025
+ if (!statSync(cwd).isDirectory()) {
1026
+ return Promise.resolve({ ok: false, errorType: ERROR_TYPES.PERMISSION_DENIED, error: `working directory is not a directory: ${cwd}` })
1027
+ }
1028
+ } catch {
1029
+ return Promise.resolve({ ok: false, errorType: ERROR_TYPES.PERMISSION_DENIED, error: `working directory does not exist: ${cwd}` })
1030
+ }
1031
+
1032
+ return new Promise(resolvePromise => {
1033
+ let settled = false
1034
+ let attempt = 0
1035
+ let proc: ChildProcess | undefined
1036
+ let stdout = ''
1037
+ let stderr = ''
1038
+ let stdoutTruncated = false
1039
+ let stderrTruncated = false
1040
+
1041
+ const finish = (result: ScriptRunResult): void => {
1042
+ if (settled) return
1043
+ settled = true
1044
+ clearTimeout(timer)
1045
+ externalSignal?.removeEventListener('abort', onExternalAbort)
1046
+ resolvePromise(result)
1047
+ }
1048
+
1049
+ // One timer for the WHOLE invocation (not per interpreter attempt), so trying
1050
+ // several candidates cannot silently extend the budget past the configured ceiling.
1051
+ const timer = setTimeout(() => {
1052
+ if (proc !== undefined) killProcessGroup(proc)
1053
+ finish({ ok: false, errorType: ERROR_TYPES.TIMEOUT, error: `script exceeded ${timeoutMs}ms and was killed` })
1054
+ }, timeoutMs)
1055
+ const onExternalAbort = (): void => {
1056
+ if (proc !== undefined) killProcessGroup(proc)
1057
+ finish({ ok: false, errorType: ERROR_TYPES.INTERNAL_ERROR, error: 'call aborted by caller (USER_CANCEL tracking lands in v1.3)' })
1058
+ }
1059
+ externalSignal?.addEventListener('abort', onExternalAbort, { once: true })
1060
+
1061
+ /**
1062
+ * Classify a failed spawn attempt. An ENOENT (candidate not on PATH) moves on to
1063
+ * the next candidate; anything else is terminal. Reached from BOTH the `error`
1064
+ * event and the synchronous throw below — Windows reports some targets
1065
+ * synchronously (EFTYPE for a non-executable file) instead of emitting `error`.
1066
+ */
1067
+ const onSpawnFailure = (err: Error & { code?: string }, argv: readonly string[]): void => {
1068
+ if (err.code === 'ENOENT' && attempt + 1 < plan.candidates.length) {
1069
+ attempt += 1
1070
+ startAttempt() // this interpreter is not installed — try the next candidate
1071
+ return
1072
+ }
1073
+ // Terminal spawn failure (interpreter missing, file vanished between stat and
1074
+ // spawn, not an executable): refuse, don't crash.
1075
+ const label = plan.candidates.length > 1 ? plan.candidates.map(candidate => candidate[0]).join('/') : argv[0]
1076
+ finish({
1077
+ ok: false,
1078
+ errorType: ERROR_TYPES.PERMISSION_DENIED,
1079
+ error: `failed to start script via "${label}": ${err.message}${err.code === 'ENOENT' ? ' (interpreter not found on PATH)' : ''}`,
1080
+ })
1081
+ }
1082
+
1083
+ /**
1084
+ * Spawn interpreter candidate #attempt and wire its streams.
1085
+ * Everything stays an ARGV array — no shell string is ever built or concatenated
1086
+ * (spec §5.5); the script path was already gated to a bare file name inside the
1087
+ * allow dir, and args pass through verbatim.
1088
+ */
1089
+ const startAttempt = (): void => {
1090
+ const argv = plan.candidates[attempt]
1091
+ let child: ChildProcess
1092
+ try {
1093
+ child = spawn(argv[0], [...argv.slice(1), ...scriptArgs], {
1094
+ cwd,
1095
+ // Own process group on POSIX so a timeout kills every descendant;
1096
+ // Windows trees are covered by taskkill /t in killProcessGroup.
1097
+ detached: process.platform !== 'win32',
1098
+ })
1099
+ } catch (err) {
1100
+ // Synchronous spawn rejection (observed on Windows: EFTYPE / EINVAL for a file
1101
+ // CreateProcess cannot start). MUST be handled here — letting it escape would
1102
+ // reject the returned promise and break the "never rejects" contract the
1103
+ // adapter relies on to always emit an audit record.
1104
+ onSpawnFailure(err as Error & { code?: string }, argv)
1105
+ return
1106
+ }
1107
+ proc = child
1108
+
1109
+ if (stdinText !== undefined && child.stdin !== null && child.stdin !== undefined) {
1110
+ // The child may never read stdin → EPIPE is expected, not an error worth reporting.
1111
+ child.stdin.on('error', () => {})
1112
+ child.stdin.end(stdinText)
1113
+ }
1114
+
1115
+ child.stdout?.on('data', (chunk: Buffer) => {
1116
+ const capped = appendCapped(stdout, chunk.toString('utf8'), cfg.maxOutputLogBytes)
1117
+ stdout = capped.text
1118
+ stdoutTruncated = capped.truncated || stdoutTruncated
1119
+ })
1120
+ child.stderr?.on('data', (chunk: Buffer) => {
1121
+ const capped = appendCapped(stderr, chunk.toString('utf8'), cfg.maxOutputLogBytes)
1122
+ stderr = capped.text
1123
+ stderrTruncated = capped.truncated || stderrTruncated
1124
+ })
1125
+
1126
+ child.on('error', (err: Error & { code?: string }) => { onSpawnFailure(err, argv) })
1127
+
1128
+ child.on('close', (exitCode: number | null) => {
1129
+ const out = stdoutTruncated ? `${stdout}[TRUNCATED]` : stdout
1130
+ const errText = stderrTruncated ? `${stderr}[TRUNCATED]` : stderr
1131
+ if (exitCode === 0) {
1132
+ finish({ ok: true, exitCode: 0, stdout: out, stderr: errText, interpreter: plan.interpreter })
1133
+ } else {
1134
+ finish({
1135
+ ok: false,
1136
+ exitCode: exitCode ?? undefined,
1137
+ stdout: out,
1138
+ stderr: errText,
1139
+ interpreter: plan.interpreter,
1140
+ errorType: ERROR_TYPES.SCRIPT_EXIT_ERROR,
1141
+ error: `script exited with code ${String(exitCode)}`,
1142
+ })
1143
+ }
1144
+ })
1145
+ }
1146
+
1147
+ startAttempt()
1148
+ })
1149
+ }