@catheadowl/dsh-eval 0.1.0 → 0.2.1
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/CHANGELOG.md +57 -0
- package/README.i18n.yaml +6 -0
- package/README.md +59 -55
- package/README.zh.md +129 -0
- package/bin/dsh-eval.mjs +335 -335
- package/bin/dsh-review.mjs +166 -154
- package/docs/README.md +6 -2
- package/docs/cross-turn.md +66 -0
- package/docs/disablerows.md +1 -1
- package/docs/experimental.md +35 -0
- package/docs/host-wiring.md +3 -3
- package/docs/known-issues.md +9 -1
- package/docs/matchers.md +28 -2
- package/docs/review.md +13 -10
- package/docs/rowconfig.md +39 -0
- package/docs/runner-api.md +42 -0
- package/package.json +19 -3
- package/src/adapters/dsh/review.mjs +210 -192
- package/src/assertions.mjs +499 -389
- package/src/cli.mjs +8 -8
- package/src/config.mjs +1 -1
- package/src/discovery.mjs +190 -115
- package/src/driver/multi-turn-driver.mjs +163 -0
- package/src/experiment/review.mjs +118 -118
- package/src/experimental.mjs +39 -0
- package/src/index.mjs +38 -49
- package/src/mock/mock-adapter.mjs +73 -73
- package/src/mock/script.mjs +49 -49
- package/src/overlay.mjs +109 -0
- package/src/report.mjs +1 -1
- package/src/review-report.mjs +14 -1
- package/src/runner.mjs +236 -373
- package/src/sandbox.mjs +262 -0
- package/src/tool-validation.mjs +77 -77
- package/src/trace.mjs +293 -218
- package/src/adapters/dsh/index.mjs +0 -7
package/src/sandbox.mjs
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The isolated-DSH_HOME sandbox shared by every headless dsh run this
|
|
3
|
+
* framework performs — the behavior eval runner (runner.mjs) and the dsh
|
|
4
|
+
* review adapter (adapters/dsh/review.mjs). Both need the identical
|
|
5
|
+
* isolation recipe, and this module is the single place it lives:
|
|
6
|
+
*
|
|
7
|
+
* - resolve where the REAL home is (`DSH_HOME` or `~/.dsh`);
|
|
8
|
+
* - stage a profile store into a temporary home (junction-aware copy, see
|
|
9
|
+
* `stageProfileStore`) and copy the managed credential document in;
|
|
10
|
+
* - spawn the compiled CLI with stdout/stderr capture and a SIGTERM timeout;
|
|
11
|
+
* - tear the temporary run directory down WITHOUT ever descending through
|
|
12
|
+
* a junction into the real store (see `teardownSandbox`).
|
|
13
|
+
*
|
|
14
|
+
* `DSH_EVAL_KEEP_TMP` / `DSH_REVIEW_KEEP_TMP` are caller concerns: each
|
|
15
|
+
* caller passes `{ keep }` so this module stays policy-free.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process'
|
|
19
|
+
import {
|
|
20
|
+
chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync,
|
|
21
|
+
} from 'node:fs'
|
|
22
|
+
import { homedir } from 'node:os'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
|
|
25
|
+
/** The profile-local module-fallback directory, rebuilt fresh by boot and never staged. */
|
|
26
|
+
const MODULE_FALLBACK_DIR = '.dsh-module-fallback'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where the real Harness home is: an ambient `DSH_HOME` when set (non-blank),
|
|
30
|
+
* otherwise the default `~/.dsh`. The sandbox overrides `DSH_HOME` per run;
|
|
31
|
+
* this resolves the store it stages FROM.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveRealDshHome(env = process.env) {
|
|
34
|
+
return (env.DSH_HOME ?? '').trim() !== '' ? env.DSH_HOME : join(homedir(), '.dsh')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Stage the profile store into a temporary DSH_HOME. The BOOTED profile's
|
|
39
|
+
* directory is COPIED (sans its own `node_modules` and `.dsh-module-fallback`):
|
|
40
|
+
* `prepareProfile` unconditionally rewrites the profile's root cordis.yml on
|
|
41
|
+
* every boot, and copying keeps that write inside the temporary home instead
|
|
42
|
+
* of leaking through a junction into the real store. Only the profile's own
|
|
43
|
+
* `node_modules` stays junctioned — out-of-tree plugin resolution needs it,
|
|
44
|
+
* and a task boot never writes it. The module-fallback directories (the
|
|
45
|
+
* store-level shared `profiles/node_modules` and the profile-local
|
|
46
|
+
* `.dsh-module-fallback`) are deliberately NOT staged: `healProfilesModuleFallback`
|
|
47
|
+
* rebuilds both fresh in the temporary home on every boot, and `.dsh-module-fallback`
|
|
48
|
+
* in particular is full of junctions that a naive recursive copy would follow
|
|
49
|
+
* into the real store. The copy is junction-aware — links are recreated as
|
|
50
|
+
* links, never descended (see `copyProfileEntry`). An absent profile copies
|
|
51
|
+
* nothing: boot initializes shipped templates inside the temporary home.
|
|
52
|
+
* @param {string} realHome - the real Harness home holding `profiles/`.
|
|
53
|
+
* @param {string} tmpHome - the temporary home (created up to `profiles/`).
|
|
54
|
+
* @param {string} profileName - the profile this run boots.
|
|
55
|
+
* @returns {string[]} every created junction path (see teardownSandbox).
|
|
56
|
+
*/
|
|
57
|
+
export function stageProfileStore(realHome, tmpHome, profileName) {
|
|
58
|
+
const junctions = []
|
|
59
|
+
const tmpProfiles = join(tmpHome, 'profiles')
|
|
60
|
+
mkdirSync(tmpProfiles, { recursive: true })
|
|
61
|
+
const realProfiles = join(realHome, 'profiles')
|
|
62
|
+
if (!existsSync(realProfiles)) return junctions
|
|
63
|
+
const realProfileDir = join(realProfiles, profileName)
|
|
64
|
+
if (!existsSync(join(realProfileDir, 'package.json'))) return junctions
|
|
65
|
+
const tmpProfileDir = join(tmpProfiles, profileName)
|
|
66
|
+
mkdirSync(tmpProfileDir, { recursive: true })
|
|
67
|
+
for (const entry of readdirSync(realProfileDir, { withFileTypes: true })) {
|
|
68
|
+
if (entry.name === 'node_modules' || entry.name === MODULE_FALLBACK_DIR) continue
|
|
69
|
+
copyProfileEntry(join(realProfileDir, entry.name), join(tmpProfileDir, entry.name), junctions)
|
|
70
|
+
}
|
|
71
|
+
const profileModules = join(realProfileDir, 'node_modules')
|
|
72
|
+
if (existsSync(profileModules)) {
|
|
73
|
+
const target = join(tmpProfileDir, 'node_modules')
|
|
74
|
+
symlinkSync(profileModules, target, 'junction')
|
|
75
|
+
junctions.push(target)
|
|
76
|
+
}
|
|
77
|
+
return junctions
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Copy one profile entry (file, directory, or link) into the staged profile.
|
|
82
|
+
* A link is recreated as a link (`'junction'` on Windows) instead of being
|
|
83
|
+
* followed: Node's `cpSync` dereferences Windows junctions in recursive mode
|
|
84
|
+
* (no cycle guard), so a junction-bearing tree would recurse into the real
|
|
85
|
+
* store and overflow the native stack. Recreated junctions are appended to
|
|
86
|
+
* `junctions` so callers can unlink them before `rmSync` (which would
|
|
87
|
+
* otherwise descend through them into the real store).
|
|
88
|
+
*/
|
|
89
|
+
function copyProfileEntry(from, to, junctions) {
|
|
90
|
+
const stat = lstatSync(from)
|
|
91
|
+
if (stat.isSymbolicLink()) {
|
|
92
|
+
const target = readlinkSync(from)
|
|
93
|
+
symlinkSync(target, to, 'junction')
|
|
94
|
+
junctions.push(to)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (stat.isDirectory()) {
|
|
98
|
+
mkdirSync(to, { recursive: true })
|
|
99
|
+
for (const child of readdirSync(from)) {
|
|
100
|
+
copyProfileEntry(join(from, child), join(to, child), junctions)
|
|
101
|
+
}
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
copyFileSync(from, to)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Enumerate every loader row id the STAGED profile composes BEYOND the host
|
|
109
|
+
* templates (`@deepseek-ai/*` bundles) — the data source for the review
|
|
110
|
+
* adapter's default blank environment (blank = dsh-base/dsh-headless only,
|
|
111
|
+
* no out-of-tree plugin face regardless of what the host profile carries).
|
|
112
|
+
*
|
|
113
|
+
* Composition-aware, per the host's ordered-layer model: the profile root
|
|
114
|
+
* `cordis.yml` ships as an EMPTY entry list — plugins enter either through
|
|
115
|
+
* the profile's own `cordis.patch.yml` rows or through out-of-tree bundles
|
|
116
|
+
* listed in `package.json`'s `dsh.profile.bundles`, each bundle contributing
|
|
117
|
+
* rows from its own `cordis.patch.yml`/`cordis.yml` under the profile's
|
|
118
|
+
* (junctioned) `node_modules`. All three sources live inside the staged home.
|
|
119
|
+
*
|
|
120
|
+
* A minimal token scan (`- id: <token>` at any indentation, which also
|
|
121
|
+
* covers rows nested under `- insert:`), not a YAML parse (same precedent as
|
|
122
|
+
* the file-history minimal session-log decoder). Entries without an `id` are
|
|
123
|
+
* invisible to id-targeted disables by construction and thus not collected.
|
|
124
|
+
* Unreadable sources contribute nothing — callers keep their static
|
|
125
|
+
* fallback rows.
|
|
126
|
+
* @param {string} tmpHome - the staged temporary home.
|
|
127
|
+
* @param {string} profileName - the staged profile name.
|
|
128
|
+
* @returns {string[]} deduplicated loader row ids beyond the host templates.
|
|
129
|
+
*/
|
|
130
|
+
export function stagedPluginRows(tmpHome, profileName) {
|
|
131
|
+
const profileDir = join(tmpHome, 'profiles', profileName)
|
|
132
|
+
const rows = []
|
|
133
|
+
const collectIds = (text) => {
|
|
134
|
+
for (const match of text.matchAll(/^[ \t]*-[ \t]+id:[ \t]*"?'?([^\s"']+)/gm)) {
|
|
135
|
+
if (!rows.includes(match[1])) rows.push(match[1])
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const readText = (file) => {
|
|
139
|
+
try {
|
|
140
|
+
return readFileSync(file, 'utf8')
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Source 1+2: the profile's own composition files (root list is normally
|
|
146
|
+
// the shipped empty `[]`, but a non-empty one is scanned all the same).
|
|
147
|
+
for (const name of ['cordis.patch.yml', 'cordis.yml']) {
|
|
148
|
+
const text = readText(join(profileDir, name))
|
|
149
|
+
if (text !== undefined) collectIds(text)
|
|
150
|
+
}
|
|
151
|
+
// Source 3: every NON-host bundle in dsh.profile.bundles — host template
|
|
152
|
+
// bundles (`@deepseek-ai/*`) define the sterile baseline and stay.
|
|
153
|
+
let bundles
|
|
154
|
+
try {
|
|
155
|
+
bundles = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))?.dsh?.profile?.bundles
|
|
156
|
+
} catch { /* no package.json → no bundle rows to collect */ }
|
|
157
|
+
for (const bundle of Array.isArray(bundles) ? bundles : []) {
|
|
158
|
+
if (typeof bundle !== 'string' || bundle.startsWith('@deepseek-ai/')) continue
|
|
159
|
+
const bundleDir = join(profileDir, 'node_modules', ...bundle.split('/'))
|
|
160
|
+
for (const name of ['cordis.patch.yml', 'cordis.yml']) {
|
|
161
|
+
const text = readText(join(bundleDir, name))
|
|
162
|
+
if (text !== undefined) collectIds(text)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return rows
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Copy the managed credential document into the temporary home (copy, not
|
|
170
|
+
* junction: single file, best-effort owner-only 0o600 — a no-op beyond the
|
|
171
|
+
* read-only bit on Windows). `dsh-credentials-local` resolves it per request.
|
|
172
|
+
*/
|
|
173
|
+
function stageCredentials(realHome, dshHome) {
|
|
174
|
+
const realCredentials = join(realHome, '.credentials.yaml')
|
|
175
|
+
if (!existsSync(realCredentials)) return
|
|
176
|
+
const credentialsCopy = join(dshHome, '.credentials.yaml')
|
|
177
|
+
copyFileSync(realCredentials, credentialsCopy)
|
|
178
|
+
try {
|
|
179
|
+
chmodSync(credentialsCopy, 0o600)
|
|
180
|
+
} catch { /* permission tightening is best-effort */ }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Stage a complete isolated home: create `dshHome`, stage the profile store,
|
|
185
|
+
* copy credentials in. One call replaces the three steps both callers used
|
|
186
|
+
* to repeat inline.
|
|
187
|
+
* @returns {string[]} every created junction path (informational —
|
|
188
|
+
* `teardownSandbox` re-discovers them by walking, so callers need not
|
|
189
|
+
* track the list themselves).
|
|
190
|
+
*/
|
|
191
|
+
export function stageSandboxHome(realHome, dshHome, profile) {
|
|
192
|
+
mkdirSync(dshHome, { recursive: true })
|
|
193
|
+
const junctions = stageProfileStore(realHome, dshHome, profile)
|
|
194
|
+
stageCredentials(realHome, dshHome)
|
|
195
|
+
return junctions
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Remove a run directory without ever descending through a junction into
|
|
200
|
+
* the real profile store: first WALK the tree collecting every symlink
|
|
201
|
+
* (covers staged profile junctions and anything a case's `prepare` created),
|
|
202
|
+
* unlink them all, then `rmSync` the remainder. Robust to errors mid-run:
|
|
203
|
+
* a walk over a not-yet-created directory is a no-op. Skipped entirely when
|
|
204
|
+
* `keep` is true (caller passes its own KEEP_TMP policy flag).
|
|
205
|
+
*/
|
|
206
|
+
export function teardownSandbox(runDir, { keep = false } = {}) {
|
|
207
|
+
if (keep) return
|
|
208
|
+
const junctions = []
|
|
209
|
+
const walk = (dir) => {
|
|
210
|
+
let entries
|
|
211
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const full = join(dir, entry.name)
|
|
214
|
+
if (entry.isSymbolicLink()) junctions.push(full)
|
|
215
|
+
else if (entry.isDirectory()) walk(full)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
walk(runDir)
|
|
219
|
+
for (const junction of junctions) {
|
|
220
|
+
try { unlinkSync(junction) } catch { /* junction absent — nothing to drop */ }
|
|
221
|
+
}
|
|
222
|
+
rmSync(runDir, { recursive: true, force: true })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Spawn one headless dsh CLI run with unified stream capture and timeout.
|
|
227
|
+
* A spawn failure (missing binary, EPERM) resolves as `exitCode: 127` with
|
|
228
|
+
* the failure text appended to `stderr` — callers decide whether an exit
|
|
229
|
+
* code is fatal, so the spawn layer never rejects.
|
|
230
|
+
*
|
|
231
|
+
* @param {object} options
|
|
232
|
+
* @param {string} options.cli - the compiled CLI entry (bin.js).
|
|
233
|
+
* @param {string[]} options.cliArgs - CLI arguments (profile/patch/task...).
|
|
234
|
+
* @param {string} options.cwd - working directory for the child.
|
|
235
|
+
* @param {object} options.env - FULL child environment (caller assembles).
|
|
236
|
+
* @param {number} options.timeoutMs - SIGTERM deadline.
|
|
237
|
+
* @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean }>}
|
|
238
|
+
*/
|
|
239
|
+
export async function spawnHeadlessDsh(options) {
|
|
240
|
+
const child = spawn(process.execPath, [options.cli, ...options.cliArgs], {
|
|
241
|
+
cwd: options.cwd,
|
|
242
|
+
env: options.env,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
let stdout = ''
|
|
246
|
+
let stderr = ''
|
|
247
|
+
child.stdout.on('data', chunk => { stdout += chunk })
|
|
248
|
+
child.stderr.on('data', chunk => { stderr += chunk })
|
|
249
|
+
|
|
250
|
+
let timedOut = false
|
|
251
|
+
const timer = setTimeout(() => {
|
|
252
|
+
timedOut = true
|
|
253
|
+
child.kill('SIGTERM')
|
|
254
|
+
}, options.timeoutMs)
|
|
255
|
+
|
|
256
|
+
const exitCode = await new Promise(resolveExit => {
|
|
257
|
+
child.on('error', error => { stderr += `\ndsh sandbox: failed to spawn dsh CLI: ${error.message}\n`; resolveExit(127) })
|
|
258
|
+
child.on('exit', code => resolveExit(code ?? 1))
|
|
259
|
+
})
|
|
260
|
+
clearTimeout(timer)
|
|
261
|
+
return { stdout, stderr, exitCode, timedOut }
|
|
262
|
+
}
|
package/src/tool-validation.mjs
CHANGED
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Post-run tool boundary validation for blind review.
|
|
3
|
-
*
|
|
4
|
-
* After a review run completes, this module inspects the session trace's
|
|
5
|
-
* `request/header` events to verify that no unexpected tools were mounted
|
|
6
|
-
* in the reviewer's session. This is the detection half of the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* The module is intentionally pure: it takes an already-parsed trace and
|
|
13
|
-
* returns a plain result object. File I/O (reading the session log,
|
|
14
|
-
* writing evidence) stays in the calling adapter.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Collect every distinct tool name mounted across all `request/header`
|
|
19
|
-
* events in a trace.
|
|
20
|
-
* @param {import('./trace.mjs').EvalTrace} trace
|
|
21
|
-
* @returns {string[]} sorted tool names.
|
|
22
|
-
*/
|
|
23
|
-
function collectMountedToolNames(trace) {
|
|
24
|
-
const names = new Set()
|
|
25
|
-
for (const header of trace.requestHeaders ?? []) {
|
|
26
|
-
for (const name of header.toolNames ?? []) {
|
|
27
|
-
names.add(name)
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return [...names].sort()
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Check a review trace for tool leakage.
|
|
35
|
-
*
|
|
36
|
-
* Every tool name appearing in any `request/header` event is compared
|
|
37
|
-
* against the allowed set. An empty allowed set (the review default)
|
|
38
|
-
* means the reviewer must see no tools at all.
|
|
39
|
-
*
|
|
40
|
-
* @param {import('./trace.mjs').EvalTrace | undefined} trace - the parsed trace; `undefined` skips validation.
|
|
41
|
-
* @param {{ allowedTools?: Set<string> }} [options]
|
|
42
|
-
* @returns {{ ok: boolean, unexpected: string[], actual: string[], allowed: string[] }}
|
|
43
|
-
*/
|
|
44
|
-
export function validateToolBoundary(trace, options = {}) {
|
|
45
|
-
const allowed = options.allowedTools ?? new Set()
|
|
46
|
-
const allowedNames = [...allowed].sort()
|
|
47
|
-
if (trace === undefined || trace === null) {
|
|
48
|
-
return { ok: true, unexpected: [], actual: [], allowed: allowedNames }
|
|
49
|
-
}
|
|
50
|
-
const actual = collectMountedToolNames(trace)
|
|
51
|
-
const unexpected = actual.filter(name => !allowed.has(name))
|
|
52
|
-
return {
|
|
53
|
-
ok: unexpected.length === 0,
|
|
54
|
-
unexpected,
|
|
55
|
-
actual,
|
|
56
|
-
allowed: allowedNames,
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Render a diagnostic evidence document for a tool boundary failure.
|
|
62
|
-
* Suitable for writing to `.runs/<id>/tool-boundary-evidence.json`.
|
|
63
|
-
*
|
|
64
|
-
* @param {{ ok: boolean, unexpected: string[], actual: string[], allowed: string[] }} validation
|
|
65
|
-
* @param {{ runDir: string, profile: string }} context
|
|
66
|
-
* @returns {string}
|
|
67
|
-
*/
|
|
68
|
-
export function renderToolBoundaryEvidence(validation, context) {
|
|
69
|
-
return JSON.stringify({
|
|
70
|
-
status: 'tool-boundary-violation',
|
|
71
|
-
runDir: context.runDir,
|
|
72
|
-
profile: context.profile,
|
|
73
|
-
unexpectedTools: validation.unexpected,
|
|
74
|
-
actualMounted: validation.actual,
|
|
75
|
-
allowedTools: validation.allowed,
|
|
76
|
-
}, null, 2) + '\n'
|
|
77
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Post-run tool boundary validation for blind review.
|
|
3
|
+
*
|
|
4
|
+
* After a review run completes, this module inspects the session trace's
|
|
5
|
+
* `request/header` events to verify that no unexpected tools were mounted
|
|
6
|
+
* in the reviewer's session. This is the detection half of the
|
|
7
|
+
* blank-environment strategy: the overlay disables every staged out-of-tree
|
|
8
|
+
* plugin row plus every host tool row, and this check makes any residual
|
|
9
|
+
* drift (a bundle leaking tools through a patch, a host regression, a
|
|
10
|
+
* misconfigured profile) an explicit adapter failure.
|
|
11
|
+
*
|
|
12
|
+
* The module is intentionally pure: it takes an already-parsed trace and
|
|
13
|
+
* returns a plain result object. File I/O (reading the session log,
|
|
14
|
+
* writing evidence) stays in the calling adapter.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Collect every distinct tool name mounted across all `request/header`
|
|
19
|
+
* events in a trace.
|
|
20
|
+
* @param {import('./trace.mjs').EvalTrace} trace
|
|
21
|
+
* @returns {string[]} sorted tool names.
|
|
22
|
+
*/
|
|
23
|
+
function collectMountedToolNames(trace) {
|
|
24
|
+
const names = new Set()
|
|
25
|
+
for (const header of trace.requestHeaders ?? []) {
|
|
26
|
+
for (const name of header.toolNames ?? []) {
|
|
27
|
+
names.add(name)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [...names].sort()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check a review trace for tool leakage.
|
|
35
|
+
*
|
|
36
|
+
* Every tool name appearing in any `request/header` event is compared
|
|
37
|
+
* against the allowed set. An empty allowed set (the review default)
|
|
38
|
+
* means the reviewer must see no tools at all.
|
|
39
|
+
*
|
|
40
|
+
* @param {import('./trace.mjs').EvalTrace | undefined} trace - the parsed trace; `undefined` skips validation.
|
|
41
|
+
* @param {{ allowedTools?: Set<string> }} [options]
|
|
42
|
+
* @returns {{ ok: boolean, unexpected: string[], actual: string[], allowed: string[] }}
|
|
43
|
+
*/
|
|
44
|
+
export function validateToolBoundary(trace, options = {}) {
|
|
45
|
+
const allowed = options.allowedTools ?? new Set()
|
|
46
|
+
const allowedNames = [...allowed].sort()
|
|
47
|
+
if (trace === undefined || trace === null) {
|
|
48
|
+
return { ok: true, unexpected: [], actual: [], allowed: allowedNames }
|
|
49
|
+
}
|
|
50
|
+
const actual = collectMountedToolNames(trace)
|
|
51
|
+
const unexpected = actual.filter(name => !allowed.has(name))
|
|
52
|
+
return {
|
|
53
|
+
ok: unexpected.length === 0,
|
|
54
|
+
unexpected,
|
|
55
|
+
actual,
|
|
56
|
+
allowed: allowedNames,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Render a diagnostic evidence document for a tool boundary failure.
|
|
62
|
+
* Suitable for writing to `.runs/<id>/tool-boundary-evidence.json`.
|
|
63
|
+
*
|
|
64
|
+
* @param {{ ok: boolean, unexpected: string[], actual: string[], allowed: string[] }} validation
|
|
65
|
+
* @param {{ runDir: string, profile: string }} context
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function renderToolBoundaryEvidence(validation, context) {
|
|
69
|
+
return JSON.stringify({
|
|
70
|
+
status: 'tool-boundary-violation',
|
|
71
|
+
runDir: context.runDir,
|
|
72
|
+
profile: context.profile,
|
|
73
|
+
unexpectedTools: validation.unexpected,
|
|
74
|
+
actualMounted: validation.actual,
|
|
75
|
+
allowedTools: validation.allowed,
|
|
76
|
+
}, null, 2) + '\n'
|
|
77
|
+
}
|