amicus 4.9.7 → 4.10.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.
Files changed (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +125 -0
  3. package/README.md +2 -1
  4. package/bin/amicus.js +5 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/architecture-map.md +41 -6
  7. package/docs/configuration.md +14 -8
  8. package/docs/council.md +140 -3
  9. package/docs/usage.md +29 -6
  10. package/electron/ipc-setup.js +6 -9
  11. package/electron/setup-ui-alias-groups.js +29 -124
  12. package/package.json +1 -1
  13. package/schemas/council-verdict.schema.json +3 -1
  14. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  15. package/src/cli-council-run-tools.js +168 -0
  16. package/src/cli-handlers-council-run.js +6 -6
  17. package/src/cli-handlers.js +8 -1
  18. package/src/cli.js +34 -1
  19. package/src/council/briefings-chair.js +1 -1
  20. package/src/council/briefings-task.js +11 -5
  21. package/src/council/briefings.js +25 -7
  22. package/src/council/report-lost-rows.js +89 -0
  23. package/src/council/report-md.js +3 -1
  24. package/src/council/report.js +3 -2
  25. package/src/council/run-degrade.js +22 -1
  26. package/src/council/run-finish.js +23 -1
  27. package/src/council/run-launch.js +33 -4
  28. package/src/council/run-retry-launch.js +9 -4
  29. package/src/council/run-retry.js +3 -0
  30. package/src/council/run-seat-tools-verify.js +296 -0
  31. package/src/council/run-seat-tools.js +274 -0
  32. package/src/council/run-server.js +41 -6
  33. package/src/council/run-stage1-launch.js +8 -3
  34. package/src/council/run.js +21 -21
  35. package/src/council/seat-tools.js +299 -0
  36. package/src/council/verdict-seats-reviewed.js +76 -6
  37. package/src/headless.js +136 -6
  38. package/src/mcp-council-pack-map.js +24 -0
  39. package/src/mcp-council-run.js +17 -15
  40. package/src/mcp-server.js +2 -2
  41. package/src/mcp-tools.js +15 -4
  42. package/src/opencode-client.js +26 -0
  43. package/src/pack/pack-validate.js +3 -1
  44. package/src/prompt-builder.js +2 -2
  45. package/src/sidecar/aliases-review-gate.js +65 -0
  46. package/src/sidecar/aliases-review-prompt.js +91 -0
  47. package/src/sidecar/aliases-review-render.js +116 -0
  48. package/src/sidecar/aliases-review.js +298 -0
  49. package/src/sidecar/aliases.js +279 -0
  50. package/src/sidecar/fanout.js +7 -1
  51. package/src/sidecar/heartbeat.js +46 -0
  52. package/src/sidecar/models.js +20 -7
  53. package/src/sidecar/session-utils.js +7 -34
  54. package/src/sidecar/setup.js +20 -18
  55. package/src/utils/agent-mapping.js +1 -1
  56. package/src/utils/alias-groups.js +128 -0
  57. package/src/utils/alias-proposals.js +151 -0
  58. package/src/utils/alias-resolver.js +1 -1
  59. package/src/utils/alias-state.js +88 -0
  60. package/src/utils/alias-store.js +65 -0
  61. package/src/utils/config.js +10 -5
  62. package/src/utils/degrade.js +8 -0
  63. package/src/utils/model-id-siblings.js +106 -0
  64. package/src/utils/model-validator.js +1 -1
  65. package/src/utils/quick-picks.js +13 -32
  66. package/src/utils/text-sanitize.js +27 -0
@@ -63,12 +63,16 @@ function createLaunchers(deps = {}) {
63
63
  const reserveBudget = deps.reserveBudget || null;
64
64
  const onBudgetRefusal = deps.onBudgetRefusal || null;
65
65
  const sharedServer = deps.sharedServer || null;
66
+ // Spec 2026-09-11 §4: getters, like sharedServer — run.js builds the launchers
67
+ // before it has decided the seat policy.
68
+ const councilAgents = deps.councilAgents || (() => null);
69
+ const agentOverride = deps.agentOverride || (() => undefined);
66
70
 
67
71
  /**
68
72
  * @param {{models: string[], prompt: string, project: string, waveId: string,
69
73
  * timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string,
70
74
  * councilRunId?: string, councilName?: string, tag?: string, seats?: Array<object>,
71
- * fallback?: object, catalog?: Array, noOutputBackstopMs?: number}} opts
75
+ * fallback?: object, catalog?: Array, noOutputBackstopMs?: number, role?: 'seat', directory?: string}} opts
72
76
  * councilRunId/councilName (v4.3 Task 3, spec §7.2) are additive attribution
73
77
  * ids forwarded verbatim into the runFanout call so it can stamp them onto
74
78
  * every leg. tag (v4.7 F8 D16) rides the same forward — every call site
@@ -83,6 +87,13 @@ function createLaunchers(deps = {}) {
83
87
  * noOutputBackstopMs (Task 5, #129) is opt-in and spread-guarded on
84
88
  * Number.isFinite (0 is a valid disable value); only run-retry.js sets it,
85
89
  * to escalate the window on a Stage-1 retry.
90
+ * role (spec 2026-09-11 §4, P2-R11) is the LAUNCH role, not the per-leg
91
+ * value run-stages.js's `roleFor()` returns — every stage-1 leg (seat,
92
+ * critic, and lens alike) launches with the literal `role: 'seat'` to get
93
+ * the tool-capable agent; a caller must pass that literal, never a leg's
94
+ * own `seat.role`. It also gates `directory` (see the comment above that
95
+ * option below): only a `role: 'seat'` launch may point tool-exec cwd
96
+ * anywhere but `opts.project`.
86
97
  * @returns {Promise<{wave: object|null, exitCode: number}>}
87
98
  */
88
99
  async function launchWave(opts) {
@@ -100,6 +111,13 @@ function createLaunchers(deps = {}) {
100
111
  // `serverClient` (see the seam comment in fanout.js). Absent → the wave
101
112
  // starts and closes its own server, exactly as before.
102
113
  const shared = sharedServer ? sharedServer() : null;
114
+ const agents = councilAgents();
115
+ // Spec 2026-09-11 §4: stage-1 seats and their retries run as council-seat,
116
+ // every other role as council-support; an explicit agent (the --agent
117
+ // escape hatch) wins. Without council agents (non-council DI, older
118
+ // callers) the pre-§4 default 'Plan' stands.
119
+ const agent = opts.agent || agentOverride()
120
+ || (agents ? (opts.role === 'seat' ? 'council-seat' : 'council-support') : 'Plan');
103
121
  const { wave, exitCode, errorDoc } = await fanoutFn({
104
122
  ...(typeof remaining === 'number' ? { maxCost: remaining } : {}),
105
123
  ...(shared ? { serverClient: shared.serverClient, server: shared.server } : {}),
@@ -112,12 +130,13 @@ function createLaunchers(deps = {}) {
112
130
  // onto every leg and its spend-ledger row (v4.3 --retry-failed machinery).
113
131
  // Spread-guarded so a normal launch's transport call stays byte-identical.
114
132
  ...(opts.retryOfWaveId ? { retryOfWaveId: opts.retryOfWaveId } : {}),
133
+ ...(agents ? { serverAgents: agents } : {}),
115
134
  models: opts.models.join(','),
116
135
  prompt: opts.prompt,
117
136
  promptMeta: { source: 'council-engine', file: null, chars: opts.prompt.length },
118
137
  waveId: opts.waveId,
119
138
  project: opts.project,
120
- agent: opts.agent || 'Plan',
139
+ agent,
121
140
  timeout: opts.timeout,
122
141
  summaryLength: 'verbose',
123
142
  includeContext: false,
@@ -160,8 +179,18 @@ function createLaunchers(deps = {}) {
160
179
  // own session dir (judges' `project` is `<runDir>/_scratch`, so this
161
180
  // scopes them there) and strip inherited MCP servers, so a tool-capable
162
181
  // judge can't read the de-anonymized review-*.md files or the plaintext
163
- // labelMap in run.json sitting in the parent run dir.
164
- directory: opts.project,
182
+ // labelMap in run.json sitting in the parent run dir. Every launch is
183
+ // scoped to `opts.project` — the ONLY exception is a stage-1 seat launch
184
+ // (`opts.role === 'seat'`) that also passes `opts.directory`: a later
185
+ // task uses that to point a local-tools seat at the real project tree
186
+ // while the run's own metadata stays in `opts.project` (P2-R11 review).
187
+ // Judge, debate, and chair legs never set `role: 'seat'`, so `_scratch`
188
+ // isolation cannot be escaped through this option.
189
+ // Named mutant DIRGATEDROP: dropping the `opts.role === 'seat' &&`
190
+ // conjunct below lets ANY caller redirect tool-exec cwd via
191
+ // `opts.directory` — reddens "a non-seat launch ignores opts.directory"
192
+ // (tests/council/run-launch.test.js).
193
+ directory: (opts.role === 'seat' && opts.directory) || opts.project,
165
194
  noMcp: true,
166
195
  });
167
196
  // A ceiling refusal returns `wave: null`, which the council driver's
@@ -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
+ };