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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +125 -0
- package/README.md +2 -1
- package/bin/amicus.js +5 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/architecture-map.md +41 -6
- package/docs/configuration.md +14 -8
- package/docs/council.md +140 -3
- package/docs/usage.md +29 -6
- package/electron/ipc-setup.js +6 -9
- package/electron/setup-ui-alias-groups.js +29 -124
- 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-handlers.js +8 -1
- package/src/cli.js +34 -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/aliases-review-gate.js +65 -0
- package/src/sidecar/aliases-review-prompt.js +91 -0
- package/src/sidecar/aliases-review-render.js +116 -0
- package/src/sidecar/aliases-review.js +298 -0
- package/src/sidecar/aliases.js +279 -0
- package/src/sidecar/fanout.js +7 -1
- package/src/sidecar/heartbeat.js +46 -0
- package/src/sidecar/models.js +20 -7
- package/src/sidecar/session-utils.js +7 -34
- package/src/sidecar/setup.js +20 -18
- package/src/utils/agent-mapping.js +1 -1
- package/src/utils/alias-groups.js +128 -0
- package/src/utils/alias-proposals.js +151 -0
- package/src/utils/alias-resolver.js +1 -1
- package/src/utils/alias-state.js +88 -0
- package/src/utils/alias-store.js +65 -0
- package/src/utils/config.js +10 -5
- package/src/utils/degrade.js +8 -0
- package/src/utils/model-id-siblings.js +106 -0
- package/src/utils/model-validator.js +1 -1
- package/src/utils/quick-picks.js +13 -32
- 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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
+
};
|