@heretyc/subagent-mcp 2.12.8 → 2.12.9

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.
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { homedir, platform } from "node:os";
3
+ import { homedir, platform, userInfo } from "node:os";
4
4
  import { dirname, join, parse as parsePath, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { CONCURRENCY_SCAFFOLD } from "./config-scaffold.js";
@@ -11,6 +11,7 @@ export const DEFAULT_CHECK_FOR_UPDATES = true;
11
11
  export const DEFAULT_PERMISSIONS_CEILING = "auto";
12
12
  export const DEFAULT_ESCALATION = "irreversible-only";
13
13
  export const DEFAULT_STRICT_READ_PARITY = "warn";
14
+ export const DEFAULT_SANDBOX_NETWORK = false;
14
15
  export const CONFIG_FILENAME = "global-subagent-mcp-config.jsonc";
15
16
  export const LEGACY_CONFIG_FILENAME = "global-concurrency.jsonc";
16
17
  export { cullStaleSlots, ZOMBIE_FORCE_GRACE_MS, ZOMBIE_LIVE_IDLE_MS, ZOMBIE_TERMINAL_IDLE_MS, buildProcessTreeKillCommands, drainZombieIntents, drainZombieReports, parseSlotMetadata, readSlotMetadata, slotPathForAgent, writeSlotMetadata, };
@@ -83,6 +84,15 @@ export function parseStrictReadParityConfig(text) {
83
84
  return DEFAULT_STRICT_READ_PARITY;
84
85
  }
85
86
  }
87
+ export function parseSandboxNetworkConfig(text) {
88
+ try {
89
+ const raw = parseJsonObject(text).sandboxNetwork;
90
+ return typeof raw === "boolean" ? raw : DEFAULT_SANDBOX_NETWORK;
91
+ }
92
+ catch {
93
+ return DEFAULT_SANDBOX_NETWORK;
94
+ }
95
+ }
86
96
  export function defaultConfigPath() {
87
97
  return fileURLToPath(new URL("./" + CONFIG_FILENAME, import.meta.url));
88
98
  }
@@ -118,6 +128,7 @@ export function readGlobalConfig(path = defaultConfigPath()) {
118
128
  permissionsCeiling: parsePermissionsCeilingConfig(text),
119
129
  escalation: parseEscalationConfig(text),
120
130
  strictReadParity: parseStrictReadParityConfig(text),
131
+ sandboxNetwork: parseSandboxNetworkConfig(text),
121
132
  path: resolved.path,
122
133
  usedLegacyPath: resolved.usedLegacyPath,
123
134
  parseFailure: null,
@@ -130,6 +141,7 @@ export function readGlobalConfig(path = defaultConfigPath()) {
130
141
  permissionsCeiling: "manual",
131
142
  escalation: DEFAULT_ESCALATION,
132
143
  strictReadParity: DEFAULT_STRICT_READ_PARITY,
144
+ sandboxNetwork: DEFAULT_SANDBOX_NETWORK,
133
145
  path,
134
146
  usedLegacyPath: false,
135
147
  parseFailure: e instanceof Error ? e.message : String(e),
@@ -151,6 +163,9 @@ export function readEscalation(path = defaultConfigPath()) {
151
163
  export function readStrictReadParity(path = defaultConfigPath()) {
152
164
  return readGlobalConfig(path).strictReadParity;
153
165
  }
166
+ export function readSandboxNetwork(path = defaultConfigPath()) {
167
+ return readGlobalConfig(path).sandboxNetwork;
168
+ }
154
169
  let legacyConfigDeprecationPending = false;
155
170
  export function noteLegacyConfigIfUsed(path = defaultConfigPath()) {
156
171
  if (resolveGlobalConfigPath(path).usedLegacyPath)
@@ -208,7 +223,7 @@ function readClaudePermissions(source, path, userScoped) {
208
223
  }
209
224
  const disableBypass = userScoped && parsed.disableBypassPermissionsMode === "disable";
210
225
  return {
211
- rules: { allow, deny, ask, additionalDirectories },
226
+ rules: { allow, deny, ask, additionalDirectories, sandboxNetwork: false },
212
227
  disableBypass,
213
228
  failure: null,
214
229
  };
@@ -227,6 +242,7 @@ function blanketMutatingAskRules() {
227
242
  deny: [],
228
243
  ask: ["Bash", "Edit", "Write", "NotebookEdit", "MultiEdit"],
229
244
  additionalDirectories: [],
245
+ sandboxNetwork: false,
230
246
  };
231
247
  }
232
248
  function mergeRules(target, next) {
@@ -234,6 +250,7 @@ function mergeRules(target, next) {
234
250
  target.deny.push(...next.deny);
235
251
  target.ask.push(...next.ask);
236
252
  target.additionalDirectories.push(...next.additionalDirectories);
253
+ target.sandboxNetwork ||= next.sandboxNetwork;
237
254
  }
238
255
  function unique(items) {
239
256
  return [...new Set(items)];
@@ -274,10 +291,14 @@ function readCodexConfig(path) {
274
291
  try {
275
292
  const text = readFileSync(path, "utf8");
276
293
  assertBasicTomlParsable(text);
277
- const rules = { allow: [], deny: [], ask: [], additionalDirectories: [] };
294
+ const rules = { allow: [], deny: [], ask: [], additionalDirectories: [], sandboxNetwork: false };
278
295
  if (codexTomlValue(text, "sandbox_mode") === "read-only") {
279
296
  rules.deny.push("Edit", "Write", "NotebookEdit");
280
297
  }
298
+ if (/\bsandbox_workspace_write\.network_access\s*=\s*true\b/i.test(text) ||
299
+ /^\s*\[sandbox_workspace_write\][\s\S]*?^\s*network_access\s*=\s*true\b/im.test(text)) {
300
+ rules.sandboxNetwork = true;
301
+ }
281
302
  rules.additionalDirectories.push(...codexTomlArray(text, "writable_roots"));
282
303
  for (const m of text.matchAll(/^\s*path\s*=\s*["']([^"']+)["']\s*\r?\n\s*access\s*=\s*["']deny["']/gm)) {
283
304
  rules.deny.push(`Edit(${m[1]})`, `Write(${m[1]})`);
@@ -285,6 +306,10 @@ function readCodexConfig(path) {
285
306
  for (const m of text.matchAll(/^\s*domain\s*=\s*["']([^"']+)["']\s*\r?\n\s*access\s*=\s*["']deny["']/gm)) {
286
307
  rules.deny.push(`WebFetch(domain:${m[1]})`);
287
308
  }
309
+ for (const m of text.matchAll(/^\s*domain\s*=\s*["']([^"']+)["']\s*\r?\n\s*access\s*=\s*["']allow["']/gm)) {
310
+ rules.allow.push(`WebFetch(domain:${m[1]})`);
311
+ rules.sandboxNetwork = true;
312
+ }
288
313
  return { rules, failure: null };
289
314
  }
290
315
  catch (e) {
@@ -327,7 +352,13 @@ const firstRepoDigests = new Map();
327
352
  export function readMergedPermissionConfig(cwd, path = defaultConfigPath()) {
328
353
  const global = readGlobalConfig(path);
329
354
  noteLegacyConfigIfUsed(path);
330
- const merged = { allow: [], deny: [], ask: [], additionalDirectories: [] };
355
+ const merged = {
356
+ allow: [],
357
+ deny: [],
358
+ ask: [],
359
+ additionalDirectories: [],
360
+ sandboxNetwork: global.sandboxNetwork,
361
+ };
331
362
  const failures = [];
332
363
  let ceiling = global.permissionsCeiling;
333
364
  if (global.parseFailure) {
@@ -377,6 +408,7 @@ export function readMergedPermissionConfig(cwd, path = defaultConfigPath()) {
377
408
  deny: unique(merged.deny),
378
409
  ask: unique(merged.ask),
379
410
  additionalDirectories: unique(merged.additionalDirectories).filter((p) => !protectedDirs.has(resolve(p))),
411
+ sandboxNetwork: merged.sandboxNetwork,
380
412
  permissionsCeiling: ceiling,
381
413
  escalation: global.escalation,
382
414
  strictReadParity: global.strictReadParity,
@@ -385,7 +417,24 @@ export function readMergedPermissionConfig(cwd, path = defaultConfigPath()) {
385
417
  selfProtectionDeny,
386
418
  };
387
419
  }
388
- export function slotDir() {
420
+ function safeSlotNamespace(raw) {
421
+ const cleaned = raw.replace(/[^A-Za-z0-9_.-]/g, "_");
422
+ return cleaned || createHash("sha256").update(raw || "unknown").digest("hex").slice(0, 16);
423
+ }
424
+ export function currentUserSlotNamespace() {
425
+ try {
426
+ const info = userInfo();
427
+ if (platform() !== "win32" && Number.isInteger(info.uid))
428
+ return `uid-${info.uid}`;
429
+ return safeSlotNamespace(info.username);
430
+ }
431
+ catch { }
432
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
433
+ if (typeof uid === "number")
434
+ return `uid-${uid}`;
435
+ return safeSlotNamespace(process.env.USERNAME || process.env.USER || "unknown");
436
+ }
437
+ export function slotBaseDir() {
389
438
  if (process.env.SUBAGENT_SLOT_DIR)
390
439
  return process.env.SUBAGENT_SLOT_DIR;
391
440
  if (platform() === "win32") {
@@ -393,6 +442,9 @@ export function slotDir() {
393
442
  }
394
443
  return "/tmp/subagent-mcp/slots";
395
444
  }
445
+ export function slotDir() {
446
+ return join(slotBaseDir(), currentUserSlotNamespace());
447
+ }
396
448
  export function countSlots(dir = slotDir()) {
397
449
  try {
398
450
  return readdirSync(dir).filter((f) => f.startsWith("slot-")).length;
@@ -410,7 +462,13 @@ export const NONBLOCKING_CULL_DEPS = {
410
462
  };
411
463
  export function reserveSlot(agentId, max, dir = slotDir(), cullDeps) {
412
464
  try {
413
- mkdirSync(dir, { recursive: true, mode: 0o1777 });
465
+ if (dir === slotDir()) {
466
+ mkdirSync(slotBaseDir(), { recursive: true, mode: 0o1777 });
467
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
468
+ }
469
+ else {
470
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
471
+ }
414
472
  cullStaleSlots(dir, cullDeps);
415
473
  const slotPath = slotPathForAgent(dir, agentId);
416
474
  const before = countSlots(dir);
@@ -1,2 +1,2 @@
1
1
  // GENERATED by scripts/gen-ruleset-scaffold.mjs from src/global-subagent-mcp-config.jsonc — DO NOT EDIT.
2
- export const CONCURRENCY_SCAFFOLD = "// subagent-mcp - Global Subagent MCP Config\n// ------------------------------------------------------------------\n// SOLE source of truth for machine-wide subagent-mcp defaults that must be\n// available beside the compiled server.\n//\n// globalConcurrentSubagents controls how many subagents may be ALIVE AT ONCE\n// across EVERY session, process, and user on this machine. There is NO\n// environment-variable override for the cap.\n//\n// RE-READ on every launch_agent call - edits take effect immediately, no\n// server restart required.\n//\n// Cap value rules:\n// - missing / unset / non-integer / 0 / negative -> reset to default 20\n// - 1 through 9 -> forced UP to minimum 10\n// - 10 or greater -> used as-is\n//\n// checkForUpdates controls the silent npmjs update check started when the MCP\n// server connects. Default true. Set to false to skip the registry fetch and\n// suppress hook notices. SUBAGENT_UPDATE_CHECK=0 or false also disables it.\n//\n// permissionsCeiling controls launch-time permissions posture:\n// - auto (default): shared engine gates unsafe/residue actions\n// - manual: human approval for residue while still auto-denying DANGER\n// - yolo: preserve the historical bypass/danger-full-access path, except\n// for non-bypassable config-file self-protection\n//\n// escalation applies only in auto mode. irreversible-only routes irreversible\n// NEUTRAL residue to a human; off leaves residue to orchestrator judgment.\n//\n// strictReadParity controls logging only. Unparseable Codex approval payloads\n// always fail closed to ask; warn logs the construct, off silences that log.\n{\n \"globalConcurrentSubagents\": 20,\n \"checkForUpdates\": true,\n \"permissionsCeiling\": \"auto\",\n \"escalation\": \"irreversible-only\",\n \"strictReadParity\": \"warn\"\n}\n";
2
+ export const CONCURRENCY_SCAFFOLD = "// subagent-mcp - Global Subagent MCP Config\n// ------------------------------------------------------------------\n// SOLE source of truth for machine-wide subagent-mcp defaults that must be\n// available beside the compiled server.\n//\n// globalConcurrentSubagents controls how many subagents may be ALIVE AT ONCE\n// across EVERY session, process, and user on this machine. There is NO\n// environment-variable override for the cap.\n//\n// RE-READ on every launch_agent call - edits take effect immediately, no\n// server restart required.\n//\n// Cap value rules:\n// - missing / unset / non-integer / 0 / negative -> reset to default 20\n// - 1 through 9 -> forced UP to minimum 10\n// - 10 or greater -> used as-is\n//\n// checkForUpdates controls the silent npmjs update check started when the MCP\n// server connects. Default true. Set to false to skip the registry fetch and\n// suppress hook notices. SUBAGENT_UPDATE_CHECK=0 or false also disables it.\n//\n// permissionsCeiling controls launch-time permissions posture:\n// - auto (default): shared engine gates unsafe/residue actions\n// - manual: human approval for residue while still auto-denying DANGER\n// - yolo: preserve the historical bypass/danger-full-access path, except\n// for non-bypassable config-file self-protection\n//\n// escalation applies only in auto mode. irreversible-only routes irreversible\n// NEUTRAL residue to a human; off leaves residue to orchestrator judgment.\n//\n// strictReadParity controls logging only. Unparseable Codex approval payloads\n// always fail closed to ask; warn logs the construct, off silences that log.\n//\n// sandboxNetwork controls Codex workspace-write network access. Default false.\n// Set true only when sub-agents need network after permission approval.\n{\n \"globalConcurrentSubagents\": 20,\n \"checkForUpdates\": true,\n \"permissionsCeiling\": \"auto\",\n \"escalation\": \"irreversible-only\",\n \"strictReadParity\": \"warn\",\n \"sandboxNetwork\": false\n}\n";
package/dist/drivers.js CHANGED
@@ -217,9 +217,29 @@ function permissionSnapshotForLaunch(options) {
217
217
  additionalDirectories: merged.additionalDirectories,
218
218
  repoConfigChangedSinceFirstSeen: merged.repoConfigChangedSinceFirstSeen,
219
219
  strictReadParity: merged.strictReadParity,
220
+ sandboxNetwork: merged.sandboxNetwork,
220
221
  };
221
222
  return snapshot;
222
223
  }
224
+ export function providerChildSpawnOptions(options, p = process.platform) {
225
+ return {
226
+ cwd: options.cwd,
227
+ env: options.env,
228
+ stdio: ["pipe", "pipe", "pipe"],
229
+ windowsHide: true,
230
+ detached: p !== "win32",
231
+ };
232
+ }
233
+ export function killProviderChildProcess(child, signal, p = process.platform, killGroup = process.kill) {
234
+ if (p !== "win32" && typeof child.pid === "number") {
235
+ try {
236
+ killGroup(-child.pid, signal);
237
+ return true;
238
+ }
239
+ catch { }
240
+ }
241
+ return child.kill(signal);
242
+ }
223
243
  function claudeToolName(request) {
224
244
  for (const key of ["toolName", "tool_name", "name"]) {
225
245
  const value = request[key];
@@ -273,7 +293,7 @@ function resolveOpPaths(paths, cwd) {
273
293
  }
274
294
  });
275
295
  }
276
- function permissionOpFromClaudeRequest(request, cwd) {
296
+ function permissionOpFromClaudeRequest(request, cwd, additionalDirectories) {
277
297
  const input = claudeToolInput(request);
278
298
  const command = stringField(input, ["command", "cmd"]);
279
299
  const url = stringField(input, ["url"]);
@@ -292,6 +312,7 @@ function permissionOpFromClaudeRequest(request, cwd) {
292
312
  ...(paths.length > 0 ? { paths, resolvedPaths: resolveOpPaths(paths, cwd) } : {}),
293
313
  ...(url || host ? { network: [{ ...(url ? { url } : {}), ...(host ? { host } : {}) }] } : {}),
294
314
  cwd,
315
+ ...(additionalDirectories ? { additionalDirectories } : {}),
295
316
  irreversible: Boolean(input.irreversible),
296
317
  };
297
318
  }
@@ -316,21 +337,38 @@ function isBypassImmuneClaudeAsk(request, op) {
316
337
  function permissionDecisionToClaude(decision, reason) {
317
338
  return decision === "allow" ? allowClaudePermission() : denyClaudePermission(reason);
318
339
  }
319
- function resolveCodexLaunchValues(snapshot) {
340
+ function networkAllowRuleTriggersSandbox(rule) {
341
+ const trimmed = rule.trim();
342
+ if (/^WebFetch(?:\b|\()/i.test(trimmed))
343
+ return true;
344
+ if (/^Bash(?:\(\s*(?:\*|[\w./\\-]*\s*)?\)|$)/i.test(trimmed))
345
+ return true;
346
+ return /^Bash\([^)]*\b(?:git\s+(?:push|fetch|pull|clone|ls-remote)|gh|npm|pnpm|yarn|curl|wget)\b/i.test(trimmed);
347
+ }
348
+ export function resolveCodexLaunchValues(snapshot, logTranslation = true) {
320
349
  if (snapshot.ceiling === "yolo") {
321
350
  return {
322
351
  approvalPolicy: "never",
323
352
  threadSandbox: "danger-full-access",
324
353
  turnSandboxPolicy: { type: "dangerFullAccess" },
354
+ cliConfigArgs: [],
325
355
  };
326
356
  }
357
+ const driverSnapshot = snapshot;
358
+ const translatedNetworkAllow = (snapshot.rules.allow ?? []).some(networkAllowRuleTriggersSandbox);
359
+ const networkAccess = Boolean(driverSnapshot.sandboxNetwork || translatedNetworkAllow);
360
+ if (logTranslation && translatedNetworkAllow && !driverSnapshot.sandboxNetwork) {
361
+ console.error("[permissions] translated network allow rule(s) to Codex workspace-write network access");
362
+ }
327
363
  return {
328
364
  approvalPolicy: "untrusted",
329
365
  threadSandbox: "workspace-write",
330
366
  turnSandboxPolicy: {
331
367
  type: "workspaceWrite",
332
368
  writableRoots: snapshot.additionalDirectories ?? [],
369
+ ...(networkAccess ? { networkAccess: true } : {}),
333
370
  },
371
+ cliConfigArgs: networkAccess ? ["-c", "sandbox_workspace_write.network_access=true"] : [],
334
372
  };
335
373
  }
336
374
  function isCodexApprovalMethod(method) {
@@ -380,7 +418,7 @@ function codexCommandFromParams(params) {
380
418
  return { command: argv.join(" "), argv };
381
419
  return {};
382
420
  }
383
- export function codexApprovalOp(method, params, cwd) {
421
+ export function codexApprovalOp(method, params, cwd, additionalDirectories) {
384
422
  if (method === "item/commandExecution/requestApproval" || method === "execCommandApproval") {
385
423
  const command = codexCommandFromParams(params);
386
424
  const opCwd = typeof params.cwd === "string" && params.cwd.length > 0 ? params.cwd : cwd;
@@ -389,6 +427,7 @@ export function codexApprovalOp(method, params, cwd) {
389
427
  tool: "Bash",
390
428
  ...command,
391
429
  cwd: opCwd,
430
+ ...(additionalDirectories ? { additionalDirectories } : {}),
392
431
  irreversible: Boolean(params.irreversible),
393
432
  },
394
433
  confidence: Boolean(command.command),
@@ -404,6 +443,7 @@ export function codexApprovalOp(method, params, cwd) {
404
443
  tool: codexFileChangeTool(params.fileChanges),
405
444
  ...(paths.length > 0 ? { paths, resolvedPaths: resolveOpPaths(paths, cwd) } : {}),
406
445
  cwd,
446
+ ...(additionalDirectories ? { additionalDirectories } : {}),
407
447
  irreversible: false,
408
448
  },
409
449
  confidence: paths.length > 0 || method === "item/fileChange/requestApproval",
@@ -413,6 +453,7 @@ export function codexApprovalOp(method, params, cwd) {
413
453
  op: {
414
454
  tool: "mcpServer/elicitation",
415
455
  cwd,
456
+ ...(additionalDirectories ? { additionalDirectories } : {}),
416
457
  irreversible: false,
417
458
  },
418
459
  confidence: false,
@@ -598,7 +639,7 @@ export class CodexAppServerDriver {
598
639
  kill() {
599
640
  this.process.killed = true;
600
641
  this.child.stdin?.destroy();
601
- this.child.kill("SIGKILL");
642
+ killProviderChildProcess(this.child, "SIGKILL");
602
643
  this.process.kill("SIGKILL");
603
644
  this.rejectPending(new Error("codex app-server driver was killed"));
604
645
  this.queuedTurns.length = 0;
@@ -732,7 +773,7 @@ export class CodexAppServerDriver {
732
773
  return;
733
774
  }
734
775
  this.pendingApprovals.set(key, record);
735
- const { op, confidence } = codexApprovalOp(method, params, this.options.cwd);
776
+ const { op, confidence } = codexApprovalOp(method, params, this.options.cwd, this.permissionSnapshot.additionalDirectories);
736
777
  const strictReadParity = this.permissionSnapshot.strictReadParity ?? "warn";
737
778
  if (!confidence && this.permissionSnapshot.ceiling !== "yolo" && strictReadParity === "warn") {
738
779
  console.error(`[permissions] strictReadParity warn: Codex approval payload for ${method} could not be matched with full confidence; routing to ask`);
@@ -890,7 +931,7 @@ export class ClaudeSdkDriver {
890
931
  // allow/deny; parks (early-returns to the parent, resolves on respond/timeout)
891
932
  // when the engine verdict is "ask".
892
933
  async gateRequest(request, options, permissionSnapshot, isYolo, harnessChannel) {
893
- const op = permissionOpFromClaudeRequest(request, options.cwd);
934
+ const op = permissionOpFromClaudeRequest(request, options.cwd, permissionSnapshot.additionalDirectories);
894
935
  const engineResult = verdict(op, permissionSnapshot.rules);
895
936
  if (isYolo && isBypassImmuneClaudeAsk(request, op)) {
896
937
  return denyClaudePermission("bypass-immune Claude safety prompt auto-denied under yolo");
@@ -1030,11 +1071,9 @@ export async function createProviderDriver(options) {
1030
1071
  driver.open(options);
1031
1072
  return driver;
1032
1073
  }
1033
- const child = spawn(options.command, options.args, {
1034
- cwd: options.cwd,
1035
- env: options.env,
1036
- stdio: ["pipe", "pipe", "pipe"],
1037
- windowsHide: true,
1038
- });
1039
- return new CodexAppServerDriver(child, options);
1074
+ const permissionSnapshot = permissionSnapshotForLaunch(options);
1075
+ const launchValues = resolveCodexLaunchValues(permissionSnapshot, false);
1076
+ const driverOptions = options.permissionSnapshot ? options : { ...options, permissionSnapshot };
1077
+ const child = spawn(options.command, [...launchValues.cliConfigArgs, ...options.args], providerChildSpawnOptions(options));
1078
+ return new CodexAppServerDriver(child, driverOptions);
1040
1079
  }
@@ -30,10 +30,14 @@
30
30
  //
31
31
  // strictReadParity controls logging only. Unparseable Codex approval payloads
32
32
  // always fail closed to ask; warn logs the construct, off silences that log.
33
+ //
34
+ // sandboxNetwork controls Codex workspace-write network access. Default false.
35
+ // Set true only when sub-agents need network after permission approval.
33
36
  {
34
37
  "globalConcurrentSubagents": 20,
35
38
  "checkForUpdates": true,
36
39
  "permissionsCeiling": "auto",
37
40
  "escalation": "irreversible-only",
38
- "strictReadParity": "warn"
41
+ "strictReadParity": "warn",
42
+ "sandboxNetwork": false
39
43
  }
@@ -30,10 +30,14 @@
30
30
  //
31
31
  // strictReadParity controls logging only. Unparseable Codex approval payloads
32
32
  // always fail closed to ask; warn logs the construct, off silences that log.
33
+ //
34
+ // sandboxNetwork controls Codex workspace-write network access. Default false.
35
+ // Set true only when sub-agents need network after permission approval.
33
36
  {
34
37
  "globalConcurrentSubagents": 20,
35
38
  "checkForUpdates": true,
36
39
  "permissionsCeiling": "auto",
37
40
  "escalation": "irreversible-only",
38
- "strictReadParity": "warn"
41
+ "strictReadParity": "warn",
42
+ "sandboxNetwork": false
39
43
  }
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ import { pendingPermissionManager, } from "./pending-permissions.js";
29
29
  const agents = new Map();
30
30
  const deadlockWindow = createDeadlockWindow();
31
31
  const STDOUT_RING_BYTES = 2 * 1024 * 1024;
32
+ export const AGENT_RETENTION_MS = 30 * 60 * 1000;
32
33
  // Advanced-ruleset gate: per-process latch with exactly the deadlock-window
33
34
  // scoping. The env-check runs lazily at the FIRST launch_agent call; success
34
35
  // latches enabled/disabled for the process lifetime, failure never latches.
@@ -116,6 +117,15 @@ const TASK_CATEGORY_GLOSS = "REQUIRED. Task shape -> routing category (server pi
116
117
  function errorResult(text) {
117
118
  return { content: [{ type: "text", text }], isError: true };
118
119
  }
120
+ function currentLaunchDepth(env = process.env) {
121
+ const raw = env.SUBAGENT_MCP_DEPTH;
122
+ if (raw !== undefined && raw !== "") {
123
+ const parsed = Number.parseInt(raw, 10);
124
+ if (Number.isInteger(parsed) && parsed >= 0 && String(parsed) === raw.trim())
125
+ return parsed;
126
+ }
127
+ return env.SUBAGENT_MCP_SUBAGENT === "1" ? 1 : 0;
128
+ }
119
129
  function envDuration(name, fallback) {
120
130
  const raw = process.env[name];
121
131
  if (raw === undefined || raw === "")
@@ -161,6 +171,33 @@ function isLiveAgent(agent) {
161
171
  agent.status === "permission_requested" ||
162
172
  agent.status === "stalled");
163
173
  }
174
+ function isTerminalAgentStatus(status) {
175
+ return (status === "finished" ||
176
+ status === "errored" ||
177
+ status === "stopped" ||
178
+ status === "zombie_killed");
179
+ }
180
+ export function shouldEvictAgent(agent, now = Date.now(), retentionMs = AGENT_RETENTION_MS) {
181
+ if (!isTerminalAgentStatus(agent.status))
182
+ return false;
183
+ if (!agent.driver.closed)
184
+ return false;
185
+ if (!agent.waitReported)
186
+ return false;
187
+ if (agent.exitedAt === null)
188
+ return false;
189
+ return now - agent.exitedAt > retentionMs;
190
+ }
191
+ export function evictExpiredAgents(agentMap, now = Date.now(), retentionMs = AGENT_RETENTION_MS) {
192
+ let evicted = 0;
193
+ for (const [id, agent] of agentMap) {
194
+ if (!shouldEvictAgent(agent, now, retentionMs))
195
+ continue;
196
+ agentMap.delete(id);
197
+ evicted++;
198
+ }
199
+ return evicted;
200
+ }
164
201
  function isSameOwnerSlot(agent, slotMeta) {
165
202
  if (!slotMeta)
166
203
  return true;
@@ -321,6 +358,7 @@ function runToolMaintenance() {
321
358
  }
322
359
  updateSlotMetadata(agent);
323
360
  }
361
+ evictExpiredAgents(agents, now);
324
362
  return records;
325
363
  }
326
364
  function withMaintenance(handler, options = {}) {
@@ -500,6 +538,7 @@ const reconcileInterval = setInterval(() => {
500
538
  });
501
539
  }
502
540
  }
541
+ evictExpiredAgents(agents, now);
503
542
  }, 10000);
504
543
  reconcileInterval.unref();
505
544
  // Heavy operating-model + governance guidance for ORCHESTRATION MODE. Carried in
@@ -512,10 +551,10 @@ reconcileInterval.unref();
512
551
  // compressed under MCP metadata limits:
513
552
  // READ-ESCALATION LADDER (the orchestrator's only read channels, in order): (1) subagent-mcp `poll_agent` TAIL; (2) if the tail is insufficient, dispatch ONE sub-agent to return a single summary of <=100 lines, trusted as-is (no separate verification step); (3) anything larger: the USER reads the document directly. No reads or writes occur outside these channels. An empty or stalled tail means the agent is ALIVE, not dead — do NOT busy-loop poll_agent; learn completion via `wait`. Large inter-agent data: the orchestrator assigns scratch-file paths (%TEMP% on Windows, /tmp on POSIX) in prompts; the producing sub-agent writes, the consuming sub-agent reads; the orchestrator NEVER reads those files.
514
553
  const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (full spec: docs/spec/dev-loop/orchestration-directive-architecture.md).\n\nPRECEDENCE. The latest <subagent-mcp state=\"...\"> hook tag and repo/system safety rules are jointly binding; genuine conflict => STOP and ask. Only the hook flips ON/OFF; absence of any tag = UNKNOWN => fail-safe ON.\n\nSOLE CHANNEL. Every launch uses launch_agent; never harness Task/Agent or shell-spawned agents.\n\nORCHESTRATION ON. You are a delegate-ONLY orchestrator: use only the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex), subagent-mcp, and /workflows. No direct reads/writes; inline-by-right does not exist. Non-delegable step: ask a one-time exception, do only that step, resume delegating.\n\nSUB-AGENT CONTRACT. Every prompt carries objective + output format + tools/sources + boundaries. SCALE: ~1 agent for a fact-find, 2-4 for comparisons; never one-shot multi-phase work; split into atomic steps, one agent each. FAN-OUT independents, sequence dependents, SERIALIZE writers over shared paths (no cwd lock). VERIFY code and non-trivial steps with a separate sub-agent first.\n\nREAD LADDER. poll_agent tail -> one <=100-line summarizer sub-agent, trusted as-is -> else the USER reads it. Large handoffs use scratch-file paths; producer writes, consumer reads, orchestrator never reads them. Empty/stalled tail means ALIVE; learn finish via wait, do not poll-loop.\n\nORCHESTRATION OFF. If context footprint since last upgrade ask exceeds 200 lines, after that turn STOP and ask whether to enable; reset count only when you ask.\n\nDROPOUT WHILE ON: HALT and ask until restored. SUB-AGENT EXEMPTION: a prompt whose literal FIRST LINE begins \"<this is a request from a parent process>\" skips this regime. DISABLE: user-only, never on your own initiative.\n\nMODEL SELECTION. Default smart auto-picks, rejects provider/model/effort selectors. user-approved-overrides honors them 30 min, expires lazily on launch_agent, needs user authorization.";
515
- const SUBAGENT_INSTRUCTIONS = "SUB-AGENT SESSION: you are a child process launched by subagent-mcp. Follow the parent prompt. Do not treat yourself as the orchestrator, do not re-trigger orchestration carryover, and do not launch further sub-agents unless the parent prompt explicitly assigns that.\n\nMODEL SELECTION MODE (parallel to orchestration-mode, set via the model-selection-mode tool). DEFAULT is \"smart\" and is used whenever unset: in smart, launch_agent REJECTS any call supplying provider/model/effort selectors and the server auto-picks the best model. \"user-approved-overrides\" opens a 30-MINUTE window where selectors are HONORED, enforced LAZILY (the mode reverts to smart on the next launch_agent call after 30 minutes) and re-enabling does NOT extend an active window. HONOR-BASED: you MUST NOT set \"user-approved-overrides\" without explicit interactive USER authorization via the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex); never enable it on your own initiative.";
554
+ const SUBAGENT_INSTRUCTIONS = "SUB-AGENT SESSION: you are a child process launched by subagent-mcp. Follow the parent prompt. Do not treat yourself as the orchestrator, do not re-trigger orchestration carryover, and do not launch further sub-agents unless the parent prompt explicitly assigns that. launch_agent is code-capped at 2 spawn levels below the main orchestrator: depth 1 may launch depth 2 workers; depth 2 workers cannot spawn further.\n\nMODEL SELECTION MODE (parallel to orchestration-mode, set via the model-selection-mode tool). DEFAULT is \"smart\" and is used whenever unset: in smart, launch_agent REJECTS any call supplying provider/model/effort selectors and the server auto-picks the best model. \"user-approved-overrides\" opens a 30-MINUTE window where selectors are HONORED, enforced LAZILY (the mode reverts to smart on the next launch_agent call after 30 minutes) and re-enabling does NOT extend an active window. HONOR-BASED: you MUST NOT set \"user-approved-overrides\" without explicit interactive USER authorization via the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex); never enable it on your own initiative.";
516
555
  const server = new McpServer({
517
556
  name: "subagent-mcp",
518
- version: "2.12.8",
557
+ version: "2.12.9",
519
558
  description: "Launches always-interactive local Claude and Codex sub-agent sessions and is the orchestrator's sole launch channel. Claude runs via the Claude Agent SDK over the local Claude Code executable; Codex via `codex app-server` over stdio. The server never calls Anthropic or OpenAI HTTP APIs directly.",
520
559
  }, {
521
560
  instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
@@ -580,7 +619,11 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, permissionSnapsho
580
619
  command: cmd,
581
620
  args: buildResult.args,
582
621
  cwd: agentCwd,
583
- env: { ...process.env, SUBAGENT_MCP_SUBAGENT: "1" },
622
+ env: {
623
+ ...process.env,
624
+ SUBAGENT_MCP_SUBAGENT: "1",
625
+ SUBAGENT_MCP_DEPTH: String(currentLaunchDepth() + 1),
626
+ },
584
627
  model: candidate.model,
585
628
  effort: candidate.effort,
586
629
  ucSettingsPath: buildResult.ucSettingsPath,
@@ -894,6 +937,10 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
894
937
  deadlock: z.boolean().optional().describe("MANDATE: ALWAYS set deadlock=true when, and ONLY when, 2 launch attempts for the SAME atomic task have already failed or been unsatisfactory — the 3rd attempt onward. Re-wording the prompt does NOT make it a different task; splitting a failed task does NOT reset attempts for its unchanged parts; re-launching for the same deliverable means the prior attempt COUNTS as failed/unsatisfactory ('partial progress' is not an exemption). NEVER set it on a 1st or 2nd attempt, NEVER for a different task, NEVER speculatively. Auto mode only: cannot be combined with provider/model/effort — from the 3rd attempt deadlock outranks any capability override, so drop those params. Passing false is identical to omitting it."),
895
938
  }, withMaintenance(async (params) => {
896
939
  const { task_category, provider, model, effort, deadlock } = params;
940
+ const launchDepth = currentLaunchDepth();
941
+ if (launchDepth >= 2) {
942
+ return errorResult(`Error: launch_agent depth cap reached: current SUBAGENT_MCP_DEPTH=${launchDepth}. subagent-mcp permits exactly 2 spawn levels below the main orchestrator (depth 0 -> 1 -> 2); depth 2 workers cannot spawn further sub-agents.`);
943
+ }
897
944
  // D19/D20/S8: server silently upserts the parent-process marker as the TRUE
898
945
  // first line of every sub-agent prompt (idempotent; never duplicates; never
899
946
  // mutates the body). This is what makes the child first-line exemption fire.
@@ -23,8 +23,6 @@ function decision(permissionDecision, permissionDecisionReason, additionalContex
23
23
  export function runClaudePreTool(payload, env, now = Date.now()) {
24
24
  try {
25
25
  const zombieRecords = cullHookZombies();
26
- if (env.SUBAGENT_MCP_SUBAGENT === "1")
27
- return null;
28
26
  const maintenanceAllowedDecision = zombieRecords.length > 0
29
27
  ? decision("allow", "maintenance completed; allowing requested tool.")
30
28
  : null;
@@ -36,6 +34,8 @@ export function runClaudePreTool(payload, env, now = Date.now()) {
36
34
  if (NATIVE_SUBAGENT_TOOLS.has(tool)) {
37
35
  return decision("deny", "subagent-mcp is alive; harness-native Task/Agent/Explore is not the sanctioned sub-agent channel. Use the subagent-mcp launch_agent MCP tool with the parent-process sentinel as prompt line 1.");
38
36
  }
37
+ if (env.SUBAGENT_MCP_SUBAGENT === "1")
38
+ return maintenanceAllowedDecision;
39
39
  return maintenanceAllowedDecision;
40
40
  }
41
41
  catch {
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import permissionClasses from "./permission-classes.json" with { type: "json" };
2
3
  const classes = permissionClasses;
3
4
  const safeTools = new Set(classes.safe.tools.map(normalizeToolName));
@@ -71,13 +72,20 @@ export function applyPermissionCeiling(engineVote, ceiling) {
71
72
  export function classifyPermissionOp(op) {
72
73
  const command = commandText(op);
73
74
  const irreversible = Boolean(op.irreversible) || (command !== "" && irreversibleRegexes.some((r) => r.test(command)));
74
- if (isDangerousPathOp(op)) {
75
+ if (hasDangerousOrProtectedPath(op)) {
75
76
  return {
76
77
  classification: "danger",
77
78
  irreversible,
78
79
  reason: "dangerous or protected path",
79
80
  };
80
81
  }
82
+ if (isReadPathOutsideAllowedRoots(op)) {
83
+ return {
84
+ classification: "neutral",
85
+ irreversible,
86
+ reason: "read path outside allowed roots",
87
+ };
88
+ }
81
89
  if (normalizeToolName(op.tool) === "bash") {
82
90
  if (command === "")
83
91
  return { classification: "neutral", irreversible, reason: "empty bash command" };
@@ -170,8 +178,8 @@ function matchesAnyPath(op, pattern) {
170
178
  return normalized === rule || normalized.startsWith(`${rule}/`) || wildcardMatch(rule, normalized);
171
179
  });
172
180
  }
173
- function isDangerousPathOp(op) {
174
- if (!writeTools.has(normalizeToolName(op.tool)))
181
+ function hasDangerousOrProtectedPath(op) {
182
+ if (!isPathProtectedTool(op))
175
183
  return false;
176
184
  return allPaths(op).some((p) => {
177
185
  const normalized = normalizePathForMatch(p);
@@ -180,6 +188,26 @@ function isDangerousPathOp(op) {
180
188
  return parts.some((part) => dangerousPathSegments.has(part.toLowerCase())) || protectedFilenames.has(last);
181
189
  });
182
190
  }
191
+ function isReadPathOutsideAllowedRoots(op) {
192
+ if (!safeTools.has(normalizeToolName(op.tool)))
193
+ return false;
194
+ return allPaths(op).some((p) => !isPathInsideAllowedRoots(p, op));
195
+ }
196
+ function isPathProtectedTool(op) {
197
+ const opTool = normalizeToolName(op.tool);
198
+ return writeTools.has(opTool) || safeTools.has(opTool);
199
+ }
200
+ function isPathInsideAllowedRoots(candidate, op) {
201
+ const roots = [op.cwd, ...(op.additionalDirectories ?? [])].map((root) => resolvePathForContainment(root, op.cwd));
202
+ const resolved = resolvePathForContainment(candidate, op.cwd);
203
+ return roots.some((root) => resolved === root || resolved.startsWith(`${root}/`));
204
+ }
205
+ function resolvePathForContainment(candidate, cwd) {
206
+ const normalized = normalizePathForMatch(candidate);
207
+ const isAbsolute = path.win32.isAbsolute(candidate) || path.posix.isAbsolute(candidate);
208
+ const joined = isAbsolute ? normalized : normalizePathForMatch(path.resolve(cwd, candidate));
209
+ return joined.replace(/\/$/, "");
210
+ }
183
211
  function isSafeBashCommand(command) {
184
212
  const stripped = stripEnvPrefix(command, false);
185
213
  const tokens = tokenizeCommand(stripped);
package/dist/zombie.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { platform } from "node:os";
3
- import { basename, dirname, join } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { platform, userInfo } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
4
5
  import { execFileSync } from "node:child_process";
5
6
  export const ZOMBIE_LIVE_IDLE_MS = 6 * 60 * 1000;
6
7
  export const ZOMBIE_TERMINAL_IDLE_MS = 6 * 60 * 1000;
@@ -153,6 +154,58 @@ function defaultIsProcessAlive(pid) {
153
154
  function livePid(pid) {
154
155
  return typeof pid === "number" && Number.isInteger(pid) && pid > 0;
155
156
  }
157
+ function safeSlotNamespace(raw) {
158
+ const cleaned = raw.replace(/[^A-Za-z0-9_.-]/g, "_");
159
+ return cleaned || createHash("sha256").update(raw || "unknown").digest("hex").slice(0, 16);
160
+ }
161
+ function currentUserSlotNamespace() {
162
+ try {
163
+ const info = userInfo();
164
+ if (platform() !== "win32" && Number.isInteger(info.uid))
165
+ return `uid-${info.uid}`;
166
+ return safeSlotNamespace(info.username);
167
+ }
168
+ catch { }
169
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
170
+ if (typeof uid === "number")
171
+ return `uid-${uid}`;
172
+ return safeSlotNamespace(process.env.USERNAME || process.env.USER || "unknown");
173
+ }
174
+ function slotBaseDir() {
175
+ if (process.env.SUBAGENT_SLOT_DIR)
176
+ return process.env.SUBAGENT_SLOT_DIR;
177
+ if (platform() === "win32") {
178
+ return join(process.env.ProgramData || process.env.ALLUSERSPROFILE || "C:\\ProgramData", "subagent-mcp", "slots");
179
+ }
180
+ return "/tmp/subagent-mcp/slots";
181
+ }
182
+ function currentUserSlotDir() {
183
+ return join(slotBaseDir(), currentUserSlotNamespace());
184
+ }
185
+ function isCurrentUserSlotDir(dir) {
186
+ return resolve(dir).toLowerCase() === resolve(currentUserSlotDir()).toLowerCase();
187
+ }
188
+ function commandLooksLikeSubagentChild(text) {
189
+ const normalized = text.replace(/\0/g, " ").toLowerCase();
190
+ return /(^|[\\/.\s_-])(claude|codex|gemini)(\.cmd|\.exe)?([\\/.\s_-]|$)/.test(normalized);
191
+ }
192
+ function defaultIsSubagentChildProcess(pid, _metadata) {
193
+ try {
194
+ if (platform() === "win32") {
195
+ const output = execFileSync("powershell.exe", [
196
+ "-NoProfile",
197
+ "-Command",
198
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($p) { "$($p.ExecutablePath) $($p.CommandLine)" }`,
199
+ ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
200
+ return commandLooksLikeSubagentChild(output);
201
+ }
202
+ const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
203
+ return commandLooksLikeSubagentChild(cmdline);
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ }
156
209
  function defaultSleepMs(ms) {
157
210
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
158
211
  }
@@ -162,7 +215,9 @@ export function cullStaleSlots(dir, deps = {}) {
162
215
  const sleepMs = deps.sleepMs ?? defaultSleepMs;
163
216
  const forceGraceMs = deps.forceGraceMs?.() ?? ZOMBIE_FORCE_GRACE_MS;
164
217
  const isProcessAlive = deps.isProcessAlive ?? defaultIsProcessAlive;
218
+ const isSubagentChildProcess = deps.isSubagentChildProcess ?? defaultIsSubagentChildProcess;
165
219
  const p = deps.platform ?? platform();
220
+ const canKillFromDir = isCurrentUserSlotDir(dir);
166
221
  const records = [];
167
222
  let files;
168
223
  try {
@@ -189,8 +244,10 @@ export function cullStaleSlots(dir, deps = {}) {
189
244
  if (ownerAlive)
190
245
  continue;
191
246
  const pid = meta.child_pid;
192
- // cull only when the owning server is dead/absent (true orphan); spare children of a live server
193
- if ((ownerPid === null || !ownerAlive) && livePid(pid) && pid !== process.pid) {
247
+ const childAlive = livePid(pid) && pid !== process.pid && isProcessAlive(pid);
248
+ const verifiedChild = childAlive && canKillFromDir && isSubagentChildProcess(pid, meta);
249
+ // cull only verified children from the current user's namespace; never trust stale JSON alone
250
+ if (verifiedChild && (ownerPid === null || !ownerAlive)) {
194
251
  const commands = buildProcessTreeKillCommands(pid, p);
195
252
  try {
196
253
  runCommand(commands.graceful.command, commands.graceful.args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretyc/subagent-mcp",
3
- "version": "2.12.8",
3
+ "version": "2.12.9",
4
4
  "description": "MCP server that launches and manages always-interactive Claude Code and Codex sub-agent sessions (no direct Anthropic/OpenAI API).",
5
5
  "keywords": [
6
6
  "mcp",