feat(tooling): add governed local PI subagent executor
This commit is contained in:
@@ -12,6 +12,11 @@
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": ["serve", "--mcp", "--path", "${workspaceFolder}"]
|
||||
},
|
||||
"pi-subagents": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": ["scripts/mcp/pi-subagents-mcp.mjs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: Route PI analysis/review/test subagent work through scripts/pi_subagents.py (not Cursor native agents).
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# PI subagents(工程工具)
|
||||
|
||||
本机 PI 子任务走 `scripts/pi_subagents.py` + 模型配置 `.doc/ops/2026-09-24-subagent-model-config.md`。
|
||||
|
||||
## 自动派发(主 Agent)
|
||||
|
||||
1. **预览配置**:`pi_subagent_roles` / CLI `roles`(不调模型)。
|
||||
2. **按阶段选角色**:research → planning → reviews → diagnosis → test_run / acceptance_check → report;实施/修复仍由 Cursor Auto。
|
||||
3. **派发 `run`**:携带 task_id、role_id、唯一 request_id、goal、authorization_ref、冻结 source_sha、input_files、timeout;test 角色另附 approved_commands + plan_digest。
|
||||
4. **租约**:报告路径 `path:.doc/test-reports/assets/pi-subagents/<task>/<request>`;裸调拒绝。
|
||||
5. **取回**:`pi_subagent_result` / CLI `result`;`run_status` 与 `verdict`/`review_state` 分离——模型成功仅 `pending`,测试 pass 仅来自批准命令证据。
|
||||
6. **门禁**:审查/测试结论须带回报告路径与 receipt 字段;不得用模型叙事冒充 PASS。
|
||||
|
||||
## 边界
|
||||
|
||||
- **Cursor Auto**:唯一代码实施车道;本规则不把 implementation 派给 PI。
|
||||
- **禁止**:自动换模型、M3 关键角色、伪造 lease、递归把本修复任务再派给 PI worker、`pkill -f` 广谱杀进程。
|
||||
- **MCP**:`.cursor/mcp.json` → `pi-subagents`(roles/run/result);executor 来自 MCP 安装根。
|
||||
@@ -6,13 +6,53 @@ Zero-dependency Node MCP wrappers used by Cursor (see [`.cursor/mcp.json`](../..
|
||||
|---|---|---|
|
||||
| `agentctl` | `agentctl-mcp.mjs` | Lease / docs apply / validate / queue |
|
||||
| `jev` | `jev-mcp.mjs` | Typed decide/health against local or remote jev-service |
|
||||
| `pi-subagents` | `pi-subagents-mcp.mjs` | Local PI subagent roles/run/result (engineering tool; not product Runner) |
|
||||
|
||||
```bash
|
||||
# from repo root
|
||||
node scripts/mcp/agentctl-mcp.mjs
|
||||
node scripts/mcp/jev-mcp.mjs
|
||||
node scripts/mcp/pi-subagents-mcp.mjs
|
||||
```
|
||||
|
||||
## pi-subagents
|
||||
|
||||
Markdown model config is the sole editable source: `.doc/ops/2026-09-24-subagent-model-config.md`.
|
||||
|
||||
| Tool | Behavior |
|
||||
|---|---|
|
||||
| `pi_subagent_roles` | Read-only catalog; never invokes PI; executor binary from **this server's INSTALL_ROOT** |
|
||||
| `pi_subagent_run` | Non-blocking; request JSON in OS tmp; wraps **discovered main worktree** `scripts/agentctl.py with` (governance SSOT lease entry) + **INSTALL_ROOT** `scripts/pi_subagents.py run` |
|
||||
| `pi_subagent_result` | Read-only receipt; `running` is not `isError`; terminal failure/`UNKNOWN_REQUEST` after a settled supervisor rejection surfaces the child's typed error (ROLE_DISABLED, LEASE_REQUIRED, …) |
|
||||
|
||||
**Install vs main:** When the MCP server lives in a linked task worktree, INSTALL_ROOT may differ from the repo main tip. Cross-repo worktrees (different `git-common-dir`) are refused. Optional `config` is worktree-relative registered model config only. `request.config_path` is rejected (use tool `config`).
|
||||
|
||||
CLI:
|
||||
|
||||
```bash
|
||||
python3 -B scripts/pi_subagents.py roles --config .doc/ops/2026-09-24-subagent-model-config.md
|
||||
python3 -B scripts/agentctl.py with --task <id> \
|
||||
--resource path:.doc/test-reports/assets/pi-subagents/<id>/<request_id> -- \
|
||||
python3 -B scripts/pi_subagents.py run --request /path/to/req.json \
|
||||
--config .doc/ops/2026-09-24-subagent-model-config.md
|
||||
python3 -B scripts/pi_subagents.py result --task <id> --request-id <request_id>
|
||||
```
|
||||
|
||||
The executor resolves `agentctl` helpers from the git main worktree scripts directory (not a stale colocated copy beside itself when running from a feature worktree).
|
||||
|
||||
Scoped reads: `scripts/pi-subagent-readonly.ts` — builtin `path` only; `@`/`~` denied; directory walks must contain only allowlisted regular files. Approved tests use a separate snapshot; model evidence stays pristine.
|
||||
|
||||
### Cursor CLI compatibility (pi-subagents)
|
||||
|
||||
Real Cursor CLI `2026.09.18` (and `2026.09.23` verified here) does **not** expand `${workspaceFolder}` in MCP `args`. The `pi-subagents` entry therefore uses workspace-relative `args: ["scripts/mcp/pi-subagents-mcp.mjs"]` with `type: "stdio"` / `command: "node"`. Absolute `${workspaceFolder}/…` paths fail with `Cannot find module '…/${workspaceFolder}/scripts/mcp/pi-subagents-mcp.mjs'`. After `agent mcp enable pi-subagents`, `agent mcp list-tools pi-subagents` must report exactly three tools: `pi_subagent_roles`, `pi_subagent_run`, `pi_subagent_result`. Older `agentctl` / `jev` entries may still use `${workspaceFolder}` until separately corrected.
|
||||
|
||||
### Limitations (v1)
|
||||
|
||||
- No shared environment ops (`SHARED_ENV_UNSUPPORTED`).
|
||||
- Process-group governance, not an OS sandbox.
|
||||
- Unit suite uses fake PI; Cursor UI MCP connectivity requires reload after checkout and host smoke.
|
||||
- Host owns live paid-model checks.
|
||||
|
||||
## jev credentials (not in git)
|
||||
|
||||
- `~/.secrets/jev/api.key` (chmod 600) or env `JEV_API_KEY`
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* pi-subagents MCP (stdio, zero deps).
|
||||
* Tools: pi_subagent_roles, pi_subagent_run, pi_subagent_result.
|
||||
*
|
||||
* - Trusted executor: INSTALL_ROOT/scripts/pi_subagents.py (this server install).
|
||||
* - Authoritative agentctl: discovered main worktree scripts/agentctl.py (governance SSOT).
|
||||
* - worktree must share git-common-dir with INSTALL_ROOT.
|
||||
* - Request JSON only under private OS tmp (no pre-lease repo writes).
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const INSTALL_ROOT = path.resolve(__dirname, '../..');
|
||||
const MAX_PAYLOAD = 2 * 1024 * 1024;
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
const active = new Map(); // key -> { child, digest, stdout, stderr, exitCode, rejection }
|
||||
|
||||
function gitCommonDir(cwd) {
|
||||
const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (r.status !== 0) return null;
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
function gitMainWorktree(cwd) {
|
||||
const list = spawnSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], { encoding: 'utf8' });
|
||||
if (list.status !== 0) return null;
|
||||
const first = /^worktree (.+)$/m.exec(list.stdout || '');
|
||||
return first ? first[1] : null;
|
||||
}
|
||||
|
||||
function validateId(value, label) {
|
||||
if (typeof value !== 'string' || !ID_RE.test(value) || value === '.' || value === '..') {
|
||||
throw new Error(`invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRepo(worktree) {
|
||||
if (!worktree || typeof worktree !== 'string') {
|
||||
throw new Error('worktree is required');
|
||||
}
|
||||
const wt = path.resolve(worktree);
|
||||
const common = gitCommonDir(wt);
|
||||
if (!common) throw new Error(`not a git worktree: ${wt}`);
|
||||
const installCommon = gitCommonDir(INSTALL_ROOT);
|
||||
if (!installCommon) throw new Error('INSTALL_ROOT is not a git worktree');
|
||||
if (common !== installCommon) {
|
||||
throw new Error('worktree does not share INSTALL_ROOT git-common-dir (cross-repo refused)');
|
||||
}
|
||||
// Authoritative main = first worktree list entry; executor stays at INSTALL_ROOT.
|
||||
const discovered = gitMainWorktree(INSTALL_ROOT) || INSTALL_ROOT;
|
||||
return { main: discovered, discoveredMain: discovered, worktree: wt, common };
|
||||
}
|
||||
|
||||
function agentctlPath(main) {
|
||||
return path.join(main, 'scripts', 'agentctl.py');
|
||||
}
|
||||
|
||||
function executorPath() {
|
||||
return path.join(INSTALL_ROOT, 'scripts', 'pi_subagents.py');
|
||||
}
|
||||
|
||||
function safeConfigPath(worktree, config) {
|
||||
const defaultRel = '.doc/ops/2026-09-24-subagent-model-config.md';
|
||||
const rel = config || defaultRel;
|
||||
if (typeof rel !== 'string' || !rel.trim()) throw new Error('invalid config path');
|
||||
if (path.isAbsolute(rel)) throw new Error('config must be worktree-relative');
|
||||
if (rel.includes('\0') || rel.split(path.sep).includes('..')) {
|
||||
throw new Error('config path traversal refused');
|
||||
}
|
||||
const abs = path.resolve(worktree, rel);
|
||||
const normWt = worktree.endsWith(path.sep) ? worktree : worktree + path.sep;
|
||||
if (!(abs === worktree || abs.startsWith(normWt))) {
|
||||
throw new Error('config escapes worktree');
|
||||
}
|
||||
// refuse symlink config
|
||||
try {
|
||||
if (fs.lstatSync(abs).isSymbolicLink()) throw new Error('config symlink refused');
|
||||
} catch (e) {
|
||||
if (e.message === 'config symlink refused') throw e;
|
||||
// missing checked by executor
|
||||
}
|
||||
// only the registered config file name
|
||||
if (path.basename(abs) !== '2026-09-24-subagent-model-config.md') {
|
||||
throw new Error('config must be the registered model config file');
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
function payloadDigest(request) {
|
||||
return crypto.createHash('sha256').update(JSON.stringify(request)).digest('hex');
|
||||
}
|
||||
|
||||
function runSync(argv, { cwd, timeout = 120000, env } = {}) {
|
||||
const r = spawnSync(argv[0], argv.slice(1), {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
env: env || process.env,
|
||||
});
|
||||
return {
|
||||
ok: r.status === 0,
|
||||
code: r.status ?? 1,
|
||||
stdout: r.stdout || '',
|
||||
stderr: r.stderr || '',
|
||||
error: r.error ? String(r.error) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function textResult(payload) {
|
||||
const body = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
||||
return { content: [{ type: 'text', text: body }] };
|
||||
}
|
||||
|
||||
function errResult(msg) {
|
||||
const body = typeof msg === 'string' ? msg : JSON.stringify(msg);
|
||||
return { content: [{ type: 'text', text: body }], isError: true };
|
||||
}
|
||||
|
||||
function parseJsonLine(text) {
|
||||
const lines = String(text || '').split(/\n/).map((l) => l.trim()).filter(Boolean);
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
return JSON.parse(lines[i]);
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeErr(text) {
|
||||
return String(text || '')
|
||||
.replace(/(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*\S+/gi, '$1=[REDACTED]')
|
||||
.slice(0, 2000);
|
||||
}
|
||||
|
||||
function killActiveTree(meta) {
|
||||
if (!meta?.child) return;
|
||||
const child = meta.child;
|
||||
try {
|
||||
if (child.pid) {
|
||||
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* not a group */ }
|
||||
try { child.kill('SIGTERM'); } catch { /* */ }
|
||||
}
|
||||
} catch { /* */ }
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (child.exitCode === null && child.pid) {
|
||||
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* */ }
|
||||
try { child.kill('SIGKILL'); } catch { /* */ }
|
||||
}
|
||||
} catch { /* */ }
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function reapAll() {
|
||||
for (const meta of active.values()) {
|
||||
killActiveTree(meta);
|
||||
}
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'pi_subagent_roles',
|
||||
description: 'Read-only: list PI subagent roles/models from markdown config (does not invoke PI)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
worktree: { type: 'string', description: 'Absolute path to registered task worktree' },
|
||||
task_id: { type: 'string' },
|
||||
config: { type: 'string', description: 'Optional config path relative to worktree' },
|
||||
},
|
||||
required: ['worktree', 'task_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pi_subagent_run',
|
||||
description: 'Start one PI subagent run under main-worktree agentctl lease (non-blocking)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
worktree: { type: 'string' },
|
||||
task_id: { type: 'string' },
|
||||
request: {
|
||||
type: 'object',
|
||||
description: 'PI subagent request (task_id optional; injected from outer task_id)',
|
||||
properties: {
|
||||
task_id: { type: 'string', description: 'Optional; must match outer task_id when set' },
|
||||
role_id: { type: 'string' },
|
||||
request_id: { type: 'string' },
|
||||
goal: { type: 'string' },
|
||||
authorization_ref: { type: 'string' },
|
||||
source_sha: { type: 'string', description: 'Full commit hash' },
|
||||
input_files: { type: 'array', items: { type: 'string' } },
|
||||
timeout_seconds: { type: 'integer' },
|
||||
approved_commands: {
|
||||
type: 'array',
|
||||
items: { type: 'array', items: { type: 'string' } },
|
||||
description: 'test_run/acceptance_check only',
|
||||
},
|
||||
plan_digest: { type: 'string', description: 'sha256 hex for test roles' },
|
||||
model_override: { type: 'object' },
|
||||
},
|
||||
required: [
|
||||
'role_id', 'request_id', 'goal', 'authorization_ref',
|
||||
'source_sha', 'input_files', 'timeout_seconds',
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
config: { type: 'string', description: 'Worktree-relative registered model config path' },
|
||||
},
|
||||
required: ['worktree', 'task_id', 'request'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pi_subagent_result',
|
||||
description: 'Read durable receipt for a request_id (does not invoke PI). running is not isError; terminal failure is.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
worktree: { type: 'string' },
|
||||
task_id: { type: 'string' },
|
||||
request_id: { type: 'string' },
|
||||
},
|
||||
required: ['worktree', 'task_id', 'request_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function callTool(name, args = {}) {
|
||||
try {
|
||||
if (name === 'pi_subagent_roles') {
|
||||
validateId(args.task_id, 'task_id');
|
||||
const { worktree } = resolveRepo(args.worktree);
|
||||
const config = safeConfigPath(worktree, args.config);
|
||||
const r = runSync(['python3', '-B', executorPath(), 'roles', '--config', config], {
|
||||
cwd: worktree,
|
||||
});
|
||||
const payload = parseJsonLine(r.stdout) || parseJsonLine(r.stderr);
|
||||
if (!r.ok) return errResult(payload || sanitizeErr(r.stderr || r.stdout || r.error));
|
||||
return textResult(payload);
|
||||
}
|
||||
if (name === 'pi_subagent_result') {
|
||||
validateId(args.task_id, 'task_id');
|
||||
validateId(args.request_id, 'request_id');
|
||||
const { worktree } = resolveRepo(args.worktree);
|
||||
const key = `${args.task_id}/${args.request_id}`;
|
||||
const meta = active.get(key);
|
||||
const r = runSync([
|
||||
'python3', '-B', executorPath(), 'result',
|
||||
'--task', args.task_id, '--request-id', args.request_id,
|
||||
], { cwd: worktree });
|
||||
const payload = parseJsonLine(r.stdout) || parseJsonLine(r.stderr);
|
||||
|
||||
const surfaceRejection = () => {
|
||||
if (meta?.rejection) return errResult(meta.rejection);
|
||||
if (meta && meta.exitCode !== null && meta.exitCode !== 0) {
|
||||
const errPayload = parseJsonLine(meta.stderr()) || parseJsonLine(meta.stdout()) || {
|
||||
ok: false,
|
||||
code: 'SUPERVISOR_FAILED',
|
||||
message: sanitizeErr(meta.stderr() || meta.stdout() || 'supervisor failed'),
|
||||
exit_code: meta.exitCode,
|
||||
};
|
||||
return errResult(errPayload);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!payload) {
|
||||
const rej = surfaceRejection();
|
||||
if (rej) return rej;
|
||||
if (meta && meta.exitCode === null) {
|
||||
return textResult({
|
||||
ok: true,
|
||||
run_status: 'running',
|
||||
task_id: args.task_id,
|
||||
request_id: args.request_id,
|
||||
});
|
||||
}
|
||||
return errResult({
|
||||
ok: false,
|
||||
code: 'NOT_FOUND',
|
||||
message: sanitizeErr(r.stderr || r.stdout || r.error || 'no receipt'),
|
||||
});
|
||||
}
|
||||
// Settled supervisor rejection with no durable receipt (UNKNOWN_REQUEST).
|
||||
if (payload.code === 'UNKNOWN_REQUEST') {
|
||||
const rej = surfaceRejection();
|
||||
if (rej) return rej;
|
||||
// Child still in flight: do not treat as terminal unknown.
|
||||
if (meta && meta.exitCode === null) {
|
||||
return textResult({
|
||||
ok: true,
|
||||
run_status: 'running',
|
||||
task_id: args.task_id,
|
||||
request_id: args.request_id,
|
||||
});
|
||||
}
|
||||
return errResult(payload);
|
||||
}
|
||||
if (!r.ok && payload.code && payload.code !== 'UNKNOWN_REQUEST') {
|
||||
return errResult(payload);
|
||||
}
|
||||
// In-flight: never isError
|
||||
if (payload.run_status === 'running') {
|
||||
return textResult(payload);
|
||||
}
|
||||
if (payload.ok === false || (payload.run_status && payload.run_status !== 'succeeded')) {
|
||||
return errResult(payload);
|
||||
}
|
||||
return textResult(payload);
|
||||
}
|
||||
if (name === 'pi_subagent_run') {
|
||||
validateId(args.task_id, 'task_id');
|
||||
const { main, worktree } = resolveRepo(args.worktree);
|
||||
const request = args.request;
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
return errResult({ ok: false, code: 'INVALID_REQUEST', message: 'request object required' });
|
||||
}
|
||||
if (request.task_id && request.task_id !== args.task_id) {
|
||||
return errResult({ ok: false, code: 'TASK_MISMATCH', message: 'request.task_id must match task_id' });
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(request, 'config_path')) {
|
||||
return errResult({
|
||||
ok: false,
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'request.config_path is not supported; use tool config argument',
|
||||
});
|
||||
}
|
||||
request.task_id = args.task_id;
|
||||
const requestId = request.request_id;
|
||||
try {
|
||||
validateId(requestId, 'request_id');
|
||||
} catch (e) {
|
||||
return errResult({ ok: false, code: 'INVALID_REQUEST', message: String(e.message || e) });
|
||||
}
|
||||
const digest = payloadDigest(request);
|
||||
const key = `${args.task_id}/${requestId}`;
|
||||
const existing = active.get(key);
|
||||
if (existing && existing.exitCode === null) {
|
||||
if (existing.digest === digest) {
|
||||
return textResult({
|
||||
ok: true, accepted: true, running: true,
|
||||
task_id: args.task_id, request_id: requestId, request_digest: digest,
|
||||
});
|
||||
}
|
||||
return errResult({
|
||||
ok: false,
|
||||
code: 'REQUEST_DIGEST_MISMATCH',
|
||||
message: 'active request_id with different payload',
|
||||
});
|
||||
}
|
||||
let config;
|
||||
try {
|
||||
config = safeConfigPath(worktree, args.config);
|
||||
} catch (e) {
|
||||
return errResult({ ok: false, code: 'CONFIG_INVALID', message: String(e.message || e) });
|
||||
}
|
||||
const resource = `path:.doc/test-reports/assets/pi-subagents/${args.task_id}/${requestId}`;
|
||||
// Private OS tmp only — never write into worktree before lease.
|
||||
let tmpDir;
|
||||
let reqPath;
|
||||
try {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-subagent-mcp-'));
|
||||
reqPath = path.join(tmpDir, `${requestId}.json`);
|
||||
fs.writeFileSync(reqPath, JSON.stringify(request), { mode: 0o600 });
|
||||
} catch (e) {
|
||||
return errResult({
|
||||
ok: false,
|
||||
code: 'TMP_WRITE_FAILED',
|
||||
message: sanitizeErr(String(e.message || e)),
|
||||
});
|
||||
}
|
||||
let child;
|
||||
try {
|
||||
child = spawn(
|
||||
'python3',
|
||||
[
|
||||
'-B', agentctlPath(main),
|
||||
'with', '--task', args.task_id, '--resource', resource, '--',
|
||||
'python3', '-B', executorPath(),
|
||||
'run', '--request', reqPath, '--config', config,
|
||||
],
|
||||
{
|
||||
cwd: worktree,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true, // own process group for cleanup
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
|
||||
return errResult({
|
||||
ok: false,
|
||||
code: 'SPAWN_FAILED',
|
||||
message: sanitizeErr(String(e.message || e)),
|
||||
});
|
||||
}
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (c) => {
|
||||
stdout += c.toString('utf8');
|
||||
if (stdout.length > MAX_PAYLOAD) stdout = stdout.slice(-MAX_PAYLOAD);
|
||||
});
|
||||
child.stderr.on('data', (c) => {
|
||||
stderr += c.toString('utf8');
|
||||
if (stderr.length > MAX_PAYLOAD) stderr = stderr.slice(-MAX_PAYLOAD);
|
||||
});
|
||||
const meta = {
|
||||
child,
|
||||
digest,
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
exitCode: null,
|
||||
rejection: null,
|
||||
tmpDir,
|
||||
reqPath,
|
||||
};
|
||||
child.on('error', (err) => {
|
||||
meta.exitCode = 1;
|
||||
meta.rejection = {
|
||||
ok: false,
|
||||
code: 'SPAWN_FAILED',
|
||||
message: sanitizeErr(String(err.message || err)),
|
||||
};
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
meta.exitCode = code;
|
||||
if (code && code !== 0) {
|
||||
const parsed = parseJsonLine(stderr) || parseJsonLine(stdout);
|
||||
if (parsed && parsed.code) {
|
||||
meta.rejection = {
|
||||
ok: false,
|
||||
code: parsed.code,
|
||||
message: sanitizeErr(parsed.message || parsed.code),
|
||||
};
|
||||
} else {
|
||||
meta.rejection = {
|
||||
ok: false,
|
||||
code: 'SUPERVISOR_FAILED',
|
||||
message: sanitizeErr(stderr || stdout || `exit ${code}`),
|
||||
exit_code: code,
|
||||
};
|
||||
}
|
||||
}
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
|
||||
});
|
||||
active.set(key, meta);
|
||||
return textResult({
|
||||
ok: true,
|
||||
accepted: true,
|
||||
running: true,
|
||||
task_id: args.task_id,
|
||||
request_id: requestId,
|
||||
request_digest: digest,
|
||||
resource,
|
||||
});
|
||||
}
|
||||
return errResult(`unknown tool: ${name}`);
|
||||
} catch (e) {
|
||||
return errResult({ ok: false, code: 'MCP_ERROR', message: sanitizeErr(String(e?.message || e)) });
|
||||
}
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
const body = JSON.stringify(msg);
|
||||
if (Buffer.byteLength(body, 'utf8') > MAX_PAYLOAD) {
|
||||
const id = msg?.id;
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id, error: { code: -32600, message: 'response too large' },
|
||||
}) + '\n');
|
||||
return;
|
||||
}
|
||||
process.stdout.write(body + '\n');
|
||||
}
|
||||
|
||||
function handle(msg) {
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
if (msg.method && msg.id === undefined) {
|
||||
return;
|
||||
}
|
||||
const { id, method, params } = msg;
|
||||
try {
|
||||
if (method === 'initialize') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: params?.protocolVersion || '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'pi-subagents', version: '1.0.0' },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === 'tools/list') {
|
||||
send({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
||||
return;
|
||||
}
|
||||
if (method === 'tools/call') {
|
||||
const name = params?.name;
|
||||
const args = params?.arguments || {};
|
||||
const raw = JSON.stringify(args);
|
||||
if (Buffer.byteLength(raw, 'utf8') > MAX_PAYLOAD) {
|
||||
send({ jsonrpc: '2.0', id, result: errResult('arguments exceed payload bound') });
|
||||
return;
|
||||
}
|
||||
const result = callTool(name, args);
|
||||
send({ jsonrpc: '2.0', id, result });
|
||||
return;
|
||||
}
|
||||
if (method === 'ping') {
|
||||
send({ jsonrpc: '2.0', id, result: {} });
|
||||
return;
|
||||
}
|
||||
send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
|
||||
} catch (e) {
|
||||
send({ jsonrpc: '2.0', id, error: { code: -32000, message: String(e?.message || e) } });
|
||||
}
|
||||
}
|
||||
|
||||
let buf = Buffer.alloc(0);
|
||||
process.stdin.on('data', (chunk) => {
|
||||
if (buf.length + chunk.length > MAX_PAYLOAD * 4) {
|
||||
buf = Buffer.alloc(0);
|
||||
process.stderr.write('payload overflow; reset buffer\n');
|
||||
return;
|
||||
}
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
while (true) {
|
||||
const headerEnd = buf.indexOf('\r\n\r\n');
|
||||
if (headerEnd !== -1) {
|
||||
const header = buf.slice(0, headerEnd).toString('utf8');
|
||||
const m = /Content-Length:\s*(\d+)/i.exec(header);
|
||||
if (!m) {
|
||||
buf = buf.slice(headerEnd + 4);
|
||||
continue;
|
||||
}
|
||||
const len = Number(m[1]);
|
||||
if (len > MAX_PAYLOAD) {
|
||||
buf = buf.slice(headerEnd + 4);
|
||||
continue;
|
||||
}
|
||||
const start = headerEnd + 4;
|
||||
if (buf.length < start + len) return;
|
||||
const body = buf.slice(start, start + len).toString('utf8');
|
||||
buf = buf.slice(start + len);
|
||||
try { handle(JSON.parse(body)); } catch (e) {
|
||||
process.stderr.write(`parse error: ${e}\n`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const nl = buf.indexOf('\n');
|
||||
if (nl === -1) return;
|
||||
const line = buf.slice(0, nl).toString('utf8').trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line || line.startsWith('Content-Length')) continue;
|
||||
try { handle(JSON.parse(line)); } catch (e) {
|
||||
process.stderr.write(`ndjson parse error: ${e}\n`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function shutdown(signal) {
|
||||
process.stderr.write(`pi-subagents-mcp shutting down (${signal})\n`);
|
||||
reapAll();
|
||||
setTimeout(() => process.exit(0), 500).unref?.();
|
||||
}
|
||||
|
||||
process.stdin.on('end', () => { reapAll(); process.exit(0); });
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* PI subagent readonly scope guard.
|
||||
*
|
||||
* Deny-by-default: only read/grep/find/ls. Builtin schema uses a single `path`
|
||||
* field (read requires it; grep/find/ls default to "."). Reject unknown
|
||||
* path-carrier fields. Enforce snapshot realpath + selected-file allowlist;
|
||||
* directory targets must contain only allowlisted regular files (bounded walk).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ALLOWED_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
||||
const UNKNOWN_PATH_KEYS = new Set([
|
||||
"file", "filename", "directory", "dir", "root", "cwd", "paths",
|
||||
]);
|
||||
const MAX_WALK_ENTRIES = 256;
|
||||
|
||||
function snapshotRoot(): string | null {
|
||||
const raw = process.env.PI_SUBAGENT_SNAPSHOT_ROOT;
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return fs.realpathSync(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function allowedFiles(): Set<string> | null {
|
||||
const raw = process.env.PI_SUBAGENT_ALLOWED_FILES;
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
return new Set(parsed.map((p: unknown) => String(p)));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject PI resolveToCwd / home-expansion inputs before any join. */
|
||||
function forbiddenPrefix(raw: string): string | null {
|
||||
const cleaned = raw
|
||||
.replace(/^\uFEFF/, "")
|
||||
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "");
|
||||
const trimmed = cleaned.trim();
|
||||
if (!trimmed) return "empty path";
|
||||
if (trimmed.startsWith("@") || trimmed.startsWith("~")) {
|
||||
return "@ or ~ prefix refused";
|
||||
}
|
||||
if (/^[\uFF20\u0040]/.test(trimmed) || /^[\uFF5E\u007E]/.test(trimmed)) {
|
||||
return "unicode @/~ lookalike refused";
|
||||
}
|
||||
if (trimmed.includes("\0") || trimmed.includes("\n") || trimmed.includes("\r")) {
|
||||
return "illegal characters";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function contained(root: string, candidate: string): { ok: boolean; resolved?: string; reason?: string } {
|
||||
const prefixErr = forbiddenPrefix(candidate);
|
||||
if (prefixErr) return { ok: false, reason: prefixErr };
|
||||
|
||||
const abs = path.isAbsolute(candidate)
|
||||
? path.normalize(candidate)
|
||||
: path.resolve(root, candidate);
|
||||
|
||||
let cur = abs;
|
||||
for (;;) {
|
||||
try {
|
||||
if (fs.lstatSync(cur).isSymbolicLink()) {
|
||||
return { ok: false, reason: "symlink segment refused" };
|
||||
}
|
||||
} catch {
|
||||
/* missing leaf checked below */
|
||||
}
|
||||
const parent = path.dirname(cur);
|
||||
if (parent === cur) break;
|
||||
cur = parent;
|
||||
}
|
||||
|
||||
let resolved: string;
|
||||
try {
|
||||
if (!fs.existsSync(abs)) {
|
||||
return { ok: false, reason: "path does not exist" };
|
||||
}
|
||||
const st = fs.lstatSync(abs);
|
||||
if (st.isSymbolicLink()) {
|
||||
return { ok: false, reason: "symlink target refused" };
|
||||
}
|
||||
resolved = fs.realpathSync(abs);
|
||||
} catch {
|
||||
return { ok: false, reason: "realpath failed" };
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.realpathSync(resolved) !== resolved) {
|
||||
return { ok: false, reason: "realpath unstable" };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, reason: "realpath recheck failed" };
|
||||
}
|
||||
|
||||
const normRoot = root.endsWith(path.sep) ? root : root + path.sep;
|
||||
if (!(resolved === root || resolved.startsWith(normRoot))) {
|
||||
return { ok: false, reason: "escapes snapshot root" };
|
||||
}
|
||||
return { ok: true, resolved };
|
||||
}
|
||||
|
||||
function relUnder(root: string, resolved: string): string | null {
|
||||
const rel = path.relative(root, resolved);
|
||||
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
||||
return rel;
|
||||
}
|
||||
|
||||
function fileAllowlisted(root: string, resolved: string, allow: Set<string> | null): boolean {
|
||||
if (!allow) return true;
|
||||
const rel = relUnder(root, resolved);
|
||||
return !!rel && allow.has(rel);
|
||||
}
|
||||
|
||||
/** Directory listing/search: every regular file under path must be allowlisted. */
|
||||
function directoryAllowOk(root: string, resolved: string, allow: Set<string> | null): { ok: boolean; reason?: string } {
|
||||
if (!allow) return { ok: true };
|
||||
let count = 0;
|
||||
const stack = [resolved];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return { ok: false, reason: "directory unreadable" };
|
||||
}
|
||||
for (const ent of entries) {
|
||||
count += 1;
|
||||
if (count > MAX_WALK_ENTRIES) {
|
||||
return { ok: false, reason: "directory walk exceeded bound" };
|
||||
}
|
||||
const child = path.join(dir, ent.name);
|
||||
let st: fs.Stats;
|
||||
try {
|
||||
st = fs.lstatSync(child);
|
||||
} catch {
|
||||
return { ok: false, reason: "lstat failed" };
|
||||
}
|
||||
if (st.isSymbolicLink()) {
|
||||
return { ok: false, reason: "symlink descendant refused" };
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
stack.push(child);
|
||||
continue;
|
||||
}
|
||||
if (!st.isFile()) {
|
||||
return { ok: false, reason: "special file refused" };
|
||||
}
|
||||
if (!fileAllowlisted(root, fs.realpathSync(child), allow)) {
|
||||
return { ok: false, reason: "directory contains non-allowlisted file" };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Exact snapshot root: path.relative(root,root) is "" so relUnder is null.
|
||||
// Inventory above already enforced allowlist; permit root listing/search
|
||||
// (including empty allowlisted snapshot). fileAllowlisted(root,root) stays false.
|
||||
if (resolved === root) {
|
||||
return { ok: true };
|
||||
}
|
||||
// Empty dir is ok only if it is an ancestor of some allowlisted file
|
||||
const rel = relUnder(root, resolved);
|
||||
if (rel === null) return { ok: false, reason: "directory outside root" };
|
||||
if (count === 0) {
|
||||
for (const f of allow) {
|
||||
if (f === rel || f.startsWith(rel + path.sep)) return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: "empty directory not covering allowlist" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export default function piSubagentReadonly(pi: {
|
||||
on: (event: string, handler: (event: any, ctx: any) => any) => void;
|
||||
}) {
|
||||
pi.on("tool_call", async (event: { toolName: string; input?: Record<string, unknown> }) => {
|
||||
const name = event.toolName;
|
||||
if (!ALLOWED_TOOLS.has(name)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Tool "${name}" is denied by pi-subagent-readonly (allowlist: read,grep,find,ls)`,
|
||||
};
|
||||
}
|
||||
const root = snapshotRoot();
|
||||
if (!root) {
|
||||
return {
|
||||
block: true,
|
||||
reason: "PI_SUBAGENT_SNAPSHOT_ROOT is unset; refusing all file tools",
|
||||
};
|
||||
}
|
||||
const allow = allowedFiles();
|
||||
const input = event.input || {};
|
||||
|
||||
for (const key of Object.keys(input)) {
|
||||
if (UNKNOWN_PATH_KEYS.has(key)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Unsupported path carrier field "${key}" (builtin tools use path only)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// read requires path; grep/find/ls default to "."
|
||||
let rawPath: string;
|
||||
if (typeof input.path === "string" && input.path.trim()) {
|
||||
rawPath = input.path;
|
||||
} else if (name === "read") {
|
||||
return { block: true, reason: 'read requires input.path' };
|
||||
} else {
|
||||
rawPath = ".";
|
||||
}
|
||||
|
||||
const base = path.basename(rawPath.replace(/^[@~]+/, ""));
|
||||
if (
|
||||
base === ".env" ||
|
||||
base.startsWith(".env.") ||
|
||||
base === "auth.json" ||
|
||||
rawPath.split(path.sep).includes(".git") ||
|
||||
rawPath.split(path.sep).includes(".secrets") ||
|
||||
rawPath.split(path.sep).includes("data")
|
||||
) {
|
||||
return { block: true, reason: `Path "${rawPath}" is denied by policy` };
|
||||
}
|
||||
|
||||
const check = contained(root, rawPath);
|
||||
if (!check.ok || !check.resolved) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Path "${rawPath}" refused (${check.reason || "policy"})`,
|
||||
};
|
||||
}
|
||||
|
||||
let st: fs.Stats;
|
||||
try {
|
||||
st = fs.lstatSync(check.resolved);
|
||||
} catch {
|
||||
return { block: true, reason: "stat failed" };
|
||||
}
|
||||
|
||||
if (st.isDirectory()) {
|
||||
const dirOk = directoryAllowOk(root, check.resolved, allow);
|
||||
if (!dirOk.ok) {
|
||||
return { block: true, reason: dirOk.reason || "directory allowlist failed" };
|
||||
}
|
||||
} else if (st.isFile()) {
|
||||
if (!fileAllowlisted(root, check.resolved, allow)) {
|
||||
return { block: true, reason: `Path "${rawPath}" not in selected-file allowlist` };
|
||||
}
|
||||
} else {
|
||||
return { block: true, reason: "special file refused" };
|
||||
}
|
||||
|
||||
// Canonicalize the only supported field
|
||||
input.path = check.resolved;
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user