@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/README.md +532 -2
- package/audit-core.ts +1149 -0
- package/dsh-log.yml +71 -0
- package/index.ts +848 -0
- package/package.json +36 -4
- package/tools/audit-log.mjs +391 -0
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
|
+
}
|