@catheadowl/dsh-eval 0.2.0 → 0.3.0
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 +129 -0
- package/README.i18n.yaml +2 -2
- package/README.md +2 -5
- package/README.zh.md +2 -5
- package/bin/dsh-eval.mjs +344 -335
- package/bin/dsh-review.mjs +179 -154
- package/docs/README.md +3 -2
- package/docs/cross-turn.md +66 -0
- package/docs/experimental.md +4 -3
- package/docs/host-wiring.md +27 -2
- package/docs/known-issues.md +9 -1
- package/docs/matchers.md +34 -1
- package/docs/review.md +15 -10
- package/docs/runner-api.md +1 -2
- package/package.json +8 -2
- package/src/adapters/dsh/review.mjs +99 -52
- package/src/assertions.mjs +499 -389
- package/src/discovery.mjs +190 -166
- package/src/driver/multi-turn-driver.mjs +166 -0
- package/src/experiment/review.mjs +118 -118
- package/src/experimental.mjs +6 -1
- package/src/index.mjs +4 -0
- package/src/mock/mock-adapter.mjs +87 -73
- package/src/mock/script.mjs +49 -49
- package/src/overlay.mjs +13 -0
- package/src/report.mjs +5 -0
- package/src/review-report.mjs +45 -1
- package/src/runner.mjs +35 -30
- package/src/sandbox.mjs +62 -1
- package/src/tool-validation.mjs +85 -77
- package/src/trace.mjs +590 -218
package/src/runner.mjs
CHANGED
|
@@ -29,12 +29,12 @@
|
|
|
29
29
|
* module is the orchestration only.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, cpSync
|
|
32
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, cpSync } from 'node:fs'
|
|
33
33
|
import { tmpdir } from 'node:os'
|
|
34
34
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
35
35
|
import { fileURLToPath } from 'node:url'
|
|
36
|
-
import {
|
|
37
|
-
import { validateRowConfig, validateDisableRows } from './discovery.mjs'
|
|
36
|
+
import { collectSessionTrace, listSessionLogFiles } from './trace.mjs'
|
|
37
|
+
import { validateRowConfig, validateDisableRows, validateFollowups } from './discovery.mjs'
|
|
38
38
|
import { CLI_RELATIVE_PATH } from './cli.mjs'
|
|
39
39
|
import { buildOverlayYaml } from './overlay.mjs'
|
|
40
40
|
import { resolveRealDshHome, stageSandboxHome, teardownSandbox, spawnHeadlessDsh } from './sandbox.mjs'
|
|
@@ -48,28 +48,32 @@ const FRAMEWORK_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
|
48
48
|
* Case shape: `{ id, task, mode?: 'real' | 'mock', expect: Matcher[],
|
|
49
49
|
* script?: { steps: ChunkStep[] }, persona?: string, disableRows?: string[],
|
|
50
50
|
* rowConfig?: Record<string, Record<string, unknown>>,
|
|
51
|
+
* followups?: string[], settleTimeoutMs?: number,
|
|
51
52
|
* prepare?: (workspace: string) => void | Promise<void>,
|
|
52
53
|
* inspect?: (workspace: string, helpers: { trace }) => void | Promise<void>,
|
|
53
54
|
* timeoutMs?: number }`
|
|
54
55
|
*
|
|
56
|
+
* `followups` opts into cross-turn driving: the overlay swaps the one-shot
|
|
57
|
+
* `headless-runner` row for the eval multi-turn driver, which — before each
|
|
58
|
+
* followup — waits for background subagent children to settle (bounded by
|
|
59
|
+
* `settleTimeoutMs`), keeping the process alive so fire-and-forget children
|
|
60
|
+
* (turn-close defer fixers) can run to completion between turns.
|
|
61
|
+
*
|
|
55
62
|
* @param {object} evalCase - the case under test.
|
|
56
63
|
* @param {object} options
|
|
57
64
|
* @param {string} options.profile - the dsh profile booting the run (plugin installed there).
|
|
58
|
-
* @param {string}
|
|
59
|
-
*
|
|
60
|
-
* @deprecated options.dshRepoDir — pass the C6 chain result via cliPath
|
|
61
|
-
* instead; this legacy option is removed in the next minor release.
|
|
62
|
-
* @param {string} [options.cliPath] - explicit compiled CLI entry (C6 chain result;
|
|
63
|
-
* takes precedence over dshRepoDir).
|
|
65
|
+
* @param {string} options.cliPath - the compiled dsh CLI entry (a
|
|
66
|
+
* `resolveDshCliChain` result); required.
|
|
64
67
|
* @param {'real' | 'mock'} [options.mode] - force a mode over the case's own.
|
|
65
68
|
* @param {string} [options.artifactsDir] - copy stdout/stderr/trace/session logs here (created).
|
|
66
69
|
* @returns {Promise<EvalRunResult>}
|
|
67
70
|
*/
|
|
68
71
|
export async function runEvalCase(evalCase, options) {
|
|
69
72
|
const mode = options.mode ?? evalCase.mode ?? 'real'
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
if (options.cliPath === undefined) {
|
|
74
|
+
throw new TypeError('runEvalCase needs options.cliPath (a resolveDshCliChain result)')
|
|
75
|
+
}
|
|
76
|
+
const binPath = resolve(options.cliPath)
|
|
73
77
|
const timeoutMs = evalCase.timeoutMs ?? 180_000
|
|
74
78
|
|
|
75
79
|
const runDir = mkdtempSync(join(tmpdir(), 'dsh-eval-'))
|
|
@@ -106,6 +110,15 @@ export async function runEvalCase(evalCase, options) {
|
|
|
106
110
|
writeFileSync(scriptPath, JSON.stringify(evalCase.script))
|
|
107
111
|
env.DSH_EVAL_MOCK_SCRIPT = scriptPath
|
|
108
112
|
}
|
|
113
|
+
if (evalCase.followups !== undefined) {
|
|
114
|
+
const planPath = join(runDir, 'driver-plan.json')
|
|
115
|
+
writeFileSync(planPath, JSON.stringify({
|
|
116
|
+
task: evalCase.task,
|
|
117
|
+
followups: evalCase.followups,
|
|
118
|
+
...(evalCase.settleTimeoutMs === undefined ? {} : { settleTimeoutMs: evalCase.settleTimeoutMs }),
|
|
119
|
+
}))
|
|
120
|
+
env.DSH_EVAL_DRIVER_PLAN = planPath
|
|
121
|
+
}
|
|
109
122
|
|
|
110
123
|
if (evalCase.disableRows !== undefined) {
|
|
111
124
|
validateDisableRows(evalCase.disableRows, `case '${evalCase.id}'`)
|
|
@@ -113,6 +126,9 @@ export async function runEvalCase(evalCase, options) {
|
|
|
113
126
|
if (evalCase.rowConfig !== undefined) {
|
|
114
127
|
validateRowConfig(evalCase.rowConfig, `case '${evalCase.id}'`)
|
|
115
128
|
}
|
|
129
|
+
if (evalCase.followups !== undefined) {
|
|
130
|
+
validateFollowups(evalCase.followups, `case '${evalCase.id}'`)
|
|
131
|
+
}
|
|
116
132
|
const overlayPath = join(runDir, 'eval-overlay.yml')
|
|
117
133
|
writeFileSync(overlayPath, buildOverlayYaml({
|
|
118
134
|
sessionsRoot,
|
|
@@ -120,6 +136,7 @@ export async function runEvalCase(evalCase, options) {
|
|
|
120
136
|
disableRows: evalCase.disableRows,
|
|
121
137
|
rowConfig: evalCase.rowConfig,
|
|
122
138
|
mock: mode === 'mock',
|
|
139
|
+
...(evalCase.followups === undefined ? {} : { followups: evalCase.followups }),
|
|
123
140
|
}))
|
|
124
141
|
|
|
125
142
|
const { stdout, stderr, exitCode, timedOut } = await spawnHeadlessDsh({
|
|
@@ -130,7 +147,7 @@ export async function runEvalCase(evalCase, options) {
|
|
|
130
147
|
timeoutMs,
|
|
131
148
|
})
|
|
132
149
|
|
|
133
|
-
const trace =
|
|
150
|
+
const { trace, gap: traceGap } = collectSessionTrace(sessionsRoot)
|
|
134
151
|
const sessionLogs = collectSessionLogTexts(sessionsRoot)
|
|
135
152
|
|
|
136
153
|
// Workspace assertions live HERE, before the run dir cleanup: a case's
|
|
@@ -164,7 +181,7 @@ export async function runEvalCase(evalCase, options) {
|
|
|
164
181
|
|
|
165
182
|
return {
|
|
166
183
|
caseId: evalCase.id, mode, task: evalCase.task, exitCode, timedOut,
|
|
167
|
-
stdout, stderr, trace, sessionLogs, inspectError, runDir,
|
|
184
|
+
stdout, stderr, trace, traceGap, sessionLogs, inspectError, runDir,
|
|
168
185
|
}
|
|
169
186
|
} finally {
|
|
170
187
|
teardownSandbox(runDir, { keep: process.env.DSH_EVAL_KEEP_TMP === '1' })
|
|
@@ -173,22 +190,7 @@ export async function runEvalCase(evalCase, options) {
|
|
|
173
190
|
|
|
174
191
|
/** Read every session artifact under the root as text (best-effort, pre-cleanup). */
|
|
175
192
|
function collectSessionLogTexts(sessionsRoot) {
|
|
176
|
-
|
|
177
|
-
const walk = (dir) => {
|
|
178
|
-
let entries
|
|
179
|
-
try {
|
|
180
|
-
entries = readdirSync(dir, { withFileTypes: true })
|
|
181
|
-
} catch {
|
|
182
|
-
return
|
|
183
|
-
}
|
|
184
|
-
for (const entry of entries) {
|
|
185
|
-
const path = join(dir, entry.name)
|
|
186
|
-
if (entry.isDirectory()) walk(path)
|
|
187
|
-
else if (entry.name === 'session.jsonl') texts.push(readFileSync(path, 'utf8'))
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
walk(sessionsRoot)
|
|
191
|
-
return texts
|
|
193
|
+
return listSessionLogFiles(sessionsRoot).map(path => readFileSync(path, 'utf8'))
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
/**
|
|
@@ -201,6 +203,9 @@ function collectSessionLogTexts(sessionsRoot) {
|
|
|
201
203
|
* @property {string} stdout - printed final assistant text (plus any startup chatter).
|
|
202
204
|
* @property {string} stderr
|
|
203
205
|
* @property {import('./trace.mjs').EvalTrace | undefined} trace
|
|
206
|
+
* @property {string | undefined} traceGap - why no trace was built (the host
|
|
207
|
+
* session seam diagnosis from `collectSessionTrace`); `undefined` whenever
|
|
208
|
+
* `trace` is defined. The CLI prints it as the failure text.
|
|
204
209
|
* @property {string[]} sessionLogs - raw session artifact texts, pre-cleanup.
|
|
205
210
|
* @property {string | undefined} inspectError - the case's `inspect` failure text, when it threw.
|
|
206
211
|
* @property {string} runDir - removed unless DSH_EVAL_KEEP_TMP=1.
|
package/src/sandbox.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { spawn } from 'node:child_process'
|
|
19
19
|
import {
|
|
20
|
-
chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync, unlinkSync,
|
|
20
|
+
chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync,
|
|
21
21
|
} from 'node:fs'
|
|
22
22
|
import { homedir } from 'node:os'
|
|
23
23
|
import { join } from 'node:path'
|
|
@@ -104,6 +104,67 @@ function copyProfileEntry(from, to, junctions) {
|
|
|
104
104
|
copyFileSync(from, to)
|
|
105
105
|
}
|
|
106
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
|
+
|
|
107
168
|
/**
|
|
108
169
|
* Copy the managed credential document into the temporary home (copy, not
|
|
109
170
|
* junction: single file, best-effort owner-only 0o600 — a no-op beyond the
|
package/src/tool-validation.mjs
CHANGED
|
@@ -1,77 +1,85 @@
|
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
actual,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
* The result is explicit about whether the check RAN: `status` is
|
|
41
|
+
* `'checked'` only when a trace was available, and `'not-executed'` when
|
|
42
|
+
* there was none. `ok` is true only for a checked boundary with no
|
|
43
|
+
* unexpected tool — a missing trace is NOT a pass, so callers must branch
|
|
44
|
+
* on `status` and account for the skip (see `createDshHeadlessReviewExecutor`
|
|
45
|
+
* and docs/review.md) instead of reading `ok` alone.
|
|
46
|
+
*
|
|
47
|
+
* @param {import('./trace.mjs').EvalTrace | undefined} trace - the parsed trace; `undefined` means the check could not run.
|
|
48
|
+
* @param {{ allowedTools?: Set<string> }} [options]
|
|
49
|
+
* @returns {{ status: 'checked' | 'not-executed', ok: boolean, unexpected: string[], actual: string[], allowed: string[] }}
|
|
50
|
+
*/
|
|
51
|
+
export function validateToolBoundary(trace, options = {}) {
|
|
52
|
+
const allowed = options.allowedTools ?? new Set()
|
|
53
|
+
const allowedNames = [...allowed].sort()
|
|
54
|
+
if (trace === undefined || trace === null) {
|
|
55
|
+
return { status: 'not-executed', ok: false, unexpected: [], actual: [], allowed: allowedNames }
|
|
56
|
+
}
|
|
57
|
+
const actual = collectMountedToolNames(trace)
|
|
58
|
+
const unexpected = actual.filter(name => !allowed.has(name))
|
|
59
|
+
return {
|
|
60
|
+
status: 'checked',
|
|
61
|
+
ok: unexpected.length === 0,
|
|
62
|
+
unexpected,
|
|
63
|
+
actual,
|
|
64
|
+
allowed: allowedNames,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render a diagnostic evidence document for a tool boundary failure.
|
|
70
|
+
* Suitable for writing to `.runs/<id>/tool-boundary-evidence.json`.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ status: 'checked' | 'not-executed', ok: boolean, unexpected: string[], actual: string[], allowed: string[] }} validation
|
|
73
|
+
* @param {{ runDir: string, profile: string }} context
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function renderToolBoundaryEvidence(validation, context) {
|
|
77
|
+
return JSON.stringify({
|
|
78
|
+
status: 'tool-boundary-violation',
|
|
79
|
+
runDir: context.runDir,
|
|
80
|
+
profile: context.profile,
|
|
81
|
+
unexpectedTools: validation.unexpected,
|
|
82
|
+
actualMounted: validation.actual,
|
|
83
|
+
allowedTools: validation.allowed,
|
|
84
|
+
}, null, 2) + '\n'
|
|
85
|
+
}
|