@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 CHANGED
@@ -1,3 +1,533 @@
1
- # Temporary Holding Version
1
+ # dsh-log (v1.1.2)
2
+
3
+ DSH plugin providing two tools with **structured audit logging**:
4
+
5
+ | Tool | Capability |
6
+ | --- | --- |
7
+ | `call_external_api` | HTTP GET/POST/PUT/PATCH/DELETE/HEAD with host whitelist (SSRF protection), AbortController timeout, recursive sensitive-field masking (keys, URL strings, error text), request/response byte caps, `set-cookie` redaction |
8
+ | `run_external_script` | Executes scripts inside a whitelisted directory (bare file names only — path traversal refused), **interpreter chosen from the file extension**, process-group kill on timeout, byte-capped stdout/stderr capture |
9
+
10
+ Every invocation emits the spec's unified audit record (`sessionId`, `callId` UUID v4, `schemaVersion`, `tool`, `level`, `seq`, `phase`, masked `request`, truncated `response`, `costMs`, `code`, `errorType`, `error`) as **two records per call** — a `start` record plus exactly one terminal record (`finished` **or** `error`, mutually exclusive) — through a dual sink:
11
+
12
+ 1. **Host log** — one structured JSON line per record on `ctx.logger('dsh-log')`. Cordis's built-in logger only buffers in memory and some DSH builds register no exporters, so the plugin registers its OWN persistent exporter: every record is appended as a JSON line to `$DSH_HOME/logs/dsh-log-YYYY-MM-DD.jsonl` (survives restarts, grep-able, works for non-agent callers too). Files rotate daily and by size (`-partN` siblings past `maxLogFileSizeBytes`), and files older than `logRetentionDays` are cleaned up automatically. If the exporter API is missing or its registration fails, the plugin says so out loud (`event: exporter-unavailable` + `persistentLog: false` on the boot record) instead of silently dropping to an in-memory buffer. Boot also writes one `plugin-started` record carrying the effective config, `pluginVersion` and the sink status for ops. These files are the v1.2 remote-push (ELK/CLS/OTEL) hook point.
13
+ 2. **Session stream / Trajectory** — the **terminal** audit record is embedded in the tool's canonical result (`audit` field), which DSH persists through the well-known `tool/result` session event and renders on the WebUI Trajectory page; `render` also prints it as human-readable text.
14
+
15
+ > **Why not a custom `audit/log` session event type?** This build's persistence restore
16
+ > refuses unknown non-ignorable event types (a session containing one would fail to reload),
17
+ > and `Session.append` cannot mark events ignorable. Embedding the record in the tool result
18
+ > is the persistence-safe, replayable equivalent — documented here as a deliberate v1.0 choice.
19
+
20
+ ## What each channel actually holds
21
+
22
+ The two sinks are not two renderings of the same thing — they have **different coverage**.
23
+ Verified against a real 30 s TIMEOUT call and its session archive:
24
+
25
+ | | Host log (channel 1) | Trajectory (channel 2) |
26
+ | --- | --- | --- |
27
+ | Records per call | **2** — `start` + one terminal | **1** — the terminal record only |
28
+ | Written | per record, in real time | once, after the tool returns |
29
+ | Timestamp | every record carries `ts` (ms) | no `ts`; the session event carries `time` |
30
+ | Coverage | non-agent callers (`sessionId: "standalone"`) and boot/lifecycle events too | only calls owned by a session; no boot record |
31
+ | Lifetime | own JSONL files, rotated daily + by size, pruned after `logRetentionDays` | persisted with the session; no rotation or expiry, dies with the session |
32
+ | Durability | best-effort — a failing sink is contained, but never silent: the downgrade is announced at boot, and a sink that dies mid-run reports on stderr and records `event: audit-sink-recovered` (with the lost count) once it writes again | structural — part of the tool result DSH must persist |
33
+ | Consumer | ops / machines: grep, and the v1.2 push to ELK/CLS/OTEL | session replay **and the model's context window** |
34
+
35
+ Three operational consequences:
36
+
37
+ - **A call that begins and never returns is invisible on the trajectory.** During that 30 s
38
+ TIMEOUT the log already held a `start` record while the trajectory was empty.
39
+ - **`isError` on the `tool/result` is `false` even for a failed audit record** (the tool
40
+ returned normally rather than throwing). Judge failure by `ok` / `audit.errorType`, never
41
+ by `isError`.
42
+ - Because `seq` is carried on every record, a **lost record is detectable**: a call whose
43
+ records are not `1, 2` lost one. That is a check the single-record channel cannot make —
44
+ `tools/audit-log.mjs --incomplete` performs it.
45
+
46
+
47
+ ---
48
+
49
+ ## Tool surface
50
+
51
+ ### `call_external_api`
52
+
53
+ | Param | Type | Notes |
54
+ | --- | --- | --- |
55
+ | `url` | string, required | Host must exactly match an `apiAllowHosts` entry |
56
+ | `method` | `GET`\|`POST`\|`PUT`\|`PATCH`\|`DELETE`\|`HEAD` | default `GET` |
57
+ | `headers` | object | Any JSON values; stringified. Sensitive names masked in logs only |
58
+ | `query` | object | Merged into the URL with the URL parser's encoder — a value containing `&`/`=`/spaces **cannot** smuggle an extra parameter |
59
+ | `body` | JSON | Sent for POST/PUT/PATCH/DELETE. Objects/arrays are `JSON.stringify`-ed; **a string is sent verbatim** (form-urlencoded, plain text). No body is sent at all when omitted |
60
+ | `timeoutMs` | integer | Per-call override, clamped to `[1, maxTimeoutMs]`. The audit record shows the **effective** value |
61
+
62
+ Result: `ok`, `status`, `responseHeaders` (byte-bounded; a 3xx keeps `location` visible
63
+ even though it is never followed), `data`, `error`, `audit`.
64
+
65
+ ### `run_external_script`
66
+
67
+ | Param | Type | Notes |
68
+ | --- | --- | --- |
69
+ | `scriptName` | string, required | **Bare file name** inside `scriptAllowDir` — paths/separators/`..` refused before any process starts |
70
+ | `args` | string[] | Passed verbatim as argv — never through a shell |
71
+ | `stdin` | string | Piped to the child, then EOF. Only its **byte count** is logged (it is a plausible credential carrier) |
72
+ | `timeoutMs` | integer | Per-call override, clamped to `[1, maxTimeoutMs]` |
73
+
74
+ Result: `ok`, `exitCode`, `interpreter`, `stdout`, `stderr`, `error`, `audit`.
75
+
76
+ **Interpreter dispatch (v1.1).** This is what makes the tool actually usable on Windows,
77
+ where a `.py`/`.js`/`.sh` file is *not* directly executable by `CreateProcess` (Node's
78
+ `spawn` fails outright — Windows resolves interpreters through file associations only when a
79
+ shell is involved, which spec §5.5 forbids):
80
+
81
+ | Extension | Launch |
82
+ | --- | --- |
83
+ | `.py` | `python` → `python3` → `py` (first one found on PATH wins) |
84
+ | `.js` / `.mjs` / `.cjs` | `node` |
85
+ | `.sh` / `.bash` | `bash` → `sh` |
86
+ | `.ps1` | `pwsh`/`powershell` `-NoProfile -ExecutionPolicy Bypass -File` |
87
+ | `.bat` / `.cmd` (Windows) | `cmd.exe /d /s /c` |
88
+ | anything else | executed directly (must be a real executable) |
89
+
90
+ Interpreter names come from this built-in table, **never from the model**, so no
91
+ caller-supplied string can become an interpreter. A refusal before spawn also names what
92
+ *is* runnable (`… (available in script_allow_dir: a.py, b.sh)`), so a wrong guess is
93
+ recoverable without guessing again.
94
+
95
+ The child's working directory is `scriptWorkingDir`, defaulting to `scriptAllowDir`, so
96
+ scripts get a predictable cwd instead of inheriting DSH's.
97
+
98
+ ---
99
+
100
+ ## Spec → real DSH SDK adaptation table
101
+
102
+ The spec document targets an idealized `@dsh/sdk`. The actual DeepSeek Harness (this
103
+ checkout) uses the Cordis plugin system + `@deepseek-ai/dsh-tools`; per the spec's own
104
+ instruction ("如接口签名不一致,优先适配真实SDK,业务逻辑、日志模型、安全规则保持不变"),
105
+ the business logic, log data model, masking/truncation rules and security rules are
106
+ implemented unchanged in `audit-core.ts`, while the adapter surface maps as follows:
107
+
108
+ | Spec (assumed) | Real DSH (this implementation) |
109
+ | --- | --- |
110
+ | `defineTool({ id, name, params, handler(ctx) })` from `@dsh/sdk` | `defineTool({ name, description, parameters, output: { schema, render }, execute(args, exec) })` from `@deepseek-ai/dsh-tools`; registered via `ctx.tools.register(...)` |
111
+ | `ctx.sessionId` | `exec.agent.session.id` (Agent → Session; same seam the todo/schedule tools use); non-agent callers log under `sessionId: "standalone"` |
112
+ | `ctx.config` (yml values) | Cordis plugin `Config`: schemastery schema validated + defaulted at plugin load, values supplied by the `cordis.patch.yml` entry's `config:` block |
113
+ | `ctx.log.info / ctx.log.error` → session event stream | Dual sink above: host logger JSON lines **plus** audit record embedded in the tool result (persisted via `tool/result`, Trajectory-replayable) |
114
+ | standalone yml plugin file loaded directly | Plugin is an ESM module with Cordis exports (`name`/`inject`/`Config`/`apply`); registered through a patch entry. `dsh-log.yml` is kept as the spec's config reference document |
115
+ | `callId` = generated UUID | `callId` is always a fresh UUID v4 (spec §3); DSH's own `exec.callId` rides along as an extra `dshCallId` field for joining with `tool/call`/`tool/result` events |
116
+
117
+ Config key mapping: `api_allow_hosts → apiAllowHosts`, `script_allow_dir → scriptAllowDir`,
118
+ `default_timeout → defaultTimeoutMs`, `maxReqLogSize → maxReqLogBytes`,
119
+ `maxRespLogSize → maxRespLogBytes`, `maxOutputLogSize → maxOutputLogBytes`.
120
+ Hardening keys added beyond the spec reference: `maxTimeoutMs` (ceiling for a per-call
121
+ timeout override, default 120000; boot fails if it is below `defaultTimeoutMs`),
122
+ `scriptWorkingDir` (defaults to `scriptAllowDir`), `maxResponseBytes` (response-body memory
123
+ cap, default 10 MiB), `maxRequestBytes` (request-body memory/egress cap, default 1 MiB),
124
+ `logRetentionDays` (audit-log retention, default 14) and
125
+ `maxLogFileSizeBytes` (rotation budget per file, default 20 MiB).
126
+ (`enable_otel` is a v1.2 switch — not read in v1.0.)
127
+
128
+ ### Deliberately NOT implemented
129
+
130
+ - **Retries / backoff.** A retry loop would turn one audit record ("this call") into an
131
+ ambiguous number of transport attempts and break the `seq` = 1/2 invariant the lost-record
132
+ check depends on. Retry belongs in the caller, where it is itself auditable.
133
+ - **Following redirects.** `redirect: 'manual'` is load-bearing: the whitelist only validates
134
+ the initial URL, so a 302 from a whitelisted host would otherwise pivot to an internal
135
+ address. The `location` header is returned instead, so the caller sees where it was sent.
136
+ - **Model-supplied env vars for scripts.** `NODE_OPTIONS`, `PYTHONPATH`, `LD_PRELOAD`,
137
+ `BASH_ENV`… turn "run a whitelisted script" into "run an arbitrary file outside the allow
138
+ dir". Credentials can be passed as argv (and/or read by the script itself).
139
+
140
+ ---
141
+
142
+ ## Files
143
+
144
+ ```
145
+ dsh-log/
146
+ ├── index.ts # DSH adapter: Cordis plugin exports + both defineTool registrations + audit sinks
147
+ ├── audit-core.ts # Pure business logic (SDK-free): masking (incl. URL-aware), truncation, whitelist, path traversal, interpreter dispatch, fetch, spawn+process-group kill
148
+ ├── dsh-log.yml # Spec §8 config reference (values are registered via cordis.patch.yml — see below)
149
+ ├── package.json # type: module (ESM + native TS execution), dependency declarations, npm scripts (test / demo / log)
150
+ ├── tsconfig.typecheck.json # Standalone typecheck config (paths → DSH checkout; see "Typechecking" below)
151
+ ├── tools/
152
+ │ └── audit-log.mjs # Host-log reader CLI: filter, stats, and lost-record detection (`npm run log`)
153
+ └── test/
154
+ ├── audit-core.test.ts # 62 unit tests: masking (keys + URLs + text + folded names) / truncation / uuid / whitelist / whitelist-hygiene classification / traversal / timeout resolution / interpreter dispatch + live HTTP & spawn round-trips (incl. redirect refusal, response-body cap, request-body cap, set-cookie redaction, .js execution, stdin, cwd)
155
+ ├── plugin.test.ts # 31 acceptance tests (spec §12 cases 1–7 + §5.6 internal-exception, standalone caller, callId chaining + phase/seq, lossless JSON, persistent/rotated log, startup record, sink-downgrade warning, sink write-failure reporting, v1.1 request/response surface, v1.1.1 leak-surface closure, v1.1.2 allowlist-risk warning + per-call address class)
156
+ ├── audit-log.test.ts # 10 CLI tests: table/stats/json/error filters, legacy records without phase/seq, lost-record detection, v1.1.2 event types, helpful failure on a missing dir
157
+ ├── register-stubs.mjs # `node --import` entry: registers the module hook
158
+ ├── stub-loader.mjs # Resolves @deepseek-ai/* to local stubs (defineTool/z/Context stand-ins)
159
+ └── stubs/ # dsh-tools.ts, schemastery.ts, cordis.ts
160
+ ```
161
+
162
+ ## Deployment (real DSH)
163
+
164
+ 1. Copy the `dsh-log/` folder into the **profile directory** (the directory
165
+ holding the profile's `cordis.yml`, e.g. under `$DSH_HOME/profiles/<name>/`). The
166
+ loader anchors relative plugin specifiers at that directory.
167
+ 2. Register in the profile's **`cordis.patch.yml`** (or the home-level
168
+ `$DSH_HOME/cordis.patch.yml`) — the same file the spec document names:
169
+
170
+ ```yaml
171
+ - insert:
172
+ - id: dsh-log
173
+ name: ./dsh-log/index.ts
174
+ config:
175
+ apiAllowHosts: [api.example.com]
176
+ scriptAllowDir: /opt/dsh/scripts/
177
+ scriptWorkingDir: /opt/dsh/work # optional; defaults to scriptAllowDir
178
+ defaultTimeoutMs: 30000
179
+ maxTimeoutMs: 120000 # ceiling for a per-call timeoutMs override
180
+ maxReqLogBytes: 2048
181
+ maxRespLogBytes: 2048
182
+ maxOutputLogBytes: 512
183
+ ```
184
+
185
+ (Explicit `/index.ts` — Node ESM needs the file extension; `name:` is resolved
186
+ relative to the profile directory where the Loader anchors its base URL.)
187
+ 3. Restart the DSH service. The WebUI tool list shows `call_external_api` and
188
+ `run_external_script`.
189
+
190
+ Notes:
191
+
192
+ - **The profile copy is the one that runs.** The loader anchors `./dsh-log/index.ts`
193
+ beside the profile directory, so editing this source folder has no effect until it is
194
+ re-copied there and the DSH service is restarted. On this machine the live copy is
195
+ `$DSH_HOME/profiles/web/dsh-log/` (registered from
196
+ `$DSH_HOME/profiles/web/cordis.patch.yml`).
197
+ - `scriptAllowDir` is **required** — boot fails loud when empty (a script tool with no
198
+ allow dir is a misconfiguration, not a runtime surprise).
199
+ - An empty `apiAllowHosts` does not break boot; every API call is then refused at
200
+ runtime with `PERMISSION_DENIED` (fail closed).
201
+ - The SDK packages (`@deepseek-ai/dsh-tools`, `@deepseek-ai/schemastery`,
202
+ `@deepseek-ai/cordis`) resolve from the DSH host's own module graph — no local
203
+ `node_modules` install is needed in the plugin folder.
204
+
205
+ ## Usage (agent calls)
206
+
207
+ ```
208
+ 使用 call_external_api,url="https://api.example.com/info", method="POST", headers={"token":"xxx"}, body={"name":"test"}
209
+ ```
210
+
211
+ ```
212
+ 使用 call_external_api,url="https://api.example.com/items", query={"page":2,"sort":"created"}, timeoutMs=10000
213
+ ```
214
+
215
+ ```
216
+ 使用 run_external_script,scriptName="get_data.py", args=["2026"], timeoutMs=60000
217
+ ```
218
+
219
+ `scriptName` is a **bare file name** — the script must live in `scriptAllowDir`;
220
+ paths/separators/`..` are refused before any process starts, and the extension selects the
221
+ interpreter (so `get_data.py` runs under `python`). After either call, the structured audit
222
+ record (masked request, truncated response/output, costMs, code, errorType) is visible in
223
+ the session Trajectory via the tool result and in the host log as JSON lines.
224
+
225
+ ## Reading the log (ops)
226
+
227
+ `tools/audit-log.mjs` is a zero-dependency reader for channel 1 — run it with
228
+ `npm run log -- <options>` (or `node tools/audit-log.mjs`):
229
+
230
+ ```sh
231
+ node tools/audit-log.mjs # newest day: header + last 20 records
232
+ node tools/audit-log.mjs --stats # calls, phases, error mix, p50/p95 latency
233
+ node tools/audit-log.mjs --errors --tail 50 # recent failures
234
+ node tools/audit-log.mjs --call 8b47827d # one invocation (callId prefix works)
235
+ node tools/audit-log.mjs --incomplete # calls that LOST a record
236
+ node tools/audit-log.mjs --all --json # every rotated file, raw JSON lines
237
+ ```
238
+
239
+ `--incomplete` is the part the trajectory cannot replace: it groups records by `callId`
240
+ and reports
241
+
242
+ - `缺终态` — the call started and never reported back (killed mid-flight, or still running), and
243
+ - `首条记录丢失` / `seq 不连续` — the log's own `seq` proves a record should exist and does not.
244
+
245
+ Lost-record detection needs `seq` evidence, so logs written by versions before 1.0.1
246
+ (no `phase`/`seq`) still analyse, just with the `缺终态` check only — those older records
247
+ are classified by field-presence inference. The reader defaults to `$DSH_HOME/logs`
248
+ (falling back to `~/.dsh/logs`), reads all `-partN` siblings of the newest day, and
249
+ tolerates unparsable lines instead of dying on them.
250
+
251
+ ## Security model (spec §5, enforced in `audit-core.ts`)
252
+
253
+ 1. **SSRF protection** — exact case-insensitive host match against `apiAllowHosts`; only
254
+ `http:`/`https:` schemes accepted; refusal happens before any network I/O. Redirects
255
+ are **never followed** (`redirect: 'manual'`): fetch's default redirect-following would
256
+ let a whitelisted host 302-jump to an internal address and bypass the gate (only the
257
+ initial URL is validated) — a 3xx response is reported as-is with its status and the
258
+ body is cancelled so no second request can happen.
259
+ 2. **Script containment** — bare file names only (absolute paths, separators and `..`
260
+ components refused), resolved path re-checked under the allow dir, target must exist
261
+ as a regular file; missing script → `PERMISSION_DENIED`. The refusal also lists what is
262
+ available, which is a usability feature, not a security one: the model could already
263
+ learn the directory contents from its own container.
264
+ 3. **Recursive masking** — keys naming any of `token/secret/password/ak/sk/accessKey/apiKey`
265
+ become `***` in log copies only; the live request keeps raw credentials. Names are **folded**
266
+ (lowercased, separators stripped) before matching, so `apiKey`, `api_key`, `api-key` and
267
+ `x-api-key` are all recognised — a separator cannot hide a credential. Applied to the request
268
+ segment, the API response summary AND the free-text `error` field of every record
269
+ (spec §5.3: logs must never store credentials in plaintext — an API that echoes request
270
+ headers back, or a refusal that quotes the caller's own input, would otherwise leak them).
271
+ A secret with no matching KEY is still caught when it hides **inside a URL string**: query
272
+ values and userinfo are masked in absolute URLs, in bare relative references
273
+ (`/v1/x?token=…`) and in free text that quotes one back (`your request to …?token=… failed`)
274
+ — the everyday shape of an error body. Ordinary prose is never rewritten. Cyclic references
275
+ become `[Circular]` instead of crashing. Script stdout/stderr are plain strings (no keys to
276
+ mask) and are bounded by truncation only — but a URL echoed into stdout is masked by the
277
+ same pass.
278
+ **The two-letter keywords over-match on purpose.** `ak`/`sk` are matched as substrings of the
279
+ folded name, so an innocuous `task`, `disk` or `mask` is redacted too. Over-masking a log field
280
+ is free; under-masking a credential is not — so the direction is deliberate, and it becomes a
281
+ naming RULE for every audit record: **a field whose name contains `ak` or `sk` is redacted
282
+ whatever its value.** (`hostRiskTier` folded to `hostri[sk]tier` and was silently masked to
283
+ `***` — which is why the address class is called `addressClass`.) A test pins both the
284
+ over-match and the field names that must survive it.
285
+ 4. **Global timeout** — both tools bound by `defaultTimeoutMs`, overridable per call via the
286
+ `timeoutMs` argument and clamped to `maxTimeoutMs`. API via AbortController, scripts via
287
+ process-group kill (POSIX: `SIGKILL -pid`; Windows: `taskkill /t /f`, array args — never
288
+ a shell string). When several interpreter candidates are tried, ONE timer covers the whole
289
+ invocation, so probing several interpreters cannot extend the budget. Caller cancellation
290
+ (`exec.signal`) is honored too.
291
+ 5. **No shell injection** — script args pass through verbatim as argv; no shell string is
292
+ ever built or concatenated. Interpreter names come from an internal table, never from the
293
+ model. On Windows, `.bat`/`.cmd` files are routed through
294
+ `cmd.exe /d /s /c <fullPath> <args…>` — still a pure argv array (the gated full path
295
+ and each arg are separate elements, never string-concatenated), with the batch's exit
296
+ code propagated back.
297
+ 6. **Audit integrity** — top-level try/catch in both tools: even a plugin-internal
298
+ exception yields an `INTERNAL_ERROR` audit record (`ok: false` result); the host-log
299
+ sink itself is failure-contained so a broken sink cannot mask the business outcome.
300
+ `runExternalScript` additionally contains *synchronous* spawn rejections (Windows throws
301
+ `EFTYPE`/`EINVAL` outright for a file `CreateProcess` cannot start) so its promise never
302
+ rejects — the "always audited" guarantee depends on that.
303
+ A sink that fails at RUN time is no longer invisible either: the first failure (then at
304
+ most once a minute) is reported on **stderr** — deliberately not through the logger, whose
305
+ only persistent output *is* that sink — and the next successful write appends
306
+ `event: audit-sink-recovered` carrying how many records were dropped meanwhile. Without
307
+ that, a mid-run failure would leave a truncated file that reads as "no calls happened".
308
+ 7. **Response memory cap** — API response bodies are STREAMED with a byte budget
309
+ (`maxResponseBytes`, default 10 MiB) instead of buffered whole; beyond it the request
310
+ is aborted and classified `NETWORK_ERROR`, so a malicious/faulty endpoint returning a
311
+ huge body cannot OOM the DSH process. Log-side truncation (item 3's siblings) still
312
+ applies on top for what actually fits in memory. Response headers are surfaced under a
313
+ separate 8 KiB budget (`MAX_RESPONSE_HEADER_BYTES`), where `set-cookie`/`set-cookie2`
314
+ values are replaced by `[REDACTED]` **at the source** — so the session credential reaches
315
+ neither the log nor the model, and the two cannot drift apart. Nothing is lost by that:
316
+ this tool holds no cookie jar, so the caller could not use the value anyway.
317
+ 8. **Request memory/egress cap** — the request body is serialized ONCE, measured, and refused
318
+ **before any I/O** when it exceeds `maxRequestBytes` (default 1 MiB), the exact counterpart
319
+ of item 7: the response side is bounded because a hostile endpoint controls it, the request
320
+ side because the *model* does. The refusal is classified `PERMISSION_DENIED` — a local
321
+ pre-flight rejection, like the whitelist gate — so it stays inside the spec's enum instead
322
+ of inventing a class every consumer would have to learn.
323
+ 9. **Whitelist hygiene** — the gate compares host STRINGS, so it can never notice that a
324
+ whitelisted host *is* the cloud metadata service or an intranet box. `classifyHost` answers
325
+ that **lexically** (special-purpose IP literals + private-use name suffixes; no DNS lookup —
326
+ the gate must stay a pure string comparison, and a request-path resolver would be both a
327
+ latency and a TOCTOU liability). Boot emits `event: allowlist-risk` at **WARN** when an entry
328
+ lands in `metadata` / `link-local` / `private` (IMDS `169.254.169.254`, `fd00:ec2::254`,
329
+ RFC1918, `fe80::/10`, ULA, CGNAT, `.internal`/`.local`/`.home.arpa`, single-label names), and
330
+ tags **loopback** (`127.0.0.0/8`, `::1`, `localhost`, `0.0.0.0`) as `INFO` only — warning on
331
+ `127.0.0.1` would train the operator to ignore the warning that matters. The boot record always
332
+ carries `allowlist: { entries, risks, scope }`, where `scope` states the limit out loud: a
333
+ public name that *resolves* to a private address is not detectable without DNS. Warnings are
334
+ advisory, never fatal — reaching an intranet API can be a legitimate deployment. Every audited
335
+ call additionally carries `request.addressClass` (short tier only, positioned high so a tight
336
+ `maxReqLogBytes` cannot truncate it away), which labels a refusal too: "this attempt was aimed
337
+ at the metadata service" is the fact an incident review needs and the fact a URL never states.
338
+
339
+ ## Error classification (spec §6)
340
+
341
+ | errorType | API tool | Script tool |
342
+ | --- | --- | --- |
343
+ | `TIMEOUT` | request exceeded the effective timeout (AbortController fired) | process group killed at the effective timeout |
344
+ | `NETWORK_ERROR` | fetch/TLS/connection failure (incl. mid-body drop); response body exceeded `maxResponseBytes` (aborted) | — |
345
+ | `PERMISSION_DENIED` | host not whitelisted / non-http(s) scheme / invalid URL / request body over `maxRequestBytes` | traversal, absolute path, missing script, interpreter not found on PATH, a target the OS refuses to start, missing `scriptWorkingDir`, spawn failure |
346
+ | `SCRIPT_EXIT_ERROR` | — | non-zero exit code (`code` carries the exit code) |
347
+ | `INTERNAL_ERROR` | plugin-internal exception (top-level catch) | plugin-internal exception; caller abort (USER_CANCEL tracking lands in v1.3) |
348
+
349
+ Non-2xx HTTP responses are **not** errors in v1.0 (the spec's enum has no HTTP_ERROR
350
+ class): the call returns `ok: true` with `status` and the parsed body; the audit record
351
+ carries the code for review.
352
+
353
+ ## Running the tests
354
+
355
+ From the plugin folder (Node ≥ 23.6, native TypeScript execution):
356
+
357
+ ```sh
358
+ # all three suites (103 tests)
359
+ npm test
360
+
361
+ # 62 unit tests — pure core, no stubs
362
+ node --test test/audit-core.test.ts
363
+
364
+ # 31 acceptance tests — real index.ts adapter with SDK boundary stubbed
365
+ node --import ./test/register-stubs.mjs --test test/plugin.test.ts
366
+
367
+ # 10 CLI tests — the host-log reader
368
+ node --test test/audit-log.test.ts
369
+ ```
370
+
371
+ The acceptance suite covers spec §12 end-to-end through `apply() → ctx.tools.register →
372
+ tool.execute(args, exec)`: case 1 whitelist refusal (asserts zero server hits), case 2
373
+ masking (server echoes raw credentials back to prove the live request is untouched),
374
+ case 3 timeout, case 4 path traversal, case 5 normal run, case 6 non-zero exit,
375
+ case 7 oversized-body truncation (full payload verified at the server), plus §5.6
376
+ internal-exception auditing, standalone (non-agent) callers, single-callId chaining
377
+ with `phase`/`seq` ordering across one invocation, lossless-JSON result regression,
378
+ persistent host-log durability, the `plugin-started` config record (including its
379
+ `persistentLog` flag), the loud sink-downgrade warning for a logger without the
380
+ `exporter` API, and size-based rotation (`-partN`) with retention cleanup of stale
381
+ dated files.
382
+
383
+ v1.1 adds: `query` merging with encoding (asserting a `&` in a value cannot create an
384
+ extra parameter) and its name-based masking; a credential inside the URL string being
385
+ masked even in the response echo; `responseHeaders` reaching both caller and audit record;
386
+ a 3xx reporting `location` while the server is hit exactly once; a per-call `timeoutMs`
387
+ override shortening a hung call and appearing in the `start` record; a string body going out
388
+ verbatim vs. an object body being JSON-encoded; a `.js` script running under `node` with a
389
+ predictable cwd and `interpreter` in the audit record; stdin being piped while only its byte
390
+ count is logged; and a refusal naming the scripts that are actually available.
391
+
392
+ v1.1.1 adds the leak-surface and health cases: a credential quoted back inside an error
393
+ message reaching neither the log nor the result (the traversal refusal echoes the caller's
394
+ own file name, which is exactly how it would escape); `set-cookie` redacted in both the
395
+ audit log and the model-visible result while `x-trace` passes through; a 1 MiB+1 request
396
+ body refused with zero server hits and a complete `start` + `error` record pair; and a sink
397
+ pointed at an unwritable path announcing itself on stderr rather than dropping records
398
+ silently.
399
+
400
+ v1.1.2 adds the whitelist-hygiene cases: the tier table asserted directly (loopback /
401
+ private / link-local / metadata, including IPv4-mapped `::ffff:169.254.169.254` and the
402
+ `172.32.0.1` / `192.169.0.1` boundaries that must NOT be flagged); a near-miss that is not
403
+ an address (`999.1.1.1`) staying half-classified-free; the single-label rule not swallowing
404
+ a global IPv6 literal (a real bug this suite caught — `2606:4700::1111` has no dot either);
405
+ whitespace/case/zone-id/bracket normalisation; a boot context whose whitelist mixes public,
406
+ private, metadata and loopback entries, asserting the WARN line lists exactly the three
407
+ warned hosts with reasons while loopback stays out of it; the suite's own loopback-only
408
+ whitelist raising NO warning; and a refused call still carrying `request.addressClass`
409
+ (incl. a metadata-targeted attempt). It also pins the masker's deliberate over-match
410
+ (`task`/`disk`/`mask` → `***`) alongside the field names that must survive it.
411
+
412
+ The CLI suite proves the reader on a fixture containing a complete call, a call missing
413
+ its terminal record, a call whose first record was lost, a call that failed *before*
414
+ starting (which must not be reported as broken), a corrupt line, legacy records
415
+ without `phase`/`seq`, and v1.1.2 records (`allowlist-risk` counted as an event, not a
416
+ parse failure; the unknown `addressClass` field round-tripping untouched).
417
+
418
+ ## Typechecking against the real SDK types
419
+
420
+ `tsconfig.typecheck.json` maps `@deepseek-ai/dsh-tools`, `@deepseek-ai/schemastery` and
421
+ `@deepseek-ai/cordis` into a local DSH checkout (edit the `paths` values to your
422
+ checkout location):
423
+
424
+ ```sh
425
+ node <dsh-checkout>/node_modules/typescript/bin/tsc -p tsconfig.typecheck.json
426
+ ```
427
+
428
+ Result on this checkout: **zero errors in `index.ts` / `audit-core.ts`**. The remaining
429
+ diagnostics are pre-existing strict-mode findings inside the vendored
430
+ `vendor/cordis/src/*.ts` sources (cordis ships no built `.d.ts`, so the standalone
431
+ harness compiles its raw source under this config's stricter flags); they are unrelated
432
+ to this plugin and do not appear in DSH's own build.
433
+
434
+ ## v1.2+ roadmap hooks (per spec §2/§7)
435
+
436
+ - Remote log push: consume the rotated host-log JSONL files (`$DSH_HOME/logs/dsh-log-YYYY-MM-DD*.jsonl`; single structured sink already in place).
437
+ - OTEL metrics, sampling, webhook alerts — off by default (`enable_otel: false`).
438
+ - v1.3: global audit middleware subscribing `tool.beforeInvoke/afterInvoke/onError` +
439
+ `USER_CANCEL` event logging; per-invocation spanId.
440
+
441
+ ## Changelog
442
+
443
+ - **1.1.2** — additive; `schemaVersion` stays `1.0` (no audit field changed shape; the new
444
+ information rides inside `request` plus a new lifecycle event). Readers of 1.0/1.1.x
445
+ records are unaffected.
446
+ - **A risky whitelist entry is announced at boot** (`event: allowlist-risk`, WARN). The gate
447
+ compares host strings, so it can never notice that a whitelisted host *is* the cloud
448
+ metadata service (`169.254.169.254`, `fd00:ec2::254`, `metadata.google.internal`) or an
449
+ intranet box (RFC1918, IPv6 ULA, CGNAT, `fe80::/10`, `.internal`/`.local`/`.home.arpa`,
450
+ single-label names). `classifyHost` decides *lexically* — no DNS, so the request path stays
451
+ a pure string comparison — and boot states the limit of that method rather than implying a
452
+ guarantee it cannot give. **Loopback is `INFO`, never part of the warning**: warning on
453
+ `127.0.0.1` would train the operator to ignore the warning that matters. Advisory, not
454
+ fatal — reaching an intranet API can be a legitimate deployment.
455
+ - **Every audited call carries `request.addressClass`** (the short tier: `loopback` /
456
+ `private` / `link-local` / `metadata`) and it labels a *refusal* too, because the attempt is
457
+ recorded before the gate runs. The field sits high in the request object on purpose: at
458
+ ~15 bytes it must not be what a tight `maxReqLogBytes` truncates away.
459
+ - **The masker's deliberate over-match is now a documented contract, with a naming rule
460
+ attached.** `ak`/`sk` match as substrings of the folded key name, so `task`, `disk` and
461
+ `mask` are redacted as well. This is the safe direction, but it bit this very release:
462
+ `request.hostRiskTier` folded to `hostri[sk]tier` and its value was silently masked to
463
+ `***` — the field is therefore named `addressClass`. Any future audit field must avoid the
464
+ substrings `ak`/`sk`; a test pins both the over-match and the names that must survive.
465
+ - **One bug caught while writing the tests**: the single-label rule (a dotless host resolves
466
+ locally, never on the public internet) also matched global IPv6 literals, which have no dot
467
+ either — so `2606:4700::1111` was reported as `private`. Address literals are now excluded
468
+ from the name rules, and IMDS detection matches on octets so the IPv4-mapped
469
+ `::ffff:169.254.169.254` is recognised as metadata rather than merely link-local.
470
+ - **1.1.1** — additive; `schemaVersion` stays `1.0` (no audit field changed shape). Closes the
471
+ remaining leak surfaces and makes a broken sink audible.
472
+ - **The `error` field is masked.** Every record's free-text `error` (and the `error` handed
473
+ back to the caller) now passes through the same URL-aware masking as the request segment.
474
+ This mattered because error strings are *helpful by design* — a refusal quotes the offending
475
+ input back so the model can correct it, and that echo is exactly how a credential would
476
+ leave the tool. `fetch` failures and internal exceptions are covered too, since their
477
+ messages are not ours to control.
478
+ - **Credential names are matched across separators.** `isSensitiveName` folds a name
479
+ (lowercase, separators stripped) before matching, so `apiKey` / `api_key` / `api-key` /
480
+ `x-api-key` are all recognised; previously only spellings containing the keyword
481
+ contiguously were masked, and `api_key` slipped through. (Found by a new test that expected
482
+ it to be caught — the test was right and the code was wrong.)
483
+ - **`maxRequestBytes` (default 1 MiB)** caps the request body. It is serialized once, measured,
484
+ and refused **before any I/O**, so an oversized body is never pushed onto the wire — the
485
+ counterpart of `maxResponseBytes`. Classified `PERMISSION_DENIED` (a local pre-flight
486
+ rejection, like the whitelist gate) rather than adding a new class to the spec's enum.
487
+ - **`set-cookie`/`set-cookie2` are redacted at the source** (inside `collectResponseHeaders`),
488
+ so the session credential reaches neither the audit log nor the model and the two cannot
489
+ drift apart. Nothing usable is lost: this tool holds no cookie jar.
490
+ - **A sink that dies mid-run reports itself.** Write failures are counted and announced on
491
+ stderr — first failure immediately, then at most once a minute — and the next successful
492
+ write appends `event: audit-sink-recovered` carrying how many records were dropped meanwhile.
493
+ Previously the only trace of a mid-run failure was a file that quietly stopped growing,
494
+ which reads as "no calls happened".
495
+ - **1.1** — additive; `schemaVersion` stays `1.0` (no audit field changed shape; new
496
+ information rides inside `request`/`response`). Readers of 1.0 records are unaffected.
497
+ - **`run_external_script` picks the interpreter from the file extension.** On Windows a
498
+ `.py`/`.js`/`.sh` file could not be spawned AT ALL in v1.0 — the tool only worked for
499
+ `.bat`/`.cmd` and real executables. Now `.py`/`.js`/`.mjs`/`.cjs`/`.sh`/`.bash`/`.ps1`
500
+ run under their interpreter (with candidate fallbacks, e.g. `python`→`python3`→`py`),
501
+ and the interpreter that actually ran is recorded on the audit record.
502
+ - **Per-call `timeoutMs`** on both tools, clamped by the new `maxTimeoutMs` ceiling
503
+ (default 120000; boot fails if it is below `defaultTimeoutMs`). The audit record shows
504
+ the effective value.
505
+ - **`call_external_api`**: added `PATCH`/`HEAD`; a `query` object merged with proper
506
+ percent-encoding; `responseHeaders` returned (and logged) under a byte budget, so a 3xx's
507
+ `location` is visible without ever following it; a **string body is sent verbatim**
508
+ instead of JSON-quoted; no body at all is sent when omitted (previously a literal `null`).
509
+ - **`run_external_script`**: `stdin` piped to the child (logged as a byte count only) and a
510
+ configurable `scriptWorkingDir` (defaults to `scriptAllowDir`) so scripts get a
511
+ predictable cwd. A refusal now names the scripts that ARE available.
512
+ - **Masking is URL-aware.** A credential with no matching KEY is now masked when it hides in
513
+ a URL string: query values and userinfo in absolute URLs, bare relative references
514
+ (`/v1/x?token=…`) and URLs quoted inside prose (`… request to /v1/x?token=… failed`) —
515
+ i.e. the shape an API's error body usually takes. Ordinary prose is never rewritten.
516
+ - **Robustness**: `runExternalScript` contains synchronous spawn rejections (Windows throws
517
+ `EFTYPE`/`EINVAL` for a file `CreateProcess` cannot start) so its promise never rejects;
518
+ a missing working directory is refused up front instead of being misreported as a missing
519
+ interpreter; one timer covers all interpreter candidates.
520
+ - **1.0.1** — additive; readers of 1.0 records are unaffected.
521
+ - `phase` (`start`/`finished`/`error`) and `seq` on every record: the lifecycle is
522
+ explicit and a lost record becomes detectable (previously only inferable).
523
+ - Boot record carries `pluginVersion`, `persistentLog` and `logDir`; if the logger has no
524
+ `exporter` API or registration fails, the plugin emits `event: exporter-unavailable`
525
+ at WARN (and `persistentLog: false`) instead of downgrading to an in-memory buffer silently.
526
+ - Request log segments are built through a never-throwing path with a credential-free
527
+ fallback (the fallback strips query strings), so a masking failure can no longer cost
528
+ the `start` record.
529
+ - New `tools/audit-log.mjs` host-log reader: filters, statistics, lost-record detection,
530
+ legacy-record inference; `npm test` / `npm run demo` / `npm run log` scripts added.
531
+ - README corrected: **two** records per call (start + one terminal), not three events.
532
+ - **1.0** — initial release (spec §1–§12).
2
533
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.