@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/index.ts
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH plugin: `call_external_api` + `run_external_script` with structured audit
|
|
3
|
+
* logging (v1.0).
|
|
4
|
+
*
|
|
5
|
+
* Written against the REAL DeepSeek Harness SDK surface (this checkout):
|
|
6
|
+
* - Cordis function-plugin exports (`name` / `inject` / `Config` / `apply`) —
|
|
7
|
+
* the shape the profile `cordis.patch.yml` loader expects, NOT a standalone
|
|
8
|
+
* `@dsh/sdk` module. See README.md for the spec→SDK adaptation table.
|
|
9
|
+
* - Tools are registered through `ctx.tools.register(defineTool(...))` from
|
|
10
|
+
* `@deepseek-ai/dsh-tools`; `defineTool` takes `{ name, description,
|
|
11
|
+
* parameters, output: { schema, render }, execute(args, exec) }`.
|
|
12
|
+
* - The spec's `ctx.log.info/error` → DUAL audit sink:
|
|
13
|
+
* 1. HOST LOG channel: one structured JSON line per record via
|
|
14
|
+
* `ctx.logger('dsh-log')` (process log; never lost even
|
|
15
|
+
* for non-agent callers — the v1.2 remote-push hook point).
|
|
16
|
+
* 2. SESSION-STREAM channel: the audit record is embedded in the tool's
|
|
17
|
+
* canonical result, which DSH persists through the well-known
|
|
18
|
+
* `tool/result` session event and renders on the WebUI Trajectory page.
|
|
19
|
+
* (A plugin-owned custom event type is deliberately NOT used: this
|
|
20
|
+
* build's persistence restore refuses unknown non-ignorable event
|
|
21
|
+
* types, so a bespoke `audit/log` event would break session reload.)
|
|
22
|
+
* - The spec's `ctx.sessionId` → `exec.agent.session.id`; `ctx.config` → the
|
|
23
|
+
* Cordis plugin `Config` (schemastery) resolved from the patch entry.
|
|
24
|
+
*
|
|
25
|
+
* Business logic, the log data model, masking/truncation rules and every
|
|
26
|
+
* security rule follow the spec document unchanged — see `audit-core.ts`.
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-log
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
32
|
+
import z from '@deepseek-ai/schemastery'
|
|
33
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
34
|
+
import { appendFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
35
|
+
import os from 'node:os'
|
|
36
|
+
import path from 'node:path'
|
|
37
|
+
import {
|
|
38
|
+
ERROR_TYPES,
|
|
39
|
+
HOST_RISK_SEVERITY,
|
|
40
|
+
SCHEMA_VERSION,
|
|
41
|
+
callExternalApi,
|
|
42
|
+
classifyHost,
|
|
43
|
+
generateCallId,
|
|
44
|
+
hostOfUrl,
|
|
45
|
+
inspectAllowHosts,
|
|
46
|
+
maskSensitive,
|
|
47
|
+
maskTextForLog,
|
|
48
|
+
maskUrlQuery,
|
|
49
|
+
resolveTimeoutMs,
|
|
50
|
+
runExternalScript,
|
|
51
|
+
truncateJson,
|
|
52
|
+
type ApiCallParams,
|
|
53
|
+
type AuditJsonValue,
|
|
54
|
+
type AuditPhase,
|
|
55
|
+
type AuditRecord,
|
|
56
|
+
type ErrorType,
|
|
57
|
+
type HttpMethod,
|
|
58
|
+
} from './audit-core.ts'
|
|
59
|
+
|
|
60
|
+
/** Cordis plugin name (loader diagnostics + logger channel name). */
|
|
61
|
+
export const name = 'dsh-log'
|
|
62
|
+
|
|
63
|
+
/** Plugin version, reported in the boot record so a log file names the build that wrote it. */
|
|
64
|
+
export const PLUGIN_VERSION = '1.1.2'
|
|
65
|
+
|
|
66
|
+
/** Services this plugin requires before it can register tools. */
|
|
67
|
+
export const inject = ['tools']
|
|
68
|
+
|
|
69
|
+
const TOOL_API = 'call_external_api'
|
|
70
|
+
const TOOL_SCRIPT = 'run_external_script'
|
|
71
|
+
|
|
72
|
+
/** Plugin configuration (spec §8), resolved by schemastery with defaults. */
|
|
73
|
+
export interface Config {
|
|
74
|
+
/** API host whitelist (`api_allow_hosts`). Empty = every call refused at runtime (fail closed). */
|
|
75
|
+
apiAllowHosts?: string[]
|
|
76
|
+
/** Script whitelist directory (`script_allow_dir`); REQUIRED — boot fails loud when empty. */
|
|
77
|
+
scriptAllowDir?: string
|
|
78
|
+
/** Global timeout budget in ms for both tools (`default_timeout`). Defaults to 30000. */
|
|
79
|
+
defaultTimeoutMs?: number
|
|
80
|
+
/** Upper bound for a per-call `timeoutMs` override on either tool (`max_timeout`). Defaults to 120000. */
|
|
81
|
+
maxTimeoutMs?: number
|
|
82
|
+
/** Max bytes of one request log segment (`maxReqLogSize`). Defaults to 2048. */
|
|
83
|
+
maxReqLogBytes?: number
|
|
84
|
+
/** Max bytes of one API response log segment (`maxRespLogSize`). Defaults to 2048. */
|
|
85
|
+
maxRespLogBytes?: number
|
|
86
|
+
/** Max bytes of captured script stdout/stderr (`maxOutputLogSize`). Defaults to 512. */
|
|
87
|
+
maxOutputLogBytes?: number
|
|
88
|
+
/** Max response BODY bytes buffered for one API call (memory protection; the live request is aborted beyond it). Defaults to 10 MiB. */
|
|
89
|
+
maxResponseBytes?: number
|
|
90
|
+
/** Max request BODY bytes serialized and sent for one API call (memory + egress protection; an oversized request is refused before any I/O). Defaults to 1 MiB. */
|
|
91
|
+
maxRequestBytes?: number
|
|
92
|
+
/** Working directory for executed scripts (`script_working_dir`); falls back to scriptAllowDir when empty. Defaults to ''. */
|
|
93
|
+
scriptWorkingDir?: string
|
|
94
|
+
/** Days to keep rotated audit log files before automatic cleanup. Defaults to 14. */
|
|
95
|
+
logRetentionDays?: number
|
|
96
|
+
/** Max bytes of one rotated audit log file before a `-partN` sibling is started. Defaults to 20 MiB. */
|
|
97
|
+
maxLogFileSizeBytes?: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Schemastery schema: validates + defaults every config field at plugin load. */
|
|
101
|
+
export const Config: z<Config> = z.object({
|
|
102
|
+
apiAllowHosts: z.array(z.string()).default([]),
|
|
103
|
+
scriptAllowDir: z.string().default(''),
|
|
104
|
+
defaultTimeoutMs: z.number().default(30_000),
|
|
105
|
+
maxTimeoutMs: z.number().default(120_000),
|
|
106
|
+
maxReqLogBytes: z.number().default(2048),
|
|
107
|
+
maxRespLogBytes: z.number().default(2048),
|
|
108
|
+
maxOutputLogBytes: z.number().default(512),
|
|
109
|
+
maxResponseBytes: z.number().default(10 * 1024 * 1024),
|
|
110
|
+
maxRequestBytes: z.number().default(1024 * 1024),
|
|
111
|
+
scriptWorkingDir: z.string().default(''),
|
|
112
|
+
logRetentionDays: z.number().default(14),
|
|
113
|
+
maxLogFileSizeBytes: z.number().default(20 * 1024 * 1024),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
/** Config after schemastery applied every default. */
|
|
117
|
+
type ResolvedConfig = Required<Config>
|
|
118
|
+
|
|
119
|
+
/** One-line JSON audit sink over the host logger (channel 1 — never lost). */
|
|
120
|
+
type HostAuditSink = (record: AuditRecord) => void
|
|
121
|
+
|
|
122
|
+
function createHostAuditSink(ctx: Context): HostAuditSink {
|
|
123
|
+
const log = ctx.logger(name)
|
|
124
|
+
return record => {
|
|
125
|
+
try {
|
|
126
|
+
// One structured JSON line per record; level routes info/error.
|
|
127
|
+
if (record.level === 'ERROR') log.error(JSON.stringify(record))
|
|
128
|
+
else log.info(JSON.stringify(record))
|
|
129
|
+
} catch {
|
|
130
|
+
// An audit-sink failure must never mask the business outcome (spec §5.6
|
|
131
|
+
// protects records, but a broken sink cannot take the tool down with it).
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function assertPositiveInteger(field: string, value: number): void {
|
|
137
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`dsh-log: ${field} must be a positive integer (got ${String(value)})`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Session id of the calling agent, or 'standalone' for non-agent callers. */
|
|
141
|
+
function sessionIdOf(exec: { agent?: { session?: { id?: unknown } } | undefined }): string {
|
|
142
|
+
const raw = exec.agent?.session?.id
|
|
143
|
+
return raw === undefined ? 'standalone' : String(raw)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Outcome of the persistent-sink registration, surfaced in the boot record so an
|
|
148
|
+
* operator can answer "did my audit lines actually land on disk?" from the log
|
|
149
|
+
* itself instead of guessing.
|
|
150
|
+
*/
|
|
151
|
+
type PersistentSinkStatus = { persistent: true; logDir: string } | { persistent: false; reason: string }
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Register a PERSISTENT exporter on the host logger service (channel-1 durability).
|
|
155
|
+
* Cordis's built-in logger only buffers messages in memory (capped at 1000), and some
|
|
156
|
+
* DSH builds register no exporters at all — without a persistent sink, audit lines
|
|
157
|
+
* would vanish with the process. This appends one JSON line per record to
|
|
158
|
+
* `$DSH_HOME/logs/dsh-log-YYYY-MM-DD.jsonl` (~/.dsh when DSH_HOME is unset),
|
|
159
|
+
* rotating daily and by size (`-partN` siblings past maxLogFileSizeBytes) and cleaning
|
|
160
|
+
* up files older than logRetentionDays. Best effort: registration and write failures are
|
|
161
|
+
* contained so a broken sink can never mask the business outcome (spec §5.6 spirit).
|
|
162
|
+
*
|
|
163
|
+
* Returns whether the sink is actually live, so the caller can warn loudly instead of
|
|
164
|
+
* letting the plugin claim durability it does not have.
|
|
165
|
+
* @param ctx - registrant context carrying the logger service.
|
|
166
|
+
* @param cfg - resolved plugin config (rotation + retention budgets).
|
|
167
|
+
* @returns live-sink status for the boot record.
|
|
168
|
+
*/
|
|
169
|
+
function registerPersistentAuditExporter(ctx: Context, cfg: ResolvedConfig): PersistentSinkStatus {
|
|
170
|
+
const service = ctx.logger as unknown as {
|
|
171
|
+
exporter?: (exporter: { levels?: Record<string, number>; export(message: { ts: number; type: string; name: string; args: unknown[] }): void }) => unknown
|
|
172
|
+
} | undefined
|
|
173
|
+
if (!service || typeof service.exporter !== 'function') {
|
|
174
|
+
return { persistent: false, reason: 'logger exporter API unavailable in this build (memory buffer only)' }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let logDir = ''
|
|
178
|
+
try {
|
|
179
|
+
const home = process.env.DSH_HOME?.trim() || path.join(os.homedir(), '.dsh')
|
|
180
|
+
logDir = path.join(home, 'logs')
|
|
181
|
+
} catch {
|
|
182
|
+
return { persistent: false, reason: 'could not resolve DSH_HOME / home directory' }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Rotation (P1): daily files `<name>-YYYY-MM-DD.jsonl`; when one grows past
|
|
186
|
+
// maxLogFileSizeBytes a `-part2`/`-part3` sibling is started. Retention cleanup
|
|
187
|
+
// (delete same-prefix files older than logRetentionDays) runs at most once per minute.
|
|
188
|
+
const state = { file: '', bytes: 0, lastCleanup: 0 }
|
|
189
|
+
// Run-time sink health. `dropped` counts records the sink failed to persist since
|
|
190
|
+
// the last successful write; `lastWarn` throttles the stderr notice to once a minute.
|
|
191
|
+
const health = { dropped: 0, lastWarn: 0, degradedSince: 0 }
|
|
192
|
+
|
|
193
|
+
const dayStamp = (): string => {
|
|
194
|
+
const d = new Date()
|
|
195
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const resolveFile = (stamp: string): string => {
|
|
199
|
+
let n = 1
|
|
200
|
+
for (;;) {
|
|
201
|
+
const candidate = path.join(logDir, n === 1 ? `${name}-${stamp}.jsonl` : `${name}-${stamp}-part${n}.jsonl`)
|
|
202
|
+
let size = 0
|
|
203
|
+
try { size = statSync(candidate).size } catch { /* missing file → size 0 */ }
|
|
204
|
+
if (size < cfg.maxLogFileSizeBytes) return candidate
|
|
205
|
+
n += 1
|
|
206
|
+
if (n > 99) return path.join(logDir, `${name}-${stamp}-part${n}.jsonl`) // give up scanning
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const cleanupExpired = (): void => {
|
|
211
|
+
try {
|
|
212
|
+
const cutoff = Date.now() - cfg.logRetentionDays * 86_400_000
|
|
213
|
+
for (const entry of readdirSync(logDir)) {
|
|
214
|
+
if (!entry.startsWith(`${name}-`) || !entry.endsWith('.jsonl')) continue
|
|
215
|
+
const m = entry.match(new RegExp(`^${name}-(\\d{4})-(\\d{2})-(\\d{2})(?:-part\\d+)?\\.jsonl$`))
|
|
216
|
+
if (!m) continue
|
|
217
|
+
const fileDate = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
|
218
|
+
if (fileDate < cutoff) unlinkSync(path.join(logDir, entry))
|
|
219
|
+
}
|
|
220
|
+
} catch { /* cleanup is best effort */ }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const writeLine = (message: { ts: number; type: string; name: string; args: unknown[] }): void => {
|
|
224
|
+
if (message.name !== name || !Array.isArray(message.args) || message.args.length === 0) return
|
|
225
|
+
const first = message.args[0]
|
|
226
|
+
let payload: Record<string, unknown>
|
|
227
|
+
if (typeof first === 'string') {
|
|
228
|
+
try {
|
|
229
|
+
const parsed: unknown = JSON.parse(first)
|
|
230
|
+
// Persist our own structured lines: audit records (carry tool + sessionId/callId)
|
|
231
|
+
// and the plugin-started config line (carries event). Anything else is skipped.
|
|
232
|
+
if (typeof parsed !== 'object' || parsed === null || (!('tool' in parsed) && !('event' in parsed))) return
|
|
233
|
+
payload = parsed as Record<string, unknown>
|
|
234
|
+
} catch {
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const stamp = dayStamp()
|
|
242
|
+
if (state.lastCleanup === 0 || Date.now() - state.lastCleanup >= 60_000) {
|
|
243
|
+
state.lastCleanup = Date.now()
|
|
244
|
+
cleanupExpired()
|
|
245
|
+
}
|
|
246
|
+
// Rotate: new day → new file; current file over budget → next part sibling.
|
|
247
|
+
if (!state.file.startsWith(`${name}-${stamp}`) || state.bytes >= cfg.maxLogFileSizeBytes) {
|
|
248
|
+
state.file = resolveFile(stamp)
|
|
249
|
+
try { state.bytes = statSync(state.file).size } catch { state.bytes = 0 /* fresh file */ }
|
|
250
|
+
}
|
|
251
|
+
mkdirSync(logDir, { recursive: true })
|
|
252
|
+
const line = JSON.stringify({ ts: message.ts, level: message.type, ...payload }) + '\n'
|
|
253
|
+
appendFileSync(state.file, line, 'utf8')
|
|
254
|
+
state.bytes += Buffer.byteLength(line, 'utf8')
|
|
255
|
+
if (health.dropped > 0) {
|
|
256
|
+
// Recovered from a broken stretch: say how much was lost while the sink was
|
|
257
|
+
// down. Written from the SUCCESS path (not the failure path) so the notice is
|
|
258
|
+
// guaranteed to land — the sink is provably working again right here.
|
|
259
|
+
const notice = JSON.stringify({
|
|
260
|
+
ts: Date.now(),
|
|
261
|
+
level: 'WARN',
|
|
262
|
+
event: 'audit-sink-recovered',
|
|
263
|
+
tool: name,
|
|
264
|
+
pluginVersion: PLUGIN_VERSION,
|
|
265
|
+
schemaVersion: SCHEMA_VERSION,
|
|
266
|
+
droppedRecords: health.dropped,
|
|
267
|
+
degradedMs: Date.now() - health.degradedSince,
|
|
268
|
+
}) + '\n'
|
|
269
|
+
try {
|
|
270
|
+
appendFileSync(state.file, notice, 'utf8')
|
|
271
|
+
state.bytes += Buffer.byteLength(notice, 'utf8')
|
|
272
|
+
health.dropped = 0
|
|
273
|
+
health.degradedSince = 0
|
|
274
|
+
} catch { /* stay degraded; the notice is retried on the next successful write */ }
|
|
275
|
+
}
|
|
276
|
+
} catch (err) {
|
|
277
|
+
// Sink failure must never mask the business outcome (spec §5.6) — but it must
|
|
278
|
+
// not be invisible either. A sink that dies mid-run used to leave NO trace, so
|
|
279
|
+
// an operator reading a truncated file would conclude "no calls happened".
|
|
280
|
+
health.dropped += 1
|
|
281
|
+
if (health.degradedSince === 0) health.degradedSince = Date.now()
|
|
282
|
+
if (Date.now() - health.lastWarn >= 60_000) {
|
|
283
|
+
health.lastWarn = Date.now()
|
|
284
|
+
try {
|
|
285
|
+
// stderr, deliberately NOT the logger: this sink IS the logger's only
|
|
286
|
+
// persistent output, so reporting the failure through it would recurse.
|
|
287
|
+
process.stderr.write(`[${name}] audit sink write failed (${health.dropped} record(s) dropped so far): ${err instanceof Error ? err.message : String(err)}\n`)
|
|
288
|
+
} catch { /* stderr is best effort */ }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
service.exporter({
|
|
295
|
+
levels: { [name]: 1 }, // INFO (1) and above, our channel only
|
|
296
|
+
export: writeLine,
|
|
297
|
+
})
|
|
298
|
+
} catch (err) {
|
|
299
|
+
// Registration is best effort — the in-memory buffer sink still applies, but the
|
|
300
|
+
// caller must hear about it (a silent downgrade is how audit trails disappear).
|
|
301
|
+
return { persistent: false, reason: `exporter registration failed: ${err instanceof Error ? err.message : String(err)}` }
|
|
302
|
+
}
|
|
303
|
+
return { persistent: true, logDir }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Coerce model-supplied header values (the schema allows any JSON value) into
|
|
308
|
+
* the string map `fetch` requires: null/undefined entries are dropped, every
|
|
309
|
+
* other value is stringified. Keys are untouched, so masking still catches
|
|
310
|
+
* sensitive header names on this map; stringification never alters a real
|
|
311
|
+
* credential's value.
|
|
312
|
+
*/
|
|
313
|
+
function normalizeHeaders(headers?: Record<string, unknown>): Record<string, string> {
|
|
314
|
+
const out: Record<string, string> = {}
|
|
315
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
316
|
+
if (value === null || value === undefined) continue
|
|
317
|
+
out[key] = String(value)
|
|
318
|
+
}
|
|
319
|
+
return out
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Coerce model-supplied query values (the schema allows any JSON value) into the
|
|
324
|
+
* string map the URL encoder needs: null/undefined entries are dropped, every
|
|
325
|
+
* other value is stringified. Keys are untouched, so masking still catches
|
|
326
|
+
* sensitive parameter names on this map.
|
|
327
|
+
*/
|
|
328
|
+
function normalizeQuery(query?: Record<string, unknown>): Record<string, string> {
|
|
329
|
+
const out: Record<string, string> = {}
|
|
330
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
331
|
+
if (value === null || value === undefined) continue
|
|
332
|
+
out[key] = String(value)
|
|
333
|
+
}
|
|
334
|
+
return out
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Narrow a core-produced log segment to the record field type. The core module
|
|
339
|
+
* is SDK-free and types its JSON-safe output as `unknown`; this adapter knows
|
|
340
|
+
* by construction that masking/truncation only ever yields JSON values.
|
|
341
|
+
*/
|
|
342
|
+
function asJsonSegment(value: unknown): AuditJsonValue {
|
|
343
|
+
return value as AuditJsonValue
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Strip a URL down to origin + path, dropping the query string and any userinfo.
|
|
348
|
+
* Used by the masking-failure fallback: credentials are commonly smuggled in
|
|
349
|
+
* `?token=…`, so a fallback must never echo the full URL into the log (spec §5.3).
|
|
350
|
+
* @param url - the raw request URL.
|
|
351
|
+
* @returns origin+path, or a marker when the URL does not parse.
|
|
352
|
+
*/
|
|
353
|
+
function bareUrl(url: string): string {
|
|
354
|
+
try {
|
|
355
|
+
const parsed = new URL(url)
|
|
356
|
+
return `${parsed.origin}${parsed.pathname}`
|
|
357
|
+
} catch {
|
|
358
|
+
return '(unparseable url)'
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Build the masked + truncated REQUEST log segment for one start/finished record.
|
|
364
|
+
* Never throws: the start record is the only place this channel holds something
|
|
365
|
+
* the trajectory structurally cannot (a call that has begun but not returned), so
|
|
366
|
+
* a failure inside the masking pipeline must not cost us that record — we fall
|
|
367
|
+
* back to a caller-supplied minimal, credential-free mirror instead.
|
|
368
|
+
* @param raw - the raw request params to mask.
|
|
369
|
+
* @param fallback - minimal credential-free segment used when masking throws.
|
|
370
|
+
* @param maxBytes - byte budget from plugin config.
|
|
371
|
+
* @returns a JSON-safe request segment.
|
|
372
|
+
*/
|
|
373
|
+
function maskRequestForLog(raw: unknown, fallback: AuditJsonValue, maxBytes: number): AuditJsonValue {
|
|
374
|
+
try {
|
|
375
|
+
return asJsonSegment(truncateJson(maskSensitive(raw), maxBytes))
|
|
376
|
+
} catch {
|
|
377
|
+
return fallback
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Build the script-output log segment from only the DEFINED fields. DSH rejects
|
|
383
|
+
* tool results that are not lossless JSON (undefined-valued keys fail the
|
|
384
|
+
* stringify round-trip), so refusal/timeout paths — which carry no exitCode or
|
|
385
|
+
* captured output at all — must omit those keys rather than set them undefined.
|
|
386
|
+
*/
|
|
387
|
+
function scriptOutputSegment(result: { exitCode?: number; stdout?: string; stderr?: string; interpreter?: string }): Record<string, unknown> {
|
|
388
|
+
const out: Record<string, unknown> = {}
|
|
389
|
+
if (result.interpreter !== undefined) out.interpreter = result.interpreter
|
|
390
|
+
if (result.exitCode !== undefined) out.exitCode = result.exitCode
|
|
391
|
+
if (result.stdout !== undefined) out.stdout = result.stdout
|
|
392
|
+
if (result.stderr !== undefined) out.stderr = result.stderr
|
|
393
|
+
return out
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Spec §3 mandates a UUID v4 per invocation, so callId is ALWAYS freshly
|
|
398
|
+
* generated; the platform's own exec.callId (when present) rides along as
|
|
399
|
+
* `dshCallId` for joining with the surrounding tool/call|tool/result events.
|
|
400
|
+
*/
|
|
401
|
+
function dshCallIdOf(exec: { callId?: unknown }): string | undefined {
|
|
402
|
+
return exec.callId !== undefined && String(exec.callId) !== '' ? String(exec.callId) : undefined
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Register both tools on `ctx.tools` with their audit wiring.
|
|
407
|
+
* @param ctx - registrant context carrying the tool registry and logger service.
|
|
408
|
+
* @param config - deployment config from the profile patch entry (defaults applied).
|
|
409
|
+
*/
|
|
410
|
+
export function apply(ctx: Context, config: Config): void {
|
|
411
|
+
const cfg = config as ResolvedConfig
|
|
412
|
+
assertPositiveInteger('defaultTimeoutMs', cfg.defaultTimeoutMs)
|
|
413
|
+
assertPositiveInteger('maxTimeoutMs', cfg.maxTimeoutMs)
|
|
414
|
+
assertPositiveInteger('maxReqLogBytes', cfg.maxReqLogBytes)
|
|
415
|
+
assertPositiveInteger('maxRespLogBytes', cfg.maxRespLogBytes)
|
|
416
|
+
assertPositiveInteger('maxOutputLogBytes', cfg.maxOutputLogBytes)
|
|
417
|
+
assertPositiveInteger('maxResponseBytes', cfg.maxResponseBytes)
|
|
418
|
+
assertPositiveInteger('maxRequestBytes', cfg.maxRequestBytes)
|
|
419
|
+
assertPositiveInteger('logRetentionDays', cfg.logRetentionDays)
|
|
420
|
+
assertPositiveInteger('maxLogFileSizeBytes', cfg.maxLogFileSizeBytes)
|
|
421
|
+
if (cfg.maxTimeoutMs < cfg.defaultTimeoutMs) {
|
|
422
|
+
// The ceiling must not silently make the configured default unreachable.
|
|
423
|
+
throw new Error(`dsh-log: maxTimeoutMs (${cfg.maxTimeoutMs}) must be >= defaultTimeoutMs (${cfg.defaultTimeoutMs})`)
|
|
424
|
+
}
|
|
425
|
+
if (cfg.scriptAllowDir.trim() === '') {
|
|
426
|
+
// Fail loud at boot: a script tool with no allow dir is a misconfiguration,
|
|
427
|
+
// not a runtime surprise.
|
|
428
|
+
throw new Error('dsh-log: scriptAllowDir is required (script whitelist directory)')
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Channel-1 durability: persist host audit lines to $DSH_HOME/logs/<name>-YYYY-MM-DD.jsonl
|
|
432
|
+
// (the built-in logger buffer alone would lose them at process exit).
|
|
433
|
+
const sink = registerPersistentAuditExporter(ctx, cfg)
|
|
434
|
+
|
|
435
|
+
// Whitelist hygiene (spec §5.1): the gate compares host strings, so it can never
|
|
436
|
+
// notice that a whitelisted host IS the cloud metadata service or an intranet box.
|
|
437
|
+
// Classifying the entries once at boot is free and makes the risk explicit in the
|
|
438
|
+
// log the operator already reads — "why was a static-looking whitelist dangerous?"
|
|
439
|
+
// is answered by the same line that states the config.
|
|
440
|
+
const allowlistRisks = inspectAllowHosts(cfg.apiAllowHosts)
|
|
441
|
+
const dangerousHosts = allowlistRisks.filter(risk => HOST_RISK_SEVERITY[risk.tier] === 'WARN')
|
|
442
|
+
|
|
443
|
+
// P1 ops: one startup record so operators can see the effective config at a glance
|
|
444
|
+
// (persisted to the same JSONL via the exporter registered above; non-sensitive values only).
|
|
445
|
+
// It also carries whether the persistent sink is live — the log itself answers
|
|
446
|
+
// "are these lines actually on disk?" instead of leaving it to guesswork.
|
|
447
|
+
try {
|
|
448
|
+
const log = ctx.logger(name)
|
|
449
|
+
log.info(JSON.stringify({
|
|
450
|
+
event: 'plugin-started',
|
|
451
|
+
tool: name,
|
|
452
|
+
pluginVersion: PLUGIN_VERSION,
|
|
453
|
+
level: 'INFO',
|
|
454
|
+
schemaVersion: SCHEMA_VERSION,
|
|
455
|
+
persistentLog: sink.persistent,
|
|
456
|
+
...(sink.persistent ? { logDir: sink.logDir } : { downgradeReason: sink.reason }),
|
|
457
|
+
allowlist: {
|
|
458
|
+
entries: cfg.apiAllowHosts.length,
|
|
459
|
+
risks: allowlistRisks.map(risk => ({ host: risk.host, tier: risk.tier })),
|
|
460
|
+
scope: 'static literal/name-suffix check; a public name that RESOLVES to a private address is not detectable without DNS',
|
|
461
|
+
},
|
|
462
|
+
config: {
|
|
463
|
+
apiAllowHosts: cfg.apiAllowHosts,
|
|
464
|
+
scriptAllowDir: cfg.scriptAllowDir,
|
|
465
|
+
scriptWorkingDir: cfg.scriptWorkingDir === '' ? cfg.scriptAllowDir : cfg.scriptWorkingDir,
|
|
466
|
+
defaultTimeoutMs: cfg.defaultTimeoutMs,
|
|
467
|
+
maxTimeoutMs: cfg.maxTimeoutMs,
|
|
468
|
+
maxReqLogBytes: cfg.maxReqLogBytes,
|
|
469
|
+
maxRespLogBytes: cfg.maxRespLogBytes,
|
|
470
|
+
maxOutputLogBytes: cfg.maxOutputLogBytes,
|
|
471
|
+
maxResponseBytes: cfg.maxResponseBytes,
|
|
472
|
+
maxRequestBytes: cfg.maxRequestBytes,
|
|
473
|
+
logRetentionDays: cfg.logRetentionDays,
|
|
474
|
+
maxLogFileSizeBytes: cfg.maxLogFileSizeBytes,
|
|
475
|
+
},
|
|
476
|
+
}))
|
|
477
|
+
if (dangerousHosts.length > 0) {
|
|
478
|
+
// A warning, not a refusal: whitelisting 127.0.0.1 is ordinary local verification,
|
|
479
|
+
// and an operator may have a legitimate reason to reach an intranet API. But a
|
|
480
|
+
// metadata/link-local/private target is one SSRF away from handing over instance
|
|
481
|
+
// credentials, so it must never be a quiet line in a config dump.
|
|
482
|
+
log.warn(JSON.stringify({
|
|
483
|
+
event: 'allowlist-risk',
|
|
484
|
+
tool: name,
|
|
485
|
+
pluginVersion: PLUGIN_VERSION,
|
|
486
|
+
level: 'WARN',
|
|
487
|
+
schemaVersion: SCHEMA_VERSION,
|
|
488
|
+
risks: dangerousHosts,
|
|
489
|
+
impact: 'a whitelisted private/link-local/metadata host lets this tool reach intranet services or cloud instance credentials',
|
|
490
|
+
}))
|
|
491
|
+
}
|
|
492
|
+
if (!sink.persistent) {
|
|
493
|
+
// Loud degradation notice: with no exporter the plugin can only buffer in memory,
|
|
494
|
+
// so every audit line dies with the process. A silent downgrade is how an audit
|
|
495
|
+
// trail quietly disappears — say it out loud instead.
|
|
496
|
+
log.warn(JSON.stringify({
|
|
497
|
+
event: 'exporter-unavailable',
|
|
498
|
+
tool: name,
|
|
499
|
+
pluginVersion: PLUGIN_VERSION,
|
|
500
|
+
level: 'WARN',
|
|
501
|
+
schemaVersion: SCHEMA_VERSION,
|
|
502
|
+
reason: sink.reason,
|
|
503
|
+
impact: 'audit records stay in the in-memory log buffer only and are lost at process exit',
|
|
504
|
+
}))
|
|
505
|
+
}
|
|
506
|
+
} catch { /* a broken sink must never block plugin boot */ }
|
|
507
|
+
|
|
508
|
+
const emitHost = createHostAuditSink(ctx)
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Build one audit record, push it to the host log, and return it for embedding
|
|
512
|
+
* in the result. Every record of one invocation shares `sessionId` + `callId`
|
|
513
|
+
* and carries a 1-based `seq` (1 = start, 2 = terminal) so a reader can tell a
|
|
514
|
+
* complete call from one that lost a record.
|
|
515
|
+
*/
|
|
516
|
+
const makeEmit = (exec: { callId?: unknown; agent?: { session?: { id?: unknown } } | undefined }) => {
|
|
517
|
+
const sessionId = sessionIdOf(exec)
|
|
518
|
+
const callId = generateCallId() // spec §3: UUID v4 per invocation, always
|
|
519
|
+
const dshCallId = dshCallIdOf(exec)
|
|
520
|
+
let seq = 0
|
|
521
|
+
return (record: Omit<AuditRecord, 'sessionId' | 'callId' | 'seq' | 'tool' | 'schemaVersion'> & { tool: string }): AuditRecord => {
|
|
522
|
+
seq += 1
|
|
523
|
+
const full: AuditRecord = { sessionId, callId, ...(dshCallId === undefined ? {} : { dshCallId }), schemaVersion: SCHEMA_VERSION, seq, ...record }
|
|
524
|
+
emitHost(full)
|
|
525
|
+
return full
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ====================== Tool: call_external_api ======================
|
|
530
|
+
ctx.tools.register(defineTool({
|
|
531
|
+
name: TOOL_API,
|
|
532
|
+
description:
|
|
533
|
+
'Call an external HTTP API (GET/POST/PUT/PATCH/DELETE/HEAD). Only hosts listed in the plugin '
|
|
534
|
+
+ 'whitelist are allowed; requests time out automatically. Extra query parameters are merged into '
|
|
535
|
+
+ 'the URL with proper encoding. Every call emits a structured audit record (masked request, '
|
|
536
|
+
+ 'truncated response, costMs, status code, errorType) that is persisted with this tool result and '
|
|
537
|
+
+ 'written to the host log.',
|
|
538
|
+
parameters: {
|
|
539
|
+
url: { type: 'string', required: true, description: 'Request URL; host must be in api_allow_hosts' },
|
|
540
|
+
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'], default: 'GET', description: 'HTTP method' },
|
|
541
|
+
headers: { type: 'object', additionalProperties: true, description: 'Request headers (string map); sensitive values are masked in logs only' },
|
|
542
|
+
query: { type: 'object', additionalProperties: true, description: 'Extra query parameters merged into the URL with proper percent-encoding; sensitive names are masked in logs only' },
|
|
543
|
+
body: { type: 'json', description: 'Request body for POST/PUT/PATCH/DELETE. Objects/arrays are JSON-encoded; a string is sent verbatim (e.g. form-urlencoded). Masked + truncated in logs only' },
|
|
544
|
+
timeoutMs: { type: 'integer', description: 'Per-call timeout override in ms; clamped to maxTimeoutMs (default 120000)' },
|
|
545
|
+
},
|
|
546
|
+
output: {
|
|
547
|
+
schema: {
|
|
548
|
+
type: 'object',
|
|
549
|
+
additionalProperties: false,
|
|
550
|
+
properties: {
|
|
551
|
+
ok: { type: 'boolean', required: true },
|
|
552
|
+
status: { type: 'integer', description: 'HTTP status code on transport success' },
|
|
553
|
+
responseHeaders: { type: 'object', additionalProperties: true, description: 'Response headers summary (byte-bounded); includes location on a 3xx' },
|
|
554
|
+
data: { type: 'json', description: 'Parsed response body (JSON when possible, raw text otherwise)' },
|
|
555
|
+
error: { type: 'string', description: 'Refusal/timeout/network/internal failure detail' },
|
|
556
|
+
audit: { type: 'object', additionalProperties: true, required: true, description: 'Structured audit record (masked + truncated); persisted with this result for Trajectory replay' },
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
render: (_args, value) => {
|
|
560
|
+
// The output schema types `audit` as an open JSON object — narrow defensively.
|
|
561
|
+
const audit = value.audit as AuditRecord
|
|
562
|
+
return [{
|
|
563
|
+
type: 'text',
|
|
564
|
+
text: value.ok
|
|
565
|
+
? `API ${value.status} in ${audit.costMs}ms\n${JSON.stringify(value.audit)}`
|
|
566
|
+
: `API failed [${String(audit.errorType)}]: ${value.error ?? 'unknown error'}\n${JSON.stringify(value.audit)}`,
|
|
567
|
+
}]
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
// Top-level try/catch (spec §5.6): even a plugin-internal exception yields an
|
|
571
|
+
// INTERNAL_ERROR audit record — the audit trail never silently vanishes.
|
|
572
|
+
// Parameter types are contravariantly widened (`unknown`) over the schema-inferred
|
|
573
|
+
// JSON value shapes: the registry validates before dispatch, this accepts anything.
|
|
574
|
+
async execute(args: { url: string; method?: HttpMethod; headers?: Record<string, unknown>; query?: Record<string, unknown>; body?: unknown; timeoutMs?: number }, exec) {
|
|
575
|
+
const startTime = Date.now()
|
|
576
|
+
const emit = makeEmit(exec)
|
|
577
|
+
// Declared before the try so the INTERNAL_ERROR catch path can reference it.
|
|
578
|
+
let maskedRequest: AuditJsonValue | undefined
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
// Resolve the effective timeout ONCE, log it, and hand the same number to the
|
|
582
|
+
// core: the audit record then states the budget that was truly applied, not the
|
|
583
|
+
// one that was requested and possibly clamped (spec §5.4).
|
|
584
|
+
const timeoutMs = resolveTimeoutMs(args.timeoutMs, cfg.defaultTimeoutMs, cfg.maxTimeoutMs)
|
|
585
|
+
const query = normalizeQuery(args.query)
|
|
586
|
+
const params: ApiCallParams = {
|
|
587
|
+
url: args.url,
|
|
588
|
+
method: args.method ?? 'GET',
|
|
589
|
+
headers: normalizeHeaders(args.headers),
|
|
590
|
+
query,
|
|
591
|
+
body: args.body,
|
|
592
|
+
timeoutMs,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// "This call was aimed at the metadata service" is the one fact an incident
|
|
596
|
+
// review needs and the one fact a URL never states out loud. Classified
|
|
597
|
+
// statically, so it costs no DNS lookup on the request path.
|
|
598
|
+
const hostRisk = (() => {
|
|
599
|
+
const host = hostOfUrl(params.url)
|
|
600
|
+
return host === null ? null : classifyHost(host)
|
|
601
|
+
})()
|
|
602
|
+
|
|
603
|
+
// Mask + truncate the LOG copy only — the live request below uses raw params.
|
|
604
|
+
// Never throws (the start record comes next and must not be lost). The URL is
|
|
605
|
+
// masked as a STRING too: `?token=…` and userinfo would otherwise escape
|
|
606
|
+
// key-based masking entirely (spec §5.3).
|
|
607
|
+
// `addressClass` sits high in the object on purpose: it is ~15 bytes, and if a
|
|
608
|
+
// tight maxReqLogBytes truncates the tail it must not be what falls off. Only
|
|
609
|
+
// the short class rides here — the explanation lives in the boot record.
|
|
610
|
+
// The name deliberately avoids the substrings `ak`/`sk`: the masker folds key
|
|
611
|
+
// names before keyword matching, so `hostRiskTier` would itself be redacted
|
|
612
|
+
// ("hostri[sk]tier"). See README's masking notes.
|
|
613
|
+
maskedRequest = maskRequestForLog(
|
|
614
|
+
{
|
|
615
|
+
url: maskUrlQuery(params.url),
|
|
616
|
+
method: params.method,
|
|
617
|
+
...(hostRisk === null ? {} : { addressClass: hostRisk.tier }),
|
|
618
|
+
...(Object.keys(query).length === 0 ? {} : { query: maskSensitive(query) }),
|
|
619
|
+
headers: params.headers ?? {},
|
|
620
|
+
body: params.body === undefined ? {} : params.body,
|
|
621
|
+
timeoutMs,
|
|
622
|
+
},
|
|
623
|
+
{ url: bareUrl(params.url), method: params.method ?? 'GET', note: 'request masking failed' },
|
|
624
|
+
cfg.maxReqLogBytes,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
// Event 1 (spec §4.1): start — INFO with the masked request. The trajectory
|
|
628
|
+
// channel has no counterpart for this record: it only ever receives the
|
|
629
|
+
// terminal one, so a call that begins and never returns is invisible there.
|
|
630
|
+
emit({ tool: TOOL_API, level: 'INFO', phase: 'start', request: maskedRequest, costMs: Date.now() - startTime })
|
|
631
|
+
|
|
632
|
+
const result = await callExternalApi(
|
|
633
|
+
params,
|
|
634
|
+
{ apiAllowHosts: cfg.apiAllowHosts, defaultTimeoutMs: cfg.defaultTimeoutMs, maxTimeoutMs: cfg.maxTimeoutMs, maxResponseBytes: cfg.maxResponseBytes, maxRequestBytes: cfg.maxRequestBytes },
|
|
635
|
+
exec.signal,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
if (result.ok) {
|
|
639
|
+
// Event 2 (spec §4.1): finished — INFO with costMs, HTTP code, truncated response summary.
|
|
640
|
+
// The response is masked too (spec §5.3: logs must never store credentials in
|
|
641
|
+
// plaintext) — an API that echoes request headers back would otherwise leak them.
|
|
642
|
+
const record = emit({
|
|
643
|
+
tool: TOOL_API,
|
|
644
|
+
level: 'INFO',
|
|
645
|
+
phase: 'finished',
|
|
646
|
+
request: maskedRequest,
|
|
647
|
+
response: asJsonSegment(truncateJson(maskSensitive({ status: result.status, headers: result.headers ?? {}, data: result.data }), cfg.maxRespLogBytes)),
|
|
648
|
+
costMs: Date.now() - startTime,
|
|
649
|
+
code: result.status,
|
|
650
|
+
})
|
|
651
|
+
// `data` is omitted when the response body was empty — undefined keys break
|
|
652
|
+
// DSH's lossless-JSON result validation (stringify round-trip).
|
|
653
|
+
return {
|
|
654
|
+
ok: true as const,
|
|
655
|
+
status: result.status,
|
|
656
|
+
...(result.headers !== undefined ? { responseHeaders: result.headers } : {}),
|
|
657
|
+
...(result.data !== undefined ? { data: result.data } : {}),
|
|
658
|
+
audit: record,
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Event 3 (spec §4.1): error — ERROR with classified errorType (response omitted per spec §3).
|
|
663
|
+
// The free-text diagnosis goes through the same masking path as the request
|
|
664
|
+
// segment: `network failure: …` routinely quotes the full URL, so a credential
|
|
665
|
+
// hiding in `?token=…` would otherwise ride into the record — and into the
|
|
666
|
+
// caller-visible error — in plaintext (spec §5.3).
|
|
667
|
+
const errorText = maskTextForLog(result.error ?? 'unknown error')
|
|
668
|
+
const record = emit({
|
|
669
|
+
tool: TOOL_API,
|
|
670
|
+
level: 'ERROR',
|
|
671
|
+
phase: 'error',
|
|
672
|
+
request: maskedRequest,
|
|
673
|
+
costMs: Date.now() - startTime,
|
|
674
|
+
...(result.status !== undefined ? { code: result.status } : {}),
|
|
675
|
+
errorType: result.errorType as ErrorType,
|
|
676
|
+
error: errorText,
|
|
677
|
+
})
|
|
678
|
+
return { ok: false as const, ...(result.status !== undefined ? { status: result.status } : {}), error: errorText, audit: record }
|
|
679
|
+
} catch (err) {
|
|
680
|
+
// Plugin-internal failure (spec §5.6): INTERNAL_ERROR, still audited.
|
|
681
|
+
// An exception message is arbitrary text and can quote a URL — mask it too.
|
|
682
|
+
const errorText = maskTextForLog(`internal error: ${err instanceof Error ? err.message : String(err)}`)
|
|
683
|
+
const record = emit({
|
|
684
|
+
tool: TOOL_API,
|
|
685
|
+
level: 'ERROR',
|
|
686
|
+
phase: 'error',
|
|
687
|
+
// If the log pipeline itself threw before masking completed, fall back to a
|
|
688
|
+
// minimal request segment — the audit record must stay lossless JSON.
|
|
689
|
+
request: maskedRequest ?? { url: bareUrl(args.url) },
|
|
690
|
+
costMs: Date.now() - startTime,
|
|
691
|
+
errorType: ERROR_TYPES.INTERNAL_ERROR,
|
|
692
|
+
error: errorText,
|
|
693
|
+
})
|
|
694
|
+
return { ok: false as const, error: errorText, audit: record }
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
}))
|
|
698
|
+
|
|
699
|
+
// ====================== Tool: run_external_script ======================
|
|
700
|
+
ctx.tools.register(defineTool({
|
|
701
|
+
name: TOOL_SCRIPT,
|
|
702
|
+
description:
|
|
703
|
+
'Execute a script that lives inside the configured whitelist directory (pass ONLY the file '
|
|
704
|
+
+ 'name — paths are refused). The interpreter is chosen from the file extension (.py/.js/.mjs/'
|
|
705
|
+
+ '.cjs/.sh/.bash/.ps1/.bat/.cmd, or a directly executable file), so scripts need no executable '
|
|
706
|
+
+ 'bit and run on Windows too. The process group is killed on timeout; stdout/stderr are '
|
|
707
|
+
+ 'byte-capped. Every run emits a structured audit record persisted with this tool result.',
|
|
708
|
+
parameters: {
|
|
709
|
+
scriptName: { type: 'string', required: true, description: 'Bare script file name inside script_allow_dir — no paths, no separators' },
|
|
710
|
+
args: { type: 'array', items: { type: 'string' }, description: 'Script arguments, passed verbatim as argv (never through a shell)' },
|
|
711
|
+
stdin: { type: 'string', description: 'Text piped to the script stdin, then EOF. Only its byte count is logged (it may hold a credential)' },
|
|
712
|
+
timeoutMs: { type: 'integer', description: 'Per-call timeout override in ms; clamped to maxTimeoutMs (default 120000)' },
|
|
713
|
+
},
|
|
714
|
+
output: {
|
|
715
|
+
schema: {
|
|
716
|
+
type: 'object',
|
|
717
|
+
additionalProperties: false,
|
|
718
|
+
properties: {
|
|
719
|
+
ok: { type: 'boolean', required: true },
|
|
720
|
+
exitCode: { type: 'integer', description: 'Process exit code' },
|
|
721
|
+
interpreter: { type: 'string', description: 'Interpreter that actually ran the file (e.g. node, python, cmd.exe)' },
|
|
722
|
+
stdout: { type: 'string', description: 'Captured stdout, byte-capped with [TRUNCATED] marker when capped' },
|
|
723
|
+
stderr: { type: 'string', description: 'Captured stderr, byte-capped with [TRUNCATED] marker when capped' },
|
|
724
|
+
error: { type: 'string', description: 'Refusal/timeout/non-zero-exit/internal failure detail' },
|
|
725
|
+
audit: { type: 'object', additionalProperties: true, required: true, description: 'Structured audit record (masked + truncated); persisted with this result for Trajectory replay' },
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
render: (_args, value) => {
|
|
729
|
+
// The output schema types `audit` as an open JSON object — narrow defensively.
|
|
730
|
+
const audit = value.audit as AuditRecord
|
|
731
|
+
const scriptName = typeof audit.request === 'object' && audit.request !== null
|
|
732
|
+
? (audit.request as { scriptName?: unknown }).scriptName
|
|
733
|
+
: undefined
|
|
734
|
+
const via = value.interpreter === undefined ? '' : ` via ${value.interpreter}`
|
|
735
|
+
return [{
|
|
736
|
+
type: 'text',
|
|
737
|
+
text: value.ok
|
|
738
|
+
? `script ${String(scriptName ?? '(unknown)')}${via} exited 0 in ${audit.costMs}ms\n${JSON.stringify(value.audit)}`
|
|
739
|
+
: `script failed [${String(audit.errorType)}]: ${value.error ?? 'unknown error'}\n${JSON.stringify(value.audit)}`,
|
|
740
|
+
}]
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
async execute(args: { scriptName: string; args?: string[]; stdin?: string; timeoutMs?: number }, exec) {
|
|
744
|
+
const startTime = Date.now()
|
|
745
|
+
const emit = makeEmit(exec)
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
// Same "resolve once, log the effective value" rule as the API tool. stdin is
|
|
749
|
+
// logged as a BYTE COUNT only — it is a plausible carrier of a credential and
|
|
750
|
+
// has no key names for the keyword masker to match against.
|
|
751
|
+
const timeoutMs = resolveTimeoutMs(args.timeoutMs, cfg.defaultTimeoutMs, cfg.maxTimeoutMs)
|
|
752
|
+
const stdinBytes = typeof args.stdin === 'string' ? Buffer.byteLength(args.stdin, 'utf8') : undefined
|
|
753
|
+
const requestSegment = {
|
|
754
|
+
scriptName: args.scriptName,
|
|
755
|
+
args: args.args ?? [],
|
|
756
|
+
timeoutMs,
|
|
757
|
+
...(stdinBytes === undefined ? {} : { stdinBytes }),
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Event 1 (spec §4.2): start — INFO with the (already safe, file-name-only)
|
|
761
|
+
// request. Like the API tool, this record has no trajectory counterpart.
|
|
762
|
+
const maskedRequest = maskRequestForLog(
|
|
763
|
+
requestSegment,
|
|
764
|
+
{ scriptName: args.scriptName, note: 'request masking failed' },
|
|
765
|
+
cfg.maxReqLogBytes,
|
|
766
|
+
)
|
|
767
|
+
emit({ tool: TOOL_SCRIPT, level: 'INFO', phase: 'start', request: maskedRequest, costMs: Date.now() - startTime })
|
|
768
|
+
|
|
769
|
+
const result = await runExternalScript(
|
|
770
|
+
{ scriptName: args.scriptName, args: args.args, stdin: args.stdin, timeoutMs },
|
|
771
|
+
{
|
|
772
|
+
scriptAllowDir: cfg.scriptAllowDir,
|
|
773
|
+
defaultTimeoutMs: cfg.defaultTimeoutMs,
|
|
774
|
+
maxTimeoutMs: cfg.maxTimeoutMs,
|
|
775
|
+
maxOutputLogBytes: cfg.maxOutputLogBytes,
|
|
776
|
+
scriptWorkingDir: cfg.scriptWorkingDir,
|
|
777
|
+
},
|
|
778
|
+
exec.signal,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
if (result.ok) {
|
|
782
|
+
// Event 2: finished — INFO with costMs, exit code, interpreter, truncated outputs.
|
|
783
|
+
const record = emit({
|
|
784
|
+
tool: TOOL_SCRIPT,
|
|
785
|
+
level: 'INFO',
|
|
786
|
+
phase: 'finished',
|
|
787
|
+
request: maskedRequest,
|
|
788
|
+
response: asJsonSegment(truncateJson(scriptOutputSegment(result), cfg.maxRespLogBytes)),
|
|
789
|
+
costMs: Date.now() - startTime,
|
|
790
|
+
code: result.exitCode,
|
|
791
|
+
})
|
|
792
|
+
return {
|
|
793
|
+
ok: true as const,
|
|
794
|
+
exitCode: result.exitCode,
|
|
795
|
+
...(result.interpreter !== undefined ? { interpreter: result.interpreter } : {}),
|
|
796
|
+
stdout: result.stdout,
|
|
797
|
+
stderr: result.stderr,
|
|
798
|
+
audit: record,
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Event 3: error — ERROR with classified errorType (outputs kept for traceability).
|
|
803
|
+
// Refusal/timeout paths carry no exitCode or captured output — those keys are
|
|
804
|
+
// omitted entirely so the result stays lossless JSON. The message is masked:
|
|
805
|
+
// spawn/launch failures echo interpreter paths and the caller's own argv back.
|
|
806
|
+
const errorText = maskTextForLog(result.error ?? 'unknown error')
|
|
807
|
+
const record = emit({
|
|
808
|
+
tool: TOOL_SCRIPT,
|
|
809
|
+
level: 'ERROR',
|
|
810
|
+
phase: 'error',
|
|
811
|
+
request: maskedRequest,
|
|
812
|
+
response: asJsonSegment(truncateJson(scriptOutputSegment(result), cfg.maxRespLogBytes)),
|
|
813
|
+
costMs: Date.now() - startTime,
|
|
814
|
+
...(result.exitCode !== undefined ? { code: result.exitCode } : {}),
|
|
815
|
+
errorType: result.errorType as ErrorType,
|
|
816
|
+
error: errorText,
|
|
817
|
+
})
|
|
818
|
+
return {
|
|
819
|
+
ok: false as const,
|
|
820
|
+
...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {}),
|
|
821
|
+
...(result.interpreter !== undefined ? { interpreter: result.interpreter } : {}),
|
|
822
|
+
...(result.stdout !== undefined ? { stdout: result.stdout } : {}),
|
|
823
|
+
...(result.stderr !== undefined ? { stderr: result.stderr } : {}),
|
|
824
|
+
error: errorText,
|
|
825
|
+
audit: record,
|
|
826
|
+
}
|
|
827
|
+
} catch (err) {
|
|
828
|
+
// Plugin-internal failure (spec §5.6): INTERNAL_ERROR, still audited.
|
|
829
|
+
// An exception message is arbitrary text and can quote a URL — mask it too.
|
|
830
|
+
const errorText = maskTextForLog(`internal error: ${err instanceof Error ? err.message : String(err)}`)
|
|
831
|
+
const record = emit({
|
|
832
|
+
tool: TOOL_SCRIPT,
|
|
833
|
+
level: 'ERROR',
|
|
834
|
+
phase: 'error',
|
|
835
|
+
request: maskRequestForLog(
|
|
836
|
+
{ scriptName: args.scriptName, args: args.args ?? [] },
|
|
837
|
+
{ scriptName: args.scriptName, note: 'request masking failed' },
|
|
838
|
+
cfg.maxReqLogBytes,
|
|
839
|
+
),
|
|
840
|
+
costMs: Date.now() - startTime,
|
|
841
|
+
errorType: ERROR_TYPES.INTERNAL_ERROR,
|
|
842
|
+
error: errorText,
|
|
843
|
+
})
|
|
844
|
+
return { ok: false as const, error: errorText, audit: record }
|
|
845
|
+
}
|
|
846
|
+
},
|
|
847
|
+
}))
|
|
848
|
+
}
|