@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/README.md
CHANGED
|
@@ -1,3 +1,533 @@
|
|
|
1
|
-
#
|
|
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.
|