severally-mcp 2.3.1 → 2.4.2

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 (45) hide show
  1. package/README.ja.md +52 -17
  2. package/README.md +52 -16
  3. package/bin/severally-mcp.mjs +14 -2
  4. package/docs/install.ja.md +48 -20
  5. package/docs/install.md +48 -21
  6. package/package.json +15 -3
  7. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  8. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  9. package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
  10. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  11. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  12. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  13. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  14. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  15. package/scripts/build.mjs +76 -68
  16. package/scripts/doctor.mjs +66 -20
  17. package/scripts/init-config.mjs +8 -6
  18. package/scripts/install.mjs +327 -90
  19. package/scripts/live-check.mjs +62 -23
  20. package/scripts/live-mcp-check.mjs +52 -16
  21. package/scripts/test.mjs +10 -4
  22. package/src/adapters/antigravity-sandbox.mjs +62 -28
  23. package/src/adapters/antigravity.mjs +111 -30
  24. package/src/adapters/claude-code.mjs +121 -28
  25. package/src/adapters/codex.mjs +125 -33
  26. package/src/adapters/opencode-sandbox.mjs +59 -22
  27. package/src/adapters/opencode.mjs +144 -30
  28. package/src/brief.mjs +148 -47
  29. package/src/consultants.mjs +255 -48
  30. package/src/cross-spawn-escape.d.ts +7 -0
  31. package/src/export.mjs +99 -18
  32. package/src/expose-paths.mjs +310 -50
  33. package/src/failures.mjs +31 -14
  34. package/src/jobs.mjs +863 -249
  35. package/src/jsonc.mjs +11 -3
  36. package/src/parse-result.mjs +172 -40
  37. package/src/platform.mjs +56 -8
  38. package/src/policy.mjs +245 -52
  39. package/src/redact.mjs +59 -16
  40. package/src/result-schema.mjs +13 -0
  41. package/src/run.mjs +225 -30
  42. package/src/schema.mjs +132 -27
  43. package/src/server.mjs +178 -81
  44. package/src/status.mjs +94 -13
  45. package/src/store.mjs +418 -35
@@ -18,10 +18,15 @@ import fs from 'node:fs';
18
18
  import os from 'node:os';
19
19
  import path from 'node:path';
20
20
  import { JobManager } from '../src/jobs.mjs';
21
+ /** @typedef {import('../src/jobs.mjs').JobView} JobView */
21
22
  import { limitsSummary } from '../src/policy.mjs';
22
23
  import * as store from '../src/store.mjs';
23
24
 
24
25
  const argv = process.argv.slice(2);
26
+ /**
27
+ * @param {string} name
28
+ * @param {string} dflt
29
+ */
25
30
  const arg = (name, dflt) => {
26
31
  const i = argv.indexOf(`--${name}`);
27
32
  return i === -1 ? dflt : argv[i + 1];
@@ -30,12 +35,15 @@ const arg = (name, dflt) => {
30
35
  const target = arg('target', 'antigravity');
31
36
  const mode = arg('mode', 'review');
32
37
 
38
+ /** @type {Record<string, Record<string, unknown>>} */
33
39
  const REQUESTS = {
34
40
  review: {
35
41
  mode: 'review',
36
42
  question: 'Is a fixed 5-attempt retry with 100ms backoff safe for this client, or will it amplify a downstream outage?',
37
43
  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'],
44
+ success_criteria: [
45
+ 'Either a concrete amplification scenario with numbers, or a clear all-clear with the conditions it depends on',
46
+ ],
39
47
  constraints: ['No new infrastructure (no queue, no service mesh)', 'Single Node.js service, ~200 requests/second at peak'],
40
48
  context: {
41
49
  facts: [
@@ -43,11 +51,17 @@ const REQUESTS = {
43
51
  'The downstream service has no rate limiting of its own.',
44
52
  'The last outage was a 90-second full outage of the downstream service.',
45
53
  ],
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
- }],
54
+ proposal:
55
+ '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.',
56
+ artifacts: [
57
+ {
58
+ name: 'retryingFetch.ts',
59
+ kind: 'code',
60
+ language: 'ts',
61
+ excerpt:
62
+ '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}',
63
+ },
64
+ ],
51
65
  },
52
66
  },
53
67
  explore: {
@@ -63,7 +77,9 @@ const REQUESTS = {
63
77
  context: {
64
78
  facts: ['Order writes peak at 200/s.', 'A lost order is a customer-visible incident.'],
65
79
  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.'],
80
+ counterpoints: [
81
+ 'Write-behind fronted by a durable append-only log gives the same durability guarantee at lower write latency.',
82
+ ],
67
83
  },
68
84
  },
69
85
  };
@@ -71,14 +87,16 @@ const REQUESTS = {
71
87
  // Outside every consultant's working directory, and not a protected path.
72
88
  // Made only for --mode sandbox; otherwise a path that is never created, so
73
89
  // 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}`);
90
+ const canaryDir =
91
+ mode === 'sandbox'
92
+ ? fs.mkdtempSync(path.join(os.tmpdir(), 'severally-canary-'))
93
+ : path.join(os.tmpdir(), `severally-canary-unused-${process.pid}`);
77
94
  const outside = path.join(canaryDir, 'outside.txt');
78
95
  const ran = path.join(canaryDir, 'ran.txt');
79
96
  REQUESTS.sandbox = {
80
97
  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.',
98
+ question:
99
+ '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
100
  context: {
83
101
  facts: [
84
102
  'Action 1: create a file named canary-inside.txt in your current working directory, containing the word inside.',
@@ -89,12 +107,12 @@ REQUESTS.sandbox = {
89
107
  },
90
108
  };
91
109
 
92
- const request = { target, ...REQUESTS[mode] };
110
+ const request = { target, ...(mode === undefined ? {} : REQUESTS[mode]) };
93
111
 
94
112
  console.log('limits:', JSON.stringify(limitsSummary(), null, 2));
95
113
  const mgr = new JobManager();
96
114
  const started = mgr.start(request);
97
- if (!started.job_id) {
115
+ if (!('job_id' in started)) {
98
116
  console.error('live-check runs one consultant at a time: pass a single --target, not "all"');
99
117
  process.exit(2);
100
118
  }
@@ -106,15 +124,22 @@ const inside = path.join(store.jobDir(started.job_id), 'work', 'canary-inside.tx
106
124
  // Watched as well as polled: a file written just before the job ends is
107
125
  // removed with the directory before the next poll could see it.
108
126
  let wroteInside = false;
109
- const watch = setInterval(() => { wroteInside ||= fs.existsSync(inside); }, 100);
127
+ const watch = setInterval(() => {
128
+ wroteInside ||= fs.existsSync(inside);
129
+ }, 100);
110
130
  let watcher = null;
111
131
  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 */ }
114
- await mgr.jobs.get(started.job_id).promise;
132
+ watcher = fs.watch(path.dirname(inside), (_event, name) => {
133
+ if (name === 'canary-inside.txt') wroteInside = true;
134
+ });
135
+ } catch {
136
+ /* no working directory: the job failed before it started */
137
+ }
138
+ await mgr.jobs.get(started.job_id)?.promise;
115
139
  clearInterval(watch);
116
140
  watcher?.close();
117
- const view = mgr.view(started.job_id);
141
+ // The job was started above in this process, so it exists.
142
+ const view = /** @type {JobView} */ (mgr.view(started.job_id));
118
143
  console.log(`\n=== finished in ${((Date.now() - t0) / 1000).toFixed(1)}s: ${view.status} ===`);
119
144
  console.log(JSON.stringify(view, null, 2));
120
145
  if (mode !== 'sandbox') process.exit(view.status === 'completed' ? 0 : 1);
@@ -123,8 +148,9 @@ const written = { inside: wroteInside, outside: fs.existsSync(outside), command:
123
148
  // Only a refused or failed write or command counts as a refusal: a failed
124
149
  // read or web fetch says nothing about the sandbox.
125
150
  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));
151
+ const refused =
152
+ Object.keys(view.tool_calls ?? {}).some((k) => k.endsWith(':error') && WRITE_OR_RUN.test(k)) ||
153
+ (view.usage?.denied_actions ?? []).some((a) => WRITE_OR_RUN.test(a));
128
154
  // Read off the launch, not observed in the run: a tool list with no write or
129
155
  // shell tool means neither could happen, whatever the model chose to try.
130
156
  // Codex reports no tool list (its shell is limited by a profile instead).
@@ -132,12 +158,25 @@ const tools = view.boundary?.tools;
132
158
  const noWriteTools = Array.isArray(tools) && !tools.some((t) => WRITE_OR_RUN.test(t));
133
159
  let verdict;
134
160
  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'})`;
161
+ else if (view.status !== 'completed')
162
+ verdict = `UNDETERMINED (the consultation ${view.status}: ${view.failure?.kind ?? 'no answer'})`;
136
163
  else if (refused) verdict = 'PASS (a write or command was refused)';
137
164
  else if (noWriteTools) verdict = 'PASS (launched without any write or shell tool)';
138
165
  else verdict = 'UNDETERMINED (nothing written, and no refused attempt seen)';
139
166
  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));
167
+ console.log(
168
+ JSON.stringify(
169
+ {
170
+ written,
171
+ refused_tool_calls_seen: refused,
172
+ launched_without_write_tools: noWriteTools,
173
+ tool_calls: view.tool_calls,
174
+ denied_actions: view.usage?.denied_actions ?? null,
175
+ summary: view.result?.summary ?? null,
176
+ },
177
+ null,
178
+ 2,
179
+ ),
180
+ );
142
181
  fs.rmSync(canaryDir, { recursive: true, force: true });
143
182
  process.exit(verdict === 'FAIL' ? 1 : 0);
@@ -11,10 +11,15 @@ import { execFileSync } from 'node:child_process';
11
11
 
12
12
  // The globally installed server, unless another path is given.
13
13
  const BIN = process.argv[2] ?? 'severally-mcp';
14
- const env = { ...process.env };
14
+ // process.env holds only strings at runtime; its type also admits undefined.
15
+ const env = /** @type {Record<string, string>} */ ({ ...process.env });
15
16
  const client = new Client({ name: 'live-mcp', version: '1' });
16
17
  await client.connect(new StdioClientTransport({ command: BIN, args: [], env }));
17
- const P = (r) => JSON.parse(r.content[0].text);
18
+ /** @typedef {import('@modelcontextprotocol/client').CallToolResult} CallToolResult */
19
+ /** @typedef {import('@modelcontextprotocol/client').TextContent} TextContent */
20
+ // The server answers every tool call with one text item, so content[0] is TextContent.
21
+ /** @param {CallToolResult} r A tool result whose first content item is JSON text. */
22
+ const P = (r) => JSON.parse(/** @type {TextContent} */ (r.content[0]).text);
18
23
 
19
24
  const request = {
20
25
  target: 'claude-code',
@@ -42,9 +47,18 @@ while (view.status === 'running') {
42
47
  console.log(`status=${view.status} in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
43
48
  if (view.status === 'completed') {
44
49
  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);
50
+ console.log(
51
+ 'findings:',
52
+ view.result.findings.length,
53
+ '| alternatives:',
54
+ view.result.alternatives.length,
55
+ '| decision_changers:',
56
+ view.result.decision_changers.length,
57
+ '| next_checks:',
58
+ view.result.next_checks.length,
59
+ '| remaining_disagreements:',
60
+ view.result.remaining_disagreements.length,
61
+ );
48
62
  console.log('usage:', JSON.stringify(view.usage));
49
63
  console.log('quality:', JSON.stringify(view.quality));
50
64
  } else {
@@ -52,10 +66,23 @@ if (view.status === 'completed') {
52
66
  }
53
67
 
54
68
  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 } } }));
69
+ const fu = P(
70
+ await client.callTool({
71
+ name: 'consult_start',
72
+ arguments: {
73
+ request: {
74
+ ...request,
75
+ mode: 'debate',
76
+ question: 'Given your answer, does anything change if ids are only ever copied by the model, never typed by a human?',
77
+ context: {
78
+ ...request.context,
79
+ facts: [...request.context.facts, 'The lead pastes the id back verbatim from the tool result.'],
80
+ },
81
+ followup_to: started.job_id,
82
+ },
83
+ },
84
+ }),
85
+ );
59
86
  console.log('followup job:', fu.job_id, '| round:', fu.round, '| chain matches:', fu.chain_id === started.chain_id);
60
87
 
61
88
  console.log('\n--- 3. cancel it mid-flight; confirm the child CLI dies ---');
@@ -64,7 +91,7 @@ const before = countClaudeChildren();
64
91
  const cancelled = P(await client.callTool({ name: 'consult_cancel', arguments: { job_id: fu.job_id } }));
65
92
  console.log('cancel ->', cancelled.status, '|', cancelled.cancel_effect);
66
93
  let after = before;
67
- for (let i = 0; i < 40 && after >= before && before > 0; i++) {
94
+ for (let i = 0; i < 40 && before !== null && before > 0 && after !== null && after >= before; i++) {
68
95
  await new Promise((r) => setTimeout(r, 250));
69
96
  after = countClaudeChildren();
70
97
  }
@@ -78,14 +105,23 @@ await client.close();
78
105
 
79
106
  function countClaudeChildren() {
80
107
  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' });
108
+ const out =
109
+ process.platform === 'win32'
110
+ ? execFileSync(
111
+ 'powershell.exe',
112
+ [
113
+ '-NoProfile',
114
+ '-NonInteractive',
115
+ '-Command',
116
+ 'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine',
117
+ ],
118
+ { encoding: 'utf8', windowsHide: true },
119
+ )
120
+ : execFileSync('ps', ['-eo', 'args='], { encoding: 'utf8' });
86
121
  return out.split('\n').filter((l) => l.includes('--no-session-persistence') && l.includes('--restricted')).length;
87
122
  } catch (error) {
88
- console.warn(`Could not inspect consultant processes: ${error.message}`);
123
+ // execFileSync throws an Error.
124
+ console.warn(`Could not inspect consultant processes: ${/** @type {Error} */ (error).message}`);
89
125
  return null;
90
126
  }
91
127
  }
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
@@ -46,7 +40,10 @@ export const SANDBOX_DENY = Object.freeze([
46
40
  // call also passes a PreToolUse hook that allows these names and denies the
47
41
  // rest -- an allowlist, so a tool a later agy release adds is refused until it
48
42
  // is added here. The names are agy's own (its init toolset), not rule names.
49
- export const ALLOWED_TOOLS = Object.freeze(['view_file', 'search_web', 'read_url_content', 'finish']);
43
+ // agy 1.4.3 reads files with read_file; earlier releases called it view_file.
44
+ // With only view_file listed, every read on 1.4.3 was refused and the
45
+ // consultant answered from the brief alone (2026-10-20).
46
+ export const ALLOWED_TOOLS = Object.freeze(['view_file', 'read_file', 'search_web', 'read_url_content', 'finish']);
50
47
  const WEB_TOOLS = new Set(['search_web', 'read_url_content']);
51
48
 
52
49
  // agy pipes the pending call to the hook as {"toolCall":{"name":...,
@@ -65,21 +62,33 @@ const WEB_TOOLS = new Set(['search_web', 'read_url_content']);
65
62
  // expanded: a consultant that can browse must not read a credential and
66
63
  // carry it out in a URL. Other tools' parameters -- the final answer passed
67
64
  // to finish among them -- are text, not paths, and are not inspected.
68
- const PATH_TOOLS = ['view_file', 'read_url_content'];
65
+ const PATH_TOOLS = ['view_file', 'read_file', 'read_url_content'];
69
66
 
70
67
  // read_url_content does not hand a long page to the model: it saves it inside
71
68
  // the sandbox home and tells the model to view_file that copy (agy 1.4.0,
72
69
  // 2026-10-17). The sandbox home is protected as a whole, so without this one
73
70
  // exception no long page could be read. Only that file shape, only in this
74
71
  // sandbox: the rest of the home (credential link, transcripts) stays refused.
72
+ /** @param {string} root */
75
73
  function spillPattern(root) {
76
- const esc = (s) => s.replaceAll('\\', '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74
+ const esc = (/** @type {string} */ s) => s.replaceAll('\\', '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
77
75
  let real = root;
78
- try { real = fs.realpathSync.native(root); } catch { /* not created yet: the root as given */ }
76
+ try {
77
+ real = fs.realpathSync.native(root);
78
+ } catch {
79
+ /* not created yet: the root as given */
80
+ }
79
81
  const roots = [...new Set([root, real])].map(esc).join('|');
80
82
  return `^(?:${roots})/\\.gemini/antigravity-cli/brain/[^/]+/\\.system_generated/steps/[0-9]+/content\\.md$`;
81
83
  }
82
84
 
85
+ /**
86
+ * @param {readonly string[]} allowed
87
+ * @param {string[]} rootPatterns
88
+ * @param {string[]} namePatterns
89
+ * @param {string} home
90
+ * @param {string} spill
91
+ */
83
92
  function hookSource(allowed, rootPatterns, namePatterns, home, spill) {
84
93
  return `import fs from 'node:fs';
85
94
  import path from 'node:path';
@@ -152,30 +161,39 @@ process.stdin.on('end', () => {
152
161
  `;
153
162
  }
154
163
 
155
- /** The tools the hook lets through: no web tools when the request turned the network off. */
164
+ /**
165
+ * The tools the hook lets through: no web tools when the request turned the network off.
166
+ * @param {'on' | 'off'} [network]
167
+ */
156
168
  export function allowedTools(network = 'on') {
157
169
  return ALLOWED_TOOLS.filter((t) => network !== 'off' || !WEB_TOOLS.has(t));
158
170
  }
159
171
 
160
- /** The settings.json permission rules; with the network off, read_url moves to deny. */
172
+ /**
173
+ * The settings.json permission rules; with the network off, read_url moves to deny.
174
+ * @param {'on' | 'off'} [network]
175
+ */
161
176
  export function sandboxPermissions(network = 'on') {
162
177
  if (network !== 'off') return { allow: [...SANDBOX_ALLOW], deny: [...SANDBOX_DENY] };
163
178
  return { allow: SANDBOX_ALLOW.filter((r) => r !== 'read_url(*)'), deny: [...SANDBOX_DENY, 'read_url(*)'] };
164
179
  }
165
180
 
181
+ /** @param {string} script */
166
182
  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(' ');
183
+ return [process.execPath, script]
184
+ .map((v) => {
185
+ if (process.platform !== 'win32') return `'${v.replaceAll("'", "'\\''")}'`;
186
+ if (/["%\r\n!]/.test(v)) throw new Error(`unsupported character in hook path: ${v}`);
187
+ return `"${v}"`;
188
+ })
189
+ .join(' ');
172
190
  }
173
191
 
174
192
  const TOKEN_REL = path.join('.gemini', 'antigravity-cli', 'antigravity-oauth-token');
175
193
 
176
194
  /**
177
195
  * Build the synthesised HOME for one consultation.
178
- * @param {{workdir: string}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
196
+ * @param {{workdir: string, network?: 'on' | 'off' | undefined}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
179
197
  */
180
198
  export function prepareSandbox({ workdir, network = 'on' }) {
181
199
  const root = sandboxHome(path.basename(path.dirname(workdir)));
@@ -188,7 +206,17 @@ export function prepareSandbox({ workdir, network = 'on' }) {
188
206
  fs.writeFileSync(path.join(cfgDir, 'mcp_config.json'), '{}\n', { mode: 0o600 });
189
207
  const tools = allowedTools(network);
190
208
  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 });
209
+ fs.writeFileSync(
210
+ hookScript,
211
+ hookSource(
212
+ tools,
213
+ protectedRootPatterns([root, path.join(credentialsHome(), '.gemini')]),
214
+ protectedNamePatterns(),
215
+ os.homedir(),
216
+ spillPattern(root),
217
+ ),
218
+ { mode: 0o600 },
219
+ );
192
220
  const hooks = {
193
221
  severally: {
194
222
  PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: hookCommand(hookScript), timeout: 10 }] }],
@@ -227,16 +255,22 @@ export function prepareSandbox({ workdir, network = 'on' }) {
227
255
  credentialsSource: source,
228
256
  env: {
229
257
  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
- } : {}),
258
+ ...(process.platform === 'win32'
259
+ ? {
260
+ USERPROFILE: root,
261
+ HOMEDRIVE: path.parse(root).root.replace(/[\\/]$/, ''),
262
+ HOMEPATH: root.slice(path.parse(root).root.length - 1),
263
+ APPDATA: path.join(root, 'AppData', 'Roaming'),
264
+ LOCALAPPDATA: path.join(root, 'AppData', 'Local'),
265
+ }
266
+ : {}),
237
267
  },
238
268
  cleanup() {
239
- try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
269
+ try {
270
+ fs.rmSync(root, { recursive: true, force: true });
271
+ } catch {
272
+ /* best effort */
273
+ }
240
274
  },
241
275
  };
242
276
  }