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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +94 -0
  3. package/README.md +1 -1
  4. package/docs/ROADMAP.md +3 -3
  5. package/docs/architecture-map.md +19 -4
  6. package/docs/council.md +140 -3
  7. package/docs/usage.md +8 -4
  8. package/package.json +1 -1
  9. package/schemas/council-verdict.schema.json +3 -1
  10. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  11. package/src/cli-council-run-tools.js +168 -0
  12. package/src/cli-handlers-council-run.js +6 -6
  13. package/src/cli.js +23 -1
  14. package/src/council/briefings-chair.js +1 -1
  15. package/src/council/briefings-task.js +11 -5
  16. package/src/council/briefings.js +25 -7
  17. package/src/council/report-lost-rows.js +89 -0
  18. package/src/council/report-md.js +3 -1
  19. package/src/council/report.js +3 -2
  20. package/src/council/run-degrade.js +22 -1
  21. package/src/council/run-finish.js +23 -1
  22. package/src/council/run-launch.js +33 -4
  23. package/src/council/run-retry-launch.js +9 -4
  24. package/src/council/run-retry.js +3 -0
  25. package/src/council/run-seat-tools-verify.js +296 -0
  26. package/src/council/run-seat-tools.js +274 -0
  27. package/src/council/run-server.js +41 -6
  28. package/src/council/run-stage1-launch.js +8 -3
  29. package/src/council/run.js +21 -21
  30. package/src/council/seat-tools.js +299 -0
  31. package/src/council/verdict-seats-reviewed.js +76 -6
  32. package/src/headless.js +136 -6
  33. package/src/mcp-council-pack-map.js +24 -0
  34. package/src/mcp-council-run.js +17 -15
  35. package/src/mcp-server.js +2 -2
  36. package/src/mcp-tools.js +15 -4
  37. package/src/opencode-client.js +26 -0
  38. package/src/pack/pack-validate.js +3 -1
  39. package/src/prompt-builder.js +2 -2
  40. package/src/sidecar/fanout.js +7 -1
  41. package/src/sidecar/heartbeat.js +46 -0
  42. package/src/sidecar/session-utils.js +7 -34
  43. package/src/utils/agent-mapping.js +1 -1
  44. 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
- /** The briefing a retry unit re-issues — same intent-aware dispatchers Stage 1 used (v4.9 W6). */
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
  /**
@@ -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
+ };