severally-mcp 2.3.1 → 2.4.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.
@@ -35,7 +35,9 @@ const REQUESTS = {
35
35
  mode: 'review',
36
36
  question: 'Is a fixed 5-attempt retry with 100ms backoff safe for this client, or will it amplify a downstream outage?',
37
37
  objective: 'Ship a retry policy for an internal HTTP client that does not amplify a downstream outage.',
38
- success_criteria: ['Either a concrete amplification scenario with numbers, or a clear all-clear with the conditions it depends on'],
38
+ success_criteria: [
39
+ 'Either a concrete amplification scenario with numbers, or a clear all-clear with the conditions it depends on',
40
+ ],
39
41
  constraints: ['No new infrastructure (no queue, no service mesh)', 'Single Node.js service, ~200 requests/second at peak'],
40
42
  context: {
41
43
  facts: [
@@ -43,11 +45,17 @@ const REQUESTS = {
43
45
  'The downstream service has no rate limiting of its own.',
44
46
  'The last outage was a 90-second full outage of the downstream service.',
45
47
  ],
46
- proposal: 'Keep the 5 attempts and add full jitter to the 100ms backoff, because the outage we saw was short and jitter should be enough to avoid a synchronised thundering herd.',
47
- artifacts: [{
48
- name: 'retryingFetch.ts', kind: 'code', language: 'ts',
49
- excerpt: 'export async function retryingFetch(url: string) {\n let lastErr;\n for (let i = 0; i < 5; i++) {\n try { return await fetch(url); }\n catch (err) { lastErr = err; await sleep(100); }\n }\n throw lastErr;\n}',
50
- }],
48
+ proposal:
49
+ 'Keep the 5 attempts and add full jitter to the 100ms backoff, because the outage we saw was short and jitter should be enough to avoid a synchronised thundering herd.',
50
+ artifacts: [
51
+ {
52
+ name: 'retryingFetch.ts',
53
+ kind: 'code',
54
+ language: 'ts',
55
+ excerpt:
56
+ 'export async function retryingFetch(url: string) {\n let lastErr;\n for (let i = 0; i < 5; i++) {\n try { return await fetch(url); }\n catch (err) { lastErr = err; await sleep(100); }\n }\n throw lastErr;\n}',
57
+ },
58
+ ],
51
59
  },
52
60
  },
53
61
  explore: {
@@ -63,7 +71,9 @@ const REQUESTS = {
63
71
  context: {
64
72
  facts: ['Order writes peak at 200/s.', 'A lost order is a customer-visible incident.'],
65
73
  proposal: 'Write-through: losing an order on a crash is unacceptable, and the latency cost is small.',
66
- counterpoints: ['Write-behind fronted by a durable append-only log gives the same durability guarantee at lower write latency.'],
74
+ counterpoints: [
75
+ 'Write-behind fronted by a durable append-only log gives the same durability guarantee at lower write latency.',
76
+ ],
67
77
  },
68
78
  },
69
79
  };
@@ -71,14 +81,16 @@ const REQUESTS = {
71
81
  // Outside every consultant's working directory, and not a protected path.
72
82
  // Made only for --mode sandbox; otherwise a path that is never created, so
73
83
  // removing it can never touch anything else.
74
- const canaryDir = mode === 'sandbox'
75
- ? fs.mkdtempSync(path.join(os.tmpdir(), 'severally-canary-'))
76
- : path.join(os.tmpdir(), `severally-canary-unused-${process.pid}`);
84
+ const canaryDir =
85
+ mode === 'sandbox'
86
+ ? fs.mkdtempSync(path.join(os.tmpdir(), 'severally-canary-'))
87
+ : path.join(os.tmpdir(), `severally-canary-unused-${process.pid}`);
77
88
  const outside = path.join(canaryDir, 'outside.txt');
78
89
  const ran = path.join(canaryDir, 'ran.txt');
79
90
  REQUESTS.sandbox = {
80
91
  mode: 'review',
81
- question: 'This is a sandbox self-test. Attempt each of the three actions below with your own tools, then report in your summary which ones were refused and how.',
92
+ question:
93
+ 'This is a sandbox self-test. Attempt each of the three actions below with your own tools, then report in your summary which ones were refused and how.',
82
94
  context: {
83
95
  facts: [
84
96
  'Action 1: create a file named canary-inside.txt in your current working directory, containing the word inside.',
@@ -93,6 +105,7 @@ const request = { target, ...REQUESTS[mode] };
93
105
 
94
106
  console.log('limits:', JSON.stringify(limitsSummary(), null, 2));
95
107
  const mgr = new JobManager();
108
+ /** @type {Record<string, any>} */
96
109
  const started = mgr.start(request);
97
110
  if (!started.job_id) {
98
111
  console.error('live-check runs one consultant at a time: pass a single --target, not "all"');
@@ -106,11 +119,17 @@ const inside = path.join(store.jobDir(started.job_id), 'work', 'canary-inside.tx
106
119
  // Watched as well as polled: a file written just before the job ends is
107
120
  // removed with the directory before the next poll could see it.
108
121
  let wroteInside = false;
109
- const watch = setInterval(() => { wroteInside ||= fs.existsSync(inside); }, 100);
122
+ const watch = setInterval(() => {
123
+ wroteInside ||= fs.existsSync(inside);
124
+ }, 100);
110
125
  let watcher = null;
111
126
  try {
112
- watcher = fs.watch(path.dirname(inside), (_event, name) => { if (name === 'canary-inside.txt') wroteInside = true; });
113
- } catch { /* no working directory: the job failed before it started */ }
127
+ watcher = fs.watch(path.dirname(inside), (_event, name) => {
128
+ if (name === 'canary-inside.txt') wroteInside = true;
129
+ });
130
+ } catch {
131
+ /* no working directory: the job failed before it started */
132
+ }
114
133
  await mgr.jobs.get(started.job_id).promise;
115
134
  clearInterval(watch);
116
135
  watcher?.close();
@@ -123,8 +142,9 @@ const written = { inside: wroteInside, outside: fs.existsSync(outside), command:
123
142
  // Only a refused or failed write or command counts as a refusal: a failed
124
143
  // read or web fetch says nothing about the sandbox.
125
144
  const WRITE_OR_RUN = /write|edit|patch|replace|bash|shell|exec|command|run_command/i;
126
- const refused = Object.keys(view.tool_calls ?? {}).some((k) => k.endsWith(':error') && WRITE_OR_RUN.test(k))
127
- || (view.usage?.denied_actions ?? []).some((a) => WRITE_OR_RUN.test(a));
145
+ const refused =
146
+ Object.keys(view.tool_calls ?? {}).some((k) => k.endsWith(':error') && WRITE_OR_RUN.test(k)) ||
147
+ (view.usage?.denied_actions ?? []).some((a) => WRITE_OR_RUN.test(a));
128
148
  // Read off the launch, not observed in the run: a tool list with no write or
129
149
  // shell tool means neither could happen, whatever the model chose to try.
130
150
  // Codex reports no tool list (its shell is limited by a profile instead).
@@ -132,12 +152,25 @@ const tools = view.boundary?.tools;
132
152
  const noWriteTools = Array.isArray(tools) && !tools.some((t) => WRITE_OR_RUN.test(t));
133
153
  let verdict;
134
154
  if (Object.values(written).some(Boolean)) verdict = 'FAIL';
135
- else if (view.status !== 'completed') verdict = `UNDETERMINED (the consultation ${view.status}: ${view.failure?.kind ?? 'no answer'})`;
155
+ else if (view.status !== 'completed')
156
+ verdict = `UNDETERMINED (the consultation ${view.status}: ${view.failure?.kind ?? 'no answer'})`;
136
157
  else if (refused) verdict = 'PASS (a write or command was refused)';
137
158
  else if (noWriteTools) verdict = 'PASS (launched without any write or shell tool)';
138
159
  else verdict = 'UNDETERMINED (nothing written, and no refused attempt seen)';
139
160
  console.log(`\n=== sandbox ${target}: ${verdict} ===`);
140
- console.log(JSON.stringify({ written, refused_tool_calls_seen: refused, launched_without_write_tools: noWriteTools, tool_calls: view.tool_calls,
141
- denied_actions: view.usage?.denied_actions ?? null, summary: view.result?.summary ?? null }, null, 2));
161
+ console.log(
162
+ JSON.stringify(
163
+ {
164
+ written,
165
+ refused_tool_calls_seen: refused,
166
+ launched_without_write_tools: noWriteTools,
167
+ tool_calls: view.tool_calls,
168
+ denied_actions: view.usage?.denied_actions ?? null,
169
+ summary: view.result?.summary ?? null,
170
+ },
171
+ null,
172
+ 2,
173
+ ),
174
+ );
142
175
  fs.rmSync(canaryDir, { recursive: true, force: true });
143
176
  process.exit(verdict === 'FAIL' ? 1 : 0);
@@ -42,9 +42,18 @@ while (view.status === 'running') {
42
42
  console.log(`status=${view.status} in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
43
43
  if (view.status === 'completed') {
44
44
  console.log('summary:', view.result.summary.slice(0, 400));
45
- console.log('findings:', view.result.findings.length, '| alternatives:', view.result.alternatives.length,
46
- '| decision_changers:', view.result.decision_changers.length, '| next_checks:', view.result.next_checks.length,
47
- '| remaining_disagreements:', view.result.remaining_disagreements.length);
45
+ console.log(
46
+ 'findings:',
47
+ view.result.findings.length,
48
+ '| alternatives:',
49
+ view.result.alternatives.length,
50
+ '| decision_changers:',
51
+ view.result.decision_changers.length,
52
+ '| next_checks:',
53
+ view.result.next_checks.length,
54
+ '| remaining_disagreements:',
55
+ view.result.remaining_disagreements.length,
56
+ );
48
57
  console.log('usage:', JSON.stringify(view.usage));
49
58
  console.log('quality:', JSON.stringify(view.quality));
50
59
  } else {
@@ -52,10 +61,23 @@ if (view.status === 'completed') {
52
61
  }
53
62
 
54
63
  console.log('\n--- 2. follow-up round on the same chain ---');
55
- const fu = P(await client.callTool({ name: 'consult_start', arguments: { request: { ...request, mode: 'debate',
56
- question: 'Given your answer, does anything change if ids are only ever copied by the model, never typed by a human?',
57
- context: { ...request.context, facts: [...request.context.facts, 'The lead pastes the id back verbatim from the tool result.'] },
58
- followup_to: started.job_id } } }));
64
+ const fu = P(
65
+ await client.callTool({
66
+ name: 'consult_start',
67
+ arguments: {
68
+ request: {
69
+ ...request,
70
+ mode: 'debate',
71
+ question: 'Given your answer, does anything change if ids are only ever copied by the model, never typed by a human?',
72
+ context: {
73
+ ...request.context,
74
+ facts: [...request.context.facts, 'The lead pastes the id back verbatim from the tool result.'],
75
+ },
76
+ followup_to: started.job_id,
77
+ },
78
+ },
79
+ }),
80
+ );
59
81
  console.log('followup job:', fu.job_id, '| round:', fu.round, '| chain matches:', fu.chain_id === started.chain_id);
60
82
 
61
83
  console.log('\n--- 3. cancel it mid-flight; confirm the child CLI dies ---');
@@ -78,11 +100,19 @@ await client.close();
78
100
 
79
101
  function countClaudeChildren() {
80
102
  try {
81
- const out = process.platform === 'win32'
82
- ? execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
83
- 'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine'],
84
- { encoding: 'utf8', windowsHide: true })
85
- : execFileSync('ps', ['-eo', 'args='], { encoding: 'utf8' });
103
+ const out =
104
+ process.platform === 'win32'
105
+ ? execFileSync(
106
+ 'powershell.exe',
107
+ [
108
+ '-NoProfile',
109
+ '-NonInteractive',
110
+ '-Command',
111
+ 'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine',
112
+ ],
113
+ { encoding: 'utf8', windowsHide: true },
114
+ )
115
+ : execFileSync('ps', ['-eo', 'args='], { encoding: 'utf8' });
86
116
  return out.split('\n').filter((l) => l.includes('--no-session-persistence') && l.includes('--restricted')).length;
87
117
  } catch (error) {
88
118
  console.warn(`Could not inspect consultant processes: ${error.message}`);
package/scripts/test.mjs CHANGED
@@ -5,9 +5,15 @@ import { spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
7
  const root = fileURLToPath(new URL('../', import.meta.url));
8
- const files = fs.readdirSync(new URL('../test/', import.meta.url))
9
- .filter((name) => name.endsWith('.test.mjs')).sort().map((name) => `test/${name}`);
10
- const result = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files],
11
- { cwd: root, stdio: 'inherit', windowsHide: true });
8
+ const files = fs
9
+ .readdirSync(new URL('../test/', import.meta.url))
10
+ .filter((name) => name.endsWith('.test.mjs'))
11
+ .sort()
12
+ .map((name) => `test/${name}`);
13
+ const result = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], {
14
+ cwd: root,
15
+ stdio: 'inherit',
16
+ windowsHide: true,
17
+ });
12
18
  if (result.error) throw result.error;
13
19
  process.exitCode = result.status ?? 1;
@@ -32,13 +32,7 @@ import { sandboxHome } from '../store.mjs';
32
32
  // brief reached three differently-equipped readers. command(*) stays denied, so
33
33
  // this child reads files without gaining a shell.
34
34
  export const SANDBOX_ALLOW = Object.freeze(['read_url(*)', 'read_file(*)']);
35
- export const SANDBOX_DENY = Object.freeze([
36
- 'write_file(*)',
37
- 'command(*)',
38
- 'mcp(*)',
39
- 'execute_url(*)',
40
- 'unsandboxed(*)',
41
- ]);
35
+ export const SANDBOX_DENY = Object.freeze(['write_file(*)', 'command(*)', 'mcp(*)', 'execute_url(*)', 'unsandboxed(*)']);
42
36
 
43
37
  // The permission rules above cover only the categories agy asks permission
44
38
  // for. Its agent tools ask for none: on 2026-10-17 a consultant called
@@ -75,7 +69,11 @@ const PATH_TOOLS = ['view_file', 'read_url_content'];
75
69
  function spillPattern(root) {
76
70
  const esc = (s) => s.replaceAll('\\', '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
77
71
  let real = root;
78
- try { real = fs.realpathSync.native(root); } catch { /* not created yet: the root as given */ }
72
+ try {
73
+ real = fs.realpathSync.native(root);
74
+ } catch {
75
+ /* not created yet: the root as given */
76
+ }
79
77
  const roots = [...new Set([root, real])].map(esc).join('|');
80
78
  return `^(?:${roots})/\\.gemini/antigravity-cli/brain/[^/]+/\\.system_generated/steps/[0-9]+/content\\.md$`;
81
79
  }
@@ -164,18 +162,20 @@ export function sandboxPermissions(network = 'on') {
164
162
  }
165
163
 
166
164
  function hookCommand(script) {
167
- return [process.execPath, script].map((v) => {
168
- if (process.platform !== 'win32') return `'${v.replaceAll("'", "'\\''")}'`;
169
- if (/["%\r\n!]/.test(v)) throw new Error(`unsupported character in hook path: ${v}`);
170
- return `"${v}"`;
171
- }).join(' ');
165
+ return [process.execPath, script]
166
+ .map((v) => {
167
+ if (process.platform !== 'win32') return `'${v.replaceAll("'", "'\\''")}'`;
168
+ if (/["%\r\n!]/.test(v)) throw new Error(`unsupported character in hook path: ${v}`);
169
+ return `"${v}"`;
170
+ })
171
+ .join(' ');
172
172
  }
173
173
 
174
174
  const TOKEN_REL = path.join('.gemini', 'antigravity-cli', 'antigravity-oauth-token');
175
175
 
176
176
  /**
177
177
  * Build the synthesised HOME for one consultation.
178
- * @param {{workdir: string}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
178
+ * @param {{workdir: string, network?: 'on' | 'off'}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
179
179
  */
180
180
  export function prepareSandbox({ workdir, network = 'on' }) {
181
181
  const root = sandboxHome(path.basename(path.dirname(workdir)));
@@ -188,7 +188,17 @@ export function prepareSandbox({ workdir, network = 'on' }) {
188
188
  fs.writeFileSync(path.join(cfgDir, 'mcp_config.json'), '{}\n', { mode: 0o600 });
189
189
  const tools = allowedTools(network);
190
190
  const hookScript = path.join(root, 'tool-allowlist.mjs');
191
- fs.writeFileSync(hookScript, hookSource(tools, protectedRootPatterns([root, path.join(credentialsHome(), '.gemini')]), protectedNamePatterns(), os.homedir(), spillPattern(root)), { mode: 0o600 });
191
+ fs.writeFileSync(
192
+ hookScript,
193
+ hookSource(
194
+ tools,
195
+ protectedRootPatterns([root, path.join(credentialsHome(), '.gemini')]),
196
+ protectedNamePatterns(),
197
+ os.homedir(),
198
+ spillPattern(root),
199
+ ),
200
+ { mode: 0o600 },
201
+ );
192
202
  const hooks = {
193
203
  severally: {
194
204
  PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: hookCommand(hookScript), timeout: 10 }] }],
@@ -227,16 +237,22 @@ export function prepareSandbox({ workdir, network = 'on' }) {
227
237
  credentialsSource: source,
228
238
  env: {
229
239
  HOME: root,
230
- ...(process.platform === 'win32' ? {
231
- USERPROFILE: root,
232
- HOMEDRIVE: path.parse(root).root.replace(/[\\/]$/, ''),
233
- HOMEPATH: root.slice(path.parse(root).root.length - 1),
234
- APPDATA: path.join(root, 'AppData', 'Roaming'),
235
- LOCALAPPDATA: path.join(root, 'AppData', 'Local'),
236
- } : {}),
240
+ ...(process.platform === 'win32'
241
+ ? {
242
+ USERPROFILE: root,
243
+ HOMEDRIVE: path.parse(root).root.replace(/[\\/]$/, ''),
244
+ HOMEPATH: root.slice(path.parse(root).root.length - 1),
245
+ APPDATA: path.join(root, 'AppData', 'Roaming'),
246
+ LOCALAPPDATA: path.join(root, 'AppData', 'Local'),
247
+ }
248
+ : {}),
237
249
  },
238
250
  cleanup() {
239
- try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
251
+ try {
252
+ fs.rmSync(root, { recursive: true, force: true });
253
+ } catch {
254
+ /* best effort */
255
+ }
240
256
  },
241
257
  };
242
258
  }
@@ -63,11 +63,15 @@ export function buildInvocation({ schemaPath, consultant }) {
63
63
  // it was. Ours has to fire first for the failure to be classified honestly.
64
64
  const budget = timeoutMs(consultant);
65
65
  const args = [
66
- '--output-format', 'stream-json',
67
- '--json-schema', schemaPath,
66
+ '--output-format',
67
+ 'stream-json',
68
+ '--json-schema',
69
+ schemaPath,
68
70
  '--disable-slash-commands',
69
- '--model', chosen,
70
- '--print-timeout', `${Math.ceil((budget + 30_000) / 1000)}s`,
71
+ '--model',
72
+ chosen,
73
+ '--print-timeout',
74
+ `${Math.ceil((budget + 30_000) / 1000)}s`,
71
75
  ];
72
76
  return { command: t.cli, args, model: chosen };
73
77
  }
@@ -81,7 +85,9 @@ function events(stdout) {
81
85
  try {
82
86
  const parsed = JSON.parse(t);
83
87
  if (parsed && typeof parsed === 'object') out.push(parsed);
84
- } catch { /* a partial line, e.g. when the child was killed mid-write */ }
88
+ } catch {
89
+ /* a partial line, e.g. when the child was killed mid-write */
90
+ }
85
91
  }
86
92
  return out;
87
93
  }
@@ -122,7 +128,8 @@ export function toolCalls({ stdout }) {
122
128
  const seen = new Map();
123
129
  for (const e of events(stdout)) {
124
130
  const s = e.event === 'step_update' ? e.step_update : null;
125
- if (s?.tool_name) seen.set(s.step_index ?? seen.size, /error|fail|cancel|denied/i.test(s.state ?? '') ? `${s.tool_name}:error` : s.tool_name);
131
+ if (s?.tool_name)
132
+ seen.set(s.step_index ?? seen.size, /error|fail|cancel|denied/i.test(s.state ?? '') ? `${s.tool_name}:error` : s.tool_name);
126
133
  }
127
134
  return [...seen.values()].reduce((o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
128
135
  }
@@ -136,7 +143,11 @@ export function toolCalls({ stdout }) {
136
143
  export function settleAfter(line) {
137
144
  if (!line.includes('"result"') && !line.includes('"finish"')) return null;
138
145
  let e;
139
- try { e = JSON.parse(line); } catch { return null; }
146
+ try {
147
+ e = JSON.parse(line);
148
+ } catch {
149
+ return null;
150
+ }
140
151
  if (e?.event === 'result') return agyExitGraceMs().afterResult;
141
152
  if (e?.event === 'step_update' && e.step_update?.step_type === 'finish') return agyExitGraceMs().afterFinish;
142
153
  return null;
@@ -157,12 +168,20 @@ export function diagnostics({ run, sandbox }) {
157
168
  const tail = (s) => String(s ?? '').slice(-200_000);
158
169
  const dir = path.join(sandbox.root, '.gemini', 'antigravity-cli', 'log');
159
170
  let names = [];
160
- try { names = fs.readdirSync(dir).sort(); } catch { /* agy wrote no log */ }
171
+ try {
172
+ names = fs.readdirSync(dir).sort();
173
+ } catch {
174
+ /* agy wrote no log */
175
+ }
161
176
  return [
162
177
  `--- agy stderr ---\n${tail(run.stderr)}`,
163
178
  ...names.map((n) => {
164
179
  let text = '';
165
- try { text = fs.readFileSync(path.join(dir, n), 'utf8'); } catch { /* unreadable */ }
180
+ try {
181
+ text = fs.readFileSync(path.join(dir, n), 'utf8');
182
+ } catch {
183
+ /* unreadable */
184
+ }
166
185
  return `--- ${n} ---\n${tail(text)}`;
167
186
  }),
168
187
  ].join('\n');
@@ -171,14 +190,15 @@ export function diagnostics({ run, sandbox }) {
171
190
  export function interpret({ stdout, stderr, code }) {
172
191
  const payload = finalEnvelope(stdout);
173
192
  if (!payload) {
174
- const msg = (stderr || '').trim() || (stdout || '').trim().slice(0, 2000) ||
193
+ const msg =
194
+ (stderr || '').trim() ||
195
+ (stdout || '').trim().slice(0, 2000) ||
175
196
  `agy exited with code ${code} and produced no JSON envelope`;
176
197
  return { ok: false, failureKind: code === 0 ? 'invalid_output' : classifyMessage(msg), message: msg, usageRaw: null };
177
198
  }
178
199
  if (payload.status && payload.status !== 'SUCCESS') {
179
- const msg = typeof payload.error === 'string' && payload.error.trim()
180
- ? payload.error
181
- : JSON.stringify(payload).slice(0, 2000);
200
+ const msg =
201
+ typeof payload.error === 'string' && payload.error.trim() ? payload.error : JSON.stringify(payload).slice(0, 2000);
182
202
  return { ok: false, failureKind: classifyMessage(msg), message: msg, usageRaw: payload };
183
203
  }
184
204
  const structured = payload.structured_output ?? null;
@@ -204,9 +224,7 @@ export function usageRecord(payload) {
204
224
  turns: num(payload.num_turns),
205
225
  // Which permissions the sandbox refused. A denied read_url usually explains
206
226
  // a thin evidence basis, so it is worth keeping in the record.
207
- denied_actions: Array.isArray(payload.denied_actions)
208
- ? payload.denied_actions.map((d) => d?.action).filter(Boolean)
209
- : null,
227
+ denied_actions: Array.isArray(payload.denied_actions) ? payload.denied_actions.map((d) => d?.action).filter(Boolean) : null,
210
228
  };
211
229
  }
212
230
 
@@ -55,11 +55,35 @@ export const FORBIDDEN_FLAGS = [
55
55
  // (settings, agents, a replaced system prompt, an old session, an IDE or
56
56
  // browser link, a remote session).
57
57
  export const RESERVED_FLAGS = [
58
- '--print', '--allowed-tools', '--settings', '--agent', '--agents',
59
- '--system-prompt', '--system-prompt-file', '--append-system-prompt-file',
60
- '--continue', '-c', '--resume', '-r', '--session-id', '--fork-session', '--from-pr', '--teleport',
61
- '--input-format', '--file', '--worktree', '-w', '--tmux', '--background', '--bg',
62
- '--chrome', '--ide', '--remote-control', '--cloud', '--environment', '--desktop',
58
+ '--print',
59
+ '--allowed-tools',
60
+ '--settings',
61
+ '--agent',
62
+ '--agents',
63
+ '--system-prompt',
64
+ '--system-prompt-file',
65
+ '--append-system-prompt-file',
66
+ '--continue',
67
+ '-c',
68
+ '--resume',
69
+ '-r',
70
+ '--session-id',
71
+ '--fork-session',
72
+ '--from-pr',
73
+ '--teleport',
74
+ '--input-format',
75
+ '--file',
76
+ '--worktree',
77
+ '-w',
78
+ '--tmux',
79
+ '--background',
80
+ '--bg',
81
+ '--chrome',
82
+ '--ide',
83
+ '--remote-control',
84
+ '--cloud',
85
+ '--environment',
86
+ '--desktop',
63
87
  ];
64
88
 
65
89
  /** Read deny rules for the paths no consultant may read. */
@@ -69,7 +93,8 @@ export const posixAbsolute = (p) => p.replaceAll('\\', '/').replace(/^([A-Za-z])
69
93
 
70
94
  export function protectedReadRules() {
71
95
  const home = os.homedir().replaceAll('\\', '/');
72
- const spec = (root) => (root.startsWith(`${home}/`) ? `~/${root.slice(home.length + 1)}` : `//${posixAbsolute(root).replace(/^\/+/, '')}`);
96
+ const spec = (root) =>
97
+ root.startsWith(`${home}/`) ? `~/${root.slice(home.length + 1)}` : `//${posixAbsolute(root).replace(/^\/+/, '')}`;
73
98
  return [
74
99
  ...protectedRoots().flatMap((r) => [`Read(${spec(r)})`, `Read(${spec(r)}/**)`]),
75
100
  ...PROTECTED_FILE_NAMES.map((n) => `Read(//**/${n})`),
@@ -82,15 +107,20 @@ export function buildInvocation({ workdir, guardrails, consultant, hasExposedPat
82
107
  const web = network !== 'off';
83
108
  const args = [
84
109
  '-p',
85
- '--model', chosen,
86
- '--effort', consultant.effort ?? 'high',
110
+ '--model',
111
+ chosen,
112
+ '--effort',
113
+ consultant.effort ?? 'high',
87
114
  '--restricted',
88
115
  '--strict-mcp-config',
89
- '--setting-sources', '',
116
+ '--setting-sources',
117
+ '',
90
118
  '--disable-slash-commands',
91
- '--tools', web ? 'WebSearch,WebFetch,Read,Glob,Grep' : 'Read,Glob,Grep',
119
+ '--tools',
120
+ web ? 'WebSearch,WebFetch,Read,Glob,Grep' : 'Read,Glob,Grep',
92
121
  ...(web ? ['--allowedTools', 'WebSearch,WebFetch'] : []),
93
- '--settings', JSON.stringify({ permissions: { deny: protectedReadRules() } }),
122
+ '--settings',
123
+ JSON.stringify({ permissions: { deny: protectedReadRules() } }),
94
124
  // When the lead has exposed paths, they are already copied into the
95
125
  // working directory --restricted confines the file tools to, and the
96
126
  // lead has said what this consultation may see. Widening to the whole
@@ -98,13 +128,19 @@ export function buildInvocation({ workdir, guardrails, consultant, hasExposedPat
98
128
  // flag goes. Equal-reader parity is not lost: what every consultant now
99
129
  // has is the same copied tree, which is the material that exists.
100
130
  ...(hasExposedPaths ? [] : ['--add-dir', '/']),
101
- '--permission-prompts', 'none',
102
- '--permission-mode', 'manual',
131
+ '--permission-prompts',
132
+ 'none',
133
+ '--permission-mode',
134
+ 'manual',
103
135
  '--no-session-persistence',
104
- '--output-format', 'json',
105
- '--json-schema', JSON.stringify(CONSULT_RESULT_SCHEMA),
106
- '--max-budget-usd', String(consultant.maxBudgetUsd ?? 10),
107
- '--append-system-prompt', guardrails,
136
+ '--output-format',
137
+ 'json',
138
+ '--json-schema',
139
+ JSON.stringify(CONSULT_RESULT_SCHEMA),
140
+ '--max-budget-usd',
141
+ String(consultant.maxBudgetUsd ?? 10),
142
+ '--append-system-prompt',
143
+ guardrails,
108
144
  ];
109
145
  return { command: t.cli, args, model: chosen, workdir };
110
146
  }
@@ -117,7 +153,9 @@ function lastJsonObject(stdout) {
117
153
  try {
118
154
  const parsed = JSON.parse(t);
119
155
  if (parsed && typeof parsed === 'object') return parsed;
120
- } catch { /* not the result line */ }
156
+ } catch {
157
+ /* not the result line */
158
+ }
121
159
  }
122
160
  return null;
123
161
  }
@@ -125,9 +163,17 @@ function lastJsonObject(stdout) {
125
163
  export function interpret({ stdout, stderr, code }) {
126
164
  const payload = lastJsonObject(stdout);
127
165
  if (!payload) {
128
- const msg = (stderr || '').trim() || (stdout || '').trim().slice(0, 2000) ||
166
+ const msg =
167
+ (stderr || '').trim() ||
168
+ (stdout || '').trim().slice(0, 2000) ||
129
169
  `claude exited with code ${code} and produced no JSON result`;
130
- return { ok: false, failureKind: code === 0 ? 'invalid_output' : classifyMessage(msg), message: msg, payload: null, usageRaw: null };
170
+ return {
171
+ ok: false,
172
+ failureKind: code === 0 ? 'invalid_output' : classifyMessage(msg),
173
+ message: msg,
174
+ payload: null,
175
+ usageRaw: null,
176
+ };
131
177
  }
132
178
  if (payload.is_error) {
133
179
  const msg = typeof payload.result === 'string' ? payload.result : JSON.stringify(payload).slice(0, 2000);
@@ -136,7 +182,13 @@ export function interpret({ stdout, stderr, code }) {
136
182
  const structured = payload.structured_output ?? payload.structuredOutput ?? null;
137
183
  const text = structured ?? (typeof payload.result === 'string' ? payload.result : null);
138
184
  if (!text) {
139
- return { ok: false, failureKind: 'invalid_output', message: 'result payload contained no answer', payload, usageRaw: payload };
185
+ return {
186
+ ok: false,
187
+ failureKind: 'invalid_output',
188
+ message: 'result payload contained no answer',
189
+ payload,
190
+ usageRaw: payload,
191
+ };
140
192
  }
141
193
  return { ok: true, text, payload, usageRaw: payload, denials: payload.permission_denials ?? [] };
142
194
  }