amicus 4.9.7 → 4.9.8
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +94 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +3 -3
- package/docs/architecture-map.md +19 -4
- package/docs/council.md +140 -3
- package/docs/usage.md +8 -4
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +3 -1
- package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
- package/src/cli-council-run-tools.js +168 -0
- package/src/cli-handlers-council-run.js +6 -6
- package/src/cli.js +23 -1
- package/src/council/briefings-chair.js +1 -1
- package/src/council/briefings-task.js +11 -5
- package/src/council/briefings.js +25 -7
- package/src/council/report-lost-rows.js +89 -0
- package/src/council/report-md.js +3 -1
- package/src/council/report.js +3 -2
- package/src/council/run-degrade.js +22 -1
- package/src/council/run-finish.js +23 -1
- package/src/council/run-launch.js +33 -4
- package/src/council/run-retry-launch.js +9 -4
- package/src/council/run-retry.js +3 -0
- package/src/council/run-seat-tools-verify.js +296 -0
- package/src/council/run-seat-tools.js +274 -0
- package/src/council/run-server.js +41 -6
- package/src/council/run-stage1-launch.js +8 -3
- package/src/council/run.js +21 -21
- package/src/council/seat-tools.js +299 -0
- package/src/council/verdict-seats-reviewed.js +76 -6
- package/src/headless.js +136 -6
- package/src/mcp-council-pack-map.js +24 -0
- package/src/mcp-council-run.js +17 -15
- package/src/mcp-server.js +2 -2
- package/src/mcp-tools.js +15 -4
- package/src/opencode-client.js +26 -0
- package/src/pack/pack-validate.js +3 -1
- package/src/prompt-builder.js +2 -2
- package/src/sidecar/fanout.js +7 -1
- package/src/sidecar/heartbeat.js +46 -0
- package/src/sidecar/session-utils.js +7 -34
- package/src/utils/agent-mapping.js +1 -1
- package/src/utils/degrade.js +8 -0
|
@@ -15,13 +15,18 @@
|
|
|
15
15
|
const briefings = require('./briefings');
|
|
16
16
|
const { bindPaddedWave } = require('./stage1-bind');
|
|
17
17
|
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* The briefing a retry unit re-issues — same intent-aware dispatchers Stage 1
|
|
20
|
+
* used (v4.9 W6), now also carrying the run's seat-tools line and any --agent
|
|
21
|
+
* override (spec 2026-09-11 §4, PR 2): a retry is a Stage-1 leg like any
|
|
22
|
+
* other, so it gets the same `tools`/`agent` a first attempt would have.
|
|
23
|
+
*/
|
|
19
24
|
function briefingFor(o, unit) {
|
|
20
|
-
if (unit.unit === 'critic') { return briefings.stage1CriticBriefing(o.intent, { briefing: o.briefing, date: o.date }); }
|
|
25
|
+
if (unit.unit === 'critic') { return briefings.stage1CriticBriefing(o.intent, { briefing: o.briefing, date: o.date, tools: o.seatTools, agent: o.agent }); }
|
|
21
26
|
if (unit.unit === 'lens') {
|
|
22
|
-
return briefings.stage1LensBriefing(o.intent, { lens: o.lenses[unit.lensIndex - 1], briefing: o.briefing, date: o.date });
|
|
27
|
+
return briefings.stage1LensBriefing(o.intent, { lens: o.lenses[unit.lensIndex - 1], briefing: o.briefing, date: o.date, tools: o.seatTools, agent: o.agent });
|
|
23
28
|
}
|
|
24
|
-
return briefings.stage1SeatBriefing(o.intent, { briefing: o.briefing, date: o.date });
|
|
29
|
+
return briefings.stage1SeatBriefing(o.intent, { briefing: o.briefing, date: o.date, tools: o.seatTools, agent: o.agent });
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/**
|
package/src/council/run-retry.js
CHANGED
|
@@ -90,6 +90,9 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [],
|
|
|
90
90
|
councilRunId: o.runId, councilName: o.councilName,
|
|
91
91
|
tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
|
|
92
92
|
fallback: o.fallback, catalog: o.catalog,
|
|
93
|
+
// Spec 2026-09-11 §4: a retry relaunches as role 'seat', scoped like Stage 1's own launch.
|
|
94
|
+
// Named mutant RETRYROLEDROP: dropping this line reddens run-retry.test.js's "seat tools" describe (review r1).
|
|
95
|
+
role: 'seat', ...(o.seatToolsLocal ? { directory: o.project } : {}),
|
|
93
96
|
waveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, prompt: briefingFor(o, unit),
|
|
94
97
|
noOutputBackstopMs: escalatedBackstopMs };
|
|
95
98
|
// Dispatch by UNIT TYPE, not model count (spec §4: bench is always a wave —
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module council/run-seat-tools-verify
|
|
3
|
+
* The engine-rendering tripwire's pure pieces, plus `verificationDirectories`'
|
|
4
|
+
* one side effect — the best-effort `_scratch` mkdir it documents — split out
|
|
5
|
+
* of run-seat-tools.js at council #247 round 3 under the 300-line size gate:
|
|
6
|
+
* which directories to check (`verificationDirectories`, ruling P2-R39), how
|
|
7
|
+
* to ask the engine what it registered for one of them (`listEngineAgents`),
|
|
8
|
+
* whether that answer still matches the allowlist an agent was given
|
|
9
|
+
* (`verifyAgentRendering`, ruling P2-R40/P2-R42's external_directory
|
|
10
|
+
* exemption), whether the same agent's non-permission surface was left alone
|
|
11
|
+
* too (`verifyAgentFields`, ruling P2-R53, round 6), and whether a run
|
|
12
|
+
* directory's placement holds up against a symlinked ancestor
|
|
13
|
+
* (`resolvePhysicalPath`/`isPhysicallyInside`, ruling P2-R54, round 6).
|
|
14
|
+
* `listEngineAgents`, `verifyAgentRendering` and `verifyAgentFields` are
|
|
15
|
+
* re-exported from run-seat-tools.js so every existing importer keeps
|
|
16
|
+
* working unchanged.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { SEAT_READ_DENY_PATTERNS } = require('./seat-tools');
|
|
25
|
+
const { isPathInside } = require('../project-root-allowlist');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The unique set of directories the post-registration tripwire checks the
|
|
29
|
+
* engine's OWN rendering against: the run directory, the project tree too
|
|
30
|
+
* when a local tool is opted in (a seat's own working directory), and —
|
|
31
|
+
* ruling P2-R39 (A1, round 3) — `_scratch`, where every SUPPORT leg (judges,
|
|
32
|
+
* debate, chair) actually runs (`project: <runDir>/_scratch`, run-debate-
|
|
33
|
+
* revote.js / run-stage2.js). Without it, an attacker's opencode.json sitting
|
|
34
|
+
* ONLY under `_scratch` renders clean at `o.runDir` and would still reach a
|
|
35
|
+
* support leg unverified. It does not exist yet at verification time —
|
|
36
|
+
* run-stage2.js creates it again once Stage 2 actually starts — so it is
|
|
37
|
+
* created here too, best-effort and with the same `0o700` mode, purely so
|
|
38
|
+
* the engine has a real directory to answer for.
|
|
39
|
+
* @param {{runDir: string, project?: string, seatToolsLocal?: boolean}} o
|
|
40
|
+
* @returns {string[]}
|
|
41
|
+
*/
|
|
42
|
+
function verificationDirectories(o) {
|
|
43
|
+
const scratchDir = path.join(o.runDir, '_scratch');
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(scratchDir, { recursive: true, mode: 0o700 });
|
|
46
|
+
} catch {
|
|
47
|
+
// Best-effort: a directory the engine cannot be asked about either
|
|
48
|
+
// degrades or refuses exactly like any other unreachable directory below.
|
|
49
|
+
}
|
|
50
|
+
return [...new Set([o.runDir, scratchDir, ...(o.seatToolsLocal ? [o.project] : [])])];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The council agents the run's engine actually registered, as rendered rule
|
|
55
|
+
* lists. Mirrors run-server.js :: listEngineToolIds (same `shared.serverClient`
|
|
56
|
+
* access, null on anything wrong, `logger.debug` on failure). Ruling P2-R33:
|
|
57
|
+
* what validateSeatToolsAgainstEngine reads back to catch a tree-supplied
|
|
58
|
+
* opencode.json/.opencode/agent file that widened a council agent (measured
|
|
59
|
+
* 2026-09-12, probe-council-agents.js's PROBE_TREE_JSON).
|
|
60
|
+
* @param {{serverClient: object}|null} shared
|
|
61
|
+
* @param {string} directory
|
|
62
|
+
* @returns {Promise<Array<{name: string, mode: string, permission: Array}>|null>}
|
|
63
|
+
*/
|
|
64
|
+
async function listEngineAgents(shared, directory) {
|
|
65
|
+
const client = shared && shared.serverClient;
|
|
66
|
+
if (!client || !client.app || typeof client.app.agents !== 'function') { return null; }
|
|
67
|
+
try {
|
|
68
|
+
const res = await client.app.agents({ query: { directory } });
|
|
69
|
+
return (res && Array.isArray(res.data)) ? res.data.slice() : null;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const { logger } = require('../utils/logger');
|
|
72
|
+
logger.debug('Engine agent list unavailable', { error: err.message });
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Ruling P2-R40 (A2/B2, round 3): is this `external_directory` pattern the
|
|
79
|
+
* engine's OWN tool-output cache rule, not a tree-supplied allow riding after
|
|
80
|
+
* the wildcard deny? The pinned engine (opencode 1.18.15) appends exactly one
|
|
81
|
+
* such rule after the agent block — under its own data directory's
|
|
82
|
+
* `tool-output/` subdirectory; denying it would break tool output
|
|
83
|
+
* round-trips, so it (and only it, by directory) is exempted from check (b)
|
|
84
|
+
* below. ANY other specific `external_directory` allow is treated like any
|
|
85
|
+
* other widened rule.
|
|
86
|
+
*
|
|
87
|
+
* Ruling P2-R45 (round 4, B1/C1): narrowed from "anywhere under the data
|
|
88
|
+
* root" to "under the data root's `tool-output/` subdirectory only" —
|
|
89
|
+
* measured T6: a tree's `external_directory: {'<dataroot>/secrets/*':
|
|
90
|
+
* 'allow'}` on council-seat is replaced WHOLESALE by the server's plain
|
|
91
|
+
* string `external_directory: 'deny'` (no per-key merge happens for this key
|
|
92
|
+
* on the pinned engine), so the wider exemption was already unreachable in
|
|
93
|
+
* practice — this narrowing is defense-in-depth against the exemption ever
|
|
94
|
+
* covering more than the ONE rule it exists for (the data root also holds
|
|
95
|
+
* the engine's `auth.json`). Named mutant EXEMPTBROAD: reverting to the bare
|
|
96
|
+
* data-root prefix (dropping the `tool-output` join below) lets a tree's
|
|
97
|
+
* `<dataroot>/secrets/*` allow read as this exemption again.
|
|
98
|
+
*
|
|
99
|
+
* Ruling P2-R42 (round-3 nits): the data directory is resolved the same
|
|
100
|
+
* XDG-first way as `src/utils/auth-json.js :: authJsonCandidates` and
|
|
101
|
+
* `src/utils/engine-log.js :: engineLogDirCandidates` (same engine, same
|
|
102
|
+
* data root) — `$XDG_DATA_HOME/opencode` when `XDG_DATA_HOME` is set, else
|
|
103
|
+
* `~/.local/share/opencode`. The original version of this check hard-coded
|
|
104
|
+
* the home form only, so a machine (or sandbox — see
|
|
105
|
+
* scripts/run-integration-keyless.js, which sets `XDG_DATA_HOME` itself)
|
|
106
|
+
* with `XDG_DATA_HOME` actually set would render its tool-output allow
|
|
107
|
+
* somewhere this check did not recognize, misreading a legitimate engine
|
|
108
|
+
* default as a widened agent. Compared after normalizing both sides to
|
|
109
|
+
* forward slashes, case-insensitively on win32; the backslash rewrite itself
|
|
110
|
+
* is win32-only — `\` is a legal filename character on POSIX, so rewriting
|
|
111
|
+
* it there could fold two DIFFERENT paths into comparing equal.
|
|
112
|
+
* @param {string} pattern
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
function isEngineToolOutputPattern(pattern) {
|
|
116
|
+
const forSlash = (p) => (process.platform === 'win32' ? String(p).replace(/\\/g, '/') : String(p));
|
|
117
|
+
const forCompare = (p) => (process.platform === 'win32' ? forSlash(p).toLowerCase() : forSlash(p));
|
|
118
|
+
const roots = [];
|
|
119
|
+
if (process.env.XDG_DATA_HOME) { roots.push(path.join(process.env.XDG_DATA_HOME, 'opencode')); }
|
|
120
|
+
roots.push(path.join(os.homedir(), '.local', 'share', 'opencode'));
|
|
121
|
+
const cmp = forCompare(pattern);
|
|
122
|
+
return roots.some((root) => cmp.startsWith(`${forCompare(path.join(root, 'tool-output'))}/`));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Pure tripwire (ruling P2-R33): does an ENGINE-RENDERED rule list for a
|
|
127
|
+
* council agent behave the way its allowlist says it should? A reviewed
|
|
128
|
+
* tree's own opencode.json (or .opencode/agent/<name>.md) merges INTO the
|
|
129
|
+
* server-registered agent by KEY ORDER (measured 2026-09-12 against opencode
|
|
130
|
+
* 1.18.15): server values win per key, but a TREE-ONLY key keeps the tree's
|
|
131
|
+
* position — after the tree's own `"*"`, it renders AFTER the server's
|
|
132
|
+
* `*=deny` and wins under findLast (`council-support: { tools: { "*": true,
|
|
133
|
+
* "task": true } }` renders `*=deny task=allow …` — task ALLOWED). A tree
|
|
134
|
+
* that re-lists a GRANTED key (e.g. `read`) can instead move ITS allow
|
|
135
|
+
* before `*=deny`, silently losing it. Neither shape is visible from what
|
|
136
|
+
* this run registered — only from what the engine says it rendered.
|
|
137
|
+
*
|
|
138
|
+
* Ruling P2-R44 (round 4, C4): order-verified, not merely existence-verified,
|
|
139
|
+
* for `read`'s three deny patterns. Measured 2026-09-12 (T1): a tree that
|
|
140
|
+
* re-lists council-seat's `permission.read` sub-keys (e.g. granting
|
|
141
|
+
* `*.env`/`*.env.*`/`*.envrc`) keeps the TREE's sub-key order in the merged
|
|
142
|
+
* rendering — the server's VALUES still win (the three patterns still say
|
|
143
|
+
* `deny`), but they render BEFORE `read[*]=allow` instead of after it, so
|
|
144
|
+
* under the engine's findLast evaluation `.env`/`.env.*`/`.envrc` are all
|
|
145
|
+
* ALLOWED even though every rule this tripwire used to check for
|
|
146
|
+
* (existence, never position) is present. Step 5 below closes that hole by
|
|
147
|
+
* checking WHERE each deny sits relative to the seat's own read allow, not
|
|
148
|
+
* merely whether it exists after the wildcard.
|
|
149
|
+
* @param {Array<{permission: string, pattern: string, action: string}>} rules
|
|
150
|
+
* @param {string[]} allowlist ids this agent should have allowed (`o.seatTools`
|
|
151
|
+
* for council-seat, `[]` for council-support)
|
|
152
|
+
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
153
|
+
*/
|
|
154
|
+
function verifyAgentRendering(rules, allowlist) {
|
|
155
|
+
// Ruling P2-R40/P2-R42/P2-R45 narrow this from "every non-'*' external_directory
|
|
156
|
+
// rule is exempt" to only the engine's OWN tool-output rule
|
|
157
|
+
// (isEngineToolOutputPattern) — any other specific pattern (a tree's
|
|
158
|
+
// `/tmp/*`, say) now falls through to the per-rule check below like any
|
|
159
|
+
// other widened rule.
|
|
160
|
+
const list = (Array.isArray(rules) ? rules : [])
|
|
161
|
+
.filter((r) => !(r.permission === 'external_directory' && r.pattern !== '*' && isEngineToolOutputPattern(r.pattern)));
|
|
162
|
+
let starIndex = -1;
|
|
163
|
+
list.forEach((r, i) => { if (r.permission === '*' && r.pattern === '*') { starIndex = i; } });
|
|
164
|
+
if (starIndex < 0 || list[starIndex].action !== 'deny') { return { ok: false, reason: 'no wildcard deny' }; }
|
|
165
|
+
// Named mutant TRIPWIREBLIND: dropping this loop leaves an extra allow
|
|
166
|
+
// OUTSIDE the allowlist (support attack: task=allow after *=deny) undetected.
|
|
167
|
+
// Named mutant GRANTDENYBLIND: dropping the deny branch below (treating
|
|
168
|
+
// every deny as harmless, as the pre-round-4 loop did) lets a granted tool
|
|
169
|
+
// be silently re-denied by a non-`.env` pattern after its own allow (e.g.
|
|
170
|
+
// `grep[*]=allow` then `grep[*]=deny`) — step 4 below still finds the
|
|
171
|
+
// earlier allow and never notices the later deny.
|
|
172
|
+
for (let i = starIndex + 1; i < list.length; i++) {
|
|
173
|
+
const r = list[i];
|
|
174
|
+
if (r.action === 'deny') {
|
|
175
|
+
if (allowlist.includes(r.permission) && !(r.permission === 'read' && SEAT_READ_DENY_PATTERNS.includes(r.pattern))) {
|
|
176
|
+
return { ok: false, reason: `${r.permission}[${r.pattern}]=deny narrows granted tool ${r.permission} after the wildcard deny` };
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (r.pattern !== '*' || !allowlist.includes(r.permission) || r.action !== 'allow') {
|
|
181
|
+
return { ok: false, reason: `${r.permission}[${r.pattern}]=${r.action} is allowed after the wildcard deny` };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const id of allowlist) {
|
|
185
|
+
const granted = list.slice(starIndex + 1).some((r) => r.permission === id && r.pattern === '*' && r.action === 'allow');
|
|
186
|
+
if (!granted) { return { ok: false, reason: `granted tool ${id} is not allowed after the wildcard deny` }; }
|
|
187
|
+
}
|
|
188
|
+
// Named mutant ENVORDERBLIND: dropping this block lets a tree reorder the
|
|
189
|
+
// seat's read denies BEFORE its read allow (measured 2026-09-12, T1) go
|
|
190
|
+
// undetected — every check above only asks whether a rule EXISTS after the
|
|
191
|
+
// wildcard, never in what order, so this function would still return
|
|
192
|
+
// {ok: true} while `.env`/`.env.*`/`.envrc` render ALLOWED on the engine.
|
|
193
|
+
if (allowlist.includes('read')) {
|
|
194
|
+
const after = list.slice(starIndex + 1);
|
|
195
|
+
let lastAllow = -1;
|
|
196
|
+
after.forEach((r, i) => { if (r.permission === 'read' && r.pattern === '*' && r.action === 'allow') { lastAllow = i; } });
|
|
197
|
+
for (const p of SEAT_READ_DENY_PATTERNS) {
|
|
198
|
+
let lastDeny = -1;
|
|
199
|
+
after.forEach((r, i) => { if (r.permission === 'read' && r.pattern === p) { lastDeny = i; } });
|
|
200
|
+
if (lastDeny < 0 || after[lastDeny].action !== 'deny' || lastDeny <= lastAllow) {
|
|
201
|
+
return { ok: false, reason: `read[${p}]=deny is missing or does not follow the seat's read allow` };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { ok: true };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Ruling P2-R53 (council #247 round 6, B1): does a rendered council agent's
|
|
210
|
+
* NON-permission surface still look like the one this run registered?
|
|
211
|
+
* `verifyAgentRendering` above only ever checked `permission` — measured
|
|
212
|
+
* 2026-09-13 (probe-r6.js) that a reviewed tree's opencode.json can ALSO set
|
|
213
|
+
* a council agent's system prompt, model and sampling (`prompt`, `model`,
|
|
214
|
+
* `temperature`, `topP`, `options`), its display metadata (`color`), its
|
|
215
|
+
* step budget (`steps`), whether it is hidden from the UI (`hidden`), a
|
|
216
|
+
* prompt variant (`variant`), and even its `mode` (`primary` vs `subagent`)
|
|
217
|
+
* or whether it is a `native` (built-in) agent — all silently, since the
|
|
218
|
+
* permission list stayed clean. `mode`/`native`/`model` are checked first
|
|
219
|
+
* (each already fatal on its own — an agent rendered as anything but the
|
|
220
|
+
* plain, non-native, model-less primary agent seat-tools.js registers is
|
|
221
|
+
* suspect regardless of what else is set); the remaining fields are checked
|
|
222
|
+
* in a fixed order so the reason always names the FIRST offender, not
|
|
223
|
+
* whichever happens to be enumerated last. Named mutant FIELDSBLIND:
|
|
224
|
+
* returning `{ ok: true }` unconditionally here restores the blind spot —
|
|
225
|
+
* every check below (and run-seat-tools.js's own call site) go dark at once.
|
|
226
|
+
* @param {object} agent a rendered council agent, as returned by listEngineAgents
|
|
227
|
+
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
228
|
+
*/
|
|
229
|
+
function verifyAgentFields(agent) {
|
|
230
|
+
if (agent.mode !== 'primary') { return { ok: false, reason: `mode is '${agent.mode}', not primary` }; }
|
|
231
|
+
if (agent.native === true) { return { ok: false, reason: 'native agent' }; }
|
|
232
|
+
if (agent.model !== undefined && agent.model !== null) { return { ok: false, reason: 'model is set' }; }
|
|
233
|
+
for (const field of ['prompt', 'temperature', 'topP', 'variant', 'steps', 'hidden', 'color']) {
|
|
234
|
+
const value = agent[field];
|
|
235
|
+
if (value !== undefined && value !== null) { return { ok: false, reason: `${field} is set` }; }
|
|
236
|
+
}
|
|
237
|
+
if (agent.options && typeof agent.options === 'object') {
|
|
238
|
+
const key = Object.keys(agent.options)[0];
|
|
239
|
+
if (key !== undefined) { return { ok: false, reason: `options has ${key}` }; }
|
|
240
|
+
}
|
|
241
|
+
return { ok: true };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Ruling P2-R54 (council #247 round 6, C1): `isPathInside`
|
|
246
|
+
* (project-root-allowlist.js:37) compares canonicalized STRINGS only — no
|
|
247
|
+
* realpath anywhere in that fence — and a run directory is created with
|
|
248
|
+
* `fs.mkdirSync(runDir, { recursive: true, mode: 0o700 })`
|
|
249
|
+
* (run-state.js:79), which follows a symlinked ancestor. So an `--out-dir`
|
|
250
|
+
* that is (or sits under) a symlink/junction pointing INTO the project
|
|
251
|
+
* passes the lexical "outside the tree" rule while the run's records land
|
|
252
|
+
* PHYSICALLY inside the tree a `read` seat runs in (measured 2026-09-13).
|
|
253
|
+
* Walks up from `p` to the deepest EXISTING ancestor (the run directory
|
|
254
|
+
* itself usually does not exist yet), resolves THAT ancestor with
|
|
255
|
+
* `fs.realpathSync.native` (follows symlinks/junctions), then re-appends the
|
|
256
|
+
* unresolved tail unchanged. Any throw (a root that never resolves, a
|
|
257
|
+
* permissions error) returns `p` unchanged, so this can only ever be AS
|
|
258
|
+
* STRICT as comparing the lexical paths, never less.
|
|
259
|
+
* @param {string} p
|
|
260
|
+
* @returns {string}
|
|
261
|
+
*/
|
|
262
|
+
function resolvePhysicalPath(p) {
|
|
263
|
+
try {
|
|
264
|
+
let current = p;
|
|
265
|
+
const tail = [];
|
|
266
|
+
while (!fs.existsSync(current)) {
|
|
267
|
+
const parent = path.dirname(current);
|
|
268
|
+
if (parent === current) { return p; } // a root that does not exist either
|
|
269
|
+
tail.unshift(path.basename(current));
|
|
270
|
+
current = parent;
|
|
271
|
+
}
|
|
272
|
+
const resolved = fs.realpathSync.native(current);
|
|
273
|
+
return tail.length ? path.join(resolved, ...tail) : resolved;
|
|
274
|
+
} catch {
|
|
275
|
+
return p;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* `isPathInside`, but on PHYSICAL paths (ruling P2-R54): closes the symlink
|
|
281
|
+
* escape `resolvePhysicalPath`'s docblock describes, alongside (never
|
|
282
|
+
* instead of) the lexical `isPathInside` check — see run-seat-tools.js ::
|
|
283
|
+
* preflightSeatTools, where both are consulted. Named mutant SYMLINKBLIND:
|
|
284
|
+
* dropping this conjunct from that placement check restores the escape.
|
|
285
|
+
* @param {string} child
|
|
286
|
+
* @param {string} parent
|
|
287
|
+
* @returns {boolean}
|
|
288
|
+
*/
|
|
289
|
+
function isPhysicallyInside(child, parent) {
|
|
290
|
+
return isPathInside(resolvePhysicalPath(child), resolvePhysicalPath(parent));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
verificationDirectories, listEngineAgents, verifyAgentRendering, verifyAgentFields,
|
|
295
|
+
resolvePhysicalPath, isPhysicallyInside,
|
|
296
|
+
};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// src/council/run-seat-tools.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/run-seat-tools
|
|
6
|
+
* `runCouncil`'s seat-tools wiring (spec 2026-09-11 §4, PR 2 of 3), split out of
|
|
7
|
+
* run.js under controller ruling P2-R14 (the 300-line size gate: run.js sat at
|
|
8
|
+
* exactly 300 lines with no headroom for this task's ~20 lines of logic). The
|
|
9
|
+
* engine-rendering tripwire's own pure pieces, plus `verificationDirectories`'
|
|
10
|
+
* one side effect — the best-effort `_scratch` mkdir it documents —
|
|
11
|
+
* (`listEngineAgents`, `verifyAgentRendering`, `verifyAgentFields`,
|
|
12
|
+
* `verificationDirectories`) live in the sibling module run-seat-tools-verify.js
|
|
13
|
+
* (council #247 round 3, same size gate; `verifyAgentFields` added round 6);
|
|
14
|
+
* `listEngineAgents`/`verifyAgentRendering`/`verifyAgentFields` are re-exported
|
|
15
|
+
* below so every existing importer keeps requiring them from here.
|
|
16
|
+
*
|
|
17
|
+
* Two pure-ish steps, called from run.js on either side of `acquireRunServer`:
|
|
18
|
+
* - `preflightSeatTools` (BEFORE the server): shape + refusals need no
|
|
19
|
+
* engine, and the run-directory placement rule for a local tool is a
|
|
20
|
+
* property of the tool id alone (seat-tools.js :: isLocal) — so both are
|
|
21
|
+
* decided, and refused, at ZERO SPEND. Its `councilAgents` output feeds
|
|
22
|
+
* `acquireRunServer`'s `agents` config, which is why it must run first.
|
|
23
|
+
* - `validateSeatToolsAgainstEngine` (AFTER the server, before any launch):
|
|
24
|
+
* the opt-in ids are checked against the engine's OWN declared tool list
|
|
25
|
+
* (run-server.js :: listEngineToolIds), so the accepted set is never
|
|
26
|
+
* hand-listed and never launched unvalidated.
|
|
27
|
+
*
|
|
28
|
+
* Neither function calls `finalize` — that stays run.js's job (the single exit
|
|
29
|
+
* every terminal outcome funnels through) — they return `{error}` and run.js
|
|
30
|
+
* decides what to do with it.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The seat-tools `intent` argument both functions derive from `o.intent`
|
|
35
|
+
* (`'task'` or absent — never a bare boolean or the raw `o.intent` string).
|
|
36
|
+
* One helper so the two derivations can never drift apart.
|
|
37
|
+
* @param {{intent?: string}} o @returns {'task'|undefined}
|
|
38
|
+
*/
|
|
39
|
+
function seatIntentOf(o) { return o.intent === 'task' ? 'task' : undefined; }
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decide the run's seat-tools policy before the server starts. Pure except for
|
|
43
|
+
* the two lazy `require`s (seat-tools.js, project-root-allowlist.js), which
|
|
44
|
+
* carry no state of their own.
|
|
45
|
+
* @param {{agent?: string, intent?: string, tools?: string[], runDir: string, project: string}} o
|
|
46
|
+
* @returns {{error: {code: string, message: string}}|{error: null, seatTools: string[],
|
|
47
|
+
* seatToolsLocal: boolean, councilAgents: object|null}}
|
|
48
|
+
*/
|
|
49
|
+
function preflightSeatTools(o) {
|
|
50
|
+
const seatTools = require('./seat-tools');
|
|
51
|
+
// Review r1: not-null-and-not-undefined (not just `!== undefined`), so
|
|
52
|
+
// `agent: null` — the CLI house style for an unset option (run.js's own `o`
|
|
53
|
+
// seed spreads `critic: null, lenses: null, maxCost: null, ...` the same
|
|
54
|
+
// way) — is treated as absent, not as an invalid override. Every OTHER
|
|
55
|
+
// `o.agent` check in this module already does this for free (`null` is
|
|
56
|
+
// falsy), so this guard was the one place stricter than the rest of the
|
|
57
|
+
// module. `!= null` would say the same thing in one operator, but the
|
|
58
|
+
// repo's eqeqeq('always') lint rule bans loose equality outright.
|
|
59
|
+
if (o.agent !== null && o.agent !== undefined && o.agent !== 'Plan' && o.agent !== 'Build') {
|
|
60
|
+
return { error: { code: 'BAD_ARGS', message: `Error: agent must be Plan or Build; got '${o.agent}'` } };
|
|
61
|
+
}
|
|
62
|
+
// Named mutant TOOLSNOTARRAY: dropping this check lets a non-array `tools`
|
|
63
|
+
// (a bare string, say) reach resolveSeatTools below, where
|
|
64
|
+
// `Array.isArray(o.tools) ? o.tools : []` silently discards it as `[]`
|
|
65
|
+
// instead of refusing the run. Pinned by run-tools.test.js's `tools: 'read'`
|
|
66
|
+
// case (BAD_ARGS naming the received type; nothing launches).
|
|
67
|
+
if (o.tools !== undefined && o.tools !== null && !Array.isArray(o.tools)) {
|
|
68
|
+
return { error: { code: 'BAD_ARGS', message: 'Error: tools must be an array of tool ids (got ' + typeof o.tools + ')' } };
|
|
69
|
+
}
|
|
70
|
+
// Ruling P2-R28 (supersedes P2-R25): --tools/--agent are refused together on
|
|
71
|
+
// every door. Named mutant AGENTTOOLSCONFLICT: dropping this check reddens
|
|
72
|
+
// the runCouncil conflict test (agent + a non-empty tools array must exit 1
|
|
73
|
+
// naming "cannot be combined", never reach a launch).
|
|
74
|
+
const conflict = seatTools.agentToolsConflict(o.agent, o.tools);
|
|
75
|
+
if (conflict) { return { error: { code: 'BAD_ARGS', message: `Error: ${conflict}` } }; }
|
|
76
|
+
// The --agent escape hatch wins: no council agents, every leg runs on the
|
|
77
|
+
// engine's own agent — so --tools is never even consulted under it. Pinned
|
|
78
|
+
// by run-tools.test.js's "--agent Build short-circuits resolveSeatTools
|
|
79
|
+
// even under task intent" case — review intent alone can't tell the
|
|
80
|
+
// short-circuit apart from an unconditional resolveSeatTools call (both
|
|
81
|
+
// give `tools: []`), only the task default (`['webfetch']`) can.
|
|
82
|
+
const seatPolicy = o.agent
|
|
83
|
+
? { ok: true, tools: [], local: false }
|
|
84
|
+
: seatTools.resolveSeatTools({ intent: seatIntentOf(o), optIn: Array.isArray(o.tools) ? o.tools : [] });
|
|
85
|
+
if (!seatPolicy.ok) { return { error: { code: seatPolicy.code, message: `Error: ${seatPolicy.message}` } }; }
|
|
86
|
+
if (seatPolicy.local) {
|
|
87
|
+
// Defensive refusal: with a local tool and a falsy o.project,
|
|
88
|
+
// isPathInside(runDir, undefined) is false, so the placement rule below
|
|
89
|
+
// would PASS, and run-launch.js's directory fallback
|
|
90
|
+
// (`(opts.role === 'seat' && opts.directory) || opts.project`) resolves to
|
|
91
|
+
// the run dir itself — a direct caller (bypassing the CLI's required
|
|
92
|
+
// --project) could scope a seat to the very dir holding its own sibling
|
|
93
|
+
// sessions, exactly what the placement rule below exists to prevent.
|
|
94
|
+
// Named mutant NOPROJECTSCOPE: dropping this guard lets that through.
|
|
95
|
+
// Pinned by run-tools.test.js's "project: undefined" case.
|
|
96
|
+
if (!o.project) {
|
|
97
|
+
return { error: { code: 'BAD_ARGS', message: 'Error: a local tool needs a project directory to scope the seats to' } };
|
|
98
|
+
}
|
|
99
|
+
// Run-directory placement (spec §4): a seat that can read the project tree
|
|
100
|
+
// must not be able to read this run's sibling sessions, so the run dir must
|
|
101
|
+
// sit OUTSIDE the tree (and still under a root amicus is willing to write to).
|
|
102
|
+
// Named mutant DIRPLACEDROP: dropping the `isPathInside(...) ||` conjunct
|
|
103
|
+
// (keeping only the allowed-root half) would accept a runDir NESTED inside
|
|
104
|
+
// the project as long as the project itself sits under an allowed root —
|
|
105
|
+
// exactly the escape this rule exists to close. Pinned by run-tools.test.js's
|
|
106
|
+
// "run dir INSIDE the project" case (allowed root, still refused) and by its
|
|
107
|
+
// "outside the project" case (same allowed root, not refused once sibling).
|
|
108
|
+
const { isPathInside, isAllowedProjectRoot } = require('../project-root-allowlist');
|
|
109
|
+
// Ruling P2-R54 (C1, round 6): `isPathInside` compares canonicalized
|
|
110
|
+
// STRINGS only, and run-dir creation follows a symlinked ancestor — so an
|
|
111
|
+
// `--out-dir` that is (or sits under) a symlink/junction into the project
|
|
112
|
+
// passed this rule lexically while landing physically inside the tree
|
|
113
|
+
// (measured 2026-09-13). Named mutant SYMLINKBLIND: dropping the
|
|
114
|
+
// `isPhysicallyInside(...) ||` conjunct restores that escape.
|
|
115
|
+
const { isPhysicallyInside } = require('./run-seat-tools-verify');
|
|
116
|
+
if (isPathInside(o.runDir, o.project) || isPhysicallyInside(o.runDir, o.project) || !isAllowedProjectRoot(o.runDir)) {
|
|
117
|
+
// C5 (P2-R35): name the ids THIS run classed local (seat-tools.js's own
|
|
118
|
+
// NON_LOCAL_TOOL_IDS, not a hand-rolled copy) — a typo'd id (e.g.
|
|
119
|
+
// `webfetsh`) is classed local by the same not-explicitly-remote rule.
|
|
120
|
+
const localIds = seatPolicy.tools.filter((id) => !seatTools.NON_LOCAL_TOOL_IDS.includes(id));
|
|
121
|
+
return {
|
|
122
|
+
error: {
|
|
123
|
+
code: 'BAD_ARGS',
|
|
124
|
+
message: `Error: --tools with a local tool (${localIds.join(', ')}) needs --out-dir OUTSIDE the project tree `
|
|
125
|
+
+ '(a seat that can read the tree must not be able to read the run\'s sibling sessions; a run directory '
|
|
126
|
+
+ 'that resolves inside the project through a symlink counts as inside) and under your home, tmp or '
|
|
127
|
+
+ 'AMICUS_PROJECT_ROOTS',
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
error: null,
|
|
134
|
+
seatTools: seatPolicy.tools,
|
|
135
|
+
seatToolsLocal: seatPolicy.local,
|
|
136
|
+
councilAgents: o.agent ? null : seatTools.buildCouncilAgents({ tools: seatPolicy.tools }),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate the run's seat tools against the engine's own declaration, after
|
|
142
|
+
* the server is up and before any Stage-1 leg launches. Two independent
|
|
143
|
+
* checks:
|
|
144
|
+
*
|
|
145
|
+
* 1. Declared tool ids (ruling P2-R30): runs for the intent's DEFAULT too,
|
|
146
|
+
* not only an explicit opt-in — a defaults-only run is never launched
|
|
147
|
+
* against an engine that does not actually declare a tool it would use. A
|
|
148
|
+
* no-op whenever there is nothing to validate (`--agent`, or a review run
|
|
149
|
+
* with no tools). When the engine cannot be asked at all (no shared
|
|
150
|
+
* server), an explicit `--tools` opt-in still refuses, but a
|
|
151
|
+
* defaults-only run degrades quietly — nobody opted into this check.
|
|
152
|
+
*
|
|
153
|
+
* 2. The engine-rendered agents (ruling P2-R33), gated on `o.councilAgents &&
|
|
154
|
+
* canVerify`. Ruling P2-R38 (B1, round 3) DROPS the P2-R30 asymmetry for
|
|
155
|
+
* this half: once verification can run, a null agent list REFUSES
|
|
156
|
+
* regardless of whether `--tools` was ever typed — never launch council
|
|
157
|
+
* agents this run could not verify. `canVerify` is true when this run owns
|
|
158
|
+
* its server (production never injects `deps.launchers`; run.js only
|
|
159
|
+
* acquires the shared server when `!deps.launchers`) OR a test supplies
|
|
160
|
+
* its own lister (`deps.listEngineAgentsFn`). With injected launchers and
|
|
161
|
+
* no lister there is no real server to ask and the launchers ARE the
|
|
162
|
+
* test's own transport, so the check is skipped rather than judged against
|
|
163
|
+
* a null it could never have resolved. In production `deps.launchers` is
|
|
164
|
+
* never injected, so verification always runs and a missing list always
|
|
165
|
+
* refuses. When verifiable, every directory in run-seat-tools-verify.js ::
|
|
166
|
+
* verificationDirectories (ruling P2-R39 adds `_scratch`) is checked
|
|
167
|
+
* against council-seat/council-support's rendered rules
|
|
168
|
+
* (run-seat-tools-verify.js :: verifyAgentRendering). Named mutant
|
|
169
|
+
* TRIPWIREOFF: skipping this whole block leaves a widened agent undetected.
|
|
170
|
+
* @param {object} o intent/tools/seatTools/seatToolsLocal/councilAgents/runDir/project
|
|
171
|
+
* @param {{serverClient: object}|null} sharedServer
|
|
172
|
+
* @param {{listEngineToolIdsFn?: Function, listEngineAgentsFn?: Function,
|
|
173
|
+
* launchers?: object}} deps test seams; `launchers` gates `canVerify` (see above)
|
|
174
|
+
* @returns {Promise<{error: {code: string, message: string}|null}>}
|
|
175
|
+
*/
|
|
176
|
+
async function validateSeatToolsAgainstEngine(o, sharedServer, deps = {}) {
|
|
177
|
+
// Named mutant DEFAULTSUNCHECKED: narrowing this to `Array.isArray(o.tools)
|
|
178
|
+
// && o.tools.length` (the pre-P2-R30 guard) would skip a task-intent run
|
|
179
|
+
// with NO explicit --tools even when the engine declares no `webfetch` at
|
|
180
|
+
// all. Pinned by run-tools.test.js's "no tools, engine has no webfetch" case.
|
|
181
|
+
if (Array.isArray(o.seatTools) && o.seatTools.length) {
|
|
182
|
+
const seatTools = require('./seat-tools');
|
|
183
|
+
const listIds = deps.listEngineToolIdsFn || require('./run-server').listEngineToolIds;
|
|
184
|
+
const declared = await listIds(sharedServer, o.project);
|
|
185
|
+
if (!declared) {
|
|
186
|
+
// No way to ask: an explicit opt-in is refused (as before P2-R30); a
|
|
187
|
+
// defaults-only run's declared-id check is skipped instead — nobody
|
|
188
|
+
// opted into it and the shared-server degrade is recorded elsewhere —
|
|
189
|
+
// but the run still falls through to the agent-rendering tripwire
|
|
190
|
+
// below (ruling P2-R38): a defaults-only run is refused there too once
|
|
191
|
+
// a null agent list comes back. Named mutant EARLYRETURN: restoring
|
|
192
|
+
// `return { error: null }` here lets a task-intent default run launch
|
|
193
|
+
// unverified when the engine lists no tool ids.
|
|
194
|
+
if (Array.isArray(o.tools) && o.tools.length) {
|
|
195
|
+
return {
|
|
196
|
+
error: {
|
|
197
|
+
code: 'BAD_ARGS',
|
|
198
|
+
message: 'Error: --tools could not be validated: the run\'s engine did not list its tools '
|
|
199
|
+
+ '(no shared server, or the tool-ids endpoint failed); nothing was launched',
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
// This second resolveSeatTools call is a GATE, not a recompute:
|
|
205
|
+
// `o.seatTools` (preflightSeatTools's result) is already authoritative
|
|
206
|
+
// and unchanged by this check, so `checked.tools`/`checked.local` are
|
|
207
|
+
// deliberately discarded here — only `checked.ok` (declared-id
|
|
208
|
+
// refusals, now over the default too) is consulted.
|
|
209
|
+
const checked = seatTools.resolveSeatTools({ intent: seatIntentOf(o), optIn: o.tools || [], declaredIds: declared });
|
|
210
|
+
if (!checked.ok) { return { error: { code: checked.code, message: `Error: ${checked.message}` } }; }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Ruling P2-R38: the run owns its server (production never injects
|
|
214
|
+
// `launchers`) OR a test injects its own lister — either way there is
|
|
215
|
+
// something real to judge a null answer against. See the docblock above.
|
|
216
|
+
const canVerify = !!deps.listEngineAgentsFn || !deps.launchers;
|
|
217
|
+
if (o.councilAgents && canVerify) {
|
|
218
|
+
const { listEngineAgents, verifyAgentRendering, verifyAgentFields, verificationDirectories } = require('./run-seat-tools-verify');
|
|
219
|
+
const agentsFn = deps.listEngineAgentsFn || listEngineAgents;
|
|
220
|
+
// Shared by both checks below (permission rendering and, ruling P2-R53,
|
|
221
|
+
// the non-permission surface) so their refusal is byte-identical either
|
|
222
|
+
// way — a caller cannot tell which check caught the tree from the message.
|
|
223
|
+
const renderMismatch = (reason, dir) => ({
|
|
224
|
+
error: {
|
|
225
|
+
code: 'BAD_ARGS',
|
|
226
|
+
message: 'Error: the engine rendered the council agents differently from what this run registered '
|
|
227
|
+
+ `(${reason}, directory ${dir}) — an opencode.json or .opencode/agent file the engine `
|
|
228
|
+
+ 'loads for that directory (the tree\'s, or your global config) defines council-seat/'
|
|
229
|
+
+ 'council-support and alters them; remove those entries, or run with --agent Plan to use the '
|
|
230
|
+
+ 'engine\'s own agent knowingly (v4.9.7 behaviour). Nothing was launched.',
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
const directories = verificationDirectories(o);
|
|
234
|
+
for (const dir of directories) {
|
|
235
|
+
const list = await agentsFn(sharedServer, dir);
|
|
236
|
+
if (!list) {
|
|
237
|
+
// Ruling P2-R38: never launch council agents this run could not
|
|
238
|
+
// verify — a defaults-only run now refuses exactly like an explicit
|
|
239
|
+
// opt-in; there is no quiet degrade left to fall back on.
|
|
240
|
+
return {
|
|
241
|
+
error: {
|
|
242
|
+
code: 'BAD_ARGS',
|
|
243
|
+
message: 'Error: the council agents could not be verified against the run\'s engine '
|
|
244
|
+
+ `(${sharedServer ? 'the shared server answered without an agent list' : 'no shared server was available to answer the agent list'}); `
|
|
245
|
+
+ 'nothing was launched; --agent Plan runs every leg on the engine\'s own Plan agent as v4.9.7 did '
|
|
246
|
+
+ '(reads, searches and shell allowed; edits denied), knowingly and without the allowlist',
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
for (const [name, allowlist] of [['council-seat', o.seatTools || []], ['council-support', []]]) {
|
|
251
|
+
const agent = list.find((a) => a.name === name);
|
|
252
|
+
if (!agent) {
|
|
253
|
+
return { error: { code: 'BAD_ARGS', message: `Error: the engine did not register ${name}` } };
|
|
254
|
+
}
|
|
255
|
+
const verified = verifyAgentRendering(Array.isArray(agent.permission) ? agent.permission : [], allowlist);
|
|
256
|
+
if (!verified.ok) { return renderMismatch(verified.reason, dir); }
|
|
257
|
+
// Ruling P2-R53 (B1, round 6): verifyAgentRendering only ever checked
|
|
258
|
+
// `permission` — a tree can ALSO set a council agent's prompt, model,
|
|
259
|
+
// sampling, options and mode (measured 2026-09-13). Named mutant
|
|
260
|
+
// FIELDSBLIND (in verifyAgentFields itself): returning {ok:true}
|
|
261
|
+
// unconditionally there reddens this call site's own coverage too.
|
|
262
|
+
const fieldsVerified = verifyAgentFields(agent);
|
|
263
|
+
if (!fieldsVerified.ok) { return renderMismatch(fieldsVerified.reason, dir); }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return { error: null };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const { listEngineAgents, verifyAgentRendering, verifyAgentFields } = require('./run-seat-tools-verify');
|
|
271
|
+
|
|
272
|
+
module.exports = {
|
|
273
|
+
preflightSeatTools, validateSeatToolsAgainstEngine, listEngineAgents, verifyAgentRendering, verifyAgentFields,
|
|
274
|
+
};
|