@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.
- package/dist/concurrency.js +64 -6
- package/dist/config-scaffold.js +1 -1
- package/dist/drivers.js +52 -13
- package/dist/global-concurrency.jsonc +5 -1
- package/dist/global-subagent-mcp-config.jsonc +5 -1
- package/dist/index.js +50 -3
- package/dist/orchestration/pretool.js +2 -2
- package/dist/permission-engine.js +31 -3
- package/dist/zombie.js +61 -4
- package/package.json +1 -1
package/dist/concurrency.js
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/config-scaffold.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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.
|
|
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: {
|
|
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 (
|
|
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
|
|
174
|
-
if (!
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
193
|
-
|
|
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